Jelajahi Sumber

feat: callout (#1732)

* feat: add callout plugin

* refactor: add SelectionMenuItem.node factory

makes calloutMenuItem more readable

* feat: add color picker

* feat: add popover to callout

* feat: add emoji to callout

* fix: store tint name

* fix: remove leading underscores

* fix: revert export of editor_entry

* refactor: move color tint names to appflowy_editor

* fix: #1732 only re-insert text node if it's parent is text node too while deleting

* docs: doc comment for SelectionMenuItem.node

* fix: disable callout plugin

should be re-enabled after #1753 is done

* fix: typo

---------

Co-authored-by: Lucas.Xu <[email protected]>
abichinger 2 tahun lalu
induk
melakukan
000569a836

+ 3 - 1
frontend/app_flowy/lib/plugins/document/document_page.dart

@@ -1,7 +1,7 @@
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
 import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
 import 'package:flowy_infra_ui/widget/error_page.dart';
 import 'package:flowy_infra_ui/widget/error_page.dart';
-import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:intl/intl.dart';
 import 'package:intl/intl.dart';
@@ -108,6 +108,8 @@ class _DocumentPageState extends State<DocumentPage> {
         kMathEquationType: MathEquationNodeWidgetBuidler(),
         kMathEquationType: MathEquationNodeWidgetBuidler(),
         // Code Block
         // Code Block
         kCodeBlockType: CodeBlockNodeWidgetBuilder(),
         kCodeBlockType: CodeBlockNodeWidgetBuilder(),
+        // Card
+        kCalloutType: CalloutNodeWidgetBuilder(),
       },
       },
       shortcutEvents: [
       shortcutEvents: [
         // Divider
         // Divider

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

@@ -73,5 +73,23 @@
   "backgroundColorPink": "Pink background",
   "backgroundColorPink": "Pink background",
   "@backgroundColorPink": {},
   "@backgroundColorPink": {},
   "backgroundColorRed": "Red background",
   "backgroundColorRed": "Red background",
-  "@backgroundColorRed": {}
+  "@backgroundColorRed": {},
+  "tint1": "Tint 1",
+  "tint2": "Tint 2",
+  "tint3": "Tint 3",
+  "tint4": "Tint 4",
+  "tint5": "Tint 5",
+  "tint6": "Tint 6",
+  "tint7": "Tint 7",
+  "tint8": "Tint 8",
+  "tint9": "Tint 9",
+  "lightLightTint1": "Purple",
+  "lightLightTint2": "Pink",
+  "lightLightTint3": "Light Pink",
+  "lightLightTint4": "Orange",
+  "lightLightTint5": "Yellow",
+  "lightLightTint6": "Lime",
+  "lightLightTint7": "Green",
+  "lightLightTint8": "Aqua",
+  "lightLightTint9": "Blue"
 }
 }

+ 27 - 26
frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_all.dart

@@ -11,6 +11,7 @@
 
 
 import 'dart:async';
 import 'dart:async';
 
 
+import 'package:flutter/foundation.dart';
 import 'package:intl/intl.dart';
 import 'package:intl/intl.dart';
 import 'package:intl/message_lookup_by_library.dart';
 import 'package:intl/message_lookup_by_library.dart';
 import 'package:intl/src/intl_helpers.dart';
 import 'package:intl/src/intl_helpers.dart';
@@ -40,28 +41,28 @@ import 'messages_zh-TW.dart' as messages_zh_tw;
 
 
 typedef Future<dynamic> LibraryLoader();
 typedef Future<dynamic> LibraryLoader();
 Map<String, LibraryLoader> _deferredLibraries = {
 Map<String, LibraryLoader> _deferredLibraries = {
-  'bn_BN': () => new Future.value(null),
-  'ca': () => new Future.value(null),
-  'cs_CZ': () => new Future.value(null),
-  'de_DE': () => new Future.value(null),
-  'en': () => new Future.value(null),
-  'es_VE': () => new Future.value(null),
-  'fr_CA': () => new Future.value(null),
-  'fr_FR': () => new Future.value(null),
-  'hi_IN': () => new Future.value(null),
-  'hu_HU': () => new Future.value(null),
-  'id_ID': () => new Future.value(null),
-  'it_IT': () => new Future.value(null),
-  'ja_JP': () => new Future.value(null),
-  'ml_IN': () => new Future.value(null),
-  'nl_NL': () => new Future.value(null),
-  'pl_PL': () => new Future.value(null),
-  'pt_BR': () => new Future.value(null),
-  'pt_PT': () => new Future.value(null),
-  'ru_RU': () => new Future.value(null),
-  'tr_TR': () => new Future.value(null),
-  'zh_CN': () => new Future.value(null),
-  'zh_TW': () => new Future.value(null),
+  'bn_BN': () => new SynchronousFuture(null),
+  'ca': () => new SynchronousFuture(null),
+  'cs_CZ': () => new SynchronousFuture(null),
+  'de_DE': () => new SynchronousFuture(null),
+  'en': () => new SynchronousFuture(null),
+  'es_VE': () => new SynchronousFuture(null),
+  'fr_CA': () => new SynchronousFuture(null),
+  'fr_FR': () => new SynchronousFuture(null),
+  'hi_IN': () => new SynchronousFuture(null),
+  'hu_HU': () => new SynchronousFuture(null),
+  'id_ID': () => new SynchronousFuture(null),
+  'it_IT': () => new SynchronousFuture(null),
+  'ja_JP': () => new SynchronousFuture(null),
+  'ml_IN': () => new SynchronousFuture(null),
+  'nl_NL': () => new SynchronousFuture(null),
+  'pl_PL': () => new SynchronousFuture(null),
+  'pt_BR': () => new SynchronousFuture(null),
+  'pt_PT': () => new SynchronousFuture(null),
+  'ru_RU': () => new SynchronousFuture(null),
+  'tr_TR': () => new SynchronousFuture(null),
+  'zh_CN': () => new SynchronousFuture(null),
+  'zh_TW': () => new SynchronousFuture(null),
 };
 };
 
 
 MessageLookupByLibrary? _findExact(String localeName) {
 MessageLookupByLibrary? _findExact(String localeName) {
@@ -116,18 +117,18 @@ MessageLookupByLibrary? _findExact(String localeName) {
 }
 }
 
 
 /// User programs should call this before using [localeName] for messages.
 /// User programs should call this before using [localeName] for messages.
-Future<bool> initializeMessages(String localeName) async {
+Future<bool> initializeMessages(String localeName) {
   var availableLocale = Intl.verifiedLocale(
   var availableLocale = Intl.verifiedLocale(
       localeName, (locale) => _deferredLibraries[locale] != null,
       localeName, (locale) => _deferredLibraries[locale] != null,
       onFailure: (_) => null);
       onFailure: (_) => null);
   if (availableLocale == null) {
   if (availableLocale == null) {
-    return new Future.value(false);
+    return new SynchronousFuture(false);
   }
   }
   var lib = _deferredLibraries[availableLocale];
   var lib = _deferredLibraries[availableLocale];
-  await (lib == null ? new Future.value(false) : lib());
+  lib == null ? new SynchronousFuture(false) : lib();
   initializeInternalMessageLookup(() => new CompositeMessageLookup());
   initializeInternalMessageLookup(() => new CompositeMessageLookup());
   messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
   messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
-  return new Future.value(true);
+  return new SynchronousFuture(true);
 }
 }
 
 
 bool _messagesExistFor(String locale) {
 bool _messagesExistFor(String locale) {

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

@@ -63,11 +63,29 @@ class MessageLookup extends MessageLookupByLibrary {
         "highlight": MessageLookupByLibrary.simpleMessage("Highlight"),
         "highlight": MessageLookupByLibrary.simpleMessage("Highlight"),
         "image": MessageLookupByLibrary.simpleMessage("Image"),
         "image": MessageLookupByLibrary.simpleMessage("Image"),
         "italic": MessageLookupByLibrary.simpleMessage("Italic"),
         "italic": MessageLookupByLibrary.simpleMessage("Italic"),
+        "lightLightTint1": MessageLookupByLibrary.simpleMessage("Purple"),
+        "lightLightTint2": MessageLookupByLibrary.simpleMessage("Pink"),
+        "lightLightTint3": MessageLookupByLibrary.simpleMessage("Light Pink"),
+        "lightLightTint4": MessageLookupByLibrary.simpleMessage("Orange"),
+        "lightLightTint5": MessageLookupByLibrary.simpleMessage("Yellow"),
+        "lightLightTint6": MessageLookupByLibrary.simpleMessage("Lime"),
+        "lightLightTint7": MessageLookupByLibrary.simpleMessage("Green"),
+        "lightLightTint8": MessageLookupByLibrary.simpleMessage("Aqua"),
+        "lightLightTint9": MessageLookupByLibrary.simpleMessage("Blue"),
         "link": MessageLookupByLibrary.simpleMessage("Link"),
         "link": MessageLookupByLibrary.simpleMessage("Link"),
         "numberedList": MessageLookupByLibrary.simpleMessage("Numbered List"),
         "numberedList": MessageLookupByLibrary.simpleMessage("Numbered List"),
         "quote": MessageLookupByLibrary.simpleMessage("Quote"),
         "quote": MessageLookupByLibrary.simpleMessage("Quote"),
         "strikethrough": MessageLookupByLibrary.simpleMessage("Strikethrough"),
         "strikethrough": MessageLookupByLibrary.simpleMessage("Strikethrough"),
         "text": MessageLookupByLibrary.simpleMessage("Text"),
         "text": MessageLookupByLibrary.simpleMessage("Text"),
+        "tint1": MessageLookupByLibrary.simpleMessage("Tint 1"),
+        "tint2": MessageLookupByLibrary.simpleMessage("Tint 2"),
+        "tint3": MessageLookupByLibrary.simpleMessage("Tint 3"),
+        "tint4": MessageLookupByLibrary.simpleMessage("Tint 4"),
+        "tint5": MessageLookupByLibrary.simpleMessage("Tint 5"),
+        "tint6": MessageLookupByLibrary.simpleMessage("Tint 6"),
+        "tint7": MessageLookupByLibrary.simpleMessage("Tint 7"),
+        "tint8": MessageLookupByLibrary.simpleMessage("Tint 8"),
+        "tint9": MessageLookupByLibrary.simpleMessage("Tint 9"),
         "underline": MessageLookupByLibrary.simpleMessage("Underline")
         "underline": MessageLookupByLibrary.simpleMessage("Underline")
       };
       };
 }
 }

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

@@ -420,6 +420,186 @@ class AppFlowyEditorLocalizations {
       args: [],
       args: [],
     );
     );
   }
   }
+
+  /// `Tint 1`
+  String get tint1 {
+    return Intl.message(
+      'Tint 1',
+      name: 'tint1',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Tint 2`
+  String get tint2 {
+    return Intl.message(
+      'Tint 2',
+      name: 'tint2',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Tint 3`
+  String get tint3 {
+    return Intl.message(
+      'Tint 3',
+      name: 'tint3',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Tint 4`
+  String get tint4 {
+    return Intl.message(
+      'Tint 4',
+      name: 'tint4',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Tint 5`
+  String get tint5 {
+    return Intl.message(
+      'Tint 5',
+      name: 'tint5',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Tint 6`
+  String get tint6 {
+    return Intl.message(
+      'Tint 6',
+      name: 'tint6',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Tint 7`
+  String get tint7 {
+    return Intl.message(
+      'Tint 7',
+      name: 'tint7',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Tint 8`
+  String get tint8 {
+    return Intl.message(
+      'Tint 8',
+      name: 'tint8',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Tint 9`
+  String get tint9 {
+    return Intl.message(
+      'Tint 9',
+      name: 'tint9',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Purple`
+  String get lightLightTint1 {
+    return Intl.message(
+      'Purple',
+      name: 'lightLightTint1',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pink`
+  String get lightLightTint2 {
+    return Intl.message(
+      'Pink',
+      name: 'lightLightTint2',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Light Pink`
+  String get lightLightTint3 {
+    return Intl.message(
+      'Light Pink',
+      name: 'lightLightTint3',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Orange`
+  String get lightLightTint4 {
+    return Intl.message(
+      'Orange',
+      name: 'lightLightTint4',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Yellow`
+  String get lightLightTint5 {
+    return Intl.message(
+      'Yellow',
+      name: 'lightLightTint5',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Lime`
+  String get lightLightTint6 {
+    return Intl.message(
+      'Lime',
+      name: 'lightLightTint6',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Green`
+  String get lightLightTint7 {
+    return Intl.message(
+      'Green',
+      name: 'lightLightTint7',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Aqua`
+  String get lightLightTint8 {
+    return Intl.message(
+      'Aqua',
+      name: 'lightLightTint8',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Blue`
+  String get lightLightTint9 {
+    return Intl.message(
+      'Blue',
+      name: 'lightLightTint9',
+      desc: '',
+      args: [],
+    );
+  }
 }
 }
 
 
 class AppLocalizationDelegate
 class AppLocalizationDelegate

+ 75 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart

@@ -53,6 +53,81 @@ class SelectionMenuItem {
       editorState.apply(transaction);
       editorState.apply(transaction);
     }
     }
   }
   }
+
+  /// Creates a selection menu entry for inserting a [Node].
+  /// [name] and [iconData] define the appearance within the selection menu.
+  ///
+  /// The insert position is determined by the result of [replace] and
+  /// [insertBefore]
+  /// If no values are provided for [replace] and [insertBefore] the node is
+  /// inserted after the current selection.
+  /// [replace] takes precedence over [insertBefore]
+  ///
+  /// [updateSelection] can be used to update the selection after the node
+  /// has been inserted.
+  factory SelectionMenuItem.node({
+    required String name,
+    required IconData iconData,
+    required List<String> keywords,
+    required Node Function(EditorState editorState) nodeBuilder,
+    bool Function(EditorState editorState, TextNode textNode)? insertBefore,
+    bool Function(EditorState editorState, TextNode textNode)? replace,
+    Selection? Function(
+      EditorState editorState,
+      Path insertPath,
+      bool replaced,
+      bool insertedBefore,
+    )?
+        updateSelection,
+  }) {
+    return SelectionMenuItem(
+      name: () => name,
+      icon: (editorState, onSelected) => Icon(
+        iconData,
+        color: onSelected
+            ? editorState.editorStyle.selectionMenuItemSelectedIconColor
+            : editorState.editorStyle.selectionMenuItemIconColor,
+        size: 18.0,
+      ),
+      keywords: keywords,
+      handler: (editorState, _, __) {
+        final selection =
+            editorState.service.selectionService.currentSelection.value;
+        final textNodes = editorState
+            .service.selectionService.currentSelectedNodes
+            .whereType<TextNode>();
+        if (textNodes.length != 1 || selection == null) {
+          return;
+        }
+        final textNode = textNodes.first;
+        final node = nodeBuilder(editorState);
+        final transaction = editorState.transaction;
+        final bReplace = replace?.call(editorState, textNode) ?? false;
+        final bInsertBefore =
+            insertBefore?.call(editorState, textNode) ?? false;
+
+        //default insert after
+        var path = textNode.path.next;
+        if (bReplace) {
+          path = textNode.path;
+        } else if (bInsertBefore) {
+          path = textNode.path;
+        }
+
+        transaction
+          ..insertNode(path, node)
+          ..afterSelection = updateSelection?.call(
+                  editorState, path, bReplace, bInsertBefore) ??
+              selection;
+
+        if (bReplace) {
+          transaction.deleteNode(textNode);
+        }
+
+        editorState.apply(transaction);
+      },
+    );
+  }
 }
 }
 
 
 class SelectionMenuWidget extends StatefulWidget {
 class SelectionMenuWidget extends StatefulWidget {

+ 2 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart

@@ -120,7 +120,8 @@ KeyEventResult _backDeleteToPreviousTextNode(
 ) {
 ) {
   if (textNode.next == null &&
   if (textNode.next == null &&
       textNode.children.isEmpty &&
       textNode.children.isEmpty &&
-      textNode.parent?.parent != null) {
+      textNode.parent?.parent != null &&
+      textNode.parent is TextNode) {
     transaction
     transaction
       ..deleteNode(textNode)
       ..deleteNode(textNode)
       ..insertNode(textNode.parent!.path.next, textNode)
       ..insertNode(textNode.parent!.path.next, textNode)

+ 3 - 1
frontend/app_flowy/packages/appflowy_editor_plugins/lib/appflowy_editor_plugins.dart

@@ -1,5 +1,7 @@
 library appflowy_editor_plugins;
 library appflowy_editor_plugins;
 
 
+// Callout
+export 'src/callout/callout_node_widget.dart';
 // Code Block
 // Code Block
 export 'src/code_block/code_block_node_widget.dart';
 export 'src/code_block/code_block_node_widget.dart';
 export 'src/code_block/code_block_shortcut_event.dart';
 export 'src/code_block/code_block_shortcut_event.dart';
@@ -9,4 +11,4 @@ export 'src/divider/divider_shortcut_event.dart';
 // Emoji Picker
 // Emoji Picker
 export 'src/emoji_picker/emoji_menu_item.dart';
 export 'src/emoji_picker/emoji_menu_item.dart';
 // Math Equation
 // Math Equation
-export 'src/math_ equation/math_equation_node_widget.dart';
+export 'src/math_ equation/math_equation_node_widget.dart';

+ 291 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/callout/callout_node_widget.dart

@@ -0,0 +1,291 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor_plugins/src/emoji_picker/emoji_menu_item.dart';
+import 'package:appflowy_editor_plugins/src/extensions/theme_extension.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:flowy_infra/theme_extension.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/button.dart';
+import 'package:flowy_infra_ui/style_widget/color_picker.dart';
+import 'package:flowy_infra_ui/style_widget/icon_button.dart';
+import 'package:flutter/material.dart';
+
+const String kCalloutType = 'callout';
+const String kCalloutAttrColor = 'color';
+const String kCalloutAttrEmoji = 'emoji';
+
+SelectionMenuItem calloutMenuItem = SelectionMenuItem.node(
+  name: 'Callout',
+  iconData: Icons.note,
+  keywords: ['callout'],
+  nodeBuilder: (editorState) {
+    final node = Node(type: kCalloutType);
+    node.insert(TextNode.empty());
+    return node;
+  },
+  replace: (_, textNode) => textNode.toPlainText().isEmpty,
+  updateSelection: (_, path, __, ___) {
+    return Selection.single(path: [...path, 0], startOffset: 0);
+  },
+);
+
+class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
+  @override
+  Widget build(NodeWidgetContext<Node> context) {
+    return _CalloutWidget(
+      key: context.node.key,
+      node: context.node,
+      editorState: context.editorState,
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator => (node) => node.type == kCalloutType;
+}
+
+class _CalloutWidget extends StatefulWidget {
+  const _CalloutWidget({
+    super.key,
+    required this.node,
+    required this.editorState,
+  });
+
+  final Node node;
+  final EditorState editorState;
+
+  @override
+  State<_CalloutWidget> createState() => _CalloutWidgetState();
+}
+
+class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
+  bool isHover = false;
+  final PopoverController colorPopoverController = PopoverController();
+  final PopoverController emojiPopoverController = PopoverController();
+  RenderBox get _renderBox => context.findRenderObject() as RenderBox;
+
+  @override
+  void initState() {
+    widget.node.addListener(nodeChanged);
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    widget.node.removeListener(nodeChanged);
+    super.dispose();
+  }
+
+  void nodeChanged() {
+    if (widget.node.children.isEmpty) {
+      deleteNode();
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return MouseRegion(
+      onEnter: (_) {
+        setState(() {
+          isHover = true;
+        });
+      },
+      onExit: (_) {
+        setState(() {
+          isHover = false;
+        });
+      },
+      child: Stack(
+        children: [
+          _buildCallout(),
+          Positioned(top: 5, right: 5, child: _buildMenu()),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildCallout() {
+    return Container(
+      decoration: BoxDecoration(
+        borderRadius: const BorderRadius.all(Radius.circular(8.0)),
+        color: tint.color(context),
+      ),
+      padding: const EdgeInsets.only(top: 8, bottom: 8, left: 0, right: 15),
+      width: double.infinity,
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          _buildEmoji(),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: widget.node.children
+                  .map(
+                    (child) => widget.editorState.service.renderPluginService
+                        .buildPluginWidget(
+                      child is TextNode
+                          ? NodeWidgetContext<TextNode>(
+                              context: context,
+                              node: child,
+                              editorState: widget.editorState,
+                            )
+                          : NodeWidgetContext<Node>(
+                              context: context,
+                              node: child,
+                              editorState: widget.editorState,
+                            ),
+                    ),
+                  )
+                  .toList(),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _popover({
+    required PopoverController controller,
+    required Widget Function(BuildContext context) popupBuilder,
+    required Widget child,
+    Size size = const Size(200, 460),
+  }) {
+    return AppFlowyPopover(
+        controller: controller,
+        constraints: BoxConstraints.loose(size),
+        triggerActions: 0,
+        popupBuilder: popupBuilder,
+        child: child);
+  }
+
+  Widget _buildMenu() {
+    return _popover(
+      controller: colorPopoverController,
+      popupBuilder: (context) => _buildColorPicker(),
+      child: isHover
+          ? Wrap(
+              children: [
+                FlowyIconButton(
+                  icon: const Icon(Icons.color_lens_outlined),
+                  onPressed: () {
+                    colorPopoverController.show();
+                  },
+                ),
+                FlowyIconButton(
+                  icon: const Icon(Icons.delete_forever_outlined),
+                  onPressed: () {
+                    deleteNode();
+                  },
+                )
+              ],
+            )
+          : const SizedBox(width: 0),
+    );
+  }
+
+  Widget _buildColorPicker() {
+    return FlowyColorPicker(
+      colors: FlowyTint.values
+          .map((t) => ColorOption(
+                color: t.color(context),
+                name: t.tintName(AppFlowyEditorLocalizations.current),
+              ))
+          .toList(),
+      selected: tint.color(context),
+      onTap: (color, index) {
+        setColor(FlowyTint.values[index]);
+        colorPopoverController.close();
+      },
+    );
+  }
+
+  Widget _buildEmoji() {
+    return _popover(
+      controller: emojiPopoverController,
+      popupBuilder: (context) => _buildEmojiPicker(),
+      size: const Size(300, 200),
+      child: FlowyTextButton(
+        emoji,
+        fontSize: 18,
+        fillColor: Colors.transparent,
+        onPressed: () {
+          emojiPopoverController.show();
+        },
+      ),
+    );
+  }
+
+  Widget _buildEmojiPicker() {
+    return EmojiSelectionMenu(
+      editorState: widget.editorState,
+      onSubmitted: (emoji) {
+        setEmoji(emoji.emoji);
+        emojiPopoverController.close();
+      },
+      onExit: () {},
+    );
+  }
+
+  void setColor(FlowyTint tint) {
+    final transaction = widget.editorState.transaction
+      ..updateNode(widget.node, {
+        kCalloutAttrColor: tint.name,
+      });
+    widget.editorState.apply(transaction);
+  }
+
+  void setEmoji(String emoji) {
+    final transaction = widget.editorState.transaction
+      ..updateNode(widget.node, {
+        kCalloutAttrEmoji: emoji,
+      });
+    widget.editorState.apply(transaction);
+  }
+
+  void deleteNode() {
+    final transaction = widget.editorState.transaction..deleteNode(widget.node);
+    widget.editorState.apply(transaction);
+  }
+
+  FlowyTint get tint {
+    final name = widget.node.attributes[kCalloutAttrColor];
+    return (name is String) ? FlowyTint.fromJson(name) : FlowyTint.tint1;
+  }
+
+  String get emoji {
+    return widget.node.attributes[kCalloutAttrEmoji] ?? "💡";
+  }
+
+  @override
+  Position start() => Position(path: widget.node.path, offset: 0);
+
+  @override
+  Position end() => Position(path: widget.node.path, offset: 1);
+
+  @override
+  Position getPositionInOffset(Offset start) => end();
+
+  @override
+  bool get shouldCursorBlink => false;
+
+  @override
+  CursorStyle get cursorStyle => CursorStyle.borderLine;
+
+  @override
+  Rect? getCursorRectInPosition(Position position) {
+    final size = _renderBox.size;
+    return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
+  }
+
+  @override
+  List<Rect> getRectsInSelection(Selection selection) =>
+      [Offset.zero & _renderBox.size];
+
+  @override
+  Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
+        path: widget.node.path,
+        startOffset: 0,
+        endOffset: 1,
+      );
+
+  @override
+  Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
+}

+ 56 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/extensions/theme_extension.dart

@@ -0,0 +1,56 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flowy_infra/theme.dart';
+import 'package:flowy_infra/theme_extension.dart';
+import 'package:flutter/material.dart';
+
+extension FlowyTintExtension on FlowyTint {
+  String tintName(
+    AppFlowyEditorLocalizations l10n, {
+    ThemeMode? themeMode,
+    String? theme,
+  }) {
+    if (themeMode == ThemeMode.light && theme == BuiltInTheme.light) {
+      switch (this) {
+        case FlowyTint.tint1:
+          return l10n.lightLightTint1;
+        case FlowyTint.tint2:
+          return l10n.lightLightTint2;
+        case FlowyTint.tint3:
+          return l10n.lightLightTint3;
+        case FlowyTint.tint4:
+          return l10n.lightLightTint4;
+        case FlowyTint.tint5:
+          return l10n.lightLightTint5;
+        case FlowyTint.tint6:
+          return l10n.lightLightTint6;
+        case FlowyTint.tint7:
+          return l10n.lightLightTint7;
+        case FlowyTint.tint8:
+          return l10n.lightLightTint8;
+        case FlowyTint.tint9:
+          return l10n.lightLightTint9;
+      }
+    }
+
+    switch (this) {
+      case FlowyTint.tint1:
+        return l10n.tint1;
+      case FlowyTint.tint2:
+        return l10n.tint2;
+      case FlowyTint.tint3:
+        return l10n.tint3;
+      case FlowyTint.tint4:
+        return l10n.tint4;
+      case FlowyTint.tint5:
+        return l10n.tint5;
+      case FlowyTint.tint6:
+        return l10n.tint6;
+      case FlowyTint.tint7:
+        return l10n.tint7;
+      case FlowyTint.tint8:
+        return l10n.tint8;
+      case FlowyTint.tint9:
+        return l10n.tint9;
+    }
+  }
+}

+ 5 - 1
frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml

@@ -14,8 +14,12 @@ dependencies:
     sdk: flutter
     sdk: flutter
   appflowy_editor:
   appflowy_editor:
     path: ../appflowy_editor
     path: ../appflowy_editor
-  flowy_infra_ui:
+  flowy_infra: 
+    path: ../flowy_infra
+  flowy_infra_ui: 
     path: ../flowy_infra_ui
     path: ../flowy_infra_ui
+  appflowy_popover: 
+    path: ../appflowy_popover
   flutter_math_fork: ^0.6.3+1
   flutter_math_fork: ^0.6.3+1
   highlight: ^0.7.0
   highlight: ^0.7.0
   shared_preferences: ^2.0.15
   shared_preferences: ^2.0.15

+ 44 - 0
frontend/app_flowy/packages/flowy_infra/lib/theme_extension.dart

@@ -120,3 +120,47 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
     );
     );
   }
   }
 }
 }
+
+enum FlowyTint {
+  tint1,
+  tint2,
+  tint3,
+  tint4,
+  tint5,
+  tint6,
+  tint7,
+  tint8,
+  tint9;
+
+  String toJson() => name;
+  static FlowyTint fromJson(String json) {
+    try {
+      return FlowyTint.values.byName(json);
+    } catch (_) {
+      return FlowyTint.tint1;
+    }
+  }
+
+  Color color(BuildContext context) {
+    switch (this) {
+      case FlowyTint.tint1:
+        return AFThemeExtension.of(context).tint1;
+      case FlowyTint.tint2:
+        return AFThemeExtension.of(context).tint2;
+      case FlowyTint.tint3:
+        return AFThemeExtension.of(context).tint3;
+      case FlowyTint.tint4:
+        return AFThemeExtension.of(context).tint4;
+      case FlowyTint.tint5:
+        return AFThemeExtension.of(context).tint5;
+      case FlowyTint.tint6:
+        return AFThemeExtension.of(context).tint6;
+      case FlowyTint.tint7:
+        return AFThemeExtension.of(context).tint7;
+      case FlowyTint.tint8:
+        return AFThemeExtension.of(context).tint8;
+      case FlowyTint.tint9:
+        return AFThemeExtension.of(context).tint9;
+    }
+  }
+}

+ 1 - 1
frontend/app_flowy/packages/flowy_infra/pubspec.lock

@@ -221,4 +221,4 @@ packages:
     version: "6.1.0"
     version: "6.1.0"
 sdks:
 sdks:
   dart: ">=2.18.0 <3.0.0"
   dart: ">=2.18.0 <3.0.0"
-  flutter: ">=2.11.0-0.1.pre"
+  flutter: ">=3.3.0"

+ 2 - 2
frontend/app_flowy/packages/flowy_infra/pubspec.yaml

@@ -4,8 +4,8 @@ version: 0.0.1
 homepage:
 homepage:
 
 
 environment:
 environment:
-  sdk: ">=2.12.0 <3.0.0"
-  flutter: ">=1.17.0"
+  sdk: ">=2.18.0 <3.0.0"
+  flutter: ">=3.3.0"
 
 
 dependencies:
 dependencies:
   flutter:
   flutter:

+ 80 - 0
frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/color_picker.dart

@@ -0,0 +1,80 @@
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/button.dart';
+import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart';
+import 'package:flowy_infra_ui/widget/spacing.dart';
+import 'package:flutter/material.dart';
+
+class ColorOption {
+  const ColorOption({
+    required this.color,
+    required this.name,
+  });
+
+  final Color color;
+  final String name;
+}
+
+class FlowyColorPicker extends StatelessWidget {
+  final List<ColorOption> colors;
+  final Color? selected;
+  final Function(Color color, int index)? onTap;
+  final double separatorSize;
+  final double iconSize;
+  final double itemHeight;
+
+  const FlowyColorPicker({
+    Key? key,
+    required this.colors,
+    this.selected,
+    this.onTap,
+    this.separatorSize = 4,
+    this.iconSize = 16,
+    this.itemHeight = 32,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return ListView.separated(
+      shrinkWrap: true,
+      controller: ScrollController(),
+      separatorBuilder: (context, index) {
+        return VSpace(separatorSize);
+      },
+      itemCount: colors.length,
+      physics: StyledScrollPhysics(),
+      itemBuilder: (BuildContext context, int index) {
+        return _buildColorOption(colors[index], index);
+      },
+    );
+  }
+
+  Widget _buildColorOption(ColorOption option, int i) {
+    Widget? checkmark;
+    if (selected == option.color) {
+      checkmark = svgWidget("grid/checkmark");
+    }
+
+    final colorIcon = SizedBox.square(
+      dimension: iconSize,
+      child: Container(
+        decoration: BoxDecoration(
+          color: option.color,
+          shape: BoxShape.circle,
+        ),
+      ),
+    );
+
+    return SizedBox(
+      height: itemHeight,
+      child: FlowyButton(
+        text: FlowyText.medium(option.name),
+        leftIcon: colorIcon,
+        rightIcon: checkmark,
+        onTap: () {
+          onTap?.call(option.color, i);
+        },
+      ),
+    );
+  }
+}