import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import '../model/document/style.dart'; import '../model/document/attribute.dart'; import '../model/document/node/embed.dart'; import '../util/color.dart'; import '../service/controller.dart'; import 'toolbar.dart'; /* -------------------------------- Constant -------------------------------- */ const double kToolbarIconHeight = 18.0; const double kToolbarButtonHeight = kToolbarIconHeight * 1.77; class FlowyToolbar extends EditorToolbar { const FlowyToolbar({ required List children, Key? key, }) : super( children: children, customButtonHeight: kToolbarButtonHeight, key: key, ); factory FlowyToolbar.basic({ required EditorController controller, double? toolbarIconSize, bool showHistory = true, bool showBold = true, bool showItalic = true, bool showUnderline = true, bool showStrikethrough = true, bool showColor = true, bool showBackgroundColor = true, bool showClearFormat = true, bool showHeader = true, bool showBulletList = true, bool showOrderedList = true, bool showCheckList = true, bool showCodeblock = true, bool showQuoteblock = true, bool showIndent = true, bool showLink = true, bool showHorizontalLine = true, OnImageSelectCallback? onImageSelectCallback, Key? key, }) { return FlowyToolbar(children: [ Visibility( visible: showHistory, child: HistoryButton( icon: Icons.undo_outlined, controller: controller, isUndo: true, ), ), Visibility( visible: showHistory, child: HistoryButton( icon: Icons.redo_outlined, controller: controller, isUndo: false, ), ), const SizedBox(width: 0.6), Visibility( visible: showBold, child: ToggleStyleButton( attribute: Attribute.bold, icon: Icons.format_bold, controller: controller, ), ), const SizedBox(width: 0.6), Visibility( visible: showItalic, child: ToggleStyleButton( attribute: Attribute.italic, icon: Icons.format_italic, controller: controller, ), ), const SizedBox(width: 0.6), Visibility( visible: showUnderline, child: ToggleStyleButton( attribute: Attribute.underline, icon: Icons.format_underline, controller: controller, ), ), const SizedBox(width: 0.6), Visibility( visible: showStrikethrough, child: ToggleStyleButton( attribute: Attribute.strikeThrough, icon: Icons.format_strikethrough, controller: controller, ), ), const SizedBox(width: 0.6), Visibility( visible: showColor, child: ColorButton( icon: Icons.color_lens, controller: controller, isBackground: false, ), ), const SizedBox(width: 0.6), Visibility( visible: showColor, child: ColorButton( icon: Icons.format_color_fill, controller: controller, isBackground: true, ), ), const SizedBox(width: 0.6), Visibility( visible: showColor, child: ClearFormatButton( icon: Icons.format_clear, controller: controller, ), ), const SizedBox(width: 0.6), Visibility( visible: showOrderedList, child: ToggleStyleButton( attribute: Attribute.ordered, icon: Icons.format_list_numbered, controller: controller, ), ), Visibility( visible: showBulletList, child: ToggleStyleButton( attribute: Attribute.bullet, icon: Icons.format_list_bulleted, controller: controller, ), ), Visibility( visible: showCheckList, child: ToggleStyleButton( attribute: Attribute.unchecked, icon: Icons.check_box, controller: controller, ), ), Visibility( visible: showCodeblock, child: ToggleStyleButton( attribute: Attribute.codeBlock, icon: Icons.code, controller: controller, ), ), Visibility( visible: showHeader, child: VerticalDivider( indent: 12, endIndent: 12, color: Colors.grey.shade400), ), Visibility( visible: showHeader, child: HeaderStyleButton(controller: controller), ), VerticalDivider(indent: 12, endIndent: 12, color: Colors.grey.shade400), Visibility( visible: !showOrderedList && !showBulletList && !showCheckList && !showCodeblock, child: VerticalDivider( indent: 12, endIndent: 12, color: Colors.grey.shade400), ), Visibility( visible: showQuoteblock, child: ToggleStyleButton( attribute: Attribute.quoteBlock, controller: controller, icon: Icons.format_quote, ), ), Visibility( visible: showIndent, child: IndentButton( icon: Icons.format_indent_increase, controller: controller, isIncrease: true, ), ), Visibility( visible: showIndent, child: IndentButton( icon: Icons.format_indent_decrease, controller: controller, isIncrease: false, ), ), Visibility( visible: showQuoteblock, child: VerticalDivider( indent: 12, endIndent: 12, color: Colors.grey.shade400), ), Visibility( visible: showLink, child: LinkStyleButton( controller: controller, icon: Icons.link, ), ), ]); } } /* ---------------------------------- Util ---------------------------------- */ class ToolbarIconButton extends StatelessWidget { const ToolbarIconButton({ this.onPressed, this.icon, this.size = 40, this.fillColor, this.hoverElevation = 1, this.highlightElevation = 1, Key? key, }) : super(key: key); final VoidCallback? onPressed; final Widget? icon; final double size; final Color? fillColor; final double hoverElevation; final double highlightElevation; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: BoxConstraints.tightFor(width: size, height: size), child: RawMaterialButton( visualDensity: VisualDensity.compact, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), fillColor: fillColor, elevation: 0, hoverElevation: hoverElevation, highlightElevation: hoverElevation, onPressed: onPressed, child: icon, ), ); } } /* ------------------------------- Button Impl ------------------------------ */ // History (Redo, Undo) class HistoryButton extends StatefulWidget { const HistoryButton({ required this.icon, required this.isUndo, required this.controller, Key? key, }) : super(key: key); final IconData icon; final bool isUndo; final EditorController controller; @override _HistoryButtonState createState() => _HistoryButtonState(); } class _HistoryButtonState extends State { Color? _iconColor; late ThemeData theme; @override Widget build(BuildContext context) { theme = Theme.of(context); _setIconColor(); final fillColor = theme.canvasColor; widget.controller.changes.listen((event) async { _setIconColor(); }); return ToolbarIconButton( size: kToolbarButtonHeight, fillColor: fillColor, hoverElevation: 0, highlightElevation: 0, icon: Icon(widget.icon, size: kToolbarIconHeight, color: _iconColor), onPressed: _applyHistory, ); } void _applyHistory() { if (widget.isUndo) { if (widget.controller.hasUndo) { widget.controller.undo(); } } else { if (widget.controller.hasRedo) { widget.controller.redo(); } } _setIconColor(); } void _setIconColor() { if (!mounted) { return; } if (widget.isUndo) { setState(() { _iconColor = widget.controller.hasUndo ? theme.iconTheme.color : theme.disabledColor; }); } else { setState(() { _iconColor = widget.controller.hasRedo ? theme.iconTheme.color : theme.disabledColor; }); } } } // Toggle Style (Bold, Italic, Underline, etc..) typedef ToggleStyleButtonBuilder = Widget Function( BuildContext context, Attribute attribute, IconData icon, Color? fillColor, bool? isToggled, VoidCallback? onPressed, ); Widget defaultToggleStyleButtonBuilder( BuildContext context, Attribute attribute, IconData icon, Color? fillColor, bool? isToggled, VoidCallback? onPressed, ) { final theme = Theme.of(context); final isEnabled = onPressed != null; final iconColor = isEnabled ? isToggled == true ? theme.primaryIconTheme.color : theme.iconTheme.color : theme.disabledColor; final theFillColor = isToggled == true ? theme.toggleableActiveColor : fillColor ?? theme.canvasColor; return ToolbarIconButton( onPressed: onPressed, icon: Icon(icon, size: kToolbarIconHeight, color: iconColor), size: kToolbarButtonHeight, fillColor: theFillColor, hoverElevation: 0, highlightElevation: 0, ); } class ToggleStyleButton extends StatefulWidget { const ToggleStyleButton({ required this.attribute, required this.icon, required this.controller, this.fillColor, this.childBuilder = defaultToggleStyleButtonBuilder, Key? key, }) : super(key: key); final Attribute attribute; final IconData icon; final Color? fillColor; final EditorController controller; final ToggleStyleButtonBuilder childBuilder; @override _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); } class _ToggleStyleButtonState extends State { bool? _isToggled; Style get _selectionStyle => widget.controller.getSelectionStyle(); @override void didUpdateWidget(covariant ToggleStyleButton oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_didChangeEditingValue); widget.controller.addListener(_didChangeEditingValue); _isToggled = _checkIfAttrToggled(_selectionStyle.attributes); } } @override void initState() { super.initState(); _isToggled = _checkIfAttrToggled(_selectionStyle.attributes); widget.controller.addListener(_didChangeEditingValue); } @override void dispose() { widget.controller.removeListener(_didChangeEditingValue); super.dispose(); } @override Widget build(BuildContext context) { final isInCodeBlock = _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); final isEnabled = !isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key; return widget.childBuilder( context, widget.attribute, widget.icon, widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null, ); } bool _checkIfAttrToggled(Map attrs) { if (widget.attribute.key == Attribute.list.key) { final attribute = attrs[widget.attribute.key]; if (attribute == null) { return false; } return attribute.value == widget.attribute.value; } return attrs.containsKey(widget.attribute.key); } void _toggleAttribute() { widget.controller.formatSelection( _isToggled! ? Attribute.clone(widget.attribute, null) : widget.attribute, ); } void _didChangeEditingValue() { setState(() { _isToggled = _checkIfAttrToggled(widget.controller.getSelectionStyle().attributes); }); } } // Header Style class HeaderStyleButton extends StatefulWidget { const HeaderStyleButton({ required this.controller, Key? key, }) : super(key: key); final EditorController controller; @override _HeaderStyleButtonState createState() => _HeaderStyleButtonState(); } class _HeaderStyleButtonState extends State { Attribute? _value; Style get _selectionStyle => widget.controller.getSelectionStyle(); @override void initState() { super.initState(); setState(() { _value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; }); widget.controller.addListener(_didChangeEditingValue); } @override void didUpdateWidget(covariant HeaderStyleButton oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_didChangeEditingValue); widget.controller.addListener(_didChangeEditingValue); _value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; } } @override void dispose() { widget.controller.removeListener(_didChangeEditingValue); super.dispose(); } @override Widget build(BuildContext context) { return _selectHeaderStyleButtonBuilder(context, _value); } void _didChangeEditingValue() { setState(() { _value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; }); } Widget _selectHeaderStyleButtonBuilder( BuildContext context, Attribute? value) { final theme = Theme.of(context); final style = TextStyle( fontWeight: FontWeight.w600, fontSize: kToolbarIconHeight * 0.7, ); final headerTextMapping = { Attribute.header: 'N', Attribute.h1: 'H1', Attribute.h2: 'H2', Attribute.h3: 'H3', Attribute.h4: 'H4', Attribute.h5: 'H5', Attribute.h6: 'H6', }; final headerStyles = headerTextMapping.keys.toList(growable: false); final headerTexts = headerTextMapping.values.toList(growable: false); return Row( mainAxisSize: MainAxisSize.min, children: List.generate(headerStyles.length, (index) { return Padding( padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), child: ConstrainedBox( constraints: BoxConstraints.tightFor( width: kToolbarButtonHeight, height: kToolbarButtonHeight), child: RawMaterialButton( fillColor: headerTextMapping[value] == headerTexts[index] ? theme.toggleableActiveColor : theme.canvasColor, hoverElevation: 0, highlightElevation: 0, visualDensity: VisualDensity.compact, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(2)), onPressed: () { widget.controller.formatSelection(headerStyles[index]); }, child: Text( headerTexts[index], style: style.copyWith( color: headerTextMapping[value] == headerTexts[index] ? theme.primaryIconTheme.color : theme.iconTheme.color, ), ), ), ), ); }), ); } } // Color (TextColor, BackgroundColor) class ColorButton extends StatefulWidget { const ColorButton({ required this.icon, required this.isBackground, required this.controller, Key? key, }) : super(key: key); final IconData icon; final bool isBackground; final EditorController controller; @override _ColorButtonState createState() => _ColorButtonState(); } class _ColorButtonState extends State { late bool _isToggledColor; late bool _isToggledBackground; late bool _isWhite; late bool _isWhiteBackground; Style get _selectionStyle => widget.controller.getSelectionStyle(); @override void didUpdateWidget(covariant ColorButton oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_didChangeEditingValue); widget.controller.addListener(_didChangeEditingValue); _updateToggledState(); } } @override void initState() { super.initState(); _updateToggledState(); } @override void dispose() { widget.controller.removeListener(_didChangeEditingValue); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final iconColor = _isToggledColor && !widget.isBackground && !_isWhite ? stringToColor(_selectionStyle.attributes['color']!.value) : theme.iconTheme.color; final iconColorBackground = _isToggledBackground && widget.isBackground && !_isWhiteBackground ? stringToColor(_selectionStyle.attributes['background']!.value) : theme.iconTheme.color; final fillColor = _isToggledColor && !widget.isBackground && _isWhite ? stringToColor('#ffffff') : theme.canvasColor; final fillColorBackground = _isToggledBackground && widget.isBackground && _isWhiteBackground ? stringToColor('#ffffff') : theme.canvasColor; return ToolbarIconButton( size: kToolbarButtonHeight, fillColor: widget.isBackground ? fillColorBackground : fillColor, hoverElevation: 0, highlightElevation: 0, icon: Icon( widget.icon, size: kToolbarIconHeight, color: widget.isBackground ? iconColorBackground : iconColor, ), onPressed: _showColorPicker, ); } void _didChangeEditingValue() { setState(() { _updateToggledState(); widget.controller.addListener(_didChangeEditingValue); }); } void _updateToggledState() { _isToggledColor = _checkIfToggledColor(_selectionStyle.attributes); _isToggledBackground = _checkIfToggledBackground(_selectionStyle.attributes); _isWhite = _isToggledColor && _selectionStyle.attributes['color']!.value == '#ffffff'; _isWhiteBackground = _isToggledBackground && _selectionStyle.attributes['background']!.value == '#ffffff'; } bool _checkIfToggledColor(Map attrs) { return attrs.containsKey(Attribute.color.key); } bool _checkIfToggledBackground(Map attrs) { return attrs.containsKey(Attribute.background.key); } void _applyColor(Color color) { var hex = color.value.toRadixString(16); if (hex.startsWith('ff')) { hex = hex.substring(2); } hex = '#$hex'; widget.controller.formatSelection( widget.isBackground ? BackgroundAttribute(hex) : ColorAttribute(hex)); Navigator.of(context).pop(); } void _showColorPicker() { showDialog( context: context, builder: (_) { return AlertDialog( title: const Text('Select Color'), backgroundColor: Theme.of(context).canvasColor, content: SingleChildScrollView( child: MaterialPicker( pickerColor: const Color(0x00000000), onColorChanged: _applyColor, enableLabel: true, ), ), ); }); } } // Clear Format class ClearFormatButton extends StatefulWidget { const ClearFormatButton({ required this.icon, required this.controller, Key? key, }) : super(key: key); final IconData icon; final EditorController controller; @override _ClearFormatButtonState createState() => _ClearFormatButtonState(); } class _ClearFormatButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); final iconColor = theme.iconTheme.color; final fillColor = theme.canvasColor; return ToolbarIconButton( size: kToolbarButtonHeight, fillColor: fillColor, hoverElevation: 0, highlightElevation: 0, icon: Icon(widget.icon, size: kToolbarIconHeight, color: iconColor), onPressed: () { widget.controller.getSelectionStyle().values.forEach((style) { widget.controller.formatSelection(Attribute.clone(style, null)); }); }, ); } } // Indent class IndentButton extends StatefulWidget { const IndentButton({ required this.icon, required this.controller, required this.isIncrease, Key? key, }) : super(key: key); final IconData icon; final EditorController controller; final bool isIncrease; @override _IndentButtonState createState() => _IndentButtonState(); } class _IndentButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); final iconColor = theme.iconTheme.color; final fillColor = theme.canvasColor; return ToolbarIconButton( size: kToolbarButtonHeight, fillColor: fillColor, hoverElevation: 0, highlightElevation: 0, icon: Icon(widget.icon, size: kToolbarIconHeight, color: iconColor), onPressed: () { final indent = widget.controller .getSelectionStyle() .attributes[Attribute.indent.key]; if (indent == null) { if (widget.isIncrease) { widget.controller.formatSelection(Attribute.indentL1); } return; } if (indent.value == 1 && !widget.isIncrease) { widget.controller .formatSelection(Attribute.clone(Attribute.indentL1, null)); return; } if (widget.isIncrease) { // Next indent value widget.controller .formatSelection(Attribute.getIndentLevel(indent.value + 1)); return; } // Prev indent value widget.controller .formatSelection(Attribute.getIndentLevel(indent.value - 1)); }, ); } } // Link class LinkStyleButton extends StatefulWidget { const LinkStyleButton({ required this.controller, required this.icon, Key? key, }) : super(key: key); final IconData? icon; final EditorController controller; @override _LinkStyleButtonState createState() => _LinkStyleButtonState(); } class _LinkStyleButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); final canAddLink = !widget.controller.selection.isCollapsed; return ToolbarIconButton( highlightElevation: 0, hoverElevation: 0, size: kToolbarButtonHeight, icon: Icon( widget.icon, size: kToolbarIconHeight, color: canAddLink ? theme.iconTheme.color : theme.disabledColor, ), fillColor: theme.canvasColor, onPressed: canAddLink ? () => _openLinkDialog(context) : null, ); } void _openLinkDialog(BuildContext context) { showDialog( context: context, builder: (context) { return const LinkEditDialog(); }).then(_applyLinkAttribute); } void _applyLinkAttribute(String? value) { if (value == null || value.isEmpty) { return; } widget.controller.formatSelection(LinkAttribute(value)); } } class LinkEditDialog extends StatefulWidget { const LinkEditDialog({Key? key}) : super(key: key); @override _LinkEditDialogState createState() => _LinkEditDialogState(); } class _LinkEditDialogState extends State { String _value = ''; @override Widget build(BuildContext context) { return AlertDialog( content: TextField( decoration: const InputDecoration(labelText: 'Input Link'), autofocus: true, onChanged: _handleLinkChanged, ), actions: [ TextButton( onPressed: _value.isNotEmpty ? _handleFinishEditing : null, child: const Text('Finish'), ), ], ); } void _handleLinkChanged(String value) { setState(() => _value = value); } void _handleFinishEditing() { Navigator.pop(context, _value); } } // Embed class AddEmbedButton extends StatelessWidget { const AddEmbedButton({ required this.controller, required this.icon, this.fillColor, Key? key, }) : super(key: key); final IconData icon; final EditorController controller; final Color? fillColor; @override Widget build(BuildContext context) { final theme = Theme.of(context); return ToolbarIconButton( highlightElevation: 0, hoverElevation: 0, size: kToolbarButtonHeight, icon: Icon(icon, size: kToolbarIconHeight, color: theme.iconTheme.color), fillColor: fillColor ?? theme.canvasColor, onPressed: () { final index = controller.selection.baseOffset; final length = controller.selection.extentOffset - index; controller.replaceText( index, length, BlockEmbed.horizontalRule, null, ); }, ); } }