From 046faf38802448aa7841089d6084f92e0e0ac5f8 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 10 Aug 2022 15:07:30 +0800 Subject: [PATCH 1/4] fix: unexpect behaviour when pressing enter key --- .../enter_without_shift_in_text_node_handler.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart index 136c06ab14..39c74d2eab 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart @@ -67,7 +67,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler = // If selection is collapsed and position.start.offset == 0, // insert a empty text node before. if (selection.isCollapsed && selection.start.offset == 0) { - if (textNode.toRawString().isEmpty) { + if (textNode.toRawString().isEmpty && textNode.subtype != null) { final afterSelection = Selection.collapsed( Position(path: textNode.path, offset: 0), ); From ae0012ba370efa271efd3f5df6a6983b665b29ac Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 10 Aug 2022 15:30:19 +0800 Subject: [PATCH 2/4] docs: documentation for selection_service --- .../flowy_editor/lib/flowy_editor.dart | 13 +- .../lib/src/document/selection.dart | 45 ++++--- .../lib/src/service/editor_service.dart | 24 +--- .../default_key_event_handlers.dart | 22 ++++ .../lib/src/service/selection_service.dart | 119 ++++++------------ .../flowy_editor/lib/src/service/service.dart | 3 +- 6 files changed, 103 insertions(+), 123 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart index a767c08407..9e75790ea3 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -1,15 +1,16 @@ library flowy_editor; -export 'src/document/state_tree.dart'; export 'src/document/node.dart'; export 'src/document/path.dart'; +export 'src/document/position.dart'; +export 'src/document/selection.dart'; +export 'src/document/state_tree.dart'; export 'src/document/text_delta.dart'; -export 'src/render/selection/selectable.dart'; +export 'src/editor_state.dart'; +export 'src/operation/operation.dart'; export 'src/operation/transaction.dart'; export 'src/operation/transaction_builder.dart'; -export 'src/operation/operation.dart'; -export 'src/editor_state.dart'; +export 'src/render/selection/selectable.dart'; export 'src/service/editor_service.dart'; -export 'src/document/selection.dart'; -export 'src/document/position.dart'; export 'src/service/render_plugin_service.dart'; +export 'src/service/service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/document/selection.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/document/selection.dart index 692cc8c91b..7cf305b131 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/document/selection.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/document/selection.dart @@ -2,15 +2,26 @@ import 'package:flowy_editor/src/document/path.dart'; import 'package:flowy_editor/src/document/position.dart'; import 'package:flowy_editor/src/extensions/path_extensions.dart'; +/// Selection represents the selected area or the cursor area in the editor. +/// +/// [Selection] is directional. +/// +/// 1. forward,the end position is before the start position. +/// 2. backward, the end position is after the start position. +/// 3. collapsed, the end position is equal to the start position. class Selection { - final Position start; - final Position end; - + /// Create a selection with [start], [end]. Selection({ required this.start, required this.end, }); + /// Create a selection with [Path], [startOffset] and [endOffset]. + /// + /// The [endOffset] is optional. + /// + /// This constructor will return a collapsed [Selection] if [endOffset] is null. + /// Selection.single({ required Path path, required int startOffset, @@ -18,10 +29,21 @@ class Selection { }) : start = Position(path: path, offset: startOffset), end = Position(path: path, offset: endOffset ?? startOffset); + /// Create a collapsed selection with [position]. Selection.collapsed(Position position) : start = position, end = position; + final Position start; + final Position end; + + bool get isCollapsed => start == end; + bool get isSingle => pathEquals(start.path, end.path); + bool get isForward => + start.path >= end.path && !pathEquals(start.path, end.path); + bool get isBackward => + start.path <= end.path && !pathEquals(start.path, end.path); + Selection collapse({bool atStart = false}) { if (atStart) { return Selection(start: start, end: start); @@ -30,13 +52,6 @@ class Selection { } } - bool get isCollapsed => start == end; - bool get isSingle => pathEquals(start.path, end.path); - bool get isUpward => - start.path >= end.path && !pathEquals(start.path, end.path); - bool get isDownward => - start.path <= end.path && !pathEquals(start.path, end.path); - Selection copyWith({Position? start, Position? end}) { return Selection( start: start ?? this.start, @@ -46,13 +61,10 @@ class Selection { Selection copy() => Selection(start: start, end: end); - @override - String toString() => '[Selection] start = $start, end = $end'; - Map toJson() { return { - "start": start.toJson(), - "end": end.toJson(), + 'start': start.toJson(), + 'end': end.toJson(), }; } @@ -69,4 +81,7 @@ class Selection { @override int get hashCode => Object.hash(start, end); + + @override + String toString() => '[Selection] start = $start, end = $end'; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/editor_service.dart index b596f83c2a..b2d649d246 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/editor_service.dart @@ -1,3 +1,4 @@ +import 'package:flowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart'; import 'package:flutter/material.dart'; import 'package:flowy_editor/src/editor_state.dart'; @@ -9,15 +10,6 @@ import 'package:flowy_editor/src/render/rich_text/number_list_text.dart'; import 'package:flowy_editor/src/render/rich_text/quoted_text.dart'; import 'package:flowy_editor/src/render/rich_text/rich_text.dart'; import 'package:flowy_editor/src/service/input_service.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/delete_nodes_handler.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/delete_text_handler.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; -import 'package:flowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart'; import 'package:flowy_editor/src/service/keyboard_service.dart'; import 'package:flowy_editor/src/service/render_plugin_service.dart'; import 'package:flowy_editor/src/service/scroll_service.dart'; @@ -34,18 +26,6 @@ NodeWidgetBuilders defaultBuilders = { 'text/quote': QuotedTextNodeWidgetBuilder(), }; -List defaultKeyEventHandler = [ - deleteTextHandler, - slashShortcutHandler, - flowyDeleteNodesHandler, - arrowKeysHandler, - copyPasteKeysHandler, - redoUndoKeysHandler, - enterWithoutShiftInTextNodesHandler, - updateTextStyleByCommandXHandler, - whiteSpaceHandler, -]; - class FlowyEditor extends StatefulWidget { const FlowyEditor({ Key? key, @@ -98,7 +78,7 @@ class _FlowyEditorState extends State { child: FlowyKeyboard( key: editorState.service.keyboardServiceKey, handlers: [ - ...defaultKeyEventHandler, + ...defaultKeyEventHandlers, ...widget.keyEventHandlers, ], editorState: editorState, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart new file mode 100644 index 0000000000..d03211461c --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart @@ -0,0 +1,22 @@ +import 'package:flowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/delete_nodes_handler.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/delete_text_handler.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; +import 'package:flowy_editor/src/service/keyboard_service.dart'; + +List defaultKeyEventHandlers = [ + deleteTextHandler, + slashShortcutHandler, + flowyDeleteNodesHandler, + arrowKeysHandler, + copyPasteKeysHandler, + redoUndoKeysHandler, + enterWithoutShiftInTextNodesHandler, + updateTextStyleByCommandXHandler, + whiteSpaceHandler, +]; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart index 19d355847f..368bac9c92 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart @@ -15,67 +15,60 @@ import 'package:flowy_editor/src/render/selection/cursor_widget.dart'; import 'package:flowy_editor/src/render/selection/selectable.dart'; import 'package:flowy_editor/src/render/selection/selection_widget.dart'; -/// Process selection and cursor +/// [FlowySelectionService] is responsible for processing +/// the [Selection] changes and updates. +/// +/// Usually, this service can be obtained by the following code. +/// ```dart +/// final selectionService = editorState.service.selectionService; +/// +/// /** get current selection value*/ +/// final selection = selectionService.currentSelection.value; +/// +/// /** get current selected nodes*/ +/// final nodes = selectionService.currentSelectedNodes; +/// ``` +/// mixin FlowySelectionService on State { - /// Returns the current [Selection] + /// The current [Selection] in editor. + /// + /// The value is null if there is no nodes are selected. ValueNotifier get currentSelection; - /// Returns the current selected [Node]s. + /// The current selected [Node]s in editor. /// - /// The order of the return is determined according to the selected order. + /// The order of the result is determined according to the [currentSelection]. + /// The result are ordered from back to front if the selection is forward. + /// The result are ordered from front to back if the selection is backward. + /// + /// For example, Here is an array of selected nodes, [n1, n2, n3]. + /// The result will be [n3, n2, n1] if the selection is forward, + /// and [n1, n2, n3] if the selection is backward. + /// + /// Returns empty result if there is no nodes are selected. List get currentSelectedNodes; - /// Update the selection or cursor. + /// Updates the selection. /// - /// If selection is collapsed, this method will - /// update the position of the cursor. - /// Otherwise, will update the selection. + /// The editor will update selection area and popup list area + /// if the [selection] is not collapsed, + /// otherwise, will update the cursor area. void updateSelection(Selection selection); - /// Clear the selection or cursor. + /// Clears the selection area, cursor area and the popup list area. void clearSelection(); - /// ------------------ Selection ------------------------ - - List rects(); - - Position? hitTest(Offset? offset); - - /// + /// Returns the [Node]s in [Selection]. List getNodesInSelection(Selection selection); - /// ------------------ Selection ------------------------ - - /// ------------------ Offset ------------------------ - - /// Return the [Node] or [Null] in single selection. + /// Returns the [Node] containing to the offset. /// - /// [offset] is under the global coordinate system. + /// [offset] must be under the global coordinate system. Node? getNodeInOffset(Offset offset); - /// Returns selected [Node]s. Empty list would be returned - /// if no nodes are in range. - /// - /// - /// [start] and [end] are under the global coordinate system. - /// - List getNodeInRange(Offset start, Offset end); - - /// Return [bool] to identify the [Node] is in Range or not. - /// - /// [start] and [end] are under the global coordinate system. - bool isNodeInRange( - Node node, - Offset start, - Offset end, - ); - - /// Return [bool] to identify the [Node] contains [Offset] or not. - /// - /// [offset] is under the global coordinate system. - bool isNodeInOffset(Node node, Offset offset); - - /// ------------------ Offset ------------------------ + // TODO: need to be documented. + List rects(); + Position? hitTest(Offset? offset); } class FlowySelection extends StatefulWidget { @@ -207,36 +200,6 @@ class _FlowySelectionState extends State return _lowerBoundInDocument(offset); } - @override - List getNodeInRange(Offset start, Offset end) { - final startNode = _lowerBoundInDocument(start); - final endNode = _upperBoundInDocument(end); - return NodeIterator(editorState.document, startNode, endNode).toList(); - } - - @override - bool isNodeInOffset(Node node, Offset offset) { - final renderBox = node.renderBox; - if (renderBox != null) { - final boxOffset = renderBox.localToGlobal(Offset.zero); - final boxRect = boxOffset & renderBox.size; - return boxRect.contains(offset); - } - return false; - } - - @override - bool isNodeInRange(Node node, Offset start, Offset end) { - final renderBox = node.renderBox; - if (renderBox != null) { - final rect = Rect.fromPoints(start, end); - final boxOffset = renderBox.localToGlobal(Offset.zero); - final boxRect = boxOffset & renderBox.size; - return rect.overlaps(boxRect); - } - return false; - } - void _onDoubleTapDown(TapDownDetails details) { final offset = details.globalPosition; final node = getNodeInOffset(offset); @@ -395,13 +358,13 @@ class _FlowySelectionState extends State // text: ghijkl // text: mn>opqr if (index == 0) { - if (selection.isDownward) { + if (selection.isBackward) { newSelection = selection.copyWith(end: selectable.end()); } else { newSelection = selection.copyWith(start: selectable.start()); } } else if (index == nodes.length - 1) { - if (selection.isDownward) { + if (selection.isBackward) { newSelection = selection.copyWith(start: selectable.start()); } else { newSelection = selection.copyWith(end: selectable.end()); @@ -498,7 +461,7 @@ class _FlowySelectionState extends State /// TODO: It is necessary to calculate the relative speed /// according to the gap and move forward more gently. - final distance = 10.0; + const distance = 10.0; if (offset.dy <= topLimit && !isDownward) { // up editorState.service.scrollService?.scrollTo(dy - distance); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/service.dart index cdf137bee8..fc2e4e3f31 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/service.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; - import 'package:flowy_editor/src/service/keyboard_service.dart'; import 'package:flowy_editor/src/service/render_plugin_service.dart'; import 'package:flowy_editor/src/service/scroll_service.dart'; import 'package:flowy_editor/src/service/selection_service.dart'; import 'package:flowy_editor/src/service/toolbar_service.dart'; +import 'package:flutter/material.dart'; class FlowyService { // selection service From 1eec97c761e75774123f6f995e3c44f2d89fe1ce Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 10 Aug 2022 15:56:52 +0800 Subject: [PATCH 3/4] chore: refactor the service type --- .../app_flowy/packages/flowy_editor/lib/flowy_editor.dart | 4 ++++ .../packages/flowy_editor/lib/src/service/input_service.dart | 5 ++--- .../flowy_editor/lib/src/service/keyboard_service.dart | 4 ++-- .../flowy_editor/lib/src/service/scroll_service.dart | 5 +++-- .../flowy_editor/lib/src/service/selection_service.dart | 5 +++-- .../flowy_editor/lib/src/service/toolbar_service.dart | 5 +++-- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart index 9e75790ea3..418b4d7ce0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -14,3 +14,7 @@ export 'src/render/selection/selectable.dart'; export 'src/service/editor_service.dart'; export 'src/service/render_plugin_service.dart'; export 'src/service/service.dart'; +export 'src/service/selection_service.dart'; +export 'src/service/scroll_service.dart'; +export 'src/service/keyboard_service.dart'; +export 'src/service/input_service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/input_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/input_service.dart index c52a411905..da755ab346 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/input_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/input_service.dart @@ -7,7 +7,7 @@ import 'package:flowy_editor/src/editor_state.dart'; import 'package:flowy_editor/src/extensions/node_extensions.dart'; import 'package:flowy_editor/src/operation/transaction_builder.dart'; -mixin FlowyInputService { +abstract class FlowyInputService { void attach(TextEditingValue textEditingValue); void apply(List deltas); void close(); @@ -29,8 +29,7 @@ class FlowyInput extends StatefulWidget { } class _FlowyInputState extends State - with FlowyInputService - implements DeltaTextInputClient { + implements FlowyInputService, DeltaTextInputClient { TextInputConnection? _textInputConnection; TextRange? _composingTextRange; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart index 81ce8348a4..ef8165049b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; -mixin FlowyKeyboardService on State { +abstract class FlowyKeyboardService { void enable(); void disable(); } @@ -31,7 +31,7 @@ class FlowyKeyboard extends StatefulWidget { } class _FlowyKeyboardState extends State - with FlowyKeyboardService { + implements FlowyKeyboardService { final FocusNode _focusNode = FocusNode(debugLabel: 'flowy_keyboard_service'); bool isFocus = true; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/scroll_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/scroll_service.dart index af48a78c49..5201a18942 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/scroll_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/scroll_service.dart @@ -1,7 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -mixin FlowyScrollService on State { +abstract class FlowyScrollService { double get dy; void scrollTo(double dy); @@ -22,7 +22,8 @@ class FlowyScroll extends StatefulWidget { State createState() => _FlowyScrollState(); } -class _FlowyScrollState extends State with FlowyScrollService { +class _FlowyScrollState extends State + implements FlowyScrollService { final _scrollController = ScrollController(); final _scrollViewKey = GlobalKey(); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart index 368bac9c92..56bcb07cd1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart @@ -29,7 +29,7 @@ import 'package:flowy_editor/src/render/selection/selection_widget.dart'; /// final nodes = selectionService.currentSelectedNodes; /// ``` /// -mixin FlowySelectionService on State { +abstract class FlowySelectionService { /// The current [Selection] in editor. /// /// The value is null if there is no nodes are selected. @@ -90,7 +90,8 @@ class FlowySelection extends StatefulWidget { } class _FlowySelectionState extends State - with FlowySelectionService, WidgetsBindingObserver { + with WidgetsBindingObserver + implements FlowySelectionService { final _cursorKey = GlobalKey(debugLabel: 'cursor'); final List _selectionOverlays = []; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/toolbar_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/toolbar_service.dart index a45fe8b778..aaf52bc20c 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/toolbar_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/toolbar_service.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/src/render/selection/toolbar_widget.dart'; -mixin FlowyToolbarService { +abstract class FlowyToolbarService { /// Show the toolbar widget beside the offset. void showInOffset(Offset offset, LayerLink layerLink); @@ -25,7 +25,8 @@ class FlowyToolbar extends StatefulWidget { State createState() => _FlowyToolbarState(); } -class _FlowyToolbarState extends State with FlowyToolbarService { +class _FlowyToolbarState extends State + implements FlowyToolbarService { OverlayEntry? _toolbarOverlay; @override From 50f8e1f5d021e9444727783da0be1517ac3216d8 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 10 Aug 2022 20:04:06 +0800 Subject: [PATCH 4/4] feat: implement automatically wrap when the selection changes --- .../lib/src/document/selection.dart | 6 +- .../src/render/rich_text/flowy_rich_text.dart | 2 +- .../arrow_keys_handler.dart | 9 +- .../service/selection/selection_gesture.dart | 113 +++ .../lib/src/service/selection_service.dart | 713 ++++++++---------- 5 files changed, 417 insertions(+), 426 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/src/service/selection/selection_gesture.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/document/selection.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/document/selection.dart index 7cf305b131..68aecba8fc 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/document/selection.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/document/selection.dart @@ -40,9 +40,11 @@ class Selection { bool get isCollapsed => start == end; bool get isSingle => pathEquals(start.path, end.path); bool get isForward => - start.path >= end.path && !pathEquals(start.path, end.path); + (start.path >= end.path && !pathEquals(start.path, end.path)) || + (isSingle && start.offset > end.offset); bool get isBackward => - start.path <= end.path && !pathEquals(start.path, end.path); + (start.path <= end.path && !pathEquals(start.path, end.path)) || + (isSingle && start.offset < end.offset); Selection collapse({bool atStart = false}) { if (atStart) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart index 350a5c71e6..fb0009ad73 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart @@ -59,7 +59,7 @@ class _FlowyRichTextState extends State with Selectable { @override Position end() => Position( - path: widget.textNode.path, offset: widget.textNode.toRawString().length); + path: widget.textNode.path, offset: widget.textNode.delta.length); @override Rect? getCursorRectInPosition(Position position) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart index 83243d2dc7..d4ddd8436c 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -1,5 +1,4 @@ import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/src/service/keyboard_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -41,25 +40,25 @@ extension on Position { } Position? _goUp(EditorState editorState) { - final rects = editorState.service.selectionService.rects(); + final rects = editorState.service.selectionService.selectionRects; if (rects.isEmpty) { return null; } final first = rects.first; final firstOffset = Offset(first.left, first.top); final hitOffset = firstOffset - Offset(0, first.height * 0.5); - return editorState.service.selectionService.hitTest(hitOffset); + return editorState.service.selectionService.getPositionInOffset(hitOffset); } Position? _goDown(EditorState editorState) { - final rects = editorState.service.selectionService.rects(); + final rects = editorState.service.selectionService.selectionRects; if (rects.isEmpty) { return null; } final first = rects.last; final firstOffset = Offset(first.right, first.bottom); final hitOffset = firstOffset + Offset(0, first.height * 0.5); - return editorState.service.selectionService.hitTest(hitOffset); + return editorState.service.selectionService.getPositionInOffset(hitOffset); } KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection/selection_gesture.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection/selection_gesture.dart new file mode 100644 index 0000000000..11a6326d26 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection/selection_gesture.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer] +/// for a while. So we need to implement our own GestureDetector. +@immutable +class SelectionGestureDetector extends StatefulWidget { + const SelectionGestureDetector({ + Key? key, + this.child, + this.onTapDown, + this.onDoubleTapDown, + this.onTripleTapDown, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd, + }) : super(key: key); + + @override + State createState() => + SelectionGestureDetectorState(); + + final Widget? child; + + final GestureTapDownCallback? onTapDown; + final GestureTapDownCallback? onDoubleTapDown; + final GestureTapDownCallback? onTripleTapDown; + final GestureDragStartCallback? onPanStart; + final GestureDragUpdateCallback? onPanUpdate; + final GestureDragEndCallback? onPanEnd; +} + +class SelectionGestureDetectorState extends State { + bool _isDoubleTap = false; + Timer? _doubleTapTimer; + int _tripleTabCount = 0; + Timer? _tripleTabTimer; + + final kTripleTapTimeout = const Duration(milliseconds: 500); + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (recognizer) { + recognizer + ..onStart = widget.onPanStart + ..onUpdate = widget.onPanUpdate + ..onEnd = widget.onPanEnd; + }, + ), + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (recognizer) { + recognizer.onTapDown = _tapDownDelegate; + }, + ), + }, + child: widget.child, + ); + } + + _tapDownDelegate(TapDownDetails tapDownDetails) { + if (_tripleTabCount == 2) { + _tripleTabCount = 0; + _tripleTabTimer?.cancel(); + _tripleTabTimer = null; + if (widget.onTripleTapDown != null) { + widget.onTripleTapDown!(tapDownDetails); + } + } else if (_isDoubleTap) { + _isDoubleTap = false; + _doubleTapTimer?.cancel(); + _doubleTapTimer = null; + if (widget.onDoubleTapDown != null) { + widget.onDoubleTapDown!(tapDownDetails); + } + _tripleTabCount++; + } else { + if (widget.onTapDown != null) { + widget.onTapDown!(tapDownDetails); + } + + _isDoubleTap = true; + _doubleTapTimer?.cancel(); + _doubleTapTimer = Timer(kDoubleTapTimeout, () { + _isDoubleTap = false; + _doubleTapTimer = null; + }); + + _tripleTabCount = 1; + _tripleTabTimer?.cancel(); + _tripleTabTimer = Timer(kTripleTapTimeout, () { + _tripleTabCount = 0; + _tripleTabTimer = null; + }); + } + } + + @override + void dispose() { + _doubleTapTimer?.cancel(); + _tripleTabTimer?.cancel(); + super.dispose(); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart index 56bcb07cd1..4a4fd6002c 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/selection_service.dart @@ -1,19 +1,18 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flowy_editor/src/document/node.dart'; import 'package:flowy_editor/src/document/node_iterator.dart'; import 'package:flowy_editor/src/document/position.dart'; import 'package:flowy_editor/src/document/selection.dart'; -import 'package:flowy_editor/src/document/state_tree.dart'; import 'package:flowy_editor/src/editor_state.dart'; import 'package:flowy_editor/src/extensions/node_extensions.dart'; +import 'package:flowy_editor/src/extensions/object_extensions.dart'; +import 'package:flowy_editor/src/extensions/path_extensions.dart'; import 'package:flowy_editor/src/render/selection/cursor_widget.dart'; import 'package:flowy_editor/src/render/selection/selectable.dart'; import 'package:flowy_editor/src/render/selection/selection_widget.dart'; +import 'package:flowy_editor/src/service/selection/selection_gesture.dart'; /// [FlowySelectionService] is responsible for processing /// the [Selection] changes and updates. @@ -50,7 +49,7 @@ abstract class FlowySelectionService { /// Updates the selection. /// - /// The editor will update selection area and popup list area + /// The editor will update selection area and toolbar area /// if the [selection] is not collapsed, /// otherwise, will update the cursor area. void updateSelection(Selection selection); @@ -61,14 +60,20 @@ abstract class FlowySelectionService { /// Returns the [Node]s in [Selection]. List getNodesInSelection(Selection selection); - /// Returns the [Node] containing to the offset. + /// Returns the [Node] containing to the [offset]. /// /// [offset] must be under the global coordinate system. Node? getNodeInOffset(Offset offset); - // TODO: need to be documented. - List rects(); - Position? hitTest(Offset? offset); + /// Returns the [Position] closest to the [offset]. + /// + /// Returns null if there is no nodes are selected. + /// + /// [offset] must be under the global coordinate system. + Position? getPositionInOffset(Offset offset); + + /// The current selection areas's rect in editor. + List get selectionRects; } class FlowySelection extends StatefulWidget { @@ -94,38 +99,25 @@ class _FlowySelectionState extends State implements FlowySelectionService { final _cursorKey = GlobalKey(debugLabel: 'cursor'); - final List _selectionOverlays = []; - final List _cursorOverlays = []; + @override + final List selectionRects = []; + final List _selectionAreas = []; + final List _cursorAreas = []; + OverlayEntry? _debugOverlay; - /// [Pan] and [Tap] must be mutually exclusive. /// Pan - Offset? panStartOffset; - double? panStartScrollDy; - Offset? panEndOffset; - - /// Tap - Offset? tapOffset; - - final List _rects = []; + Offset? _panStartOffset; + double? _panStartScrollDy; EditorState get editorState => widget.editorState; - @override - ValueNotifier currentSelection = ValueNotifier(null); - - @override - List currentSelectedNodes = []; - - @override - List getNodesInSelection(Selection selection) => - _selectedNodesInSelection(editorState.document, selection); - @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + currentSelection.addListener(_onSelectionChange); } @override @@ -142,13 +134,14 @@ class _FlowySelectionState extends State void dispose() { clearSelection(); WidgetsBinding.instance.removeObserver(this); + currentSelection.removeListener(_onSelectionChange); super.dispose(); } @override Widget build(BuildContext context) { - return _SelectionGestureDetector( + return SelectionGestureDetector( onPanStart: _onPanStart, onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, @@ -160,23 +153,48 @@ class _FlowySelectionState extends State } @override - List rects() { - return _rects; + ValueNotifier currentSelection = ValueNotifier(null); + + @override + List currentSelectedNodes = []; + + @override + List getNodesInSelection(Selection selection) { + final start = + selection.isBackward ? selection.start.path : selection.end.path; + final end = + selection.isBackward ? selection.end.path : selection.start.path; + assert(start <= end); + final startNode = editorState.document.nodeAtPath(start); + final endNode = editorState.document.nodeAtPath(end); + if (startNode != null && endNode != null) { + final nodes = + NodeIterator(editorState.document, startNode, endNode).toList(); + if (selection.isBackward) { + return nodes; + } else { + return nodes.reversed.toList(growable: false); + } + } + return []; } @override void updateSelection(Selection selection) { - _rects.clear(); + selectionRects.clear(); clearSelection(); - // cursor if (selection.isCollapsed) { - debugPrint('Update cursor'); - _updateCursor(selection.start); + /// updates cursor area. + debugPrint('updating cursor'); + _updateCursorAreas(selection.start); } else { - debugPrint('Update selection'); - _updateSelection(selection); + // updates selection area. + debugPrint('updating selection'); + _updateSelectionAreas(selection); } + + currentSelection.value = selection; } @override @@ -184,194 +202,172 @@ class _FlowySelectionState extends State currentSelectedNodes = []; currentSelection.value = null; - // clear selection - _selectionOverlays + // clear selection areas + _selectionAreas ..forEach((overlay) => overlay.remove()) ..clear(); - // clear cursors - _cursorOverlays + // clear cursor areas + _cursorAreas ..forEach((overlay) => overlay.remove()) ..clear(); - // clear toolbar + // hide toolbar editorState.service.toolbarService?.hide(); } @override Node? getNodeInOffset(Offset offset) { - return _lowerBoundInDocument(offset); - } - - void _onDoubleTapDown(TapDownDetails details) { - final offset = details.globalPosition; - final node = getNodeInOffset(offset); - if (node == null) { - editorState.updateCursorSelection(null); - return; - } - final selectable = node.selectable; - if (selectable == null) { - editorState.updateCursorSelection(null); - return; - } - editorState - .updateCursorSelection(selectable.getWorldBoundaryInOffset(offset)); - } - - void _onTripleTapDown(TapDownDetails details) { - final offset = details.globalPosition; - final node = getNodeInOffset(offset); - if (node == null) { - editorState.updateCursorSelection(null); - return; - } - Selection selection; - if (node is TextNode) { - final textLen = node.delta.length; - selection = Selection( - start: Position(path: node.path, offset: 0), - end: Position(path: node.path, offset: textLen)); - } else { - selection = Selection.collapsed(Position(path: node.path, offset: 0)); - } - editorState.updateCursorSelection(selection); - } - - void _onTapDown(TapDownDetails details) { - // clear old state. - panStartOffset = null; - panEndOffset = null; - - tapOffset = details.globalPosition; - - final position = hitTest(tapOffset); - if (position == null) { - return; - } - final selection = Selection.collapsed(position); - editorState.updateCursorSelection(selection); - - editorState.service.keyboardService?.enable(); - editorState.service.scrollService?.enable(); + final sortedNodes = + editorState.document.root.children.toList(growable: false); + return _getNodeInOffset( + sortedNodes, + offset, + 0, + sortedNodes.length - 1, + ); } @override - Position? hitTest(Offset? offset) { - if (offset == null) { - editorState.updateCursorSelection(null); - return null; - } + Position? getPositionInOffset(Offset offset) { final node = getNodeInOffset(offset); - if (node == null) { - editorState.updateCursorSelection(null); - return null; - } - final selectable = node.selectable; + final selectable = node?.selectable; if (selectable == null) { - editorState.updateCursorSelection(null); + clearSelection(); return null; } return selectable.getPositionInOffset(offset); } - void _onPanStart(DragStartDetails details) { + void _onTapDown(TapDownDetails details) { // clear old state. - panEndOffset = null; - tapOffset = null; + _panStartOffset = null; + + final position = getPositionInOffset(details.globalPosition); + if (position == null) { + return; + } + final selection = Selection.collapsed(position); + updateSelection(selection); + + _enableInteraction(); + + _showDebugLayerIfNeeded(offset: details.globalPosition); + } + + void _onDoubleTapDown(TapDownDetails details) { + final offset = details.globalPosition; + final node = getNodeInOffset(offset); + final selection = node?.selectable?.getWorldBoundaryInOffset(offset); + if (selection == null) { + clearSelection(); + return; + } + updateSelection(selection); + + _enableInteraction(); + } + + void _onTripleTapDown(TapDownDetails details) { + final offset = details.globalPosition; + final node = getNodeInOffset(offset); + final selectable = node?.selectable; + if (selectable == null) { + clearSelection(); + return; + } + Selection selection = Selection( + start: selectable.start(), + end: selectable.end(), + ); + updateSelection(selection); + + _enableInteraction(); + } + + void _onPanStart(DragStartDetails details) { clearSelection(); - panStartOffset = details.globalPosition; - panStartScrollDy = editorState.service.scrollService?.dy; + _panStartOffset = details.globalPosition; + _panStartScrollDy = editorState.service.scrollService?.dy; - debugPrint('[_onPanStart] panStartOffset = $panStartOffset'); + _enableInteraction(); } void _onPanUpdate(DragUpdateDetails details) { - if (panStartOffset == null || panStartScrollDy == null) { + if (_panStartOffset == null || _panStartScrollDy == null) { return; } - editorState.service.keyboardService?.enable(); - editorState.service.scrollService?.enable(); + _enableInteraction(); - panEndOffset = details.globalPosition; + final panEndOffset = details.globalPosition; final dy = editorState.service.scrollService?.dy; - var panStartOffsetWithScrollDyGap = panStartOffset!; - if (dy != null) { - panStartOffsetWithScrollDyGap = - panStartOffsetWithScrollDyGap.translate(0, panStartScrollDy! - dy); - } + final panStartOffset = dy == null + ? _panStartOffset! + : _panStartOffset!.translate(0, _panStartScrollDy! - dy); - final first = - _lowerBoundInDocument(panStartOffsetWithScrollDyGap).selectable; - final last = _upperBoundInDocument(panEndOffset!).selectable; + final first = getNodeInOffset(panStartOffset)?.selectable; + final last = getNodeInOffset(panEndOffset)?.selectable; // compute the selection in range. if (first != null && last != null) { - bool isDownward; - if (first == last) { - isDownward = panStartOffsetWithScrollDyGap.dx < panEndOffset!.dx; - } else { - isDownward = panStartOffsetWithScrollDyGap.dy < panEndOffset!.dy; - } - final start = first - .getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!) - .start; - final end = last - .getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!) - .end; - final selection = Selection( - start: isDownward ? start : end, end: isDownward ? end : start); + bool isDownward = (identical(first, last)) + ? panStartOffset.dx < panEndOffset.dx + : panStartOffset.dy < panEndOffset.dy; + final start = + first.getSelectionInRange(panStartOffset, panEndOffset).start; + final end = last.getSelectionInRange(panStartOffset, panEndOffset).end; + final selection = Selection(start: start, end: end); debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection'); - editorState.updateCursorSelection(selection); - - _scrollUpOrDownIfNeeded(panEndOffset!, isDownward); + updateSelection(selection); } - _showDebugLayerIfNeeded(); + _showDebugLayerIfNeeded(offset: panEndOffset); } void _onPanEnd(DragEndDetails details) { // do nothing } - void _updateSelection(Selection selection) { - final nodes = _selectedNodesInSelection(editorState.document, selection); + void _updateSelectionAreas(Selection selection) { + final nodes = getNodesInSelection(selection); currentSelectedNodes = nodes; - currentSelection.value = selection; + // TODO: need to be refactored. Rect? topmostRect; LayerLink? layerLink; - var index = 0; - for (final node in nodes) { + final backwardNodes = + selection.isBackward ? nodes : nodes.reversed.toList(growable: false); + final backwardSelection = selection.isBackward + ? selection + : selection.copyWith(start: selection.end, end: selection.start); + assert(backwardSelection.isBackward); + + for (var i = 0; i < backwardNodes.length; i++) { + final node = backwardNodes[i]; final selectable = node.selectable; if (selectable == null) { continue; } - var newSelection = selection.copy(); - // In the case of multiple selections, - // we need to return a new selection for each selected node individually. - if (!selection.isSingle) { - // <> means selected. - // text: abcdopqr - if (index == 0) { - if (selection.isBackward) { - newSelection = selection.copyWith(end: selectable.end()); - } else { - newSelection = selection.copyWith(start: selectable.start()); - } - } else if (index == nodes.length - 1) { - if (selection.isBackward) { - newSelection = selection.copyWith(start: selectable.start()); - } else { - newSelection = selection.copyWith(end: selectable.end()); - } + var newSelection = backwardSelection.copy(); + + /// In the case of multiple selections, + /// we need to return a new selection for each selected node individually. + /// + /// < > means selected. + /// text: abcdopqr + /// + if (!backwardSelection.isSingle) { + if (i == 0) { + newSelection = newSelection.copyWith(end: selectable.end()); + } else if (i == nodes.length - 1) { + newSelection = newSelection.copyWith(start: selectable.start()); } else { - newSelection = selection.copyWith( + newSelection = Selection( start: selectable.start(), end: selectable.end(), ); @@ -379,13 +375,13 @@ class _FlowySelectionState extends State } final rects = selectable.getRectsInSelection(newSelection); - for (final rect in rects) { - // FIXME: Need to compute more precise location. + // TODO: Need to compute more precise location. topmostRect ??= rect; layerLink ??= node.layerLink; - _rects.add(_transformRectToGlobal(selectable, rect)); + selectionRects.add(_transformRectToGlobal(selectable, rect)); + final overlay = OverlayEntry( builder: (context) => SelectionWidget( color: widget.selectionColor, @@ -393,11 +389,11 @@ class _FlowySelectionState extends State rect: rect, ), ); - _selectionOverlays.add(overlay); + _selectionAreas.add(overlay); } - index += 1; } - Overlay.of(context)?.insertAll(_selectionOverlays); + + Overlay.of(context)?.insertAll(_selectionAreas); if (topmostRect != null && layerLink != null) { editorState.service.toolbarService @@ -405,89 +401,141 @@ class _FlowySelectionState extends State } } + void _updateCursorAreas(Position position) { + final node = editorState.document.root.childAtPath(position.path); + + if (node == null) { + assert(false); + return; + } + + currentSelectedNodes = [node]; + + _showCursor(node, position); + } + + void _showCursor(Node node, Position position) { + final selectable = node.selectable; + final cursorRect = selectable?.getCursorRectInPosition(position); + if (selectable != null && cursorRect != null) { + final cursorArea = OverlayEntry( + builder: (context) => CursorWidget( + key: _cursorKey, + rect: cursorRect, + color: widget.cursorColor, + layerLink: node.layerLink, + ), + ); + + _cursorAreas.add(cursorArea); + selectionRects.add(_transformRectToGlobal(selectable, cursorRect)); + Overlay.of(context)?.insertAll(_cursorAreas); + + _forceShowCursor(); + } + } + + void _forceShowCursor() { + _cursorKey.currentState?.unwrapOrNull()?.show(); + } + + void _scrollUpOrDownIfNeeded() { + final dy = editorState.service.scrollService?.dy; + final selectNodes = currentSelectedNodes; + final selection = currentSelection.value; + if (dy == null || selection == null || selectNodes.isEmpty) { + return; + } + + final rect = selectNodes.last.rect; + + final size = MediaQuery.of(context).size.height; + final topLimit = size * 0.3; + final bottomLimit = size * 0.8; + + /// TODO: It is necessary to calculate the relative speed + /// according to the gap and move forward more gently. + if (rect.top >= bottomLimit) { + if (selection.isSingle) { + editorState.service.scrollService?.scrollTo(dy + size * 0.2); + } else if (selection.isBackward) { + editorState.service.scrollService?.scrollTo(dy + 10.0); + } + } else if (rect.bottom <= topLimit) { + if (selection.isForward) { + editorState.service.scrollService?.scrollTo(dy - 10.0); + } + } + } + + Node? _getNodeInOffset( + List sortedNodes, Offset offset, int start, int end) { + if (start < 0 && end >= sortedNodes.length) { + return null; + } + var min = start; + var max = end; + while (min <= max) { + final mid = min + ((max - min) >> 1); + final rect = sortedNodes[mid].rect; + if (rect.bottom <= offset.dy) { + min = mid + 1; + } else { + max = mid - 1; + } + } + final node = sortedNodes[min]; + if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) { + final children = node.children.toList(growable: false); + return _getNodeInOffset( + children, + offset, + 0, + children.length - 1, + ); + } + return node; + } + + void _enableInteraction() { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + Rect _transformRectToGlobal(Selectable selectable, Rect r) { final Offset topLeft = selectable.localToGlobal(Offset(r.left, r.top)); return Rect.fromLTWH(topLeft.dx, topLeft.dy, r.width, r.height); } - void _updateCursor(Position position) { - final node = editorState.document.root.childAtPath(position.path); - - assert(node != null); - if (node == null) { - return; - } - - currentSelectedNodes = [node]; - currentSelection.value = Selection.collapsed(position); - - final selectable = node.selectable; - final rect = selectable?.getCursorRectInPosition(position); - if (rect != null) { - _rects.add(_transformRectToGlobal(selectable!, rect)); - final cursor = OverlayEntry( - builder: (context) => CursorWidget( - key: _cursorKey, - rect: rect, - color: widget.cursorColor, - layerLink: node.layerLink, - ), - ); - _cursorOverlays.add(cursor); - Overlay.of(context)?.insertAll(_cursorOverlays); - _forceShowCursor(); - } + void _onSelectionChange() { + _scrollUpOrDownIfNeeded(); } - _forceShowCursor() { - final currentState = _cursorKey.currentState as CursorWidgetState?; - currentState?.show(); - } - - List _selectedNodesInSelection( - StateTree stateTree, Selection selection) { - final startNode = stateTree.nodeAtPath(selection.start.path)!; - final endNode = stateTree.nodeAtPath(selection.end.path)!; - return NodeIterator(stateTree, startNode, endNode).toList(); - } - - void _scrollUpOrDownIfNeeded(Offset offset, bool isDownward) { - final dy = editorState.service.scrollService?.dy; - if (dy == null) { - assert(false, 'Dy could not be null'); - return; - } - final topLimit = MediaQuery.of(context).size.height * 0.2; - final bottomLimit = MediaQuery.of(context).size.height * 0.8; - - /// TODO: It is necessary to calculate the relative speed - /// according to the gap and move forward more gently. - const distance = 10.0; - if (offset.dy <= topLimit && !isDownward) { - // up - editorState.service.scrollService?.scrollTo(dy - distance); - } else if (offset.dy >= bottomLimit && isDownward) { - //down - editorState.service.scrollService?.scrollTo(dy + distance); - } - } - - void _showDebugLayerIfNeeded() { + void _showDebugLayerIfNeeded({Offset? offset}) { // remove false to show debug overlay. if (kDebugMode && false) { _debugOverlay?.remove(); - if (panStartOffset != null) { + if (offset != null) { + _debugOverlay = OverlayEntry( + builder: (context) => Positioned.fromRect( + rect: Rect.fromPoints(offset, offset.translate(20, 20)), + child: Container( + color: Colors.red.withOpacity(0.2), + ), + ), + ); + Overlay.of(context)?.insert(_debugOverlay!); + } else if (_panStartOffset != null) { _debugOverlay = OverlayEntry( builder: (context) => Positioned.fromRect( rect: Rect.fromPoints( - panStartOffset?.translate( - 0, - -(editorState.service.scrollService!.dy - - panStartScrollDy!), - ) ?? - Offset.zero, - panEndOffset ?? Offset.zero) - .translate(0, 0), + _panStartOffset?.translate( + 0, + -(editorState.service.scrollService!.dy - + _panStartScrollDy!), + ) ?? + Offset.zero, + offset ?? Offset.zero), child: Container( color: Colors.red.withOpacity(0.2), ), @@ -499,175 +547,4 @@ class _FlowySelectionState extends State } } } - - Node _lowerBoundInDocument(Offset offset) { - final sortedNodes = - editorState.document.root.children.toList(growable: false); - return _lowerBound(sortedNodes, offset, 0, sortedNodes.length - 1); - } - - Node _upperBoundInDocument(Offset offset) { - final sortedNodes = - editorState.document.root.children.toList(growable: false); - return _upperBound(sortedNodes, offset, 0, sortedNodes.length - 1); - } - - /// TODO: Supports multi-level nesting, - /// currently only single-level nesting is supported - // find the first node's rect.bottom <= offset.dy - Node _lowerBound(List sortedNodes, Offset offset, int start, int end) { - assert(start >= 0 && end < sortedNodes.length); - var min = start; - var max = end; - while (min <= max) { - final mid = min + ((max - min) >> 1); - if (sortedNodes[mid].rect.bottom <= offset.dy) { - min = mid + 1; - } else { - max = mid - 1; - } - } - final node = sortedNodes[min]; - if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) { - final children = node.children.toList(growable: false); - return _lowerBound(children, offset, 0, children.length - 1); - } - return node; - } - - /// TODO: Supports multi-level nesting, - /// currently only single-level nesting is supported - // find the first node's rect.top < offset.dy - Node _upperBound( - List sortedNodes, - Offset offset, - int start, - int end, - ) { - assert(start >= 0 && end < sortedNodes.length); - var min = start; - var max = end; - while (min <= max) { - final mid = min + ((max - min) >> 1); - if (sortedNodes[mid].rect.top < offset.dy) { - min = mid + 1; - } else { - max = mid - 1; - } - } - final node = sortedNodes[max]; - if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) { - final children = node.children.toList(growable: false); - return _lowerBound(children, offset, 0, children.length - 1); - } - return node; - } -} - -/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer] -/// for a while. So we need to implement our own GestureDetector. -@immutable -class _SelectionGestureDetector extends StatefulWidget { - const _SelectionGestureDetector( - {Key? key, - this.child, - this.onTapDown, - this.onDoubleTapDown, - this.onTripleTapDown, - this.onPanStart, - this.onPanUpdate, - this.onPanEnd}) - : super(key: key); - - @override - State<_SelectionGestureDetector> createState() => - _SelectionGestureDetectorState(); - - final Widget? child; - - final GestureTapDownCallback? onTapDown; - final GestureTapDownCallback? onDoubleTapDown; - final GestureTapDownCallback? onTripleTapDown; - final GestureDragStartCallback? onPanStart; - final GestureDragUpdateCallback? onPanUpdate; - final GestureDragEndCallback? onPanEnd; -} - -const Duration kTripleTapTimeout = Duration(milliseconds: 500); - -class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> { - bool _isDoubleTap = false; - Timer? _doubleTapTimer; - int _tripleTabCount = 0; - Timer? _tripleTabTimer; - @override - Widget build(BuildContext context) { - return RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (recognizer) { - recognizer - ..onStart = widget.onPanStart - ..onUpdate = widget.onPanUpdate - ..onEnd = widget.onPanEnd; - }, - ), - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (recognizer) { - recognizer.onTapDown = _tapDownDelegate; - }, - ), - }, - child: widget.child, - ); - } - - _tapDownDelegate(TapDownDetails tapDownDetails) { - if (_tripleTabCount == 2) { - _tripleTabCount = 0; - _tripleTabTimer?.cancel(); - _tripleTabTimer = null; - if (widget.onTripleTapDown != null) { - widget.onTripleTapDown!(tapDownDetails); - } - } else if (_isDoubleTap) { - _isDoubleTap = false; - _doubleTapTimer?.cancel(); - _doubleTapTimer = null; - if (widget.onDoubleTapDown != null) { - widget.onDoubleTapDown!(tapDownDetails); - } - _tripleTabCount++; - } else { - if (widget.onTapDown != null) { - widget.onTapDown!(tapDownDetails); - } - - _isDoubleTap = true; - _doubleTapTimer?.cancel(); - _doubleTapTimer = Timer(kDoubleTapTimeout, () { - _isDoubleTap = false; - _doubleTapTimer = null; - }); - - _tripleTabCount = 1; - _tripleTabTimer?.cancel(); - _tripleTabTimer = Timer(kTripleTapTimeout, () { - _tripleTabCount = 0; - _tripleTabTimer = null; - }); - } - } - - @override - void dispose() { - _doubleTapTimer?.cancel(); - _tripleTabTimer?.cancel(); - super.dispose(); - } }