Browse Source

Merge pull request #722 from LucasXu0/feat/flowy_editor

chore: add selection_service documentation and resymbol shortcut service.
Nathan.fooo 2 years ago
parent
commit
5dde868e0b
20 changed files with 434 additions and 282 deletions
  1. 1 1
      frontend/app_flowy/packages/flowy_editor/example/lib/main.dart
  2. 20 16
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart
  3. 32 54
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart
  4. 1 2
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart
  5. 2 0
      frontend/app_flowy/packages/flowy_editor/lib/document/path.dart
  6. 10 0
      frontend/app_flowy/packages/flowy_editor/lib/document/position.dart
  7. 30 6
      frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart
  8. 23 0
      frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart
  9. 25 0
      frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart
  10. 11 9
      frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart
  11. 11 9
      frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart
  12. 0 37
      frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/arrow_keys_handler.dart
  13. 14 0
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart
  14. 0 0
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_nodes_handler.dart
  15. 2 2
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart
  16. 7 7
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart
  17. 243 137
      frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart
  18. 1 1
      frontend/app_flowy/packages/flowy_editor/lib/service/service.dart
  19. 0 0
      frontend/app_flowy/packages/flowy_editor/lib/service/shortcut_service.dart
  20. 1 1
      frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/example/lib/main.dart

