feat: render drop target (#848)

* feat: expose method to render drop target

* feat: add style to drop target

* feat: add DropTargetRenderData for more concise control

* fix: split up rendering and data fetching

* chore: clean unused code

* chore: remove unused example code for testing
This commit is contained in:
Mathias Mogensen 2024-07-25 20:20:14 +02:00 • committed by GitHub
commit 819e579afd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 240 additions and 15 deletions

View file

@ -1,9 +1,11 @@
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:example/pages/desktop_editor.dart';
import 'package:example/pages/mobile_editor.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
class Editor extends StatefulWidget {
const Editor({

View file

@ -27,3 +27,4 @@ export 'style/default_text_span_decorator_for_attribute.dart';
// style
export 'style/editor_style.dart';
export 'style/mobile_text_span_decorator_for_attribute.dart';
export 'style/drop_target_style.dart';

View file

@ -1,10 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:provider/provider.dart';
import 'package:appflowy_editor/appflowy_editor.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:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:provider/provider.dart';
// workaround for the issue:
// the popover will grab the focus even if it's inside the editor
@ -40,6 +42,7 @@ class AppFlowyEditor extends StatefulWidget {
this.focusNode,
this.enableAutoComplete = false,
this.autoCompleteTextProvider,
this.dropTargetStyle,
}) : blockComponentBuilders =
blockComponentBuilders ?? standardBlockComponentBuilderMap,
characterShortcutEvents =
@ -177,6 +180,21 @@ class AppFlowyEditor extends StatefulWidget {
/// {@macro flutter.widgets.editableText.contentInsertionConfiguration}
final ContentInsertionConfiguration? contentInsertionConfiguration;
/// The style of the drop target.
///
/// Defaults to [AppFlowyDropTargetStyle].
///
/// The drop target is rendered in the [AppFlowyEditor] using the [DesktopSelectionService]
/// specifically the [renderDropTargetForOffset] method.
///
/// The drop target is a horizontal line that is drawn between the nearest nodes to the [offset].
///
/// Should call [removeDropTarget] to remove the line once the drop action is done.
///
/// only works on desktop.
///
final AppFlowyDropTargetStyle? dropTargetStyle;
@override
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
}
@ -283,6 +301,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
selectionColor: widget.editorStyle.selectionColor,
showMagnifier: widget.showMagnifier,
contextMenuItems: widget.contextMenuItems,
dropTargetStyle: widget.dropTargetStyle,
child: child,
);

View file

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/shared.dart';
import 'package:appflowy_editor/src/flutter/overlay.dart';
import 'package:appflowy_editor/src/service/selection/selection_gesture.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class DesktopSelectionServiceWidget extends StatefulWidget {
const DesktopSelectionServiceWidget({
@ -14,12 +15,14 @@ class DesktopSelectionServiceWidget extends StatefulWidget {
this.selectionColor = const Color(0xFF00BCF0),
this.contextMenuItems,
required this.child,
this.dropTargetStyle = const AppFlowyDropTargetStyle(),
});
final Widget child;
final Color cursorColor;
final Color selectionColor;
final List<List<ContextMenuItem>>? contextMenuItems;
final AppFlowyDropTargetStyle dropTargetStyle;
@override
State<DesktopSelectionServiceWidget> createState() =>
@ -50,6 +53,8 @@ class _DesktopSelectionServiceWidgetState
Position? _panStartPosition;
OverlayEntry? _dropTargetEntry;
late EditorState editorState = Provider.of<EditorState>(
context,
listen: false,
@ -83,7 +88,7 @@ class _DesktopSelectionServiceWidgetState
WidgetsBinding.instance.removeObserver(this);
editorState.selectionNotifier.removeListener(_updateSelection);
currentSelection.dispose();
removeDropTarget();
super.dispose();
}
@ -403,7 +408,7 @@ class _DesktopSelectionServiceWidgetState
);
_contextMenuAreas.add(contextMenu);
Overlay.of(context, rootOverlay: true)?.insert(contextMenu);
Overlay.of(context, rootOverlay: true).insert(contextMenu);
}
@override
@ -415,4 +420,89 @@ class _DesktopSelectionServiceWidgetState
void unregisterGestureInterceptor(String key) {
_interceptors.removeWhere((element) => element.key == key);
}
@override
void removeDropTarget() {
_dropTargetEntry?.remove();
_dropTargetEntry = null;
}
@override
void renderDropTargetForOffset(Offset offset) {
removeDropTarget();
final node = getNodeInOffset(offset);
final selectable = node?.selectable;
if (selectable == null) {
return;
}
final blockRect = selectable.getBlockRect();
final startRect = blockRect.topLeft;
final endRect = blockRect.bottomLeft;
final startDistance = (startRect - offset).distance;
final endDistance = (endRect - offset).distance;
final isCloserToStart = startDistance < endDistance;
_dropTargetEntry = OverlayEntry(
builder: (context) {
final overlayRenderBox =
Overlay.of(context).context.findRenderObject() as RenderBox;
final editorRenderBox =
selectable.context.findRenderObject() as RenderBox;
final editorOffset = editorRenderBox.localToGlobal(
Offset.zero,
ancestor: overlayRenderBox,
);
final indicatorTop =
(isCloserToStart ? startRect.dy : endRect.dy) + editorOffset.dy;
final width = blockRect.topRight.dx - startRect.dx;
return Positioned(
top: indicatorTop,
left: startRect.dx + editorOffset.dx,
child: Container(
height: widget.dropTargetStyle.height,
width: width,
margin: widget.dropTargetStyle.margin,
constraints: widget.dropTargetStyle.constraints,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(widget.dropTargetStyle.borderRadius),
color: widget.dropTargetStyle.color,
),
),
);
},
);
Overlay.of(context).insert(_dropTargetEntry!);
}
@override
DropTargetRenderData? getDropTargetRenderData(Offset offset) {
final node = getNodeInOffset(offset);
final selectable = node?.selectable;
if (selectable == null) {
return null;
}
final blockRect = selectable.getBlockRect();
final startRect = blockRect.topLeft;
final endRect = blockRect.bottomLeft;
final startDistance = (startRect - offset).distance;
final endDistance = (endRect - offset).distance;
final isCloserToStart = startDistance < endDistance;
return DropTargetRenderData(
dropTarget: isCloserToStart ? node : node?.next ?? node,
cursorNode: node,
);
}
}

