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:
parent
200b572fa3
commit
5d1d311a2c
11 changed files with 301 additions and 33 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
213
example/lib/pages/drag_to_reorder_editor.dart
Normal file
213
example/lib/pages/drag_to_reorder_editor.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue