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:
parent
61d4363b46
commit
819e579afd
8 changed files with 240 additions and 15 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
40
lib/src/editor/editor_component/style/drop_target_style.dart
Normal file
40
lib/src/editor/editor_component/style/drop_target_style.dart
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue