From 5d1d311a2c22adc8c42707d6008a4408150c869c Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 12 Sep 2024 20:46:55 +0800 Subject: [PATCH] feat: support dragging to reorder block (#887) * feat: support drag to reorder block * feat: enable drag to reorder on home page * fix: proper offset calculation for drop path * docs: update doc comments for the drop target API * fix: render issue --------- Co-authored-by: Mathias Mogensen --- example/lib/home_page.dart | 9 + example/lib/main.dart | 5 +- example/lib/pages/desktop_editor.dart | 14 ++ example/lib/pages/drag_to_reorder_editor.dart | 213 ++++++++++++++++++ example/pubspec.yaml | 6 + lib/src/core/document/node.dart | 9 +- .../block_component_action_wrapper.dart | 17 ++ .../renderer/block_component_container.dart | 21 +- .../renderer/block_component_service.dart | 4 + .../selection/desktop_selection_service.dart | 14 +- .../service/selection_service.dart | 22 +- 11 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 example/lib/pages/drag_to_reorder_editor.dart diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index e213c02fd..e626e13be 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -8,6 +8,7 @@ import 'package:example/pages/auto_complete_editor.dart'; import 'package:example/pages/collab_editor.dart'; import 'package:example/pages/collab_selection_editor.dart'; import 'package:example/pages/customize_theme_for_editor.dart'; +import 'package:example/pages/drag_to_reorder_editor.dart'; import 'package:example/pages/editor.dart'; import 'package:example/pages/editor_list.dart'; import 'package:example/pages/fixed_toolbar_editor.dart'; @@ -167,6 +168,14 @@ class _HomePageState extends State { // Theme Demo _buildSeparator(context, 'Showcases'), + _buildListTile(context, 'Drag to reorder', () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DragToReorderEditor(), + ), + ); + }), _buildListTile(context, 'Markdown Editor', () { Navigator.push( context, diff --git a/example/lib/main.dart b/example/lib/main.dart index 6fb3f9d6f..74ff5bc94 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,8 +1,9 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:example/home_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:example/home_page.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; void main() { diff --git a/example/lib/pages/desktop_editor.dart b/example/lib/pages/desktop_editor.dart index 999583752..ef5a01b78 100644 --- a/example/lib/pages/desktop_editor.dart +++ b/example/lib/pages/desktop_editor.dart @@ -1,4 +1,5 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:example/pages/drag_to_reorder_editor.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; @@ -93,6 +94,9 @@ class _DesktopEditorState extends State { editorStyle: editorStyle, enableAutoComplete: true, autoCompleteTextProvider: _buildAutoCompleteTextProvider, + dropTargetStyle: const AppFlowyDropTargetStyle( + color: Colors.red, + ), header: Padding( padding: const EdgeInsets.only(bottom: 10.0), child: Image.asset( @@ -164,6 +168,16 @@ class _DesktopEditorState extends State { value.configuration = value.configuration.copyWith( padding: (_) => const EdgeInsets.symmetric(vertical: 8.0), ); + + if (key != PageBlockKeys.type) { + value.showActions = (_) => true; + value.actionBuilder = (context, actionState) { + return DragToReorderAction( + blockComponentContext: context, + builder: value, + ); + }; + } }); return map; } diff --git a/example/lib/pages/drag_to_reorder_editor.dart b/example/lib/pages/drag_to_reorder_editor.dart new file mode 100644 index 000000000..5c2997054 --- /dev/null +++ b/example/lib/pages/drag_to_reorder_editor.dart @@ -0,0 +1,213 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +class DragToReorderEditor extends StatefulWidget { + const DragToReorderEditor({ + super.key, + }); + + @override + State createState() => _DragToReorderEditorState(); +} + +class _DragToReorderEditorState extends State { + late final EditorState editorState; + late final EditorStyle editorStyle; + late final Map blockComponentBuilders; + + @override + void initState() { + super.initState(); + + forceShowBlockAction = true; + editorState = _createEditorState(); + editorStyle = _createEditorStyle(); + blockComponentBuilders = _createBlockComponentBuilders(); + } + + @override + void dispose() { + forceShowBlockAction = false; + editorState.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Drag to reorder'), + ), + body: AppFlowyEditor( + editorState: editorState, + editorStyle: editorStyle, + blockComponentBuilders: blockComponentBuilders, + dropTargetStyle: const AppFlowyDropTargetStyle( + color: Colors.red, + ), + ), + ); + } + + Map _createBlockComponentBuilders() { + final builders = {...standardBlockComponentBuilderMap}; + for (final entry in builders.entries) { + if (entry.key == PageBlockKeys.type) { + continue; + } + + final builder = entry.value; + + // only customize the todo list block + if (entry.key == TodoListBlockKeys.type) { + builder.showActions = (_) => true; + builder.actionBuilder = (context, actionState) { + return DragToReorderAction( + blockComponentContext: context, + builder: builder, + ); + }; + } + } + return builders; + } + + EditorState _createEditorState() { + final document = Document.blank() + ..insert([ + 0, + ], [ + todoListNode(checked: false, text: 'Todo 1'), + todoListNode(checked: false, text: 'Todo 2'), + todoListNode(checked: false, text: 'Todo 3'), + ]); + return EditorState( + document: document, + ); + } + + EditorStyle _createEditorStyle() { + return EditorStyle.desktop( + cursorWidth: 2.0, + cursorColor: Colors.black, + selectionColor: Colors.grey.shade300, + textStyleConfiguration: TextStyleConfiguration( + text: GoogleFonts.poppins( + fontSize: 16, + color: Colors.black, + ), + code: GoogleFonts.architectsDaughter(), + bold: GoogleFonts.poppins( + fontWeight: FontWeight.w500, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 200.0), + ); + } +} + +class DragToReorderAction extends StatefulWidget { + const DragToReorderAction({ + super.key, + required this.blockComponentContext, + required this.builder, + }); + + final BlockComponentContext blockComponentContext; + final BlockComponentBuilder builder; + + @override + State createState() => _DragToReorderActionState(); +} + +class _DragToReorderActionState extends State { + late final Node node; + late final BlockComponentContext blockComponentContext; + + Offset? globalPosition; + + @override + void initState() { + super.initState(); + + // copy the node to avoid the node in document being updated + node = widget.blockComponentContext.node.copyWith(); + blockComponentContext = BlockComponentContext( + widget.blockComponentContext.buildContext, + node, + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 10.0, right: 4.0), + child: Draggable( + data: node, + feedback: Opacity( + opacity: 0.7, + child: Material( + color: Colors.transparent, + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Provider.value( + value: context.read(), + child: widget.builder.build(blockComponentContext), + ), + ), + ), + ), + ), + onDragStarted: () { + debugPrint('onDragStarted'); + context.read().selectionService.removeDropTarget(); + }, + onDragUpdate: (details) { + context + .read() + .selectionService + .renderDropTargetForOffset(details.globalPosition); + + globalPosition = details.globalPosition; + }, + onDragEnd: (details) { + context.read().selectionService.removeDropTarget(); + + if (globalPosition == null) { + return; + } + + final data = context + .read() + .selectionService + .getDropTargetRenderData(globalPosition!); + final acceptedPath = data?.dropPath; + debugPrint('onDragEnd, acceptedPath($acceptedPath)'); + _moveNodeToNewPosition(node, acceptedPath); + }, + child: const Icon( + Icons.drag_indicator_rounded, + size: 18, + ), + ), + ); + } + + Future _moveNodeToNewPosition(Node node, Path? acceptedPath) async { + if (acceptedPath == null) { + debugPrint('acceptedPath is null'); + return; + } + + debugPrint('move node($node) to path($acceptedPath)'); + + final editorState = context.read(); + final transaction = editorState.transaction; + transaction.insertNode(acceptedPath, node.copyWith()); + transaction.deleteNode(widget.blockComponentContext.node); + await editorState.apply(transaction); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 32d7335cf..1007ce1c6 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -52,6 +52,12 @@ dependency_overrides: appflowy_editor: path: ../ + appflowy_editor_plugins: + git: + url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git + path: "packages/appflowy_editor_plugins" + ref: "b228456" + dev_dependencies: flutter_test: sdk: flutter diff --git a/lib/src/core/document/node.dart b/lib/src/core/document/node.dart index 2e8b46694..14e4fdee3 100644 --- a/lib/src/core/document/node.dart +++ b/lib/src/core/document/node.dart @@ -1,8 +1,9 @@ import 'dart:collection'; +import 'package:flutter/material.dart'; + import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; import 'package:nanoid/non_secure.dart'; abstract class NodeExternalValues { @@ -225,11 +226,7 @@ final class Node extends ChangeNotifier with LinkedListEntry { @override String toString() { - return '''Node(id: $id, - type: $type, - attributes: $attributes, - children: $children, - )'''; + return 'Node(id: $id, type: $type, attributes: $attributes, children: $children)'; } Delta? get delta { diff --git a/lib/src/editor/block_component/base_component/block_component_action_wrapper.dart b/lib/src/editor/block_component/base_component/block_component_action_wrapper.dart index 3095170e0..879a307ef 100644 --- a/lib/src/editor/block_component/base_component/block_component_action_wrapper.dart +++ b/lib/src/editor/block_component/base_component/block_component_action_wrapper.dart @@ -39,6 +39,23 @@ class _BlockComponentActionWrapperState } } + @override + void initState() { + super.initState(); + + if (forceShowBlockAction) { + alwaysShowActions = true; + showActionsNotifier.value = true; + } + } + + @override + void dispose() { + showActionsNotifier.dispose(); + + super.dispose(); + } + @override Widget build(BuildContext context) { return MouseRegion( diff --git a/lib/src/editor/editor_component/service/renderer/block_component_container.dart b/lib/src/editor/editor_component/service/renderer/block_component_container.dart index c4d982fb9..82c204850 100644 --- a/lib/src/editor/editor_component/service/renderer/block_component_container.dart +++ b/lib/src/editor/editor_component/service/renderer/block_component_container.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; /// 1. used to update the child widget when node is changed /// ~~2. used to show block component actions~~ /// 3. used to add the layer link to the child widget -class BlockComponentContainer extends StatefulWidget { +class BlockComponentContainer extends StatelessWidget { const BlockComponentContainer({ super.key, required this.configuration, @@ -17,29 +17,24 @@ class BlockComponentContainer extends StatefulWidget { final Node node; final BlockComponentConfiguration configuration; - final WidgetBuilder builder; - @override - State createState() => - BlockComponentContainerState(); -} - -class BlockComponentContainerState extends State { @override Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: widget.node, + final child = ChangeNotifierProvider.value( + value: node, child: Consumer( builder: (_, __, ___) { AppFlowyEditorLog.editor - .debug('node is rebuilding...: type: ${widget.node.type} '); + .debug('node is rebuilding...: type: ${node.type} '); return CompositedTransformTarget( - link: widget.node.layerLink, - child: widget.builder(context), + link: node.layerLink, + child: builder(context), ); }, ), ); + + return child; } } diff --git a/lib/src/editor/editor_component/service/renderer/block_component_service.dart b/lib/src/editor/editor_component/service/renderer/block_component_service.dart index 2975961f9..92b26f6c1 100644 --- a/lib/src/editor/editor_component/service/renderer/block_component_service.dart +++ b/lib/src/editor/editor_component/service/renderer/block_component_service.dart @@ -3,6 +3,10 @@ import 'package:flutter/material.dart'; const errorBlockComponentBuilderKey = 'errorBlockComponentBuilderKey'; +// this value is used to force show the block action. +// it is only for test now. +bool forceShowBlockAction = false; + typedef BlockActionBuilder = Widget Function( BlockComponentContext blockComponentContext, BlockComponentActionState state, 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 f5d55a3b0..6e0ed89af 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 @@ -498,13 +498,19 @@ class _DesktopSelectionServiceWidgetState final startRect = blockRect.topLeft; final endRect = blockRect.bottomLeft; - final startDistance = (startRect - offset).distance; - final endDistance = (endRect - offset).distance; + final renderBox = selectable.context.findRenderObject() as RenderBox; + final globalStartRect = renderBox.localToGlobal(startRect); + final globalEndRect = renderBox.localToGlobal(endRect); + + final topDistance = (globalStartRect - offset).distanceSquared; + final bottomDistance = (globalEndRect - offset).distanceSquared; + + final isCloserToStart = topDistance < bottomDistance; - final isCloserToStart = startDistance < endDistance; + final dropPath = isCloserToStart ? node?.path : node?.path.next; return DropTargetRenderData( - dropTarget: isCloserToStart ? node : node?.next ?? node, + dropPath: dropPath ?? node?.path, cursorNode: node, ); } diff --git a/lib/src/editor/editor_component/service/selection_service.dart b/lib/src/editor/editor_component/service/selection_service.dart index 66c0c4408..0a120268f 100644 --- a/lib/src/editor/editor_component/service/selection_service.dart +++ b/lib/src/editor/editor_component/service/selection_service.dart @@ -119,27 +119,33 @@ class SelectionGestureInterceptor { bool Function(DragEndDetails details)? canPanEnd; } -/// Data returned when calling [renderDropTargetForOffset] +/// Data returned when calling [AppFlowySelectionService.getDropTargetRenderData] /// -/// Includes the [Node] which the drop target is rendered for +/// Includes the position (path) which the drop target is rendered for /// and the [Node] which the cursor is directly hovering over. /// class DropTargetRenderData { - const DropTargetRenderData({this.dropTarget, this.cursorNode}); + const DropTargetRenderData({this.dropPath, this.cursorNode}); - /// The [Node] which the drop is rendered for, - /// this is also the [Node] in which any content should be + /// The path which the drop is rendered for, + /// this is the position in which any content should be /// inserted into. /// - final Node? dropTarget; + final List? dropPath; /// The [Node] which the cursor is directly hovering over, - /// this might be the same as [dropTarget] but might also - /// be another [Node] if the cursor is between two [Node]s. + /// this node __might__ be at same position as [dropPath] but might also + /// be another [Node] depending on distance to top/bottom of the [Node] to the + /// cursors offset. /// /// This is useful in case you want to cancel or pause the drop /// for specific [Node]s, in case they as example implement their /// own drop logic. /// final Node? cursorNode; + + @override + String toString() { + return 'DropTargetRenderData(dropPath: $dropPath, cursorNode: $cursorNode)'; + } }