瀏覽代碼

Added : customize the color and background color of selected text (#1601)

* Added Emoji Support

* Added Color Picker for font color and background color

* chore: revert code

* feat: re-implement the color picker

* test: add test case for adding color

* test: update appflowy_editor test flag

Co-authored-by: Muhammad Rizwan <[email protected]>
Co-authored-by: Lucas.Xu <[email protected]>
Muhammad Rizwan 2 年之前
父節點
當前提交
e4b07e69fa

+ 1 - 1
.github/workflows/appflowy_editor_test.yml

@@ -44,7 +44,7 @@ jobs:
       - uses: codecov/codecov-action@v3
         with: 
           name: appflowy_editor
-          flags: appflowy editor
+          flags: appflowy_editor
           env_vars: ${{ matrix.os }}
           fail_ci_if_error: true
           verbose: true

+ 2 - 1
frontend/app_flowy/assets/translations/en.json

@@ -96,7 +96,8 @@
     "inlineCode": "Inline Code",
     "quote": "Quote Block",
     "header": "Header",
-    "highlight": "Highlight"
+    "highlight": "Highlight",
+    "color": "Color"
   },
   "tooltip": {
     "lightMode": "Switch to Light mode",

+ 2 - 1
frontend/app_flowy/assets/translations/es-VE.json

@@ -90,7 +90,8 @@
     "inlineCode": "Código embebido",
     "quote": "Cita",
     "header": "Título",
-    "highlight": "Resaltado"
+    "highlight": "Resaltado",
+    "color": "Color"
   },
   "tooltip": {
     "lightMode": "Cambiar a modo Claro",

+ 3 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/checkmark.svg

@@ -0,0 +1,3 @@
+<svg width="10" height="8" viewBox="0 0 10 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1 5.2L2.84615 7L9 1" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 43 - 1
frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_en.arb

@@ -16,6 +16,8 @@
   "@heading3": {},
   "highlight": "Highlight",
   "@highlight": {},
+  "color": "Color",
+  "@color": {},
   "image": "Image",
   "@image": {},
   "italic": "Italic",
@@ -31,5 +33,45 @@
   "text": "Text",
   "@text": {},
   "underline": "Underline",
-  "@underline": {}
+  "@underline": {},
+  "fontColorDefault": "Default",
+  "@fontColorDefault": {},
+  "fontColorGray": "Gray",
+  "@fontColorGray": {},
+  "fontColorBrown": "Brown",
+  "@fontColorBrown": {},
+  "fontColorOrange": "Orange",
+  "@fontColorOrange": {},
+  "fontColorYellow": "Yellow",
+  "@fontColorYellow": {},
+  "fontColorGreen": "Green",
+  "@fontColorGreen": {},
+  "fontColorBlue": "Blue",
+  "@fontColorBlue": {},
+  "fontColorPurple": "Purple",
+  "@fontColorPurple": {},
+  "fontColorPink": "Pink",
+  "@fontColorPink": {},
+  "fontColorRed": "Red",
+  "@fontColorRed": {},
+  "backgroundColorDefault": "Default background",
+  "@backgroundColorDefault": {},
+  "backgroundColorGray": "Gray background",
+  "@backgroundColorGray": {},
+  "backgroundColorBrown": "Brown background",
+  "@backgroundColorBrown": {},
+  "backgroundColorOrange": "Orange background",
+  "@backgroundColorOrange": {},
+  "backgroundColorYellow": "Yellow background",
+  "@backgroundColorYellow": {},
+  "backgroundColorGreen": "Green background",
+  "@backgroundColorGreen": {},
+  "backgroundColorBlue": "Blue background",
+  "@backgroundColorBlue": {},
+  "backgroundColorPurple": "Purple background",
+  "@backgroundColorPurple": {},
+  "backgroundColorPink": "Pink background",
+  "@backgroundColorPink": {},
+  "backgroundColorRed": "Red background",
+  "@backgroundColorRed": {}
 }

+ 1 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/core/legacy/built_in_attribute_keys.dart

