浏览代码

chore: resolve conflicts.

Lucas.Xu 2 年之前
父节点
当前提交
ec83a68602
共有 19 个文件被更改,包括 596 次插入85 次删除
  1. 4 6
      frontend/.vscode/settings.json
  2. 45 0
      frontend/app_flowy/packages/flowy_editor/.vscode/launch.json
  3. 5 0
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart
  4. 5 0
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart
  5. 5 1
      frontend/app_flowy/packages/flowy_editor/lib/document/node.dart
  6. 17 2
      frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart
  7. 77 11
      frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart
  8. 4 8
      frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart
  9. 7 10
      frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart
  10. 5 0
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart
  11. 15 0
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart
  12. 0 1
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart
  13. 19 6
      frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart
  14. 6 0
      frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart
  15. 133 4
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart
  16. 164 34
      frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart
  17. 1 1
      frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart
  18. 1 1
      frontend/app_flowy/packages/flowy_editor/pubspec.yaml
  19. 83 0
      frontend/app_flowy/packages/flowy_editor/test/operation_test.dart

+ 4 - 6
frontend/.vscode/settings.json

@@ -2,23 +2,21 @@
     "[dart]": {
         "editor.formatOnSave": true,
         "editor.formatOnType": true,
-        "editor.rulers": [
-            120
-        ],
+        "editor.rulers": [80],
         "editor.selectionHighlight": false,
         "editor.suggest.snippetsPreventQuickSuggestions": false,
         "editor.suggestSelection": "first",
         "editor.tabCompletion": "onlySnippets",
-        "editor.wordBasedSuggestions": false
+        "editor.wordBasedSuggestions": false,
     },
     "svgviewer.enableautopreview": true,
     "svgviewer.previewcolumn": "Active",
     "svgviewer.showzoominout": true,
-    "editor.wordWrapColumn": 120,
+    "editor.wordWrapColumn": 80,
     "editor.minimap.maxColumn": 140,
     "prettier.printWidth": 140,
     "editor.wordWrap": "wordWrapColumn",
-    "dart.lineLength": 120,
+    "dart.lineLength": 80,
     "files.associations": {
         "*.log.*": "log"
     },

+ 45 - 0
frontend/app_flowy/packages/flowy_editor/.vscode/launch.json

@@ -0,0 +1,45 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "example",
+            "cwd": "example",
+            "request": "launch",
+            "type": "dart"
+        },
+        {
+            "name": "example (profile mode)",
+            "cwd": "example",
+            "request": "launch",
+            "type": "dart",
+            "flutterMode": "profile"
+        },
+        {
+            "name": "example (release mode)",
+            "cwd": "example",
+            "request": "launch",
+            "type": "dart",
+            "flutterMode": "release"
+        },
+        {
+            "name": "flowy_editor",
+            "request": "launch",
+            "type": "dart"
+        },
+        {
+            "name": "flowy_editor (profile mode)",
+            "request": "launch",
+            "type": "dart",
+            "flutterMode": "profile"
+        },
+        {
+            "name": "flowy_editor (release mode)",
+            "request": "launch",
+            "type": "dart",
+            "flutterMode": "release"
+        },
+    ]
+}

+ 5 - 0
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart

@@ -63,6 +63,11 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
     throw UnimplementedError();
   }
 
+  @override
+  Offset localToGlobal(Offset offset) {
+    throw UnimplementedError();
+  }
+
   @override
   Rect getCursorRectInPosition(Position position) {
     // TODO: implement getCursorRectInPosition

+ 5 - 0
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart

@@ -70,6 +70,11 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
     );
   }
 
+  @override
+  Offset localToGlobal(Offset offset) {
+    return _renderParagraph.localToGlobal(offset);
+  }
+
   @override
   List<Rect> getRectsInSelection(Selection selection) {
     assert(pathEquals(selection.start.path, selection.end.path));

+ 5 - 1
frontend/app_flowy/packages/flowy_editor/lib/document/node.dart

@@ -32,7 +32,11 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
     required this.children,
     required this.attributes,
     this.parent,
-  });
+  }) {
+    for (final child in children) {
+      child.parent = this;
+    }
+  }
 
   factory Node.fromJson(Map<String, Object> json) {
     assert(json['type'] is String);

+ 17 - 2
frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart

@@ -38,7 +38,22 @@ class EditorState {
   final service = FlowyService();
 
   final UndoManager undoManager = UndoManager();
-  Selection? cursorSelection;
+  Selection? _cursorSelection;
+
+  Selection? get cursorSelection {
+    return _cursorSelection;
+  }
+
+  /// add the set reason in the future, don't use setter
+  updateCursorSelection(Selection? cursorSelection) {
+    // broadcast to other users here
+    if (cursorSelection == null) {
+      service.selectionService.clearSelection();
+    } else {
+      service.selectionService.updateSelection(cursorSelection);
+    }
+    _cursorSelection = cursorSelection;
+  }
 
   Timer? _debouncedSealHistoryItemTimer;
 
@@ -74,7 +89,7 @@ class EditorState {
     for (final op in transaction.operations) {
       _applyOperation(op);
     }
-    cursorSelection = transaction.afterSelection;
+    updateCursorSelection(transaction.afterSelection);
 
     if (options.recordUndo) {
       final undoItem = undoManager.getUndoHistoryItem();

+ 77 - 11
frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart

@@ -1,21 +1,27 @@
-import 'package:flowy_editor/document/path.dart';
-import 'package:flowy_editor/document/node.dart';
-import 'package:flowy_editor/document/text_delta.dart';
 import 'package:flowy_editor/document/attributes.dart';
+import 'package:flowy_editor/flowy_editor.dart';
 
 abstract class Operation {
+  final Path path;
+  Operation({required this.path});
+  Operation copyWithPath(Path path);
   Operation invert();
 }
 
 class InsertOperation extends Operation {
-  final Path path;
   final Node value;
 
   InsertOperation({
-    required this.path,
+    required super.path,
     required this.value,
   });
 
+  InsertOperation copyWith({Path? path, Node? value}) =>
+      InsertOperation(path: path ?? this.path, value: value ?? this.value);
+
+  @override
+  Operation copyWithPath(Path path) => copyWith(path: path);
+
   @override
   Operation invert() {
     return DeleteOperation(
@@ -26,16 +32,25 @@ class InsertOperation extends Operation {
 }
 
 class UpdateOperation extends Operation {
-  final Path path;
   final Attributes attributes;
   final Attributes oldAttributes;
 
   UpdateOperation({
-    required this.path,
+    required super.path,
     required this.attributes,
     required this.oldAttributes,
   });
 
+  UpdateOperation copyWith(
+          {Path? path, Attributes? attributes, Attributes? oldAttributes}) =>
+      UpdateOperation(
+          path: path ?? this.path,
+          attributes: attributes ?? this.attributes,
+          oldAttributes: oldAttributes ?? this.oldAttributes);
+
+  @override
+  Operation copyWithPath(Path path) => copyWith(path: path);
+
   @override
   Operation invert() {
     return UpdateOperation(
@@ -47,14 +62,19 @@ class UpdateOperation extends Operation {
 }
 
 class DeleteOperation extends Operation {
-  final Path path;
   final Node removedValue;
 
   DeleteOperation({
-    required this.path,
+    required super.path,
     required this.removedValue,
   });
 
+  DeleteOperation copyWith({Path? path, Node? removedValue}) => DeleteOperation(
+      path: path ?? this.path, removedValue: removedValue ?? this.removedValue);
+
+  @override
+  Operation copyWithPath(Path path) => copyWith(path: path);
+
   @override
   Operation invert() {
     return InsertOperation(
@@ -65,18 +85,64 @@ class DeleteOperation extends Operation {
 }
 
 class TextEditOperation extends Operation {
-  final Path path;
   final Delta delta;
   final Delta inverted;
 
   TextEditOperation({
-    required this.path,
+    required super.path,
     required this.delta,
     required this.inverted,
   });
 
+  TextEditOperation copyWith({Path? path, Delta? delta, Delta? inverted}) =>
+      TextEditOperation(
+          path: path ?? this.path,
+          delta: delta ?? this.delta,
+          inverted: inverted ?? this.inverted);
+
+  @override
+  Operation copyWithPath(Path path) => copyWith(path: path);
+
   @override
   Operation invert() {
     return TextEditOperation(path: path, delta: inverted, inverted: delta);
   }
 }
+
+Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
+  if (preInsertPath.length > b.length) {
+    return b;
+  }
+  if (preInsertPath.isEmpty || b.isEmpty) {
+    return b;
+  }
+  // check the prefix
+  for (var i = 0; i < preInsertPath.length - 1; i++) {
+    if (preInsertPath[i] != b[i]) {
+      return b;
+    }
+  }
+  final prefix = preInsertPath.sublist(0, preInsertPath.length - 1);
+  final suffix = b.sublist(preInsertPath.length);
+  final preInsertLast = preInsertPath.last;
+  final bAtIndex = b[preInsertPath.length - 1];
+  if (preInsertLast <= bAtIndex) {
+    prefix.add(bAtIndex + delta);
+  } else {
+    prefix.add(bAtIndex);
+  }
+  prefix.addAll(suffix);
+  return prefix;
+}
+
+Operation transformOperation(Operation a, Operation b) {
+  if (a is InsertOperation) {
+    final newPath = transformPath(a.path, b.path);
+    return b.copyWithPath(newPath);
+  } else if (b is DeleteOperation) {
+    final newPath = transformPath(a.path, b.path, -1);
+    return b.copyWithPath(newPath);
+  }
+  // TODO: transform update and textedit
+  return b;
+}

+ 4 - 8
frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart

@@ -3,19 +3,15 @@ import 'package:flutter/material.dart';
 import 'package:flowy_editor/document/selection.dart';
 import './operation.dart';
 
-/// This class to use to store the **changes**
-/// will be applied to the editor.
+/// A [Transaction] has a list of [Operation] objects that will be applied
+/// to the editor. It is an immutable class and used to store and transmit.
 ///
-/// This class is immutable version the the class
-/// [[Transaction]]. Is used to stored and
-/// transmit. If you want to build the transaction,
-/// use [[Transaction]] directly.
+/// If you want to build a new [Transaction], use [TransactionBuilder] directly.
 ///
 /// There will be several ways to consume the transaction:
 /// 1. Apply to the state to update the UI.
 /// 2. Send to the backend to store and do operation transforming.
-/// 3. Stored by the UndoManager to implement redo/undo.
-///
+/// 3. Used by the UndoManager to implement redo/undo.
 @immutable
 class Transaction {
   final UnmodifiableListView<Operation> operations;

+ 7 - 10
frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart

@@ -11,16 +11,10 @@ import 'package:flowy_editor/document/selection.dart';
 import './operation.dart';
 import './transaction.dart';
 
-///
-/// This class is used to
-/// build the transaction from the state.
-///
-/// This class automatically save the
-/// cursor from the state.
-///
-/// When the transaction is undo, the
-/// cursor can be restored.
-///
+/// A [TransactionBuilder] is used to build the transaction from the state.
+/// It will save make a snapshot of the cursor selection state automatically.
+/// The cursor can be resoted if the transaction is undo.
+
 class TransactionBuilder {
   final List<Operation> operations = [];
   EditorState state;
@@ -96,6 +90,9 @@ class TransactionBuilder {
         return;
       }
     }
+    for (var i = 0; i < operations.length; i++) {
+      op = transformOperation(operations[i], op);
+    }
     operations.add(op);
   }
 

+ 5 - 0
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart

@@ -22,6 +22,11 @@ mixin DefaultSelectable {
   Selection getSelectionInRange(Offset start, Offset end) =>
       forward.getSelectionInRange(start, end);
 
+  Offset localToGlobal(Offset offset) => forward.localToGlobal(offset);
+
+  Selection? getWorldBoundaryInOffset(Offset offset) =>
+      forward.getWorldBoundaryInOffset(offset);
+
   Position start() => forward.start();
 
   Position end() => forward.end();

+ 15 - 0
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart

@@ -92,6 +92,16 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     return Position(path: widget.textNode.path, offset: baseOffset);
   }
 
+  @override
+  Selection? getWorldBoundaryInOffset(Offset offset) {
+    final localOffset = _renderParagraph.globalToLocal(offset);
+    final textPosition = _renderParagraph.getPositionForOffset(localOffset);
+    final textRange = _renderParagraph.getWordBoundary(textPosition);
+    final start = Position(path: widget.textNode.path, offset: textRange.start);
+    final end = Position(path: widget.textNode.path, offset: textRange.end);
+    return Selection(start: start, end: end);
+  }
+
   @override
   List<Rect> getRectsInSelection(Selection selection) {
     assert(pathEquals(selection.start.path, selection.end.path) &&
@@ -155,6 +165,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     );
   }
 
+  @override
+  Offset localToGlobal(Offset offset) {
+    return _renderParagraph.localToGlobal(offset);
+  }
+
   TextSpan get _textSpan => TextSpan(
       children: widget.textNode.delta.operations
           .whereType<TextInsert>()

+ 0 - 1
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart

@@ -4,7 +4,6 @@ import 'package:flowy_editor/infra/flowy_svg.dart';
 import 'package:flowy_editor/render/node_widget_builder.dart';
 import 'package:flowy_editor/render/rich_text/default_selectable.dart';
 import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
-import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
 import 'package:flowy_editor/render/selection/selectable.dart';
 import 'package:flutter/material.dart';
 

+ 19 - 6
frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart

@@ -17,10 +17,10 @@ class CursorWidget extends StatefulWidget {
   final LayerLink layerLink;
 
   @override
-  State<CursorWidget> createState() => _CursorWidgetState();
+  State<CursorWidget> createState() => CursorWidgetState();
 }
 
-class _CursorWidgetState extends State<CursorWidget> {
+class CursorWidgetState extends State<CursorWidget> {
   bool showCursor = true;
   late Timer timer;
 
@@ -28,7 +28,17 @@ class _CursorWidgetState extends State<CursorWidget> {
   void initState() {
     super.initState();
 
-    timer = Timer.periodic(
+    timer = _initTimer();
+  }
+
+  @override
+  void dispose() {
+    timer.cancel();
+    super.dispose();
+  }
+
+  Timer _initTimer() {
+    return Timer.periodic(
         Duration(milliseconds: (widget.blinkingInterval * 1000).toInt()),
         (timer) {
       setState(() {
@@ -37,10 +47,13 @@ class _CursorWidgetState extends State<CursorWidget> {
     });
   }
 
-  @override
-  void dispose() {
+  /// force the cursor widget to show for a while
+  show() {
+    setState(() {
+      showCursor = true;
+    });
     timer.cancel();
-    super.dispose();
+    timer = _initTimer();
   }
 
   @override

+ 6 - 0
frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart

@@ -21,8 +21,14 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
   ///
   /// The return result must be an offset of the local coordinate system.
   Position getPositionInOffset(Offset start);
+  Selection? getWorldBoundaryInOffset(Offset start) {
+    return null;
+  }
+
   Rect getCursorRectInPosition(Position position);
 
+  Offset localToGlobal(Offset offset);
+
   Position start();
   Position end();
 

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

@@ -1,14 +1,143 @@
+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';
 
+int _endOffsetOfNode(Node node) {
+  if (node is TextNode) {
+    return node.delta.length;
+  }
+  return 0;
+}
+
+extension on Position {
+  Position? goLeft(EditorState editorState) {
+    if (offset == 0) {
+      final node = editorState.document.nodeAtPath(path)!;
+      final prevNode = node.previous;
+      if (prevNode != null) {
+        return Position(
+            path: prevNode.path, offset: _endOffsetOfNode(prevNode));
+      }
+      return null;
+    }
+
+    return Position(path: path, offset: offset - 1);
+  }
+
+  Position? goRight(EditorState editorState) {
+    final node = editorState.document.nodeAtPath(path)!;
+    final lengthOfNode = _endOffsetOfNode(node);
+    if (offset >= lengthOfNode) {
+      final nextNode = node.next;
+      if (nextNode != null) {
+        return Position(path: nextNode.path, offset: 0);
+      }
+      return null;
+    }
+
+    return Position(path: path, offset: offset + 1);
+  }
+}
+
+Position? _goUp(EditorState editorState) {
+  final rects = editorState.service.selectionService.rects();
+  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);
+}
+
+Position? _goDown(EditorState editorState) {
+  final rects = editorState.service.selectionService.rects();
+  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);
+}
+
+KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) {
+  final currentSelection = editorState.cursorSelection;
+  if (currentSelection == null) {
+    return KeyEventResult.ignored;
+  }
+
+  if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
+    final leftPosition = currentSelection.end.goLeft(editorState);
+    editorState.updateCursorSelection(leftPosition == null
+        ? null
+        : Selection(start: currentSelection.start, end: leftPosition));
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
+    final rightPosition = currentSelection.start.goRight(editorState);
+    editorState.updateCursorSelection(rightPosition == null
+        ? null
+        : Selection(start: rightPosition, end: currentSelection.end));
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
+    final position = _goUp(editorState);
+    editorState.updateCursorSelection(position == null
+        ? null
+        : Selection(start: position, end: currentSelection.end));
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
+    final position = _goDown(editorState);
+    editorState.updateCursorSelection(position == null
+        ? null
+        : Selection(start: currentSelection.start, end: position));
+    return KeyEventResult.handled;
+  }
+  return KeyEventResult.ignored;
+}
+
 FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
-  if (event.logicalKey != LogicalKeyboardKey.arrowUp &&
-      event.logicalKey != LogicalKeyboardKey.arrowDown &&
-      event.logicalKey != LogicalKeyboardKey.arrowLeft &&
-      event.logicalKey != LogicalKeyboardKey.arrowRight) {
+  if (event.isShiftPressed) {
+    return _handleShiftKey(editorState, event);
+  }
+
+  final currentSelection = editorState.cursorSelection;
+  if (currentSelection == null) {
     return KeyEventResult.ignored;
   }
 
+  if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
+    if (currentSelection.isCollapsed) {
+      final leftPosition = currentSelection.start.goLeft(editorState);
+      if (leftPosition != null) {
+        editorState.updateCursorSelection(Selection.collapsed(leftPosition));
+      }
+    } else {
+      editorState
+          .updateCursorSelection(currentSelection.collapse(atStart: true));
+    }
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
+    if (currentSelection.isCollapsed) {
+      final rightPosition = currentSelection.end.goRight(editorState);
+      if (rightPosition != null) {
+        editorState.updateCursorSelection(Selection.collapsed(rightPosition));
+      }
+    } else {
+      editorState.updateCursorSelection(currentSelection.collapse());
+    }
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
+    final position = _goUp(editorState);
+    editorState.updateCursorSelection(
+        position == null ? null : Selection.collapsed(position));
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
+    final position = _goDown(editorState);
+    editorState.updateCursorSelection(
+        position == null ? null : Selection.collapsed(position));
+    return KeyEventResult.handled;
+  }
+
   return KeyEventResult.ignored;
 };

+ 164 - 34
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -1,15 +1,17 @@
-import 'package:flowy_editor/document/path.dart';
+import 'dart:async';
+
 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/selectable.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/extensions/node_extensions.dart';
+import 'package:flutter/gestures.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';
 
 /// Process selection and cursor
@@ -28,6 +30,10 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
   ///
   void clearSelection();
 
+  List<Rect> rects();
+
+  Position? hitTest(Offset? offset);
+
   ///
   List<Node> getNodesInSelection(Selection selection);
 
@@ -50,7 +56,7 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
   /// [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
+  /// Return the [Node]s in multiple selection. Empty list would be returned
   ///   if no nodes are in range.
   ///
   /// [start] is the offset under the global coordinate system.
@@ -95,6 +101,92 @@ class FlowySelection extends StatefulWidget {
   State<FlowySelection> createState() => _FlowySelectionState();
 }
 
+/// 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.onPanStart,
+      this.onPanUpdate,
+      this.onPanEnd})
+      : super(key: key);
+
+  @override
+  State<_SelectionGestureDetector> createState() =>
+      _SelectionGestureDetectorState();
+
+  final Widget? child;
+
+  final GestureTapDownCallback? onTapDown;
+  final GestureTapDownCallback? onDoubleTapDown;
+  final GestureDragStartCallback? onPanStart;
+  final GestureDragUpdateCallback? onPanUpdate;
+  final GestureDragEndCallback? onPanEnd;
+}
+
+class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> {
+  bool _isDoubleTap = false;
+  Timer? _doubleTapTimer;
+  @override
+  Widget build(BuildContext context) {
+    return RawGestureDetector(
+      behavior: HitTestBehavior.translucent,
+      gestures: {
+        PanGestureRecognizer:
+            GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
+          () => PanGestureRecognizer(),
+          (recognizer) {
+            recognizer
+              ..onStart = widget.onPanStart
+              ..onUpdate = widget.onPanUpdate
+              ..onEnd = widget.onPanEnd;
+          },
+        ),
+        TapGestureRecognizer:
+            GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
+          () => TapGestureRecognizer(),
+          (recognizer) {
+            recognizer.onTapDown = _tapDownDelegate;
+          },
+        ),
+      },
+      child: widget.child,
+    );
+  }
+
+  _tapDownDelegate(TapDownDetails tapDownDetails) {
+    if (_isDoubleTap) {
+      _isDoubleTap = false;
+      _doubleTapTimer?.cancel();
+      _doubleTapTimer = null;
+      if (widget.onDoubleTapDown != null) {
+        widget.onDoubleTapDown!(tapDownDetails);
+      }
+    } else {
+      if (widget.onTapDown != null) {
+        widget.onTapDown!(tapDownDetails);
+      }
+
+      _isDoubleTap = true;
+      _doubleTapTimer?.cancel();
+      _doubleTapTimer = Timer(kDoubleTapTimeout, () {
+        _isDoubleTap = false;
+        _doubleTapTimer = null;
+      });
+    }
+  }
+
+  @override
+  void dispose() {
+    _doubleTapTimer?.cancel();
+    super.dispose();
+  }
+}
+
 class _FlowySelectionState extends State<FlowySelection>
     with FlowySelectionService, WidgetsBindingObserver {
   final _cursorKey = GlobalKey(debugLabel: 'cursor');
@@ -110,6 +202,8 @@ class _FlowySelectionState extends State<FlowySelection>
   /// Tap
   Offset? tapOffset;
 
+  final List<Rect> _rects = [];
+
   EditorState get editorState => widget.editorState;
 
   @override
@@ -146,33 +240,24 @@ class _FlowySelectionState extends State<FlowySelection>
 
   @override
   Widget build(BuildContext context) {
-    return RawGestureDetector(
-      behavior: HitTestBehavior.translucent,
-      gestures: {
-        PanGestureRecognizer:
-            GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
-          () => PanGestureRecognizer(),
-          (recognizer) {
-            recognizer
-              ..onStart = _onPanStart
-              ..onUpdate = _onPanUpdate
-              ..onEnd = _onPanEnd;
-          },
-        ),
-        TapGestureRecognizer:
-            GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
-          () => TapGestureRecognizer(),
-          (recognizer) {
-            recognizer.onTapDown = _onTapDown;
-          },
-        ),
-      },
+    return _SelectionGestureDetector(
+      onPanStart: _onPanStart,
+      onPanUpdate: _onPanUpdate,
+      onPanEnd: _onPanEnd,
+      onTapDown: _onTapDown,
+      onDoubleTapDown: _onDoubleTapDown,
       child: widget.child,
     );
   }
 
+  @override
+  List<Rect> rects() {
+    return _rects;
+  }
+
   @override
   void updateSelection(Selection selection) {
+    _rects.clear();
     _clearSelection();
 
     // cursor
@@ -267,6 +352,22 @@ class _FlowySelectionState extends State<FlowySelection>
     return false;
   }
 
+  void _onDoubleTapDown(TapDownDetails details) {
+    final offset = details.globalPosition;
+    final nodes = getNodesInRange(offset);
+    if (nodes.isEmpty) {
+      editorState.updateCursorSelection(null);
+      return;
+    }
+    final selectable = nodes.first.selectable;
+    if (selectable == null) {
+      editorState.updateCursorSelection(null);
+      return;
+    }
+    editorState
+        .updateCursorSelection(selectable.getWorldBoundaryInOffset(offset));
+  }
+
   void _onTapDown(TapDownDetails details) {
     // clear old state.
     panStartOffset = null;
@@ -274,16 +375,32 @@ class _FlowySelectionState extends State<FlowySelection>
 
     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);
-      }
+    final position = hitTest(tapOffset);
+    if (position == null) {
+      return;
     }
+    final selection = Selection.collapsed(position);
+    editorState.updateCursorSelection(selection);
+  }
+
+  @override
+  Position? hitTest(Offset? offset) {
+    if (offset == null) {
+      editorState.updateCursorSelection(null);
+      return null;
+    }
+    final nodes = getNodesInRange(offset);
+    if (nodes.isEmpty) {
+      editorState.updateCursorSelection(null);
+      return null;
+    }
+    assert(nodes.length == 1);
+    final selectable = nodes.first.selectable;
+    if (selectable == null) {
+      editorState.updateCursorSelection(null);
+      return null;
+    }
+    return selectable.getPositionInOffset(offset);
   }
 
   void _onPanStart(DragStartDetails details) {
@@ -314,7 +431,7 @@ class _FlowySelectionState extends State<FlowySelection>
       final selection = Selection(
           start: isDownward ? start : end, end: isDownward ? end : start);
       debugPrint('[_onPanUpdate] $selection');
-      updateSelection(selection);
+      editorState.updateCursorSelection(selection);
     }
   }
 
@@ -385,6 +502,7 @@ class _FlowySelectionState extends State<FlowySelection>
       final rects = selectable.getRectsInSelection(newSelection);
 
       for (final rect in rects) {
+        _rects.add(_transformRectToGlobal(selectable, rect));
         final overlay = OverlayEntry(
           builder: ((context) => SelectionWidget(
                 color: widget.selectionColor,
@@ -399,6 +517,11 @@ class _FlowySelectionState extends State<FlowySelection>
     Overlay.of(context)?.insertAll(_selectionOverlays);
   }
 
+  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);
 
@@ -413,6 +536,7 @@ class _FlowySelectionState extends State<FlowySelection>
     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,
@@ -423,9 +547,15 @@ class _FlowySelectionState extends State<FlowySelection>
       );
       _cursorOverlays.add(cursor);
       Overlay.of(context)?.insertAll(_cursorOverlays);
+      _forceShowCursor();
     }
   }
 
+  _forceShowCursor() {
+    final currentState = _cursorKey.currentState as CursorWidgetState?;
+    currentState?.show();
+  }
+
   List<Node> _selectedNodesInSelection(Node node, Selection selection) {
     List<Node> result = [];
     if (node.parent != null) {

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

@@ -7,7 +7,7 @@ import 'package:flowy_editor/operation/transaction.dart';
 import 'package:flowy_editor/editor_state.dart';
 import 'package:flutter/foundation.dart';
 
-/// This class contains operations committed by users.
+/// A [HistoryItem] contains list of operations committed by users.
 /// If a [HistoryItem] is not sealed, operations can be added sequentially.
 /// Otherwise, the operations should be added to a new [HistoryItem].
 class HistoryItem extends LinkedListEntry<HistoryItem> {

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/pubspec.yaml

@@ -24,11 +24,11 @@ dev_dependencies:
 
 # The following section is specific to Flutter packages.
 flutter:
-
   # To add assets to your package, add an assets section, like this:
   assets:
     - assets/images/uncheck.svg
     - assets/images/
+    - assets/document.json
   #   - images/a_dot_burr.jpeg
   #   - images/a_dot_ham.jpeg
   #

+ 83 - 0
frontend/app_flowy/packages/flowy_editor/test/operation_test.dart

@@ -0,0 +1,83 @@
+import 'dart:collection';
+
+import 'package:flowy_editor/document/node.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flowy_editor/operation/operation.dart';
+import 'package:flowy_editor/operation/transaction_builder.dart';
+import 'package:flowy_editor/editor_state.dart';
+import 'package:flowy_editor/document/state_tree.dart';
+import 'package:flowy_editor/render/render_plugins.dart';
+
+void main() {
+  group('transform path', () {
+    test('transform path changed', () {
+      expect(transformPath([0, 1], [0, 1]), [0, 2]);
+      expect(transformPath([0, 1], [0, 2]), [0, 3]);
+      expect(transformPath([0, 1], [0, 2, 7, 8, 9]), [0, 3, 7, 8, 9]);
+      expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]);
+    });
+    test("transform path not changed", () {
+      expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]);
+      expect(transformPath([0, 1, 2], [0, 1]), [0, 1]);
+      expect(transformPath([1, 1], [1, 0]), [1, 0]);
+    });
+    test("transform path delta", () {
+      expect(transformPath([0, 1], [0, 1], 5), [0, 6]);
+    });
+  });
+  group('transform operation', () {
+    test('insert + insert', () {
+      final t = transformOperation(
+          InsertOperation(path: [
+            0,
+            1
+          ], value: Node(type: "node", attributes: {}, children: LinkedList())),
+          InsertOperation(
+              path: [0, 1],
+              value:
+                  Node(type: "node", attributes: {}, children: LinkedList())));
+      expect(t.path, [0, 2]);
+    });
+    test('delete + delete', () {
+      final t = transformOperation(
+          DeleteOperation(
+              path: [0, 1],
+              removedValue:
+                  Node(type: "node", attributes: {}, children: LinkedList())),
+          DeleteOperation(
+              path: [0, 2],
+              removedValue:
+                  Node(type: "node", attributes: {}, children: LinkedList())));
+      expect(t.path, [0, 1]);
+    });
+  });
+  test('transform transaction builder', () {
+    final item1 = Node(type: "node", attributes: {}, children: LinkedList());
+    final item2 = Node(type: "node", attributes: {}, children: LinkedList());
+    final item3 = Node(type: "node", attributes: {}, children: LinkedList());
+    final root = Node(
+        type: "root",
+        attributes: {},
+        children: LinkedList()
+          ..addAll([
+            item1,
+            item2,
+            item3,
+          ]));
+    final state = EditorState(
+        document: StateTree(root: root), renderPlugins: RenderPlugins());
+
+    expect(item1.path, [0]);
+    expect(item2.path, [1]);
+    expect(item3.path, [2]);
+
+    final tb = TransactionBuilder(state);
+    tb.deleteNode(item1);
+    tb.deleteNode(item2);
+    tb.deleteNode(item3);
+    final transaction = tb.finish();
+    expect(transaction.operations[0].path, [0]);
+    expect(transaction.operations[1].path, [0]);
+    expect(transaction.operations[2].path, [0]);
+  });
+}