Browse Source

feat: node widget action menu (#1783)

* feat: add action menu

* feat: add customActionMenuBuilder

* docs: add comments to action menu classes

* fix: enable callout

* test: add action menu tests

add AppFlowyRenderPluginService.getBuilder

* fix: appflowy_editor exports

* fix: action menu

* chore: add of function to EditorStyle

* fix: action menu test

---------

Co-authored-by: Lucas.Xu <[email protected]>
abichinger 2 years ago
parent
commit
e2f6f68923
19 changed files with 738 additions and 342 deletions
  1. 2 0
      frontend/app_flowy/lib/plugins/document/document_page.dart
  2. 1 0
      frontend/app_flowy/lib/plugins/document/editor_styles.dart
  3. 2 0
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  4. 180 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu.dart
  5. 111 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu_item.dart
  6. 58 16
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart
  7. 7 138
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart
  8. 6 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart
  9. 8 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart
  10. 32 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart
  11. 36 1
      frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart
  12. 165 0
      frontend/app_flowy/packages/appflowy_editor/test/render/action_menu/action_menu_test.dart
  13. 16 20
      frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart
  14. 11 44
      frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart
  15. 63 53
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/callout/callout_node_widget.dart
  16. 21 37
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart
  17. 16 23
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart
  18. 1 0
      frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml
  19. 2 2
      frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart

+ 2 - 0
frontend/app_flowy/lib/plugins/document/document_page.dart

@@ -139,6 +139,8 @@ class _DocumentPageState extends State<DocumentPage> {
         boardMenuItem,
         // Grid
         gridMenuItem,
+        // Callout
+        calloutMenuItem,
       ],
       themeData: theme.copyWith(extensions: [
         ...theme.extensions.values,

+ 1 - 0
frontend/app_flowy/lib/plugins/document/editor_styles.dart

@@ -23,6 +23,7 @@ EditorStyle customEditorTheme(BuildContext context) {
       fontFamily: 'poppins-Bold',
     ),
     backgroundColor: Theme.of(context).colorScheme.surface,
+    selectionMenuItemSelectedIconColor: Theme.of(context).colorScheme.primary,
   );
   return editorStyle;
 }

+ 2 - 0
frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart

@@ -45,3 +45,5 @@ export 'src/plugins/quill_delta/delta_document_encoder.dart';
 export 'src/commands/text/text_commands.dart';
 export 'src/render/toolbar/toolbar_item.dart';
 export 'src/extensions/node_extensions.dart';
+export 'src/render/action_menu/action_menu.dart';
+export 'src/render/action_menu/action_menu_item.dart';

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

@@ -0,0 +1,180 @@
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
+import 'package:appflowy_editor/src/render/style/editor_style.dart';
+import 'package:appflowy_editor/src/service/render_plugin_service.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+/// [ActionProvider] is an optional mixin to define the actions of a node widget.
+mixin ActionProvider<T extends Node> on NodeWidgetBuilder<T> {
+  List<ActionMenuItem> actions(NodeWidgetContext<T> context);
+}
+
+class ActionMenuArenaMember {
+  final ActionMenuState state;
+  final VoidCallback listener;
+
+  const ActionMenuArenaMember({required this.state, required this.listener});
+}
+
+/// Decides which action menu is visible.
+/// The menu with the greatest [Node.path] wins.
+class ActionMenuArena {
+  final Map<Path, ActionMenuArenaMember> _members = {};
+  final Set<Path> _visible = {};
+
+  ActionMenuArena._singleton();
+  static final instance = ActionMenuArena._singleton();
+
+  void add(ActionMenuState menuState) {
+    final member = ActionMenuArenaMember(
+      state: menuState,
+      listener: () {
+        final len = _visible.length;
+        if (menuState.isHover || menuState.isPinned) {
+          _visible.add(menuState.path);
+        } else {
+          _visible.remove(menuState.path);
+        }
+        if (len != _visible.length) {
+          _notifyAllVisible();
+        }
+      },
+    );
+    menuState.addListener(member.listener);
+    _members[menuState.path] = member;
+  }
+
+  void _notifyAllVisible() {
+    for (var path in _visible) {
+      _members[path]?.state.notify();
+    }
+  }
+
+  void remove(ActionMenuState menuState) {
+    final member = _members.remove(menuState.path);
+    if (member != null) {
+      menuState.removeListener(member.listener);
+      _visible.remove(menuState.path);
+    }
+  }
+
+  bool isVisible(Path path) {
+    var sorted = _visible.toList()
+      ..sort(
+        (a, b) => a <= b ? 1 : -1,
+      );
+    return sorted.isNotEmpty && path == sorted.first;
+  }
+}
+
+/// Used to manage the state of each [ActionMenuOverlay].
+class ActionMenuState extends ChangeNotifier {
+  final Path path;
+
+  ActionMenuState(this.path) {
+    ActionMenuArena.instance.add(this);
+  }
+
+  @override
+  void dispose() {
+    ActionMenuArena.instance.remove(this);
+    super.dispose();
+  }
+
+  bool _isHover = false;
+  bool _isPinned = false;
+
+  bool get isPinned => _isPinned;
+  bool get isHover => _isHover;
+  bool get isVisible => ActionMenuArena.instance.isVisible(path);
+
+  set isPinned(bool value) {
+    if (_isPinned == value) {
+      return;
+    }
+    _isPinned = value;
+    notifyListeners();
+  }
+
+  set isHover(bool value) {
+    if (_isHover == value) {
+      return;
+    }
+    _isHover = value;
+    notifyListeners();
+  }
+
+  void notify() {
+    notifyListeners();
+  }
+}
+
+/// The default widget to render an action menu
+class ActionMenuWidget extends StatelessWidget {
+  final List<ActionMenuItem> items;
+
+  const ActionMenuWidget({super.key, required this.items});
+
+  @override
+  Widget build(BuildContext context) {
+    final editorStyle = EditorStyle.of(context);
+
+    return Card(
+      color: editorStyle?.selectionMenuBackgroundColor,
+      elevation: 3.0,
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: items.map((item) {
+          return ActionMenuItemWidget(
+            item: item,
+          );
+        }).toList(),
+      ),
+    );
+  }
+}
+
+class ActionMenuOverlay extends StatelessWidget {
+  final Widget child;
+  final List<ActionMenuItem> items;
+  final Positioned Function(BuildContext context, List<ActionMenuItem> items)?
+      customActionMenuBuilder;
+
+  const ActionMenuOverlay({
+    super.key,
+    required this.items,
+    required this.child,
+    this.customActionMenuBuilder,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final menuState = Provider.of<ActionMenuState>(context);
+
+    return MouseRegion(
+      onEnter: (_) {
+        menuState.isHover = true;
+      },
+      onExit: (_) {
+        menuState.isHover = false;
+      },
+      onHover: (_) {
+        menuState.isHover = true;
+      },
+      child: Stack(
+        children: [
+          child,
+          if (menuState.isVisible) _buildMenu(context),
+        ],
+      ),
+    );
+  }
+
+  Positioned _buildMenu(BuildContext context) {
+    return customActionMenuBuilder != null
+        ? customActionMenuBuilder!(context, items)
+        : Positioned(top: 5, right: 5, child: ActionMenuWidget(items: items));
+  }
+}

+ 111 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu_item.dart

@@ -0,0 +1,111 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:flutter/material.dart';
+
+/// Represents a single action inside an action menu.
+///
+/// [itemWrapper] can be used to wrap the [ActionMenuItemWidget] with another
+/// widget (e.g. a popover).
+class ActionMenuItem {
+  final Widget Function({double? size, Color? color}) iconBuilder;
+  final Function()? onPressed;
+  final bool Function()? selected;
+  final Widget Function(Widget item)? itemWrapper;
+
+  ActionMenuItem({
+    required this.iconBuilder,
+    required this.onPressed,
+    this.selected,
+    this.itemWrapper,
+  });
+
+  factory ActionMenuItem.icon({
+    required IconData iconData,
+    required Function()? onPressed,
+    bool Function()? selected,
+    Widget Function(Widget item)? itemWrapper,
+  }) {
+    return ActionMenuItem(
+      iconBuilder: ({size, color}) {
+        return Icon(
+          iconData,
+          size: size,
+          color: color,
+        );
+      },
+      onPressed: onPressed,
+      selected: selected,
+      itemWrapper: itemWrapper,
+    );
+  }
+
+  factory ActionMenuItem.svg({
+    required String name,
+    required Function()? onPressed,
+    bool Function()? selected,
+    Widget Function(Widget item)? itemWrapper,
+  }) {
+    return ActionMenuItem(
+      iconBuilder: ({size, color}) {
+        return FlowySvg(
+          name: name,
+          color: color,
+          width: size,
+          height: size,
+        );
+      },
+      onPressed: onPressed,
+      selected: selected,
+      itemWrapper: itemWrapper,
+    );
+  }
+
+  factory ActionMenuItem.separator() {
+    return ActionMenuItem(
+      iconBuilder: ({size, color}) {
+        return FlowySvg(
+          name: 'image_toolbar/divider',
+          color: color,
+          height: size,
+        );
+      },
+      onPressed: null,
+    );
+  }
+}
+
+class ActionMenuItemWidget extends StatelessWidget {
+  final ActionMenuItem item;
+  final double iconSize;
+
+  const ActionMenuItemWidget({
+    super.key,
+    required this.item,
+    this.iconSize = 20,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final editorStyle = EditorStyle.of(context);
+    final isSelected = item.selected?.call() ?? false;
+    final color = isSelected
+        ? editorStyle?.selectionMenuItemSelectedIconColor
+        : editorStyle?.selectionMenuItemIconColor;
+
+    var icon = item.iconBuilder(size: iconSize, color: color);
+    var itemWidget = Padding(
+      padding: const EdgeInsets.all(3),
+      child: item.onPressed != null
+          ? MouseRegion(
+              cursor: SystemMouseCursors.click,
+              child: GestureDetector(
+                onTap: item.onPressed,
+                child: icon,
+              ),
+            )
+          : icon,
+    );
+
+    return item.itemWrapper?.call(itemWidget) ?? itemWidget;
+  }
+}

+ 58 - 16
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart

@@ -1,11 +1,14 @@
 import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/infra/clipboard.dart';
+import 'package:appflowy_editor/src/render/action_menu/action_menu.dart';
+import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
 import 'package:appflowy_editor/src/service/render_plugin_service.dart';
 import 'package:flutter/material.dart';
 
 import 'image_node_widget.dart';
 
-class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
+class ImageNodeBuilder extends NodeWidgetBuilder<Node>
+    with ActionProvider<Node> {
   @override
   Widget build(NodeWidgetContext<Node> context) {
     final src = context.node.attributes['image_src'];
@@ -20,21 +23,6 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
       src: src,
       width: width,
       alignment: _textToAlignment(align),
-      onCopy: () {
-        AppFlowyClipboard.setData(text: src);
-      },
-      onDelete: () {
-        final transaction = context.editorState.transaction
-          ..deleteNode(context.node);
-        context.editorState.apply(transaction);
-      },
-      onAlign: (alignment) {
-        final transaction = context.editorState.transaction
-          ..updateNode(context.node, {
-            'align': _alignmentToText(alignment),
-          });
-        context.editorState.apply(transaction);
-      },
       onResize: (width) {
         final transaction = context.editorState.transaction
           ..updateNode(context.node, {
@@ -52,6 +40,52 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
             node.attributes.containsKey('align');
       });
 
+  @override
+  List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
+    return [
+      ActionMenuItem.svg(
+        name: 'image_toolbar/align_left',
+        selected: () {
+          final align = context.node.attributes['align'];
+          return _textToAlignment(align) == Alignment.centerLeft;
+        },
+        onPressed: () => _onAlign(context, Alignment.centerLeft),
+      ),
+      ActionMenuItem.svg(
+        name: 'image_toolbar/align_center',
+        selected: () {
+          final align = context.node.attributes['align'];
+          return _textToAlignment(align) == Alignment.center;
+        },
+        onPressed: () => _onAlign(context, Alignment.center),
+      ),
+      ActionMenuItem.svg(
+        name: 'image_toolbar/align_right',
+        selected: () {
+          final align = context.node.attributes['align'];
+          return _textToAlignment(align) == Alignment.centerRight;
+        },
+        onPressed: () => _onAlign(context, Alignment.centerRight),
+      ),
+      ActionMenuItem.separator(),
+      ActionMenuItem.svg(
+        name: 'image_toolbar/copy',
+        onPressed: () {
+          final src = context.node.attributes['image_src'];
+          AppFlowyClipboard.setData(text: src);
+        },
+      ),
+      ActionMenuItem.svg(
+        name: 'image_toolbar/delete',
+        onPressed: () {
+          final transaction = context.editorState.transaction
+            ..deleteNode(context.node);
+          context.editorState.apply(transaction);
+        },
+      ),
+    ];
+  }
+
   Alignment _textToAlignment(String text) {
     if (text == 'left') {
       return Alignment.centerLeft;
@@ -69,4 +103,12 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
     }
     return 'center';
   }
+
+  void _onAlign(NodeWidgetContext context, Alignment alignment) {
+    final transaction = context.editorState.transaction
+      ..updateNode(context.node, {
+        'align': _alignmentToText(alignment),
+      });
+    context.editorState.apply(transaction);
+  }
 }

+ 7 - 138
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart

@@ -1,8 +1,7 @@
-import 'package:appflowy_editor/src/extensions/object_extensions.dart';
 import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/core/location/position.dart';
 import 'package:appflowy_editor/src/core/location/selection.dart';
-import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:appflowy_editor/src/extensions/object_extensions.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:flutter/material.dart';
 
@@ -13,9 +12,6 @@ class ImageNodeWidget extends StatefulWidget {
     required this.src,
     this.width,
     required this.alignment,
-    required this.onCopy,
-    required this.onDelete,
-    required this.onAlign,
     required this.onResize,
   }) : super(key: key);
 
@@ -23,9 +19,6 @@ class ImageNodeWidget extends StatefulWidget {
   final String src;
   final double? width;
   final Alignment alignment;
-  final VoidCallback onCopy;
-  final VoidCallback onDelete;
-  final void Function(Alignment alignment) onAlign;
   final void Function(double width) onResize;
 
   @override
@@ -146,8 +139,12 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget>
       widget.src,
       width: _imageWidth == null ? null : _imageWidth! - _distance,
       gaplessPlayback: true,
-      loadingBuilder: (context, child, loadingProgress) =>
-          loadingProgress == null ? child : _buildLoading(context),
+      loadingBuilder: (context, child, loadingProgress) {
+        if (loadingProgress == null ||
+            loadingProgress.cumulativeBytesLoaded ==
+                loadingProgress.expectedTotalBytes) return child;
+        return _buildLoading(context);
+      },
       errorBuilder: (context, error, stackTrace) {
         // _imageWidth ??= defaultMaxTextNodeWidth;
         return _buildError(context);
@@ -184,16 +181,6 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget>
             });
           },
         ),
