From e2f6f68923d45d5074af8cc69747bd01cd4b05b0 Mon Sep 17 00:00:00 2001 From: abichinger Date: Tue, 7 Feb 2023 03:03:36 +0100 Subject: [PATCH] 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 --- .../lib/plugins/document/document_page.dart | 2 + .../lib/plugins/document/editor_styles.dart | 1 + .../appflowy_editor/lib/appflowy_editor.dart | 2 + .../src/render/action_menu/action_menu.dart | 180 ++++++++++++++++++ .../render/action_menu/action_menu_item.dart | 111 +++++++++++ .../src/render/image/image_node_builder.dart | 74 +++++-- .../src/render/image/image_node_widget.dart | 145 +------------- .../lib/src/render/style/editor_style.dart | 8 +- .../lib/src/service/editor_service.dart | 12 +- .../src/service/render_plugin_service.dart | 34 +++- .../test/infra/test_editor.dart | 37 +++- .../render/action_menu/action_menu_test.dart | 165 ++++++++++++++++ .../render/image/image_node_builder_test.dart | 36 ++-- .../render/image/image_node_widget_test.dart | 55 ++---- .../lib/src/callout/callout_node_widget.dart | 116 +++++------ .../code_block/code_block_node_widget.dart | 58 ++---- .../math_equation_node_widget.dart | 39 ++-- .../appflowy_editor_plugins/pubspec.yaml | 1 + .../src/flowy_overlay/appflowy_popover.dart | 4 +- 19 files changed, 738 insertions(+), 342 deletions(-) create mode 100644 frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu.dart create mode 100644 frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu_item.dart create mode 100644 frontend/app_flowy/packages/appflowy_editor/test/render/action_menu/action_menu_test.dart diff --git a/frontend/app_flowy/lib/plugins/document/document_page.dart b/frontend/app_flowy/lib/plugins/document/document_page.dart index 9e9bd432ad..7d76bd06dc 100644 --- a/frontend/app_flowy/lib/plugins/document/document_page.dart +++ b/frontend/app_flowy/lib/plugins/document/document_page.dart @@ -139,6 +139,8 @@ class _DocumentPageState extends State { boardMenuItem, // Grid gridMenuItem, + // Callout + calloutMenuItem, ], themeData: theme.copyWith(extensions: [ ...theme.extensions.values, diff --git a/frontend/app_flowy/lib/plugins/document/editor_styles.dart b/frontend/app_flowy/lib/plugins/document/editor_styles.dart index d2b3f83e93..67010af1be 100644 --- a/frontend/app_flowy/lib/plugins/document/editor_styles.dart +++ b/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; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index 93f58d1b55..18142b360c 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/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'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu.dart new file mode 100644 index 0000000000..1e242094f8 --- /dev/null +++ b/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 on NodeWidgetBuilder { + List actions(NodeWidgetContext 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 _members = {}; + final Set _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 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 items; + final Positioned Function(BuildContext context, List items)? + customActionMenuBuilder; + + const ActionMenuOverlay({ + super.key, + required this.items, + required this.child, + this.customActionMenuBuilder, + }); + + @override + Widget build(BuildContext context) { + final menuState = Provider.of(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)); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu_item.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/action_menu/action_menu_item.dart new file mode 100644 index 0000000000..5129b83141 --- /dev/null +++ b/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; + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart index 56115af39a..4f5e760ea2 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart +++ b/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 { +class ImageNodeBuilder extends NodeWidgetBuilder + with ActionProvider { @override Widget build(NodeWidgetContext context) { final src = context.node.attributes['image_src']; @@ -20,21 +23,6 @@ class ImageNodeBuilder extends NodeWidgetBuilder { 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.attributes.containsKey('align'); }); + @override + List actions(NodeWidgetContext 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 { } return 'center'; } + + void _onAlign(NodeWidgetContext context, Alignment alignment) { + final transaction = context.editorState.transaction + ..updateNode(context.node, { + 'align': _alignmentToText(alignment), + }); + context.editorState.apply(transaction); + } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart index 1a0d63862b..27812d996e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart +++ b/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 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 }); }, ), - 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 ); } } - -@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(); - }, - ), - ], - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart index 93305bb31e..0d252d1e04 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart @@ -158,6 +158,10 @@ class EditorStyle extends ThemeExtension { ); } + static EditorStyle? of(BuildContext context) { + return Theme.of(context).extension(); + } + 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 { 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), diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart index 1c15df05f8..cd045a0582 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart +++ b/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 items)? + customActionMenuBuilder; + @override State createState() => _AppFlowyEditorState(); } @@ -171,5 +174,6 @@ class _AppFlowyEditorState extends State { ...defaultBuilders, ...widget.customBuilders, }, + customActionMenuBuilder: widget.customActionMenuBuilder, ); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart index e2aec3c4b6..24adece17a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart +++ b/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 { } class AppFlowyRenderPlugin extends AppFlowyRenderPluginService { + final Positioned Function(BuildContext context, List 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( 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( 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) { diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart index e5d171d417..55672fba47 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart +++ b/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( + context: buildContext, + node: node, + editorState: editorState, + ) + : NodeWidgetContext( + 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 { diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/action_menu/action_menu_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/action_menu/action_menu_test.dart new file mode 100644 index 0000000000..9725b5e0f9 --- /dev/null +++ b/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); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart index 201f07861a..118d30e362 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart +++ b/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); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart index a566b7ec07..f758983f78 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart +++ b/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()); }); }); }); diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/callout/callout_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/callout/callout_node_widget.dart index 8a42c57035..13335688dc 100644 --- a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/callout/callout_node_widget.dart +++ b/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 { +class CalloutNodeWidgetBuilder extends NodeWidgetBuilder + with ActionProvider { @override Widget build(NodeWidgetContext context) { return _CalloutWidget( @@ -40,6 +41,61 @@ class CalloutNodeWidgetBuilder extends NodeWidgetBuilder { @override NodeValidator get nodeValidator => (node) => node.type == kCalloutType; + + _CalloutWidgetState? _getState(NodeWidgetContext context) { + return context.node.key.currentState as _CalloutWidgetState?; + } + + BuildContext? _getBuildContext(NodeWidgetContext context) { + return context.node.key.currentContext; + } + + @override + List actions(NodeWidgetContext 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(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(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, ); } diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart index bcc30e0c3b..eec5c8ae3d 100644 --- a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart +++ b/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 { +class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder + with ActionProvider { @override Widget build(NodeWidgetContext context) { return _CodeBlockNodeWidge( @@ -24,6 +24,20 @@ class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder { return node is TextNode && node.attributes[kCodeBlockAttrTheme] is String; }; + + @override + List actions(NodeWidgetContext 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 _convert(List nodes) { diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart index c416c7d22b..5485baa6c2 100644 --- a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart +++ b/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 { +class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder + with ActionProvider { @override Widget build(NodeWidgetContext context) { return _MathEquationNodeWidget( @@ -68,6 +68,20 @@ class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder { @override NodeValidator get nodeValidator => (node) => node.attributes[kMathEquationAttr] is String; + + @override + List actions(NodeWidgetContext 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, diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml index a2ccec7754..f77db0e665 100644 --- a/frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml +++ b/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: diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index d9afaf03f2..bceadd6dd0 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/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");