@@ -96,7 +96,7 @@ class _MyHomePageState extends State<MyHomePage> {
             );
             return FlowyEditor(
               editorState: _editorState,
-              keyEventHandler: const [],
+              keyEventHandlers: const [],
               shortcuts: [
                 // TODO: this won't work, just a example for now.
                 {

+ 20 - 16
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart

@@ -1,3 +1,5 @@
+import 'package:flowy_editor/document/position.dart';
+import 'package:flowy_editor/document/selection.dart';
 import 'package:flowy_editor/flowy_editor.dart';
 import 'package:flutter/material.dart';
 
@@ -38,37 +40,39 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
   String get src => widget.node.attributes['image_src'] as String;
 
   @override
-  List<Rect> getSelectionRectsInRange(Offset start, Offset end) {
-    final renderBox = context.findRenderObject() as RenderBox;
-    return [Offset.zero & renderBox.size];
+  Position end() {
+    // TODO: implement end
+    throw UnimplementedError();
   }
 
   @override
-  Rect getCursorRect(Offset start) {
-    final renderBox = context.findRenderObject() as RenderBox;
-    final size = Size(2, renderBox.size.height);
-    final cursorOffset = Offset(renderBox.size.width, 0);
-    return cursorOffset & size;
+  Position start() {
+    // TODO: implement start
+    throw UnimplementedError();
   }
 
   @override
-  TextSelection? getCurrentTextSelection() {
-    return null;
+  List<Rect> getRectsInSelection(Selection selection) {
+    // TODO: implement getRectsInSelection
+    throw UnimplementedError();
   }
 
   @override
-  Offset getOffsetByTextSelection(TextSelection textSelection) {
-    return Offset.zero;
+  Selection getSelectionInRange(Offset start, Offset end) {
+    // TODO: implement getSelectionInRange
+    throw UnimplementedError();
   }
 
   @override
-  Offset getBackwardOffset() {
-    return Offset.zero;
+  Rect getCursorRectInPosition(Position position) {
+    // TODO: implement getCursorRectInPosition
+    throw UnimplementedError();
   }
 
   @override
-  Offset getForwardOffset() {
-    return Offset.zero;
+  Position getPositionInOffset(Offset start) {
+    // TODO: implement getPositionInOffset
+    throw UnimplementedError();
   }
 
   @override

+ 32 - 54
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart

@@ -1,6 +1,8 @@
 import 'dart:math';
 
 import 'package:example/plugin/debuggable_rich_text.dart';
+import 'package:flowy_editor/document/selection.dart';
+import 'package:flowy_editor/document/position.dart';
 import 'package:flowy_editor/flowy_editor.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
@@ -56,49 +58,43 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
       _textKey.currentContext?.findRenderObject() as RenderParagraph;
 
   @override
-  List<Rect> getSelectionRectsInRange(Offset start, Offset end) {
+  Selection getSelectionInRange(Offset start, Offset end) {
     final localStart = _renderParagraph.globalToLocal(start);
     final localEnd = _renderParagraph.globalToLocal(end);
+    final baseOffset = _getTextPositionAtOffset(localStart).offset;
+    final extentOffset = _getTextPositionAtOffset(localEnd).offset;
+    return Selection.single(
+      path: node.path,
+      startOffset: baseOffset,
+      endOffset: extentOffset,
+    );
+  }
 
-    var textSelection =
-        TextSelection(baseOffset: 0, extentOffset: node.toRawString().length);
-    // Returns select all if the start or end exceeds the size of the box
-    // TODO: don't need to compute everytime.
-    var rects = _computeSelectionRects(textSelection);
-    _textSelection = textSelection;
-
-    if (localEnd.dy > localStart.dy) {
-      // downward
-      if (localEnd.dy >= rects.last.bottom) {
-        return rects;
-      }
-    } else {
-      // upward
-      if (localEnd.dy <= rects.first.top) {
-        return rects;
-      }
-    }
-
-    final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset;
-    final selectionExtentOffset = _getTextPositionAtOffset(localEnd).offset;
-    textSelection = TextSelection(
-      baseOffset: selectionBaseOffset,
-      extentOffset: selectionExtentOffset,
+  @override
+  List<Rect> getRectsInSelection(Selection selection) {
+    assert(pathEquals(selection.start.path, selection.end.path));
+    assert(pathEquals(selection.start.path, node.path));
+    final textSelection = TextSelection(
+      baseOffset: selection.start.offset,
+      extentOffset: selection.end.offset,
     );
-    _textSelection = textSelection;
     return _computeSelectionRects(textSelection);
   }
 
   @override
-  Rect getCursorRect(Offset start) {
-    final localStart = _renderParagraph.globalToLocal(start);
-    final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset;
-    final textSelection = TextSelection.collapsed(offset: selectionBaseOffset);
+  Rect getCursorRectInPosition(Position position) {
+    final textSelection = TextSelection.collapsed(offset: position.offset);
     _textSelection = textSelection;
-    print('text selection = $textSelection');
     return _computeCursorRect(textSelection.baseOffset);
   }
 
+  @override
+  Position getPositionInOffset(Offset start) {
+    final localStart = _renderParagraph.globalToLocal(start);
+    final baseOffset = _getTextPositionAtOffset(localStart).offset;
+    return Position(path: node.path, offset: baseOffset);
+  }
+
   @override
   TextSelection? getCurrentTextSelection() {
     return _textSelection;
@@ -111,28 +107,11 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
   }
 
   @override
-  Offset getBackwardOffset() {
-    final textSelection = _textSelection;
-    if (textSelection != null) {
-      final leftTextSelection = TextSelection.collapsed(
-        offset: max(0, textSelection.baseOffset - 1),
-      );
-      return getOffsetByTextSelection(leftTextSelection);
-    }
-    return Offset.zero;
-  }
+  Position start() => Position(path: node.path, offset: 0);
 
   @override
-  Offset getForwardOffset() {
-    final textSelection = _textSelection;
-    if (textSelection != null) {
-      final leftTextSelection = TextSelection.collapsed(
-        offset: min(node.toRawString().length, textSelection.extentOffset + 1),
-      );
-      return getOffsetByTextSelection(leftTextSelection);
-    }
-    return Offset.zero;
-  }
+  Position end() =>
+      Position(path: node.path, offset: node.toRawString().length);
 
   @override
   Widget build(BuildContext context) {
@@ -175,8 +154,8 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
     return _renderParagraph.getPositionForOffset(offset);
   }
 
-  List<Rect> _computeSelectionRects(TextSelection selection) {
-    final textBoxes = _renderParagraph.getBoxesForSelection(selection);
+  List<Rect> _computeSelectionRects(TextSelection textSelection) {
+    final textBoxes = _renderParagraph.getBoxesForSelection(textSelection);
     return textBoxes.map((box) => box.toRect()).toList();
   }
 
@@ -185,7 +164,6 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
     final cursorOffset =
         _renderParagraph.getOffsetForCaret(position, Rect.zero);
     final cursorHeight = _renderParagraph.getFullHeightForCaret(position);
-    print('offset = $offset, cursorHeight = $cursorHeight');
     if (cursorHeight != null) {
       const cursorWidth = 2;
       return Rect.fromLTWH(

+ 1 - 2
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart

@@ -1,6 +1,5 @@
 import 'package:flowy_editor/document/position.dart';
 import 'package:flowy_editor/document/selection.dart';
-import 'package:flowy_editor/document/text_delta.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flowy_editor/flowy_editor.dart';
@@ -327,7 +326,7 @@ TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) {
   if (!pathEquals(nodePath, globalSel.start.path)) {
     return null;
   }
-  if (globalSel.isCollapsed()) {
+  if (globalSel.isCollapsed) {
     return TextSelection(
         baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset);
   } else {

+ 2 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/path.dart

@@ -1,3 +1,5 @@
+import 'dart:math';
+
 import 'package:flutter/foundation.dart';
 
 typedef Path = List<int>;

+ 10 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/position.dart

@@ -24,4 +24,14 @@ class Position {
     final pathHash = hashList(path);
     return Object.hash(pathHash, offset);
   }
+
+  Position copyWith({Path? path, int? offset}) {
+    return Position(
+      path: path ?? this.path,
+      offset: offset ?? this.offset,
+    );
+  }
+
+  @override
+  String toString() => 'path = $path, offset = $offset';
 }

+ 30 - 6
frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart

@@ -1,4 +1,6 @@
-import './position.dart';
+import 'package:flowy_editor/document/path.dart';
+import 'package:flowy_editor/document/position.dart';
+import 'package:flowy_editor/extensions/path_extensions.dart';
 
 class Selection {
   final Position start;
@@ -9,9 +11,16 @@ class Selection {
     required this.end,
   });
 
-  factory Selection.collapsed(Position pos) {
-    return Selection(start: pos, end: pos);
-  }
+  Selection.single({
+    required Path path,
+    required int startOffset,
+    int? endOffset,
+  })  : start = Position(path: path, offset: startOffset),
+        end = Position(path: path, offset: endOffset ?? startOffset);
+
+  Selection.collapsed(Position position)
+      : start = position,
+        end = position;
 
   Selection collapse({bool atStart = false}) {
     if (atStart) {
@@ -21,7 +30,22 @@ class Selection {
     }
   }
 
-  bool isCollapsed() {
-    return start == end;
+  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,
+      end: end ?? this.end,
+    );
   }
+
+  Selection copy() => Selection(start: start, end: end);
+
+  @override
+  String toString() => '[Selection] start = $start, end = $end';
 }

+ 23 - 0
frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart

@@ -0,0 +1,23 @@
+import 'dart:math';
+
+import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/document/selection.dart';
+import 'package:flowy_editor/extensions/object_extensions.dart';
+import 'package:flowy_editor/extensions/path_extensions.dart';
+import 'package:flowy_editor/render/selection/selectable.dart';
+import 'package:flutter/material.dart';
+
+extension NodeExtensions on Node {
+  RenderBox? get renderBox =>
+      key?.currentContext?.findRenderObject()?.unwrapOrNull<RenderBox>();
+
+  Selectable? get selectable => key?.currentState?.unwrapOrNull<Selectable>();
+
+  bool inSelection(Selection selection) {
+    if (selection.start.path <= selection.end.path) {
+      return selection.start.path <= path && path <= selection.end.path;
+    } else {
+      return selection.end.path <= path && path <= selection.start.path;
+    }
+  }
+}

+ 25 - 0
frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart

@@ -0,0 +1,25 @@
+import 'package:flowy_editor/document/path.dart';
+
+import 'dart:math';
+
+extension PathExtensions on Path {
+  bool operator >=(Path other) {
+    final length = min(this.length, other.length);
+    for (var i = 0; i < length; i++) {
+      if (this[i] < other[i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  bool operator <=(Path other) {
+    final length = min(this.length, other.length);
+    for (var i = 0; i < length; i++) {
+      if (this[i] > other[i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+}

+ 11 - 9
frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart

@@ -1,3 +1,5 @@
+import 'package:flowy_editor/document/position.dart';
+import 'package:flowy_editor/document/selection.dart';
 import 'package:flutter/material.dart';
 
 ///
@@ -9,20 +11,20 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
   ///
   /// The return result must be a [List] of the [Rect]
   ///   under the local coordinate system.
-  List<Rect> getSelectionRectsInRange(Offset start, Offset end);
+  Selection getSelectionInRange(Offset start, Offset end);
+
+  List<Rect> getRectsInSelection(Selection selection);
 
   /// Returns a [Rect] for the offset in current widget.
   ///
   /// [start] is the offset of the global coordination system.
   ///
   /// The return result must be an offset of the local coordinate system.
-  Rect getCursorRect(Offset start);
-
-  /// Returns a backward offset of the current offset based on the cause.
-  Offset getBackwardOffset(/* Cause */);
+  Position getPositionInOffset(Offset start);
+  Rect getCursorRectInPosition(Position position);
 
-  /// Returns a forward offset of the current offset based on the cause.
-  Offset getForwardOffset(/* Cause */);
+  Position start();
+  Position end();
 
   /// For [TextNode] only.
   ///
@@ -30,12 +32,12 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
   ///
   /// Only the widget rendered by [TextNode] need to implement the detail,
   ///   and the rest can return null.
-  TextSelection? getCurrentTextSelection();
+  TextSelection? getCurrentTextSelection() => null;
 
   /// For [TextNode] only.
   ///
   /// Retruns a [Offset].
   /// Only the widget rendered by [TextNode] need to implement the detail,
   ///   and the rest can return [Offset.zero].
-  Offset getOffsetByTextSelection(TextSelection textSelection);
+  Offset getOffsetByTextSelection(TextSelection textSelection) => Offset.zero;
 }

+ 11 - 9
frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart

@@ -1,25 +1,27 @@
 import 'package:flowy_editor/render/selection/floating_shortcut_widget.dart';
-import 'package:flowy_editor/service/floating_shortcut_service.dart';
-import 'package:flowy_editor/service/flowy_key_event_handlers/arrow_keys_handler.dart';
-import 'package:flowy_editor/service/flowy_key_event_handlers/delete_nodes_handler.dart';
-import 'package:flowy_editor/service/flowy_key_event_handlers/delete_single_text_node_handler.dart';
-import 'package:flowy_editor/service/flowy_key_event_handlers/shortcut_handler.dart';
+import 'package:flowy_editor/service/shortcut_service.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/delete_single_text_node_handler.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/shortcut_handler.dart';
 import 'package:flowy_editor/service/keyboard_service.dart';
 import 'package:flowy_editor/service/selection_service.dart';
+import 'package:flowy_editor/editor_state.dart';
 
-import '../editor_state.dart';
 import 'package:flutter/material.dart';
 
 class FlowyEditor extends StatefulWidget {
   const FlowyEditor({
     Key? key,
     required this.editorState,
-    required this.keyEventHandler,
+    required this.keyEventHandlers,
     required this.shortcuts,
   }) : super(key: key);
 
   final EditorState editorState;
-  final List<FlowyKeyEventHandler> keyEventHandler;
+  final List<FlowyKeyEventHandler> keyEventHandlers;
+
+  /// Shortcusts
   final FloatingShortcuts shortcuts;
 
   @override
@@ -41,7 +43,7 @@ class _FlowyEditorState extends State<FlowyEditor> {
           flowyDeleteNodesHandler,
           deleteSingleTextNodeHandler,
           arrowKeysHandler,
-          ...widget.keyEventHandler,
+          ...widget.keyEventHandlers,
         ],
         editorState: editorState,
         child: FloatingShortcut(

+ 0 - 37
frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/arrow_keys_handler.dart

@@ -1,37 +0,0 @@
-import 'package:flowy_editor/extensions/object_extensions.dart';
-import 'package:flowy_editor/flowy_editor.dart';
-import 'package:flowy_editor/service/keyboard_service.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-
-FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
-  if (event.logicalKey != LogicalKeyboardKey.arrowUp &&
-      event.logicalKey != LogicalKeyboardKey.arrowDown &&
-      event.logicalKey != LogicalKeyboardKey.arrowLeft &&
-      event.logicalKey != LogicalKeyboardKey.arrowRight) {
-    return KeyEventResult.ignored;
-  }
-
-  // TODO: Up and Down
-
-  // Left and Right
-  final selectedNodes = editorState.selectedNodes;
-  if (selectedNodes.length != 1) {
-    return KeyEventResult.ignored;
-  }
-
-  final node = selectedNodes.first.unwrapOrNull<TextNode>();
-  final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
-  Offset? offset;
-  if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
-    offset = selectable?.getBackwardOffset();
-  } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
-    offset = selectable?.getForwardOffset();
-  }
-  final selectionService = editorState.service.selectionService;
-  if (offset != null) {
-    selectionService.updateCursor(offset);
-    return KeyEventResult.handled;
-  }
-  return KeyEventResult.ignored;
-};

+ 14 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart

@@ -0,0 +1,14 @@
+import 'package:flowy_editor/service/keyboard_service.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
+  if (event.logicalKey != LogicalKeyboardKey.arrowUp &&
+      event.logicalKey != LogicalKeyboardKey.arrowDown &&
+      event.logicalKey != LogicalKeyboardKey.arrowLeft &&
+      event.logicalKey != LogicalKeyboardKey.arrowRight) {
+    return KeyEventResult.ignored;
+  }
+
+  return KeyEventResult.ignored;
+};

+ 0 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_nodes_handler.dart → frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_nodes_handler.dart


+ 2 - 2
frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_single_text_node_handler.dart → frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart

@@ -37,7 +37,7 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
               final newOfset = previousSelectable
                   ?.getOffsetByTextSelection(newTextSelection);
               if (newOfset != null) {
-                selectionService.updateCursor(newOfset);
+                // selectionService.updateCursor(newOfset);
               }
               // merge
               TransactionBuilder(editorState)
@@ -58,7 +58,7 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
             final selectionService = editorState.service.selectionService;
             final newOfset =
                 selectable.getOffsetByTextSelection(newTextSelection);
-            selectionService.updateCursor(newOfset);
+            // selectionService.updateCursor(newOfset);
             return KeyEventResult.handled;
           }
         }

+ 7 - 7
frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/shortcut_handler.dart → frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart

@@ -18,13 +18,13 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
   final textNode = selectedNodes.first.unwrapOrNull<TextNode>();
   final selectable = textNode?.key?.currentState?.unwrapOrNull<Selectable>();
   final textSelection = selectable?.getCurrentTextSelection();
-  if (textNode != null && selectable != null && textSelection != null) {
-    final offset = selectable.getOffsetByTextSelection(textSelection);
-    final rect = selectable.getCursorRect(offset);
-    editorState.service.floatingToolbarService
-        .showInOffset(rect.topLeft, textNode.layerLink);
-    return KeyEventResult.handled;
-  }
+  // if (textNode != null && selectable != null && textSelection != null) {
+  //   final offset = selectable.getOffsetByTextSelection(textSelection);
+  //   final rect = selectable.getCursorRect(offset);
+  //   editorState.service.floatingToolbarService
+  //       .showInOffset(rect.topLeft, textNode.layerLink);
+  //   return KeyEventResult.handled;
+  // }
 
   return KeyEventResult.ignored;
 };

+ 243 - 137
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -1,68 +1,94 @@
+import 'package:flowy_editor/document/path.dart';
+import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/document/position.dart';
+import 'package:flowy_editor/document/selection.dart';
 import 'package:flowy_editor/render/selection/cursor_widget.dart';
 import 'package:flowy_editor/render/selection/flowy_selection_widget.dart';
 import 'package:flowy_editor/extensions/object_extensions.dart';
-import 'package:flowy_editor/service/floating_shortcut_service.dart';
+import 'package:flowy_editor/extensions/node_extensions.dart';
+import 'package:flowy_editor/service/shortcut_service.dart';
+import 'package:flowy_editor/editor_state.dart';
+
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 
-import '../editor_state.dart';
-import '../document/node.dart';
-import '../render/selection/selectable.dart';
-
 /// Process selection and cursor
 mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
-  /// [Pan] and [Tap] must be mutually exclusive.
-  /// Pan
-  Offset? panStartOffset;
-  Offset? panEndOffset;
-
-  /// Tap
-  Offset? tapOffset;
-
-  void updateSelection(Offset start, Offset end);
-
-  void updateCursor(Offset start);
-
-  /// Returns selected node(s)
-  /// Returns empty list if no nodes are being selected.
-  List<Node> getSelectedNodes(Offset start, [Offset? end]);
-
-  /// Compute selected node triggered by [Tap]
-  Node? computeSelectedNodeInOffset(
-    Node node,
-    Offset offset,
-  );
-
-  /// Compute selected nodes triggered by [Pan]
-  List<Node> computeSelectedNodesInRange(
+  /// Returns the currently selected [Node]s.
+  ///
+  /// The order of the return is determined according to the selected order.
+  List<Node> get currentSelectedNodes;
+
+  /// ------------------ Selection ------------------------
+
+  ///
+  void updateSelection(Selection selection);
+
+  ///
+  void clearSelection();
+
+  ///
+  List<Node> getNodesInSelection(Selection selection);
+
+  /// ------------------ Selection ------------------------
+
+  /// ------------------ Offset ------------------------
+
+  /// Returns selected [Node]s. Empty list would be returned
+  ///   if no nodes are being selected.
+  ///
+  ///
+  /// [start] and [end] are the offsets under the global coordinate system.
+  ///
+  /// If end is not null, it means multiple selection,
+  ///   otherwise single selection.
+  List<Node> getNodesInRange(Offset start, [Offset? end]);
+
+  /// Return the [Node] or [Null] in single selection.
+  ///
+  /// [start] is the offset under the global coordinate system.
+  Node? computeNodeInOffset(Node node, Offset offset);
+
+  /// Return the [Node]s in multiple selection. Emtpy list would be returned
+  ///   if no nodes are in range.
+  ///
+  /// [start] is the offset under the global coordinate system.
+  List<Node> computeNodesInRange(
     Node node,
     Offset start,
     Offset end,
   );
 
-  /// Pan
-  bool isNodeInSelection(
+  /// Return [bool] to identify the [Node] is in Range or not.
+  ///
+  /// [start] and [end] are the offsets under the global coordinate system.
+  bool isNodeInRange(
     Node node,
     Offset start,
     Offset end,
   );
 
-  /// Tap
-  bool isNodeInOffset(
-    Node node,
-    Offset offset,
-  );
+  /// Return [bool] to identify the [Node] contains [Offset] or not.
+  ///
+  /// [start] is the offset under the global coordinate system.
+  bool isNodeInOffset(Node node, Offset offset);
+
+  /// ------------------ Offset ------------------------
 }
 
 class FlowySelection extends StatefulWidget {
   const FlowySelection({
     Key? key,
+    this.cursorColor = Colors.black,
+    this.selectionColor = const Color.fromARGB(60, 61, 61, 213),
     required this.editorState,
     required this.child,
   }) : super(key: key);
 
   final EditorState editorState;
   final Widget child;
+  final Color cursorColor;
+  final Color selectionColor;
 
   @override
   State<FlowySelection> createState() => _FlowySelectionState();
@@ -75,8 +101,23 @@ class _FlowySelectionState extends State<FlowySelection>
   final List<OverlayEntry> _selectionOverlays = [];
   final List<OverlayEntry> _cursorOverlays = [];
 
+  /// [Pan] and [Tap] must be mutually exclusive.
+  /// Pan
+  Offset? panStartOffset;
+  Offset? panEndOffset;
+
+  /// Tap
+  Offset? tapOffset;
+
   EditorState get editorState => widget.editorState;
 
+  @override
+  List<Node> currentSelectedNodes = [];
+
+  @override
+  List<Node> getNodesInSelection(Selection selection) =>
+      _selectedNodesInSelection(editorState.document.root, selection);
+
   @override
   Widget build(BuildContext context) {
     return RawGestureDetector(
@@ -105,76 +146,28 @@ class _FlowySelectionState extends State<FlowySelection>
   }
 
   @override
-  void updateSelection(Offset start, Offset end) {
-    _clearAllOverlayEntries();
-
-    final nodes = getSelectedNodes(start, end);
-    editorState.selectedNodes = nodes;
-    if (nodes.isEmpty) {
-      return;
-    }
+  void updateSelection(Selection selection) {
+    _clearSelection();
 
-    for (final node in nodes) {
-      if (node.key?.currentState is! Selectable) {
-        continue;
-      }
-      final selectable = node.key?.currentState as Selectable;
-      final selectionRects = selectable.getSelectionRectsInRange(start, end);
-      for (final rect in selectionRects) {
-        final overlay = OverlayEntry(
-          builder: ((context) => SelectionWidget(
-                color: Colors.yellow.withAlpha(100),
-                layerLink: node.layerLink,
-                rect: rect,
-              )),
-        );
-        _selectionOverlays.add(overlay);
-      }
+    // cursor
+    if (selection.isCollapsed) {
+      _updateCursor(selection.start);
+    } else {
+      _updateSelection(selection);
     }
-    Overlay.of(context)?.insertAll(_selectionOverlays);
   }
 
   @override
-  void updateCursor(Offset start) {
-    _clearAllOverlayEntries();
-
-    final nodes = getSelectedNodes(start);
-    editorState.selectedNodes = nodes;
-    if (nodes.isEmpty) {
-      return;
-    }
-
-    final selectedNode = nodes.first;
-    if (selectedNode.key?.currentState is! Selectable) {
-      return;
-    }
-    final selectable = selectedNode.key?.currentState as Selectable;
-    final rect = selectable.getCursorRect(start);
-    final cursor = OverlayEntry(
-      builder: ((context) => CursorWidget(
-            key: _cursorKey,
-            rect: rect,
-            color: Colors.red,
-            layerLink: selectedNode.layerLink,
-          )),
-    );
-    _cursorOverlays.add(cursor);
-    Overlay.of(context)?.insertAll(_cursorOverlays);
+  void clearSelection() {
+    _clearSelection();
   }
 
   @override
-  List<Node> getSelectedNodes(Offset start, [Offset? end]) {
+  List<Node> getNodesInRange(Offset start, [Offset? end]) {
     if (end != null) {
-      return computeSelectedNodesInRange(
-        editorState.document.root,
-        start,
-        end,
-      );
+      return computeNodesInRange(editorState.document.root, start, end);
     } else {
-      final reuslt = computeSelectedNodeInOffset(
-        editorState.document.root,
-        start,
-      );
+      final reuslt = computeNodeInOffset(editorState.document.root, start);
       if (reuslt != null) {
         return [reuslt];
       }
@@ -183,43 +176,49 @@ class _FlowySelectionState extends State<FlowySelection>
   }
 
   @override
-  Node? computeSelectedNodeInOffset(Node node, Offset offset) {
+  Node? computeNodeInOffset(Node node, Offset offset) {
     for (final child in node.children) {
-      final result = computeSelectedNodeInOffset(child, offset);
+      final result = computeNodeInOffset(child, offset);
       if (result != null) {
         return result;
       }
     }
-
     if (node.parent != null && node.key != null) {
       if (isNodeInOffset(node, offset)) {
         return node;
       }
     }
-
     return null;
   }
 
   @override
-  List<Node> computeSelectedNodesInRange(Node node, Offset start, Offset end) {
+  List<Node> computeNodesInRange(Node node, Offset start, Offset end) {
+    final result = _computeNodesInRange(node, start, end);
+    if (start.dy <= end.dy) {
+      // downward
+      return result;
+    } else {
+      // upward
+      return result.reversed.toList(growable: false);
+    }
+  }
+
+  List<Node> _computeNodesInRange(Node node, Offset start, Offset end) {
     List<Node> result = [];
     if (node.parent != null && node.key != null) {
-      if (isNodeInSelection(node, start, end)) {
+      if (isNodeInRange(node, start, end)) {
         result.add(node);
       }
     }
     for (final child in node.children) {
-      result.addAll(computeSelectedNodesInRange(child, start, end));
+      result.addAll(computeNodesInRange(child, start, end));
     }
-    // TODO: sort the result
     return result;
   }
 
   @override
   bool isNodeInOffset(Node node, Offset offset) {
-    assert(node.key != null);
-    final renderBox =
-        node.key?.currentContext?.findRenderObject() as RenderBox?;
+    final renderBox = node.renderBox;
     if (renderBox != null) {
       final boxOffset = renderBox.localToGlobal(Offset.zero);
       final boxRect = boxOffset & renderBox.size;
@@ -229,10 +228,8 @@ class _FlowySelectionState extends State<FlowySelection>
   }
 
   @override
-  bool isNodeInSelection(Node node, Offset start, Offset end) {
-    assert(node.key != null);
-    final renderBox =
-        node.key?.currentContext?.findRenderObject() as RenderBox?;
+  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);
@@ -243,59 +240,168 @@ class _FlowySelectionState extends State<FlowySelection>
   }
 
   void _onTapDown(TapDownDetails details) {
-    debugPrint('on tap down');
-
-    // TODO: use setter to make them exclusive??
-    tapOffset = details.globalPosition;
+    // clear old state.
     panStartOffset = null;
     panEndOffset = null;
 
-    updateCursor(tapOffset!);
+    tapOffset = details.globalPosition;
+
+    final nodes = getNodesInRange(tapOffset!);
+    if (nodes.isNotEmpty) {
+      assert(nodes.length == 1);
+      final selectable = nodes.first.selectable;
+      if (selectable != null) {
+        final position = selectable.getPositionInOffset(tapOffset!);
+        final selection = Selection.collapsed(position);
+        updateSelection(selection);
+      }
+    }
   }
 
   void _onPanStart(DragStartDetails details) {
-    debugPrint('on pan start');
-
-    panStartOffset = details.globalPosition;
+    // clear old state.
     panEndOffset = null;
     tapOffset = null;
+    clearSelection();
+
+    panStartOffset = details.globalPosition;
   }
 
   void _onPanUpdate(DragUpdateDetails details) {
-    // debugPrint('on pan update');
-
     panEndOffset = details.globalPosition;
-    tapOffset = null;
 
-    updateSelection(panStartOffset!, panEndOffset!);
+    final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
+    final first = nodes.first.selectable;
+    final last = nodes.last.selectable;
+
+    // compute the selection in range.
+    if (first != null && last != null) {
+      bool isDownward = panStartOffset!.dy <= panEndOffset!.dy;
+      final start =
+          first.getSelectionInRange(panStartOffset!, panEndOffset!).start;
+      final end = last.getSelectionInRange(panStartOffset!, panEndOffset!).end;
+      final selection = Selection(
+          start: isDownward ? start : end, end: isDownward ? end : start);
+      debugPrint('[_onPanUpdate] $selection');
+      updateSelection(selection);
+    }
   }
 
   void _onPanEnd(DragEndDetails details) {
     // do nothing
   }
 
-  void _clearAllOverlayEntries() {
-    _clearSelection();
-    _clearCursor();
-    _clearFloatingShorts();
-  }
-
   void _clearSelection() {
+    currentSelectedNodes = [];
+
+    // clear selection
     _selectionOverlays
       ..forEach((overlay) => overlay.remove())
       ..clear();
-  }
-
-  void _clearCursor() {
+    // clear cursors
     _cursorOverlays
       ..forEach((overlay) => overlay.remove())
       ..clear();
+    // clear floating shortcusts
+    editorState.service.floatingShortcutServiceKey.currentState
+        ?.unwrapOrNull<FlowyFloatingShortcutService>()
+        ?.hide();
+  }
+
+  void _updateSelection(Selection selection) {
+    final nodes =
+        _selectedNodesInSelection(editorState.document.root, selection);
+
+    currentSelectedNodes = nodes;
+
+    var index = 0;
+    for (final node in nodes) {
+      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: abcd<ef
+        // text: ghijkl
+        // text: mn>opqr
+        if (index == 0) {
+          if (selection.isDownward) {
+            newSelection = selection.copyWith(end: selectable.end());
+          } else {
+            newSelection = selection.copyWith(start: selectable.start());
+          }
+        } else if (index == nodes.length - 1) {
+          if (selection.isDownward) {
+            newSelection = selection.copyWith(start: selectable.start());
+          } else {
+            newSelection = selection.copyWith(end: selectable.end());
+          }
+        } else {
+          newSelection = selection.copyWith(
+            start: selectable.start(),
+            end: selectable.end(),
+          );
+        }
+      }
+
+      final rects = selectable.getRectsInSelection(newSelection);
+
+      for (final rect in rects) {
+        final overlay = OverlayEntry(
+          builder: ((context) => SelectionWidget(
+                color: widget.selectionColor,
+                layerLink: node.layerLink,
+                rect: rect,
+              )),
+        );
+        _selectionOverlays.add(overlay);
+      }
+      index += 1;
+    }
+    Overlay.of(context)?.insertAll(_selectionOverlays);
   }
 
-  void _clearFloatingShorts() {
-    final shortcutService = editorState
-        .service.floatingShortcutServiceKey.currentState
-        ?.unwrapOrNull<FlowyFloatingShortcutService>();
-    shortcutService?.hide();
+  void _updateCursor(Position position) {
+    final node = editorState.document.root.childAtPath(position.path);
+
+    assert(node != null);
+    if (node == null) {
+      return;
+    }
+
+    currentSelectedNodes = [node];
+
+    final selectable = node.selectable;
+    final rect = selectable?.getCursorRectInPosition(position);
+    if (rect != null) {
+      final cursor = OverlayEntry(
+        builder: ((context) => CursorWidget(
+              key: _cursorKey,
+              rect: rect,
+              color: widget.cursorColor,
+              layerLink: node.layerLink,
+            )),
+      );
+      _cursorOverlays.add(cursor);
+      Overlay.of(context)?.insertAll(_cursorOverlays);
+    }
+  }
+
+  List<Node> _selectedNodesInSelection(Node node, Selection selection) {
+    List<Node> result = [];
+    if (node.parent != null) {
+      if (node.inSelection(selection)) {
+        result.add(node);
+      }
+    }
+    for (final child in node.children) {
+      result.addAll(_selectedNodesInSelection(child, selection));
+    }
+    return result;
   }
 }

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/service/service.dart

@@ -1,4 +1,4 @@
-import 'package:flowy_editor/service/floating_shortcut_service.dart';
+import 'package:flowy_editor/service/shortcut_service.dart';
 import 'package:flowy_editor/service/selection_service.dart';
 import 'package:flutter/material.dart';
 

+ 0 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/floating_shortcut_service.dart → frontend/app_flowy/packages/flowy_editor/lib/service/shortcut_service.dart


+ 1 - 1
frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart

@@ -127,7 +127,7 @@ void main() {
     final pos = Position(path: [0], offset: 0);
     final sel = Selection.collapsed(pos);
     expect(sel.start, sel.end);
-    expect(sel.isCollapsed(), true);
+    expect(sel.isCollapsed, true);
   });
 
   test('test selection collapse', () {