-        if (_onFocus)
-          ImageToolbar(
-            top: 8,
-            right: 8,
-            height: 30,
-            alignment: widget.alignment,
-            onAlign: widget.onAlign,
-            onCopy: widget.onCopy,
-            onDelete: widget.onDelete,
-          )
       ],
     );
   }
@@ -282,121 +269,3 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget>
     );
   }
 }
-
-@visibleForTesting
-class ImageToolbar extends StatelessWidget {
-  const ImageToolbar({
-    Key? key,
-    required this.top,
-    required this.right,
-    required this.height,
-    required this.alignment,
-    required this.onCopy,
-    required this.onDelete,
-    required this.onAlign,
-  }) : super(key: key);
-
-  final double top;
-  final double right;
-  final double height;
-  final Alignment alignment;
-  final VoidCallback onCopy;
-  final VoidCallback onDelete;
-  final void Function(Alignment alignment) onAlign;
-
-  @override
-  Widget build(BuildContext context) {
-    return Positioned(
-      top: top,
-      right: right,
-      height: height,
-      child: Container(
-        decoration: BoxDecoration(
-          color: const Color(0xFF333333),
-          boxShadow: [
-            BoxShadow(
-              blurRadius: 5,
-              spreadRadius: 1,
-              color: Colors.black.withOpacity(0.1),
-            ),
-          ],
-          borderRadius: BorderRadius.circular(8.0),
-        ),
-        child: Row(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            IconButton(
-              hoverColor: Colors.transparent,
-              constraints: const BoxConstraints(),
-              padding: const EdgeInsets.fromLTRB(6.0, 4.0, 0.0, 4.0),
-              icon: FlowySvg(
-                name: 'image_toolbar/align_left',
-                color: alignment == Alignment.centerLeft
-                    ? const Color(0xFF00BCF0)
-                    : null,
-              ),
-              onPressed: () {
-                onAlign(Alignment.centerLeft);
-              },
-            ),
-            IconButton(
-              hoverColor: Colors.transparent,
-              constraints: const BoxConstraints(),
-              padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0),
-              icon: FlowySvg(
-                name: 'image_toolbar/align_center',
-                color: alignment == Alignment.center
-                    ? const Color(0xFF00BCF0)
-                    : null,
-              ),
-              onPressed: () {
-                onAlign(Alignment.center);
-              },
-            ),
-            IconButton(
-              hoverColor: Colors.transparent,
-              constraints: const BoxConstraints(),
-              padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0),
-              icon: FlowySvg(
-                name: 'image_toolbar/align_right',
-                color: alignment == Alignment.centerRight
-                    ? const Color(0xFF00BCF0)
-                    : null,
-              ),
-              onPressed: () {
-                onAlign(Alignment.centerRight);
-              },
-            ),
-            const Center(
-              child: FlowySvg(
-                name: 'image_toolbar/divider',
-              ),
-            ),
-            IconButton(
-              hoverColor: Colors.transparent,
-              constraints: const BoxConstraints(),
-              padding: const EdgeInsets.fromLTRB(4.0, 4.0, 0.0, 4.0),
-              icon: const FlowySvg(
-                name: 'image_toolbar/copy',
-              ),
-              onPressed: () {
-                onCopy();
-              },
-            ),
-            IconButton(
-              hoverColor: Colors.transparent,
-              constraints: const BoxConstraints(),
-              padding: const EdgeInsets.fromLTRB(0.0, 4.0, 6.0, 4.0),
-              icon: const FlowySvg(
-                name: 'image_toolbar/delete',
-              ),
-              onPressed: () {
-                onDelete();
-              },
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-}

+ 6 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart

@@ -158,6 +158,10 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
     );
   }
 
+  static EditorStyle? of(BuildContext context) {
+    return Theme.of(context).extension<EditorStyle>();
+  }
+
   static final light = EditorStyle(
     padding: const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0),
     backgroundColor: Colors.white,
@@ -166,8 +170,8 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
     selectionMenuBackgroundColor: const Color(0xFFFFFFFF),
     selectionMenuItemTextColor: const Color(0xFF333333),
     selectionMenuItemIconColor: const Color(0xFF333333),
-    selectionMenuItemSelectedTextColor: const Color(0xFF333333),
-    selectionMenuItemSelectedIconColor: const Color(0xFF333333),
+    selectionMenuItemSelectedTextColor: const Color.fromARGB(255, 56, 91, 247),
+    selectionMenuItemSelectedIconColor: const Color.fromARGB(255, 56, 91, 247),
     selectionMenuItemSelectedColor: const Color(0xFFE0F8FF),
     textPadding: const EdgeInsets.symmetric(vertical: 8.0),
     textStyle: const TextStyle(fontSize: 16.0, color: Colors.black),

+ 8 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart

@@ -1,16 +1,15 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/flutter/overlay.dart';
-import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
-import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart';
-import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
-
 import 'package:appflowy_editor/src/render/editor/editor_entry.dart';
+import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
 import 'package:appflowy_editor/src/render/rich_text/bulleted_list_text.dart';
 import 'package:appflowy_editor/src/render/rich_text/checkbox_text.dart';
 import 'package:appflowy_editor/src/render/rich_text/heading_text.dart';
 import 'package:appflowy_editor/src/render/rich_text/number_list_text.dart';
 import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text.dart';
+import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart';
+import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
 
 NodeWidgetBuilders defaultBuilders = {
   'editor': EditorEntryWidgetBuilder(),
@@ -33,6 +32,7 @@ class AppFlowyEditor extends StatefulWidget {
     this.toolbarItems = const [],
     this.editable = true,
     this.autoFocus = false,
+    this.customActionMenuBuilder,
     ThemeData? themeData,
   }) : super(key: key) {
     this.themeData = themeData ??
@@ -61,6 +61,9 @@ class AppFlowyEditor extends StatefulWidget {
   /// Set the value to true to focus the editor on the start of the document.
   final bool autoFocus;
 
+  final Positioned Function(BuildContext context, List<ActionMenuItem> items)?
+      customActionMenuBuilder;
+
   @override
   State<AppFlowyEditor> createState() => _AppFlowyEditorState();
 }
@@ -171,5 +174,6 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
           ...defaultBuilders,
           ...widget.customBuilders,
         },
+        customActionMenuBuilder: widget.customActionMenuBuilder,
       );
 }

