Browse Source

feat: compute cursor and selection by [Selection] or [Offset]

Lucas.Xu 2 years ago
parent
commit
114ae2b45d

+ 14 - 12
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,27 +40,27 @@ 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];
+  List<Rect> getRectsInSelection(Selection selection) {
+    // TODO: implement getRectsInSelection
+    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;
+  Selection getSelectionInRange(Offset start, Offset end) {
+    // TODO: implement getSelectionInRange
+    throw UnimplementedError();
   }
 
   @override
-  TextSelection? getCurrentTextSelection() {
-    return null;
+  Rect getCursorRectInPosition(Position position) {
+    // TODO: implement getCursorRectInPosition
+    throw UnimplementedError();
   }
 
   @override
-  Offset getOffsetByTextSelection(TextSelection textSelection) {
-    return Offset.zero;
+  Position getPositionInOffset(Offset start) {
+    // TODO: implement getPositionInOffset
+    throw UnimplementedError();
   }
 
   @override

+ 29 - 34
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;
@@ -175,8 +171,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 +181,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(

+ 26 - 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>;
@@ -5,3 +7,27 @@ typedef Path = List<int>;
 bool pathEquals(Path path1, Path path2) {
   return listEquals(path1, path2);
 }
+
+/// Returns true if path1 >= path2, otherwise returns false.
+/// TODO: Rename this function.
+bool pathGreaterOrEquals(Path path1, Path path2) {
+  final length = min(path1.length, path2.length);
+  for (var i = 0; i < length; i++) {
+    if (path1[i] < path2[i]) {
+      return false;
+    }
+  }
+  return true;
+}
+
+/// Returns true if path1 <= path2, otherwise returns false.
+/// TODO: Rename this function.
+bool pathLessOrEquals(Path path1, Path path2) {
+  final length = min(path1.length, path2.length);
+  for (var i = 0; i < length; i++) {
+    if (path1[i] > path2[i]) {
+      return false;
+    }
+  }
+  return true;
+}

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

@@ -24,4 +24,11 @@ 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,
+    );
+  }
 }

+ 19 - 4
frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart

@@ -1,4 +1,5 @@
-import './position.dart';
+import 'package:flowy_editor/document/path.dart';
+import 'package:flowy_editor/document/position.dart';
 
 class Selection {
   final Position start;
@@ -9,9 +10,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) {
@@ -24,4 +32,11 @@ class Selection {
   bool isCollapsed() {
     return start == end;
   }
+
+  Selection copyWith({Position? start, Position? end}) {
+    return Selection(
+      start: start ?? this.start,
+      end: end ?? this.end,
+    );
+  }
 }

+ 9 - 4
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,14 +11,17 @@ 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);
+  Position getPositionInOffset(Offset start);
+  Rect getCursorRectInPosition(Position position);
 
   /// Returns a backward offset of the current offset based on the cause.
   Offset getBackwardOffset(/* Cause */);
@@ -30,12 +35,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;
 }

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

@@ -30,7 +30,7 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
   }
   final selectionService = editorState.service.selectionService;
   if (offset != null) {
-    selectionService.updateCursor(offset);
+    // selectionService.updateCursor(offset);
     return KeyEventResult.handled;
   }
   return KeyEventResult.ignored;

+ 2 - 2
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/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;
 };

+ 150 - 70
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -1,3 +1,6 @@
+import 'package:flowy_editor/document/path.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';
@@ -12,11 +15,8 @@ import '../render/selection/selectable.dart';
 
 /// Process selection and cursor
 mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
-  /// [start] and [end] are the offsets under the global coordinate system.
-  void updateSelection(Offset start, Offset end);
-
-  /// [start] is the offset under the global coordinate system.
-  void updateCursor(Offset start);
+  ///
+  void updateSelection(Selection selection);
 
   /// Returns selected [Node]s. Empty list would be returned
   ///   if no nodes are being selected.
@@ -26,18 +26,21 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
   ///
   /// If end is not null, it means multiple selection,
   ///   otherwise single selection.
-  List<Node> getSelectedNodes(Offset start, [Offset? end]);
+  List<Node> getNodesInRange(Offset start, [Offset? end]);
+
+  ///
+  List<Node> getNodesInSelection(Selection selection);
 
   /// Return the [Node] or [Null] in single selection.
   ///
   /// [start] is the offset under the global coordinate system.