@@ -45,6 +45,7 @@ class BuiltInAttributeKey {
     BuiltInAttributeKey.underline,
     BuiltInAttributeKey.strikethrough,
     BuiltInAttributeKey.backgroundColor,
+    BuiltInAttributeKey.color,
     BuiltInAttributeKey.href,
     BuiltInAttributeKey.code,
   ];

+ 11 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart

@@ -34,6 +34,17 @@ extension TextNodeExtension on TextNode {
         return value != null;
       });
 
+  bool allSatisfyFontColorInSelection(Selection selection) =>
+      allSatisfyInSelection(selection, BuiltInAttributeKey.color, (value) {
+        return value != null;
+      });
+
+  bool allSatisfyBackgroundColorInSelection(Selection selection) =>
+      allSatisfyInSelection(selection, BuiltInAttributeKey.backgroundColor,
+          (value) {
+        return value != null;
+      });
+
   bool allSatisfyBoldInSelection(Selection selection) =>
       allSatisfyInSelection(selection, BuiltInAttributeKey.bold, (value) {
         return value == true;

+ 31 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_en.dart

@@ -22,10 +22,41 @@ class MessageLookup extends MessageLookupByLibrary {
 
   final messages = _notInlinedMessages(_notInlinedMessages);
   static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
+        "backgroundColorBlue":
+            MessageLookupByLibrary.simpleMessage("Blue background"),
+        "backgroundColorBrown":
+            MessageLookupByLibrary.simpleMessage("Brown background"),
+        "backgroundColorDefault":
+            MessageLookupByLibrary.simpleMessage("Default background"),
+        "backgroundColorGray":
+            MessageLookupByLibrary.simpleMessage("Gray background"),
+        "backgroundColorGreen":
+            MessageLookupByLibrary.simpleMessage("Green background"),
+        "backgroundColorOrange":
+            MessageLookupByLibrary.simpleMessage("Orange background"),
+        "backgroundColorPink":
+            MessageLookupByLibrary.simpleMessage("Pink background"),
+        "backgroundColorPurple":
+            MessageLookupByLibrary.simpleMessage("Purple background"),
+        "backgroundColorRed":
+            MessageLookupByLibrary.simpleMessage("Red background"),
+        "backgroundColorYellow":
+            MessageLookupByLibrary.simpleMessage("Yellow background"),
         "bold": MessageLookupByLibrary.simpleMessage("Bold"),
         "bulletedList": MessageLookupByLibrary.simpleMessage("Bulleted List"),
         "checkbox": MessageLookupByLibrary.simpleMessage("Checkbox"),
+        "color": MessageLookupByLibrary.simpleMessage("Color"),
         "embedCode": MessageLookupByLibrary.simpleMessage("Embed Code"),
+        "fontColorBlue": MessageLookupByLibrary.simpleMessage("Blue"),
+        "fontColorBrown": MessageLookupByLibrary.simpleMessage("Brown"),
+        "fontColorDefault": MessageLookupByLibrary.simpleMessage("Default"),
+        "fontColorGray": MessageLookupByLibrary.simpleMessage("Gray"),
+        "fontColorGreen": MessageLookupByLibrary.simpleMessage("Green"),
+        "fontColorOrange": MessageLookupByLibrary.simpleMessage("Orange"),
+        "fontColorPink": MessageLookupByLibrary.simpleMessage("Pink"),
+        "fontColorPurple": MessageLookupByLibrary.simpleMessage("Purple"),
+        "fontColorRed": MessageLookupByLibrary.simpleMessage("Red"),
+        "fontColorYellow": MessageLookupByLibrary.simpleMessage("Yellow"),
         "heading1": MessageLookupByLibrary.simpleMessage("H1"),
         "heading2": MessageLookupByLibrary.simpleMessage("H2"),
         "heading3": MessageLookupByLibrary.simpleMessage("H3"),

+ 210 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/l10n.dart

@@ -131,6 +131,16 @@ class AppFlowyEditorLocalizations {
     );
   }
 
+  /// `Color`
+  String get color {
+    return Intl.message(
+      'Color',
+      name: 'color',
+      desc: '',
+      args: [],
+    );
+  }
+
   /// `Image`
   String get image {
     return Intl.message(
@@ -210,6 +220,206 @@ class AppFlowyEditorLocalizations {
       args: [],
     );
   }
+
+  /// `Default`
+  String get fontColorDefault {
+    return Intl.message(
+      'Default',
+      name: 'fontColorDefault',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Gray`
+  String get fontColorGray {
+    return Intl.message(
+      'Gray',
+      name: 'fontColorGray',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Brown`
+  String get fontColorBrown {
+    return Intl.message(
+      'Brown',
+      name: 'fontColorBrown',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Orange`
+  String get fontColorOrange {
+    return Intl.message(
+      'Orange',
+      name: 'fontColorOrange',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Yellow`
+  String get fontColorYellow {
+    return Intl.message(
+      'Yellow',
+      name: 'fontColorYellow',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Green`
+  String get fontColorGreen {
+    return Intl.message(
+      'Green',
+      name: 'fontColorGreen',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Blue`
+  String get fontColorBlue {
+    return Intl.message(
+      'Blue',
+      name: 'fontColorBlue',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Purple`
+  String get fontColorPurple {
+    return Intl.message(
+      'Purple',
+      name: 'fontColorPurple',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pink`
+  String get fontColorPink {
+    return Intl.message(
+      'Pink',
+      name: 'fontColorPink',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Red`
+  String get fontColorRed {
+    return Intl.message(
+      'Red',
+      name: 'fontColorRed',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Default background`
+  String get backgroundColorDefault {
+    return Intl.message(
+      'Default background',
+      name: 'backgroundColorDefault',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Gray background`
+  String get backgroundColorGray {
+    return Intl.message(
+      'Gray background',
+      name: 'backgroundColorGray',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Brown background`
+  String get backgroundColorBrown {
+    return Intl.message(
+      'Brown background',
+      name: 'backgroundColorBrown',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Orange background`
+  String get backgroundColorOrange {
+    return Intl.message(
+      'Orange background',
+      name: 'backgroundColorOrange',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Yellow background`
+  String get backgroundColorYellow {
+    return Intl.message(
+      'Yellow background',
+      name: 'backgroundColorYellow',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Green background`
+  String get backgroundColorGreen {
+    return Intl.message(
+      'Green background',
+      name: 'backgroundColorGreen',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Blue background`
+  String get backgroundColorBlue {
+    return Intl.message(
+      'Blue background',
+      name: 'backgroundColorBlue',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Purple background`
+  String get backgroundColorPurple {
+    return Intl.message(
+      'Purple background',
+      name: 'backgroundColorPurple',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pink background`
+  String get backgroundColorPink {
+    return Intl.message(
+      'Pink background',
+      name: 'backgroundColorPink',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Red background`
+  String get backgroundColorRed {
+    return Intl.message(
+      'Red background',
+      name: 'backgroundColorRed',
+      desc: '',
+      args: [],
+    );
+  }
 }
 
 class AppLocalizationDelegate

+ 168 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/color_menu/color_picker.dart

@@ -0,0 +1,168 @@
+import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:flutter/material.dart';
+
+class ColorOption {
+  const ColorOption({
+    required this.colorHex,
+    required this.name,
+  });
+
+  final String colorHex;
+  final String name;
+}
+
+enum _ColorType {
+  font,
+  background,
+}
+
+class ColorPicker extends StatefulWidget {
+  const ColorPicker({
+    super.key,
+    this.selectedFontColorHex,
+    this.selectedBackgroundColorHex,
+    required this.pickerBackgroundColor,
+    required this.fontColorOptions,
+    required this.backgroundColorOptions,
+    required this.pickerItemHoverColor,
+    required this.pickerItemTextColor,
+    required this.onSubmittedbackgroundColorHex,
+    required this.onSubmittedFontColorHex,
+  });
+
+  final String? selectedFontColorHex;
+  final String? selectedBackgroundColorHex;
+  final Color pickerBackgroundColor;
+  final Color pickerItemHoverColor;
+  final Color pickerItemTextColor;
+  final void Function(String color) onSubmittedbackgroundColorHex;
+  final void Function(String color) onSubmittedFontColorHex;
+
+  final List<ColorOption> fontColorOptions;
+  final List<ColorOption> backgroundColorOptions;
+
+  @override
+  State<ColorPicker> createState() => _ColorPickerState();
+}
+
+class _ColorPickerState extends State<ColorPicker> {
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      decoration: BoxDecoration(
+        color: widget.pickerBackgroundColor,
+        boxShadow: [
+          BoxShadow(
+            blurRadius: 5,
+            spreadRadius: 1,
+            color: Colors.black.withOpacity(0.1),
+          ),
+        ],
+        borderRadius: BorderRadius.circular(6.0),
+      ),
+      height: 250,
+      width: 220,
+      padding: const EdgeInsets.fromLTRB(10, 6, 10, 6),
+      child: ScrollConfiguration(
+        behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
+        child: SingleChildScrollView(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            mainAxisAlignment: MainAxisAlignment.start,
+            children: [
+              // font color
+              _buildHeader('font color'),
+              // padding
+              const SizedBox(height: 6),
+              _buildColorItems(
+                _ColorType.font,
+                widget.fontColorOptions,
+                widget.selectedFontColorHex,
+              ),
+              // background color
+              const SizedBox(height: 6),
+              _buildHeader('background color'),
+              const SizedBox(height: 6),
+              _buildColorItems(
+                _ColorType.background,
+                widget.backgroundColorOptions,
+                widget.selectedBackgroundColorHex,
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildHeader(String text) {
+    return Text(
+      text,
+      style: const TextStyle(
+        color: Colors.grey,
+        fontWeight: FontWeight.bold,
+      ),
+    );
+  }
+
+  Widget _buildColorItems(
+      _ColorType type, List<ColorOption> options, String? selectedColor) {
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      mainAxisAlignment: MainAxisAlignment.start,
+      children: options
+          .map((e) => _buildColorItem(type, e, e.colorHex == selectedColor))
+          .toList(),
+    );
+  }
+
+  Widget _buildColorItem(_ColorType type, ColorOption option, bool isChecked) {
+    return SizedBox(
+      height: 36,
+      child: InkWell(
+        customBorder: RoundedRectangleBorder(
+          borderRadius: BorderRadius.circular(6),
+        ),
+        hoverColor: widget.pickerItemHoverColor,
+        onTap: () {
+          if (type == _ColorType.font) {
+            widget.onSubmittedFontColorHex(option.colorHex);
+          } else if (type == _ColorType.background) {
+            widget.onSubmittedbackgroundColorHex(option.colorHex);
+          }
+        },
+        child: Row(
+          mainAxisAlignment: MainAxisAlignment.start,
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            // padding
+            const SizedBox(width: 6),
+            // icon
+            SizedBox.square(
+              dimension: 12,
+              child: Container(
+                decoration: BoxDecoration(
+                  color: Color(int.tryParse(option.colorHex) ?? 0xFFFFFFFF),
+                  shape: BoxShape.circle,
+                ),
+              ),
+            ),
+            // padding
+            const SizedBox(width: 10),
+            // text
+            Expanded(
+              child: Text(
+                option.name,
+                style:
+                    TextStyle(fontSize: 12, color: widget.pickerItemTextColor),
+              ),
+            ),
+            // checkbox
+            if (isChecked) const FlowySvg(name: 'checkmark'),
+            const SizedBox(width: 6),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -255,6 +255,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
             TextStyle(backgroundColor: attributes.backgroundColor),
           );
         }
+        if (attributes.color != null) {
+          textStyle = textStyle.combine(
+            TextStyle(color: attributes.color),
+          );
+        }
       }
       offset += textInsert.length;
       textSpans.add(

+ 244 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -4,6 +4,7 @@ import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
 import 'package:appflowy_editor/src/flutter/overlay.dart';
 import 'package:appflowy_editor/src/infra/clipboard.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:appflowy_editor/src/render/color_menu/color_picker.dart';
 import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
 import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
 import 'package:appflowy_editor/src/extensions/editor_state_extensions.dart';
@@ -264,6 +265,37 @@ List<ToolbarItem> defaultToolbarItems = [
       editorState.editorStyle.highlightColorHex!,
     ),
   ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.color',
+    type: 4,
+    tooltipsMessage: AppFlowyEditorLocalizations.current.color,
+    iconBuilder: (isHighlight) => Icon(
+      Icons.color_lens_outlined,
+      size: 14,
+      color: isHighlight ? Colors.lightBlue : Colors.white,
+    ),
+    validator: _showInBuiltInTextSelection,
+    highlightCallback: (editorState) =>
+        _allSatisfy(
+          editorState,
+          BuiltInAttributeKey.color,
+          (value) =>
+              value != null &&
+              value != _generateFontColorOptions(editorState).first.colorHex,
+        ) ||
+        _allSatisfy(
+          editorState,
+          BuiltInAttributeKey.backgroundColor,
+          (value) =>
+              value != null &&
+              value !=
+                  _generateBackgroundColorOptions(editorState).first.colorHex,
+        ),
+    handler: (editorState, context) => showColorMenu(
+      context,
+      editorState,
+    ),
+  ),
 ];
 
 ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) {
@@ -301,6 +333,8 @@ bool _allSatisfy(
 }
 
 OverlayEntry? _linkMenuOverlay;
+OverlayEntry? _colorMenuOverlay;
+
 EditorState? _editorState;
 bool _changeSelectionInner = false;
 void showLinkMenu(
@@ -343,6 +377,7 @@ void showLinkMenu(
       BuiltInAttributeKey.href,
     );
   }
+
   _linkMenuOverlay = OverlayEntry(builder: (context) {
     return Positioned(
       top: matchRect.bottom + 5.0,
@@ -360,6 +395,7 @@ void showLinkMenu(
               text,
               textNode: textNode,
             );
+
             _dismissLinkMenu();
           },
           onCopyLink: () {
@@ -419,3 +455,211 @@ void _dismissLinkMenu() {
       .removeListener(_dismissLinkMenu);
   _editorState = null;
 }
+
+void _dismissColorMenu() {
+  // workaround: SelectionService has been released after hot reload.
+  final isSelectionDisposed =
+      _editorState?.service.selectionServiceKey.currentState == null;
+  if (isSelectionDisposed) {
+    return;
+  }
+  if (_editorState?.service.selectionService.currentSelection.value == null) {
+    return;
+  }
+  if (_changeSelectionInner) {
+    _changeSelectionInner = false;
+    return;
+  }
+  _colorMenuOverlay?.remove();
+  _colorMenuOverlay = null;
+
+  _editorState?.service.scrollService?.enable();
+  _editorState?.service.keyboardService?.enable();
+  _editorState?.service.selectionService.currentSelection
+      .removeListener(_dismissColorMenu);
+  _editorState = null;
+}
+
+void showColorMenu(
+  BuildContext context,
+  EditorState editorState, {
+  Selection? customSelection,
+}) {
+  final rects = editorState.service.selectionService.selectionRects;
+  var maxBottom = 0.0;
+  late Rect matchRect;
+  for (final rect in rects) {
+    if (rect.bottom > maxBottom) {
+      maxBottom = rect.bottom;
+      matchRect = rect;
+    }
+  }
+  final baseOffset =
+      editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
+  matchRect = matchRect.shift(-baseOffset);
+
+  _dismissColorMenu();
+  _editorState = editorState;
+
+  // Since the link menu will only show in single text selection,
+  // We get the text node directly instead of judging details again.
+  final selection = customSelection ??
+      editorState.service.selectionService.currentSelection.value;
+
+  final node = editorState.service.selectionService.currentSelectedNodes;
+  if (selection == null || node.isEmpty || node.first is! TextNode) {
+    return;
+  }
+  final textNode = node.first as TextNode;
+
+  String? backgroundColorHex;
+  if (textNode.allSatisfyBackgroundColorInSelection(selection)) {
+    backgroundColorHex = textNode.getAttributeInSelection<String>(
+      selection,
+      BuiltInAttributeKey.backgroundColor,
+    );
+  }
+  String? fontColorHex;
+  if (textNode.allSatisfyFontColorInSelection(selection)) {
+    fontColorHex = textNode.getAttributeInSelection<String>(
+      selection,
+      BuiltInAttributeKey.color,
+    );
+  } else {
+    fontColorHex = editorState.editorStyle.textStyle?.color?.toHex();
+  }
+
+  final style = editorState.editorStyle;
+  _colorMenuOverlay = OverlayEntry(builder: (context) {
+    return Positioned(
+      top: matchRect.bottom + 5.0,
+      left: matchRect.left + 10,
+      child: Material(
+        color: Colors.transparent,
+        child: ColorPicker(
+          pickerBackgroundColor:
+              style.selectionMenuBackgroundColor ?? Colors.white,
+          pickerItemHoverColor: style.selectionMenuItemSelectedColor ??
+              Colors.blue.withOpacity(0.3),
+          pickerItemTextColor: style.selectionMenuItemTextColor ?? Colors.black,
+          selectedFontColorHex: fontColorHex,
+          selectedBackgroundColorHex: backgroundColorHex,
+          fontColorOptions: _generateFontColorOptions(editorState),
+          backgroundColorOptions: _generateBackgroundColorOptions(editorState),
+          onSubmittedbackgroundColorHex: (color) {
+            formatHighlightColor(
+              editorState,
+              color,
+            );
+            _dismissColorMenu();
+          },
+          onSubmittedFontColorHex: (color) {
+            formatFontColor(
+              editorState,
+              color,
+            );
+            _dismissColorMenu();
+          },
+        ),
+      ),
+    );
+  });
+  Overlay.of(context)?.insert(_colorMenuOverlay!);
+
+  editorState.service.scrollService?.disable();
+  editorState.service.keyboardService?.disable();
+  editorState.service.selectionService.currentSelection
+      .addListener(_dismissColorMenu);
+}
+
+List<ColorOption> _generateFontColorOptions(EditorState editorState) {
+  final defaultColor =
+      editorState.editorStyle.textStyle?.color ?? Colors.black; // black
+  return [
+    ColorOption(
+      colorHex: defaultColor.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorDefault,
+    ),
+    ColorOption(
+      colorHex: Colors.grey.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorGray,
+    ),
+    ColorOption(
+      colorHex: Colors.brown.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorBrown,
+    ),
+    ColorOption(
+      colorHex: Colors.yellow.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorYellow,
+    ),
+    ColorOption(
+      colorHex: Colors.green.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorGreen,
+    ),
+    ColorOption(
+      colorHex: Colors.blue.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorBlue,
+    ),
+    ColorOption(
+      colorHex: Colors.purple.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorPurple,
+    ),
+    ColorOption(
+      colorHex: Colors.pink.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorPink,
+    ),
+    ColorOption(
+      colorHex: Colors.red.toHex(),
+      name: AppFlowyEditorLocalizations.current.fontColorRed,
+    ),
+  ];
+}
+
+List<ColorOption> _generateBackgroundColorOptions(EditorState editorState) {
+  final defaultBackgroundColorHex =
+      editorState.editorStyle.highlightColorHex ?? '0x6000BCF0';
+  return [
+    ColorOption(
+      colorHex: defaultBackgroundColorHex,
+      name: AppFlowyEditorLocalizations.current.backgroundColorDefault,
+    ),
+    ColorOption(
+      colorHex: Colors.grey.withOpacity(0.3).toHex(),
+      name: AppFlowyEditorLocalizations.current.backgroundColorGray,
+    ),
+    ColorOption(
+      colorHex: Colors.brown.withOpacity(0.3).toHex(),
+      name: AppFlowyEditorLocalizations.current.backgroundColorBrown,
+    ),
+    ColorOption(
+      colorHex: Colors.yellow.withOpacity(0.3).toHex(),
+      name: AppFlowyEditorLocalizations.current.backgroundColorYellow,
+    ),
+    ColorOption(
+      colorHex: Colors.green.withOpacity(0.3).toHex(),
+      name: AppFlowyEditorLocalizations.current.backgroundColorGreen,
+    ),
+    ColorOption(
+      colorHex: Colors.blue.withOpacity(0.3).toHex(),
+      name: AppFlowyEditorLocalizations.current.backgroundColorBlue,
+    ),
+    ColorOption(
+      colorHex: Colors.purple.withOpacity(0.3).toHex(),
+      name: AppFlowyEditorLocalizations.current.backgroundColorPurple,
+    ),
+    ColorOption(
+      colorHex: Colors.pink.withOpacity(0.3).toHex(),
+      name: AppFlowyEditorLocalizations.current.backgroundColorPink,
+    ),
+    ColorOption(
+      colorHex: Colors.red.withOpacity(0.3).toHex(),
+      name: AppFlowyEditorLocalizations.current.backgroundColorRed,
+    ),
+  ];
+}
+
+extension on Color {
+  String toHex() {
+    return '0x${value.toRadixString(16)}';
+  }
+}

+ 16 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart

@@ -173,6 +173,22 @@ bool formatHighlight(EditorState editorState, String colorHex) {
   );
 }
 
+bool formatHighlightColor(EditorState editorState, String colorHex) {
+  return formatRichTextPartialStyle(
+    editorState,
+    BuiltInAttributeKey.backgroundColor,
+    customValue: colorHex,
+  );
+}
+
+bool formatFontColor(EditorState editorState, String colorHex) {
+  return formatRichTextPartialStyle(
+    editorState,
+    BuiltInAttributeKey.color,
+    customValue: colorHex,
+  );
+}
+
 bool formatRichTextPartialStyle(EditorState editorState, String styleKey,
     {Object? customValue}) {
   Attributes attributes = {

+ 3 - 2
frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart

@@ -50,14 +50,15 @@ void main() async {
           null,
           delta: Delta()
             ..insert(
-              'appflowy.io',
+              link,
               attributes: {
                 BuiltInAttributeKey.href: link,
               },
             ),
         );
       await editor.startTesting();
-      final finder = find.byType(RichText);
+      await tester.pumpAndSettle();
+      final finder = find.text(link, findRichText: true);
       expect(finder, findsOneWidget);
 
       // tap the link

+ 48 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/toolbar_rich_text_test.dart

@@ -2,6 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
 import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
 import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
+import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import '../../infra/test_editor.dart';
 
@@ -327,4 +328,51 @@ void main() async {
       );
     });
   }));
+
+  group('toolbar, color picker', (() {
+    testWidgets(
+        'Select Text, Click Toolbar and set color for the selected text',
+        (tester) async {
+      final editor = tester.editor..insertTextNode(singleLineText);
+      await editor.startTesting();
+
+      final node = editor.nodeAtPath([0]) as TextNode;
+      final selection = Selection(
+        start: Position(path: [0], offset: 0),
+        end: Position(path: [0], offset: singleLineText.length),
+      );
+
+      await editor.updateSelection(selection);
+      expect(find.byType(ToolbarWidget), findsOneWidget);
+      final colorButton = find.byWidgetPredicate((widget) {
+        if (widget is ToolbarItemWidget) {
+          return widget.item.id == 'appflowy.toolbar.color';
+        }
+        return false;
+      });
+      expect(colorButton, findsOneWidget);
+      await tester.tap(colorButton);
+      await tester.pumpAndSettle();
+      // select a yellow color
+      final yellowButton = find.text('Yellow');
+      await tester.tap(yellowButton);
+      await tester.pumpAndSettle();
+      expect(
+        node.allSatisfyInSelection(
+          selection,
+          BuiltInAttributeKey.color,
+          (value) {
+            return value == Colors.yellow.toHex();
+          },
+        ),
+        true,
+      );
+    });
+  }));
+}
+
+extension on Color {
+  String toHex() {
+    return '0x${value.toRadixString(16)}';
+  }
 }