+ 32 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart

@@ -1,6 +1,8 @@
 import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/infra/log.dart';
+import 'package:appflowy_editor/src/render/action_menu/action_menu.dart';
+import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
@@ -29,6 +31,9 @@ abstract class AppFlowyRenderPluginService {
   /// UnRegister plugin with specified [name].
   void unRegister(String name);
 
+  /// Returns a [NodeWidgetBuilder], if one has been registered for [name]
+  NodeWidgetBuilder? getBuilder(String name);
+
   Widget buildPluginWidget(NodeWidgetContext context);
 }
 
@@ -57,9 +62,13 @@ class NodeWidgetContext<T extends Node> {
 }
 
 class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
+  final Positioned Function(BuildContext context, List<ActionMenuItem> items)?
+      customActionMenuBuilder;
+
   AppFlowyRenderPlugin({
     required this.editorState,
     required NodeWidgetBuilders builders,
+    this.customActionMenuBuilder,
   }) {
     registerAll(builders);
   }
@@ -106,6 +115,11 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
     _builders.remove(name);
   }
 
+  @override
+  NodeWidgetBuilder? getBuilder(String name) {
+    return _builders[name];
+  }
+
   Widget _autoUpdateNodeWidget(
       NodeWidgetBuilder builder, NodeWidgetContext context) {
     Widget notifier;
@@ -116,7 +130,7 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
             return Consumer<TextNode>(
               builder: ((_, value, child) {
                 Log.ui.debug('TextNode is rebuilding...');
-                return builder.build(context);
+                return _buildWithActions(builder, context);
               }),
             );
           });