-  Node? computeSelectedNodeInOffset(Node node, Offset offset);
+  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> computeSelectedNodesInRange(
+  List<Node> computeNodesInRange(
     Node node,
     Offset start,
     Offset end,
@@ -93,6 +96,10 @@ class _FlowySelectionState extends State<FlowySelection>
 
   EditorState get editorState => widget.editorState;
 
+  @override
+  List<Node> getNodesInSelection(Selection selection) =>
+      _selectedNodesInSelection(editorState.document.root, selection);
+
   @override
   Widget build(BuildContext context) {
     return RawGestureDetector(
@@ -121,70 +128,23 @@ 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;
-    }
-
-    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: widget.selectionColor,
-                layerLink: node.layerLink,
-                rect: rect,
-              )),
-        );
-        _selectionOverlays.add(overlay);
-      }
-    }
-    Overlay.of(context)?.insertAll(_selectionOverlays);
-  }
-
-  @override
-  void updateCursor(Offset start) {
+  void updateSelection(Selection selection) {
     _clearAllOverlayEntries();
 
-    final nodes = getSelectedNodes(start);
-    editorState.selectedNodes = nodes;
-    if (nodes.isEmpty) {
-      return;
-    }
-
-    final selectedNode = nodes.first;
-    if (selectedNode.key?.currentState is! Selectable) {
-      return;
+    // cursor
+    if (selection.isCollapsed()) {
+      _updateCursor(selection.start);
+    } else {
+      _updateSelection(selection);
     }
-    final selectable = selectedNode.key?.currentState as Selectable;
-    final rect = selectable.getCursorRect(start);
-    final cursor = OverlayEntry(
-      builder: ((context) => CursorWidget(
-            key: _cursorKey,
-            rect: rect,
-            color: widget.cursorColor,
-            layerLink: selectedNode.layerLink,
-          )),
-    );
-    _cursorOverlays.add(cursor);
-    Overlay.of(context)?.insertAll(_cursorOverlays);
   }
 
   @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];
       }
@@ -193,9 +153,9 @@ 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;
       }
@@ -209,7 +169,7 @@ class _FlowySelectionState extends State<FlowySelection>
   }
 
   @override
-  List<Node> computeSelectedNodesInRange(Node node, Offset start, Offset end) {
+  List<Node> computeNodesInRange(Node node, Offset start, Offset end) {
     List<Node> result = [];
     if (node.parent != null && node.key != null) {
       if (isNodeInSelection(node, start, end)) {
@@ -217,7 +177,7 @@ class _FlowySelectionState extends State<FlowySelection>
       }
     }
     for (final child in node.children) {
-      result.addAll(computeSelectedNodesInRange(child, start, end));
+      result.addAll(computeNodesInRange(child, start, end));
     }
     // TODO: sort the result
     return result;
@@ -254,7 +214,16 @@ class _FlowySelectionState extends State<FlowySelection>
     panStartOffset = null;
     panEndOffset = null;
 
-    updateCursor(tapOffset!);
+    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) {
@@ -271,7 +240,16 @@ class _FlowySelectionState extends State<FlowySelection>
     panEndOffset = details.globalPosition;
     tapOffset = null;
 
-    updateSelection(panStartOffset!, panEndOffset!);
+    final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
+    final first = nodes.first.selectable;
+    final last = nodes.last.selectable;
+    if (first != null && last != null) {
+      final selection = Selection(
+        start: first.getSelectionInRange(panStartOffset!, panEndOffset!).start,
+        end: last.getSelectionInRange(panStartOffset!, panEndOffset!).end,
+      );
+      updateSelection(selection);
+    }
   }
 
   void _onPanEnd(DragEndDetails details) {
@@ -302,4 +280,106 @@ class _FlowySelectionState extends State<FlowySelection>
         ?.unwrapOrNull<FlowyFloatingShortcutService>();
     shortcutService?.hide();
   }
+
+  void _updateSelection(Selection selection) {
+    final nodes =
+        _selectedNodesInSelection(editorState.document.root, selection);
+
+    var index = 0;
+    for (final node in nodes) {
+      final selectable = node.selectable;
+      if (selectable == null) {
+        continue;
+      }
+
+      Selection newSelection;
+      if (node is TextNode) {
+        if (pathEquals(selection.start.path, selection.end.path)) {
+          newSelection = selection.copyWith();
+        } else {
+          if (index == 0) {
+            newSelection = selection.copyWith(
+              /// FIXME: make it better.
+              end: selection.start.copyWith(offset: node.toRawString().length),
+            );
+          } else if (index == nodes.length - 1) {
+            newSelection = selection.copyWith(
+              /// FIXME: make it better.
+              start: selection.end.copyWith(offset: 0),
+            );
+          } else {
+            final position = Position(path: node.path);
+            newSelection = Selection(
+              start: position.copyWith(offset: 0),
+              end: position.copyWith(offset: node.toRawString().length),
+            );
+          }
+        }
+      } else {
+        newSelection = Selection.collapsed(
+          Position(path: node.path),
+        );
+      }
+
+      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 _updateCursor(Position position) {
+    final node = _selectedNodeInPostion(editorState.document.root, position);
+
+    assert(node != null);
+    if (node == null) {
+      return;
+    }
+
+    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 (_isNodeInSelection(node, selection)) {
+        result.add(node);
+      }
+    }
+    for (final child in node.children) {
+      result.addAll(_selectedNodesInSelection(child, selection));
+    }
+    return result;
+  }
+
+  Node? _selectedNodeInPostion(Node node, Position position) =>
+      node.childAtPath(position.path);
+
+  bool _isNodeInSelection(Node node, Selection selection) {
+    return pathGreaterOrEquals(node.path, selection.start.path) &&
+        pathLessOrEquals(node.path, selection.end.path);
+  }
 }