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 <mathiasrieckm@gmail.com>
This commit is contained in:
Lucas.Xu 2024-09-12 20:46:55 +08:00 • committed by GitHub
commit 5d1d311a2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 301 additions and 33 deletions

View file

@ -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<HomePage> {
// 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,

View file

@ -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() {

View file

@ -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<DesktopEditor> {
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<DesktopEditor> {
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;
}

View file

@ -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<DragToReorderEditor> createState() => _DragToReorderEditorState();
}
class _DragToReorderEditorState extends State<DragToReorderEditor> {
late final EditorState editorState;
late final EditorStyle editorStyle;
late final Map<String, BlockComponentBuilder> 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<String, BlockComponentBuilder> _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<DragToReorderAction> createState() => _DragToReorderActionState();
}
class _DragToReorderActionState extends State<DragToReorderAction> {
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<Node>(
data: node,
feedback: Opacity(
opacity: 0.7,
child: Material(
color: Colors.transparent,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Provider.value(
value: context.read<EditorState>(),
child: widget.builder.build(blockComponentContext),
),
),
),
),
),
onDragStarted: () {
debugPrint('onDragStarted');
context.read<EditorState>().selectionService.removeDropTarget();
},
onDragUpdate: (details) {
context
.read<EditorState>()
.selectionService
.renderDropTargetForOffset(details.globalPosition);
globalPosition = details.globalPosition;
},
onDragEnd: (details) {
context.read<EditorState>().selectionService.removeDropTarget();
if (globalPosition == null) {
return;
}
final data = context
.read<EditorState>()
.selectionService
.getDropTargetRenderData(globalPosition!);
final acceptedPath = data?.dropPath;
debugPrint('onDragEnd, acceptedPath($acceptedPath)');
_moveNodeToNewPosition(node, acceptedPath);
},
child: const Icon(
Icons.drag_indicator_rounded,
size: 18,
),
),
);
}
Future<void> _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<EditorState>();
final transaction = editorState.transaction;
transaction.insertNode(acceptedPath, node.copyWith());
transaction.deleteNode(widget.blockComponentContext.node);
await editorState.apply(transaction);
}
}

View file

@ -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

View file

@ -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<Node> {
@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 {

View file

@ -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(

View file

@ -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<BlockComponentContainer> createState() =>
BlockComponentContainerState();
}
class BlockComponentContainerState extends State<BlockComponentContainer> {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<Node>.value(
value: widget.node,
final child = ChangeNotifierProvider<Node>.value(
value: node,
child: Consumer<Node>(
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;
}
}

View file

@ -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,

View file

@ -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 isCloserToStart = startDistance < endDistance;
final topDistance = (globalStartRect - offset).distanceSquared;
final bottomDistance = (globalEndRect - offset).distanceSquared;
final isCloserToStart = topDistance < bottomDistance;
final dropPath = isCloserToStart ? node?.path : node?.path.next;
return DropTargetRenderData(
dropTarget: isCloserToStart ? node : node?.next ?? node,
dropPath: dropPath ?? node?.path,
cursorNode: node,
);
}

View file

@ -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<int>? 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)';
}
}