@@ -127,7 +141,7 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
             return Consumer<Node>(
               builder: ((_, value, child) {
                 Log.ui.debug('Node is rebuilding...');
-                return builder.build(context);
+                return _buildWithActions(builder, context);
               }),
             );
           });
@@ -138,6 +152,22 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
     );
   }
 
+  Widget _buildWithActions(
+      NodeWidgetBuilder builder, NodeWidgetContext context) {
+    if (builder is ActionProvider) {
+      return ChangeNotifierProvider(
+        create: (_) => ActionMenuState(context.node.path),
+        child: ActionMenuOverlay(
+          items: builder.actions(context),
+          customActionMenuBuilder: customActionMenuBuilder,
+          child: builder.build(context),
+        ),
+      );
+    } else {
+      return builder.build(context);
+    }
+  }
+
   void _validatePlugin(String name) {
     final paths = name.split('/');
     if (paths.length > 2) {

+ 36 - 1
frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart

@@ -68,7 +68,7 @@ class EditorWidgetTester {
     );
   }
 
-  void insertImageNode(String src, {String? align}) {
+  void insertImageNode(String src, {String? align, double? width}) {
     insert(
       Node(
         type: 'image',
@@ -76,6 +76,7 @@ class EditorWidgetTester {
         attributes: {
           'image_src': src,
           'align': align ?? 'center',
+          ...width != null ? {'width': width} : {},
         },
       ),
     );
@@ -161,6 +162,40 @@ class EditorWidgetTester {
       ..disableSealTimer = true
       ..disbaleRules = true;
   }
+
+  bool runAction(int actionIndex, Node node) {
+    final builder = editorState.service.renderPluginService.getBuilder(node.id);
+    if (builder is! ActionProvider) {
+      return false;
+    }
+
+    final buildContext = node.key.currentContext;
+    if (buildContext == null) {
+      return false;
+    }
+
+    final context = node is TextNode
+        ? NodeWidgetContext<TextNode>(
+            context: buildContext,
+            node: node,
+            editorState: editorState,
+          )
+        : NodeWidgetContext<Node>(
+            context: buildContext,
+            node: node,
+            editorState: editorState,
+          );
+
+    final actions =
+        builder.actions(context).where((a) => a.onPressed != null).toList();
+    if (actionIndex > actions.length) {
+      return false;
+    }
+
+    final action = actions[actionIndex];
+    action.onPressed!();
+    return true;
+  }
 }
 
 extension TestString on String {

+ 165 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/action_menu/action_menu_test.dart

@@ -0,0 +1,165 @@
+import 'package:appflowy_editor/src/render/action_menu/action_menu.dart';
+import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('action_menu.dart', () {
+    testWidgets('hover and tap action', (tester) async {
+      var actionHit = false;
+
+      final widget = ActionMenuOverlay(
+        items: [
+          ActionMenuItem.icon(
+            iconData: Icons.download,
+            onPressed: () => actionHit = true,
+          )
+        ],
+        child: const SizedBox(
+          height: 100,
+          width: 100,
+        ),
+      );
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Material(
+            child: ChangeNotifierProvider(
+              create: (context) => ActionMenuState([]),
+              child: widget,
+            ),
+          ),
+        ),
+      );
+      expect(find.byType(ActionMenuWidget), findsNothing);
+
+      final actionMenuOverlay = find.byType(ActionMenuOverlay);
+
+      final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+      await gesture.addPointer(location: Offset.zero);
+      await tester.pump();
+      await gesture.moveTo(tester.getCenter(actionMenuOverlay));
+      await tester.pumpAndSettle();
+
+      final actionMenu = find.byType(ActionMenuWidget);
+      expect(actionMenu, findsOneWidget);
+
+      final action = find.descendant(
+        of: actionMenu,
+        matching: find.byType(ActionMenuItemWidget),
+      );
+      expect(action, findsOneWidget);
+
+      await tester.tap(action);
+      expect(actionHit, true);
+    });
+
+    testWidgets('stacked action menu overlays', (tester) async {
+      final childWidget = ChangeNotifierProvider(
+        create: (context) => ActionMenuState([0, 0]),
+        child: ActionMenuOverlay(
+          items: [
+            ActionMenuItem(
+              iconBuilder: ({color, size}) => const Text("child"),
+              onPressed: null,
+            )
+          ],
+          child: const SizedBox(
+            height: 100,
+            width: 100,
+          ),
+        ),
+      );
+
+      final parentWidget = ChangeNotifierProvider(
+        create: (context) => ActionMenuState([0]),
+        child: ActionMenuOverlay(
+          items: [
+            ActionMenuItem(
+              iconBuilder: ({color, size}) => const Text("parent"),
+              onPressed: null,
+            )
+          ],
+          child: childWidget,
+        ),
+      );
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Material(
+            child: Center(child: parentWidget),
+          ),
+        ),
+      );
+      expect(find.byType(ActionMenuWidget), findsNothing);
+
+      final overlays = find.byType(ActionMenuOverlay);
+      expect(
+        tester.getCenter(overlays.at(0)),
+        tester.getCenter(overlays.at(1)),
+      );
+
+      final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+      await gesture.addPointer(location: Offset.zero);
+      await tester.pump();
+      await gesture.moveTo(tester.getCenter(overlays.at(0)));
+      await tester.pumpAndSettle();
+
+      final actionMenu = find.byType(ActionMenuWidget);
+      expect(actionMenu, findsOneWidget);
+
+      expect(find.text("child"), findsOneWidget);
+      expect(find.text("parent"), findsNothing);
+    });
+
+    testWidgets('customActionMenuBuilder', (tester) async {
+      final widget = ActionMenuOverlay(
+        items: [
+          ActionMenuItem.icon(
+            iconData: Icons.download,
+            onPressed: null,
+          )
+        ],
+        customActionMenuBuilder: (context, items) {
+          return const Positioned.fill(
+            child: Center(
+              child: Text("custom"),
+            ),
+          );
+        },
+        child: const SizedBox(
+          height: 100,
+          width: 100,
+        ),
+      );
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Material(
+            child: ChangeNotifierProvider(
+              create: (context) => ActionMenuState([]),
+              child: widget,
+            ),
+          ),
+        ),
+      );
+      expect(find.text("custom"), findsNothing);
+
+      final actionMenuOverlay = find.byType(ActionMenuOverlay);
+
+      final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+      await gesture.addPointer(location: Offset.zero);
+      await tester.pump();
+      await gesture.moveTo(tester.getCenter(actionMenuOverlay));
+      await tester.pumpAndSettle();
+
+      expect(find.text("custom"), findsOneWidget);
+    });
+  });
+}

+ 16 - 20
frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart

@@ -1,4 +1,3 @@
-import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
 import 'package:appflowy_editor/src/service/editor_service.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -22,6 +21,7 @@ void main() async {
           ..insertImageNode(src)
           ..insertTextNode(text);
         await editor.startTesting();
+        await tester.pumpAndSettle();
 
         expect(editor.documentLength, 3);
         expect(find.byType(Image), findsOneWidget);
@@ -35,11 +35,12 @@ void main() async {
             'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
         final editor = tester.editor
           ..insertTextNode(text)
-          ..insertImageNode(src, align: 'left')
-          ..insertImageNode(src, align: 'center')
-          ..insertImageNode(src, align: 'right')
+          ..insertImageNode(src, align: 'left', width: 100)
+          ..insertImageNode(src, align: 'center', width: 100)
+          ..insertImageNode(src, align: 'right', width: 100)
           ..insertTextNode(text);
         await editor.startTesting();
+        await tester.pumpAndSettle();
 
         expect(editor.documentLength, 5);
         final imageFinder = find.byType(Image);
@@ -60,20 +61,17 @@ void main() async {
         expect(leftImageRect.size, centerImageRect.size);
         expect(rightImageRect.size, centerImageRect.size);
 
-        final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
+        final leftImageNode = editor.document.nodeAtPath([1]);
 
-        final leftImage =
-            tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
-
-        leftImage.onAlign(Alignment.center);
-        await tester.pump(const Duration(milliseconds: 100));
+        expect(editor.runAction(1, leftImageNode!), true); // align center
+        await tester.pump();
         expect(
           tester.getRect(imageFinder.at(0)).left,
           centerImageRect.left,
         );
 
-        leftImage.onAlign(Alignment.centerRight);
-        await tester.pump(const Duration(milliseconds: 100));
+        expect(editor.runAction(2, leftImageNode), true); // align right
+        await tester.pump();
         expect(
           tester.getRect(imageFinder.at(0)).right,
           rightImageRect.right,
@@ -96,10 +94,10 @@ void main() async {
         final imageFinder = find.byType(Image);
         expect(imageFinder, findsOneWidget);
 
-        final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
-        final image =
-            tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
-        image.onCopy();
+        final imageNode = editor.document.nodeAtPath([1]);
+
+        expect(editor.runAction(3, imageNode!), true); // copy
+        await tester.pump();
       });
     });
 
@@ -119,10 +117,8 @@ void main() async {
         final imageFinder = find.byType(Image);
         expect(imageFinder, findsNWidgets(2));
 
-        final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
-        final image =
-            tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
-        image.onDelete();
+        final imageNode = editor.document.nodeAtPath([1]);
+        expect(editor.runAction(4, imageNode!), true); // delete
 
         await tester.pump(const Duration(milliseconds: 100));
         expect(editor.documentLength, 3);

+ 11 - 44
frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart

@@ -2,7 +2,6 @@ import 'dart:collection';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
-import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:network_image_mock/network_image_mock.dart';
@@ -15,14 +14,12 @@ void main() async {
   group('image_node_widget.dart', () {
     testWidgets('build the image node widget', (tester) async {
       mockNetworkImagesFor(() async {
-        var onCopyHit = false;
-        var onDeleteHit = false;
-        var onAlignHit = false;
         const src =
             'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
 
         final widget = ImageNodeWidget(
           src: src,
+          width: 100,
           node: Node(
             type: 'image',
             children: LinkedList(),
@@ -32,15 +29,6 @@ void main() async {
             },
           ),
           alignment: Alignment.center,
-          onCopy: () {
-            onCopyHit = true;
-          },
-          onDelete: () {
-            onDeleteHit = true;
-          },
-          onAlign: (alignment) {
-            onAlignHit = true;
-          },
           onResize: (width) {},
         );
 
@@ -51,41 +39,20 @@ void main() async {
             ),
           ),
         );
-        expect(find.byType(ImageNodeWidget), findsOneWidget);
+        await tester.pumpAndSettle();
 
-        final gesture =
-            await tester.createGesture(kind: PointerDeviceKind.mouse);
-        await gesture.addPointer(location: Offset.zero);
+        final imageNodeFinder = find.byType(ImageNodeWidget);
+        expect(imageNodeFinder, findsOneWidget);
 
-        expect(find.byType(ImageToolbar), findsNothing);
+        final imageFinder = find.byType(Image);
+        expect(imageFinder, findsOneWidget);
 
-        addTearDown(gesture.removePointer);
-        await tester.pump();
-        await gesture.moveTo(tester.getCenter(find.byType(ImageNodeWidget)));
-        await tester.pump();
+        final imageNodeRect = tester.getRect(imageNodeFinder);
+        final imageRect = tester.getRect(imageFinder);
 
-        expect(find.byType(ImageToolbar), findsOneWidget);
-
-        final iconFinder = find.byType(IconButton);
-        expect(iconFinder, findsNWidgets(5));
-
-        await tester.tap(iconFinder.at(0));
-        expect(onAlignHit, true);
-        onAlignHit = false;
-
-        await tester.tap(iconFinder.at(1));
-        expect(onAlignHit, true);
-        onAlignHit = false;
-
-        await tester.tap(iconFinder.at(2));
-        expect(onAlignHit, true);
-        onAlignHit = false;
-
-        await tester.tap(iconFinder.at(3));
-        expect(onCopyHit, true);
-
-        await tester.tap(iconFinder.at(4));
-        expect(onDeleteHit, true);
+        expect(imageRect.width, 100);
+        expect((imageNodeRect.left - imageRect.left).abs(),
+            (imageNodeRect.right - imageRect.right).abs());
       });
     });
   });

