Просмотр исходного кода

feat: support incremental updates for textblock's delta. (#3216)

* feat: support incremental to update textblock's delta

* fix: update test code

* fix: remove console

* fix: update test

* feat: integrate increamental delta in Flutter

* fix: delete quill editor

* fix: delete quill editor

* feat: add csharp in codeblock (#3371)

* chore: pt-PT & pt-BR translation updated  (#3353)

* chore: Ensure Cargo.lock Is Updated Alongside Changes to Cargo.toml (#3361)

* ci: add cargo check workflow

* ci: test cargo.toml

* fix: update test

* fix: code review

* fix: update cargo.toml and cargo.lock

* fix: code review

* fix: rust format

---------

Co-authored-by: Lucas.Xu <[email protected]>
Co-authored-by: Mayur Mahajan <[email protected]>
Co-authored-by: Carlos Silva <[email protected]>
Kilu.He 2 лет назад
Родитель
Сommit
c7af04b317
81 измененных файлов с 2858 добавлено и 1777 удалено
  1. 1 0
      .github/workflows/tauri_ci.yaml
  2. 19 9
      frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart
  3. 41 4
      frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart
  4. 49 5
      frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart
  5. 175 24
      frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart
  6. 6 4
      frontend/appflowy_flutter/lib/util/json_print.dart
  7. 0 1
      frontend/appflowy_flutter/pubspec.yaml
  8. 33 33
      frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart
  9. 3 1
      frontend/appflowy_tauri/.gitignore
  10. 18 0
      frontend/appflowy_tauri/jest.config.cjs
  11. 9 2
      frontend/appflowy_tauri/package.json
  12. 269 81
      frontend/appflowy_tauri/pnpm-lock.yaml
  13. 17 23
      frontend/appflowy_tauri/src-tauri/Cargo.lock
  14. 9 9
      frontend/appflowy_tauri/src-tauri/Cargo.toml
  15. 8 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts
  16. 4 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx
  17. 4 22
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts
  18. 5 33
      frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts
  19. 6 11
      frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx
  20. 4 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  21. 1 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
  22. 18 13
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts
  23. 5 8
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts
  24. 10 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts
  25. 0 23
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.css
  26. 0 30
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.tsx
  27. 0 100
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/useEditor.ts
  28. 11 19
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
  29. 13 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
  30. 23 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  31. 4 32
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts
  32. 36 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts
  33. 1 10
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  34. 1 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts
  35. 13 5
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  36. 29 2
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_bd_svc.ts
  37. 50 24
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
  38. 3 2
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_bd_svc.ts
  39. 0 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts
  40. 0 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts
  41. 0 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts
  42. 50 22
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts
  43. 0 57
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts
  44. 40 16
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
  45. 3 213
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts
  46. 22 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts
  47. 20 14
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
  48. 83 146
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
  49. 36 32
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts
  50. 12 101
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
  51. 82 125
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
  52. 24 16
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts
  53. 77 30
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts
  54. 2 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts
  55. 12 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  56. 277 0
      frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/block_delta.test.ts
  57. 322 0
      frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/document_state.ts
  58. 24 212
      frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
  59. 4 2
      frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts
  60. 453 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts
  61. 0 82
      frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts
  62. 59 6
      frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts
  63. 5 5
      frontend/appflowy_tauri/src/appflowy_app/utils/log.ts
  64. 0 43
      frontend/appflowy_tauri/src/tests/user.test.ts
  65. 2 2
      frontend/appflowy_tauri/tsconfig.json
  66. 17 23
      frontend/rust-lib/Cargo.lock
  67. 8 8
      frontend/rust-lib/Cargo.toml
  68. 0 5
      frontend/rust-lib/flowy-document2/src/document.rs
  69. 13 2
      frontend/rust-lib/flowy-document2/src/document_data.rs
  70. 57 2
      frontend/rust-lib/flowy-document2/src/entities.rs
  71. 37 3
      frontend/rust-lib/flowy-document2/src/event_handler.rs
  72. 8 0
      frontend/rust-lib/flowy-document2/src/event_map.rs
  73. 55 14
      frontend/rust-lib/flowy-document2/src/parser/json/parser.rs
  74. 3 1
      frontend/rust-lib/flowy-document2/tests/document/document_insert_test.rs
  75. 3 1
      frontend/rust-lib/flowy-document2/tests/document/document_redo_undo_test.rs
  76. 12 5
      frontend/rust-lib/flowy-document2/tests/document/document_test.rs
  77. 20 0
      frontend/rust-lib/flowy-document2/tests/parser/json/parser_test.rs
  78. 2 0
      frontend/rust-lib/flowy-error/src/code.rs
  79. 65 8
      frontend/rust-lib/flowy-test/src/document/document_event.rs
  80. 11 8
      frontend/rust-lib/flowy-test/src/document/utils.rs
  81. 40 7
      frontend/rust-lib/flowy-test/tests/document/local_test/test.rs

+ 1 - 0
.github/workflows/tauri_ci.yaml

@@ -94,6 +94,7 @@ jobs:
           mkdir dist
           pnpm install
           cargo make --cwd .. tauri_build
+          pnpm test
           pnpm test:errors
 
       - name: Check for uncommitted changes

+ 19 - 9
frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart

@@ -5,7 +5,6 @@ import 'package:appflowy/plugins/document/application/document_data_pb_extension
 import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
 import 'package:appflowy/plugins/trash/application/trash_service.dart';
 import 'package:appflowy/user/application/user_service.dart';
-import 'package:appflowy/util/json_print.dart';
 import 'package:appflowy/workspace/application/doc/doc_listener.dart';
 import 'package:appflowy/workspace/application/view/view_listener.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
@@ -122,10 +121,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
   /// subscribe to the document content change
   void _onDocumentChanged() {
     _documentListener.start(
-      didReceiveUpdate: (docEvent) {
-        // todo: integrate the document change to the editor
-        // prettyPrintJson(docEvent.toProto3Json());
-      },
+      didReceiveUpdate: syncDocumentDataPB,
     );
   }
 
@@ -143,10 +139,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
   }
 
   Future<void> _initAppFlowyEditorState(DocumentDataPB data) async {
-    if (kDebugMode) {
-      prettyPrintJson(data.toProto3Json());
-    }
-
     final document = data.toDocument();
     if (document == null) {
       assert(false, 'document is null');
@@ -213,6 +205,24 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
       await editorState.apply(transaction);
     }
   }
+
+  void syncDocumentDataPB(DocEventPB docEvent) {
+    // prettyPrintJson(docEvent.toProto3Json());
+    // todo: integrate the document change to the editor
+    // for (final event in docEvent.events) {
+    //   for (final blockEvent in event.event) {
+    //     switch (blockEvent.command) {
+    //       case DeltaTypePB.Inserted:
+    //         break;
+    //       case DeltaTypePB.Updated:
+    //         break;
+    //       case DeltaTypePB.Removed:
+    //         break;
+    //       default:
+    //     }
+    //   }
+    // }
+  }
 }
 
 @freezed

+ 41 - 4
frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart

@@ -1,9 +1,8 @@
-import 'package:dartz/dartz.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
-
-import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:dartz/dartz.dart';
 
 class DocumentService {
   // unused now.
@@ -46,4 +45,42 @@ class DocumentService {
     final result = await DocumentEventApplyAction(payload).send();
     return result.swap();
   }
+
+  /// Creates a new external text.
+  ///
+  /// Normally, it's used to the block that needs sync long text.
+  ///
+  /// the delta parameter is the json representation of the delta.
+  Future<Either<FlowyError, Unit>> createExternalText({
+    required String documentId,
+    required String textId,
+    String? delta,
+  }) async {
+    final payload = TextDeltaPayloadPB(
+      documentId: documentId,
+      textId: textId,
+      delta: delta,
+    );
+    final result = await DocumentEventCreateText(payload).send();
+    return result.swap();
+  }
+
+  /// Updates the external text.
+  ///
+  /// this function is compatible with the [createExternalText] function.
+  ///
+  /// the delta parameter is the json representation of the delta too.
+  Future<Either<FlowyError, Unit>> updateExternalText({
+    required String documentId,
+    required String textId,
+    String? delta,
+  }) async {
+    final payload = TextDeltaPayloadPB(
+      documentId: documentId,
+      textId: textId,
+      delta: delta,
+    );
+    final result = await DocumentEventApplyTextDeltaEvent(payload).send();
+    return result.swap();
+  }
 }

+ 49 - 5
frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart

@@ -3,10 +3,27 @@ import 'dart:convert';
 import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
 import 'package:appflowy_editor/appflowy_editor.dart'
-    show Document, Node, Attributes, Delta, ParagraphBlockKeys, NodeIterator;
+    show
+        Document,
+        Node,
+        Attributes,
+        Delta,
+        ParagraphBlockKeys,
+        NodeIterator,
+        NodeExternalValues;
 import 'package:collection/collection.dart';
 import 'package:nanoid/nanoid.dart';
 
+class ExternalValues extends NodeExternalValues {
+  const ExternalValues({
+    required this.externalId,
+    required this.externalType,
+  });
+
+  final String externalId;
+  final String externalType;
+}
+
 extension DocumentDataPBFromTo on DocumentDataPB {
   static DocumentDataPB? fromDocument(Document document) {
     final startNode = document.first;
@@ -84,24 +101,51 @@ extension DocumentDataPBFromTo on DocumentDataPB {
       children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull());
     }
 
-    return block?.toNode(children: children);
+    return block?.toNode(
+      children: children,
+      meta: meta,
+    );
   }
 }
 
 extension BlockToNode on BlockPB {
   Node toNode({
     Iterable<Node>? children,
+    required MetaPB meta,
   }) {
-    return Node(
+    final node = Node(
       id: id,
       type: ty,
-      attributes: _dataAdapter(ty, data),
+      attributes: _dataAdapter(ty, data, meta),
       children: children ?? [],
     );
+    node.externalValues = ExternalValues(
+      externalId: externalId,
+      externalType: externalType,
+    );
+    return node;
   }
 
-  Attributes _dataAdapter(String ty, String data) {
+  Attributes _dataAdapter(String ty, String data, MetaPB meta) {
     final map = Attributes.from(jsonDecode(data));
+
+    // it used in the delta case now.
+    final externalType = this.externalType;
+    final externalId = this.externalId;
+    if (externalType.isNotEmpty && externalId.isNotEmpty) {
+      // the 'text' type is the only type that is supported now.
+      if (externalType == 'text') {
+        final deltaString = meta.textMap[externalId];
+        if (deltaString != null) {
+          final delta = jsonDecode(deltaString);
+          map.putIfAbsent(
+            'delta',
+            () => delta,
+          );
+        }
+      }
+    }
+
     final adapter = {
       ParagraphBlockKeys.type: (Attributes map) => map
         ..putIfAbsent(

+ 175 - 24
frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart

@@ -1,4 +1,5 @@
 import 'dart:async';
+import 'dart:convert';
 
 import 'package:appflowy/plugins/document/application/doc_service.dart';
 import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
@@ -15,6 +16,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'
         PathExtensions,
         Node,
         Path,
+        Delta,
         composeAttributes;
 import 'package:collection/collection.dart';
 import 'package:nanoid/nanoid.dart';
@@ -32,28 +34,66 @@ class TransactionAdapter {
   final DocumentService documentService;
   final String documentId;
 
+  final bool _enableDebug = false;
+
   Future<void> apply(Transaction transaction, EditorState editorState) async {
+    final stopwatch = Stopwatch()..start();
     Log.debug('transaction => ${transaction.toJson()}');
     final actions = transaction.operations
-        .map((op) => op.toBlockAction(editorState))
+        .map((op) => op.toBlockAction(editorState, documentId))
         .whereNotNull()
         .expand((element) => element)
         .toList(growable: false); // avoid lazy evaluation
-    Log.debug('actions => $actions');
+    final textActions = actions.where(
+      (e) =>
+          e.textDeltaType != TextDeltaType.none && e.textDeltaPayloadPB != null,
+    );
+    final actionCostTime = stopwatch.elapsedMilliseconds;
+    for (final textAction in textActions) {
+      final payload = textAction.textDeltaPayloadPB!;
+      final type = textAction.textDeltaType;
+      if (type == TextDeltaType.create) {
+        await documentService.createExternalText(
+          documentId: payload.documentId,
+          textId: payload.textId,
+          delta: payload.delta,
+        );
+        Log.debug('create external text: ${payload.delta}');
+      } else if (type == TextDeltaType.update) {
+        await documentService.updateExternalText(
+          documentId: payload.documentId,
+          textId: payload.textId,
+          delta: payload.delta,
+        );
+        Log.debug('update external text: ${payload.delta}');
+      }
+    }
+    final blockActions =
+        actions.map((e) => e.blockActionPB).toList(growable: false);
     await documentService.applyAction(
       documentId: documentId,
-      actions: actions,
+      actions: blockActions,
     );
+    final elapsed = stopwatch.elapsedMilliseconds;
+    stopwatch.stop();
+    if (_enableDebug) {
+      Log.debug(
+        'apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms',
+      );
+    }
   }
 }
 
 extension BlockAction on Operation {
-  List<BlockActionPB> toBlockAction(EditorState editorState) {
+  List<BlockActionWrapper> toBlockAction(
+    EditorState editorState,
+    String documentId,
+  ) {
     final op = this;
     if (op is InsertOperation) {
-      return op.toBlockAction(editorState);
+      return op.toBlockAction(editorState, documentId);
     } else if (op is UpdateOperation) {
-      return op.toBlockAction(editorState);
+      return op.toBlockAction(editorState, documentId);
     } else if (op is DeleteOperation) {
       return op.toBlockAction(editorState);
     }
@@ -62,12 +102,13 @@ extension BlockAction on Operation {
 }
 
 extension on InsertOperation {
-  List<BlockActionPB> toBlockAction(
-    EditorState editorState, {
+  List<BlockActionWrapper> toBlockAction(
+    EditorState editorState,
+    String documentId, {
     Node? previousNode,
   }) {
     Path currentPath = path;
-    final List<BlockActionPB> actions = [];
+    final List<BlockActionWrapper> actions = [];
     for (final node in nodes) {
       final parentId = node.parent?.id ??
           editorState.getNodeAtPath(currentPath.parent)?.id ??
@@ -82,22 +123,58 @@ extension on InsertOperation {
       } else {
         assert(prevId.isNotEmpty && prevId != node.id);
       }
+
+      // create the external text if the node contains the delta in its data.
+      final delta = node.delta;
+      TextDeltaPayloadPB? textDeltaPayloadPB;
+      if (delta != null) {
+        final textId = nanoid(6);
+
+        textDeltaPayloadPB = TextDeltaPayloadPB(
+          documentId: documentId,
+          textId: textId,
+          delta: jsonEncode(node.delta!.toJson()),
+        );
+
+        // sync the text id to the node
+        node.externalValues = ExternalValues(
+          externalId: textId,
+          externalType: 'text',
+        );
+      }
+
+      // remove the delta from the data when the incremental update is stable.
       final payload = BlockActionPayloadPB()
-        ..block = node.toBlock(childrenId: nanoid(10))
+        ..block = node.toBlock(childrenId: nanoid(6))
         ..parentId = parentId
         ..prevId = prevId;
+
+      // pass the external text id to the payload.
+      if (textDeltaPayloadPB != null) {
+        payload.textId = textDeltaPayloadPB.textId;
+      }
+
       assert(payload.block.childrenId.isNotEmpty);
+      final blockActionPB = BlockActionPB()
+        ..action = BlockActionTypePB.Insert
+        ..payload = payload;
+
       actions.add(
-        BlockActionPB()
-          ..action = BlockActionTypePB.Insert
-          ..payload = payload,
+        BlockActionWrapper(
+          blockActionPB: blockActionPB,
+          textDeltaPayloadPB: textDeltaPayloadPB,
+          textDeltaType: TextDeltaType.create,
+        ),
       );
       if (node.children.isNotEmpty) {
         Node? prevChild;
         for (final child in node.children) {
           actions.addAll(
-            InsertOperation(currentPath + child.path, [child])
-                .toBlockAction(editorState, previousNode: prevChild),
+            InsertOperation(currentPath + child.path, [child]).toBlockAction(
+              editorState,
+              documentId,
+              previousNode: prevChild,
+            ),
           );
           prevChild = child;
         }
@@ -110,8 +187,11 @@ extension on InsertOperation {
 }
 
 extension on UpdateOperation {
-  List<BlockActionPB> toBlockAction(EditorState editorState) {
-    final List<BlockActionPB> actions = [];
+  List<BlockActionWrapper> toBlockAction(
+    EditorState editorState,
+    String documentId,
+  ) {
+    final List<BlockActionWrapper> actions = [];
 
     // if the attributes are both empty, we don't need to update
     if (const DeepCollectionEquality().equals(attributes, oldAttributes)) {
@@ -125,23 +205,74 @@ extension on UpdateOperation {
     final parentId =
         node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? '';
     assert(parentId.isNotEmpty);
+
+    // create the external text if the node contains the delta in its data.
+    final prevDelta = oldAttributes['delta'];
+    final delta = attributes['delta'];
+    final diff = prevDelta != null && delta != null
+        ? Delta.fromJson(prevDelta).diff(
+            Delta.fromJson(delta),
+          )
+        : null;
+
     final payload = BlockActionPayloadPB()
       ..block = node.toBlock(
         parentId: parentId,
         attributes: composeAttributes(oldAttributes, attributes),
       )
       ..parentId = parentId;
-    actions.add(
-      BlockActionPB()
-        ..action = BlockActionTypePB.Update
-        ..payload = payload,
-    );
+    final blockActionPB = BlockActionPB()
+      ..action = BlockActionTypePB.Update
+      ..payload = payload;
+
+    final textId = (node.externalValues as ExternalValues?)?.externalId;
+    if (textId == null || textId.isEmpty) {
+      // to be compatible with the old version, we create a new text id if the text id is empty.
+      final textId = nanoid(6);
+      final textDeltaPayloadPB = delta == null
+          ? null
+          : TextDeltaPayloadPB(
+              documentId: documentId,
+              textId: textId,
+              delta: jsonEncode(delta),
+            );
+
+      node.externalValues = ExternalValues(
+        externalId: textId,
+        externalType: 'text',
+      );
+
+      actions.add(
+        BlockActionWrapper(
+          blockActionPB: blockActionPB,
+          textDeltaPayloadPB: textDeltaPayloadPB,
+          textDeltaType: TextDeltaType.create,
+        ),
+      );
+    } else {
+      final textDeltaPayloadPB = delta == null
+          ? null
+          : TextDeltaPayloadPB(
+              documentId: documentId,
+              textId: textId,
+              delta: jsonEncode(diff),
+            );
+
+      actions.add(
+        BlockActionWrapper(
+          blockActionPB: blockActionPB,
+          textDeltaPayloadPB: textDeltaPayloadPB,
+          textDeltaType: TextDeltaType.update,
+        ),
+      );
+    }
+
     return actions;
   }
 }
 
 extension on DeleteOperation {
-  List<BlockActionPB> toBlockAction(EditorState editorState) {
+  List<BlockActionWrapper> toBlockAction(EditorState editorState) {
     final List<BlockActionPB> actions = [];
     for (final node in nodes) {
       final parentId =
@@ -158,6 +289,26 @@ extension on DeleteOperation {
           ..payload = payload,
       );
     }
-    return actions;
+    return actions
+        .map((e) => BlockActionWrapper(blockActionPB: e))
+        .toList(growable: false);
   }
 }
+
+enum TextDeltaType {
+  none,
+  create,
+  update,
+}
+
+class BlockActionWrapper {
+  BlockActionWrapper({
+    required this.blockActionPB,
+    this.textDeltaType = TextDeltaType.none,
+    this.textDeltaPayloadPB,
+  });
+
+  final BlockActionPB blockActionPB;
+  final TextDeltaPayloadPB? textDeltaPayloadPB;
+  final TextDeltaType textDeltaType;
+}

+ 6 - 4
frontend/appflowy_flutter/lib/util/json_print.dart

@@ -1,6 +1,8 @@
-// import 'dart:convert';
-// import 'package:appflowy_backend/log.dart';
-// const JsonEncoder _encoder = JsonEncoder.withIndent('  ');
+import 'dart:convert';
+
+import 'package:appflowy_backend/log.dart';
+
+const JsonEncoder _encoder = JsonEncoder.withIndent('  ');
 void prettyPrintJson(Object? object) {
-  // Log.trace(_encoder.convert(object));
+  Log.trace(_encoder.convert(object));
 }

+ 0 - 1
frontend/appflowy_flutter/pubspec.yaml

@@ -44,7 +44,6 @@ dependencies:
     git:
       url: https://github.com/AppFlowy-IO/appflowy-board.git
       ref: a183c57
-  # appflowy_editor: 1.2.3
   appflowy_editor:
     git:
       url: https://github.com/AppFlowy-IO/appflowy-editor.git

+ 33 - 33
frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart

@@ -1,7 +1,7 @@
+import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter_test/flutter_test.dart';
-import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
 
 void main() {
   group('TransactionAdapter', () {
@@ -24,81 +24,81 @@ void main() {
       expect(transaction.operations.length, 1);
       expect(transaction.operations[0] is InsertOperation, true);
 
-      final actions = transaction.operations[0].toBlockAction(editorState);
+      final actions = transaction.operations[0].toBlockAction(editorState, '');
 
       expect(actions.length, 7);
       for (final action in actions) {
-        expect(action.action, BlockActionTypePB.Insert);
+        expect(action.blockActionPB.action, BlockActionTypePB.Insert);
       }
 
       expect(
-        actions[0].payload.parentId,
+        actions[0].blockActionPB.payload.parentId,
         editorState.document.root.id,
         reason: '0 - parent id',
       );
       expect(
-        actions[0].payload.prevId,
+        actions[0].blockActionPB.payload.prevId,
         editorState.document.root.children.first.id,
         reason: '0 - prev id',
       );
       expect(
-        actions[1].payload.parentId,
-        actions[0].payload.block.id,
+        actions[1].blockActionPB.payload.parentId,
+        actions[0].blockActionPB.payload.block.id,
         reason: '1 - parent id',
       );
       expect(
-        actions[1].payload.prevId,
+        actions[1].blockActionPB.payload.prevId,
         '',
         reason: '1 - prev id',
       );
       expect(
-        actions[2].payload.parentId,
-        actions[1].payload.block.id,
+        actions[2].blockActionPB.payload.parentId,
+        actions[1].blockActionPB.payload.block.id,
         reason: '2 - parent id',
       );
       expect(
-        actions[2].payload.prevId,
+        actions[2].blockActionPB.payload.prevId,
         '',
         reason: '2 - prev id',
       );
       expect(
-        actions[3].payload.parentId,
-        actions[0].payload.block.id,
+        actions[3].blockActionPB.payload.parentId,
+        actions[0].blockActionPB.payload.block.id,
         reason: '3 - parent id',
       );
       expect(
-        actions[3].payload.prevId,
-        actions[1].payload.block.id,
+        actions[3].blockActionPB.payload.prevId,
+        actions[1].blockActionPB.payload.block.id,
         reason: '3 - prev id',
       );
       expect(
-        actions[4].payload.parentId,
-        actions[0].payload.block.id,
+        actions[4].blockActionPB.payload.parentId,
+        actions[0].blockActionPB.payload.block.id,
         reason: '4 - parent id',
       );
       expect(
-        actions[4].payload.prevId,
-        actions[3].payload.block.id,
+        actions[4].blockActionPB.payload.prevId,
+        actions[3].blockActionPB.payload.block.id,
         reason: '4 - prev id',
       );
       expect(
-        actions[5].payload.parentId,
-        actions[4].payload.block.id,
+        actions[5].blockActionPB.payload.parentId,
+        actions[4].blockActionPB.payload.block.id,
         reason: '5 - parent id',
       );
       expect(
-        actions[5].payload.prevId,
+        actions[5].blockActionPB.payload.prevId,
         '',
         reason: '5 - prev id',
       );
       expect(
-        actions[6].payload.parentId,
-        actions[0].payload.block.id,
+        actions[6].blockActionPB.payload.parentId,
+        actions[0].blockActionPB.payload.block.id,
         reason: '6 - parent id',
       );
       expect(
-        actions[6].payload.prevId,
-        actions[4].payload.block.id,
+        actions[6].blockActionPB.payload.prevId,
+        actions[4].blockActionPB.payload.block.id,
         reason: '6 - prev id',
       );
     });
@@ -120,31 +120,31 @@ void main() {
       expect(transaction.operations.length, 1);
       expect(transaction.operations[0] is InsertOperation, true);
 
-      final actions = transaction.operations[0].toBlockAction(editorState);
+      final actions = transaction.operations[0].toBlockAction(editorState, '');
 
       expect(actions.length, 2);
       for (final action in actions) {
-        expect(action.action, BlockActionTypePB.Insert);
+        expect(action.blockActionPB.action, BlockActionTypePB.Insert);
       }
 
       expect(
-        actions[0].payload.parentId,
+        actions[0].blockActionPB.payload.parentId,
         editorState.document.root.children.first.id,
         reason: '0 - parent id',
       );
       expect(
-        actions[0].payload.prevId,
+        actions[0].blockActionPB.payload.prevId,
         '',
         reason: '0 - prev id',
       );
       expect(
-        actions[1].payload.parentId,
+        actions[1].blockActionPB.payload.parentId,
         editorState.document.root.children.first.id,
         reason: '1 - parent id',
       );
       expect(
-        actions[1].payload.prevId,
-        actions[0].payload.block.id,
+        actions[1].blockActionPB.payload.prevId,
+        actions[0].blockActionPB.payload.block.id,
         reason: '1 - prev id',
       );
     });

+ 3 - 1
frontend/appflowy_tauri/.gitignore

@@ -25,4 +25,6 @@ dist-ssr
 
 **/src/services/backend/models/
 **/src/services/backend/events/
-**/src/appflowy_app/i18n/translations/
+**/src/appflowy_app/i18n/translations/
+
+coverage

+ 18 - 0
frontend/appflowy_tauri/jest.config.cjs

@@ -0,0 +1,18 @@
+const { compilerOptions } = require('./tsconfig.json');
+const { pathsToModuleNameMapper } = require("ts-jest");
+
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+  preset: 'ts-jest',
+  testEnvironment: 'node',
+  roots: ['<rootDir>'],
+  modulePaths: [compilerOptions.baseUrl],
+  moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
+  "transform": {
+    "(.*)/node_modules/nanoid/.+\\.(j|t)sx?$": "ts-jest"
+  },
+  "transformIgnorePatterns": [
+    "node_modules/(?!nanoid/.*)"
+  ],
+  "testRegex": "(/__tests__/.*\.(test|spec))\\.(jsx?|tsx?)$",
+};

+ 9 - 2
frontend/appflowy_tauri/package.json

@@ -14,7 +14,8 @@
     "tauri:clean": "cargo make --cwd .. tauri_clean",
     "tauri:dev": "pnpm sync:i18n && tauri dev",
     "sync:i18n": "node scripts/i18n/index.cjs",
-    "css:variables": "node style-dictionary/config.cjs"
+    "css:variables": "node style-dictionary/config.cjs",
+    "test": "jest"
   },
   "dependencies": {
     "@emoji-mart/data": "^1.1.2",
@@ -70,6 +71,7 @@
     "@tauri-apps/cli": "^1.2.2",
     "@types/google-protobuf": "^3.15.6",
     "@types/is-hotkey": "^0.1.7",
+    "@types/jest": "^29.5.3",
     "@types/katex": "^0.16.0",
     "@types/node": "^18.7.10",
     "@types/prismjs": "^1.26.0",
@@ -86,17 +88,22 @@
     "@typescript-eslint/parser": "^5.51.0",
     "@vitejs/plugin-react": "^3.0.0",
     "autoprefixer": "^10.4.13",
+    "babel-jest": "^29.6.2",
     "eslint": "^8.34.0",
     "eslint-plugin-react": "^7.32.2",
     "eslint-plugin-react-hooks": "^4.6.0",
+    "jest-environment-jsdom": "^29.6.2",
     "postcss": "^8.4.21",
     "prettier": "2.8.4",
     "prettier-plugin-tailwindcss": "^0.2.2",
     "style-dictionary": "^3.8.0",
     "tailwindcss": "^3.2.7",
+    "ts-jest": "^29.1.1",
+    "ts-node-dev": "^2.0.0",
+    "tsconfig-paths-jest": "^0.0.1",
     "typescript": "^4.6.4",
     "uuid": "^9.0.0",
     "vite": "^4.0.0",
     "vite-plugin-svgr": "^3.2.0"
   }
-}
+}

Разница между файлами не показана из-за своего большого размера
+ 269 - 81
frontend/appflowy_tauri/pnpm-lock.yaml


+ 17 - 23
frontend/appflowy_tauri/src-tauri/Cargo.lock

@@ -140,7 +140,7 @@ checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
 [[package]]
 name = "appflowy-integrate"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "collab",
@@ -729,7 +729,7 @@ dependencies = [
 [[package]]
 name = "collab"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -748,7 +748,7 @@ dependencies = [
 [[package]]
 name = "collab-database"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -777,7 +777,7 @@ dependencies = [
 [[package]]
 name = "collab-define"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "collab",
@@ -789,7 +789,7 @@ dependencies = [
 [[package]]
 name = "collab-derive"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -801,12 +801,13 @@ dependencies = [
 [[package]]
 name = "collab-document"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "collab",
  "collab-derive",
  "collab-persistence",
+ "lib0",
  "nanoid",
  "parking_lot",
  "serde",
@@ -820,7 +821,7 @@ dependencies = [
 [[package]]
 name = "collab-folder"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "chrono",
@@ -840,7 +841,7 @@ dependencies = [
 [[package]]
 name = "collab-persistence"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "async-trait",
  "bincode",
@@ -861,16 +862,17 @@ dependencies = [
 [[package]]
 name = "collab-plugins"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "async-trait",
  "collab",
  "collab-define",
  "collab-persistence",
- "collab-sync",
+ "collab-sync-protocol",
  "collab-ws",
  "futures-util",
+ "lib0",
  "parking_lot",
  "rand 0.8.5",
  "serde",
@@ -887,23 +889,15 @@ dependencies = [
 ]
 
 [[package]]
-name = "collab-sync"
+name = "collab-sync-protocol"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "bytes",
  "collab",
- "futures-util",
- "lib0",
  "md5",
- "parking_lot",
  "serde",
  "serde_json",
- "thiserror",
- "tokio",
- "tokio-stream",
- "tokio-util",
- "tracing",
  "y-sync",
  "yrs",
 ]
@@ -911,7 +905,7 @@ dependencies = [
 [[package]]
 name = "collab-user"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "collab",
@@ -927,10 +921,10 @@ dependencies = [
 [[package]]
 name = "collab-ws"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "bytes",
- "collab-sync",
+ "collab-sync-protocol",
  "futures-util",
  "serde",
  "serde_json",

+ 9 - 9
frontend/appflowy_tauri/src-tauri/Cargo.toml

@@ -34,15 +34,15 @@ default = ["custom-protocol"]
 custom-protocol = ["tauri/custom-protocol"]
 
 [patch.crates-io]
-collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
+collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
 
 #collab = { path = "../../../../AppFlowy-Collab/collab" }
 #collab-folder = { path = "../../../../AppFlowy-Collab/collab-folder" }

+ 8 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts

@@ -40,11 +40,10 @@ export function useRangeKeyDown() {
         },
         handler: (e: KeyboardEvent) => {
           if (!controller) return;
-          const insertDelta = new Delta().insert(e.key);
           dispatch(
             deleteRangeAndInsertThunk({
               controller,
-              insertDelta,
+              insertChar: e.key,
             })
           );
         },
@@ -104,6 +103,7 @@ export function useRangeKeyDown() {
         handler: (e: KeyboardEvent) => {
           if (!controller) return;
           const format = parseFormat(e);
+
           if (!format) return;
           dispatch(
             toggleFormatThunk({
@@ -122,19 +122,25 @@ export function useRangeKeyDown() {
       if (!rangeRef.current) {
         return;
       }
+
       const { anchor, focus } = rangeRef.current;
+
       if (!anchor || !focus) return;
 
       if (anchor.id === focus.id) {
         return;
       }
+
       e.stopPropagation();
       const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
       const lastIndex = filteredEvents.length - 1;
+
       if (lastIndex < 0) {
         return;
       }
+
       const lastEvent = filteredEvents[lastIndex];
+
       if (!lastEvent) return;
       e.preventDefault();
       lastEvent.handler(e);

+ 4 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx

@@ -22,11 +22,11 @@ import {
   SlashCommandOptionKey,
 } from '$app/interfaces/document';
 import { useAppDispatch } from '$app/stores/store';
-import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { Keyboard } from '$app/constants/document/keyboard';
 import { selectOptionByUpDown } from '$app/utils/document/menu';
+import { turnToBlockThunk } from '$app_reducers/document/async-actions';
 
 function BlockSlashMenu({
   id,
@@ -48,13 +48,11 @@ function BlockSlashMenu({
     async (type: BlockType, data?: BlockData<any>) => {
       if (!controller) return;
       await dispatch(
-        triggerSlashCommandActionThunk({
+        turnToBlockThunk({
           controller,
           id,
-          props: {
-            type,
-            data,
-          },
+          type,
+          data,
         })
       );
       onClose?.();

+ 4 - 22
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts

@@ -1,11 +1,9 @@
 import { useAppDispatch } from '$app/stores/store';
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { slashCommandActions } from '$app_reducers/document/slice';
-import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
-import Delta from 'quill-delta';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks';
-import { getDeltaText } from '$app/utils/document/delta';
+import { useSubscribePanelSearchText } from '$app/components/document/_shared/usePanelSearchText';
 
 export function useBlockSlash() {
   const dispatch = useAppDispatch();
@@ -68,28 +66,12 @@ export function useSubscribeSlash() {
   const slashCommandState = useSubscribeSlashState();
   const visible = slashCommandState.isSlashCommand;
   const blockId = slashCommandState.blockId;
-  const rightDistanceRef = useRef<number>(0);
-
-  const { node } = useSubscribeNode(blockId || '');
-
-  const slashText = useMemo(() => {
-    if (!node) return '';
-    const delta = new Delta(node.data.delta);
-    const length = delta.length();
-    const slicedDelta = delta.slice(0, length - rightDistanceRef.current);
-
-    return getDeltaText(slicedDelta);
-  }, [node]);
-
-  useEffect(() => {
-    if (!visible) return;
-    rightDistanceRef.current = new Delta(node.data.delta).length();
-  }, [visible]);
+  const { searchText } = useSubscribePanelSearchText({ blockId: '', open: visible });
 
   return {
     visible,
     blockId,
-    slashText,
+    slashText: searchText,
     hoverOption: slashCommandState.hoverOption,
   };
 }

+ 5 - 33
frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts

@@ -1,36 +1,7 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import Delta, { Op } from 'quill-delta';
-import { getDeltaText } from '$app/utils/document/delta';
-import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { useCallback, useEffect, useState } from 'react';
 import { useAppSelector } from '$app/stores/store';
 import { Page } from '$app_reducers/pages/slice';
 
-export function useSubscribeMentionSearchText({ blockId, open }: { blockId: string; open: boolean }) {
-  const [searchText, setSearchText] = useState<string>('');
-  const beforeOpenDeltaRef = useRef<Op[]>([]);
-  const { node } = useSubscribeNode(blockId);
-  const handleSearch = useCallback((newDelta: Delta) => {
-    const diff = new Delta(beforeOpenDeltaRef.current).diff(newDelta);
-    const text = getDeltaText(diff);
-
-    setSearchText(text);
-  }, []);
-
-  useEffect(() => {
-    if (!open) return;
-    handleSearch(new Delta(node?.data?.delta));
-  }, [handleSearch, node?.data?.delta, open]);
-
-  useEffect(() => {
-    if (!open) return;
-    beforeOpenDeltaRef.current = node?.data?.delta;
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [open]);
-
-  return {
-    searchText,
-  };
-}
 export function useMentionPopoverProps({ open }: { open: boolean }) {
   const [anchorPosition, setAnchorPosition] = useState<
     | {
@@ -43,12 +14,14 @@ export function useMentionPopoverProps({ open }: { open: boolean }) {
   const getPosition = useCallback(() => {
     const range = document.getSelection()?.getRangeAt(0);
     const rangeRect = range?.getBoundingClientRect();
+
     return rangeRect;
   }, []);
 
   useEffect(() => {
     if (open) {
       const position = getPosition();
+
       if (!position) return;
       setAnchorPosition({
         top: position.top + position.height || 0,
@@ -75,10 +48,9 @@ export function useLoadRecentPages(searchText: string) {
         return page;
       })
       .filter((page) => {
-        const text = searchText.slice(1, searchText.length);
-        if (!text) return true;
-        return page.name.toLowerCase().includes(text.toLowerCase());
+        return page.name.toLowerCase().includes(searchText.toLowerCase());
       });
+
     setRecentPages(recentPages);
   }, [pages, searchText]);
 

+ 6 - 11
frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx

@@ -1,16 +1,18 @@
-import React, { useCallback, useEffect } from 'react';
+import React, { useCallback } from 'react';
 import { useSubscribeMentionState } from '$app/components/document/_shared/SubscribeMention.hooks';
 import Popover from '@mui/material/Popover';
 import { useAppDispatch } from '$app/stores/store';
 import { mentionActions } from '$app_reducers/document/mention_slice';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-import { useMentionPopoverProps, useSubscribeMentionSearchText } from '$app/components/document/Mention/Mention.hooks';
+import { useMentionPopoverProps } from '$app/components/document/Mention/Mention.hooks';
 import RecentPages from '$app/components/document/Mention/RecentPages';
 import { formatMention, MentionType } from '$app_reducers/document/async-actions/mention';
+import { useSubscribePanelSearchText } from '$app/components/document/_shared/usePanelSearchText';
 
 function MentionPopover() {
   const { docId, controller } = useSubscribeDocument();
   const { open, blockId } = useSubscribeMentionState();
+
   const dispatch = useAppDispatch();
   const onClose = useCallback(() => {
     dispatch(
@@ -20,7 +22,7 @@ function MentionPopover() {
     );
   }, [dispatch, docId]);
 
-  const { searchText } = useSubscribeMentionSearchText({
+  const { searchText } = useSubscribePanelSearchText({
     blockId,
     open,
   });
@@ -29,12 +31,6 @@ function MentionPopover() {
     open,
   });
 
-  useEffect(() => {
-    if (searchText === '' && popoverOpen) {
-      onClose();
-    }
-  }, [searchText, popoverOpen, onClose]);
-
   const onSelectPage = useCallback(
     async (pageId: string) => {
       await dispatch(
@@ -70,8 +66,7 @@ function MentionPopover() {
     >
       <div
         style={{
-          boxShadow:
-            "var(--shadow-resize-popover)",
+          boxShadow: 'var(--shadow-resize-popover)',
         }}
         className={'flex w-[420px] flex-col rounded-md bg-bg-body px-4 py-2'}
       >

+ 4 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx

@@ -52,8 +52,11 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
           isActive,
         })
       );
+      const actived = await isFormatActive();
+
+      setIsActive(actived);
     },
-    [controller, dispatch, isActive]
+    [controller, dispatch, isActive, isFormatActive]
   );
 
   const addTemporaryInput = useCallback(

+ 1 - 10
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts

@@ -83,18 +83,9 @@ export function useKeyDown(id: string) {
           );
         },
       },
-      {
-        // handle @ key for mention panel
-        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
-          return e.key === '@';
-        },
-        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
-          dispatch(openMention({ docId }));
-        },
-      },
       ...turnIntoEvents,
     ];
-  }, [docId, commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
+  }, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
 
   const onKeyDown = useCallback(
     (e: React.KeyboardEvent<HTMLDivElement>) => {

+ 18 - 13
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts

@@ -6,7 +6,7 @@ import { blockConfig } from '$app/constants/document/config';
 
 import Delta, { Op } from 'quill-delta';
 import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
-import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { getBlock, getBlockDelta } from '$app/components/document/_shared/SubscribeNode.hooks';
 import isHotkey from 'is-hotkey';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { getDeltaText } from '$app/utils/document/delta';
@@ -23,9 +23,10 @@ export function useTurnIntoBlockEvents(id: string) {
     const range = rangeRef.current?.caret;
 
     if (!range || range.id !== id) return;
-    const node = getBlock(docId, id);
-    const delta = new Delta(node.data.delta || []);
 
+    const delta = getBlockDelta(docId, id);
+
+    if (!delta) return '';
     return getDeltaText(delta.slice(0, range.index));
   }, [docId, id, rangeRef]);
 
@@ -33,8 +34,9 @@ export function useTurnIntoBlockEvents(id: string) {
     const range = rangeRef.current?.caret;
 
     if (!range || range.id !== id) return;
-    const node = getBlock(docId, id);
-    const delta = new Delta(node.data.delta || []);
+    const delta = getBlockDelta(docId, id);
+
+    if (!delta) return '';
     const content = delta.slice(range.index);
 
     return new Delta(content);
@@ -174,9 +176,7 @@ export function useTurnIntoBlockEvents(id: string) {
               id,
               controller,
               type: BlockType.DividerBlock,
-              data: {
-                delta: delta?.ops as Op[],
-              },
+              data: {},
             })
           );
         },
@@ -187,12 +187,17 @@ export function useTurnIntoBlockEvents(id: string) {
           e.preventDefault();
           if (!controller) return;
           const defaultData = blockConfig[BlockType.CodeBlock].defaultData;
-          const data = {
-            ...defaultData,
-            delta: getDeltaContent()?.ops as Op[],
-          };
 
-          dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
+          dispatch(
+            turnToBlockThunk({
+              id,
+              data: {
+                ...defaultData,
+              },
+              type: BlockType.CodeBlock,
+              controller,
+            })
+          );
         },
       },
       {

+ 5 - 8
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts

@@ -1,6 +1,6 @@
 import { BlockType, NestedBlock } from '$app/interfaces/document';
 import { useCallback, useEffect, useState } from 'react';
-import Delta from 'quill-delta';
+import Delta, { Op } from 'quill-delta';
 import { useDelta } from '$app/components/document/_shared/EditorHooks/useDelta';
 
 export function useChange(node: NestedBlock<BlockType.TextBlock | BlockType.CodeBlock>) {
@@ -15,13 +15,10 @@ export function useChange(node: NestedBlock<BlockType.TextBlock | BlockType.Code
   }, [delta]);
 
   const onChange = useCallback(
-    (newContents: Delta, oldContents: Delta, _source?: string) => {
-      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-      // @ts-ignore
-      const isSame = newContents.diff(oldContents).ops.length === 0;
-      if (isSame) return;
-      setValue(newContents);
-      update(newContents);
+    async (ops: Op[], newDelta: Delta) => {
+      if (ops.length === 0) return;
+      setValue(newDelta);
+      await update(ops, newDelta);
     },
     [update]
   );

+ 10 - 7
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts

@@ -2,31 +2,34 @@ import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode
 import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
 import { useAppDispatch } from '$app/stores/store';
 import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions';
-import Delta from 'quill-delta';
+import Delta, { Op } from 'quill-delta';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) {
   const { controller } = useSubscribeDocument();
   const dispatch = useAppDispatch();
   const penddingRef = useRef(false);
-  const { node } = useSubscribeNode(id);
+  const { delta: deltaStr } = useSubscribeNode(id);
 
   const delta = useMemo(() => {
-    if (!node || !node.data.delta) return new Delta();
-    return new Delta(node.data.delta);
-  }, [node]);
+    if (!deltaStr) return new Delta();
+    const deltaJson = JSON.parse(deltaStr);
+
+    return new Delta(deltaJson);
+  }, [deltaStr]);
 
   useEffect(() => {
     onDeltaChange?.(delta);
   }, [delta, onDeltaChange]);
 
   const update = useCallback(
-    async (delta: Delta) => {
+    async (ops: Op[], newDelta: Delta) => {
       if (!controller) return;
       await dispatch(
         updateNodeDeltaThunk({
           id,
-          delta: delta.ops,
+          ops,
+          newDelta,
           controller,
         })
       );

+ 0 - 23
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.css

@@ -1,23 +0,0 @@
-.ql-container.ql-snow {
-    border: none;
-    font-family: 'Poppins', sans-serif;
-    font-size: inherit;
-    line-height: inherit;
-}
-.ql-editor {
-    outline: none;
-    max-width: 100%;
-    white-space: pre-wrap;
-    word-break: break-word;
-    padding: 4px 2px;
-    text-align: left;
-    flex-grow: 1;
-}
-
-.ql-editor.ql-blank::before {
-    left: 2px;
-    right: 2px;
-    font-style: normal;
-}
-
-

+ 0 - 30
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.tsx

@@ -1,30 +0,0 @@
-import React from 'react';
-import { useEditor } from '$app/components/document/_shared/QuillEditor/useEditor';
-import 'quill/dist/quill.snow.css';
-import './Editor.css';
-import { EditorProps } from '$app/interfaces/document';
-
-function Editor({
-  value,
-  onChange,
-  onSelectionChange,
-  selection,
-  placeholder = "Type '/' for commands",
-  ...props
-}: EditorProps) {
-  const { ref, editor } = useEditor({
-    value,
-    onChange,
-    onSelectionChange,
-    selection,
-    placeholder,
-  });
-  return (
-    <div className={'min-h-[30px]'}>
-      <div ref={ref} {...props} />
-      {!editor && <div className={'px-0.5 py-1 text-text-caption'}>{placeholder}</div>}
-    </div>
-  );
-}
-
-export default React.memo(Editor);

+ 0 - 100
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/useEditor.ts

@@ -1,100 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-import Quill, { Sources } from 'quill';
-import Delta from 'quill-delta';
-import { adaptDeltaForQuill } from '$app/utils/document/quill_editor';
-import { EditorProps } from '$app/interfaces/document';
-
-/**
- * Here we can use ts-ignore because the quill-delta's version of quill is not uploaded to DefinitelyTyped
- */
-export function useEditor({ placeholder, value, onChange, onSelectionChange, selection }: EditorProps) {
-  const ref = useRef<HTMLDivElement>(null);
-  const [editor, setEditor] = useState<Quill>();
-
-  useEffect(() => {
-    if (!ref.current) return;
-    const editor = new Quill(ref.current, {
-      modules: {
-        toolbar: false, // Snow includes toolbar by default
-      },
-      theme: 'snow',
-      formats: ['bold', 'italic', 'underline', 'strike', 'code'],
-      placeholder: placeholder || 'Please enter some text...',
-    });
-    const keyboard = editor.getModule('keyboard');
-    // clear all keyboard bindings
-    keyboard.bindings = {};
-    const initialDelta = new Delta(adaptDeltaForQuill(value?.ops || []));
-    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-    // @ts-ignore
-    editor.setContents(initialDelta);
-    setEditor(editor);
-  }, []);
-
-  // listen to text-change event
-  useEffect(() => {
-    if (!editor) return;
-    const onTextChange = (delta: Delta, oldContents: Delta, source: Sources) => {
-      const newContents = oldContents.compose(delta);
-      const newOps = adaptDeltaForQuill(newContents.ops, true);
-      const newDelta = new Delta(newOps);
-      onChange?.(newDelta, oldContents, source);
-      if (source === 'user') {
-        const selection = editor.getSelection(false);
-        onSelectionChange?.(selection, null, source);
-      }
-    };
-    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-    // @ts-ignore
-    editor.on('text-change', onTextChange);
-    return () => {
-      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-      // @ts-ignore
-      editor.off('text-change', onTextChange);
-    };
-  }, [editor, onChange, onSelectionChange]);
-
-  // listen to selection-change event
-  useEffect(() => {
-    const handleSelectionChange = () => {
-      if (!editor) return;
-      const selection = editor.getSelection(false);
-      onSelectionChange?.(selection, null, 'user');
-    };
-    document.addEventListener('selectionchange', handleSelectionChange);
-    return () => {
-      document.removeEventListener('selectionchange', handleSelectionChange);
-    };
-  }, [editor, onSelectionChange]);
-
-  // set value
-  useEffect(() => {
-    if (!editor) return;
-    const content = editor.getContents();
-
-    const newOps = adaptDeltaForQuill(value?.ops || []);
-    const newDelta = new Delta(newOps);
-
-    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-    // @ts-ignore
-    const diffDelta = content.diff(newDelta);
-    const isSame = diffDelta.ops.length === 0;
-    if (isSame) return;
-    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-    // @ts-ignore
-    editor.updateContents(diffDelta, 'api');
-  }, [editor, value]);
-
-  // set Selection
-  useEffect(() => {
-    if (!editor || !selection) return;
-    if (JSON.stringify(selection) === JSON.stringify(editor.getSelection())) return;
-
-    editor.setSelection(selection);
-  }, [selection, editor]);
-
-  return {
-    ref,
-    editor,
-  };
-}

+ 11 - 19
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts

@@ -1,19 +1,13 @@
 import { EditorProps } from '$app/interfaces/document';
 import { useCallback, useEffect, useMemo, useRef } from 'react';
 import { ReactEditor } from 'slate-react';
-import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection, Transforms } from 'slate';
-import {
-  converToIndexLength,
-  convertToDelta,
-  convertToSlateSelection,
-  indent,
-  outdent,
-} from '$app/utils/document/slate_editor';
+import { BaseRange, Editor, NodeEntry, Range, Selection, Transforms } from 'slate';
+import { converToIndexLength, convertToSlateSelection, indent, outdent } from '$app/utils/document/slate_editor';
 import { focusNodeByIndex } from '$app/utils/document/node';
 import { Keyboard } from '$app/constants/document/keyboard';
-import Delta from 'quill-delta';
 import isHotkey from 'is-hotkey';
 import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
+import { openMention } from '$app_reducers/document/async-actions/mention';
 
 const AFTER_RENDER_DELAY = 100;
 
@@ -27,7 +21,7 @@ export function useEditor({
   isCodeBlock,
   temporarySelection,
 }: EditorProps) {
-  const { editor } = useSlateYjs({ delta });
+  const { editor } = useSlateYjs({ delta, onChange });
   const ref = useRef<HTMLDivElement | null>(null);
   const newValue = useMemo(() => [], []);
   const onSelectionChangeHandler = useCallback(
@@ -39,15 +33,9 @@ export function useEditor({
     [editor, onSelectionChange]
   );
 
-  const onChangeHandler = useCallback(
-    (slateValue: Descendant[]) => {
-      const oldContents = delta || new Delta();
-      const newContents = convertToDelta(slateValue);
-      onChange?.(newContents, oldContents);
-      onSelectionChangeHandler(editor.selection);
-    },
-    [delta, editor, onChange, onSelectionChangeHandler]
-  );
+  const onChangeHandler = useCallback(() => {
+    onSelectionChangeHandler(editor.selection);
+  }, [editor, onSelectionChangeHandler]);
 
   // Prevent attributes from being applied when entering text at the beginning or end of an inline block.
   // For example, when entering text before or after a mentioned page,
@@ -62,11 +50,13 @@ export function useEditor({
     const currentSelection = editor.selection || [];
     let removeMark = markKeys.length > 0;
     const [_, path] = editor.node(currentSelection);
+
     if (removeMark) {
       const selectionStart = editor.start(currentSelection);
       const selectionEnd = editor.end(currentSelection);
       const isNodeEnd = editor.isEnd(selectionEnd, path);
       const isNodeStart = editor.isStart(selectionStart, path);
+
       removeMark = isNodeStart || isNodeEnd;
     }
 
@@ -85,6 +75,7 @@ export function useEditor({
       if (e.inputType === 'insertFromComposition') {
         e.preventDefault();
       }
+
       preventInlineBlockAttributeOverride();
     },
     [preventInlineBlockAttributeOverride]
@@ -195,6 +186,7 @@ export function useEditor({
     if (!slateSelection) return;
 
     const isEqual = JSON.stringify(slateSelection) === JSON.stringify(editor.selection);
+
     if (isFocused && isEqual) return;
 
     // why we didn't use slate api to change selection?

+ 13 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts

@@ -1,4 +1,4 @@
-import Delta from 'quill-delta';
+import Delta, { Op } from 'quill-delta';
 import { useEffect, useMemo, useState } from 'react';
 import * as Y from 'yjs';
 import { convertToSlateValue } from '$app/utils/document/slate_editor';
@@ -7,7 +7,7 @@ import { withReact } from 'slate-react';
 import { createEditor } from 'slate';
 import { withMarkdown } from '$app/components/document/_shared/SlateEditor/markdown';
 
-export function useSlateYjs({ delta }: { delta?: Delta }) {
+export function useSlateYjs({ delta, onChange }: { delta?: Delta; onChange: (ops: Op[], newDelta: Delta) => void }) {
   const [yText, setYText] = useState<Y.Text | undefined>(undefined);
   const sharedType = useMemo(() => {
     const yDoc = new Y.Doc();
@@ -26,15 +26,25 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
   // Connect editor in useEffect to comply with concurrent mode requirements.
   useEffect(() => {
     YjsEditor.connect(editor);
+    const observer = (event: Y.YTextEvent) => {
+      const ops = event.changes.delta as Op[];
+      const newDelta = new Delta(yText?.toDelta());
+
+      onChange(ops, newDelta);
+    };
+
+    yText?.observe(observer);
     return () => {
       YjsEditor.disconnect(editor);
+      yText?.unobserve(observer);
     };
-  }, [editor]);
+  }, [editor, yText, onChange]);
 
   useEffect(() => {
     if (!yText) return;
     const oldContents = new Delta(yText.toDelta());
     const diffDelta = oldContents.diff(delta || new Delta());
+
     if (diffDelta.ops.length === 0) return;
     yText.applyDelta(diffDelta.ops);
   }, [delta, editor, yText]);

+ 23 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts

@@ -3,6 +3,7 @@ import { createContext, useMemo } from 'react';
 import { Node } from '$app/interfaces/document';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
+import Delta from 'quill-delta';
 
 /**
  * Subscribe node information
@@ -11,10 +12,18 @@ import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
 export function useSubscribeNode(id: string) {
   const { docId } = useSubscribeDocument();
 
-  const node = useAppSelector<Node>((state) => {
+  const { node, delta } = useAppSelector<{
+    node: Node;
+    delta: string;
+  }>((state) => {
     const documentState = state[DOCUMENT_NAME][docId];
+    const node = documentState?.nodes[id];
+    const externalId = node?.externalId;
 
-    return documentState?.nodes[id];
+    return {
+      node,
+      delta: externalId ? documentState?.deltaMap[externalId] : '',
+    };
   });
 
   const childIds = useAppSelector<string[] | undefined>((state) => {
@@ -40,6 +49,7 @@ export function useSubscribeNode(id: string) {
   return {
     node: memoizedNode,
     childIds: memoizedChildIds,
+    delta,
     isSelected,
   };
 }
@@ -48,4 +58,15 @@ export function getBlock(docId: string, id: string) {
   return store.getState().document[docId]?.nodes[id];
 }
 
+export function getBlockDelta(docId: string, id: string) {
+  const node = getBlock(docId, id);
+
+  if (!node?.externalId) return;
+  const deltaStr = store.getState().document[docId]?.deltaMap[node.externalId];
+  const deltaJson = JSON.parse(deltaStr);
+  const delta = new Delta(deltaJson);
+
+  return delta;
+}
+
 export const NodeIdContext = createContext<string>('');

+ 4 - 32
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts

@@ -4,8 +4,6 @@ import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
 import { turnToBlockThunk } from '$app_reducers/document/async-actions';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-import Delta from 'quill-delta';
-import { getDeltaText } from '$app/utils/document/delta';
 import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
 
 export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
@@ -13,34 +11,6 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: ()
 
   const { controller, docId } = useSubscribeDocument();
 
-  const getTurnIntoData = useCallback(
-    (targetType: BlockType, sourceNode: NestedBlock) => {
-      if (targetType === sourceNode.type) return;
-      const config = blockConfig[targetType];
-      const defaultData = config.defaultData;
-      const data: BlockData<any> = {
-        ...defaultData,
-        delta: sourceNode?.data?.delta || [],
-      };
-
-      if (targetType === BlockType.EquationBlock) {
-        data.formula = getDeltaText(new Delta(sourceNode.data.delta));
-        delete data.delta;
-      }
-
-      if (sourceNode.type === BlockType.EquationBlock) {
-        data.delta = [
-          {
-            insert: node.data.formula,
-          },
-        ];
-      }
-
-      return data;
-    },
-    [node.data.formula]
-  );
-
   const turnIntoBlock = useCallback(
     async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {
       if (!controller || isSelected) {
@@ -48,8 +18,10 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: ()
         return;
       }
 
+      const config = blockConfig[type];
+      const defaultData = config.defaultData;
       const updateData = {
-        ...getTurnIntoData(type, node),
+        ...defaultData,
         ...data,
       };
 
@@ -70,7 +42,7 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: ()
         })
       );
     },
-    [controller, getTurnIntoData, node, dispatch, onClose, docId]
+    [controller, node, dispatch, onClose, docId]
   );
 
   const turnIntoHeading = useCallback(

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts

@@ -0,0 +1,36 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import Delta, { Op } from 'quill-delta';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { getDeltaText } from '$app/utils/document/delta';
+
+export function useSubscribePanelSearchText({ blockId, open }: { blockId: string; open: boolean }) {
+  const [searchText, setSearchText] = useState<string>('');
+  const beforeOpenDeltaRef = useRef<Op[]>([]);
+  const { delta } = useSubscribeNode(blockId);
+  const handleSearch = useCallback((newDelta: Delta) => {
+    const diff = new Delta(beforeOpenDeltaRef.current).diff(newDelta);
+    const text = getDeltaText(diff);
+
+    setSearchText(text);
+  }, []);
+
+  useEffect(() => {
+    if (!open || !delta) return;
+    handleSearch(new Delta(JSON.parse(delta)));
+  }, [handleSearch, delta, open]);
+
+  useEffect(() => {
+    if (!open) {
+      beforeOpenDeltaRef.current = [];
+      return;
+    }
+
+    beforeOpenDeltaRef.current = new Delta(JSON.parse(delta)).ops;
+    handleSearch(new Delta(JSON.parse(delta)));
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open]);
+
+  return {
+    searchText,
+  };
+}

+ 1 - 10
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -7,9 +7,7 @@ import { randomEmoji } from '$app/utils/document/emoji';
 export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.TextBlock]: {
     canAddChild: true,
-    defaultData: {
-      delta: [],
-    },
+    defaultData: {},
     splitProps: {
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineBlockType: BlockType.TextBlock,
@@ -25,7 +23,6 @@ export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.TodoListBlock]: {
     canAddChild: true,
     defaultData: {
-      delta: [],
       checked: false,
     },
     splitProps: {
@@ -36,7 +33,6 @@ export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.BulletedListBlock]: {
     canAddChild: true,
     defaultData: {
-      delta: [],
       format: 'default',
     },
     splitProps: {
@@ -47,7 +43,6 @@ export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.NumberedListBlock]: {
     canAddChild: true,
     defaultData: {
-      delta: [],
       format: 'default',
     },
     splitProps: {
@@ -58,7 +53,6 @@ export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.QuoteBlock]: {
     canAddChild: true,
     defaultData: {
-      delta: [],
       size: 'default',
     },
     splitProps: {
@@ -69,7 +63,6 @@ export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.CalloutBlock]: {
     canAddChild: true,
     defaultData: {
-      delta: [],
       icon: randomEmoji(),
     },
     splitProps: {
@@ -80,7 +73,6 @@ export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.ToggleListBlock]: {
     canAddChild: true,
     defaultData: {
-      delta: [],
       collapsed: false,
     },
     splitProps: {
@@ -92,7 +84,6 @@ export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.CodeBlock]: {
     canAddChild: false,
     defaultData: {
-      delta: [],
       language: 'javascript',
     },
   },

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts

@@ -12,4 +12,5 @@ export const BLOCK_MAP_NAME = 'blocks';
 export const META_NAME = 'meta';
 export const CHILDREN_MAP_NAME = 'children_map';
 
+export const TEXT_MAP_NAME = 'text_map';
 export const EQUATION_PLACEHOLDER = '$';

+ 13 - 5
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -62,9 +62,7 @@ export interface CalloutBlockData extends TextBlockData {
   icon: string;
 }
 
-export interface TextBlockData {
-  delta: Op[];
-}
+export type TextBlockData = Record<string, any>;
 
 export interface DividerBlockData {}
 
@@ -120,9 +118,11 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock
 export interface NestedBlock<Type = any> {
   id: string;
   type: BlockType;
-  data: BlockData<Type>;
+  data: BlockData<Type> | any;
   parent: string | null;
   children: string;
+  externalId?: string;
+  externalType?: string;
 }
 
 export type Node = NestedBlock;
@@ -133,12 +133,15 @@ export interface DocumentData {
   nodes: Record<string, Node>;
   // map of block id to children block ids
   children: Record<string, string[]>;
+
+  deltaMap: Record<string, string>;
 }
 export interface DocumentState {
   // map of block id to block
   nodes: Record<string, Node>;
   // map of block id to children block ids
   children: Record<string, string[]>;
+  deltaMap: Record<string, string>;
 }
 
 export interface SlashCommandState {
@@ -219,6 +222,9 @@ export enum ChangeType {
   ChildrenMapInsert,
   ChildrenMapUpdate,
   ChildrenMapDelete,
+  DeltaMapInsert,
+  DeltaMapUpdate,
+  DeltaMapDelete,
 }
 
 export interface BlockPBValue {
@@ -227,6 +233,8 @@ export interface BlockPBValue {
   parent: string;
   children: string;
   data: string;
+  external_id?: string;
+  external_type?: string;
 }
 
 export enum SplitRelationship {
@@ -308,7 +316,7 @@ export interface EditorProps {
   decorateSelection?: RangeStaticNoId;
   temporarySelection?: RangeStaticNoId;
   onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
-  onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
+  onChange: (ops: Op[], newDelta: Delta) => void;
   onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
 }
 

+ 29 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_bd_svc.ts

@@ -2,22 +2,23 @@ import {
   FlowyError,
   DocumentDataPB,
   OpenDocumentPayloadPB,
-  CreateDocumentPayloadPB,
   ApplyActionPayloadPB,
   BlockActionPB,
   CloseDocumentPayloadPB,
   DocumentRedoUndoPayloadPB,
   DocumentRedoUndoResponsePB,
+  TextDeltaPayloadPB,
 } from '@/services/backend';
 import { Result } from 'ts-results';
 import {
   DocumentEventApplyAction,
   DocumentEventCloseDocument,
   DocumentEventOpenDocument,
-  DocumentEventCreateDocument,
   DocumentEventCanUndoRedo,
   DocumentEventRedo,
   DocumentEventUndo,
+  DocumentEventCreateText,
+  DocumentEventApplyTextDeltaEvent,
 } from '@/services/backend/events/flowy-document2';
 
 export class DocumentBackendService {
@@ -27,6 +28,7 @@ export class DocumentBackendService {
     const payload = OpenDocumentPayloadPB.fromObject({
       document_id: this.viewId,
     });
+
     return DocumentEventOpenDocument(payload);
   };
 
@@ -35,13 +37,35 @@ export class DocumentBackendService {
       document_id: this.viewId,
       actions: actions,
     });
+
     return DocumentEventApplyAction(payload);
   };
 
+  createText = (textId: string, defaultDelta?: string): Promise<Result<void, FlowyError>> => {
+    const payload = TextDeltaPayloadPB.fromObject({
+      document_id: this.viewId,
+      text_id: textId,
+      delta: defaultDelta,
+    });
+
+    return DocumentEventCreateText(payload);
+  };
+
+  applyTextDelta = (textId: string, delta: string): Promise<Result<void, FlowyError>> => {
+    const payload = TextDeltaPayloadPB.fromObject({
+      document_id: this.viewId,
+      text_id: textId,
+      delta: delta,
+    });
+
+    return DocumentEventApplyTextDeltaEvent(payload);
+  };
+
   close = (): Promise<Result<void, FlowyError>> => {
     const payload = CloseDocumentPayloadPB.fromObject({
       document_id: this.viewId,
     });
+
     return DocumentEventCloseDocument(payload);
   };
 
@@ -49,6 +73,7 @@ export class DocumentBackendService {
     const payload = DocumentRedoUndoPayloadPB.fromObject({
       document_id: this.viewId,
     });
+
     return DocumentEventCanUndoRedo(payload);
   };
 
@@ -56,6 +81,7 @@ export class DocumentBackendService {
     const payload = DocumentRedoUndoPayloadPB.fromObject({
       document_id: this.viewId,
     });
+
     return DocumentEventUndo(payload);
   };
 
@@ -63,6 +89,7 @@ export class DocumentBackendService {
     const payload = DocumentRedoUndoPayloadPB.fromObject({
       document_id: this.viewId,
     });
+
     return DocumentEventRedo(payload);
   };
 }

+ 50 - 24
frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts

@@ -10,11 +10,10 @@ import {
   ChildrenPB,
 } from '@/services/backend';
 import { DocumentObserver } from './document_observer';
-import * as Y from 'yjs';
 import { get } from '@/appflowy_app/utils/tool';
 import { blockPB2Node } from '$app/utils/document/block';
 import { Log } from '$app/utils/log';
-import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
+import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME, TEXT_MAP_NAME } from '$app/constants/document/name';
 
 export class DocumentController {
   private readonly backendService: DocumentBackendService;
@@ -28,6 +27,10 @@ export class DocumentController {
     this.observer = new DocumentObserver(documentId);
   }
 
+  get backend() {
+    return this.backendService;
+  }
+
   open = async (): Promise<DocumentData> => {
     await this.observer.subscribe({
       didReceiveUpdate: this.updated,
@@ -44,20 +47,36 @@ export class DocumentController {
         });
       });
       const children: Record<string, string[]> = {};
+      const deltaMap: Record<string, string> = {};
 
       get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
         children[key] = child.children;
       });
+
+      get<Map<string, string>>(document.val, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => {
+        deltaMap[key] = delta;
+      });
       return {
         rootId: document.val.page_id,
         nodes,
         children,
+        deltaMap,
       };
     }
 
     return Promise.reject(document.val);
   };
 
+  applyTextDelta = async (textId: string, delta: string) => {
+    const result = await this.backendService.applyTextDelta(textId, delta);
+
+    if (result.ok) {
+      return;
+    }
+
+    return Promise.reject(result.err);
+  };
+
   applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => {
     Log.debug('applyActions', actions);
     if (actions.length === 0) return;
@@ -65,17 +84,40 @@ export class DocumentController {
   };
 
   getInsertAction = (node: Node, prevId: string | null) => {
-    // Here to make sure the delta is correct
-    this.composeDelta(node);
     return {
       action: BlockActionTypePB.Insert,
       payload: this.getActionPayloadByNode(node, prevId),
     };
   };
 
+  getInsertTextActions = (node: Node, delta: string, prevId: string | null) => {
+    const textId = node.externalId;
+
+    return [
+      {
+        action: BlockActionTypePB.InsertText,
+        payload: {
+          text_id: textId,
+          delta,
+        },
+      },
+      this.getInsertAction(node, prevId),
+    ];
+  };
+
+  getApplyTextDeltaAction = (node: Node, delta: string) => {
+    const textId = node.externalId;
+
+    return {
+      action: BlockActionTypePB.ApplyTextDelta,
+      payload: {
+        text_id: textId,
+        delta,
+      },
+    };
+  };
+
   getUpdateAction = (node: Node) => {
-    // Here to make sure the delta is correct
-    this.composeDelta(node);
     return {
       action: BlockActionTypePB.Update,
       payload: this.getActionPayloadByNode(node, ''),
@@ -152,31 +194,15 @@ export class DocumentController {
       children_id: node.children,
       data: JSON.stringify(node.data),
       ty: node.type,
+      external_id: node.externalId,
+      external_type: node.externalType,
     };
   };
 
-  private composeDelta = (node: Node) => {
-    const delta = node.data.delta;
-
-    if (!delta) {
-      return;
-    }
-
-    // we use yjs to compose delta, it can make sure the delta is correct
-    // for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }]
-    // but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }]
-    const ydoc = new Y.Doc();
-    const ytext = ydoc.getText(node.id);
-
-    ytext.applyDelta(delta);
-    Object.assign(node.data, { delta: ytext.toDelta() });
-  };
-
   private updated = (payload: Uint8Array) => {
     if (!this.onDocChange) return;
     const { events, is_remote } = DocEventPB.deserializeBinary(payload);
 
-    Log.debug('DocumentController', 'updated', { events, is_remote });
     events.forEach((blockEvent) => {
       blockEvent.event.forEach((_payload) => {
         this.onDocChange?.({

+ 3 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_bd_svc.ts

@@ -115,8 +115,9 @@ export class AuthBackendService {
     return UserEventSignIn(payload);
   };
 
-  signUp = (params: { name: string; email: string; password: string }) => {
-    const payload = SignUpPayloadPB.fromObject({ name: params.name, email: params.email, password: params.password });
+  signUp = (params: { name: string; email: string; password: string; }) => {
+    const deviceId = nanoid(8);
+    const payload = SignUpPayloadPB.fromObject({ name: params.name, email: params.email, password: params.password, device_id: deviceId });
 
     return UserEventSignUp(payload);
   };

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts

@@ -1,6 +1,5 @@
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { rectSelectionActions } from '$app_reducers/document/slice';
 import { getDuplicateActions } from '$app/utils/document/action';
 import { RootState } from '$app/stores/store';
 import { DOCUMENT_NAME } from '$app/constants/document/name';

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts

@@ -1,4 +1,3 @@
-import { DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { blockConfig } from '$app/constants/document/config';

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts

@@ -1,7 +1,6 @@
 export * from './delete';
 export * from './duplicate';
 export * from './insert';
-export * from './merge';
 export * from './update';
 export * from './indent';
 export * from './outdent';

+ 50 - 22
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts

@@ -1,47 +1,75 @@
-import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
+import { BlockData, BlockType } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { newBlock } from '$app/utils/document/block';
+import { generateId, newBlock } from '$app/utils/document/block';
 import { RootState } from '$app/stores/store';
 import { DOCUMENT_NAME } from '$app/constants/document/name';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
+import Delta from 'quill-delta';
 
 export const insertAfterNodeThunk = createAsyncThunk(
   'document/insertAfterNode',
-  async (payload: { id: string; controller: DocumentController; data?: BlockData<any>; type?: BlockType }, thunkAPI) => {
-    const {
-      controller,
-      type = BlockType.TextBlock,
-      data = {
-        delta: [],
-      },
-      id,
-    } = payload;
+  async (
+    payload: {
+      id: string;
+      controller: DocumentController;
+      type: BlockType;
+      data?: BlockData<any>;
+      defaultDelta?: Delta;
+    },
+    thunkAPI
+  ) => {
+    const { controller, id, type, data, defaultDelta } = payload;
     const { getState } = thunkAPI;
     const state = getState() as RootState;
     const docId = controller.documentId;
-    const docState = state[DOCUMENT_NAME][docId];
-    const node = docState.nodes[id];
+    const documentState = state[DOCUMENT_NAME][docId];
+    const node = documentState.nodes[id];
 
     if (!node) return;
     const parentId = node.parent;
 
     if (!parentId) return;
     // create new node
-    const newNode = newBlock<any>(type, parentId, data);
-    let nodeId = newNode.id;
-    const actions = [controller.getInsertAction(newNode, node.id)];
+    const actions = [];
+    let newNodeId;
+    const deltaOperator = new BlockDeltaOperator(documentState, controller);
+
+    if (defaultDelta) {
+      newNodeId = generateId();
+      actions.push(
+        ...deltaOperator.getNewTextLineActions({
+          blockId: newNodeId,
+          parentId,
+          prevId: node.id,
+          delta: defaultDelta,
+          type,
+        })
+      );
+    } else {
+      const newNode = newBlock<any>(type, parentId, data);
+
+      actions.push(controller.getInsertAction(newNode, node.id));
+      newNodeId = newNode.id;
+    }
 
     if (type === BlockType.DividerBlock) {
-      const newTextNode = newBlock<any>(BlockType.TextBlock, parentId, {
-        delta: [],
-      });
+      const nodeId = generateId();
 
-      nodeId = newTextNode.id;
-      actions.push(controller.getInsertAction(newTextNode, newNode.id));
+      actions.push(
+        ...deltaOperator.getNewTextLineActions({
+          blockId: nodeId,
+          parentId,
+          prevId: newNodeId,
+          delta: new Delta([{ insert: '' }]),
+          type: BlockType.TextBlock,
+        })
+      );
+      newNodeId = nodeId;
     }
 
     await controller.applyActions(actions);
 
-    return nodeId;
+    return newNodeId;
   }
 );

+ 0 - 57
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts

@@ -1,57 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { DocumentState } from '$app/interfaces/document';
-import Delta from 'quill-delta';
-import { blockConfig } from '$app/constants/document/config';
-import { getMoveChildrenActions } from '$app/utils/document/action';
-import { RootState } from '$app/stores/store';
-import { DOCUMENT_NAME } from '$app/constants/document/name';
-
-/**
- * Merge two blocks
- * 1. merge delta
- * 2. move children
- * 3. delete current block
- */
-export const mergeDeltaThunk = createAsyncThunk(
-  'document/mergeDelta',
-  async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => {
-    const { sourceId, targetId, controller } = payload;
-    const { getState } = thunkAPI;
-    const state = getState() as RootState;
-    const docId = controller.documentId;
-    const docState = state[DOCUMENT_NAME][docId];
-    const target = docState.nodes[targetId];
-    const source = docState.nodes[sourceId];
-
-    if (!target || !source) return;
-    const targetDelta = new Delta(target.data.delta);
-    const sourceDelta = new Delta(source.data.delta);
-    const mergeDelta = targetDelta.concat(sourceDelta);
-    const ops = mergeDelta.ops;
-    const updateAction = controller.getUpdateAction({
-      ...target,
-      data: {
-        ...target.data,
-        delta: ops,
-      },
-    });
-
-    const actions = [updateAction];
-    // move children
-    const children = docState.children[source.children].map((id) => docState.nodes[id]);
-    const moveActions = getMoveChildrenActions({
-      controller,
-      children,
-      target,
-    });
-
-    actions.push(...moveActions);
-    // delete current block
-    const deleteAction = controller.getDeleteAction(source);
-
-    actions.push(deleteAction);
-
-    await controller.applyActions(actions);
-  }
-);

+ 40 - 16
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts

@@ -1,4 +1,4 @@
-import { BlockData, DocumentState } from '$app/interfaces/document';
+import { BlockData } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import Delta, { Op } from 'quill-delta';
@@ -6,19 +6,51 @@ import { RootState } from '$app/stores/store';
 import { DOCUMENT_NAME } from '$app/constants/document/name';
 import { updatePageName } from '$app_reducers/pages/async_actions';
 import { getDeltaText } from '$app/utils/document/delta';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
+import { openMention, closeMention } from '$app_reducers/document/async-actions/mention';
+
+const updateNodeDeltaAfterThunk = createAsyncThunk(
+  'document/updateNodeDeltaAfter',
+  async (
+    payload: { docId: string; id: string; ops: Op[]; newDelta: Delta; oldDelta: Delta; controller: DocumentController },
+    thunkAPI
+  ) => {
+    const { dispatch } = thunkAPI;
+    const { docId, ops, oldDelta, newDelta } = payload;
+    const insertOps = ops.filter((op) => op.insert !== undefined);
+
+    const deleteOps = ops.filter((op) => op.delete !== undefined);
+    const oldText = getDeltaText(oldDelta);
+    const newText = getDeltaText(newDelta);
+    const deleteText = oldText.slice(newText.length);
+
+    if (insertOps.length === 1 && insertOps[0].insert === '@') {
+      dispatch(openMention({ docId }));
+    }
+
+    if (deleteOps.length === 1 && deleteText === '@') {
+      dispatch(closeMention({ docId }));
+    }
+  }
+);
 
 export const updateNodeDeltaThunk = createAsyncThunk(
   'document/updateNodeDelta',
-  async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
-    const { id, delta, controller } = payload;
+  async (payload: { id: string; ops: Op[]; newDelta: Delta; controller: DocumentController }, thunkAPI) => {
+    const { id, ops, newDelta, controller } = payload;
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docState = state[DOCUMENT_NAME][docId];
     const node = docState.nodes[id];
-    const oldDelta = new Delta(node.data.delta);
-    const newDelta = new Delta(delta);
 
+    const deltaOperator = new BlockDeltaOperator(docState, controller);
+    const oldDelta = deltaOperator.getDeltaWithBlockId(id);
+
+    if (!oldDelta) return;
+    const diff = oldDelta?.diff(newDelta);
+
+    if (ops.length === 0 || diff?.ops.length === 0) return;
     // If the node is the root node, update the page name
     if (!node.parent) {
       await dispatch(
@@ -30,18 +62,10 @@ export const updateNodeDeltaThunk = createAsyncThunk(
       return;
     }
 
-    const diffDelta = newDelta.diff(oldDelta);
-
-    if (diffDelta.ops.length === 0) return;
-
-    const newData = { ...node.data, delta };
+    if (!node.externalId) return;
 
-    await controller.applyActions([
-      controller.getUpdateAction({
-        ...node,
-        data: newData,
-      }),
-    ]);
+    await controller.applyTextDelta(node.externalId, JSON.stringify(ops));
+    await dispatch(updateNodeDeltaAfterThunk({ docId, id, ops, newDelta, oldDelta, controller }));
   }
 );
 

+ 3 - 213
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts

@@ -1,19 +1,6 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { RootState } from '$app/stores/store';
-import { getMiddleIds, getMoveChildrenActions, getStartAndEndIdsByRange } from '$app/utils/document/action';
-import { BlockCopyData, BlockType, DocumentBlockJSON } from '$app/interfaces/document';
-import Delta from 'quill-delta';
-import { getDeltaByRange } from '$app/utils/document/delta';
-import { deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions/range';
+import { BlockCopyData } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import {
-  generateBlocks,
-  getAppendBlockDeltaAction,
-  getCopyBlock,
-  getInsertBlockActions,
-} from '$app/utils/document/copy_paste';
-import { rangeActions } from '$app_reducers/document/slice';
-import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
 
 export const copyThunk = createAsyncThunk<
   void,
@@ -23,70 +10,7 @@ export const copyThunk = createAsyncThunk<
     setClipboardData: (data: BlockCopyData) => void;
   }
 >('document/copy', async (payload, thunkAPI) => {
-  const { getState, dispatch } = thunkAPI;
-  const { setClipboardData, isCut = false, controller } = payload;
-  const docId = controller.documentId;
-  const state = getState() as RootState;
-  const document = state[DOCUMENT_NAME][docId];
-  const documentRange = state[RANGE_NAME][docId];
-  const startAndEndIds = getStartAndEndIdsByRange(documentRange);
-
-  if (startAndEndIds.length === 0) return;
-  const result: DocumentBlockJSON[] = [];
-
-  if (startAndEndIds.length === 1) {
-    // copy single block
-    const id = startAndEndIds[0];
-    const node = document.nodes[id];
-    const nodeDelta = new Delta(node.data.delta);
-    const range = documentRange.ranges[id] || { index: 0, length: 0 };
-    const isFull = range.index === 0 && range.length === nodeDelta.length();
-
-    if (isFull) {
-      result.push(getCopyBlock(id, document, documentRange));
-    } else {
-      result.push({
-        type: BlockType.TextBlock,
-        children: [],
-        data: {
-          delta: getDeltaByRange(nodeDelta, range).ops,
-        },
-      });
-    }
-  } else {
-    // copy multiple blocks
-    const copyIds: string[] = [];
-    const [startId, endId] = startAndEndIds;
-    const middleIds = getMiddleIds(document, startId, endId);
-
-    copyIds.push(startId, ...middleIds, endId);
-    const map = new Map<string, DocumentBlockJSON>();
-
-    copyIds.forEach((id) => {
-      const block = getCopyBlock(id, document, documentRange);
-
-      map.set(id, block);
-      const node = document.nodes[id];
-      const parent = node.parent;
-
-      if (parent && map.has(parent)) {
-        map.get(parent)!.children.push(block);
-      } else {
-        result.push(block);
-      }
-    });
-  }
-
-  setClipboardData({
-    json: JSON.stringify(result),
-    // TODO: implement plain text and html
-    text: '',
-    html: '',
-  });
-  if (isCut) {
-    // delete range blocks
-    await dispatch(deleteRangeAndInsertThunk({ controller }));
-  }
+  // TODO: Migrate to Rust implementation.
 });
 
 /**
@@ -106,139 +30,5 @@ export const pasteThunk = createAsyncThunk<
     controller: DocumentController;
   }
 >('document/paste', async (payload, thunkAPI) => {
-  const { getState, dispatch } = thunkAPI;
-  const { data, controller } = payload;
-
-  // delete range blocks
-  await dispatch(deleteRangeAndInsertThunk({ controller }));
-
-  const state = getState() as RootState;
-  const docId = controller.documentId;
-  const document = state[DOCUMENT_NAME][docId];
-  const documentRange = state[RANGE_NAME][docId];
-
-  let pasteData;
-
-  if (data.json) {
-    pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
-  } else if (data.text) {
-    // TODO: implement plain text
-  } else if (data.html) {
-    // TODO: implement html
-  }
-
-  if (!pasteData) return;
-  const { caret } = documentRange;
-
-  if (!caret) return;
-  const currentBlock = document.nodes[caret.id];
-
-  if (!currentBlock.parent) return;
-  const pasteBlocks = generateBlocks(pasteData, currentBlock.parent);
-  const currentBlockDelta = new Delta(currentBlock.data.delta);
-  const type = currentBlock.type;
-  const actions = getInsertBlockActions(pasteBlocks, currentBlock.id, controller);
-  const firstPasteBlock = pasteBlocks[0];
-  const firstPasteBlockChildren = pasteBlocks.filter((block) => block.parent === firstPasteBlock.id);
-
-  const lastPasteBlock = pasteBlocks[pasteBlocks.length - 1];
-
-  if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) {
-    // move current block children to first paste block
-    const children = document.children[currentBlock.children].map((id) => document.nodes[id]);
-    const firstPasteBlockLastChild =
-      firstPasteBlockChildren.length > 0 ? firstPasteBlockChildren[firstPasteBlockChildren.length - 1] : undefined;
-    const prevId = firstPasteBlockLastChild ? firstPasteBlockLastChild.id : undefined;
-    const moveChildrenActions = getMoveChildrenActions({
-      target: firstPasteBlock,
-      children,
-      controller,
-      prevId,
-    });
-
-    actions.push(...moveChildrenActions);
-    // delete current block
-    actions.push(controller.getDeleteAction(currentBlock));
-    await controller.applyActions(actions);
-    // set caret to the end of the last paste block
-    dispatch(
-      rangeActions.setCaret({
-        docId,
-        caret: {
-          id: lastPasteBlock.id,
-          index: new Delta(lastPasteBlock.data.delta).length(),
-          length: 0,
-        },
-      })
-    );
-    return;
-  }
-
-  // split current block
-  const currentBeforeDelta = getDeltaByRange(currentBlockDelta, { index: 0, length: caret.index });
-  const currentAfterDelta = getDeltaByRange(currentBlockDelta, {
-    index: caret.index,
-    length: currentBlockDelta.length() - caret.index,
-  });
-
-  let newCaret: {
-    id: string;
-    index: number;
-    length: number;
-  };
-  const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
-  const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
-  let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
-
-  if (firstPasteBlock.id !== lastPasteBlock.id) {
-    // update the last block of paste data
-    actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller));
-    newCaret = {
-      id: lastPasteBlock.id,
-      index: lastPasteBlockDelta.length(),
-      length: 0,
-    };
-  } else {
-    newCaret = {
-      id: currentBlock.id,
-      index: mergeDelta.length(),
-      length: 0,
-    };
-    mergeDelta = mergeDelta.concat(currentAfterDelta);
-  }
-
-  // update current block and merge the first block of paste data
-  actions.push(
-    controller.getUpdateAction({
-      ...currentBlock,
-      data: {
-        ...currentBlock.data,
-        delta: mergeDelta.ops,
-      },
-    })
-  );
-
-  // move the first block children of paste data to current block
-  if (firstPasteBlockChildren.length > 0) {
-    const moveChildrenActions = getMoveChildrenActions({
-      target: currentBlock,
-      children: firstPasteBlockChildren,
-      controller,
-    });
-
-    actions.push(...moveChildrenActions);
-  }
-
-  // delete first block of paste data
-  actions.push(controller.getDeleteAction(firstPasteBlock));
-  await controller.applyActions(actions);
-  // set caret to the end of the last paste block
-  if (!newCaret) return;
-
-  dispatch(
-    rangeActions.setCaret({
-      docId,
-      caret: newCaret,
-    })
-  );
+  // TODO: Migrate to Rust implementation.
 });

+ 22 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts

@@ -0,0 +1,22 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { rangeActions } from '$app_reducers/document/slice';
+
+export const setCursorRangeThunk = createAsyncThunk(
+  'document/setCursorRange',
+  async (payload: { docId: string; blockId: string; index: number; length?: number }, thunkAPI) => {
+    const { blockId, index, docId, length = 0 } = payload;
+    const { dispatch } = thunkAPI;
+
+    dispatch(rangeActions.initialState(docId));
+    dispatch(
+      rangeActions.setCaret({
+        docId,
+        caret: {
+          id: blockId,
+          index,
+          length,
+        },
+      })
+    );
+  }
+);

+ 20 - 14
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts

@@ -4,6 +4,8 @@ import { TextAction } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import Delta from 'quill-delta';
 import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
+import { BlockActionPB } from '@/services/backend';
 
 type FormatValues = Record<string, (boolean | string | undefined)[]>;
 
@@ -15,6 +17,7 @@ export const getFormatValuesThunk = createAsyncThunk(
     const document = state[DOCUMENT_NAME][docId];
     const documentRange = state[RANGE_NAME][docId];
     const { ranges } = documentRange;
+    const deltaOperator = new BlockDeltaOperator(document);
     const mapAttrs = (delta: Delta, format: TextAction) => {
       return delta.ops.map((op) => op.attributes?.[format] as boolean | string | undefined);
     };
@@ -23,12 +26,13 @@ export const getFormatValuesThunk = createAsyncThunk(
 
     Object.entries(ranges).forEach(([id, range]) => {
       const node = document.nodes[id];
-      const delta = new Delta(node.data?.delta);
       const index = range?.index || 0;
       const length = range?.length || 0;
-      const rangeDelta = delta.slice(index, index + length);
+      const rangeDelta = deltaOperator.sliceDeltaWithBlockId(node.id, index, index + length);
 
-      formatValues[id] = mapAttrs(rangeDelta, format);
+      if (rangeDelta) {
+        formatValues[id] = mapAttrs(rangeDelta, format);
+      }
     });
     return formatValues;
   }
@@ -73,6 +77,7 @@ export const toggleFormatThunk = createAsyncThunk(
     }
 
     const formatValue = isActive ? null : true;
+
     await dispatch(formatThunk({ format, value: formatValue, controller }));
   }
 );
@@ -87,23 +92,24 @@ export const formatThunk = createAsyncThunk(
     const document = state[DOCUMENT_NAME][docId];
     const documentRange = state[RANGE_NAME][docId];
     const { ranges } = documentRange;
+    const deltaOperator = new BlockDeltaOperator(document, controller);
+    const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = [];
 
-    const actions = Object.entries(ranges).map(([id, range]) => {
+    Object.entries(ranges).forEach(([id, range]) => {
       const node = document.nodes[id];
-      const delta = new Delta(node.data?.delta);
+      const delta = deltaOperator.getDeltaWithBlockId(node.id);
+
+      if (!delta) return;
       const index = range?.index || 0;
       const length = range?.length || 0;
       const diffDelta: Delta = new Delta();
+
       diffDelta.retain(index).retain(length, { [format]: value });
-      const newDelta = delta.compose(diffDelta);
-
-      return controller.getUpdateAction({
-        ...node,
-        data: {
-          ...node.data,
-          delta: newDelta.ops,
-        },
-      });
+      const action = deltaOperator.getApplyDeltaAction(node.id, diffDelta);
+
+      if (action) {
+        actions.push(action);
+      }
     });
 
     await controller.applyActions(actions);

+ 83 - 146
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts

@@ -1,35 +1,29 @@
-import { createAsyncThunk } from "@reduxjs/toolkit";
-import { DocumentController } from "$app/stores/effects/document/document_controller";
-import { BlockType, RangeStatic, SplitRelationship } from "$app/interfaces/document";
-import { turnToTextBlockThunk } from "$app_reducers/document/async-actions/turn_to";
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { BlockType, RangeStatic } from '$app/interfaces/document';
+import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
 import {
-  findNextHasDeltaNode,
-  findPrevHasDeltaNode,
-  getInsertEnterNodeAction,
   getLeftCaretByRange,
   getRightCaretByRange,
   transformToNextLineCaret,
-  transformToPrevLineCaret
-} from "$app/utils/document/action";
-import Delta from "quill-delta";
-import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from "$app_reducers/document/async-actions/blocks";
-import { rangeActions } from "$app_reducers/document/slice";
-import { RootState } from "$app/stores/store";
-import { blockConfig } from "$app/constants/document/config";
-import { Keyboard } from "$app/constants/document/keyboard";
-import { DOCUMENT_NAME, RANGE_NAME } from "$app/constants/document/name";
-import { getDeltaText, getPreviousWordIndex } from "$app/utils/document/delta";
-import { updatePageName } from "$app_reducers/pages/async_actions";
-import { newBlock } from "$app/utils/document/block";
-
+  transformToPrevLineCaret,
+} from '$app/utils/document/action';
+import { indentNodeThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks';
+import { rangeActions } from '$app_reducers/document/slice';
+import { RootState } from '$app/stores/store';
+import { Keyboard } from '$app/constants/document/keyboard';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
+import { getPreviousWordIndex } from '$app/utils/document/delta';
+import { updatePageName } from '$app_reducers/pages/async_actions';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
+import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor';
 
 /**
  - Deletes a block using the backspace or delete key.
  - If the block is not a text block, it is converted into a text block.
  - If the block is a text block:
- - - If the block is the first line, it is merged into the document title, and a new line is inserted.
- - - If the block is not the first line and it has a next sibling, it is merged into the previous line (including the previous sibling and its parent).
- - - If the block has no next sibling and is not a top-level block, it is outdented (moved to a higher level in the hierarchy).
+ - - If the block has a next sibling, it is merged into the prev line (including its children).
+ - - If the block has no next sibling, it is outdented (moved to a higher level in the hierarchy).
  */
 export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
   'document/backspaceDeleteActionForBlock',
@@ -41,6 +35,14 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
     const node = state.nodes[id];
 
     if (!node.parent) return;
+    const deltaOperator = new BlockDeltaOperator(state, controller, async (name: string) => {
+      await dispatch(
+        updatePageName({
+          id: docId,
+          name,
+        })
+      );
+    });
     const parent = state.nodes[node.parent];
     const children = state.children[parent.children];
     const index = children.indexOf(id);
@@ -53,65 +55,31 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
     }
 
     const isTopLevel = parent.type === BlockType.PageBlock;
-    const isFirstLine = isTopLevel && index === 0;
 
-    if (isTopLevel && isFirstLine) {
-      // merge to document title and insert a new line
-      const parentDelta = new Delta(parent.data.delta);
-      const caretIndex = parentDelta.length();
-      const caret = {
-        id: parent.id,
-        index: caretIndex,
-        length: 0,
-      };
-      const titleDelta = parentDelta.concat(new Delta(node.data.delta));
-      await dispatch(updatePageName({ id: docId, name: getDeltaText(titleDelta) }));
-      const actions = [
-        controller.getDeleteAction(node),
-      ]
-
-      if (!nextNodeId) {
-        // insert a new line
-        const block = newBlock<any>(BlockType.TextBlock, parent.id, {
-          delta: [{ insert: "" }]
-        });
-        actions.push(controller.getInsertAction(block, null));
-      }
-      await controller.applyActions(actions);
-      dispatch(rangeActions.initialState(docId));
-      dispatch(
-        rangeActions.setCaret({
-          docId,
-          caret,
-        })
-      );
-      return;
-    }
     if (isTopLevel || nextNodeId) {
       // merge to previous line
-      const prevLine = findPrevHasDeltaNode(state, id);
-      if (!prevLine) return;
-      const caretIndex = new Delta(prevLine.data.delta).length();
+      const prevLineId = deltaOperator.findPrevTextLine(id);
+
+      if (!prevLineId) return;
+
+      const res = await deltaOperator.mergeText(prevLineId, id);
+
+      if (!res) return;
       const caret = {
-        id: prevLine.id,
-        index: caretIndex,
+        id: res.id,
+        index: res.index,
         length: 0,
       };
 
-      await dispatch(
-        mergeDeltaThunk({
-          sourceId: id,
-          targetId: prevLine.id,
-          controller,
-        })
-      );
-      dispatch(rangeActions.initialState(docId));
       dispatch(
-        rangeActions.setCaret({
+        setCursorRangeThunk({
           docId,
-          caret,
+          blockId: caret.id,
+          index: caret.index,
+          length: caret.length,
         })
       );
+
       return;
     }
 
@@ -121,10 +89,9 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
 );
 
 /**
- * Insert a new node after the current node by pressing enter.
- * 1. Split the current node into two nodes.
- * 2. Insert a new node after the current node.
- * 3. Move the children of the current node to the new node if needed.
+ * enter key handler
+ * 1. If node is empty, and it is not a text block, turn it into a text block.
+ * 2. Otherwise, split the node into two nodes.
  */
 export const enterActionForBlockThunk = createAsyncThunk(
   'document/insertNodeByEnter',
@@ -138,81 +105,45 @@ export const enterActionForBlockThunk = createAsyncThunk(
     const caret = state[RANGE_NAME][docId]?.caret;
 
     if (!node || !caret || caret.id !== id) return;
-    const delta = new Delta(node.data.delta);
-
-    const nodeDelta = delta.slice(0, caret.index);
-
-    const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
 
-    const isDocumentTitle = !node.parent;
-    // update page title and insert a new line
-    if (isDocumentTitle) {
-      // update page title
-      await dispatch(updatePageName({
-        id: docId,
-        name: getDeltaText(nodeDelta),
-      }));
-      // insert a new line
-      const block = newBlock<any>(BlockType.TextBlock, node.id, {
-        delta: insertNodeDelta.ops,
-      });
-      const insertNodeAction = controller.getInsertAction(block, null);
-      await controller.applyActions([insertNodeAction]);
-      dispatch(rangeActions.initialState(docId));
-      dispatch(
-        rangeActions.setCaret({
-          docId,
-          caret: {
-            id: block.id,
-            index: 0,
-            length: 0,
-          },
+    const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => {
+      await dispatch(
+        updatePageName({
+          id: docId,
+          name,
         })
       );
-      return;
-    }
+    });
+    const isDocumentTitle = !node.parent;
+    let newLineId;
+
+    const delta = deltaOperator.getDeltaWithBlockId(node.id);
 
-    if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
+    if (!delta) return;
+    if (!isDocumentTitle && delta.length() === 0 && node.type !== BlockType.TextBlock) {
       // If the node is not a text block, turn it to a text block
       await dispatch(turnToTextBlockThunk({ id, controller }));
       return;
     }
 
-
-    const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
-
-    if (!insertNodeAction) return;
-
-    const updateNode = {
-      ...node,
-      data: {
-        ...node.data,
-        delta: nodeDelta.ops,
+    newLineId = await deltaOperator.splitText(
+      {
+        id: node.id,
+        index: caret.index,
       },
-    };
-
-    const children = documentState.children[node.children];
-    const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
-    const moveChildrenAction = needMoveChildren
-      ? controller.getMoveChildrenAction(
-          children.map((id) => documentState.nodes[id]),
-          insertNodeAction.id,
-          ''
-        )
-      : [];
-    const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
-
-    await controller.applyActions(actions);
+      {
+        id: node.id,
+        index: caret.index + caret.length,
+      }
+    );
 
-    dispatch(rangeActions.initialState(docId));
+    if (!newLineId) return;
     dispatch(
-      rangeActions.setCaret({
+      setCursorRangeThunk({
         docId,
-        caret: {
-          id: insertNodeAction.id,
-          index: 0,
-          length: 0,
-        },
+        blockId: newLineId,
+        index: 0,
+        length: 0,
       })
     );
   }
@@ -275,7 +206,10 @@ export const leftActionForBlockThunk = createAsyncThunk(
 
     if (!node || !caret || id !== caret.id) return;
     let newCaret: RangeStatic;
+    const deltaOperator = new BlockDeltaOperator(documentState);
+    const delta = deltaOperator.getDeltaWithBlockId(node.id);
 
+    if (!delta) return;
     if (caret.length > 0) {
       newCaret = {
         id,
@@ -284,7 +218,6 @@ export const leftActionForBlockThunk = createAsyncThunk(
       };
     } else {
       if (caret.index > 0) {
-        const delta = new Delta(node.data.delta);
         const newIndex = getPreviousWordIndex(delta, caret.index);
 
         newCaret = {
@@ -293,13 +226,14 @@ export const leftActionForBlockThunk = createAsyncThunk(
           length: 0,
         };
       } else {
-        const prevNode = findPrevHasDeltaNode(documentState, id);
+        const prevNodeId = deltaOperator.findPrevTextLine(id);
 
-        if (!prevNode) return;
-        const prevDelta = new Delta(prevNode.data.delta);
+        if (!prevNodeId) return;
+        const prevDelta = deltaOperator.getDeltaWithBlockId(prevNodeId);
 
+        if (!prevDelta) return;
         newCaret = {
-          id: prevNode.id,
+          id: prevNodeId,
           index: prevDelta.length(),
           length: 0,
         };
@@ -333,7 +267,10 @@ export const rightActionForBlockThunk = createAsyncThunk(
 
     if (!node || !caret || id !== caret.id) return;
     let newCaret: RangeStatic;
-    const delta = new Delta(node.data.delta);
+    const deltaOperator = new BlockDeltaOperator(documentState);
+    const delta = deltaOperator.getDeltaWithBlockId(node.id);
+
+    if (!delta) return;
     const deltaLength = delta.length();
 
     if (caret.length > 0) {
@@ -352,11 +289,11 @@ export const rightActionForBlockThunk = createAsyncThunk(
           length: 0,
         };
       } else {
-        const nextNode = findNextHasDeltaNode(documentState, id);
+        const nextNodeId = deltaOperator.findNextTextLine(id);
 
-        if (!nextNode) return;
+        if (!nextNodeId) return;
         newCaret = {
-          id: nextNode.id,
+          id: nextNodeId,
           index: 0,
           length: 0,
         };

+ 36 - 32
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts

@@ -2,10 +2,10 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { RootState } from '$app/stores/store';
 import { DOCUMENT_NAME, MENTION_NAME, RANGE_NAME } from '$app/constants/document/name';
 import Delta from 'quill-delta';
-import { getDeltaText } from '$app/utils/document/delta';
 import { mentionActions } from '$app_reducers/document/mention_slice';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { rangeActions } from '$app_reducers/document/slice';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
+import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor';
 
 export enum MentionType {
   PAGE = 'page',
@@ -15,27 +15,16 @@ export const openMention = createAsyncThunk('document/mention/open', async (payl
   const { dispatch, getState } = thunkAPI;
   const state = getState() as RootState;
   const rangeState = state[RANGE_NAME][docId];
+  const documentState = state[DOCUMENT_NAME][docId];
   const { caret } = rangeState;
+
   if (!caret) return;
-  const { id, index } = caret;
-  const node = state[DOCUMENT_NAME][docId].nodes[id];
+  const { id } = caret;
+  const node = documentState.nodes[id];
+
   if (!node.parent) {
     return;
   }
-  const nodeDelta = new Delta(node.data?.delta);
-
-  const beforeDelta = nodeDelta.slice(0, index);
-  const beforeText = getDeltaText(beforeDelta);
-  let canOpenMention = !beforeText;
-  if (!canOpenMention) {
-    if (index === 1) {
-      canOpenMention = beforeText.endsWith('@');
-    } else {
-      canOpenMention = beforeText.endsWith(' ');
-    }
-  }
-
-  if (!canOpenMention) return;
 
   dispatch(
     mentionActions.open({
@@ -45,6 +34,17 @@ export const openMention = createAsyncThunk('document/mention/open', async (payl
   );
 });
 
+export const closeMention = createAsyncThunk('document/mention/close', async (payload: { docId: string }, thunkAPI) => {
+  const { docId } = payload;
+  const { dispatch } = thunkAPI;
+
+  dispatch(
+    mentionActions.close({
+      docId,
+    })
+  );
+});
+
 export const formatMention = createAsyncThunk(
   'document/mention/format',
   async (
@@ -58,12 +58,17 @@ export const formatMention = createAsyncThunk(
     const mentionState = state[MENTION_NAME][docId];
     const { blockId } = mentionState;
     const rangeState = state[RANGE_NAME][docId];
+    const documentState = state[DOCUMENT_NAME][docId];
     const caret = rangeState.caret;
+
     if (!caret) return;
     const index = caret.index - searchTextLength;
 
-    const node = state[DOCUMENT_NAME][docId].nodes[blockId];
-    const nodeDelta = new Delta(node.data?.delta);
+    const deltaOperator = new BlockDeltaOperator(documentState);
+
+    const nodeDelta = deltaOperator.getDeltaWithBlockId(blockId);
+
+    if (!nodeDelta) return;
     const diffDelta = new Delta()
       .retain(index)
       .delete(searchTextLength)
@@ -73,18 +78,17 @@ export const formatMention = createAsyncThunk(
           [type]: value,
         },
       });
-    const newDelta = nodeDelta.compose(diffDelta);
-    const updateAction = controller.getUpdateAction({
-      ...node,
-      data: {
-        ...node.data,
-        delta: newDelta.ops,
-      },
-    });
-
-    await controller.applyActions([updateAction]);
+    const applyTextDeltaAction = deltaOperator.getApplyDeltaAction(blockId, diffDelta);
 
-    dispatch(rangeActions.initialState(docId));
-    dispatch(rangeActions.setCaret({ docId, caret: { id: blockId, index, length: 0 } }));
+    if (!applyTextDeltaAction) return;
+    await controller.applyActions([applyTextDeltaAction]);
+    dispatch(
+      setCursorRangeThunk({
+        docId,
+        blockId,
+        index,
+        length: 0,
+      })
+    );
   }
 );

+ 12 - 101
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts

@@ -8,8 +8,9 @@ import { blockConfig } from '$app/constants/document/config';
 import Delta, { Op } from 'quill-delta';
 import { getDeltaText } from '$app/utils/document/delta';
 import { RootState } from '$app/stores/store';
-import { DOCUMENT_NAME } from '$app/constants/document/name';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
 import { blockEditActions } from '$app_reducers/document/block_edit_slice';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
 
 /**
  * add block below click
@@ -26,13 +27,19 @@ export const addBlockBelowClickThunk = createAsyncThunk(
     const node = state.nodes[id];
 
     if (!node) return;
-    const delta = (node.data.delta as Op[]) || [];
-    const text = delta.map((d) => d.insert).join('');
+    const deltaOperator = new BlockDeltaOperator(state, controller);
+    const delta = deltaOperator.getDeltaWithBlockId(id);
 
     // if current block is not empty, insert a new block after current block
-    if (node.type !== BlockType.TextBlock || text !== '') {
+    if (!delta || delta.length() > 1) {
       const { payload: newBlockId } = await dispatch(
-        insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
+        insertAfterNodeThunk({
+          id: id,
+          type: BlockType.TextBlock,
+          controller,
+          data: {},
+          defaultDelta: new Delta([{ insert: '' }]),
+        })
       );
 
       if (newBlockId) {
@@ -59,99 +66,3 @@ export const addBlockBelowClickThunk = createAsyncThunk(
     dispatch(slashCommandActions.openSlashCommand({ docId, blockId: id }));
   }
 );
-
-/**
- * slash command action be triggered
- * 1. if current block is empty, operate on current block
- * 2. if current block is not empty, insert a new block after current block and operate on new block
- */
-export const triggerSlashCommandActionThunk = createAsyncThunk(
-  'document/slashCommandAction',
-  async (
-    payload: {
-      id: string;
-      controller: DocumentController;
-      props: {
-        data?: BlockData<any>;
-        type: BlockType;
-      };
-    },
-    thunkAPI
-  ) => {
-    const { id, controller, props } = payload;
-    const { dispatch, getState } = thunkAPI;
-    const docId = controller.documentId;
-    const state = getState() as RootState;
-    const document = state[DOCUMENT_NAME][docId];
-    const node = document.nodes[id];
-
-    if (!node) return;
-    const delta = new Delta(node.data.delta);
-    const text = getDeltaText(delta);
-    const defaultData = blockConfig[props.type].defaultData;
-
-    if (node.type === BlockType.TextBlock && (text === '' || text === '/')) {
-      const { payload: newId } = await dispatch(
-        turnToBlockThunk({
-          id,
-          controller,
-          type: props.type,
-          data: {
-            ...defaultData,
-            ...props.data,
-          },
-        })
-      );
-
-      dispatch(
-        blockEditActions.setBlockEditState({
-          id: docId,
-          state: {
-            id: newId as string,
-            editing: true,
-          },
-        })
-      );
-      return;
-    }
-
-    // if current block has slash command, remove slash command
-    if (text.slice(0, 1) === '/') {
-      const updateNode = {
-        ...node,
-        data: {
-          ...node.data,
-          delta: delta.slice(1, delta.length()).ops,
-        },
-      };
-
-      await controller.applyActions([controller.getUpdateAction(updateNode)]);
-    }
-
-    const insertNodePayload = await dispatch(
-      insertAfterNodeThunk({
-        id,
-        controller,
-        type: props.type,
-        data: defaultData,
-      })
-    );
-    const newBlockId = insertNodePayload.payload as string;
-
-    dispatch(
-      rangeActions.setCaret({
-        docId,
-        caret: { id: newBlockId, index: 0, length: 0 },
-      })
-    );
-    dispatch(
-      blockEditActions.setBlockEditState({
-        id: docId,
-        state: {
-          id: newBlockId,
-          editing: true,
-        },
-      })
-    );
-  }
-);

+ 82 - 125
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts

@@ -1,19 +1,13 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { RootState } from '$app/stores/store';
 import { rangeActions } from '$app_reducers/document/slice';
-import Delta from 'quill-delta';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import {
-  getAfterMergeCaretByRange,
-  getInsertEnterNodeAction,
-  getMergeEndDeltaToStartActionsByRange,
-  getMiddleIds,
-  getMiddleIdsByRange,
-  getStartAndEndExtentDelta,
-} from '$app/utils/document/action';
-import { RangeState, SplitRelationship } from '$app/interfaces/document';
-import { blockConfig } from '$app/constants/document/config';
+import { getMiddleIds, getStartAndEndIdsByRange } from '$app/utils/document/action';
+import { RangeState } from '$app/interfaces/document';
 import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
+import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor';
+import { updatePageName } from '$app_reducers/pages/async_actions';
 
 interface storeRangeThunkPayload {
   docId: string;
@@ -71,18 +65,20 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
 
   // amend anchor range because slatejs will stop update selection when dragging quickly
   const isForward = anchor.point.y < focus.point.y;
-  const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
+  const deltaOperator = new BlockDeltaOperator(documentState);
 
   if (isForward) {
-    const selectedDelta = anchorDelta.slice(anchorIndex);
+    const selectedDelta = deltaOperator.sliceDeltaWithBlockId(anchor.id, anchorIndex);
 
+    if (!selectedDelta) return;
     ranges[anchor.id] = {
       index: anchorIndex,
       length: selectedDelta.length(),
     };
   } else {
-    const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
+    const selectedDelta = deltaOperator.sliceDeltaWithBlockId(anchor.id, 0, anchorIndex + anchorLength);
 
+    if (!selectedDelta) return;
     ranges[anchor.id] = {
       index: 0,
       length: selectedDelta.length(),
@@ -98,8 +94,10 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
   middleIds.forEach((id) => {
     const node = documentState.nodes[id];
 
-    if (!node || !node.data.delta) return;
-    const delta = new Delta(node.data.delta);
+    if (!node) return;
+    const delta = deltaOperator.getDeltaWithBlockId(node.id);
+
+    if (!delta) return;
     const rangeStatic = {
       index: 0,
       length: delta.length(),
@@ -120,48 +118,52 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
  * delete range and insert delta
  * 1. merge start and end delta to start node and delete end node
  * 2. delete middle nodes
+ * 3. move end node's children to start node
  * 3. clear range
  */
 export const deleteRangeAndInsertThunk = createAsyncThunk(
   'document/deleteRange',
-  async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => {
-    const { controller, insertDelta } = payload;
+  async (payload: { controller: DocumentController; insertChar?: string }, thunkAPI) => {
+    const { controller, insertChar } = payload;
     const docId = controller.documentId;
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
     const rangeState = state[RANGE_NAME][docId];
     const documentState = state[DOCUMENT_NAME][docId];
-
-    const actions = [];
-    // get merge actions
-    const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
-
-    if (mergeActions) {
-      actions.push(...mergeActions);
-    }
-
-    // get middle nodes
-    const middleIds = getMiddleIdsByRange(rangeState, documentState);
-    // delete middle nodes
-    const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
-
-    actions.push(...deleteMiddleNodesActions);
-
-    const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
-
-    if (actions.length === 0) return;
-    // apply actions
-    await controller.applyActions(actions);
-    // clear range
-    dispatch(rangeActions.initialState(docId));
-    if (caret) {
-      dispatch(
-        rangeActions.setCaret({
-          docId,
-          caret,
+    const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => {
+      await dispatch(
+        updatePageName({
+          id: docId,
+          name,
         })
       );
-    }
+    });
+    const [startId, endId] = getStartAndEndIdsByRange(rangeState);
+    const startSelection = rangeState.ranges[startId];
+    const endSelection = rangeState.ranges[endId];
+
+    if (!startSelection || !endSelection) return;
+    const id = await deltaOperator.deleteText(
+      {
+        id: startId,
+        index: startSelection.index,
+      },
+      {
+        id: endId,
+        index: endSelection.length,
+      },
+      insertChar
+    );
+
+    if (!id) return;
+    dispatch(
+      setCursorRangeThunk({
+        docId,
+        blockId: id,
+        index: insertChar ? startSelection.index + insertChar.length : startSelection.index,
+        length: 0,
+      })
+    );
   }
 );
 
@@ -169,7 +171,7 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
  * delete range and insert enter
  * 1. if shift key, insert '\n' to start node and concat end node delta
  * 2. if not shift key
- *    2.1 insert node under start node, and concat end node delta to insert node
+ *    2.1 insert node under start node
  *    2.2 filter rest children and move to insert node, if need
  * 3. delete middle nodes
  * 4. clear range
@@ -183,84 +185,39 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
     const state = getState() as RootState;
     const rangeState = state[RANGE_NAME][docId];
     const documentState = state[DOCUMENT_NAME][docId];
-    const actions = [];
-
-    const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
-
-    if (!startDelta || !endDelta || !endNode || !startNode) return;
-
-    // get middle nodes
-    const middleIds = getMiddleIds(documentState, startNode.id, endNode.id);
-
-    let newStartDelta = new Delta(startDelta);
-    let caret = null;
-
-    if (shiftKey) {
-      newStartDelta = newStartDelta.insert('\n').concat(endDelta);
-      caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
-    } else {
-      const insertNodeDelta = new Delta(endDelta);
-      const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
-
-      if (!insertNodeAction) return;
-      actions.push(insertNodeAction.action);
-      caret = {
-        id: insertNodeAction.id,
-        index: 0,
-        length: 0,
-      };
-      // move start node children to insert node
-      const needMoveChildren =
-        blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
-
-      if (needMoveChildren) {
-        // filter children by delete middle ids
-        const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
-        const moveChildrenAction = needMoveChildren
-          ? controller.getMoveChildrenAction(
-              children.map((id) => documentState.nodes[id]),
-              insertNodeAction.id,
-              ''
-            )
-          : [];
-
-        actions.push(...moveChildrenAction);
-      }
-    }
-
-    // udpate start node
-    const updateAction = controller.getUpdateAction({
-      ...startNode,
-      data: {
-        ...startNode.data,
-        delta: newStartDelta.ops,
-      },
-    });
-
-    if (endNode.id !== startNode.id) {
-      // delete end node
-      const deleteAction = controller.getDeleteAction(endNode);
-
-      actions.push(updateAction, deleteAction);
-    }
-
-    // delete middle nodes
-    const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
-
-    actions.push(...deleteMiddleNodesActions);
-
-    // apply actions
-    await controller.applyActions(actions);
-
-    // clear range
-    dispatch(rangeActions.initialState(docId));
-    if (caret) {
-      dispatch(
-        rangeActions.setCaret({
-          docId,
-          caret,
+    const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => {
+      await dispatch(
+        updatePageName({
+          id: docId,
+          name,
         })
       );
-    }
+    });
+    const [startId, endId] = getStartAndEndIdsByRange(rangeState);
+    const startSelection = rangeState.ranges[startId];
+    const endSelection = rangeState.ranges[endId];
+
+    if (!startSelection || !endSelection) return;
+    const newLineId = await deltaOperator.splitText(
+      {
+        id: startId,
+        index: startSelection.index,
+      },
+      {
+        id: endId,
+        index: endSelection.length,
+      },
+      shiftKey
+    );
+
+    if (!newLineId) return;
+    dispatch(
+      setCursorRangeThunk({
+        docId,
+        blockId: newLineId,
+        index: shiftKey ? startSelection.index + 1 : 0,
+        length: 0,
+      })
+    );
   }
 );

+ 24 - 16
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts

@@ -7,6 +7,7 @@ import { TemporaryState, TemporaryType } from '$app/interfaces/document';
 import { temporaryActions } from '$app_reducers/document/temporary_slice';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { rangeActions } from '$app_reducers/document/slice';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
 
 export const createTemporary = createAsyncThunk(
   'document/temporary/create',
@@ -15,6 +16,8 @@ export const createTemporary = createAsyncThunk(
     const { dispatch, getState } = thunkAPI;
     const state = getState() as RootState;
     let temporaryState = payload.state;
+    const documentState = state[DOCUMENT_NAME][docId];
+    const deltaOperator = new BlockDeltaOperator(documentState);
 
     if (!temporaryState && type) {
       const caret = state[RANGE_NAME][docId].caret;
@@ -28,12 +31,22 @@ export const createTemporary = createAsyncThunk(
         index,
         length,
       };
+
       const node = state[DOCUMENT_NAME][docId].nodes[id];
-      const nodeDelta = new Delta(node.data?.delta);
-      const rangeDelta = getDeltaByRange(nodeDelta, selection);
-      const text = getDeltaText(rangeDelta);
+      const nodeDelta = deltaOperator.getDeltaWithBlockId(node.id);
+
+      if (!nodeDelta) return;
+      const rangeDelta = deltaOperator.sliceDeltaWithBlockId(
+        node.id,
+        selection.index,
+        selection.index + selection.length
+      );
+
+      if (!rangeDelta) return;
+      const text = deltaOperator.getDeltaText(rangeDelta);
 
       const data = newDataWithTemporaryType(type, text);
+
       temporaryState = {
         id,
         selection,
@@ -71,17 +84,17 @@ export const formatTemporary = createAsyncThunk(
   async (payload: { controller: DocumentController }, thunkAPI) => {
     const { controller } = payload;
     const docId = controller.documentId;
-    const { dispatch, getState } = thunkAPI;
+    const { getState } = thunkAPI;
     const state = getState() as RootState;
     const temporaryState = state[TEMPORARY_NAME][docId];
+    const documentState = state[DOCUMENT_NAME][docId];
+    const deltaOperator = new BlockDeltaOperator(documentState, controller);
 
     if (!temporaryState) {
       return;
     }
 
     const { id, selection, type, data } = temporaryState;
-    const node = state[DOCUMENT_NAME][docId].nodes[id];
-    const nodeDelta = new Delta(node.data?.delta);
     const { index, length } = selection;
     const diffDelta: Delta = new Delta();
     let newSelection = selection;
@@ -106,6 +119,7 @@ export const formatTemporary = createAsyncThunk(
 
         break;
       }
+
       case TemporaryType.Link: {
         if (!data.text) return;
         if (!data.href) {
@@ -115,6 +129,7 @@ export const formatTemporary = createAsyncThunk(
             href: data.href,
           });
         }
+
         newSelection = {
           index: selection.index,
           length: data.text.length,
@@ -126,17 +141,10 @@ export const formatTemporary = createAsyncThunk(
         break;
     }
 
-    const newDelta = nodeDelta.compose(diffDelta);
-
-    const updateAction = controller.getUpdateAction({
-      ...node,
-      data: {
-        ...node.data,
-        delta: newDelta.ops,
-      },
-    });
+    const applyTextDeltaAction = deltaOperator.getApplyDeltaAction(id, diffDelta);
 
-    await controller.applyActions([updateAction]);
+    if (!applyTextDeltaAction) return;
+    await controller.applyActions([applyTextDeltaAction]);
     return {
       ...temporaryState,
       selection: newSelection,

+ 77 - 30
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts

@@ -2,9 +2,13 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { BlockData, BlockType } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
-import { newBlock } from '$app/utils/document/block';
-import { rangeActions } from '$app_reducers/document/slice';
+import { generateId, newBlock } from '$app/utils/document/block';
 import { RootState } from '$app/stores/store';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
+import Delta from 'quill-delta';
+import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor';
+import { blockEditActions } from '$app_reducers/document/block_edit_slice';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
 
 /**
  * transform to block
@@ -20,45 +24,94 @@ export const turnToBlockThunk = createAsyncThunk(
     const { id, controller, type, data } = payload;
     const docId = controller.documentId;
     const { dispatch, getState } = thunkAPI;
-    const state = (getState() as RootState).document[docId];
-
-    const node = state.nodes[id];
+    const state = getState() as RootState;
+    const documentState = state[DOCUMENT_NAME][docId];
+    const caret = state[RANGE_NAME][docId].caret;
+    const node = documentState.nodes[id];
 
     if (!node.parent) return;
 
-    const parent = state.nodes[node.parent];
-    const children = state.children[node.children].map((id) => state.nodes[id]);
-
-    const block = newBlock<any>(type, parent.id, type === BlockType.DividerBlock ? {} : data);
-    let caretId = block.id;
+    const parent = documentState.nodes[node.parent];
+    const children = documentState.children[node.children].map((id) => documentState.nodes[id]);
+    let caretId,
+      caretIndex = caret?.index || 0;
+    const deltaOperator = new BlockDeltaOperator(documentState, controller);
+    let delta = deltaOperator.getDeltaWithBlockId(node.id);
     // insert new block after current block
-    const insertActions = [controller.getInsertAction(block, node.id)];
+    const insertActions = [];
+
+    if (node.type === BlockType.EquationBlock) {
+      delta = new Delta([{ insert: node.data.formula }]);
+    }
+
+    if (delta && type === BlockType.EquationBlock) {
+      data.formula = deltaOperator.getDeltaText(delta);
+      const block = newBlock<any>(type, parent.id, data);
+
+      insertActions.push(controller.getInsertAction(block, node.id));
+      caretId = block.id;
+      caretIndex = 0;
+    } else if (delta && type === BlockType.DividerBlock) {
+      const block = newBlock<any>(type, parent.id, data);
+
+      insertActions.push(controller.getInsertAction(block, node.id));
+      const nodeId = generateId();
+      const actions = deltaOperator.getNewTextLineActions({
+        blockId: nodeId,
+        parentId: parent.id,
+        prevId: block.id || null,
+        delta: delta ? delta : new Delta([{ insert: '' }]),
+        type,
+        data,
+      });
 
-    if (type === BlockType.DividerBlock) {
-      const newTextNode = newBlock<any>(BlockType.TextBlock, parent.id, data);
+      caretId = nodeId;
+      caretIndex = 0;
+      insertActions.push(...actions);
+    } else if (delta) {
+      caretId = generateId();
 
-      insertActions.push(controller.getInsertAction(newTextNode, block.id));
-      caretId = newTextNode.id;
+      const actions = deltaOperator.getNewTextLineActions({
+        blockId: caretId,
+        parentId: parent.id,
+        prevId: node.id,
+        delta: delta,
+        type,
+        data,
+      });
+
+      insertActions.push(...actions);
     }
 
+    if (!caretId) return;
     // check if prev node is allowed to have children
-    const config = blockConfig[block.type];
+    const config = blockConfig[type];
     // if new block is not allowed to have children, move children to parent
-    const newParent = config.canAddChild ? block : parent;
+    const newParentId = config.canAddChild ? caretId : parent.id;
     // if move children to parent, set prev to current block, otherwise the prev is empty
-    const newPrev = newParent.id === parent.id ? block.id : '';
-    const moveChildrenActions = controller.getMoveChildrenAction(children, newParent.id, newPrev);
+    const newPrev = config.canAddChild ? null : caretId;
+    const moveChildrenActions = controller.getMoveChildrenAction(children, newParentId, newPrev);
 
     // delete current block
     const deleteAction = controller.getDeleteAction(node);
 
     // submit actions
     await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]);
-    // set cursor in new block
     dispatch(
-      rangeActions.setCaret({
+      setCursorRangeThunk({
         docId,
-        caret: { id: caretId, index: 0, length: 0 },
+        blockId: caretId,
+        index: caretIndex,
+        length: 0,
+      })
+    );
+    dispatch(
+      blockEditActions.setBlockEditState({
+        id: docId,
+        state: {
+          id: caretId,
+          editing: true,
+        },
       })
     );
     return caretId;
@@ -75,20 +128,14 @@ export const turnToTextBlockThunk = createAsyncThunk(
   'document/turnToTextBlock',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
-    const { dispatch, getState } = thunkAPI;
-    const docId = controller.documentId;
-    const state = (getState() as RootState).document[docId];
-    const node = state.nodes[id];
-    const data = {
-      delta: node.data.delta,
-    };
+    const { dispatch } = thunkAPI;
 
     await dispatch(
       turnToBlockThunk({
         id,
         controller,
         type: BlockType.TextBlock,
-        data,
+        data: {},
       })
     );
   }

+ 2 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts

@@ -21,6 +21,7 @@ export const mentionSlice = createSlice({
       }
     ) => {
       const { docId, blockId } = action.payload;
+
       state[docId] = {
         open: true,
         blockId,
@@ -28,6 +29,7 @@ export const mentionSlice = createSlice({
     },
     close: (state, action: { payload: { docId: string } }) => {
       const { docId } = action.payload;
+
       delete state[docId];
     },
   },

+ 12 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -15,6 +15,7 @@ import { DOCUMENT_NAME, RANGE_NAME, RECT_RANGE_NAME, SLASH_COMMAND_NAME } from '
 import { blockEditSlice } from '$app_reducers/document/block_edit_slice';
 import { Op } from 'quill-delta';
 import { mentionSlice } from '$app_reducers/document/mention_slice';
+import { generateId } from '$app/utils/document/block';
 
 const initialState: Record<string, DocumentState> = {};
 
@@ -37,6 +38,7 @@ export const documentSlice = createSlice({
       state[docId] = {
         nodes: {},
         children: {},
+        deltaMap: {},
       };
     },
     clear: (state, action: PayloadAction<string>) => {
@@ -52,13 +54,15 @@ export const documentSlice = createSlice({
         docId: string;
         nodes: Record<string, Node>;
         children: Record<string, string[]>;
+        deltaMap: Record<string, string>;
       }>
     ) => {
-      const { docId, nodes, children } = action.payload;
+      const { docId, nodes, children, deltaMap } = action.payload;
 
       state[docId] = {
         nodes,
         children,
+        deltaMap,
       };
     },
 
@@ -72,10 +76,16 @@ export const documentSlice = createSlice({
     ) => {
       const { docId, delta, rootId } = action.payload;
       const documentState = state[docId];
+
       if (!documentState) return;
       const rootNode = documentState.nodes[rootId];
+
       if (!rootNode) return;
-      rootNode.data.delta = delta;
+      let externalId = rootNode.externalId;
+
+      if (!externalId) externalId = generateId();
+      rootNode.externalId = externalId;
+      documentState.deltaMap[externalId] = JSON.stringify(delta);
     },
     /**
      This function listens for changes in the data layer triggered by the data API,

+ 277 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/block_delta.test.ts

@@ -0,0 +1,277 @@
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
+import { mockDocument } from './document_state';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { generateId } from '$app/utils/document/block';
+
+jest.mock('nanoid', () => ({ nanoid: jest.fn().mockReturnValue(String(Math.random())) }));
+
+jest.mock('$app/utils/document/emoji', () => ({
+  randomEmoji: jest.fn().mockReturnValue('👍'),
+}));
+
+jest.mock('$app/stores/effects/document/document_observer', () => ({
+  DocumentObserver: jest.fn().mockImplementation(() => ({
+    subscribe: jest.fn().mockReturnValue(Promise.resolve()),
+  })),
+}));
+
+jest.mock('$app/stores/effects/document/document_bd_svc', () => ({
+  DocumentBackendService: jest.fn().mockImplementation(() => ({
+    open: jest.fn().mockReturnValue(Promise.resolve({ ok: true, val: mockDocument })),
+    applyActions: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
+    createText: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
+    applyTextDelta: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
+    close: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
+    canUndoRedo: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
+    undo: jest.fn().mockReturnValue(Promise.resolve({ ok: true })),
+  })),
+}));
+
+describe('Test BlockDeltaOperator', () => {
+  let operator: BlockDeltaOperator;
+  let controller: DocumentController;
+  beforeEach(() => {
+    controller = new DocumentController(generateId());
+    operator = new BlockDeltaOperator(mockDocument, controller);
+  });
+  test('get block', () => {
+    const block = operator.getBlock('1');
+    expect(block).toEqual(undefined);
+
+    const blockId = Object.keys(mockDocument.nodes)[0];
+    const block2 = operator.getBlock(blockId);
+    expect(block2).toEqual(mockDocument.nodes[blockId]);
+  });
+
+  test('get delta with block id', () => {
+    const blockId = 'gtYcSzwLYw';
+    const delta = operator.getDeltaWithBlockId(blockId);
+    expect(delta).toBeTruthy();
+    const deltaStr = JSON.stringify(delta!.ops);
+    const externalId = mockDocument.nodes[blockId].externalId;
+    expect(externalId).toBeTruthy();
+    expect(deltaStr).toEqual(mockDocument.deltaMap[externalId!]);
+  });
+
+  test('get delta text', () => {
+    const blockId = 'gtYcSzwLYw';
+    const delta = operator.getDeltaWithBlockId(blockId);
+    expect(delta).toBeTruthy();
+    const text = operator.getDeltaText(delta!);
+    expect(text).toEqual('Welcome to AppFlowy!');
+  });
+
+  test('get split delta', () => {
+    const blockId = 'gtYcSzwLYw';
+    const splitDeltaResult = operator.getSplitDelta(blockId, 7, 4);
+    expect(splitDeltaResult).toBeTruthy();
+    const { updateDelta, diff, insertDelta } = splitDeltaResult!;
+    expect(updateDelta).toBeTruthy();
+    expect(diff).toBeTruthy();
+    expect(insertDelta).toBeTruthy();
+    expect(updateDelta.ops).toEqual([{ insert: 'Welcome' }]);
+    expect(diff.ops).toEqual([{ retain: 7 }, { delete: 13 }]);
+    expect(insertDelta.ops).toEqual([{ insert: 'AppFlowy!' }]);
+
+    const blockId1 = 'wh475aelU_';
+    const splitDeltaResult1 = operator.getSplitDelta(blockId1, 14, 0);
+    expect(splitDeltaResult1).toBeTruthy();
+    const { updateDelta: updateDelta1, diff: diff1, insertDelta: insertDelta1 } = splitDeltaResult1!;
+    expect(updateDelta1).toBeTruthy();
+    expect(diff1).toBeTruthy();
+    expect(insertDelta1).toBeTruthy();
+    expect(updateDelta1.ops).toEqual([
+      { insert: 'Markdown ' },
+      { insert: 'refer', attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/markdown' } },
+    ]);
+    expect(diff1.ops).toEqual([{ retain: 14 }, { delete: 4 }]);
+    expect(insertDelta1.ops).toEqual([
+      { insert: 'ence', attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/markdown' } },
+    ]);
+  });
+
+  test('split a line text', async () => {
+    const startId = 'gtYcSzwLYw';
+    const endId = 'gtYcSzwLYw';
+    const index = 7;
+    await operator.splitText(
+      {
+        id: startId,
+        index,
+      },
+      {
+        id: endId,
+        index,
+      }
+    );
+    const backendService = controller.backend;
+    expect(backendService.applyActions).toBeCalledTimes(1);
+    // @ts-ignore
+    const actions = backendService.applyActions.mock.calls[0][0];
+    expect(actions).toBeTruthy();
+    expect(actions.length).toEqual(3);
+    expect(actions[0].action).toEqual(5);
+    expect(actions[0].payload).toEqual({
+      delta: '[{"retain":7},{"delete":13}]',
+      text_id: 'KbkL-wXQrN',
+    });
+    expect(actions[1].action).toEqual(4);
+    expect(actions[1].payload).toHaveProperty('text_id');
+    expect(actions[1].payload).toHaveProperty('delta');
+    expect(actions[1].payload.delta).toEqual('[{"insert":" to AppFlowy!"}]');
+    expect(actions[1].payload.text_id).toEqual(actions[2].payload.block.external_id);
+    expect(actions[2].action).toEqual(0);
+    expect(actions[2].payload).toHaveProperty('block');
+    expect(actions[2].payload.block.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[2].payload.block.ty).toEqual('paragraph');
+    expect(actions[2].payload.block).toHaveProperty('external_id');
+    expect(actions[2].payload.block.external_id).toBeTruthy();
+    expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[2].payload.prev_id).toEqual('gtYcSzwLYw');
+  });
+
+  test('split multi line text', async () => {
+    const startId = 'pYV_AGVqEE';
+    const endId = 'eqf0luv-Fy';
+    const startIndex = 8;
+    const endIndex = 5;
+    await operator.splitText(
+      {
+        id: startId,
+        index: startIndex,
+      },
+      {
+        id: endId,
+        index: endIndex,
+      }
+    );
+    const backendService = controller.backend;
+    expect(backendService.applyActions).toBeCalledTimes(1);
+    // @ts-ignore
+    const actions = backendService.applyActions.mock.calls[0][0];
+    expect(actions).toBeTruthy();
+    expect(actions.length).toEqual(6);
+    expect(actions[0].action).toEqual(5);
+    expect(actions[0].payload.text_id).toEqual('F3zvDsXHha');
+    expect(actions[0].payload.delta).toEqual('[{"retain":8},{"delete":87}]');
+    expect(actions[1].action).toEqual(2);
+    expect(actions[1].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[1].payload.prev_id).toEqual('');
+    expect(actions[2].action).toEqual(2);
+    expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[2].payload.prev_id).toEqual('');
+    expect(actions[3].action).toEqual(4);
+    expect(actions[3].payload.text_id).toEqual(actions[4].payload.block.external_id);
+    expect(actions[3].payload.delta).toEqual(
+      '[{"insert":" "},{"attributes":{"code":true},"insert":"+"},{"insert":" next to any page title in the sidebar to "},{"attributes":{"font_color":"0xff8427e0"},"insert":"quickly"},{"insert":" add a new subpage, "},{"attributes":{"code":true},"insert":"Document"},{"attributes":{"code":false},"insert":", "},{"attributes":{"code":true},"insert":"Grid"},{"attributes":{"code":false},"insert":", or "},{"attributes":{"code":true},"insert":"Kanban Board"},{"attributes":{"code":false},"insert":"."}]'
+    );
+    expect(actions[4].action).toEqual(0);
+    expect(actions[4].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[4].payload.prev_id).toEqual('pYV_AGVqEE');
+    expect(actions[5].action).toEqual(2);
+    expect(actions[5].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[5].payload.prev_id).toEqual('');
+  });
+
+  test('delete a line text', async () => {
+    const startId = 'gtYcSzwLYw';
+    const endId = 'gtYcSzwLYw';
+    await operator.deleteText(
+      {
+        id: startId,
+        index: 7,
+      },
+      {
+        id: endId,
+        index: 8,
+      }
+    );
+    const backendService = controller.backend;
+    expect(backendService.applyActions).toBeCalledTimes(1);
+    // @ts-ignore
+    const actions = backendService.applyActions.mock.calls[0][0];
+    expect(actions).toBeTruthy();
+    expect(actions.length).toEqual(1);
+    expect(actions[0].action).toEqual(5);
+    expect(actions[0].payload).toEqual({
+      delta: '[{"retain":7},{"delete":1}]',
+      text_id: 'KbkL-wXQrN',
+    });
+  });
+
+  test('delete multi line text', async () => {
+    const startId = 'pYV_AGVqEE';
+    const endId = 'eqf0luv-Fy';
+    const startIndex = 8;
+    const endIndex = 5;
+    await operator.splitText(
+      {
+        id: startId,
+        index: startIndex,
+      },
+      {
+        id: endId,
+        index: endIndex,
+      }
+    );
+    const backendService = controller.backend;
+    expect(backendService.applyActions).toBeCalledTimes(1);
+    // @ts-ignore
+    const actions = backendService.applyActions.mock.calls[0][0];
+    expect(actions).toBeTruthy();
+    expect(actions.length).toEqual(6);
+    expect(actions[0].action).toEqual(5);
+    expect(actions[0].payload.text_id).toEqual('F3zvDsXHha');
+    expect(actions[0].payload.delta).toEqual('[{"retain":8},{"delete":87}]');
+    expect(actions[1].action).toEqual(2);
+    expect(actions[1].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[1].payload.prev_id).toEqual('');
+    expect(actions[2].action).toEqual(2);
+    expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[2].payload.prev_id).toEqual('');
+    expect(actions[3].action).toEqual(4);
+    expect(actions[3].payload.delta).toEqual(
+      '[{"insert":" "},{"attributes":{"code":true},"insert":"+"},{"insert":" next to any page title in the sidebar to "},{"attributes":{"font_color":"0xff8427e0"},"insert":"quickly"},{"insert":" add a new subpage, "},{"attributes":{"code":true},"insert":"Document"},{"attributes":{"code":false},"insert":", "},{"attributes":{"code":true},"insert":"Grid"},{"attributes":{"code":false},"insert":", or "},{"attributes":{"code":true},"insert":"Kanban Board"},{"attributes":{"code":false},"insert":"."}]'
+    );
+    expect(actions[3].payload.text_id).toEqual(actions[4].payload.block.external_id);
+    expect(actions[4].action).toEqual(0);
+    expect(actions[4].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[4].payload.prev_id).toEqual('pYV_AGVqEE');
+    expect(actions[5].action).toEqual(2);
+    expect(actions[5].payload.parent_id).toEqual('ifF_PvQeOu');
+    expect(actions[5].payload.prev_id).toEqual('');
+  });
+
+  test('merge two line text', async () => {
+    const startId = 'gtYcSzwLYw';
+    const endId = 'YsJ-DVO-sC';
+    await operator.mergeText(startId, endId);
+    const backendService = controller.backend;
+    expect(backendService.applyActions).toBeCalledTimes(1);
+    // @ts-ignore
+    const actions = backendService.applyActions.mock.calls[0][0];
+    expect(actions).toBeTruthy();
+    expect(actions.length).toEqual(2);
+    expect(actions[0].action).toEqual(5);
+    expect(actions[0].payload).toEqual({
+      delta: '[{"retain":20},{"insert":"Here are the basics"}]',
+      text_id: 'KbkL-wXQrN',
+    });
+    expect(actions[1].action).toEqual(2);
+    expect(actions[1].payload).toEqual({
+      block: {
+        id: 'YsJ-DVO-sC',
+        ty: 'heading',
+        parent_id: 'ifF_PvQeOu',
+        children_id: 'PM5MctaruD',
+        data: '{"level":2}',
+        external_id: 'QHPzz4O1mV',
+        external_type: 'text',
+      },
+      parent_id: 'ifF_PvQeOu',
+      prev_id: '',
+    });
+  });
+});
+
+export {};

+ 322 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/document_state.ts

@@ -0,0 +1,322 @@
+import { DocumentState } from '$app/interfaces/document';
+
+export const mockDocument = {
+  nodes: {
+    wh475aelU_: {
+      id: 'wh475aelU_',
+      type: 'numbered_list',
+      parent: 'ifF_PvQeOu',
+      children: 'VcfuvGuodm',
+      data: {},
+      externalId: 'sUF-3L5JHd',
+      externalType: 'text',
+    },
+    pYV_AGVqEE: {
+      id: 'pYV_AGVqEE',
+      type: 'todo_list',
+      parent: 'ifF_PvQeOu',
+      children: 'e6ByZ0nZk9',
+      data: { checked: false },
+      externalId: 'F3zvDsXHha',
+      externalType: 'text',
+    },
+    '0whp025621': {
+      id: '0whp025621',
+      type: 'callout',
+      parent: 'ifF_PvQeOu',
+      children: 'b5ypKcGf5_',
+      data: { bgColor: '#F0F0F0', icon: '🥰' },
+      externalId: 'P_ODpxtY-S',
+      externalType: 'text',
+    },
+    d4Qo2OFOpX: {
+      id: 'd4Qo2OFOpX',
+      type: 'paragraph',
+      parent: 'ifF_PvQeOu',
+      children: '2lNOUVOJJ5',
+      data: {},
+      externalId: 'QT_VkSHge-',
+      externalType: 'text',
+    },
+    tLi0Tg4dBc: {
+      id: 'tLi0Tg4dBc',
+      type: 'paragraph',
+      parent: 'ifF_PvQeOu',
+      children: 'rgDc-GrgOa',
+      data: {},
+      externalId: '7FQuBVPxeZ',
+      externalType: 'text',
+    },
+    '-sili1kmaR': {
+      id: '-sili1kmaR',
+      type: 'todo_list',
+      parent: 'ifF_PvQeOu',
+      children: 'mAxPJngROh',
+      data: { checked: false },
+      externalId: 'VGLCGgx_rk',
+      externalType: 'text',
+    },
+    '5I64JF3Hzw': {
+      id: '5I64JF3Hzw',
+      type: 'numbered_list',
+      parent: 'ifF_PvQeOu',
+      children: 'TzJU1gv2PE',
+      data: {},
+      externalId: 'zYOHSlXpWE',
+      externalType: 'text',
+    },
+    'eqf0luv-Fy': {
+      id: 'eqf0luv-Fy',
+      type: 'todo_list',
+      parent: 'ifF_PvQeOu',
+      children: 'oxKR2cHeeH',
+      data: {
+        checked: false,
+      },
+      externalId: '6BnmM6ZkJV',
+      externalType: 'text',
+    },
+    ZMPoVs7lC4: {
+      id: 'ZMPoVs7lC4',
+      type: 'numbered_list',
+      parent: 'ifF_PvQeOu',
+      children: 'jwB_QmOn21',
+      data: {},
+      externalId: 'qIDnwwdSQF',
+      externalType: 'text',
+    },
+    'PM-4wkNVlu': {
+      id: 'PM-4wkNVlu',
+      type: 'paragraph',
+      parent: 'ifF_PvQeOu',
+      children: 'HdHqxm7-e-',
+      data: {},
+      externalId: 'lPI1KU7usc',
+      externalType: 'text',
+    },
+    '5qS3sKv9C2': {
+      id: '5qS3sKv9C2',
+      type: 'heading',
+      parent: 'ifF_PvQeOu',
+      children: 'LaCFrFbNeA',
+      data: { level: 2 },
+      externalId: 'fy82xqO08a',
+      externalType: 'text',
+    },
+    tEGSjQM2LP: {
+      id: 'tEGSjQM2LP',
+      type: 'todo_list',
+      parent: 'ifF_PvQeOu',
+      children: 'G_zBND8YZl',
+      data: { checked: true },
+      externalId: 'xWJGGIB-fp',
+      externalType: 'text',
+    },
+    IteP77UNrr: {
+      id: 'IteP77UNrr',
+      type: 'divider',
+      parent: 'ifF_PvQeOu',
+      children: '8ZAdHr4H4J',
+      data: {},
+      externalId: '',
+      externalType: '',
+    },
+    vMc1WwxjJu: {
+      id: 'vMc1WwxjJu',
+      type: 'quote',
+      parent: 'ifF_PvQeOu',
+      children: 'zWkL_b8_Mi',
+      data: {},
+      externalId: 'oOxRotTYg2',
+      externalType: 'text',
+    },
+    gtYcSzwLYw: {
+      id: 'gtYcSzwLYw',
+      type: 'heading',
+      parent: 'ifF_PvQeOu',
+      children: 'WhIA288H8O',
+      data: { level: 1 },
+      externalId: 'KbkL-wXQrN',
+      externalType: 'text',
+    },
+    jk7YrtfAgz: {
+      id: 'jk7YrtfAgz',
+      type: 'paragraph',
+      parent: 'ifF_PvQeOu',
+      children: 'KIO68twg3J',
+      data: {},
+      externalId: 'b3BIaLzS_o',
+      externalType: 'text',
+    },
+    jAl6GnPNB_: {
+      id: 'jAl6GnPNB_',
+      type: 'todo_list',
+      parent: 'ifF_PvQeOu',
+      children: 'HR3s1f_gpD',
+      data: { checked: false },
+      externalId: 'qiW6xN-o5Q',
+      externalType: 'text',
+    },
+    NFtEOGjXEm: {
+      id: 'NFtEOGjXEm',
+      type: 'paragraph',
+      parent: 'ifF_PvQeOu',
+      children: 'kDx1WbW6ni',
+      data: {},
+      externalId: 'r19i_oNV3O',
+      externalType: 'text',
+    },
+    '4f6_TWg8x5': {
+      id: '4f6_TWg8x5',
+      type: 'paragraph',
+      parent: 'ifF_PvQeOu',
+      children: 'RGXTAjco5O',
+      data: {},
+      externalId: 'pf1dV9EJer',
+      externalType: 'text',
+    },
+    xFhJgOxACc: {
+      id: 'xFhJgOxACc',
+      type: 'heading',
+      parent: 'ifF_PvQeOu',
+      children: 'CMqq7y9JTX',
+      data: { level: 2 },
+      externalId: 'b3mbfhloLa',
+      externalType: 'text',
+    },
+    'kih-t9tRZr': {
+      id: 'kih-t9tRZr',
+      type: 'code',
+      parent: 'ifF_PvQeOu',
+      children: 'fnWMHsa5if',
+      data: { language: 'rust' },
+      externalId: 'HBZkdYM6Ka',
+      externalType: 'text',
+    },
+    ifF_PvQeOu: {
+      id: 'ifF_PvQeOu',
+      type: 'page',
+      parent: '',
+      children: '5_bawmri6x',
+      data: {},
+      externalId: 'm_SX-ck0GL',
+      externalType: 'text',
+    },
+    'YsJ-DVO-sC': {
+      id: 'YsJ-DVO-sC',
+      type: 'heading',
+      parent: 'ifF_PvQeOu',
+      children: 'PM5MctaruD',
+      data: { level: 2 },
+      externalId: 'QHPzz4O1mV',
+      externalType: 'text',
+    },
+    JcIU0PjpyD: {
+      id: 'JcIU0PjpyD',
+      type: 'todo_list',
+      parent: 'ifF_PvQeOu',
+      children: 'xcYFnxMXai',
+      data: { checked: false },
+      externalId: 'g4WQvF8doI',
+      externalType: 'text',
+    },
+    Oi2cxSuUls: {
+      id: 'Oi2cxSuUls',
+      type: 'paragraph',
+      parent: 'ifF_PvQeOu',
+      children: 'NI4TCeq2Lv',
+      data: {},
+      externalId: 'D27H4Hf9re',
+      externalType: 'text',
+    },
+  },
+  children: {
+    xcYFnxMXai: [],
+    '5_bawmri6x': [
+      'gtYcSzwLYw',
+      'YsJ-DVO-sC',
+      'jAl6GnPNB_',
+      '-sili1kmaR',
+      'pYV_AGVqEE',
+      'JcIU0PjpyD',
+      'tEGSjQM2LP',
+      'eqf0luv-Fy',
+      '4f6_TWg8x5',
+      'IteP77UNrr',
+      'PM-4wkNVlu',
+      '5qS3sKv9C2',
+      '5I64JF3Hzw',
+      'wh475aelU_',
+      'ZMPoVs7lC4',
+      'kih-t9tRZr',
+      'Oi2cxSuUls',
+      'xFhJgOxACc',
+      'vMc1WwxjJu',
+      'd4Qo2OFOpX',
+      '0whp025621',
+      'tLi0Tg4dBc',
+      'jk7YrtfAgz',
+      'NFtEOGjXEm',
+    ],
+    'rgDc-GrgOa': [],
+    jwB_QmOn21: [],
+    b5ypKcGf5_: [],
+    LaCFrFbNeA: [],
+    '8ZAdHr4H4J': [],
+    'HdHqxm7-e-': [],
+    G_zBND8YZl: [],
+    CMqq7y9JTX: [],
+    WhIA288H8O: [],
+    HR3s1f_gpD: [],
+    zWkL_b8_Mi: [],
+    KIO68twg3J: [],
+    oxKR2cHeeH: [],
+    fnWMHsa5if: [],
+    kDx1WbW6ni: [],
+    '2lNOUVOJJ5': [],
+    PM5MctaruD: [],
+    TzJU1gv2PE: [],
+    RGXTAjco5O: [],
+    e6ByZ0nZk9: [],
+    VcfuvGuodm: [],
+    mAxPJngROh: [],
+    NI4TCeq2Lv: [],
+  },
+  deltaMap: {
+    VGLCGgx_rk:
+      '[{"insert":"Highlight ","attributes":{"bg_color":"0x4dffeb3b"}},{"insert":"any text, and use the editing menu to "},{"insert":"style","attributes":{"italic":true}},{"insert":" "},{"insert":"your","attributes":{"bold":true}},{"insert":" "},{"insert":"writing","attributes":{"underline":true}},{"insert":" "},{"insert":"however","attributes":{"code":true}},{"insert":" you "},{"insert":"like.","attributes":{"strikethrough":true}}]',
+    '6BnmM6ZkJV':
+      '[{"insert":"Click "},{"insert":"+","attributes":{"code":true}},{"insert":" next to any page title in the sidebar to "},{"insert":"quickly","attributes":{"font_color":"0xff8427e0"}},{"insert":" add a new subpage, "},{"insert":"Document","attributes":{"code":true}},{"insert":", ","attributes":{"code":false}},{"insert":"Grid","attributes":{"code":true}},{"insert":", or ","attributes":{"code":false}},{"insert":"Kanban Board","attributes":{"code":true}},{"insert":".","attributes":{"code":false}}]',
+    g4WQvF8doI:
+      '[{"insert":"Type "},{"insert":"/","attributes":{"code":true}},{"insert":" followed by "},{"insert":"/bullet","attributes":{"code":true}},{"insert":" or "},{"insert":"/num","attributes":{"code":true}},{"insert":" to create a list.","attributes":{"code":false}}]',
+    HBZkdYM6Ka:
+      '[{"insert":"// This is the main function.\\nfn main() {\\n    // Print text to the console.\\n    println!(\\"Hello World!\\");\\n}"}]',
+    'qiW6xN-o5Q': '[{"insert":"Click anywhere and just start typing."}]',
+    'KbkL-wXQrN': '[{"insert":"Welcome to AppFlowy!"}]',
+    lPI1KU7usc: '[]',
+    D27H4Hf9re: '[]',
+    oOxRotTYg2:
+      '[{"insert":"Click "},{"insert":"?","attributes":{"code":true}},{"insert":" at the bottom right for help and support."}]',
+    'P_ODpxtY-S':
+      '[{"insert":"\\nLike AppFlowy? Follow us:\\n"},{"insert":"GitHub","attributes":{"href":"https://github.com/AppFlowy-IO/AppFlowy"}},{"insert":"\\n"},{"insert":"Twitter","attributes":{"href":"https://twitter.com/appflowy"}},{"insert":": @appflowy\\n"},{"insert":"Newsletter","attributes":{"href":"https://blog-appflowy.ghost.io/"}},{"insert":"\\n"}]',
+    F3zvDsXHha:
+      '[{"insert":"As soon as you type "},{"insert":"/","attributes":{"code":true,"font_color":"0xff00b5ff"}},{"insert":" a menu will pop up. Select "},{"insert":"different types","attributes":{"bg_color":"0x4d9c27b0"}},{"insert":" of content blocks you can add."}]',
+    fy82xqO08a: '[{"insert":"Keyboard shortcuts, markdown, and code block"}]',
+    'sUF-3L5JHd':
+      '[{"insert":"Markdown "},{"insert":"reference","attributes":{"href":"https://appflowy.gitbook.io/docs/essential-documentation/markdown"}}]',
+    r19i_oNV3O: '[]',
+    'm_SX-ck0GL': '[]',
+    b3mbfhloLa: '[{"insert":"Have a question❓"}]',
+    'xWJGGIB-fp':
+      '[{"insert":"Click "},{"insert":"+ New Page ","attributes":{"code":true}},{"insert":"button at the bottom of your sidebar to add a new page."}]',
+    'QT_VkSHge-': '[]',
+    zYOHSlXpWE:
+      '[{"insert":"Keyboard shortcuts "},{"insert":"guide","attributes":{"href":"https://appflowy.gitbook.io/docs/essential-documentation/shortcuts"}}]',
+    b3BIaLzS_o: '[]',
+    '7FQuBVPxeZ': '[]',
+    pf1dV9EJer: '[]',
+    QHPzz4O1mV: '[{"insert":"Here are the basics"}]',
+    qIDnwwdSQF:
+      '[{"insert":"Type "},{"insert":"/code","attributes":{"code":true}},{"insert":" to insert a code block","attributes":{"code":false}}]',
+  },
+} as DocumentState;

+ 24 - 212
frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts

@@ -24,6 +24,7 @@ import {
   transformIndexToPrevLine,
 } from '$app/utils/document/delta';
 import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
+import { BlockDeltaOperator } from '$app/utils/document/block_delta';
 
 export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
   const middleIds = [];
@@ -54,207 +55,6 @@ export function getStartAndEndIdsByRange(rangeState: RangeState) {
   return [startId, endId];
 }
 
-export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
-  const ids = getStartAndEndIdsByRange(rangeState);
-
-  if (ids.length < 2) return;
-  const [startId, endId] = ids;
-
-  return getMiddleIds(document, startId, endId);
-}
-
-export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
-  const { anchor, focus, ranges } = rangeState;
-
-  if (!anchor || !focus) return;
-
-  const isForward = anchor.point.y < focus.point.y;
-  const startId = isForward ? anchor.id : focus.id;
-  const startRange = ranges[startId];
-
-  if (!startRange) return;
-  const offset = insertDelta ? insertDelta.length() : 0;
-
-  return {
-    id: startId,
-    index: startRange.index + offset,
-    length: 0,
-  };
-}
-
-export function getStartAndEndExtentDelta(documentState: DocumentState, rangeState: RangeState) {
-  const ids = getStartAndEndIdsByRange(rangeState);
-
-  if (ids.length === 0) return;
-  const startId = ids[0];
-  const endId = ids[ids.length - 1];
-  const { ranges } = rangeState;
-  // get start and end delta
-  const startRange = ranges[startId];
-  const endRange = ranges[endId];
-
-  if (!startRange || !endRange) return;
-  const startNode = documentState.nodes[startId];
-  const startNodeDelta = new Delta(startNode.data.delta);
-  const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange);
-
-  const endNode = documentState.nodes[endId];
-  const endNodeDelta = new Delta(endNode.data.delta);
-  const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange);
-
-  return {
-    startNode,
-    endNode,
-    startDelta: startBeforeExtentDelta,
-    endDelta: endAfterExtentDelta,
-  };
-}
-
-export function getMergeEndDeltaToStartActionsByRange(
-  state: RootState,
-  controller: DocumentController,
-  insertDelta?: Delta
-) {
-  const actions = [];
-  const docId = controller.documentId;
-  const documentState = state[DOCUMENT_NAME][docId];
-  const rangeState = state[RANGE_NAME][docId];
-  const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
-
-  if (!startDelta || !endDelta || !endNode || !startNode) return;
-  // merge start and end nodes
-  const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
-
-  actions.push(
-    controller.getUpdateAction({
-      ...startNode,
-      data: {
-        delta: mergeDelta.ops,
-      },
-    })
-  );
-  if (endNode.id !== startNode.id) {
-    const children = documentState.children[endNode.children].map((id) => documentState.nodes[id]);
-
-    const moveChildrenActions = getMoveChildrenActions({
-      target: startNode,
-      children,
-      controller,
-    });
-
-    actions.push(...moveChildrenActions);
-    // delete end node
-    actions.push(controller.getDeleteAction(endNode));
-  }
-
-  return actions;
-}
-
-export function getMoveChildrenActions({
-  target,
-  children,
-  controller,
-  prevId = '',
-}: {
-  target: NestedBlock;
-  children: NestedBlock[];
-  controller: DocumentController;
-  prevId?: string;
-}) {
-  // move children
-  const config = blockConfig[target.type];
-  const targetParentId = config.canAddChild ? target.id : target.parent;
-
-  if (!targetParentId) return [];
-  const targetPrevId = targetParentId === target.id ? prevId : target.id;
-  const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
-
-  return moveActions;
-}
-
-export function getInsertEnterNodeFields(sourceNode: NestedBlock) {
-  if (!sourceNode.parent) return;
-  const parentId = sourceNode.parent;
-
-  const config = blockConfig[sourceNode.type].splitProps || {
-    nextLineRelationShip: SplitRelationship.NextSibling,
-    nextLineBlockType: BlockType.TextBlock,
-  };
-
-  const newNodeType = config.nextLineBlockType;
-  const relationShip = config.nextLineRelationShip;
-  const defaultData = blockConfig[newNodeType].defaultData;
-
-  // if the defaultData property is not defined for the new block type, we throw an error.
-  if (!defaultData) {
-    throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`);
-  }
-
-  const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id;
-  const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : '';
-
-  return {
-    parentId: newParentId,
-    prevId: newPrevId,
-    type: newNodeType,
-    data: defaultData,
-  };
-}
-
-export function getInsertEnterNodeAction(
-  sourceNode: NestedBlock,
-  insertNodeDelta: Delta,
-  controller: DocumentController
-) {
-  const insertNodeFields = getInsertEnterNodeFields(sourceNode);
-
-  if (!insertNodeFields) return;
-  const { type, data, parentId, prevId } = insertNodeFields;
-  const insertNode = newBlock<any>(type, parentId, {
-    ...data,
-    delta: insertNodeDelta.ops,
-  });
-
-  return {
-    id: insertNode.id,
-    action: controller.getInsertAction(insertNode, prevId),
-  };
-}
-
-export function findPrevHasDeltaNode(state: DocumentState, id: string) {
-  const prevLineId = getPrevLineId(state, id);
-
-  if (!prevLineId) return;
-  let prevLine = state.nodes[prevLineId];
-
-  // Find the prev line that has delta
-  while (prevLine && !prevLine.data.delta) {
-    const id = getPrevLineId(state, prevLine.id);
-
-    if (!id) return;
-    prevLine = state.nodes[id];
-  }
-
-  return prevLine;
-}
-
-export function findNextHasDeltaNode(state: DocumentState, id: string) {
-  const nextLineId = getNextLineId(state, id);
-
-  if (!nextLineId) return;
-  let nextLine = state.nodes[nextLineId];
-
-  // Find the next line that has delta
-  while (nextLine && !nextLine.data.delta) {
-    const id = getNextLineId(state, nextLine.id);
-
-    if (!id) return;
-    nextLine = state.nodes[id];
-  }
-
-  return nextLine;
-}
-
 export function isPrintableKeyEvent(event: KeyboardEvent) {
   const key = event.key;
   const isPrintable = key.length === 1;
@@ -298,7 +98,10 @@ export function getRightCaretByRange(rangeState: RangeState) {
 }
 
 export function transformToPrevLineCaret(document: DocumentState, caret: RangeStatic) {
-  const delta = new Delta(document.nodes[caret.id].data.delta);
+  const deltaOperator = new BlockDeltaOperator(document);
+  const delta = deltaOperator.getDeltaWithBlockId(caret.id);
+
+  if (!delta) return;
   const inTopEdge = caretInTopEdgeByDelta(delta, caret.index);
 
   if (!inTopEdge) {
@@ -311,25 +114,31 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
     };
   }
 
-  const prevLine = findPrevHasDeltaNode(document, caret.id);
+  const prevLineId = deltaOperator.findPrevTextLine(caret.id);
 
-  if (!prevLine) return;
+  if (!prevLineId) return;
   const relativeIndex = getIndexRelativeEnter(delta, caret.index);
-  const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta));
-  const prevLineText = getDeltaText(new Delta(prevLine.data.delta));
+  const prevLineDelta = deltaOperator.getDeltaWithBlockId(prevLineId);
+
+  if (!prevLineDelta) return;
+  const prevLineIndex = getLastLineIndex(prevLineDelta);
+  const prevLineText = deltaOperator.getDeltaText(prevLineDelta);
   const newPrevLineIndex = prevLineIndex + relativeIndex;
   const prevLineLength = prevLineText.length;
   const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex;
 
   return {
-    id: prevLine.id,
+    id: prevLineId,
     index,
     length: 0,
   };
 }
 
 export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) {
-  const delta = new Delta(document.nodes[caret.id].data.delta);
+  const deltaOperator = new BlockDeltaOperator(document);
+  const delta = deltaOperator.getDeltaWithBlockId(caret.id);
+
+  if (!delta) return;
   const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index);
 
   if (!inBottomEdge) {
@@ -343,15 +152,18 @@ export function transformToNextLineCaret(document: DocumentState, caret: RangeSt
     return;
   }
 
-  const nextLine = findNextHasDeltaNode(document, caret.id);
+  const nextLineId = deltaOperator.findNextTextLine(caret.id);
+
+  if (!nextLineId) return;
+  const nextLineDelta = deltaOperator.getDeltaWithBlockId(nextLineId);
 
-  if (!nextLine) return;
-  const nextLineText = getDeltaText(new Delta(nextLine.data.delta));
+  if (!nextLineDelta) return;
+  const nextLineText = deltaOperator.getDeltaText(nextLineDelta);
   const relativeIndex = getIndexRelativeEnter(delta, caret.index);
   const index = relativeIndex >= nextLineText.length ? nextLineText.length : relativeIndex;
 
   return {
-    id: nextLine.id,
+    id: nextLineId,
     index,
     length: 0,
   };

+ 4 - 2
frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts

@@ -18,6 +18,8 @@ export function blockPB2Node(block: BlockPB) {
     parent: block.parent_id,
     children: block.children_id,
     data,
+    externalId: block.external_id,
+    externalType: block.external_type,
   };
 
   return node;
@@ -97,12 +99,12 @@ export function getPrevNodeId(state: DocumentState, id: string) {
   return prevNodeId;
 }
 
-export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
+export function newBlock<Type>(type: BlockType, parentId: string, data?: BlockData<Type>): NestedBlock<Type> {
   return {
     id: generateId(),
     type,
     parent: parentId,
     children: generateId(),
-    data,
+    data: data ? data : {},
   };
 }

+ 453 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts

@@ -0,0 +1,453 @@
+import { BlockData, BlockType, DocumentState, NestedBlock, SplitRelationship } from '$app/interfaces/document';
+import { generateId, getNextLineId, getPrevLineId } from '$app/utils/document/block';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import Delta, { Op } from 'quill-delta';
+import { blockConfig } from '$app/constants/document/config';
+
+export class BlockDeltaOperator {
+  constructor(
+    private state: DocumentState,
+    private controller?: DocumentController,
+    private updatePageName?: (name: string) => Promise<void>
+  ) {}
+
+  getBlock = (blockId: string) => {
+    return this.state.nodes[blockId];
+  };
+
+  getExternalId = (blockId: string) => {
+    return this.getBlock(blockId)?.externalId;
+  };
+
+  getDeltaStrWithExternalId = (externalId: string) => {
+    return this.state.deltaMap[externalId];
+  };
+
+  getDeltaWithExternalId = (externalId: string) => {
+    const deltaStr = this.getDeltaStrWithExternalId(externalId);
+
+    if (!deltaStr) return;
+    return new Delta(JSON.parse(deltaStr));
+  };
+
+  getDeltaWithBlockId = (blockId: string) => {
+    const externalId = this.getExternalId(blockId);
+
+    if (!externalId) return;
+    return this.getDeltaWithExternalId(externalId);
+  };
+
+  hasDelta = (blockId: string) => {
+    const externalId = this.getExternalId(blockId);
+
+    if (!externalId) return false;
+    return !!this.getDeltaStrWithExternalId(externalId);
+  };
+
+  getDeltaText = (delta: Delta) => {
+    return delta.ops.map((op) => op.insert).join('');
+  };
+
+  sliceDeltaWithBlockId = (blockId: string, startIndex: number, endIndex?: number) => {
+    const delta = this.getDeltaWithBlockId(blockId);
+
+    return delta?.slice(startIndex, endIndex);
+  };
+
+  getSplitDelta = (blockId: string, index: number, length: number) => {
+    const externalId = this.getExternalId(blockId);
+
+    if (!externalId) return;
+    const delta = this.getDeltaWithExternalId(externalId);
+
+    if (!delta) return;
+    const diff = new Delta().retain(index).delete(delta.length() - index);
+    const updateDelta = delta.slice(0, index);
+    const insertDelta = delta.slice(index + length);
+
+    return {
+      diff,
+      updateDelta,
+      insertDelta,
+    };
+  };
+
+  getApplyDeltaAction = (blockId: string, delta: Delta) => {
+    const block = this.getBlock(blockId);
+    const deltaStr = JSON.stringify(delta.ops);
+
+    return this.controller?.getApplyTextDeltaAction(block, deltaStr);
+  };
+
+  getNewTextLineActions = ({
+    blockId,
+    delta,
+    parentId,
+    type = BlockType.TextBlock,
+    prevId,
+    data = {},
+  }: {
+    blockId: string;
+    delta: Delta;
+    parentId: string;
+    type: BlockType;
+    prevId: string | null;
+    data?: BlockData<any>;
+  }) => {
+    const externalId = generateId();
+    const block = {
+      id: blockId,
+      type,
+      externalId,
+      externalType: 'text',
+      parent: parentId,
+      children: generateId(),
+      data,
+    };
+    const deltaStr = JSON.stringify(delta.ops);
+
+    if (!this.controller) return [];
+    return this.controller?.getInsertTextActions(block, deltaStr, prevId);
+  };
+
+  splitText = async (
+    startBlock: {
+      id: string;
+      index: number;
+    },
+    endBlock: {
+      id: string;
+      index: number;
+    },
+    shiftKey?: boolean
+  ) => {
+    if (!this.controller) return;
+
+    const startNode = this.getBlock(startBlock.id);
+    const endNode = this.getBlock(endBlock.id);
+    const startNodeIsRoot = !startNode.parent;
+
+    if (!startNode || !endNode) return;
+    const startNodeDelta = this.getDeltaWithBlockId(startNode.id);
+    const endNodeDelta = this.getDeltaWithBlockId(endNode.id);
+
+    if (!startNodeDelta || !endNodeDelta) return;
+    let diff: Delta, insertDelta;
+
+    if (startNode.id === endNode.id) {
+      const splitResult = this.getSplitDelta(startNode.id, startBlock.index, endBlock.index - startBlock.index);
+
+      if (!splitResult) return;
+      diff = splitResult.diff;
+      insertDelta = splitResult.insertDelta;
+    } else {
+      const startSplitResult = this.getSplitDelta(
+        startNode.id,
+        startBlock.index,
+        startNodeDelta.length() - startBlock.index
+      );
+
+      const endSplitResult = this.getSplitDelta(endNode.id, 0, endBlock.index);
+
+      if (!startSplitResult || !endSplitResult) return;
+      diff = startSplitResult.diff;
+      insertDelta = endSplitResult.insertDelta;
+    }
+
+    if (!diff || !insertDelta) return;
+
+    const actions = [];
+
+    const { nextLineBlockType, nextLineRelationShip } = blockConfig[startNode.type]?.splitProps || {
+      nextLineBlockType: BlockType.TextBlock,
+      nextLineRelationShip: SplitRelationship.NextSibling,
+    };
+    const parentId =
+      nextLineRelationShip === SplitRelationship.NextSibling && startNode.parent ? startNode.parent : startNode.id;
+    const prevId = nextLineRelationShip === SplitRelationship.NextSibling && startNode.parent ? startNode.id : null;
+
+    let newLineId = startNode.id;
+
+    // delete middle nodes
+    if (startNode.id !== endNode.id) {
+      actions.push(...this.getDeleteMiddleNodesActions(startNode.id, endNode.id));
+    }
+
+    if (shiftKey) {
+      const enter = new Delta().insert('\n');
+      const newOps = diff.ops.concat(enter.ops.concat(insertDelta.ops));
+
+      diff = new Delta(newOps);
+      if (startNode.id !== endNode.id) {
+        // move the children of endNode to startNode
+        actions.push(...this.getMoveChildrenActions(endNode.id, startNode));
+      }
+    } else {
+      newLineId = generateId();
+      actions.push(
+        ...this.getNewTextLineActions({
+          blockId: newLineId,
+          delta: insertDelta,
+          parentId,
+          type: nextLineBlockType,
+          prevId,
+        })
+      );
+      if (!startNodeIsRoot) {
+        // move the children of startNode to newLine
+        actions.push(
+          ...this.getMoveChildrenActions(
+            startNode.id,
+            {
+              id: newLineId,
+              type: nextLineBlockType,
+            },
+            [endNode.id]
+          )
+        );
+      }
+
+      if (startNode.id !== endNode.id) {
+        // move the children of endNode to newLine
+        actions.push(
+          ...this.getMoveChildrenActions(endNode.id, {
+            id: newLineId,
+            type: nextLineBlockType,
+          })
+        );
+      }
+    }
+
+    if (startNode.id !== endNode.id) {
+      // delete end node
+      const deleteEndNodeAction = this.controller.getDeleteAction(endNode);
+
+      actions.push(deleteEndNodeAction);
+    }
+
+    if (startNode.parent) {
+      // apply delta
+      const applyDeltaAction = this.getApplyDeltaAction(startNode.id, diff);
+
+      if (applyDeltaAction) actions.unshift(applyDeltaAction);
+    } else {
+      await this.updateRootNodeDelta(startNode.id, diff);
+    }
+
+    await this.controller.applyActions(actions);
+
+    return newLineId;
+  };
+
+  deleteText = async (
+    startBlock: {
+      id: string;
+      index: number;
+    },
+    endBlock: {
+      id: string;
+      index: number;
+    },
+    insertChar?: string
+  ) => {
+    if (!this.controller) return;
+    const startNode = this.getBlock(startBlock.id);
+    const endNode = this.getBlock(endBlock.id);
+
+    if (!startNode || !endNode) return;
+    const startNodeDelta = this.getDeltaWithBlockId(startNode.id);
+    const endNodeDelta = this.getDeltaWithBlockId(endNode.id);
+
+    if (!startNodeDelta || !endNodeDelta) return;
+
+    let startDiff: Delta | undefined;
+    const actions = [];
+
+    if (startNode.id === endNode.id) {
+      const length = endBlock.index - startBlock.index;
+
+      const newOps: Op[] = [
+        {
+          retain: startBlock.index,
+        },
+        {
+          delete: length,
+        },
+      ];
+
+      if (insertChar) {
+        newOps.push({
+          insert: insertChar,
+        });
+      }
+
+      startDiff = new Delta(newOps);
+    } else {
+      const startSplitResult = this.getSplitDelta(
+        startNode.id,
+        startBlock.index,
+        startNodeDelta.length() - startBlock.index
+      );
+      const endSplitResult = this.getSplitDelta(endNode.id, 0, endBlock.index);
+
+      if (!startSplitResult || !endSplitResult) return;
+      const insertDelta = endSplitResult.insertDelta;
+      const newOps = [...startSplitResult.diff.ops];
+
+      if (insertChar) {
+        newOps.push({
+          insert: insertChar,
+        });
+      }
+
+      newOps.push(...insertDelta.ops);
+      startDiff = new Delta(newOps);
+      // delete middle nodes
+      actions.push(...this.getDeleteMiddleNodesActions(startNode.id, endNode.id));
+      // move the children of endNode to startNode
+      actions.push(...this.getMoveChildrenActions(endNode.id, startNode));
+      // delete end node
+      const deleteEndNodeAction = this.controller.getDeleteAction(endNode);
+
+      actions.push(deleteEndNodeAction);
+    }
+
+    if (!startDiff) return;
+    if (startNode.parent) {
+      const applyDeltaAction = this.getApplyDeltaAction(startNode.id, startDiff);
+
+      if (applyDeltaAction) actions.unshift(applyDeltaAction);
+    } else {
+      await this.updateRootNodeDelta(startNode.id, startDiff);
+    }
+
+    await this.controller.applyActions(actions);
+
+    return startNode.id;
+  };
+
+  mergeText = async (targetId: string, sourceId: string) => {
+    if (!this.controller || targetId === sourceId) return;
+    const startNode = this.getBlock(targetId);
+    const endNode = this.getBlock(sourceId);
+
+    if (!startNode || !endNode) return;
+    const startNodeDelta = this.getDeltaWithBlockId(startNode.id);
+    const endNodeDelta = this.getDeltaWithBlockId(endNode.id);
+
+    if (!startNodeDelta || !endNodeDelta) return;
+
+    const startNodeIsRoot = !startNode.parent;
+    const actions = [];
+    const index = startNodeDelta.length();
+    const retain = new Delta().retain(startNodeDelta.length());
+    const newOps = [...retain.ops, ...endNodeDelta.ops];
+    const diff = new Delta(newOps);
+
+    if (!startNodeIsRoot) {
+      const applyDeltaAction = this.getApplyDeltaAction(startNode.id, diff);
+
+      if (applyDeltaAction) actions.push(applyDeltaAction);
+    } else {
+      await this.updateRootNodeDelta(startNode.id, diff);
+    }
+
+    const moveChildrenActions = this.getMoveChildrenActions(endNode.id, startNode);
+
+    // move the children of endNode to startNode
+    actions.push(...moveChildrenActions);
+    // delete end node
+    const deleteEndNodeAction = this.controller.getDeleteAction(endNode);
+
+    actions.push(deleteEndNodeAction);
+
+    await this.controller.applyActions(actions);
+    return {
+      id: targetId,
+      index,
+    };
+  };
+  updateRootNodeDelta = async (id: string, diff: Delta) => {
+    const nodeDelta = this.getDeltaWithBlockId(id);
+    const delta = nodeDelta?.compose(diff);
+
+    const name = delta ? this.getDeltaText(delta) : '';
+
+    await this.updatePageName?.(name);
+  };
+
+  getMoveChildrenActions = (
+    blockId: string,
+    newParent: {
+      id: string;
+      type: BlockType;
+    },
+    excludeIds?: string[]
+  ) => {
+    if (!this.controller) return [];
+    const block = this.getBlock(blockId);
+    const config = blockConfig[newParent.type];
+
+    if (!config.canAddChild) return [];
+    const childrenId = block.children;
+    const children = this.state.children[childrenId]
+      .filter((id) => !excludeIds || (excludeIds && !excludeIds.includes(id)))
+      .map((id) => this.getBlock(id));
+
+    return this.controller.getMoveChildrenAction(children, newParent.id, null);
+  };
+
+  getDeleteMiddleNodesActions = (startId: string, endId: string) => {
+    const controller = this.controller;
+
+    if (!controller) return [];
+    const middleIds = this.getMiddleIds(startId, endId);
+
+    return middleIds.map((id) => controller.getDeleteAction(this.getBlock(id)));
+  };
+
+  getMiddleIds = (startId: string, endId: string) => {
+    const middleIds = [];
+    let currentId: string | undefined = startId;
+
+    while (currentId && currentId !== endId) {
+      const nextId = getNextLineId(this.state, currentId);
+
+      if (nextId && nextId !== endId) {
+        middleIds.push(nextId);
+      }
+
+      currentId = nextId;
+    }
+
+    return middleIds;
+  };
+
+  findPrevTextLine = (blockId: string) => {
+    let currentId: string | undefined = blockId;
+
+    while (currentId) {
+      const prevId = getPrevLineId(this.state, currentId);
+
+      if (prevId && this.hasDelta(prevId)) {
+        return prevId;
+      }
+
+      currentId = prevId;
+    }
+  };
+
+  findNextTextLine = (blockId: string) => {
+    let currentId: string | undefined = blockId;
+
+    while (currentId) {
+      const nextId = getNextLineId(this.state, currentId);
+
+      if (nextId && this.hasDelta(nextId)) {
+        return nextId;
+      }
+
+      currentId = nextId;
+    }
+  };
+}

+ 0 - 82
frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts

@@ -1,85 +1,3 @@
-import { BlockData, DocumentBlockJSON, DocumentState, NestedBlock, RangeState } from '$app/interfaces/document';
-import { getDeltaByRange } from '$app/utils/document/delta';
-import Delta from 'quill-delta';
-import { generateId } from '$app/utils/document/block';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-
-export function getCopyData(
-  node: NestedBlock,
-  range: {
-    index: number;
-    length: number;
-  }
-): BlockData<any> {
-  const nodeDeltaOps = node.data.delta;
-  if (!nodeDeltaOps) {
-    return {
-      ...node.data,
-    };
-  }
-  const delta = getDeltaByRange(new Delta(node.data.delta), range);
-  return {
-    ...node.data,
-    delta: delta.ops,
-  };
-}
-
-export function getCopyBlock(id: string, document: DocumentState, documentRange: RangeState): DocumentBlockJSON {
-  const node = document.nodes[id];
-  const range = documentRange.ranges[id] || { index: 0, length: 0 };
-  const copyData = getCopyData(node, range);
-  return {
-    type: node.type,
-    data: copyData,
-    children: [],
-  };
-}
-
-export function generateBlocks(data: DocumentBlockJSON[], parentId: string) {
-  const blocks: NestedBlock[] = [];
-  function dfs(data: DocumentBlockJSON[], parentId: string) {
-    data.forEach((item) => {
-      const block = {
-        id: generateId(),
-        type: item.type,
-        data: item.data,
-        parent: parentId,
-        children: generateId(),
-      };
-      blocks.push(block);
-      if (item.children) {
-        dfs(item.children, block.id);
-      }
-    });
-  }
-  dfs(data, parentId);
-  return blocks;
-}
-
-export function getInsertBlockActions(blocks: NestedBlock[], prevId: string, controller: DocumentController) {
-  return blocks.map((block, index) => {
-    const prevBlockId = index === 0 ? prevId : blocks[index - 1].id;
-    return controller.getInsertAction(block, prevBlockId);
-  });
-}
-
-export function getAppendBlockDeltaAction(
-  block: NestedBlock,
-  appendDelta: Delta,
-  isForward: boolean,
-  controller: DocumentController
-) {
-  const nodeDelta = new Delta(block.data.delta);
-  const mergeDelta = isForward ? appendDelta.concat(nodeDelta) : nodeDelta.concat(appendDelta);
-  return controller.getUpdateAction({
-    ...block,
-    data: {
-      ...block.data,
-      delta: mergeDelta.ops,
-    },
-  });
-}
-
 export function copyText(text: string) {
   return navigator.clipboard.writeText(text);
 }

+ 59 - 6
frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts

@@ -2,7 +2,8 @@ import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
 import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
 import { Log } from '../log';
 import { isEqual } from '$app/utils/tool';
-import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
+import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME, TEXT_MAP_NAME } from '$app/constants/document/name';
+import Delta, { Op } from 'quill-delta';
 
 // This is a list of all the possible changes that can happen to document data
 const matchCases = [
@@ -12,6 +13,9 @@ const matchCases = [
   { match: matchChildrenMapInsert, type: ChangeType.ChildrenMapInsert, onMatch: onMatchChildrenInsert },
   { match: matchChildrenMapUpdate, type: ChangeType.ChildrenMapUpdate, onMatch: onMatchChildrenUpdate },
   { match: matchChildrenMapDelete, type: ChangeType.ChildrenMapDelete, onMatch: onMatchChildrenDelete },
+  { match: matchDeltaMapInsert, type: ChangeType.DeltaMapInsert, onMatch: onMatchDeltaInsert },
+  { match: matchDeltaMapUpdate, type: ChangeType.DeltaMapUpdate, onMatch: onMatchDeltaUpdate },
+  { match: matchDeltaMapDelete, type: ChangeType.DeltaMapDelete, onMatch: onMatchDeltaDelete },
 ];
 
 export function matchChange(
@@ -25,7 +29,7 @@ export function matchChange(
     command: DeltaTypePB;
     path: string[];
     id: string;
-    value: BlockPBValue & string[];
+    value: BlockPBValue & string[] & Op[];
   }
 ) {
   const matchCase = matchCases.find((item) => item.match(command, path));
@@ -99,6 +103,39 @@ function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) {
   );
 }
 
+/**
+ * @param command DeltaTypePB.Inserted
+ * @param command
+ * @param path [META_NAME, TEXT_MAP_NAME]
+ */
+function matchDeltaMapInsert(command: DeltaTypePB, path: string[]) {
+  if (path.length !== 2) return false;
+  return command === DeltaTypePB.Inserted && path[0] === META_NAME && path[1] === TEXT_MAP_NAME;
+}
+
+/**
+ * @param command DeltaTypePB.Updated
+ * @param command
+ * @param path [META_NAME, TEXT_MAP_NAME, id]
+ */
+function matchDeltaMapUpdate(command: DeltaTypePB, path: string[]) {
+  if (path.length !== 3) return false;
+  return (
+    command === DeltaTypePB.Updated && path[0] === META_NAME && path[1] === TEXT_MAP_NAME && typeof path[2] === 'string'
+  );
+}
+
+/**
+ * @param command DeltaTypePB.Removed
+ * @param path [META_NAME, TEXT_MAP_NAME, id]
+ */
+function matchDeltaMapDelete(command: DeltaTypePB, path: string[]) {
+  if (path.length !== 3) return false;
+  return (
+    command === DeltaTypePB.Removed && path[0] === META_NAME && path[1] === TEXT_MAP_NAME && typeof path[2] === 'string'
+  );
+}
+
 function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
   state.nodes[blockId] = blockChangeValue2Node(blockValue);
 }
@@ -133,6 +170,22 @@ function onMatchChildrenDelete(state: DocumentState, id: string, _children: stri
   delete state.children[id];
 }
 
+function onMatchDeltaInsert(state: DocumentState, id: string, ops: Op[]) {
+  state.deltaMap[id] = JSON.stringify(ops);
+}
+
+function onMatchDeltaUpdate(state: DocumentState, id: string, ops: Op[]) {
+  const delta = new Delta(ops);
+  const oldDelta = new Delta(JSON.parse(state.deltaMap[id]));
+  const newDelta = oldDelta.compose(delta);
+
+  state.deltaMap[id] = JSON.stringify(newDelta.ops);
+}
+
+function onMatchDeltaDelete(state: DocumentState, id: string, _ops: Op[]) {
+  delete state.deltaMap[id];
+}
+
 /**
  * convert block change value to node
  * @param value
@@ -143,9 +196,9 @@ export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
     type: value.ty as BlockType,
     parent: value.parent,
     children: value.children,
-    data: {
-      delta: [],
-    },
+    data: {},
+    externalId: value.external_id,
+    externalType: value.external_type,
   };
 
   if ('data' in value && typeof value.data === 'string') {
@@ -168,7 +221,7 @@ export function parseValue(value: string) {
     valueJson = JSON.parse(value);
   } catch {
     Log.error('[onDataChange] json parse error', value);
-    return;
+    return value;
   }
 
   return valueJson;

+ 5 - 5
frontend/appflowy_tauri/src/appflowy_app/utils/log.ts

@@ -1,20 +1,20 @@
 export class Log {
   static error(...msg: unknown[]) {
-    console.log(...msg);
+    console.error(...msg);
   }
   static info(...msg: unknown[]) {
-    console.log(...msg);
+    console.info(...msg);
   }
 
   static debug(...msg: unknown[]) {
-    console.log(...msg);
+    console.debug(...msg);
   }
 
   static trace(...msg: unknown[]) {
-    console.log(...msg);
+    console.trace(...msg);
   }
 
   static warn(...msg: unknown[]) {
-    console.log(...msg);
+    console.warn(...msg);
   }
 }

+ 0 - 43
frontend/appflowy_tauri/src/tests/user.test.ts

@@ -1,43 +0,0 @@
-export {}
-// import { AuthBackendService, UserBackendService } from '../appflowy_app/stores/effects/user/user_bd_svc';
-// import { randomFillSync } from 'crypto';
-// import { nanoid } from '@reduxjs/toolkit';
-
-// beforeAll(() => {
-//   //@ts-ignore
-//   window.crypto = {
-//     // @ts-ignore
-//     getRandomValues: function (buffer) {
-//       // @ts-ignore
-//       return randomFillSync(buffer);
-//     },
-//   };
-// });
-
-// describe('User backend service', () => {
-//   it('sign up', async () => {
-//     const service = new AuthBackendService();
-//     const result = await service.autoSignUp();
-//     expect(result.ok).toBeTruthy;
-//   });
-
-//   it('sign in', async () => {
-//     const authService = new AuthBackendService();
-//     const email = nanoid(4) + '@appflowy.io';
-//     const password = nanoid(10);
-//     const signUpResult = await authService.signUp({ name: 'nathan', email: email, password: password });
-//     expect(signUpResult.ok).toBeTruthy;
-
-//     const signInResult = await authService.signIn({ email: email, password: password });
-//     expect(signInResult.ok).toBeTruthy;
-//   });
-
-//   it('get user profile', async () => {
-//     const service = new AuthBackendService();
-//     const result = await service.autoSignUp();
-//     const userProfile = result.unwrap();
-
-//     const userService = new UserBackendService(userProfile.id);
-//     expect((await userService.getUserProfile()).unwrap()).toBe(userProfile);
-//   });
-// });

+ 2 - 2
frontend/appflowy_tauri/tsconfig.json

@@ -20,8 +20,8 @@
     "paths": {
       "@/*": ["src/*"],
       "$app/*": ["src/appflowy_app/*"],
-      "$app_reducers/*": ["src/appflowy_app/stores/reducers/*"],
-    },
+      "$app_reducers/*": ["src/appflowy_app/stores/reducers/*"]
+    }
   },
   "include": ["src", "vite.config.ts", "../app_flowy/assets/translations"],
   "exclude": ["node_modules"],

+ 17 - 23
frontend/rust-lib/Cargo.lock

@@ -120,7 +120,7 @@ checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
 [[package]]
 name = "appflowy-integrate"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "collab",
@@ -612,7 +612,7 @@ dependencies = [
 [[package]]
 name = "collab"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -631,7 +631,7 @@ dependencies = [
 [[package]]
 name = "collab-database"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -660,7 +660,7 @@ dependencies = [
 [[package]]
 name = "collab-define"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "collab",
@@ -672,7 +672,7 @@ dependencies = [
 [[package]]
 name = "collab-derive"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -684,12 +684,13 @@ dependencies = [
 [[package]]
 name = "collab-document"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "collab",
  "collab-derive",
  "collab-persistence",
+ "lib0",
  "nanoid",
  "parking_lot",
  "serde",
@@ -703,7 +704,7 @@ dependencies = [
 [[package]]
 name = "collab-folder"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "chrono",
@@ -723,7 +724,7 @@ dependencies = [
 [[package]]
 name = "collab-persistence"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "async-trait",
  "bincode",
@@ -744,16 +745,17 @@ dependencies = [
 [[package]]
 name = "collab-plugins"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "async-trait",
  "collab",
  "collab-define",
  "collab-persistence",
- "collab-sync",
+ "collab-sync-protocol",
  "collab-ws",
  "futures-util",
+ "lib0",
  "parking_lot",
  "rand 0.8.5",
  "serde",
@@ -770,23 +772,15 @@ dependencies = [
 ]
 
 [[package]]
-name = "collab-sync"
+name = "collab-sync-protocol"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "bytes",
  "collab",
- "futures-util",
- "lib0",
  "md5",
- "parking_lot",
  "serde",
  "serde_json",
- "thiserror",
- "tokio",
- "tokio-stream",
- "tokio-util",
- "tracing",
  "y-sync",
  "yrs",
 ]
@@ -794,7 +788,7 @@ dependencies = [
 [[package]]
 name = "collab-user"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "anyhow",
  "collab",
@@ -810,10 +804,10 @@ dependencies = [
 [[package]]
 name = "collab-ws"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=a6ce82#a6ce82e00345fb34eccaf2fad55ec722365d38fa"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=eaa9844#eaa9844c17bd64b2ef00c26245b5cb44756dda4c"
 dependencies = [
  "bytes",
- "collab-sync",
+ "collab-sync-protocol",
  "futures-util",
  "serde",
  "serde_json",

+ 8 - 8
frontend/rust-lib/Cargo.toml

@@ -49,14 +49,14 @@ lto = false
 incremental = false
 
 [patch.crates-io]
-collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
-collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "a6ce82" }
+collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
+collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "eaa9844" }
 
 #collab = { path = "../AppFlowy-Collab/collab" }
 #collab-folder = { path = "../AppFlowy-Collab/collab-folder" }

+ 0 - 5
frontend/rust-lib/flowy-document2/src/document.rs

@@ -52,11 +52,6 @@ fn subscribe_document_changed(doc_id: &str, document: &MutexDocument) {
   document
     .lock()
     .subscribe_block_changed(move |events, is_remote| {
-      tracing::trace!(
-        "document changed: {:?}, from remote: {}",
-        &events,
-        is_remote
-      );
       // send notification to the client.
       send_notification(&doc_id, DocumentNotification::DidReceiveUpdate)
         .payload::<DocEventPB>((events, is_remote).into())

+ 13 - 2
frontend/rust-lib/flowy-document2/src/document_data.rs

@@ -17,12 +17,17 @@ impl From<DocumentData> for DocumentDataPB {
       .map(|(id, children)| (id, children.into()))
       .collect();
 
+    let text_map = data.meta.text_map.unwrap_or_default();
+
     let page_id = data.page_id;
 
     Self {
       page_id,
       blocks,
-      meta: MetaPB { children_map },
+      meta: MetaPB {
+        children_map,
+        text_map,
+      },
     }
   }
 }
@@ -42,12 +47,16 @@ impl From<DocumentDataPB> for DocumentData {
       .map(|(id, children)| (id, children.children))
       .collect();
 
+    let text_map = data.meta.text_map;
     let page_id = data.page_id;
 
     DocumentData {
       page_id,
       blocks,
-      meta: DocumentMeta { children_map },
+      meta: DocumentMeta {
+        children_map,
+        text_map: Some(text_map),
+      },
     }
   }
 }
@@ -60,6 +69,8 @@ impl From<Block> for BlockPB {
       data: serde_json::to_string(&block.data).unwrap_or_default(),
       parent_id: block.parent,
       children_id: block.children,
+      external_id: block.external_id,
+      external_type: block.external_type,
     }
   }
 }

+ 57 - 2
frontend/rust-lib/flowy-document2/src/entities.rs

@@ -166,12 +166,20 @@ pub struct BlockPB {
 
   #[pb(index = 5)]
   pub children_id: String,
+
+  #[pb(index = 6, one_of)]
+  pub external_id: Option<String>,
+
+  #[pb(index = 7, one_of)]
+  pub external_type: Option<String>,
 }
 
 #[derive(Default, ProtoBuf, Debug)]
 pub struct MetaPB {
   #[pb(index = 1)]
   pub children_map: HashMap<String, ChildrenPB>,
+  #[pb(index = 2)]
+  pub text_map: HashMap<String, String>,
 }
 
 #[derive(Default, ProtoBuf, Debug)]
@@ -191,14 +199,26 @@ pub struct BlockActionPB {
 
 #[derive(Default, ProtoBuf, Debug)]
 pub struct BlockActionPayloadPB {
-  #[pb(index = 1)]
-  pub block: BlockPB,
+  // When action = Insert, Update, Delete or Move, block needs to be passed.
+  #[pb(index = 1, one_of)]
+  pub block: Option<BlockPB>,
 
+  // When action = Insert or Move, prev_id needs to be passed.
   #[pb(index = 2, one_of)]
   pub prev_id: Option<String>,
 
+  // When action = Insert or Move, parent_id needs to be passed.
   #[pb(index = 3, one_of)]
   pub parent_id: Option<String>,
+
+  // When action = InsertText or ApplyTextDelta, text_id needs to be passed.
+  #[pb(index = 4, one_of)]
+  pub text_id: Option<String>,
+
+  // When action = InsertText or ApplyTextDelta, delta needs to be passed.
+  // The format of delta is a JSON string, similar to the serialization result of [{ "insert": "Hello World" }].
+  #[pb(index = 5, one_of)]
+  pub delta: Option<String>,
 }
 
 #[derive(ProtoBuf_Enum, Debug)]
@@ -207,6 +227,8 @@ pub enum BlockActionTypePB {
   Update = 1,
   Delete = 2,
   Move = 3,
+  InsertText = 4,
+  ApplyTextDelta = 5,
 }
 
 impl Default for BlockActionTypePB {
@@ -384,3 +406,36 @@ impl From<SyncState> for DocumentSyncStatePB {
     }
   }
 }
+
+#[derive(Default, ProtoBuf, Debug)]
+pub struct TextDeltaPayloadPB {
+  #[pb(index = 1)]
+  pub document_id: String,
+
+  #[pb(index = 2)]
+  pub text_id: String,
+
+  #[pb(index = 3, one_of)]
+  pub delta: Option<String>,
+}
+
+pub struct TextDeltaParams {
+  pub document_id: String,
+  pub text_id: String,
+  pub delta: String,
+}
+
+impl TryInto<TextDeltaParams> for TextDeltaPayloadPB {
+  type Error = ErrorCode;
+  fn try_into(self) -> Result<TextDeltaParams, Self::Error> {
+    let document_id =
+      NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?;
+    let text_id = NotEmptyStr::parse(self.text_id).map_err(|_| ErrorCode::TextIdIsEmpty)?;
+    let delta = self.delta.map_or_else(|| "".to_string(), |delta| delta);
+    Ok(TextDeltaParams {
+      document_id: document_id.0,
+      text_id: text_id.0,
+      delta,
+    })
+  }
+}

+ 37 - 3
frontend/rust-lib/flowy-document2/src/event_handler.rs

@@ -91,6 +91,36 @@ pub(crate) async fn apply_action_handler(
   Ok(())
 }
 
+/// Handler for creating a text
+pub(crate) async fn create_text_handler(
+  data: AFPluginData<TextDeltaPayloadPB>,
+  manager: AFPluginState<Weak<DocumentManager>>,
+) -> FlowyResult<()> {
+  let manager = upgrade_document(manager)?;
+  let params: TextDeltaParams = data.into_inner().try_into()?;
+  let doc_id = params.document_id;
+  let document = manager.get_document(&doc_id).await?;
+  let document = document.lock();
+  document.create_text(&params.text_id, params.delta);
+  Ok(())
+}
+
+/// Handler for applying delta to a text
+pub(crate) async fn apply_text_delta_handler(
+  data: AFPluginData<TextDeltaPayloadPB>,
+  manager: AFPluginState<Weak<DocumentManager>>,
+) -> FlowyResult<()> {
+  let manager = upgrade_document(manager)?;
+  let params: TextDeltaParams = data.into_inner().try_into()?;
+  let doc_id = params.document_id;
+  let document = manager.get_document(&doc_id).await?;
+  let text_id = params.text_id;
+  let delta = params.delta;
+  let document = document.lock();
+  document.apply_text_delta(&text_id, delta);
+  Ok(())
+}
+
 pub(crate) async fn convert_data_to_document(
   data: AFPluginData<ConvertDataPayloadPB>,
 ) -> DataResult<DocumentDataPB, FlowyError> {
@@ -198,6 +228,8 @@ impl From<BlockActionTypePB> for BlockActionType {
       BlockActionTypePB::Update => Self::Update,
       BlockActionTypePB::Delete => Self::Delete,
       BlockActionTypePB::Move => Self::Move,
+      BlockActionTypePB::InsertText => Self::InsertText,
+      BlockActionTypePB::ApplyTextDelta => Self::ApplyTextDelta,
     }
   }
 }
@@ -205,9 +237,11 @@ impl From<BlockActionTypePB> for BlockActionType {
 impl From<BlockActionPayloadPB> for BlockActionPayload {
   fn from(pb: BlockActionPayloadPB) -> Self {
     Self {
-      block: pb.block.into(),
+      block: pb.block.map(|b| b.into()),
       parent_id: pb.parent_id,
       prev_id: pb.prev_id,
+      text_id: pb.text_id,
+      delta: pb.delta,
     }
   }
 }
@@ -224,8 +258,8 @@ impl From<BlockPB> for Block {
       children: pb.children_id,
       parent: pb.parent_id,
       data,
-      external_id: None,
-      external_type: None,
+      external_id: pb.external_id,
+      external_type: pb.external_type,
     }
   }
 }

+ 8 - 0
frontend/rust-lib/flowy-document2/src/event_map.rs

@@ -25,6 +25,8 @@ pub fn init(document_manager: Weak<DocumentManager>) -> AFPlugin {
     .event(DocumentEvent::Undo, undo_handler)
     .event(DocumentEvent::CanUndoRedo, can_undo_redo_handler)
     .event(DocumentEvent::GetDocumentSnapshots, get_snapshot_handler)
+    .event(DocumentEvent::CreateText, create_text_handler)
+    .event(DocumentEvent::ApplyTextDeltaEvent, apply_text_delta_handler)
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)]
@@ -68,4 +70,10 @@ pub enum DocumentEvent {
 
   #[event(input = "OpenDocumentPayloadPB", output = "RepeatedDocumentSnapshotPB")]
   GetDocumentSnapshots = 9,
+
+  #[event(input = "TextDeltaPayloadPB")]
+  CreateText = 10,
+
+  #[event(input = "TextDeltaPayloadPB")]
+  ApplyTextDeltaEvent = 11,
 }

+ 55 - 14
frontend/rust-lib/flowy-document2/src/parser/json/parser.rs

@@ -11,6 +11,8 @@ use super::block::Block;
 
 pub struct JsonToDocumentParser;
 
+const DELTA: &str = "delta";
+const TEXT_EXTERNAL_TYPE: &str = "text";
 impl JsonToDocumentParser {
   pub fn json_str_to_document(json_str: &str) -> FlowyResult<DocumentDataPB> {
     let root = serde_json::from_str::<Block>(json_str)?;
@@ -19,15 +21,20 @@ impl JsonToDocumentParser {
 
     // generate the blocks
     // the root's parent id is empty
-    let blocks = Self::generate_blocks(&root, Some(page_id.clone()), "".to_string());
+    let (blocks, text_map) = Self::generate_blocks(&root, Some(page_id.clone()), "".to_string());
 
     // generate the children map
     let children_map = Self::generate_children_map(&blocks);
 
+    // generate the text map
+    let text_map = Self::generate_text_map(&text_map);
     Ok(DocumentDataPB {
       page_id,
       blocks: blocks.into_iter().collect(),
-      meta: MetaPB { children_map },
+      meta: MetaPB {
+        children_map,
+        text_map,
+      },
     })
   }
 
@@ -35,15 +42,31 @@ impl JsonToDocumentParser {
     block: &Block,
     id: Option<String>,
     parent_id: String,
-  ) -> IndexMap<String, BlockPB> {
-    let block_pb = Self::block_to_block_pb(block, id, parent_id);
+  ) -> (IndexMap<String, BlockPB>, IndexMap<String, String>) {
+    let (block_pb, delta) = Self::block_to_block_pb(block, id, parent_id);
     let mut blocks = IndexMap::new();
+    let mut text_map = IndexMap::new();
     for child in &block.children {
-      let child_blocks = Self::generate_blocks(child, None, block_pb.id.clone());
+      let (child_blocks, child_blocks_text_map) =
+        Self::generate_blocks(child, None, block_pb.id.clone());
       blocks.extend(child_blocks);
+      text_map.extend(child_blocks_text_map);
     }
+    let external_id = block_pb.external_id.clone();
     blocks.insert(block_pb.id.clone(), block_pb);
-    blocks
+    if let Some(delta) = delta {
+      if let Some(external_id) = external_id {
+        text_map.insert(external_id, delta);
+      }
+    }
+    (blocks, text_map)
+  }
+
+  fn generate_text_map(text_map: &IndexMap<String, String>) -> HashMap<String, String> {
+    text_map
+      .iter()
+      .map(|(k, v)| (k.clone(), v.clone()))
+      .collect()
   }
 
   fn generate_children_map(blocks: &IndexMap<String, BlockPB>) -> HashMap<String, ChildrenPB> {
@@ -69,14 +92,32 @@ impl JsonToDocumentParser {
     children_map
   }
 
-  fn block_to_block_pb(block: &Block, id: Option<String>, parent_id: String) -> BlockPB {
+  fn block_to_block_pb(
+    block: &Block,
+    id: Option<String>,
+    parent_id: String,
+  ) -> (BlockPB, Option<String>) {
     let id = id.unwrap_or_else(|| nanoid!(10));
-    BlockPB {
-      id,
-      ty: block.ty.clone(),
-      data: serde_json::to_string(&block.data).unwrap(),
-      parent_id,
-      children_id: nanoid!(10),
-    }
+    let mut data = block.data.clone();
+
+    let delta = data.remove(DELTA).map(|d| d.to_string());
+
+    let (external_id, external_type) = match delta {
+      None => (None, None),
+      Some(_) => (Some(nanoid!(10)), Some(TEXT_EXTERNAL_TYPE.to_string())),
+    };
+
+    (
+      BlockPB {
+        id,
+        ty: block.ty.clone(),
+        data: serde_json::to_string(&data).unwrap(),
+        parent_id,
+        children_id: nanoid!(10),
+        external_id,
+        external_type,
+      },
+      delta,
+    )
   }
 }

+ 3 - 1
frontend/rust-lib/flowy-document2/tests/document/document_insert_test.rs

@@ -24,9 +24,11 @@ async fn document_apply_insert_block_with_empty_parent_id() {
   let insert_text_action = BlockAction {
     action: BlockActionType::Insert,
     payload: BlockActionPayload {
-      block: text_block,
+      block: Some(text_block),
       parent_id: Some(page_id.clone()),
       prev_id: None,
+      delta: None,
+      text_id: None,
     },
   };
   document.lock().apply_action(vec![insert_text_action]);

+ 3 - 1
frontend/rust-lib/flowy-document2/tests/document/document_redo_undo_test.rs

@@ -37,9 +37,11 @@ async fn undo_redo_test() {
   let insert_text_action = BlockAction {
     action: BlockActionType::Insert,
     payload: BlockActionPayload {
-      block: text_block,
+      block: Some(text_block),
       parent_id: Some(page_id),
       prev_id: None,
+      delta: None,
+      text_id: None,
     },
   };
   document.apply_action(vec![insert_text_action]);

+ 12 - 5
frontend/rust-lib/flowy-document2/tests/document/document_test.rs

@@ -21,7 +21,6 @@ async fn restore_document() {
   let data_a = document_a.lock().get_document_data().unwrap();
   assert_eq!(data_a, data);
 
-  // open a document
   let data_b = test
     .get_document(&doc_id)
     .await
@@ -76,9 +75,11 @@ async fn document_apply_insert_action() {
   let insert_text_action = BlockAction {
     action: BlockActionType::Insert,
     payload: BlockActionPayload {
-      block: text_block,
       parent_id: None,
       prev_id: None,
+      block: Some(text_block),
+      delta: None,
+      text_id: None,
     },
   };
   document.lock().apply_action(vec![insert_text_action]);
@@ -123,9 +124,11 @@ async fn document_apply_update_page_action() {
   let action = BlockAction {
     action: BlockActionType::Update,
     payload: BlockActionPayload {
-      block: page_block_clone,
       parent_id: None,
       prev_id: None,
+      block: Some(page_block_clone),
+      delta: None,
+      text_id: None,
     },
   };
   let actions = vec![action];
@@ -169,9 +172,11 @@ async fn document_apply_update_action() {
   let insert_text_action = BlockAction {
     action: BlockActionType::Insert,
     payload: BlockActionPayload {
-      block: text_block,
+      block: Some(text_block),
       parent_id: None,
       prev_id: None,
+      delta: None,
+      text_id: None,
     },
   };
   document.lock().apply_action(vec![insert_text_action]);
@@ -192,9 +197,11 @@ async fn document_apply_update_action() {
   let update_text_action = BlockAction {
     action: BlockActionType::Update,
     payload: BlockActionPayload {
-      block: updated_text_block,
+      block: Some(updated_text_block),
       parent_id: None,
       prev_id: None,
+      delta: None,
+      text_id: None,
     },
   };
   document.lock().apply_action(vec![update_text_action]);

+ 20 - 0
frontend/rust-lib/flowy-document2/tests/parser/json/parser_test.rs

@@ -1,3 +1,4 @@
+use collab_document::blocks::json_str_to_hashmap;
 use flowy_document2::parser::json::parser::JsonToDocumentParser;
 use serde_json::json;
 
@@ -101,3 +102,22 @@ fn test_parser_nested_children() {
   assert_eq!(page_first_child.ty, "paragraph");
   assert_eq!(page_first_child.parent_id, page_id.to_owned());
 }
+
+#[tokio::test]
+async fn parse_readme_test() {
+  let json = include_str!("../../../../flowy-core/assets/read_me.json");
+  let document = JsonToDocumentParser::json_str_to_document(json).unwrap();
+
+  document.blocks.iter().for_each(|(_, block)| {
+    let data = json_str_to_hashmap(&block.data).ok();
+    assert!(data.is_some());
+    if let Some(data) = data {
+      assert!(data.get("delta").is_none());
+    }
+
+    if let Some(external_id) = &block.external_id {
+      let text = document.meta.text_map.get(external_id);
+      assert!(text.is_some());
+    }
+  });
+}

+ 2 - 0
frontend/rust-lib/flowy-error/src/code.rs

@@ -238,6 +238,8 @@ pub enum ErrorCode {
 
   #[error("Parse url failed")]
   InvalidURL = 78,
+  #[error("Text id is empty")]
+  TextIdIsEmpty = 79,
 }
 
 impl ErrorCode {

+ 65 - 8
frontend/rust-lib/flowy-test/src/document/document_event.rs

@@ -2,8 +2,10 @@ use flowy_document2::entities::*;
 use flowy_document2::event_map::DocumentEvent;
 use flowy_folder2::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB};
 use flowy_folder2::event_map::FolderEvent;
+use serde_json::Value;
+use std::collections::HashMap;
 
-use crate::document::utils::{gen_id, gen_text_block_data};
+use crate::document::utils::{gen_delta_str, gen_id, gen_text_block_data};
 use crate::event_builder::EventBuilder;
 use crate::FlowyCoreTest;
 
@@ -90,6 +92,11 @@ impl DocumentEventTest {
     children_map.get(&children_id).map(|c| c.children.clone())
   }
 
+  pub async fn get_block_text_delta(&self, doc_id: &str, text_id: &str) -> Option<String> {
+    let document_data = self.get_document_data(doc_id).await;
+    document_data.meta.text_map.get(text_id).cloned()
+  }
+
   pub async fn apply_actions(&self, payload: ApplyActionPayloadPB) {
     let core = &self.inner;
     EventBuilder::new(core.clone())
@@ -99,6 +106,24 @@ impl DocumentEventTest {
       .await;
   }
 
+  pub async fn create_text(&self, payload: TextDeltaPayloadPB) {
+    let core = &self.inner;
+    EventBuilder::new(core.clone())
+      .event(DocumentEvent::CreateText)
+      .payload(payload)
+      .async_send()
+      .await;
+  }
+
+  pub async fn apply_text_delta(&self, payload: TextDeltaPayloadPB) {
+    let core = &self.inner;
+    EventBuilder::new(core.clone())
+      .event(DocumentEvent::ApplyTextDeltaEvent)
+      .payload(payload)
+      .async_send()
+      .await;
+  }
+
   pub async fn undo(&self, doc_id: String) -> DocumentRedoUndoResponsePB {
     let core = &self.inner;
     let payload = DocumentRedoUndoPayloadPB {
@@ -138,6 +163,19 @@ impl DocumentEventTest {
       .parse::<DocumentRedoUndoResponsePB>()
   }
 
+  pub async fn apply_delta_for_block(&self, document_id: &str, block_id: &str, delta: String) {
+    let block = self.get_block(document_id, block_id).await;
+    // Here is unsafe, but it should be fine for testing.
+    let text_id = block.unwrap().external_id.unwrap();
+    self
+      .apply_text_delta(TextDeltaPayloadPB {
+        document_id: document_id.to_string(),
+        text_id,
+        delta: Some(delta),
+      })
+      .await;
+  }
+
   /// Insert a new text block at the index of parent's children.
   /// return the new block id.
   pub async fn insert_index(
@@ -171,7 +209,18 @@ impl DocumentEventTest {
     };
 
     let new_block_id = gen_id();
-    let data = gen_text_block_data(&text);
+    let data = gen_text_block_data();
+
+    let external_id = gen_id();
+    let external_type = "text".to_string();
+
+    self
+      .create_text(TextDeltaPayloadPB {
+        document_id: document_id.to_string(),
+        text_id: external_id.clone(),
+        delta: Some(gen_delta_str(&text)),
+      })
+      .await;
 
     let new_block = BlockPB {
       id: new_block_id.clone(),
@@ -179,13 +228,17 @@ impl DocumentEventTest {
       data,
       parent_id: parent_id.clone(),
       children_id: gen_id(),
+      external_id: Some(external_id),
+      external_type: Some(external_type),
     };
     let action = BlockActionPB {
       action: BlockActionTypePB::Insert,
       payload: BlockActionPayloadPB {
-        block: new_block,
+        block: Some(new_block),
         prev_id,
         parent_id: Some(parent_id),
+        text_id: None,
+        delta: None,
       },
     };
     let payload = ApplyActionPayloadPB {
@@ -196,20 +249,22 @@ impl DocumentEventTest {
     new_block_id
   }
 
-  pub async fn update(&self, document_id: &str, block_id: &str, text: &str) {
+  pub async fn update_data(&self, document_id: &str, block_id: &str, data: HashMap<String, Value>) {
     let block = self.get_block(document_id, block_id).await.unwrap();
-    let data = gen_text_block_data(text);
+
     let new_block = {
       let mut new_block = block.clone();
-      new_block.data = data;
+      new_block.data = serde_json::to_string(&data).unwrap();
       new_block
     };
     let action = BlockActionPB {
       action: BlockActionTypePB::Update,
       payload: BlockActionPayloadPB {
-        block: new_block,
+        block: Some(new_block),
         prev_id: None,
         parent_id: Some(block.parent_id.clone()),
+        text_id: None,
+        delta: None,
       },
     };
     let payload = ApplyActionPayloadPB {
@@ -225,9 +280,11 @@ impl DocumentEventTest {
     let action = BlockActionPB {
       action: BlockActionTypePB::Delete,
       payload: BlockActionPayloadPB {
-        block,
+        block: Some(block),
         prev_id: None,
         parent_id: Some(parent_id),
+        text_id: None,
+        delta: None,
       },
     };
     let payload = ApplyActionPayloadPB {

+ 11 - 8
frontend/rust-lib/flowy-test/src/document/utils.rs

@@ -8,13 +8,12 @@ pub fn gen_id() -> String {
   nanoid!(10)
 }
 
-pub fn gen_text_block_data(text: &str) -> String {
-  json!({
-    "delta": [{
-      "insert": text
-    }]
-  })
-  .to_string()
+pub fn gen_text_block_data() -> String {
+  json!({}).to_string()
+}
+
+pub fn gen_delta_str(text: &str) -> String {
+  json!([{ "insert": text }]).to_string()
 }
 
 pub struct ParseDocumentData {
@@ -56,13 +55,17 @@ pub fn gen_insert_block_action(document: OpenDocumentData) -> BlockActionPB {
     data,
     parent_id: page_id.clone(),
     children_id: gen_id(),
+    external_id: None,
+    external_type: None,
   };
   BlockActionPB {
     action: BlockActionTypePB::Insert,
     payload: BlockActionPayloadPB {
-      block: new_block,
+      block: Some(new_block),
       prev_id: Some(first_block_id),
       parent_id: Some(page_id),
+      text_id: None,
+      delta: None,
     },
   }
 }

+ 40 - 7
frontend/rust-lib/flowy-test/tests/document/local_test/test.rs

@@ -1,6 +1,9 @@
+use collab_document::blocks::json_str_to_hashmap;
 use flowy_document2::entities::*;
 use flowy_test::document::document_event::DocumentEventTest;
 use flowy_test::document::utils::*;
+use serde_json::{json, Value};
+use std::collections::HashMap;
 
 #[tokio::test]
 async fn get_document_event_test() {
@@ -70,20 +73,50 @@ async fn insert_text_block_test() {
   let block = test.get_block(&view.id, &block_id).await;
   assert!(block.is_some());
   let block = block.unwrap();
-  let data = gen_text_block_data(text);
-  assert_eq!(block.data, data);
+  assert!(block.external_id.is_some());
+  let external_id = block.external_id.unwrap();
+  let delta = test.get_block_text_delta(&view.id, &external_id).await;
+  assert_eq!(delta.unwrap(), json!([{ "insert": text }]).to_string());
 }
 
 #[tokio::test]
-async fn update_text_block_test() {
+async fn update_block_test() {
   let test = DocumentEventTest::new().await;
   let view = test.create_document().await;
   let block_id = test.insert_index(&view.id, "Hello World", 1, None).await;
-  let update_text = "Hello World 2";
-  test.update(&view.id, &block_id, update_text).await;
+  let data: HashMap<String, Value> = HashMap::from([
+    (
+      "bg_color".to_string(),
+      serde_json::to_value("#000000").unwrap(),
+    ),
+    (
+      "text_color".to_string(),
+      serde_json::to_value("#ffffff").unwrap(),
+    ),
+  ]);
+  test.update_data(&view.id, &block_id, data.clone()).await;
   let block = test.get_block(&view.id, &block_id).await;
   assert!(block.is_some());
   let block = block.unwrap();
-  let update_data = gen_text_block_data(update_text);
-  assert_eq!(block.data, update_data);
+  let block_data = json_str_to_hashmap(&block.data).ok().unwrap();
+  assert_eq!(block_data, data);
+}
+
+#[tokio::test]
+async fn apply_text_delta_test() {
+  let test = DocumentEventTest::new().await;
+  let view = test.create_document().await;
+  let text = "Hello World";
+  let block_id = test.insert_index(&view.id, text, 1, None).await;
+  let update_delta = json!([{ "retain": 5 }, { "insert": "!" }]).to_string();
+  test
+    .apply_delta_for_block(&view.id, &block_id, update_delta)
+    .await;
+  let block = test.get_block(&view.id, &block_id).await;
+  let text_id = block.unwrap().external_id.unwrap();
+  let block_delta = test.get_block_text_delta(&view.id, &text_id).await;
+  assert_eq!(
+    block_delta.unwrap(),
+    json!([{ "insert": "Hello! World" }]).to_string()
+  );
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов