diff --git a/lib/src/editor/block_component/base_component/block_component_padding.dart b/lib/src/editor/block_component/base_component/block_component_padding.dart deleted file mode 100644 index 852c0d6fc..000000000 --- a/lib/src/editor/block_component/base_component/block_component_padding.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -class BlockComponentPadding extends StatelessWidget { - const BlockComponentPadding({ - super.key, - required this.node, - required this.padding, - this.indentPadding = EdgeInsets.zero, - required this.child, - }); - - final Node node; - final Widget child; - final EdgeInsets padding; - final EdgeInsets indentPadding; - - @override - Widget build(BuildContext context) { - final level = node.level.toDouble(); - return Padding( - padding: padding, - child: Padding( - padding: indentPadding * level, - child: child, - ), - ); - } -} diff --git a/lib/src/editor/block_component/base_component/widget/nested_list_widget.dart b/lib/src/editor/block_component/base_component/widget/nested_list_widget.dart index 8aa1d0228..f3615a7a9 100644 --- a/lib/src/editor/block_component/base_component/widget/nested_list_widget.dart +++ b/lib/src/editor/block_component/base_component/widget/nested_list_widget.dart @@ -3,10 +3,21 @@ import 'package:flutter/material.dart'; class NestedListWidget extends StatelessWidget { const NestedListWidget({ super.key, + this.indentPadding = const EdgeInsets.only(left: 30), required this.child, required this.children, }); + /// used to indent the nested list when the children's level is greater than 1. + /// + /// For example, + /// + /// Hello AppFlowy + /// Hello AppFlowy + /// ↑ + /// the indent padding is applied to the second line. + final EdgeInsets indentPadding; + final Widget child; final List children; @@ -18,11 +29,14 @@ class NestedListWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ child, - Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: children, + Padding( + padding: indentPadding, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), ), ], ); diff --git a/lib/src/editor/block_component/block_component.dart b/lib/src/editor/block_component/block_component.dart index e9d12a31d..95e410905 100644 --- a/lib/src/editor/block_component/block_component.dart +++ b/lib/src/editor/block_component/block_component.dart @@ -37,7 +37,6 @@ export 'base_component/convert_to_paragraph_command.dart'; export 'base_component/insert_newline_in_type_command.dart'; export 'base_component/indent_command.dart'; export 'base_component/outdent_command.dart'; -export 'base_component/block_component_padding.dart'; export 'base_component/widget/full_screen_overlay_entry.dart'; export 'base_component/widget/ignore_parent_pointer.dart'; diff --git a/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart b/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart index 523c16595..23f5268fc 100644 --- a/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart +++ b/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart @@ -97,14 +97,24 @@ class _BulletedListBlockComponentWidgetState @override GlobalKey> get containerKey => widget.node.key; + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: BulletedListBlockKeys.type, + ); + @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; - String? lastStartText; - TextDirection? lastDirection; + @override + EdgeInsets get indentPadding => configuration.indentPadding( + node, + calculateTextDirection( + defaultTextDirection: Directionality.maybeOf(context), + ), + ); @override Widget buildComponent(BuildContext context) { @@ -147,6 +157,12 @@ class _BulletedListBlockComponentWidgetState ), ); + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, @@ -155,13 +171,7 @@ class _BulletedListBlockComponentWidgetState ); } - final indentPadding = configuration.indentPadding(node, textDirection); - return BlockComponentPadding( - node: node, - padding: padding, - indentPadding: indentPadding, - child: child, - ); + return child; } } diff --git a/lib/src/editor/block_component/divider_block_component/divider_block_component.dart b/lib/src/editor/block_component/divider_block_component/divider_block_component.dart index c3bfc589d..d61fe224f 100644 --- a/lib/src/editor/block_component/divider_block_component/divider_block_component.dart +++ b/lib/src/editor/block_component/divider_block_component/divider_block_component.dart @@ -82,7 +82,6 @@ class _DividerBlockComponentWidgetState @override Widget build(BuildContext context) { Widget child = Container( - key: dividerKey, height: widget.height, alignment: Alignment.center, child: Divider( @@ -91,19 +90,21 @@ class _DividerBlockComponentWidgetState ), ); + child = Padding( + key: dividerKey, + padding: padding, + child: child, + ); + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( - node: widget.node, + node: node, actionBuilder: widget.actionBuilder!, child: child, ); } - return BlockComponentPadding( - node: node, - padding: padding, - child: child, - ); + return child; } @override @@ -121,6 +122,11 @@ class _DividerBlockComponentWidgetState @override CursorStyle get cursorStyle => CursorStyle.cover; + @override + Rect getBlockRect() { + return getCursorRectInPosition(Position.invalid()) ?? Rect.zero; + } + @override Rect? getCursorRectInPosition(Position position) { final size = _renderBox.size; diff --git a/lib/src/editor/block_component/heading_block_component/heading_block_component.dart b/lib/src/editor/block_component/heading_block_component/heading_block_component.dart index d49acf5de..02fb2b233 100644 --- a/lib/src/editor/block_component/heading_block_component/heading_block_component.dart +++ b/lib/src/editor/block_component/heading_block_component/heading_block_component.dart @@ -105,6 +105,11 @@ class _HeadingBlockComponentWidgetState @override GlobalKey> get containerKey => widget.node.key; + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: HeadingBlockKeys.type, + ); + @override BlockComponentConfiguration get configuration => widget.configuration; @@ -145,6 +150,12 @@ class _HeadingBlockComponentWidgetState ), ); + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, @@ -153,13 +164,7 @@ class _HeadingBlockComponentWidgetState ); } - final indentPadding = configuration.indentPadding(node, textDirection); - return BlockComponentPadding( - node: node, - padding: padding, - indentPadding: indentPadding, - child: child, - ); + return child; } TextStyle? defaultTextStyle(int level) { diff --git a/lib/src/editor/block_component/image_block_component/image_block_component.dart b/lib/src/editor/block_component/image_block_component/image_block_component.dart index 8741f3f83..dc787c8fa 100644 --- a/lib/src/editor/block_component/image_block_component/image_block_component.dart +++ b/lib/src/editor/block_component/image_block_component/image_block_component.dart @@ -143,7 +143,6 @@ class ImageBlockComponentWidgetState extends State final height = attributes[ImageBlockKeys.height]?.toDouble(); Widget child = ResizableImage( - key: imageKey, src: src, width: width, height: height, @@ -158,6 +157,12 @@ class ImageBlockComponentWidgetState extends State }, ); + child = Padding( + key: imageKey, + padding: padding, + child: child, + ); + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, @@ -191,11 +196,7 @@ class ImageBlockComponentWidgetState extends State ); } - return BlockComponentPadding( - node: node, - padding: padding, - child: child, - ); + return child; } @override @@ -213,6 +214,11 @@ class ImageBlockComponentWidgetState extends State @override CursorStyle get cursorStyle => CursorStyle.cover; + @override + Rect getBlockRect() { + return getCursorRectInPosition(Position.invalid()) ?? Rect.zero; + } + @override Rect? getCursorRectInPosition(Position position) { final size = _renderBox.size; diff --git a/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart b/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart index 202415347..816f12a0d 100644 --- a/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart +++ b/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart @@ -102,12 +102,25 @@ class _NumberedListBlockComponentWidgetState @override GlobalKey> get containerKey => widget.node.key; + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: NumberedListBlockKeys.type, + ); + @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; + @override + EdgeInsets get indentPadding => configuration.indentPadding( + node, + calculateTextDirection( + defaultTextDirection: Directionality.maybeOf(context), + ), + ); + @override Widget buildComponent(BuildContext context) { final textDirection = calculateTextDirection( @@ -150,6 +163,12 @@ class _NumberedListBlockComponentWidgetState ), ); + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, @@ -158,13 +177,7 @@ class _NumberedListBlockComponentWidgetState ); } - final indentPadding = configuration.indentPadding(node, textDirection); - return BlockComponentPadding( - node: node, - padding: padding, - indentPadding: indentPadding, - child: child, - ); + return child; } } diff --git a/lib/src/editor/block_component/quote_block_component/quote_block_component.dart b/lib/src/editor/block_component/quote_block_component/quote_block_component.dart index 9d80049fd..e7b543f1a 100644 --- a/lib/src/editor/block_component/quote_block_component/quote_block_component.dart +++ b/lib/src/editor/block_component/quote_block_component/quote_block_component.dart @@ -93,6 +93,11 @@ class _QuoteBlockComponentWidgetState extends State @override GlobalKey> get containerKey => widget.node.key; + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: QuoteBlockKeys.type, + ); + @override BlockComponentConfiguration get configuration => widget.configuration; @@ -141,6 +146,12 @@ class _QuoteBlockComponentWidgetState extends State ), ); + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, @@ -149,13 +160,7 @@ class _QuoteBlockComponentWidgetState extends State ); } - final indentPadding = configuration.indentPadding(node, textDirection); - return BlockComponentPadding( - node: node, - padding: padding, - indentPadding: indentPadding, - child: child, - ); + return child; } } diff --git a/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart b/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart index 4a055c47b..5d38d0757 100644 --- a/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart +++ b/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart @@ -115,6 +115,11 @@ class _AppFlowyRichTextState extends State offset: widget.node.delta?.toPlainText().length ?? 0, ); + @override + Rect getBlockRect() { + throw UnimplementedError(); + } + @override Rect? getCursorRectInPosition(Position position) { final textPosition = TextPosition(offset: position.offset); diff --git a/lib/src/editor/block_component/rich_text/default_selectable_mixin.dart b/lib/src/editor/block_component/rich_text/default_selectable_mixin.dart index 5c05c56f7..d88c2d4e5 100644 --- a/lib/src/editor/block_component/rich_text/default_selectable_mixin.dart +++ b/lib/src/editor/block_component/rich_text/default_selectable_mixin.dart @@ -1,11 +1,10 @@ -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; mixin DefaultSelectableMixin { GlobalKey get forwardKey; GlobalKey get containerKey; + GlobalKey get blockComponentKey; SelectableMixin get forward => forwardKey.currentState as SelectableMixin; @@ -19,6 +18,17 @@ mixin DefaultSelectableMixin { return Offset.zero; } + Rect getBlockRect() { + final parentBox = containerKey.currentContext?.findRenderObject(); + final childBox = blockComponentKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && childBox is RenderBox) { + final offset = childBox.localToGlobal(Offset.zero, ancestor: parentBox); + final size = parentBox.size; + return offset & (size - offset as Size); + } + return Rect.zero; + } + Position getPositionInOffset(Offset start) => forward.getPositionInOffset(start); diff --git a/lib/src/editor/block_component/text_block_component/text_block_component.dart b/lib/src/editor/block_component/text_block_component/text_block_component.dart index 96ef3998a..321f8ef2d 100644 --- a/lib/src/editor/block_component/text_block_component/text_block_component.dart +++ b/lib/src/editor/block_component/text_block_component/text_block_component.dart @@ -90,14 +90,24 @@ class _TextBlockComponentWidgetState extends State @override GlobalKey> get containerKey => widget.node.key; + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: ParagraphBlockKeys.type, + ); + @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; - String? lastStartText; - TextDirection? lastDirection; + @override + EdgeInsets get indentPadding => configuration.indentPadding( + node, + calculateTextDirection( + defaultTextDirection: Directionality.maybeOf(context), + ), + ); @override Widget buildComponent(BuildContext context) { @@ -131,7 +141,14 @@ class _TextBlockComponentWidgetState extends State ], ), ); - if (showActions) { + + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, @@ -139,12 +156,6 @@ class _TextBlockComponentWidgetState extends State ); } - final indentPadding = configuration.indentPadding(node, textDirection); - return BlockComponentPadding( - node: node, - padding: padding, - indentPadding: indentPadding, - child: child, - ); + return child; } } diff --git a/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart b/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart index 7f754ef2b..bcc9d6dfd 100644 --- a/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart +++ b/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart @@ -111,12 +111,25 @@ class _TodoListBlockComponentWidgetState @override GlobalKey> get containerKey => widget.node.key; + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: TodoListBlockKeys.type, + ); + @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; + @override + EdgeInsets get indentPadding => configuration.indentPadding( + node, + calculateTextDirection( + defaultTextDirection: Directionality.maybeOf(context), + ), + ); + bool get checked => widget.node.attributes[TodoListBlockKeys.checked]; @override @@ -162,6 +175,12 @@ class _TodoListBlockComponentWidgetState ), ); + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, @@ -170,13 +189,7 @@ class _TodoListBlockComponentWidgetState ); } - final indentPadding = configuration.indentPadding(node, textDirection); - return BlockComponentPadding( - node: node, - padding: padding, - indentPadding: indentPadding, - child: child, - ); + return child; } Future checkOrUncheck() async { diff --git a/lib/src/editor/editor_component/service/renderer/block_component_action.dart b/lib/src/editor/editor_component/service/renderer/block_component_action.dart index 40d6521eb..718bbe999 100644 --- a/lib/src/editor/editor_component/service/renderer/block_component_action.dart +++ b/lib/src/editor/editor_component/service/renderer/block_component_action.dart @@ -1,8 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; -const double blockComponentActionContainerWidth = 50; - class BlockComponentActionContainer extends StatelessWidget { const BlockComponentActionContainer({ super.key, @@ -18,12 +16,15 @@ class BlockComponentActionContainer extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - alignment: Alignment.centerRight, - width: blockComponentActionContainerWidth, - height: 25, // TODO: magic number, change it to the height of the block - color: Colors - .transparent, // have to set the color to transparent to make the MouseRegion work - child: !showActions ? const SizedBox.shrink() : actionBuilder(context), + // Set the color to transparent to make the MouseRegion work + color: Colors.transparent, + child: Visibility( + maintainSize: true, + maintainAnimation: true, + maintainState: true, + visible: showActions, + child: actionBuilder(context), + ), ); } } diff --git a/lib/src/editor/editor_component/service/renderer/block_component_widget.dart b/lib/src/editor/editor_component/service/renderer/block_component_widget.dart index 6bb42df05..d84fd8e2b 100644 --- a/lib/src/editor/editor_component/service/renderer/block_component_widget.dart +++ b/lib/src/editor/editor_component/service/renderer/block_component_widget.dart @@ -1,5 +1,4 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/editor/editor_component/service/renderer/block_component_action.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -71,7 +70,22 @@ mixin NestedBlockComponentStatefulWidgetMixin< T extends BlockComponentStatefulWidget> on State, BlockComponentBackgroundColorMixin { late final editorState = Provider.of(context, listen: false); - bool get showActions => widget.showActions && widget.actionBuilder != null; + + EdgeInsets get indentPadding; + + double? cachedLeft; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final left = node.selectable?.getBlockRect().left; + if (cachedLeft != left) { + setState(() => cachedLeft = left); + } + }); + } @override Widget build(BuildContext context) { @@ -81,16 +95,16 @@ mixin NestedBlockComponentStatefulWidgetMixin< } Widget buildComponentWithChildren(BuildContext context) { - final left = showActions ? blockComponentActionContainerWidth : 0.0; return Stack( children: [ Positioned.fill( - left: left, + left: cachedLeft, child: Container( color: backgroundColor, ), ), NestedListWidget( + indentPadding: indentPadding, child: buildComponent(context), children: editorState.renderer.buildList( context, diff --git a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart index 0a46ac7ee..e7dfee8d3 100644 --- a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart @@ -1,5 +1,4 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/editor/editor_component/service/renderer/block_component_action.dart'; import 'package:appflowy_editor/src/flutter/overlay.dart'; import 'package:appflowy_editor/src/service/context_menu/built_in_context_menu_item.dart'; import 'package:appflowy_editor/src/service/context_menu/context_menu.dart'; @@ -12,11 +11,11 @@ import 'package:provider/provider.dart'; class DesktopSelectionServiceWidget extends StatefulWidget { const DesktopSelectionServiceWidget({ - Key? key, + super.key, this.cursorColor = const Color(0xFF00BCF0), this.selectionColor = const Color.fromARGB(53, 111, 201, 231), required this.child, - }) : super(key: key); + }); final Widget child; final Color cursorColor; @@ -182,10 +181,6 @@ class _DesktopSelectionServiceWidgetState _selectionAreas ..forEach((overlay) => overlay.remove()) ..clear(); - // clear cursor areas - - // hide toolbar - // editorState.service.toolbarService?.hide(); // clear context menu _clearContextMenu(); @@ -284,7 +279,7 @@ class _DesktopSelectionServiceWidgetState void _onSecondaryTapDown(TapDownDetails details) { // if selection is null, or - // selection.isCollapsedand and the selected node is TextNode. + // selection.isCollapsed and the selected node is TextNode. // try to select the word. final selection = currentSelection.value; if (selection == null || @@ -336,20 +331,20 @@ class _DesktopSelectionServiceWidgetState void _updateBlockSelectionAreas(Selection selection) { assert(editorState.selectionType == SelectionType.block); - final nodes = editorState.getNodesInSelection(selection).normalized; + final nodes = editorState.getNodesInSelection(selection.normalized); + if (nodes.isEmpty) { + return; + } currentSelectedNodes = nodes; - final node = nodes.first; - var offset = Offset.zero; - var size = node.rect.size; - final builder = editorState.renderer.blockComponentBuilder(node.type); - if (builder != null && builder.showActions(node)) { - offset = offset.translate(blockComponentActionContainerWidth, 0); - size = Size(size.width - blockComponentActionContainerWidth, size.height); + final selectable = node.selectable; + + if (selectable == null) { + return; } - final rect = offset & size; + final rect = selectable.getBlockRect(); final overlay = OverlayEntry( builder: (context) => SelectionWidget( color: widget.selectionColor, diff --git a/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart b/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart index 3f21e29fb..d2d22397c 100644 --- a/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart @@ -1,5 +1,4 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/editor/editor_component/service/renderer/block_component_action.dart'; import 'package:appflowy_editor/src/flutter/overlay.dart'; import 'package:appflowy_editor/src/render/selection/mobile_selection_widget.dart'; import 'package:appflowy_editor/src/service/selection/mobile_selection_gesture.dart'; @@ -354,11 +353,6 @@ class _MobileSelectionServiceWidgetState final node = nodes.first; var offset = Offset.zero; var size = node.rect.size; - final builder = editorState.renderer.blockComponentBuilder(node.type); - if (builder != null && builder.showActions(node)) { - offset = offset.translate(blockComponentActionContainerWidth, 0); - size = Size(size.width - blockComponentActionContainerWidth, size.height); - } final rect = offset & size; final overlay = OverlayEntry( diff --git a/lib/src/render/image/image_node_widget.dart b/lib/src/render/image/image_node_widget.dart deleted file mode 100644 index d1591451e..000000000 --- a/lib/src/render/image/image_node_widget.dart +++ /dev/null @@ -1,283 +0,0 @@ -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/extensions/object_extensions.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:flutter/material.dart'; - -class ImageNodeWidget extends StatefulWidget { - const ImageNodeWidget({ - Key? key, - required this.node, - required this.src, - this.width, - required this.alignment, - required this.editable, - required this.onResize, - }) : super(key: key); - - final Node node; - final String src; - final double? width; - final Alignment alignment; - final bool editable; - final void Function(double width) onResize; - - @override - State createState() => ImageNodeWidgetState(); -} - -class ImageNodeWidgetState extends State with SelectableMixin { - RenderBox get _renderBox => context.findRenderObject() as RenderBox; - - final _imageKey = GlobalKey(); - - double? _imageWidth; - double _initial = 0; - double _distance = 0; - - @visibleForTesting - bool onFocus = false; - - ImageStream? _imageStream; - late ImageStreamListener _imageStreamListener; - - @override - void initState() { - super.initState(); - - _imageWidth = widget.width; - _imageStreamListener = ImageStreamListener( - (image, _) { - _imageWidth = _imageKey.currentContext - ?.findRenderObject() - ?.unwrapOrNull() - ?.size - .width; - }, - ); - } - - @override - void dispose() { - _imageStream?.removeListener(_imageStreamListener); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // only support network image. - return Container( - key: _imageKey, - padding: const EdgeInsets.only(top: 8, bottom: 8), - child: _buildNetworkImage(context), - ); - } - - @override - bool get shouldCursorBlink => false; - - @override - CursorStyle get cursorStyle => CursorStyle.borderLine; - - @override - Position start() { - return Position(path: widget.node.path, offset: 0); - } - - @override - Position end() { - return start(); - } - - @override - Position getPositionInOffset(Offset start) { - return end(); - } - - @override - Rect? getCursorRectInPosition(Position position) { - final size = _renderBox.size; - return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height); - } - - @override - List getRectsInSelection(Selection selection) { - final renderBox = context.findRenderObject() as RenderBox; - return [Offset.zero & renderBox.size]; - } - - @override - Selection getSelectionInRange(Offset start, Offset end) { - if (start <= end) { - return Selection(start: this.start(), end: this.end()); - } else { - return Selection(start: this.end(), end: this.start()); - } - } - - @override - Offset localToGlobal(Offset offset) { - final renderBox = context.findRenderObject() as RenderBox; - return renderBox.localToGlobal(offset); - } - - Widget _buildNetworkImage(BuildContext context) { - return Align( - alignment: widget.alignment, - child: MouseRegion( - onEnter: (event) => setState(() { - onFocus = true; - }), - onExit: (event) => setState(() { - onFocus = false; - }), - child: _buildResizableImage(context), - ), - ); - } - - @override - TextDirection textDirection() { - return TextDirection.ltr; - } - - Widget _buildResizableImage(BuildContext context) { - final networkImage = Image.network( - widget.src, - width: _imageWidth == null ? null : _imageWidth! - _distance, - gaplessPlayback: true, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null || - loadingProgress.cumulativeBytesLoaded == - loadingProgress.expectedTotalBytes) { - return child; - } - - return _buildLoading(context); - }, - errorBuilder: (context, error, stackTrace) => _buildError(context), - ); - - if (_imageWidth == null) { - _imageStream = networkImage.image.resolve(const ImageConfiguration()) - ..addListener(_imageStreamListener); - } - - return Stack( - children: [ - networkImage, - if (widget.editable) ...[ - _buildEdgeGesture( - context, - top: 0, - left: 0, - bottom: 0, - width: 5, - onUpdate: (distance) { - setState(() { - _distance = distance; - }); - }, - ), - _buildEdgeGesture( - context, - top: 0, - right: 0, - bottom: 0, - width: 5, - onUpdate: (distance) { - setState(() { - _distance = -distance; - }); - }, - ), - ], - ], - ); - } - - Widget _buildLoading(BuildContext context) { - return SizedBox( - height: 150, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox.fromSize( - size: const Size(18, 18), - child: const CircularProgressIndicator(), - ), - SizedBox.fromSize( - size: const Size(10, 10), - ), - const Text('Loading'), - ], - ), - ); - } - - Widget _buildError(BuildContext context) { - return Container( - height: 100, - width: _imageWidth, - alignment: Alignment.center, - padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4.0)), - border: Border.all(width: 1, color: Colors.grey), - ), - child: const Text('Could not load the image'), - ); - } - - Widget _buildEdgeGesture( - BuildContext context, { - double? top, - double? left, - double? right, - double? bottom, - double? width, - void Function(double distance)? onUpdate, - }) { - return Positioned( - top: top, - left: left, - right: right, - bottom: bottom, - width: width, - child: GestureDetector( - onHorizontalDragStart: (details) { - _initial = details.globalPosition.dx; - }, - onHorizontalDragUpdate: (details) { - if (onUpdate != null) { - onUpdate(details.globalPosition.dx - _initial); - } - }, - onHorizontalDragEnd: (details) { - _imageWidth = _imageWidth! - _distance; - _initial = 0; - _distance = 0; - - widget.onResize(_imageWidth!); - }, - child: MouseRegion( - cursor: SystemMouseCursors.resizeLeftRight, - child: onFocus - ? Center( - child: Container( - height: 40, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.2), - borderRadius: const BorderRadius.all( - Radius.circular(5.0), - ), - ), - ), - ) - : null, - ), - ), - ); - } -} diff --git a/lib/src/render/selection/selectable.dart b/lib/src/render/selection/selectable.dart index 791e4279c..73446ff86 100644 --- a/lib/src/render/selection/selectable.dart +++ b/lib/src/render/selection/selectable.dart @@ -14,6 +14,11 @@ enum CursorStyle { /// The widget returned by NodeWidgetBuilder must be with [SelectableMixin], /// otherwise the [AppFlowySelectionService] will not work properly. mixin SelectableMixin on State { + /// Returns the [Rect] representing the block selection in current widget. + /// + /// Normally, the rect should not include the action menu area. + Rect getBlockRect(); + /// Returns the [Selection] surrounded by start and end /// in current widget. /// diff --git a/test/customer/custom_action_builder_test.dart b/test/customer/custom_action_builder_test.dart new file mode 100644 index 000000000..9b42a991a --- /dev/null +++ b/test/customer/custom_action_builder_test.dart @@ -0,0 +1,103 @@ +import 'dart:ui'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/selection/selection_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; + +void main() async { + /// customize the action builder + testWidgets('customize the image block\'s menu', (tester) async { + await mockNetworkImagesFor(() async { + const widget = CustomActionBuilder(); + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo( + tester.getCenter(find.byType(TextBlockComponentWidget)), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + // expect to see the menu + final menuButton = find.text(menu); + expect(menuButton, findsOneWidget); + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + final selectionAreaRect = tester.getTopLeft( + find.byType(SelectionWidget), + ); + expect(selectionAreaRect.dx, greaterThan(0)); + }); + }); +} + +const menu = 'menu'; + +class CustomActionBuilder extends StatelessWidget { + const CustomActionBuilder({super.key}); + + @override + Widget build(BuildContext context) { + const text = 'Hello AppFlowy!'; + final document = Document.blank() + ..insert([ + 0 + ], [ + paragraphNode(text: text), + ]); + + final editorState = EditorState(document: document); + + final paragraphBuilder = TextBlockComponentBuilder( + configuration: standardBlockComponentConfiguration, + ); + paragraphBuilder.showActions = (_) => true; + paragraphBuilder.actionBuilder = (blockComponentContext, state) { + return Container( + width: 50, + height: 30, + color: Colors.red, + child: TextButton( + onPressed: () { + // update block selection + editorState.selectionType = SelectionType.block; + editorState.selection = Selection.single( + path: [0], + startOffset: 0, + endOffset: text.length, + ); + }, + child: const Text(menu), + ), + ); + }; + + final customBlockComponentBuilders = { + PageBlockKeys.type: PageBlockComponentBuilder(), + ParagraphBlockKeys.type: paragraphBuilder, + }; + + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: Container( + width: 500, + decoration: BoxDecoration( + border: Border.all(color: Colors.blue), + ), + child: AppFlowyEditor( + editorState: editorState, + blockComponentBuilders: customBlockComponentBuilders, + ), + ), + ), + ), + ); + } +} diff --git a/test/customer/custom_block_icon_test.dart b/test/customer/custom_block_icon_test.dart index 222325385..eb31171a4 100644 --- a/test/customer/custom_block_icon_test.dart +++ b/test/customer/custom_block_icon_test.dart @@ -74,8 +74,6 @@ class CustomBlockIcon extends StatelessWidget { child: AppFlowyEditor( editorState: editorState, blockComponentBuilders: customBlockComponentBuilders, - commandShortcutEvents: standardCommandShortcutEvents, - characterShortcutEvents: standardCharacterShortcutEvents, ), ), ), diff --git a/test/customer/custom_image_menu_test.dart b/test/customer/custom_image_menu_test.dart index 0709a2105..13bdf89f7 100644 --- a/test/customer/custom_image_menu_test.dart +++ b/test/customer/custom_image_menu_test.dart @@ -73,8 +73,6 @@ class CustomImageMenu extends StatelessWidget { child: AppFlowyEditor( editorState: editorState, blockComponentBuilders: customBlockComponentBuilders, - commandShortcutEvents: standardCommandShortcutEvents, - characterShortcutEvents: standardCharacterShortcutEvents, ), ), ), diff --git a/test/render/image/image_node_widget_test.dart b/test/render/image/image_node_widget_test.dart deleted file mode 100644 index 5daf747a9..000000000 --- a/test/render/image/image_node_widget_test.dart +++ /dev/null @@ -1,197 +0,0 @@ -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'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('image_node_widget.dart', () { - testWidgets('build the image node widget', (tester) async { - mockNetworkImagesFor(() async { - 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, - editable: true, - node: Node( - type: 'image', - children: LinkedList(), - attributes: { - 'image_src': src, - 'align': 'center', - }, - ), - alignment: Alignment.center, - onResize: (width) {}, - ); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: widget, - ), - ), - ); - await tester.pumpAndSettle(); - - final imageNodeFinder = find.byType(ImageNodeWidget); - expect(imageNodeFinder, findsOneWidget); - - final imageFinder = find.byType(Image); - expect(imageFinder, findsOneWidget); - - final imageNodeRect = tester.getRect(imageNodeFinder); - final imageRect = tester.getRect(imageFinder); - - expect(imageRect.width, 100); - expect( - (imageNodeRect.left - imageRect.left).abs(), - (imageNodeRect.right - imageRect.right).abs(), - ); - }); - }); - - testWidgets('can see resize when editable', (tester) async { - final imageResizeFinder = find.descendant( - of: find.byType(Center), - matching: find.byType(Container), - ); - - mockNetworkImagesFor(() async { - 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, - editable: true, - node: Node( - type: 'image', - children: LinkedList(), - attributes: { - 'image_src': src, - 'align': 'center', - }, - ), - alignment: Alignment.center, - onResize: (width) {}, - ); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: widget, - ), - ), - ); - await tester.pumpAndSettle(); - - final imageNodeFinder = find.byType(ImageNodeWidget); - expect(imageNodeFinder, findsOneWidget); - - ImageNodeWidgetState nodeState = tester.state( - find.byType(ImageNodeWidget), - ); - - expect(nodeState.onFocus, false); - expect(tester.widgetList(imageResizeFinder).length, 0); - - final gesture = - await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - - addTearDown(gesture.removePointer); - - await gesture.moveTo( - tester.getCenter( - find.byType(Image), - ), - ); - await tester.pumpAndSettle(); - - nodeState = tester.state( - find.byType(ImageNodeWidget), - ); - - expect(nodeState.onFocus, true); - expect(tester.widgetList(imageResizeFinder).length, 2); - }); - }); - - testWidgets('cannot see resize when not editable', (tester) async { - final imageResizeFinder = find.descendant( - of: find.byType(Center), - matching: find.byType(Container), - ); - - mockNetworkImagesFor(() async { - 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, - editable: false, - node: Node( - type: 'image', - children: LinkedList(), - attributes: { - 'image_src': src, - 'align': 'center', - }, - ), - alignment: Alignment.center, - onResize: (width) {}, - ); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: widget, - ), - ), - ); - await tester.pumpAndSettle(); - - final imageNodeFinder = find.byType(ImageNodeWidget); - expect(imageNodeFinder, findsOneWidget); - - ImageNodeWidgetState nodeState = tester.state( - find.byType(ImageNodeWidget), - ); - - expect(nodeState.onFocus, false); - expect(tester.widgetList(imageResizeFinder).length, 0); - - final gesture = - await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - - addTearDown(gesture.removePointer); - - await gesture.moveTo( - tester.getCenter( - find.byType(Image), - ), - ); - await tester.pumpAndSettle(); - - nodeState = tester.state( - find.byType(ImageNodeWidget), - ); - - expect(nodeState.onFocus, true); - expect(tester.widgetList(imageResizeFinder).length, 0); - }); - }); - }); -}