+ 63 - 53
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/callout/callout_node_widget.dart

@@ -6,8 +6,8 @@ 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';
+import 'package:provider/provider.dart';
 
 const String kCalloutType = 'callout';
 const String kCalloutAttrColor = 'color';
@@ -28,7 +28,8 @@ SelectionMenuItem calloutMenuItem = SelectionMenuItem.node(
   },
 );
 
-class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
+class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node>
+    with ActionProvider<Node> {
   @override
   Widget build(NodeWidgetContext<Node> context) {
     return _CalloutWidget(
@@ -40,6 +41,61 @@ class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
 
   @override
   NodeValidator<Node> get nodeValidator => (node) => node.type == kCalloutType;
+
+  _CalloutWidgetState? _getState(NodeWidgetContext<Node> context) {
+    return context.node.key.currentState as _CalloutWidgetState?;
+  }
+
+  BuildContext? _getBuildContext(NodeWidgetContext<Node> context) {
+    return context.node.key.currentContext;
+  }
+
+  @override
+  List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
+    return [
+      ActionMenuItem.icon(
+        iconData: Icons.color_lens_outlined,
+        onPressed: () {
+          final state = _getState(context);
+          final ctx = _getBuildContext(context);
+          if (state == null || ctx == null) {
+            return;
+          }
+          final menuState = Provider.of<ActionMenuState>(ctx, listen: false);
+          menuState.isPinned = true;
+          state.colorPopoverController.show();
+        },
+        itemWrapper: (item) {
+          final state = _getState(context);
+          final ctx = _getBuildContext(context);
+          if (state == null || ctx == null) {
+            return item;
+          }
+          return AppFlowyPopover(
+            controller: state.colorPopoverController,
+            popupBuilder: (context) => state._buildColorPicker(),
+            constraints: BoxConstraints.loose(const Size(200, 460)),
+            triggerActions: 0,
+            offset: const Offset(0, 30),
+            child: item,
+            onClose: () {
+              final menuState =
+                  Provider.of<ActionMenuState>(ctx, listen: false);
+              menuState.isPinned = false;
+            },
+          );
+        },
+      ),
+      ActionMenuItem.svg(
+        name: 'delete',
+        onPressed: () {
+          final transaction = context.editorState.transaction
+            ..deleteNode(context.node);
+          context.editorState.apply(transaction);
+        },
+      ),
+    ];
+  }
 }
 
 class _CalloutWidget extends StatefulWidget {
@@ -57,7 +113,6 @@ class _CalloutWidget extends StatefulWidget {
 }
 
 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;
@@ -82,27 +137,6 @@ class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
 
   @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)),
@@ -149,35 +183,11 @@ class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
     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),
+      controller: controller,
+      constraints: BoxConstraints.loose(size),
+      triggerActions: 0,
+      popupBuilder: popupBuilder,
+      child: child,
     );
   }
 

+ 21 - 37
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart

@@ -1,5 +1,4 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:appflowy_editor_plugins/src/infra/svg.dart';
 import 'package:flutter/material.dart';
 import 'package:highlight/highlight.dart' as highlight;
 import 'package:highlight/languages/all.dart';
@@ -9,7 +8,8 @@ const String kCodeBlockSubType = 'code_block';
 const String kCodeBlockAttrTheme = 'theme';
 const String kCodeBlockAttrLanguage = 'language';
 