View file

@ -1,6 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_magnifier.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/shared.dart';
@ -8,9 +13,6 @@ import 'package:appflowy_editor/src/render/selection/mobile_basic_handle.dart';
import 'package:appflowy_editor/src/render/selection/mobile_collapsed_handle.dart';
import 'package:appflowy_editor/src/render/selection/mobile_selection_handle.dart';
import 'package:appflowy_editor/src/service/selection/mobile_selection_gesture.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
/// only used in mobile
///
@ -825,4 +827,17 @@ class _MobileSelectionServiceWidgetState
}
return false;
}
@override
void removeDropTarget() {
// Do nothing on mobile
}
@override
void renderDropTargetForOffset(Offset offset) {
// Do nothing on mobile
}
@override
DropTargetRenderData? getDropTargetRenderData(Offset offset) => null;
}

View file

@ -82,6 +82,22 @@ abstract class AppFlowySelectionService {
DragEndDetails details,
MobileSelectionDragMode mode,
);
/// Draws a horizontal line between the nearest nodes to the [offset].
///
/// The [offset] must be under the global coordinate system.
///
/// Should call [removeDropTarget] to remove the line once drop is done.
///
void renderDropTargetForOffset(Offset offset);
/// Removes the horizontal line drawn by [renderDropTargetForOffset].
///
void removeDropTarget();
/// Returns the [DropTargetRenderData] for the [offset].
///
DropTargetRenderData? getDropTargetRenderData(Offset offset);
}
class SelectionGestureInterceptor {
@ -102,3 +118,28 @@ class SelectionGestureInterceptor {
bool Function(DragUpdateDetails details)? canPanUpdate;
bool Function(DragEndDetails details)? canPanEnd;
}
/// Data returned when calling [renderDropTargetForOffset]
///
/// Includes the [Node] 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});
/// The [Node] which the drop is rendered for,
/// this is also the [Node] in which any content should be
/// inserted into.
///
final Node? dropTarget;
/// 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 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;
}

View file

@ -1,8 +1,10 @@
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:provider/provider.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/desktop_selection_service.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:provider/provider.dart';
class SelectionServiceWidget extends StatefulWidget {
const SelectionServiceWidget({
@ -11,6 +13,7 @@ class SelectionServiceWidget extends StatefulWidget {
this.selectionColor = const Color.fromARGB(53, 111, 201, 231),
this.showMagnifier = true,
this.contextMenuItems,
this.dropTargetStyle,
required this.child,
});
@ -18,6 +21,7 @@ class SelectionServiceWidget extends StatefulWidget {
final Color cursorColor;
final Color selectionColor;
final List<List<ContextMenuItem>>? contextMenuItems;
final AppFlowyDropTargetStyle? dropTargetStyle;
/// Show the magnifier or not.
///
@ -45,6 +49,8 @@ class _SelectionServiceWidgetState extends State<SelectionServiceWidget>
cursorColor: widget.cursorColor,
selectionColor: widget.selectionColor,
contextMenuItems: widget.contextMenuItems,
dropTargetStyle:
widget.dropTargetStyle ?? const AppFlowyDropTargetStyle(),
child: widget.child,
);
}
@ -114,4 +120,15 @@ class _SelectionServiceWidgetState extends State<SelectionServiceWidget>
MobileSelectionDragMode mode,
) =>
forward.onPanEnd(details, mode);
@override
void removeDropTarget() => forward.removeDropTarget();
@override
void renderDropTargetForOffset(Offset offset) =>
forward.renderDropTargetForOffset(offset);
@override
DropTargetRenderData? getDropTargetRenderData(Offset offset) =>
forward.getDropTargetRenderData(offset);
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
/// Style for the Drop target which is rendered in the [AppFlowyEditor]
/// using the [DesktopSelectionService] specifically the [renderDropTargetForOffset] method.
///
class AppFlowyDropTargetStyle {
const AppFlowyDropTargetStyle({
this.margin,
this.constraints = const BoxConstraints(),
this.color,
this.borderRadius = 8,
this.height = 2,
});
/// The margin to apply to the drop target.
///
/// Useful if you want to add some padding around the drop target.
///
final EdgeInsets? margin;
/// Constraints of the drop target
///
/// Defaults to default [BoxConstraints]
///
final BoxConstraints constraints;
/// The color of the drop target (horizontal line)
///
/// Defaults to [ThemeData.colorScheme.primary]
///
final Color? color;
/// Defaults to 8
///
final double borderRadius;
/// Defaults to 2
///
final double height;
}