-class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
+class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode>
+    with ActionProvider<TextNode> {
   @override
   Widget build(NodeWidgetContext<TextNode> context) {
     return _CodeBlockNodeWidge(
@@ -24,6 +24,20 @@ class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
         return node is TextNode &&
             node.attributes[kCodeBlockAttrTheme] is String;
       };
+
+  @override
+  List<ActionMenuItem> actions(NodeWidgetContext<TextNode> context) {
+    return [
+      ActionMenuItem.svg(
+        name: 'delete',
+        onPressed: () {
+          final transaction = context.editorState.transaction
+            ..deleteNode(context.node);
+          context.editorState.apply(transaction);
+        },
+      ),
+    ];
+  }
 }
 
 class _CodeBlockNodeWidge extends StatefulWidget {
@@ -44,7 +58,6 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
     with SelectableMixin, DefaultSelectable {
   final _richTextKey = GlobalKey(debugLabel: kCodeBlockType);
   final _padding = const EdgeInsets.only(left: 20, top: 30, bottom: 30);
-  bool _isHover = false;
   String? get _language =>
       widget.textNode.attributes[kCodeBlockAttrLanguage] as String?;
   String? _detectLanguage;
@@ -61,20 +74,11 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
 
   @override
   Widget build(BuildContext context) {
-    return InkWell(
-      onHover: (value) {
-        setState(() {
-          _isHover = value;
-        });
-      },
-      onTap: () {},
-      child: Stack(
-        children: [
-          _buildCodeBlock(context),
-          _buildSwitchCodeButton(context),
-          if (_isHover) _buildDeleteButton(context),
-        ],
-      ),
+    return Stack(
+      children: [
+        _buildCodeBlock(context),
+        _buildSwitchCodeButton(context),
+      ],
     );
   }
 
@@ -137,26 +141,6 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
     );
   }
 
-  Widget _buildDeleteButton(BuildContext context) {
-    return Positioned(
-      top: -5,
-      right: -5,
-      child: IconButton(
-        icon: Svg(
-          name: 'delete',
-          color: widget.editorState.editorStyle.selectionMenuItemIconColor,
-          width: 16,
-          height: 16,
-        ),
-        onPressed: () {
-          final transaction = widget.editorState.transaction
-            ..deleteNode(widget.textNode);
-          widget.editorState.apply(transaction);
-        },
-      ),
-    );
-  }
-
   // Copy from flutter.highlight package.
   // https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
   List<TextSpan> _convert(List<highlight.Node> nodes) {

+ 16 - 23
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart

@@ -1,5 +1,4 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:appflowy_editor_plugins/src/infra/svg.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_math_fork/flutter_math.dart';
@@ -55,7 +54,8 @@ SelectionMenuItem mathEquationMenuItem = SelectionMenuItem(
   },
 );
 
-class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node> {
+class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node>
+    with ActionProvider<Node> {
   @override
   Widget build(NodeWidgetContext<Node> context) {
     return _MathEquationNodeWidget(
@@ -68,6 +68,20 @@ class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node> {
   @override
   NodeValidator<Node> get nodeValidator =>
       (node) => node.attributes[kMathEquationAttr] is String;
+
+  @override
+  List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
+    return [
+      ActionMenuItem.svg(
+        name: "delete",
+        onPressed: () {
+          final transaction = context.editorState.transaction
+            ..deleteNode(context.node);
+          context.editorState.apply(transaction);
+        },
+      ),
+    ];
+  }
 }
 
 class _MathEquationNodeWidget extends StatefulWidget {
@@ -104,7 +118,6 @@ class _MathEquationNodeWidgetState extends State<_MathEquationNodeWidget> {
       child: Stack(
         children: [
           _buildMathEquation(context),
-          if (_isHover) _buildDeleteButton(context),
         ],
       ),
     );
@@ -136,26 +149,6 @@ class _MathEquationNodeWidgetState extends State<_MathEquationNodeWidget> {
     );
   }
 
-  Widget _buildDeleteButton(BuildContext context) {
-    return Positioned(
-      top: -5,
-      right: -5,
-      child: IconButton(
-        icon: Svg(
-          name: 'delete',
-          color: widget.editorState.editorStyle.selectionMenuItemIconColor,
-          width: 16,
-          height: 16,
-        ),
-        onPressed: () {
-          final transaction = widget.editorState.transaction
-            ..deleteNode(widget.node);
-          widget.editorState.apply(transaction);
-        },
-      ),
-    );
-  }
-
   void showEditingDialog() {
     showDialog(
       context: context,

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

@@ -24,6 +24,7 @@ dependencies:
   highlight: ^0.7.0
   shared_preferences: ^2.0.15
   flutter_svg: ^1.1.1+1
+  provider: ^6.0.3
 
 dev_dependencies:
   flutter_test:

+ 2 - 2
frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart

@@ -1,7 +1,6 @@
 import 'package:appflowy_popover/appflowy_popover.dart';
-import 'package:flutter/material.dart';
-
 import 'package:flowy_infra_ui/style_widget/decoration.dart';
+import 'package:flutter/material.dart';
 
 class AppFlowyPopover extends StatelessWidget {
   final Widget child;
@@ -43,6 +42,7 @@ class AppFlowyPopover extends StatelessWidget {
       asBarrier: asBarrier,
       triggerActions: triggerActions,
       windowPadding: windowPadding,
+      offset: offset,
       popupBuilder: (context) {
         final child = popupBuilder(context);
         debugPrint("Show popover: $child");