浏览代码

refactor: implement appflowy editor core

Lucas.Xu 2 年之前
父节点
当前提交
d80a67bdda
共有 93 个文件被更改,包括 2543 次插入2006 次删除
  1. 1 1
      frontend/app_flowy/packages/appflowy_editor/README.md
  2. 1 1
      frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart
  3. 15 17
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart
  4. 11 12
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart
  5. 14 16
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart
  6. 4 4
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/underscore_to_italic.dart
  7. 10 12
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  8. 5 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart
  9. 5 5
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart
  10. 14 17
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart
  11. 3 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart
  12. 51 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/attributes.dart
  13. 118 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart
  14. 105 85
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node.dart
  15. 24 26
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart
  16. 30 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart
  17. 229 247
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart
  18. 0 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/legacy/built_in_attribute_keys.dart
  19. 12 14
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart
  20. 40 33
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart
  21. 273 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/operation.dart
  22. 267 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart
  23. 0 42
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/attributes.dart
  24. 0 7
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/path.dart
  25. 0 116
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart
  26. 26 8
      frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart
  27. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/attributes_extension.dart
  28. 3 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart
  29. 11 12
      frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart
  30. 25 22
      frontend/app_flowy/packages/appflowy_editor/lib/src/infra/html_converter.dart
  31. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/infra/infra.dart
  32. 0 218
      frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart
  33. 0 39
      frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart
  34. 0 230
      frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart
  35. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart
  36. 11 15
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart
  37. 3 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart
  38. 6 8
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart
  39. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart
  40. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart
  41. 8 8
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart
  42. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart
  43. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart
  44. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart
  45. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart
  46. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart
  47. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart
  48. 19 22
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart
  49. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart
  50. 3 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart
  51. 14 22
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart
  52. 16 18
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart
  53. 4 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart
  54. 45 49
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart
  55. 56 51
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart
  56. 21 21
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart
  57. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/format_style_handler.dart
  58. 17 16
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart
  59. 8 8
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart
  60. 5 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/number_list_helper.dart
  61. 3 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart
  62. 5 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart
  63. 5 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart
  64. 21 22
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart
  65. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart
  66. 12 9
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart
  67. 5 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart
  68. 59 0
      frontend/app_flowy/packages/appflowy_editor/test/core/document/attributes_test.dart
  69. 77 0
      frontend/app_flowy/packages/appflowy_editor/test/core/document/document_test.dart
  70. 33 0
      frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart
  71. 88 9
      frontend/app_flowy/packages/appflowy_editor/test/core/document/node_test.dart
  72. 33 0
      frontend/app_flowy/packages/appflowy_editor/test/core/document/path_test.dart
  73. 332 0
      frontend/app_flowy/packages/appflowy_editor/test/core/document/text_delta_test.dart
  74. 26 0
      frontend/app_flowy/packages/appflowy_editor/test/core/location/position_test.dart
  75. 77 0
      frontend/app_flowy/packages/appflowy_editor/test/core/location/selection_test.dart
  76. 79 0
      frontend/app_flowy/packages/appflowy_editor/test/core/transform/operation_test.dart
  77. 1 1
      frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart
  78. 0 1
      frontend/app_flowy/packages/appflowy_editor/test/infra/infra_test.dart
  79. 4 5
      frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart
  80. 0 329
      frontend/app_flowy/packages/appflowy_editor/test/legacy/delta_test.dart
  81. 21 21
      frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart
  82. 33 33
      frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart
  83. 13 13
      frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart
  84. 2 2
      frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart
  85. 1 1
      frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart
  86. 12 14
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart
  87. 6 6
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart
  88. 18 18
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart
  89. 18 18
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart
  90. 1 1
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart
  91. 2 2
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart
  92. 2 2
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart
  93. 4 4
      frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/README.md

@@ -67,7 +67,7 @@ You can also create an editor from a JSON object in order to configure your init
 ```dart
 final json = ...;
 final editorStyle = EditorStyle.defaultStyle();
-final editorState = EditorState(StateTree.fromJson(data));
+final editorState = EditorState(Document.fromJson(data));
 final editor = AppFlowyEditor(
     editorState: editorState,
     editorStyle: editorStyle,

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

@@ -98,7 +98,7 @@ class _MyHomePageState extends State<MyHomePage> {
         if (snapshot.hasData &&
             snapshot.connectionState == ConnectionState.done) {
           _editorState ??= EditorState(
-            document: StateTree.fromJson(
+            document: Document.fromJson(
               Map<String, Object>.from(
                 json.decode(snapshot.data!),
               ),

+ 15 - 17
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart

@@ -26,9 +26,9 @@ ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
     return KeyEventResult.ignored;
   }
   if (selection.isCollapsed) {
-    TransactionBuilder(editorState)
-      ..insertText(codeBlockNode.first, selection.end.offset, '\n')
-      ..commit();
+    editorState.transaction
+        .insertText(codeBlockNode.first, selection.end.offset, '\n');
+    editorState.commit();
     return KeyEventResult.handled;
   }
   return KeyEventResult.ignored;
@@ -60,21 +60,20 @@ SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
     if (selection == null || textNodes.isEmpty) {
       return;
     }
-    if (textNodes.first.toRawString().isEmpty) {
-      TransactionBuilder(editorState)
+    if (textNodes.first.toPlainText().isEmpty) {
+      editorState.transaction
         ..updateNode(textNodes.first, {
           'subtype': 'code_block',
           'theme': 'vs',
           'language': null,
         })
-        ..afterSelection = selection
-        ..commit();
+        ..afterSelection = selection;
+      editorState.commit();
     } else {
-      TransactionBuilder(editorState)
+      editorState.transaction
         ..insertNode(
           selection.end.path.next,
           TextNode(
-            type: 'text',
             children: LinkedList(),
             attributes: {
               'subtype': 'code_block',
@@ -84,8 +83,8 @@ SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
             delta: Delta()..insert('\n'),
           ),
         )
-        ..afterSelection = selection
-        ..commit();
+        ..afterSelection = selection;
+      editorState.commit();
     }
   },
 );
@@ -149,7 +148,7 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
 
   Widget _buildCodeBlock(BuildContext context) {
     final result = highlight.highlight.parse(
-      widget.textNode.toRawString(),
+      widget.textNode.toPlainText(),
       language: _language,
       autoDetection: _language == null,
     );
@@ -182,11 +181,10 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
       child: DropdownButton<String>(
         value: _detectLanguage,
         onChanged: (value) {
-          TransactionBuilder(widget.editorState)
-            ..updateNode(widget.textNode, {
-              'language': value,
-            })
-            ..commit();
+          widget.editorState.transaction.updateNode(widget.textNode, {
+            'language': value,
+          });
+          widget.editorState.commit();
         },
         items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
           return DropdownMenuItem<String>(

+ 11 - 12
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart

@@ -17,8 +17,8 @@ ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
     return KeyEventResult.ignored;
   }
   final textNode = textNodes.first;
-  if (textNode.toRawString() == '--') {
-    TransactionBuilder(editorState)
+  if (textNode.toPlainText() == '--') {
+    editorState.transaction
       ..deleteText(textNode, 0, 2)
       ..insertNode(
         textNode.path,
@@ -29,8 +29,8 @@ ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
         ),
       )
       ..afterSelection =
-          Selection.single(path: textNode.path.next, startOffset: 0)
-      ..commit();
+          Selection.single(path: textNode.path.next, startOffset: 0);
+    editorState.commit();
     return KeyEventResult.handled;
   }
   return KeyEventResult.ignored;
@@ -53,8 +53,8 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
       return;
     }
     final textNode = textNodes.first;
-    if (textNode.toRawString().isEmpty) {
-      TransactionBuilder(editorState)
+    if (textNode.toPlainText().isEmpty) {
+      editorState.transaction
         ..insertNode(
           textNode.path,
           Node(
@@ -64,14 +64,13 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
           ),
         )
         ..afterSelection =
-            Selection.single(path: textNode.path.next, startOffset: 0)
-        ..commit();
+            Selection.single(path: textNode.path.next, startOffset: 0);
+      editorState.commit();
     } else {
-      TransactionBuilder(editorState)
+      editorState.transaction
         ..insertNode(
           selection.end.path.next,
           TextNode(
-            type: 'text',
             children: LinkedList(),
             attributes: {
               'subtype': 'horizontal_rule',
@@ -79,8 +78,8 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
             delta: Delta()..insert('---'),
           ),
         )
-        ..afterSelection = selection
-        ..commit();
+        ..afterSelection = selection;
+      editorState.commit();
     }
   },
 );

+ 14 - 16
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart

@@ -21,9 +21,9 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
       return;
     }
     final Path texNodePath;
-    if (textNodes.first.toRawString().isEmpty) {
+    if (textNodes.first.toPlainText().isEmpty) {
       texNodePath = selection.end.path;
-      TransactionBuilder(editorState)
+      editorState.transaction
         ..insertNode(
           selection.end.path,
           Node(
@@ -33,11 +33,11 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
           ),
         )
         ..deleteNode(textNodes.first)
-        ..afterSelection = selection
-        ..commit();
+        ..afterSelection = selection;
+      editorState.commit();
     } else {
       texNodePath = selection.end.path.next;
-      TransactionBuilder(editorState)
+      editorState.transaction
         ..insertNode(
           selection.end.path.next,
           Node(
@@ -46,8 +46,8 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
             attributes: {'tex': ''},
           ),
         )
-        ..afterSelection = selection
-        ..commit();
+        ..afterSelection = selection;
+      editorState.commit();
     }
     WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
       final texState =
@@ -142,9 +142,8 @@ class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> {
           size: 16,
         ),
         onPressed: () {
-          TransactionBuilder(widget.editorState)
-            ..deleteNode(widget.node)
-            ..commit();
+          widget.editorState.transaction.deleteNode(widget.node);
+          widget.editorState.commit();
         },
       ),
     );
@@ -175,12 +174,11 @@ class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> {
               onPressed: () {
                 Navigator.of(context).pop();
                 if (controller.text != _tex) {
-                  TransactionBuilder(widget.editorState)
-                    ..updateNode(
-                      widget.node,
-                      {'tex': controller.text},
-                    )
-                    ..commit();
+                  widget.editorState.transaction.updateNode(
+                    widget.node,
+                    {'tex': controller.text},
+                  );
+                  widget.editorState.commit();
                 }
               },
               child: const Text('OK'),

+ 4 - 4
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/underscore_to_italic.dart

@@ -18,7 +18,7 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
   }
 
   final textNode = textNodes.first;
-  final text = textNode.toRawString();
+  final text = textNode.toPlainText();
   // Determine if an 'underscore' already exists in the text node and only once.
   final firstUnderscore = text.indexOf('_');
   final lastUnderscore = text.lastIndexOf('_');
@@ -31,7 +31,7 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
   // Delete the previous 'underscore',
   // update the style of the text surrounded by the two underscores to 'italic',
   // and update the cursor position.
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, firstUnderscore, 1)
     ..formatText(
       textNode,
@@ -46,8 +46,8 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
         path: textNode.path,
         offset: selection.end.offset - 1,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
 
   return KeyEventResult.handled;
 };

+ 10 - 12
frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart

@@ -3,18 +3,17 @@ library appflowy_editor;
 
 export 'src/infra/log.dart';
 export 'src/render/style/editor_style.dart';
-export 'src/document/node.dart';
-export 'src/document/path.dart';
-export 'src/document/position.dart';
-export 'src/document/selection.dart';
-export 'src/document/state_tree.dart';
-export 'src/document/text_delta.dart';
-export 'src/document/attributes.dart';
-export 'src/document/built_in_attribute_keys.dart';
+export 'src/core/document/node.dart';
+export 'src/core/document/path.dart';
+export 'src/core/location/position.dart';
+export 'src/core/location/selection.dart';
+export 'src/core/document/document.dart';
+export 'src/core/document/text_delta.dart';
+export 'src/core/document/attributes.dart';
+export 'src/core/legacy/built_in_attribute_keys.dart';
 export 'src/editor_state.dart';
-export 'src/operation/operation.dart';
-export 'src/operation/transaction.dart';
-export 'src/operation/transaction_builder.dart';
+export 'src/core/transform/operation.dart';
+export 'src/core/transform/transaction.dart';
 export 'src/render/selection/selectable.dart';
 export 'src/service/editor_service.dart';
 export 'src/service/render_plugin_service.dart';
@@ -28,7 +27,6 @@ export 'src/service/shortcut_event/keybinding.dart';
 export 'src/service/shortcut_event/shortcut_event.dart';
 export 'src/service/shortcut_event/shortcut_event_handler.dart';
 export 'src/extensions/attributes_extension.dart';
-export 'src/extensions/path_extensions.dart';
 export 'src/render/rich_text/default_selectable.dart';
 export 'src/render/rich_text/flowy_rich_text.dart';
 export 'src/render/selection_menu/selection_menu_widget.dart';

+ 5 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart

@@ -1,10 +1,10 @@
 import 'dart:async';
 
 import 'package:appflowy_editor/src/commands/text_command_infra.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/core/transform/transaction.dart';
 import 'package:flutter/widgets.dart';
 
 Future<void> insertContextInText(
@@ -22,9 +22,8 @@ Future<void> insertContextInText(
 
   final completer = Completer<void>();
 
-  TransactionBuilder(editorState)
-    ..insertText(result, index, content)
-    ..commit();
+  editorState.transaction.insertText(result, index, content);
+  editorState.commit();
 
   WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
     completer.complete();

+ 5 - 5
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart

@@ -1,10 +1,10 @@
 import 'package:appflowy_editor/src/commands/format_text.dart';
 import 'package:appflowy_editor/src/commands/text_command_infra.dart';
-import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 
 Future<void> formatBuiltInTextAttributes(

+ 14 - 17
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart

@@ -1,12 +1,12 @@
 import 'dart:async';
 
 import 'package:appflowy_editor/src/commands/text_command_infra.dart';
-import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/core/transform/transaction.dart';
 import 'package:flutter/widgets.dart';
 
 Future<void> updateTextNodeAttributes(
@@ -23,9 +23,8 @@ Future<void> updateTextNodeAttributes(
 
   final completer = Completer<void>();
 
-  TransactionBuilder(editorState)
-    ..updateNode(result, attributes)
-    ..commit();
+  editorState.transaction.updateNode(result, attributes);
+  editorState.commit();
 
   WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
     completer.complete();
@@ -49,15 +48,13 @@ Future<void> updateTextNodeDeltaAttributes(
   final newSelection = getSelection(editorState, selection: selection);
 
   final completer = Completer<void>();
-
-  TransactionBuilder(editorState)
-    ..formatText(
-      result,
-      newSelection.startIndex,
-      newSelection.length,
-      attributes,
-    )
-    ..commit();
+  editorState.transaction.formatText(
+    result,
+    newSelection.startIndex,
+    newSelection.length,
+    attributes,
+  );
+  editorState.commit();
 
   WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
     completer.complete();

+ 3 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart

@@ -1,6 +1,6 @@
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 
 // get formatted [TextNode]

+ 51 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/attributes.dart

@@ -0,0 +1,51 @@
+/// Attributes is used to describe the Node's information.
+///
+/// Please note: The keywords in [BuiltInAttributeKey] are reserved.
+typedef Attributes = Map<String, dynamic>;
+
+Attributes? composeAttributes(
+  Attributes? base,
+  Attributes? other, {
+  keepNull = false,
+}) {
+  base ??= {};
+  other ??= {};
+  Attributes attributes = {
+    ...base,
+    ...other,
+  };
+
+  if (!keepNull) {
+    attributes = Attributes.from(attributes)
+      ..removeWhere((_, value) => value == null);
+  }
+
+  return attributes.isNotEmpty ? attributes : null;
+}
+
+Attributes invertAttributes(Attributes? from, Attributes? to) {
+  from ??= {};
+  to ??= {};
+  final attributes = Attributes.from({});
+
+  // key in from but not in to, or value is different
+  for (final entry in from.entries) {
+    if ((!to.containsKey(entry.key) && entry.value != null) ||
+        to[entry.key] != entry.value) {
+      attributes[entry.key] = entry.value;
+    }
+  }
+
+  // key in to but not in from, or value is different
+  for (final entry in to.entries) {
+    if (!from.containsKey(entry.key) && entry.value != null) {
+      attributes[entry.key] = null;
+    }
+  }
+
+  return attributes;
+}
+
+int hashAttributes(Attributes base) => Object.hashAllUnordered(
+      base.entries.map((e) => Object.hash(e.key, e.value)),
+    );

+ 118 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart

@@ -0,0 +1,118 @@
+import 'dart:collection';
+
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
+
+/// [Document] reprensents a AppFlowy Editor document structure.
+///
+/// It stores the root of the document.
+///
+/// DO NOT directly mutate the properties of a [Document] object.
+class Document {
+  Document({
+    required this.root,
+  });
+
+  factory Document.fromJson(Map<String, dynamic> json) {
+    assert(json['document'] is Map);
+
+    final document = Map<String, Object>.from(json['document'] as Map);
+    final root = Node.fromJson(document);
+    return Document(root: root);
+  }
+
+  /// Creates a empty document with a single text node.
+  factory Document.empty() {
+    final root = Node(
+      type: 'editor',
+      children: LinkedList<Node>()..add(TextNode.empty()),
+    );
+    return Document(
+      root: root,
+    );
+  }
+
+  final Node root;
+
+  /// Returns the node at the given [path].
+  Node? nodeAtPath(Path path) {
+    return root.childAtPath(path);
+  }
+
+  /// Inserts a [Node]s at the given [Path].
+  bool insert(Path path, Iterable<Node> nodes) {
+    if (path.isEmpty || nodes.isEmpty) {
+      return false;
+    }
+
+    final target = nodeAtPath(path);
+    if (target != null) {
+      for (final node in nodes) {
+        target.insertBefore(node);
+      }
+      return true;
+    }
+
+    final parent = nodeAtPath(path.parent);
+    if (parent != null) {
+      for (final node in nodes) {
+        parent.insert(node, index: path.last);
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  /// Deletes the [Node]s at the given [Path].
+  bool delete(Path path, [int length = 1]) {
+    if (path.isEmpty || length <= 0) {
+      return false;
+    }
+    var target = nodeAtPath(path);
+    if (target == null) {
+      return false;
+    }
+    while (target != null && length > 0) {
+      final next = target.next;
+      target.unlink();
+      target = next;
+      length--;
+    }
+    return true;
+  }
+
+  /// Updates the [Node] at the given [Path]
+  bool update(Path path, Attributes attributes) {
+    if (path.isEmpty) {
+      return false;
+    }
+    final target = nodeAtPath(path);
+    if (target == null) {
+      return false;
+    }
+    target.updateAttributes(attributes);
+    return true;
+  }
+
+  /// Updates the [TextNode] at the given [Path]
+  bool updateText(Path path, Delta delta) {
+    if (path.isEmpty) {
+      return false;
+    }
+    final target = nodeAtPath(path);
+    if (target == null || target is! TextNode) {
+      return false;
+    }
+    target.delta = target.delta.compose(delta);
+    return true;
+  }
+
+  Map<String, Object> toJson() {
+    return {
+      'document': root.toJson(),
+    };
+  }
+}

+ 105 - 85
frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node.dart

@@ -1,47 +1,21 @@
 import 'dart:collection';
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/text_delta.dart';
-import 'package:flutter/material.dart';
-import './attributes.dart';
-
-class Node extends ChangeNotifier with LinkedListEntry<Node> {
-  Node? parent;
-  final String type;
-  final LinkedList<Node> children;
-  Attributes _attributes;
 
-  GlobalKey? key;
-  // TODO: abstract a selectable node??
-  final layerLink = LayerLink();
-
-  String? get subtype {
-    // TODO: make 'subtype' as a const value.
-    if (_attributes.containsKey('subtype')) {
-      assert(_attributes['subtype'] is String?,
-          'subtype must be a [String] or [null]');
-      return _attributes['subtype'] as String?;
-    }
-    return null;
-  }
-
-  String get id {
-    if (subtype != null) {
-      return '$type/$subtype';
-    }
-    return type;
-  }
-
-  Path get path => _path();
+import 'package:flutter/material.dart';
 
-  Attributes get attributes => _attributes;
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
 
+class Node extends ChangeNotifier with LinkedListEntry<Node> {
   Node({
     required this.type,
-    required this.children,
-    required Attributes attributes,
+    Attributes? attributes,
     this.parent,
-  }) : _attributes = attributes {
-    for (final child in children) {
+    LinkedList<Node>? children,
+  })  : children = children ?? LinkedList<Node>(),
+        _attributes = attributes ?? {} {
+    for (final child in this.children) {
       child.parent = this;
     }
   }
@@ -49,14 +23,13 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
   factory Node.fromJson(Map<String, Object> json) {
     assert(json['type'] is String);
 
-    // TODO: check the type that not exist on plugins.
     final jType = json['type'] as String;
     final jChildren = json['children'] as List?;
     final jAttributes = json['attributes'] != null
         ? Attributes.from(json['attributes'] as Map)
         : Attributes.from({});
 
-    final LinkedList<Node> children = LinkedList();
+    final children = LinkedList<Node>();
     if (jChildren != null) {
       children.addAll(
         jChildren.map(
@@ -69,14 +42,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
 
     Node node;
 
-    if (jType == "text") {
+    if (jType == 'text') {
       final jDelta = json['delta'] as List<dynamic>?;
       final delta = jDelta == null ? Delta() : Delta.fromJson(jDelta);
       node = TextNode(
-          type: jType,
-          children: children,
-          attributes: jAttributes,
-          delta: delta);
+        children: children,
+        attributes: jAttributes,
+        delta: delta,
+      );
     } else {
       node = Node(
         type: jType,
@@ -92,20 +65,48 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
     return node;
   }
 
+  final String type;
+  final LinkedList<Node> children;
+  Node? parent;
+  Attributes _attributes;
+
+  // Renderable
+  GlobalKey? key;
+  final layerLink = LayerLink();
+
+  Attributes get attributes => {..._attributes};
+
+  String get id {
+    if (subtype != null) {
+      return '$type/$subtype';
+    }
+    return type;
+  }
+
+  String? get subtype {
+    if (attributes[BuiltInAttributeKey.subtype] is String) {
+      return attributes[BuiltInAttributeKey.subtype] as String;
+    }
+    return null;
+  }
+
+  Path get path => _computePath();
+
   void updateAttributes(Attributes attributes) {
-    final oldAttributes = {..._attributes};
-    _attributes = composeAttributes(_attributes, attributes) ?? {};
+    final oldAttributes = this.attributes;
+
+    _attributes = composeAttributes(this.attributes, attributes) ?? {};
 
     // Notifies the new attributes
     // if attributes contains 'subtype', should notify parent to rebuild node
     // else, just notify current node.
     bool shouldNotifyParent =
-        _attributes['subtype'] != oldAttributes['subtype'];
+        this.attributes['subtype'] != oldAttributes['subtype'];
     shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
   }
 
   Node? childAtIndex(int index) {
-    if (children.length <= index) {
+    if (children.length <= index || index < 0) {
       return null;
     }
 
@@ -121,7 +122,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
   }
 
   void insert(Node entry, {int? index}) {
-    index ??= children.length;
+    final length = children.length;
+    index ??= length;
 
     if (children.isEmpty) {
       entry.parent = this;
@@ -130,8 +132,9 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
       return;
     }
 
-    final length = children.length;
-
+    // If index is out of range, insert at the end.
+    // If index is negative, insert at the beginning.
+    // If index is positive, insert at the index.
     if (index >= length) {
       children.last.insertAfter(entry);
     } else if (index <= 0) {
@@ -173,28 +176,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
     };
     if (children.isNotEmpty) {
       map['children'] =
-          (children.map((node) => node.toJson())).toList(growable: false);
+          children.map((node) => node.toJson()).toList(growable: false);
     }
-    if (_attributes.isNotEmpty) {
-      map['attributes'] = _attributes;
+    if (attributes.isNotEmpty) {
+      map['attributes'] = attributes;
     }
     return map;
   }
 
-  Path _path([Path previous = const []]) {
-    if (parent == null) {
-      return previous;
-    }
-    var index = 0;
-    for (var child in parent!.children) {
-      if (child == this) {
-        break;
-      }
-      index += 1;
-    }
-    return parent!._path([index, ...previous]);
-  }
-
   Node copyWith({
     String? type,
     LinkedList<Node>? children,
@@ -202,8 +191,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
   }) {
     final node = Node(
       type: type ?? this.type,
-      attributes: attributes ?? {..._attributes},
-      children: children ?? LinkedList(),
+      attributes: attributes ?? {...this.attributes},
+      children: children,
     );
     if (children == null && this.children.isNotEmpty) {
       for (final child in this.children) {
@@ -214,34 +203,43 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
     }
     return node;
   }
+
+  Path _computePath([Path previous = const []]) {
+    if (parent == null) {
+      return previous;
+    }
+    var index = 0;
+    for (final child in parent!.children) {
+      if (child == this) {
+        break;
+      }
+      index += 1;
+    }
+    return parent!._computePath([index, ...previous]);
+  }
 }
 
 class TextNode extends Node {
-  Delta _delta;
-
   TextNode({
-    required super.type,
     required Delta delta,
     LinkedList<Node>? children,
     Attributes? attributes,
   })  : _delta = delta,
         super(
-          children: children ?? LinkedList(),
+          type: 'text',
+          children: children,
           attributes: attributes ?? {},
         );
 
   TextNode.empty({Attributes? attributes})
-      : _delta = Delta([TextInsert('')]),
+      : _delta = Delta(operations: [TextInsert('')]),
         super(
           type: 'text',
-          children: LinkedList(),
           attributes: attributes ?? {},
         );
 
-  Delta get delta {
-    return _delta;
-  }
-
+  Delta _delta;
+  Delta get delta => _delta;
   set delta(Delta v) {
     _delta = v;
     notifyListeners();
@@ -250,21 +248,20 @@ class TextNode extends Node {
   @override
   Map<String, Object> toJson() {
     final map = super.toJson();
-    map['delta'] = _delta.toJson();
+    map['delta'] = delta.toJson();
     return map;
   }
 
   @override
   TextNode copyWith({
-    String? type,
+    String? type = 'text',
     LinkedList<Node>? children,
     Attributes? attributes,
     Delta? delta,
   }) {
     final textNode = TextNode(
-      type: type ?? this.type,
       children: children,
-      attributes: attributes ?? _attributes,
+      attributes: attributes ?? this.attributes,
       delta: delta ?? this.delta,
     );
     if (children == null && this.children.isNotEmpty) {
@@ -277,5 +274,28 @@ class TextNode extends Node {
     return textNode;
   }
 
-  String toRawString() => _delta.toRawString();
+  String toPlainText() => _delta.toPlainText();
+}
+
+extension NodeEquality on Iterable<Node> {
+  bool equals(Iterable<Node> other) {
+    if (length != other.length) {
+      return false;
+    }
+    for (var i = 0; i < length; i++) {
+      if (!_nodeEquals(elementAt(i), other.elementAt(i))) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  bool _nodeEquals<T, U>(T base, U other) {
+    if (identical(this, other)) return true;
+
+    return base is Node &&
+        other is Node &&
+        other.type == base.type &&
+        other.children.equals(base.children);
+  }
 }

+ 24 - 26
frontend/app_flowy/packages/appflowy_editor/lib/src/document/node_iterator.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart

@@ -1,23 +1,28 @@
-import 'package:appflowy_editor/src/document/node.dart';
-
-import './state_tree.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/document.dart';
 
 /// [NodeIterator] is used to traverse the nodes in visual order.
 class NodeIterator implements Iterator<Node> {
-  final StateTree stateTree;
-  final Node _startNode;
-  final Node? _endNode;
+  NodeIterator({
+    required this.document,
+    required this.startNode,
+    this.endNode,
+  });
+
+  final Document document;
+  final Node startNode;
+  final Node? endNode;
+
   Node? _currentNode;
   bool _began = false;
 
-  NodeIterator(this.stateTree, Node startNode, [Node? endNode])
-      : _startNode = startNode,
-        _endNode = endNode;
+  @override
+  Node get current => _currentNode!;
 
   @override
   bool moveNext() {
     if (!_began) {
-      _currentNode = _startNode;
+      _currentNode = startNode;
       _began = true;
       return true;
     }
@@ -27,7 +32,7 @@ class NodeIterator implements Iterator<Node> {
       return false;
     }
 
-    if (_endNode != null && _endNode == node) {
+    if (endNode != null && endNode == node) {
       _currentNode = null;
       return false;
     }
@@ -42,32 +47,25 @@ class NodeIterator implements Iterator<Node> {
       if (nextOfParent == null) {
         _currentNode = null;
       } else {
-        _currentNode = _findLeadingChild(nextOfParent);
+        _currentNode = nextOfParent;
       }
     }
 
     return _currentNode != null;
   }
 
-  Node _findLeadingChild(Node node) {
-    while (node.children.isNotEmpty) {
-      node = node.children.first;
-    }
-    return node;
-  }
-
-  @override
-  Node get current {
-    return _currentNode!;
-  }
-
   List<Node> toList() {
     final result = <Node>[];
-
     while (moveNext()) {
       result.add(current);
     }
-
     return result;
   }
+
+  Node _findLeadingChild(Node node) {
+    while (node.children.isNotEmpty) {
+      node = node.children.first;
+    }
+    return node;
+  }
 }

+ 30 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart

@@ -1,17 +1,23 @@
-import 'package:appflowy_editor/src/document/path.dart';
-
 import 'dart:math';
 
+import 'package:flutter/foundation.dart';
+
+typedef Path = List<int>;
+
 extension PathExtensions on Path {
+  bool equals(Path other) {
+    return listEquals(this, other);
+  }
+
   bool operator >=(Path other) {
-    if (pathEquals(this, other)) {
+    if (equals(other)) {
       return true;
     }
     return this > other;
   }
 
   bool operator >(Path other) {
-    if (pathEquals(this, other)) {
+    if (equals(other)) {
       return false;
     }
     final length = min(this.length, other.length);
@@ -29,14 +35,14 @@ extension PathExtensions on Path {
   }
 
   bool operator <=(Path other) {
-    if (pathEquals(this, other)) {
+    if (equals(other)) {
       return true;
     }
     return this < other;
   }
 
   bool operator <(Path other) {
-    if (pathEquals(this, other)) {
+    if (equals(other)) {
       return false;
     }
     final length = min(this.length, other.length);
@@ -63,4 +69,22 @@ extension PathExtensions on Path {
       ..removeLast()
       ..add(last + 1);
   }
+
+  Path get previous {
+    Path previousPath = Path.from(this, growable: true);
+    if (isEmpty) {
+      return previousPath;
+    }
+    final last = previousPath.last;
+    return previousPath
+      ..removeLast()
+      ..add(max(0, last - 1));
+  }
+
+  Path get parent {
+    if (isEmpty) {
+      return this;
+    }
+    return Path.from(this, growable: true)..removeLast();
+  }
 }

+ 229 - 247
frontend/app_flowy/packages/appflowy_editor/lib/src/document/text_delta.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart

@@ -1,257 +1,138 @@
 import 'dart:collection';
 import 'dart:math';
 
-import 'package:appflowy_editor/src/document/attributes.dart';
 import 'package:flutter/foundation.dart';
 
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+
 // constant number: 2^53 - 1
 const int _maxInt = 9007199254740991;
 
-abstract class TextOperation {
-  bool get isEmpty => length == 0;
+List<int> stringIndexes(String text) {
+  final indexes = List<int>.filled(text.length, 0);
+  final iterator = text.runes.iterator;
 
+  while (iterator.moveNext()) {
+    for (var i = 0; i < iterator.currentSize; i++) {
+      indexes[iterator.rawIndex + i] = iterator.rawIndex;
+    }
+  }
+
+  return indexes;
+}
+
+abstract class TextOperation {
+  Attributes? get attributes;
   int get length;
 
-  Attributes? get attributes => null;
+  bool get isEmpty => length == 0;
 
   Map<String, dynamic> toJson();
 }
 
 class TextInsert extends TextOperation {
-  String content;
-  final Attributes? _attributes;
-
-  TextInsert(this.content, [Attributes? attrs]) : _attributes = attrs;
-
-  @override
-  int get length {
-    return content.length;
-  }
+  TextInsert(
+    this.text, {
+    Attributes? attributes,
+  }) : _attributes = attributes;
 
-  @override
-  Attributes? get attributes {
-    return _attributes;
-  }
+  String text;
+  final Attributes? _attributes;
 
   @override
-  bool operator ==(Object other) {
-    if (other is! TextInsert) {
-      return false;
-    }
-    return content == other.content &&
-        mapEquals(_attributes, other._attributes);
-  }
+  int get length => text.length;
 
   @override
-  int get hashCode {
-    final contentHash = content.hashCode;
-    final attrs = _attributes;
-    return Object.hash(
-        contentHash, attrs == null ? null : hashAttributes(attrs));
-  }
+  Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
 
   @override
   Map<String, dynamic> toJson() {
     final result = <String, dynamic>{
-      'insert': content,
+      'insert': text,
     };
-    final attrs = _attributes;
-    if (attrs != null) {
-      result['attributes'] = {...attrs};
+    if (_attributes != null) {
+      result['attributes'] = attributes;
     }
     return result;
   }
-}
-
-class TextRetain extends TextOperation {
-  int _length;
-  final Attributes? _attributes;
-
-  TextRetain(length, [Attributes? attributes])
-      : _length = length,
-        _attributes = attributes;
 
   @override
-  bool get isEmpty {
-    return length == 0;
-  }
-
-  @override
-  int get length {
-    return _length;
-  }
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
 
-  set length(int v) {
-    _length = v;
+    return other is TextInsert &&
+        other.text == text &&
+        mapEquals(_attributes, other._attributes);
   }
 
   @override
-  Attributes? get attributes {
-    return _attributes;
-  }
+  int get hashCode => text.hashCode ^ _attributes.hashCode;
+}
+
+class TextRetain extends TextOperation {
+  TextRetain(
+    this.length, {
+    Attributes? attributes,
+  }) : _attributes = attributes;
 
   @override
-  bool operator ==(Object other) {
-    if (other is! TextRetain) {
-      return false;
-    }
-    return _length == other.length && mapEquals(_attributes, other._attributes);
-  }
+  int length;
+  final Attributes? _attributes;
 
   @override
-  int get hashCode {
-    final attrs = _attributes;
-    return Object.hash(_length, attrs == null ? null : hashAttributes(attrs));
-  }
+  Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
 
   @override
   Map<String, dynamic> toJson() {
     final result = <String, dynamic>{
-      'retain': _length,
+      'retain': length,
     };
-    final attrs = _attributes;
-    if (attrs != null) {
-      result['attributes'] = {...attrs};
+    if (_attributes != null) {
+      result['attributes'] = attributes;
     }
     return result;
   }
-}
 
-class TextDelete extends TextOperation {
-  int _length;
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
 
-  TextDelete(int length) : _length = length;
+    return other is TextRetain &&
+        other.length == length &&
+        mapEquals(_attributes, other._attributes);
+  }
 
   @override
-  int get length {
-    return _length;
-  }
+  int get hashCode => length.hashCode ^ _attributes.hashCode;
+}
 
-  set length(int v) {
-    _length = v;
-  }
+class TextDelete extends TextOperation {
+  TextDelete({
+    required this.length,
+  });
 
   @override
-  bool operator ==(Object other) {
-    if (other is! TextDelete) {
-      return false;
-    }
-    return _length == other.length;
-  }
+  int length;
 
   @override
-  int get hashCode {
-    return _length.hashCode;
-  }
+  Attributes? get attributes => null;
 
   @override
   Map<String, dynamic> toJson() {
     return {
-      'delete': _length,
+      'delete': length,
     };
   }
-}
-
-class _OpIterator {
-  final UnmodifiableListView<TextOperation> _operations;
-  int _index = 0;
-  int _offset = 0;
-
-  _OpIterator(List<TextOperation> operations)
-      : _operations = UnmodifiableListView(operations);
-
-  bool get hasNext {
-    return peekLength() < _maxInt;
-  }
-
-  TextOperation? peek() {
-    if (_index >= _operations.length) {
-      return null;
-    }
-
-    return _operations[_index];
-  }
-
-  int peekLength() {
-    if (_index < _operations.length) {
-      final op = _operations[_index];
-      return op.length - _offset;
-    }
-    return _maxInt;
-  }
-
-  TextOperation _next([int? length]) {
-    length ??= _maxInt;
-
-    if (_index >= _operations.length) {
-      return TextRetain(_maxInt);
-    }
-
-    final nextOp = _operations[_index];
-
-    final offset = _offset;
-    final opLength = nextOp.length;
-    if (length >= opLength - offset) {
-      length = opLength - offset;
-      _index += 1;
-      _offset = 0;
-    } else {
-      _offset += length;
-    }
-    if (nextOp is TextDelete) {
-      return TextDelete(length);
-    }
-
-    if (nextOp is TextRetain) {
-      return TextRetain(
-        length,
-        nextOp.attributes,
-      );
-    }
-
-    if (nextOp is TextInsert) {
-      return TextInsert(
-        nextOp.content.substring(offset, offset + length),
-        nextOp.attributes,
-      );
-    }
-
-    return TextRetain(_maxInt);
-  }
 
-  List<TextOperation> rest() {
-    if (!hasNext) {
-      return [];
-    } else if (_offset == 0) {
-      return _operations.sublist(_index);
-    } else {
-      final offset = _offset;
-      final index = _index;
-      final next = _next();
-      final rest = _operations.sublist(_index);
-      _offset = offset;
-      _index = index;
-      return [next] + rest;
-    }
-  }
-}
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
 
-TextOperation? _textOperationFromJson(Map<String, dynamic> json) {
-  TextOperation? result;
-
-  if (json['insert'] is String) {
-    final attrs = json['attributes'] as Map<String, dynamic>?;
-    result =
-        TextInsert(json['insert'] as String, attrs == null ? null : {...attrs});
-  } else if (json['retain'] is int) {
-    final attrs = json['attributes'] as Map<String, dynamic>?;
-    result =
-        TextRetain(json['retain'] as int, attrs == null ? null : {...attrs});
-  } else if (json['delete'] is int) {
-    result = TextDelete(json['delete'] as int);
+    return other is TextDelete && other.length == length;
   }
 
-  return result;
+  @override
+  int get hashCode => length.hashCode;
 }
 
 /// Deltas are a simple, yet expressive format that can be used to describe contents and changes.
@@ -261,62 +142,66 @@ TextOperation? _textOperationFromJson(Map<String, dynamic> json) {
 
 /// Basically borrowed from: https://github.com/quilljs/delta
 class Delta extends Iterable<TextOperation> {
-  final List<TextOperation> _operations;
-  String? _rawString;
-  List<int>? _runeIndexes;
+  Delta({
+    List<TextOperation>? operations,
+  }) : _operations = operations ?? <TextOperation>[];
 
   factory Delta.fromJson(List<dynamic> list) {
     final operations = <TextOperation>[];
 
-    for (final obj in list) {
-      final op = _textOperationFromJson(obj as Map<String, dynamic>);
-      if (op != null) {
-        operations.add(op);
+    for (final value in list) {
+      if (value is Map<String, dynamic>) {
+        final op = _textOperationFromJson(value);
+        if (op != null) {
+          operations.add(op);
+        }
       }
     }
 
-    return Delta(operations);
+    return Delta(operations: operations);
   }
 
-  Delta([List<TextOperation>? ops]) : _operations = ops ?? <TextOperation>[];
+  final List<TextOperation> _operations;
+  String? _plainText;
+  List<int>? _runeIndexes;
 
-  void addAll(Iterable<TextOperation> textOps) {
-    textOps.forEach(add);
+  void addAll(Iterable<TextOperation> textOperations) {
+    textOperations.forEach(add);
   }
 
-  void add(TextOperation textOp) {
-    if (textOp.isEmpty) {
+  void add(TextOperation textOperation) {
+    if (textOperation.isEmpty) {
       return;
     }
-    _rawString = null;
+    _plainText = null;
 
     if (_operations.isNotEmpty) {
       final lastOp = _operations.last;
-      if (lastOp is TextDelete && textOp is TextDelete) {
-        lastOp.length += textOp.length;
+      if (lastOp is TextDelete && textOperation is TextDelete) {
+        lastOp.length += textOperation.length;
         return;
       }
-      if (mapEquals(lastOp.attributes, textOp.attributes)) {
-        if (lastOp is TextInsert && textOp is TextInsert) {
-          lastOp.content += textOp.content;
+      if (mapEquals(lastOp.attributes, textOperation.attributes)) {
+        if (lastOp is TextInsert && textOperation is TextInsert) {
+          lastOp.text += textOperation.text;
           return;
         }
         // if there is an delete before the insert
         // swap the order
-        if (lastOp is TextDelete && textOp is TextInsert) {
+        if (lastOp is TextDelete && textOperation is TextInsert) {
           _operations.removeLast();
-          _operations.add(textOp);
+          _operations.add(textOperation);
           _operations.add(lastOp);
           return;
         }
-        if (lastOp is TextRetain && textOp is TextRetain) {
-          lastOp.length += textOp.length;
+        if (lastOp is TextRetain && textOperation is TextRetain) {
+          lastOp.length += textOperation.length;
           return;
         }
       }
     }
 
-    _operations.add(textOp);
+    _operations.add(textOperation);
   }
 
   /// The slice() method does not change the original string.
@@ -344,19 +229,19 @@ class Delta extends Iterable<TextOperation> {
 
   /// Insert operations have an `insert` key defined.
   /// A String value represents inserting text.
-  void insert(String content, [Attributes? attributes]) =>
-      add(TextInsert(content, attributes));
+  void insert(String text, {Attributes? attributes}) =>
+      add(TextInsert(text, attributes: attributes));
 
   /// Retain operations have a Number `retain` key defined representing the number of characters to keep (other libraries might use the name keep or skip).
   /// An optional `attributes` key can be defined with an Object to describe formatting changes to the character range.
   /// A value of `null` in the `attributes` Object represents removal of that key.
   ///
   /// *Note: It is not necessary to retain the last characters of a document as this is implied.*
-  void retain(int length, [Attributes? attributes]) =>
-      add(TextRetain(length, attributes));
+  void retain(int length, {Attributes? attributes}) =>
+      add(TextRetain(length, attributes: attributes));
 
   /// Delete operations have a Number `delete` key defined representing the number of characters to delete.
-  void delete(int length) => add(TextDelete(length));
+  void delete(int length) => add(TextDelete(length: length));
 
   /// The length of the string fo the [Delta].
   @override
@@ -369,7 +254,7 @@ class Delta extends Iterable<TextOperation> {
   Delta compose(Delta other) {
     final thisIter = _OpIterator(_operations);
     final otherIter = _OpIterator(other._operations);
-    final ops = <TextOperation>[];
+    final operations = <TextOperation>[];
 
     final firstOther = otherIter.peek();
     if (firstOther != null &&
@@ -380,14 +265,14 @@ class Delta extends Iterable<TextOperation> {
           thisIter.peek() is TextInsert && thisIter.peekLength() <= firstLeft) {
         firstLeft -= thisIter.peekLength();
         final next = thisIter._next();
-        ops.add(next);
+        operations.add(next);
       }
       if (firstOther.length - firstLeft > 0) {
         otherIter._next(firstOther.length - firstLeft);
       }
     }
 
-    final delta = Delta(ops);
+    final delta = Delta(operations: operations);
     while (thisIter.hasNext || otherIter.hasNext) {
       if (otherIter.peek() is TextInsert) {
         final next = otherIter._next();
@@ -401,13 +286,17 @@ class Delta extends Iterable<TextOperation> {
         final thisOp = thisIter._next(length);
         final otherOp = otherIter._next(length);
         final attributes = composeAttributes(
-            thisOp.attributes, otherOp.attributes, thisOp is TextRetain);
+          thisOp.attributes,
+          otherOp.attributes,
+          keepNull: thisOp is TextRetain,
+        );
+
         if (otherOp is TextRetain && otherOp.length > 0) {
           TextOperation? newOp;
           if (thisOp is TextRetain) {
-            newOp = TextRetain(length, attributes);
+            newOp = TextRetain(length, attributes: attributes);
           } else if (thisOp is TextInsert) {
-            newOp = TextInsert(thisOp.content, attributes);
+            newOp = TextInsert(thisOp.text, attributes: attributes);
           }
 
           if (newOp != null) {
@@ -418,7 +307,7 @@ class Delta extends Iterable<TextOperation> {
           if (!otherIter.hasNext &&
               delta._operations.isNotEmpty &&
               delta._operations.last == newOp) {
-            final rest = Delta(thisIter.rest());
+            final rest = Delta(operations: thisIter.rest());
             return (delta + rest)..chop();
           }
         } else if (otherOp is TextDelete && (thisOp is TextRetain)) {
@@ -432,19 +321,19 @@ class Delta extends Iterable<TextOperation> {
 
   /// This method joins two Delta together.
   Delta operator +(Delta other) {
-    var ops = [..._operations];
+    var operations = [..._operations];
     if (other._operations.isNotEmpty) {
-      ops.add(other._operations[0]);
-      ops.addAll(other._operations.sublist(1));
+      operations.add(other._operations[0]);
+      operations.addAll(other._operations.sublist(1));
     }
-    return Delta(ops);
+    return Delta(operations: operations);
   }
 
   void chop() {
     if (_operations.isEmpty) {
       return;
     }
-    _rawString = null;
+    _plainText = null;
     final lastOp = _operations.last;
     if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) {
       _operations.removeLast();
@@ -480,8 +369,10 @@ class Delta extends Iterable<TextOperation> {
           if (op is TextDelete) {
             inverted.add(baseOp);
           } else if (op is TextRetain && op.attributes != null) {
-            inverted.retain(baseOp.length,
-                invertAttributes(op.attributes, baseOp.attributes));
+            inverted.retain(
+              baseOp.length,
+              attributes: invertAttributes(baseOp.attributes, op.attributes),
+            );
           }
         }
         return previousValue + length;
@@ -506,9 +397,9 @@ class Delta extends Iterable<TextOperation> {
     if (pos == 0) {
       return pos - 1;
     }
-    _rawString ??=
-        _operations.whereType<TextInsert>().map((op) => op.content).join();
-    _runeIndexes ??= stringIndexes(_rawString!);
+    _plainText ??=
+        _operations.whereType<TextInsert>().map((op) => op.text).join();
+    _runeIndexes ??= stringIndexes(_plainText!);
     return _runeIndexes![pos - 1];
   }
 
@@ -520,11 +411,11 @@ class Delta extends Iterable<TextOperation> {
   ///
   /// This method can help you to compute the position of the next character.
   int nextRunePosition(int pos) {
-    final stringContent = toRawString();
+    final stringContent = toPlainText();
     if (pos >= stringContent.length - 1) {
       return stringContent.length;
     }
-    _runeIndexes ??= stringIndexes(_rawString!);
+    _runeIndexes ??= stringIndexes(_plainText!);
 
     for (var i = pos + 1; i < _runeIndexes!.length; i++) {
       if (_runeIndexes![i] != pos) {
@@ -535,25 +426,116 @@ class Delta extends Iterable<TextOperation> {
     return stringContent.length;
   }
 
-  String toRawString() {
-    _rawString ??=
-        _operations.whereType<TextInsert>().map((op) => op.content).join();
-    return _rawString!;
+  String toPlainText() {
+    _plainText ??=
+        _operations.whereType<TextInsert>().map((op) => op.text).join();
+    return _plainText!;
   }
 
   @override
   Iterator<TextOperation> get iterator => _operations.iterator;
+
+  static TextOperation? _textOperationFromJson(Map<String, dynamic> json) {
+    TextOperation? operation;
+
+    if (json['insert'] is String) {
+      final attributes = json['attributes'] as Map<String, dynamic>?;
+      operation = TextInsert(
+        json['insert'] as String,
+        attributes: attributes != null ? {...attributes} : null,
+      );
+    } else if (json['retain'] is int) {
+      final attrs = json['attributes'] as Map<String, dynamic>?;
+      operation = TextRetain(
+        json['retain'] as int,
+        attributes: attrs != null ? {...attrs} : null,
+      );
+    } else if (json['delete'] is int) {
+      operation = TextDelete(length: json['delete'] as int);
+    }
+
+    return operation;
+  }
 }
 
-List<int> stringIndexes(String content) {
-  final indexes = List<int>.filled(content.length, 0);
-  final iterator = content.runes.iterator;
+class _OpIterator {
+  _OpIterator(
+    Iterable<TextOperation> operations,
+  ) : _operations = UnmodifiableListView(operations);
 
-  while (iterator.moveNext()) {
-    for (var i = 0; i < iterator.currentSize; i++) {
-      indexes[iterator.rawIndex + i] = iterator.rawIndex;
+  final UnmodifiableListView<TextOperation> _operations;
+  int _index = 0;
+  int _offset = 0;
+
+  bool get hasNext {
+    return peekLength() < _maxInt;
+  }
+
+  TextOperation? peek() {
+    if (_index >= _operations.length) {
+      return null;
     }
+
+    return _operations[_index];
   }
 
-  return indexes;
+  int peekLength() {
+    if (_index < _operations.length) {
+      final op = _operations[_index];
+      return op.length - _offset;
+    }
+    return _maxInt;
+  }
+
+  TextOperation _next([int? length]) {
+    length ??= _maxInt;
+
+    if (_index >= _operations.length) {
+      return TextRetain(_maxInt);
+    }
+
+    final nextOp = _operations[_index];
+
+    final offset = _offset;
+    final opLength = nextOp.length;
+    if (length >= opLength - offset) {
+      length = opLength - offset;
+      _index += 1;
+      _offset = 0;
+    } else {
+      _offset += length;
+    }
+    if (nextOp is TextDelete) {
+      return TextDelete(length: length);
+    }
+
+    if (nextOp is TextRetain) {
+      return TextRetain(length, attributes: nextOp.attributes);
+    }
+
+    if (nextOp is TextInsert) {
+      return TextInsert(
+        nextOp.text.substring(offset, offset + length),
+        attributes: nextOp.attributes,
+      );
+    }
+
+    return TextRetain(_maxInt);
+  }
+
+  List<TextOperation> rest() {
+    if (!hasNext) {
+      return [];
+    } else if (_offset == 0) {
+      return _operations.sublist(_index);
+    } else {
+      final offset = _offset;
+      final index = _index;
+      final next = _next();
+      final rest = _operations.sublist(_index);
+      _offset = offset;
+      _index = index;
+      return [next] + rest;
+    }
+  }
 }

+ 0 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/document/built_in_attribute_keys.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/core/legacy/built_in_attribute_keys.dart


+ 12 - 14
frontend/app_flowy/packages/appflowy_editor/lib/src/document/position.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart

@@ -1,4 +1,4 @@
-import './path.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
 
 class Position {
   final Path path;
@@ -11,17 +11,18 @@ class Position {
 
   @override
   bool operator ==(Object other) {
-    if (other is! Position) {
-      return false;
-    }
-    return pathEquals(path, other.path) && offset == other.offset;
+    if (identical(this, other)) return true;
+
+    return other is Position &&
+        other.path.equals(path) &&
+        other.offset == offset;
   }
 
   @override
-  int get hashCode {
-    final pathHash = Object.hashAll(path);
-    return Object.hash(pathHash, offset);
-  }
+  int get hashCode => Object.hash(offset, Object.hashAll(path));
+
+  @override
+  String toString() => 'path = $path, offset = $offset';
 
   Position copyWith({Path? path, int? offset}) {
     return Position(
@@ -30,13 +31,10 @@ class Position {
     );
   }
 
-  @override
-  String toString() => 'path = $path, offset = $offset';
-
   Map<String, dynamic> toJson() {
     return {
-      "path": path.toList(),
-      "offset": offset,
+      'path': path,
+      'offset': offset,
     };
   }
 }

+ 40 - 33
frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart

@@ -1,6 +1,5 @@
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/extensions/path_extensions.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
 
 /// Selection represents the selected area or the cursor area in the editor.
 ///
@@ -37,31 +36,58 @@ class Selection {
   final Position start;
   final Position end;
 
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is Selection && other.start == start && other.end == end;
+  }
+
+  @override
+  int get hashCode => start.hashCode ^ end.hashCode;
+
+  @override
+  String toString() => 'start = $start, end = $end';
+
+  /// Returns a Boolean indicating whether the selection's start and end points
+  /// are at the same position.
   bool get isCollapsed => start == end;
-  bool get isSingle => pathEquals(start.path, end.path);
+
+  /// Returns a Boolean indicating whether the selection's start and end points
+  /// are at the same path.
+  bool get isSingle => start.path.equals(end.path);
+
+  /// Returns a Boolean indicating whether the selection is forward.
   bool get isForward =>
       (start.path > end.path) || (isSingle && start.offset > end.offset);
+
+  /// Returns a Boolean indicating whether the selection is backward.
   bool get isBackward =>
       (start.path < end.path) || (isSingle && start.offset < end.offset);
 
-  Selection get normalize {
-    if (isForward) {
-      return reversed;
-    }
-    return this;
-  }
+  /// Returns a normalized selection that direction is forward.
+  Selection get normalized => isBackward ? copyWith() : reversed.copyWith();
 
+  /// Returns a reversed selection.
   Selection get reversed => copyWith(start: end, end: start);
 
-  int get startIndex => normalize.start.offset;
-  int get endIndex => normalize.end.offset;
+  /// Returns the offset in the starting position under the normalized selection.
+  int get startIndex => normalized.start.offset;
+
+  /// Returns the offset in the ending position under the normalized selection.
+  int get endIndex => normalized.end.offset;
+
   int get length => endIndex - startIndex;
 
+  /// Collapses the current selection to a single point.
+  ///
+  /// If [atStart] is true, the selection will be collapsed to the start point.
+  /// If [atStart] is false, the selection will be collapsed to the end point.
   Selection collapse({bool atStart = false}) {
     if (atStart) {
-      return Selection(start: start, end: start);
+      return copyWith(end: start);
     } else {
-      return Selection(start: end, end: end);
+      return copyWith(start: end);
     }
   }
 
@@ -72,29 +98,10 @@ class Selection {
     );
   }
 
-  Selection copy() => Selection(start: start, end: end);
-
   Map<String, dynamic> toJson() {
     return {
       'start': start.toJson(),
       'end': end.toJson(),
     };
   }
-
-  @override
-  bool operator ==(Object other) {
-    if (other is! Selection) {
-      return false;
-    }
-    if (identical(this, other)) {
-      return true;
-    }
-    return start == other.start && end == other.end;
-  }
-
-  @override
-  int get hashCode => Object.hash(start, end);
-
-  @override
-  String toString() => '[Selection] start = $start, end = $end';
 }

+ 273 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/operation.dart

@@ -0,0 +1,273 @@
+import 'package:flutter/foundation.dart';
+
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
+
+/// [Operation] represents a change to a [Document].
+abstract class Operation {
+  Operation(
+    this.path,
+  );
+
+  factory Operation.fromJson() => throw UnimplementedError();
+
+  final Path path;
+
+  /// Inverts the operation.
+  ///
+  /// Returns the inverted operation.
+  Operation invert();
+
+  /// Returns the JSON representation of the operation.
+  Map<String, dynamic> toJson();
+
+  Operation copyWith({Path? path});
+}
+
+/// [InsertOperation] represents an insert operation.
+class InsertOperation extends Operation {
+  InsertOperation(
+    super.path,
+    this.nodes,
+  );
+
+  factory InsertOperation.fromJson(Map<String, dynamic> json) {
+    final path = json['path'] as Path;
+    final nodes = (json['nodes'] as List)
+        .map((n) => Node.fromJson(n))
+        .toList(growable: false);
+    return InsertOperation(path, nodes);
+  }
+
+  final Iterable<Node> nodes;
+
+  @override
+  Operation invert() => DeleteOperation(path, nodes);
+
+  @override
+  Map<String, dynamic> toJson() {
+    return {
+      'op': 'insert',
+      'path': path,
+      'nodes': nodes.map((n) => n.toJson()).toList(growable: false),
+    };
+  }
+
+  @override
+  Operation copyWith({Path? path}) {
+    return InsertOperation(path ?? this.path, nodes);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is InsertOperation &&
+        other.path.equals(path) &&
+        other.nodes.equals(nodes);
+  }
+
+  @override
+  int get hashCode => path.hashCode ^ Object.hashAll(nodes);
+}
+
+/// [DeleteOperation] represents a delete operation.
+class DeleteOperation extends Operation {
+  DeleteOperation(
+    super.path,
+    this.nodes,
+  );
+
+  factory DeleteOperation.fromJson(Map<String, dynamic> json) {
+    final path = json['path'] as Path;
+    final nodes = (json['nodes'] as List)
+        .map((n) => Node.fromJson(n))
+        .toList(growable: false);
+    return DeleteOperation(path, nodes);
+  }
+
+  final Iterable<Node> nodes;
+
+  @override
+  Operation invert() => InsertOperation(path, nodes);
+
+  @override
+  Map<String, dynamic> toJson() {
+    return {
+      'op': 'delete',
+      'path': path,
+      'nodes': nodes.map((n) => n.toJson()).toList(growable: false),
+    };
+  }
+
+  @override
+  Operation copyWith({Path? path}) {
+    return DeleteOperation(path ?? this.path, nodes);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is DeleteOperation &&
+        other.path.equals(path) &&
+        other.nodes.equals(nodes);
+  }
+
+  @override
+  int get hashCode => path.hashCode ^ Object.hashAll(nodes);
+}
+
+/// [UpdateOperation] represents an attributes update operation.
+class UpdateOperation extends Operation {
+  UpdateOperation(
+    super.path,
+    this.attributes,
+    this.oldAttributes,
+  );
+
+  factory UpdateOperation.fromJson(Map<String, dynamic> json) {
+    final path = json['path'] as Path;
+    final oldAttributes = json['oldAttributes'] as Attributes;
+    final attributes = json['attributes'] as Attributes;
+    return UpdateOperation(
+      path,
+      attributes,
+      oldAttributes,
+    );
+  }
+
+  final Attributes attributes;
+  final Attributes oldAttributes;
+
+  @override
+  Operation invert() => UpdateOperation(
+        path,
+        oldAttributes,
+        attributes,
+      );
+
+  @override
+  Map<String, dynamic> toJson() {
+    return {
+      'op': 'update',
+      'path': path,
+      'attributes': {...attributes},
+      'oldAttributes': {...oldAttributes},
+    };
+  }
+
+  @override
+  Operation copyWith({Path? path}) {
+    return UpdateOperation(
+      path ?? this.path,
+      {...attributes},
+      {...oldAttributes},
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is UpdateOperation &&
+        other.path.equals(path) &&
+        mapEquals(other.attributes, attributes) &&
+        mapEquals(other.oldAttributes, oldAttributes);
+  }
+
+  @override
+  int get hashCode =>
+      path.hashCode ^ attributes.hashCode ^ oldAttributes.hashCode;
+}
+
+/// [UpdateTextOperation] represents a text update operation.
+class UpdateTextOperation extends Operation {
+  UpdateTextOperation(
+    super.path,
+    this.delta,
+    this.inverted,
+  );
+
+  factory UpdateTextOperation.fromJson(Map<String, dynamic> json) {
+    final path = json['path'] as Path;
+    final delta = Delta.fromJson(json['delta']);
+    final inverted = Delta.fromJson(json['inverted']);
+    return UpdateTextOperation(path, delta, inverted);
+  }
+
+  final Delta delta;
+  final Delta inverted;
+
+  @override
+  Operation invert() => UpdateTextOperation(path, inverted, delta);
+
+  @override
+  Map<String, dynamic> toJson() {
+    return {
+      'op': 'update_text',
+      'path': path,
+      'delta': delta.toJson(),
+      'inverted': inverted.toJson(),
+    };
+  }
+
+  @override
+  Operation copyWith({Path? path}) {
+    return UpdateTextOperation(path ?? this.path, delta, inverted);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is UpdateTextOperation &&
+        other.path.equals(path) &&
+        other.delta == delta &&
+        other.inverted == inverted;
+  }
+
+  @override
+  int get hashCode => delta.hashCode ^ inverted.hashCode;
+}
+
+// TODO(Lucas.Xu): refactor this part
+Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
+  if (preInsertPath.length > b.length) {
+    return b;
+  }
+  if (preInsertPath.isEmpty || b.isEmpty) {
+    return b;
+  }
+  // check the prefix
+  for (var i = 0; i < preInsertPath.length - 1; i++) {
+    if (preInsertPath[i] != b[i]) {
+      return b;
+    }
+  }
+  final prefix = preInsertPath.sublist(0, preInsertPath.length - 1);
+  final suffix = b.sublist(preInsertPath.length);
+  final preInsertLast = preInsertPath.last;
+  final bAtIndex = b[preInsertPath.length - 1];
+  if (preInsertLast <= bAtIndex) {
+    prefix.add(bAtIndex + delta);
+  } else {
+    prefix.add(bAtIndex);
+  }
+  prefix.addAll(suffix);
+  return prefix;
+}
+
+Operation transformOperation(Operation a, Operation b) {
+  if (a is InsertOperation) {
+    final newPath = transformPath(a.path, b.path, a.nodes.length);
+    return b.copyWith(path: newPath);
+  } else if (a is DeleteOperation) {
+    final newPath = transformPath(a.path, b.path, -1 * a.nodes.length);
+    return b.copyWith(path: newPath);
+  }
+  // TODO: transform update and textedit
+  return b;
+}

+ 267 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart

@@ -0,0 +1,267 @@
+import 'dart:math';
+
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/document/document.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
+import 'package:appflowy_editor/src/core/transform/operation.dart';
+
+/// A [Transaction] has a list of [Operation] objects that will be applied
+/// to the editor.
+///
+/// There will be several ways to consume the transaction:
+/// 1. Apply to the state to update the UI.
+/// 2. Send to the backend to store and do operation transforming.
+class Transaction {
+  Transaction({
+    required this.document,
+  });
+
+  final Document document;
+
+  /// The operations to be applied.
+  final List<Operation> operations = [];
+
+  /// The selection to be applied.
+  Selection? afterSelection;
+
+  /// The before selection is to be recovered if needed.
+  Selection? beforeSelection;
+
+  /// Inserts the [Node] at the given [Path].
+  void insertNode(
+    Path path,
+    Node node, {
+    bool deepCopy = true,
+  }) {
+    insertNodes(path, [node], deepCopy: deepCopy);
+  }
+
+  /// Inserts a sequence of [Node]s at the given [Path].
+  void insertNodes(
+    Path path,
+    Iterable<Node> nodes, {
+    bool deepCopy = true,
+  }) {
+    if (deepCopy) {
+      add(InsertOperation(path, nodes.map((e) => e.copyWith())));
+    } else {
+      add(InsertOperation(path, nodes));
+    }
+  }
+
+  /// Updates the attributes of the [Node].
+  ///
+  /// The [attributes] will be merged into the existing attributes.
+  void updateNode(Node node, Attributes attributes) {
+    final inverted = invertAttributes(node.attributes, attributes);
+    add(UpdateOperation(
+      node.path,
+      {...attributes},
+      inverted,
+    ));
+  }
+
+  /// Deletes the [Node] in the document.
+  void deleteNode(Node node) {
+    deleteNodesAtPath(node.path);
+  }
+
+  /// Deletes the [Node]s in the document.
+  void deleteNodes(Iterable<Node> nodes) {
+    nodes.forEach(deleteNode);
+  }
+
+  /// Deletes the [Node]s at the given [Path].
+  ///
+  /// The [length] indicates the number of consecutive deletions,
+  ///   including the node of the current path.
+  void deleteNodesAtPath(Path path, [int length = 1]) {
+    if (path.isEmpty) return;
+    final nodes = <Node>[];
+    final parent = path.parent;
+    for (var i = 0; i < length; i++) {
+      final node = document.nodeAtPath(parent + [path.last + i]);
+      if (node == null) {
+        break;
+      }
+      nodes.add(node);
+    }
+    add(DeleteOperation(path, nodes));
+  }
+
+  /// Update the [TextNode]s with the given [Delta].
+  void updateText(TextNode textNode, Delta delta) {
+    final inverted = delta.invert(textNode.delta);
+    add(UpdateTextOperation(textNode.path, delta, inverted));
+  }
+
+  /// Returns the JSON representation of the transaction.
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+    if (operations.isNotEmpty) {
+      json['operations'] = operations.map((o) => o.toJson());
+    }
+    if (afterSelection != null) {
+      json['after_selection'] = afterSelection!.toJson();
+    }
+    if (beforeSelection != null) {
+      json['before_selection'] = beforeSelection!.toJson();
+    }
+    return json;
+  }
+
+  /// Adds an operation to the transaction.
+  /// This method will merge operations if they are both TextEdits.
+  ///
+  /// Also, this method will transform the path of the operations
+  /// to avoid conflicts.
+  add(Operation op, {bool transform = true}) {
+    final Operation? last = operations.isEmpty ? null : operations.last;
+    if (last != null) {
+      if (op is UpdateTextOperation &&
+          last is UpdateTextOperation &&
+          op.path.equals(last.path)) {
+        final newOp = UpdateTextOperation(
+          op.path,
+          last.delta.compose(op.delta),
+          op.inverted.compose(last.inverted),
+        );
+        operations[operations.length - 1] = newOp;
+        return;
+      }
+    }
+    if (transform) {
+      for (var i = 0; i < operations.length; i++) {
+        op = transformOperation(operations[i], op);
+      }
+    }
+    if (op is UpdateTextOperation && op.delta.isEmpty) {
+      return;
+    }
+    operations.add(op);
+  }
+}
+
+extension TextTransaction on Transaction {
+  void mergeText(
+    TextNode first,
+    TextNode second, {
+    int? firstOffset,
+    int secondOffset = 0,
+  }) {
+    final firstLength = first.delta.length;
+    final secondLength = second.delta.length;
+    firstOffset ??= firstLength;
+    updateText(
+      first,
+      Delta()
+        ..retain(firstOffset)
+        ..delete(firstLength - firstOffset)
+        ..addAll(second.delta.slice(secondOffset, secondLength)),
+    );
+    afterSelection = Selection.collapsed(Position(
+      path: first.path,
+      offset: firstOffset,
+    ));
+  }
+
+  /// Inserts the text content at a specified index.
+  ///
+  /// Optionally, you may specify formatting attributes that are applied to the inserted string.
+  /// By default, the formatting attributes before the insert position will be reused.
+  void insertText(
+    TextNode textNode,
+    int index,
+    String text, {
+    Attributes? attributes,
+  }) {
+    var newAttributes = attributes;
+    if (index != 0 && attributes == null) {
+      newAttributes =
+          textNode.delta.slice(max(index - 1, 0), index).first.attributes;
+      if (newAttributes != null) {
+        newAttributes = {...newAttributes}; // make a copy
+      }
+    }
+    updateText(
+      textNode,
+      Delta()
+        ..retain(index)
+        ..insert(text, attributes: newAttributes),
+    );
+    afterSelection = Selection.collapsed(
+      Position(path: textNode.path, offset: index + text.length),
+    );
+  }
+
+  /// Assigns a formatting attributes to a range of text.
+  formatText(
+    TextNode textNode,
+    int index,
+    int length,
+    Attributes attributes,
+  ) {
+    afterSelection = beforeSelection;
+    updateText(
+      textNode,
+      Delta()
+        ..retain(index)
+        ..retain(length, attributes: attributes),
+    );
+  }
+
+  /// Deletes the text of specified length starting at index.
+  deleteText(
+    TextNode textNode,
+    int index,
+    int length,
+  ) {
+    updateText(
+      textNode,
+      Delta()
+        ..retain(index)
+        ..delete(length),
+    );
+    afterSelection = Selection.collapsed(
+      Position(path: textNode.path, offset: index),
+    );
+  }
+
+  /// Replaces the text of specified length starting at index.
+  ///
+  /// Optionally, you may specify formatting attributes that are applied to the inserted string.
+  /// By default, the formatting attributes before the insert position will be reused.
+  replaceText(
+    TextNode textNode,
+    int index,
+    int length,
+    String text, {
+    Attributes? attributes,
+  }) {
+    var newAttributes = attributes;
+    if (index != 0 && attributes == null) {
+      newAttributes =
+          textNode.delta.slice(max(index - 1, 0), index).first.attributes;
+      if (newAttributes != null) {
+        newAttributes = {...newAttributes}; // make a copy
+      }
+    }
+    updateText(
+      textNode,
+      Delta()
+        ..retain(index)
+        ..delete(length)
+        ..insert(text, attributes: newAttributes),
+    );
+    afterSelection = Selection.collapsed(
+      Position(
+        path: textNode.path,
+        offset: index + text.length,
+      ),
+    );
+  }
+}

+ 0 - 42
frontend/app_flowy/packages/appflowy_editor/lib/src/document/attributes.dart

@@ -1,42 +0,0 @@
-typedef Attributes = Map<String, dynamic>;
-
-int hashAttributes(Attributes attributes) {
-  return Object.hashAllUnordered(
-      attributes.entries.map((e) => Object.hash(e.key, e.value)));
-}
-
-Attributes invertAttributes(Attributes? attr, Attributes? base) {
-  attr ??= {};
-  base ??= {};
-  final Attributes baseInverted = base.keys.fold({}, (memo, key) {
-    if (base![key] != attr![key] && attr.containsKey(key)) {
-      memo[key] = base[key];
-    }
-    return memo;
-  });
-  return attr.keys.fold(baseInverted, (memo, key) {
-    if (attr![key] != base![key] && !base.containsKey(key)) {
-      memo[key] = null;
-    }
-    return memo;
-  });
-}
-
-Attributes? composeAttributes(Attributes? a, Attributes? b,
-    [bool keepNull = false]) {
-  a ??= {};
-  b ??= {};
-  Attributes attributes = {...b};
-
-  if (!keepNull) {
-    attributes = Map.from(attributes)..removeWhere((_, value) => value == null);
-  }
-
-  for (final entry in a.entries) {
-    if (!b.containsKey(entry.key)) {
-      attributes[entry.key] = entry.value;
-    }
-  }
-
-  return attributes.isNotEmpty ? attributes : null;
-}

+ 0 - 7
frontend/app_flowy/packages/appflowy_editor/lib/src/document/path.dart

@@ -1,7 +0,0 @@
-import 'package:flutter/foundation.dart';
-
-typedef Path = List<int>;
-
-bool pathEquals(Path path1, Path path2) {
-  return listEquals(path1, path2);
-}

+ 0 - 116
frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart

@@ -1,116 +0,0 @@
-import 'dart:math';
-
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/text_delta.dart';
-import './attributes.dart';
-
-class StateTree {
-  final Node root;
-
-  StateTree({
-    required this.root,
-  });
-
-  factory StateTree.empty() {
-    return StateTree(
-      root: Node.fromJson({
-        'type': 'editor',
-        'children': [
-          {
-            'type': 'text',
-          }
-        ]
-      }),
-    );
-  }
-
-  factory StateTree.fromJson(Attributes json) {
-    assert(json['document'] is Map);
-
-    final document = Map<String, Object>.from(json['document'] as Map);
-    final root = Node.fromJson(document);
-    return StateTree(root: root);
-  }
-
-  Map<String, Object> toJson() {
-    return {
-      'document': root.toJson(),
-    };
-  }
-
-  Node? nodeAtPath(Path path) {
-    return root.childAtPath(path);
-  }
-
-  bool insert(Path path, List<Node> nodes) {
-    if (path.isEmpty) {
-      return false;
-    }
-    Node? insertedNode = root.childAtPath(
-      path.sublist(0, path.length - 1) + [max(0, path.last - 1)],
-    );
-    if (insertedNode == null) {
-      final insertedNode = root.childAtPath(
-        path.sublist(0, path.length - 1),
-      );
-      if (insertedNode != null) {
-        for (final node in nodes) {
-          insertedNode.insert(node);
-        }
-        return true;
-      }
-      return false;
-    }
-    if (path.last <= 0) {
-      for (var i = 0; i < nodes.length; i++) {
-        final node = nodes[i];
-        insertedNode.insertBefore(node);
-      }
-    } else {
-      for (var i = 0; i < nodes.length; i++) {
-        final node = nodes[i];
-        insertedNode!.insertAfter(node);
-        insertedNode = node;
-      }
-    }
-    return true;
-  }
-
-  bool textEdit(Path path, Delta delta) {
-    if (path.isEmpty) {
-      return false;
-    }
-    final node = root.childAtPath(path);
-    if (node == null || node is! TextNode) {
-      return false;
-    }
-    node.delta = node.delta.compose(delta);
-    return false;
-  }
-
-  delete(Path path, [int length = 1]) {
-    if (path.isEmpty) {
-      return null;
-    }
-    var deletedNode = root.childAtPath(path);
-    while (deletedNode != null && length > 0) {
-      final next = deletedNode.next;
-      deletedNode.unlink();
-      length--;
-      deletedNode = next;
-    }
-  }
-
-  bool update(Path path, Attributes attributes) {
-    if (path.isEmpty) {
-      return false;
-    }
-    final updatedNode = root.childAtPath(path);
-    if (updatedNode == null) {
-      return false;
-    }
-    updatedNode.updateAttributes(attributes);
-    return true;
-  }
-}

+ 26 - 8
frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart

@@ -5,10 +5,10 @@ import 'package:appflowy_editor/src/render/style/editor_style.dart';
 import 'package:appflowy_editor/src/service/service.dart';
 import 'package:flutter/material.dart';
 
-import 'package:appflowy_editor/src/document/selection.dart';
-import 'package:appflowy_editor/src/document/state_tree.dart';
-import 'package:appflowy_editor/src/operation/operation.dart';
-import 'package:appflowy_editor/src/operation/transaction.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
+import 'package:appflowy_editor/src/core/document/document.dart';
+import 'package:appflowy_editor/src/core/transform/operation.dart';
+import 'package:appflowy_editor/src/core/transform/transaction.dart';
 import 'package:appflowy_editor/src/undo_manager.dart';
 
 class ApplyOptions {
@@ -46,7 +46,7 @@ enum CursorUpdateReason {
 ///
 /// Mutating the document with document's API is not recommended.
 class EditorState {
-  final StateTree document;
+  final Document document;
 
   // Service reference.
   final service = FlowyService();
@@ -74,6 +74,24 @@ class EditorState {
 
   bool editable = true;
 
+  Transaction get transaction {
+    if (_transaction != null) {
+      return _transaction!;
+    }
+    _transaction = Transaction(document: document);
+    _transaction!.beforeSelection = _cursorSelection;
+    return _transaction!;
+  }
+
+  Transaction? _transaction;
+
+  void commit() {
+    if (_transaction != null) {
+      apply(_transaction!, const ApplyOptions(recordUndo: true));
+      _transaction = null;
+    }
+  }
+
   Selection? get cursorSelection {
     return _cursorSelection;
   }
@@ -105,7 +123,7 @@ class EditorState {
   }
 
   factory EditorState.empty() {
-    return EditorState(document: StateTree.empty());
+    return EditorState(document: Document.empty());
   }
 
   /// Apply the transaction to the state.
@@ -166,8 +184,8 @@ class EditorState {
       document.update(op.path, op.attributes);
     } else if (op is DeleteOperation) {
       document.delete(op.path, op.nodes.length);
-    } else if (op is TextEditOperation) {
-      document.textEdit(op.path, op.delta);
+    } else if (op is UpdateTextOperation) {
+      document.updateText(op.path, op.delta);
     }
     _observer.add(op);
   }

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/attributes_extension.dart

@@ -1,5 +1,5 @@
-import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
 import 'package:flutter/material.dart';
 
 extension NodeAttributesExtensions on Attributes {

+ 3 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart

@@ -1,7 +1,7 @@
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/extensions/object_extensions.dart';
-import 'package:appflowy_editor/src/extensions/path_extensions.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:flutter/material.dart';
 

+ 11 - 12
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart

@@ -1,9 +1,9 @@
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
-import 'package:appflowy_editor/src/document/text_delta.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
 
 extension TextNodeExtension on TextNode {
   T? getAttributeInSelection<T>(Selection selection, String styleKey) {
@@ -168,18 +168,17 @@ extension TextNodesExtension on List<TextNode> {
       for (var i = 0; i < length; i++) {
         final node = this[i];
         final Selection newSelection;
-        if (i == 0 && pathEquals(node.path, selection.start.path)) {
+        if (i == 0 && node.path.equals(selection.start.path)) {
           if (selection.isBackward) {
             newSelection = selection.copyWith(
-              end: Position(path: node.path, offset: node.toRawString().length),
+              end: Position(path: node.path, offset: node.toPlainText().length),
             );
           } else {
             newSelection = selection.copyWith(
               end: Position(path: node.path, offset: 0),
             );
           }
-        } else if (i == length - 1 &&
-            pathEquals(node.path, selection.end.path)) {
+        } else if (i == length - 1 && node.path.equals(selection.end.path)) {
           if (selection.isBackward) {
             newSelection = selection.copyWith(
               start: Position(path: node.path, offset: 0),
@@ -187,13 +186,13 @@ extension TextNodesExtension on List<TextNode> {
           } else {
             newSelection = selection.copyWith(
               start:
-                  Position(path: node.path, offset: node.toRawString().length),
+                  Position(path: node.path, offset: node.toPlainText().length),
             );
           }
         } else {
           newSelection = Selection(
             start: Position(path: node.path, offset: 0),
-            end: Position(path: node.path, offset: node.toRawString().length),
+            end: Position(path: node.path, offset: node.toPlainText().length),
           );
         }
         if (!node.allSatisfyInSelection(newSelection, styleKey, test)) {

+ 25 - 22
frontend/app_flowy/packages/appflowy_editor/lib/src/infra/html_converter.dart

@@ -1,14 +1,14 @@
 import 'dart:collection';
 import 'dart:ui';
 
-import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/text_delta.dart';
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
 import 'package:appflowy_editor/src/extensions/color_extension.dart';
 import 'package:flutter/material.dart';
 import 'package:html/parser.dart' show parse;
 import 'package:html/dom.dart' as html;
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
 
 class HTMLTag {
   static const h1 = "h1";
@@ -89,7 +89,7 @@ class HTMLToNodesConverter {
       }
     }
     if (delta.isNotEmpty) {
-      result.add(TextNode(type: "text", delta: delta));
+      result.add(TextNode(delta: delta));
     }
     return result;
   }
@@ -134,7 +134,7 @@ class HTMLToNodesConverter {
       final delta = Delta();
       delta.insert(element.text);
       if (delta.isNotEmpty) {
-        return [TextNode(type: "text", delta: delta)];
+        return [TextNode(delta: delta)];
       }
     }
     return [];
@@ -218,24 +218,29 @@ class HTMLToNodesConverter {
 
   _handleRichTextElement(Delta delta, html.Element element) {
     if (element.localName == HTMLTag.span) {
-      delta.insert(element.text,
-          _getDeltaAttributesFromHtmlAttributes(element.attributes));
+      delta.insert(
+        element.text,
+        attributes: _getDeltaAttributesFromHtmlAttributes(element.attributes),
+      );
     } else if (element.localName == HTMLTag.anchor) {
       final hyperLink = element.attributes["href"];
       Map<String, dynamic>? attributes;
       if (hyperLink != null) {
         attributes = {"href": hyperLink};
       }
-      delta.insert(element.text, attributes);
+      delta.insert(element.text, attributes: attributes);
     } else if (element.localName == HTMLTag.strong ||
         element.localName == HTMLTag.bold) {
-      delta.insert(element.text, {BuiltInAttributeKey.bold: true});
+      delta.insert(element.text, attributes: {BuiltInAttributeKey.bold: true});
     } else if (element.localName == HTMLTag.underline) {
-      delta.insert(element.text, {BuiltInAttributeKey.underline: true});
+      delta.insert(element.text,
+          attributes: {BuiltInAttributeKey.underline: true});
     } else if (element.localName == HTMLTag.italic) {
-      delta.insert(element.text, {BuiltInAttributeKey.italic: true});
+      delta
+          .insert(element.text, attributes: {BuiltInAttributeKey.italic: true});
     } else if (element.localName == HTMLTag.del) {
-      delta.insert(element.text, {BuiltInAttributeKey.strikethrough: true});
+      delta.insert(element.text,
+          attributes: {BuiltInAttributeKey.strikethrough: true});
     } else {
       delta.insert(element.text);
     }
@@ -271,8 +276,7 @@ class HTMLToNodesConverter {
       }
     }
 
-    final textNode =
-        TextNode(type: "text", delta: delta, attributes: attributes);
+    final textNode = TextNode(delta: delta, attributes: attributes);
     if (isCheckbox) {
       textNode.attributes["subtype"] = BuiltInAttributeKey.checkbox;
       textNode.attributes["checkbox"] = checked;
@@ -315,7 +319,6 @@ class HTMLToNodesConverter {
     final delta = Delta();
     delta.insert(element.text);
     return TextNode(
-        type: "text",
         attributes: {"subtype": "heading", "heading": headingStyle},
         delta: delta);
   }
@@ -537,22 +540,22 @@ class NodesToHTMLConverter {
           if (attributes.length == 1 &&
               attributes[BuiltInAttributeKey.bold] == true) {
             final strong = html.Element.tag(HTMLTag.strong);
-            strong.append(html.Text(op.content));
+            strong.append(html.Text(op.text));
             childNodes.add(strong);
           } else if (attributes.length == 1 &&
               attributes[BuiltInAttributeKey.underline] == true) {
             final strong = html.Element.tag(HTMLTag.underline);
-            strong.append(html.Text(op.content));
+            strong.append(html.Text(op.text));
             childNodes.add(strong);
           } else if (attributes.length == 1 &&
               attributes[BuiltInAttributeKey.italic] == true) {
             final strong = html.Element.tag(HTMLTag.italic);
-            strong.append(html.Text(op.content));
+            strong.append(html.Text(op.text));
             childNodes.add(strong);
           } else if (attributes.length == 1 &&
               attributes[BuiltInAttributeKey.strikethrough] == true) {
             final strong = html.Element.tag(HTMLTag.del);
-            strong.append(html.Text(op.content));
+            strong.append(html.Text(op.text));
             childNodes.add(strong);
           } else {
             final span = html.Element.tag(HTMLTag.span);
@@ -560,11 +563,11 @@ class NodesToHTMLConverter {
             if (cssString.isNotEmpty) {
               span.attributes["style"] = cssString;
             }
-            span.append(html.Text(op.content));
+            span.append(html.Text(op.text));
             childNodes.add(span);
           }
         } else {
-          childNodes.add(html.Text(op.content));
+          childNodes.add(html.Text(op.text));
         }
       }
     }

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/infra/infra.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 
 class Infra {
 // find the forward nearest text node

+ 0 - 218
frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart

@@ -1,218 +0,0 @@
-import 'package:appflowy_editor/appflowy_editor.dart';
-
-abstract class Operation {
-  factory Operation.fromJson(Map<String, dynamic> map) {
-    String t = map["op"] as String;
-    if (t == "insert") {
-      return InsertOperation.fromJson(map);
-    } else if (t == "update") {
-      return UpdateOperation.fromJson(map);
-    } else if (t == "delete") {
-      return DeleteOperation.fromJson(map);
-    } else if (t == "text-edit") {
-      return TextEditOperation.fromJson(map);
-    }
-
-    throw ArgumentError('unexpected type $t');
-  }
-  final Path path;
-  Operation(this.path);
-  Operation copyWithPath(Path path);
-  Operation invert();
-  Map<String, dynamic> toJson();
-}
-
-class InsertOperation extends Operation {
-  final List<Node> nodes;
-
-  factory InsertOperation.fromJson(Map<String, dynamic> map) {
-    final path = map["path"] as List<int>;
-    final value =
-        (map["nodes"] as List<dynamic>).map((n) => Node.fromJson(n)).toList();
-    return InsertOperation(path, value);
-  }
-
-  InsertOperation(Path path, this.nodes) : super(path);
-
-  InsertOperation copyWith({Path? path, List<Node>? nodes}) =>
-      InsertOperation(path ?? this.path, nodes ?? this.nodes);
-
-  @override
-  Operation copyWithPath(Path path) => copyWith(path: path);
-
-  @override
-  Operation invert() {
-    return DeleteOperation(
-      path,
-      nodes,
-    );
-  }
-
-  @override
-  Map<String, dynamic> toJson() {
-    return {
-      "op": "insert",
-      "path": path.toList(),
-      "nodes": nodes.map((n) => n.toJson()),
-    };
-  }
-}
-
-class UpdateOperation extends Operation {
-  final Attributes attributes;
-  final Attributes oldAttributes;
-
-  factory UpdateOperation.fromJson(Map<String, dynamic> map) {
-    final path = map["path"] as List<int>;
-    final attributes = map["attributes"] as Map<String, dynamic>;
-    final oldAttributes = map["oldAttributes"] as Map<String, dynamic>;
-    return UpdateOperation(path, attributes, oldAttributes);
-  }
-
-  UpdateOperation(
-    Path path,
-    this.attributes,
-    this.oldAttributes,
-  ) : super(path);
-
-  UpdateOperation copyWith(
-          {Path? path, Attributes? attributes, Attributes? oldAttributes}) =>
-      UpdateOperation(path ?? this.path, attributes ?? this.attributes,
-          oldAttributes ?? this.oldAttributes);
-
-  @override
-  Operation copyWithPath(Path path) => copyWith(path: path);
-
-  @override
-  Operation invert() {
-    return UpdateOperation(
-      path,
-      oldAttributes,
-      attributes,
-    );
-  }
-
-  @override
-  Map<String, dynamic> toJson() {
-    return {
-      "op": "update",
-      "path": path.toList(),
-      "attributes": {...attributes},
-      "oldAttributes": {...oldAttributes},
-    };
-  }
-}
-
-class DeleteOperation extends Operation {
-  final List<Node> nodes;
-
-  factory DeleteOperation.fromJson(Map<String, dynamic> map) {
-    final path = map["path"] as List<int>;
-    final List<Node> nodes =
-        (map["nodes"] as List<dynamic>).map((e) => Node.fromJson(e)).toList();
-    return DeleteOperation(path, nodes);
-  }
-
-  DeleteOperation(
-    Path path,
-    this.nodes,
-  ) : super(path);
-
-  DeleteOperation copyWith({Path? path, List<Node>? nodes}) =>
-      DeleteOperation(path ?? this.path, nodes ?? this.nodes);
-
-  @override
-  Operation copyWithPath(Path path) => copyWith(path: path);
-
-  @override
-  Operation invert() {
-    return InsertOperation(path, nodes);
-  }
-
-  @override
-  Map<String, dynamic> toJson() {
-    return {
-      "op": "delete",
-      "path": path.toList(),
-      "nodes": nodes.map((n) => n.toJson()),
-    };
-  }
-}
-
-class TextEditOperation extends Operation {
-  final Delta delta;
-  final Delta inverted;
-
-  factory TextEditOperation.fromJson(Map<String, dynamic> map) {
-    final path = map["path"] as List<int>;
-    final delta = Delta.fromJson(map["delta"]);
-    final invert = Delta.fromJson(map["invert"]);
-    return TextEditOperation(path, delta, invert);
-  }
-
-  TextEditOperation(
-    Path path,
-    this.delta,
-    this.inverted,
-  ) : super(path);
-
-  TextEditOperation copyWith({Path? path, Delta? delta, Delta? inverted}) =>
-      TextEditOperation(
-          path ?? this.path, delta ?? this.delta, inverted ?? this.inverted);
-
-  @override
-  Operation copyWithPath(Path path) => copyWith(path: path);
-
-  @override
-  Operation invert() {
-    return TextEditOperation(path, inverted, delta);
-  }
-
-  @override
-  Map<String, dynamic> toJson() {
-    return {
-      "op": "text-edit",
-      "path": path.toList(),
-      "delta": delta.toJson(),
-      "invert": inverted.toJson(),
-    };
-  }
-}
-
-Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
-  if (preInsertPath.length > b.length) {
-    return b;
-  }
-  if (preInsertPath.isEmpty || b.isEmpty) {
-    return b;
-  }
-  // check the prefix
-  for (var i = 0; i < preInsertPath.length - 1; i++) {
-    if (preInsertPath[i] != b[i]) {
-      return b;
-    }
-  }
-  final prefix = preInsertPath.sublist(0, preInsertPath.length - 1);
-  final suffix = b.sublist(preInsertPath.length);
-  final preInsertLast = preInsertPath.last;
-  final bAtIndex = b[preInsertPath.length - 1];
-  if (preInsertLast <= bAtIndex) {
-    prefix.add(bAtIndex + delta);
-  } else {
-    prefix.add(bAtIndex);
-  }
-  prefix.addAll(suffix);
-  return prefix;
-}
-
-Operation transformOperation(Operation a, Operation b) {
-  if (a is InsertOperation) {
-    final newPath = transformPath(a.path, b.path, a.nodes.length);
-    return b.copyWithPath(newPath);
-  } else if (a is DeleteOperation) {
-    final newPath = transformPath(a.path, b.path, -1 * a.nodes.length);
-    return b.copyWithPath(newPath);
-  }
-  // TODO: transform update and textedit
-  return b;
-}

+ 0 - 39
frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart

@@ -1,39 +0,0 @@
-import 'dart:collection';
-import 'package:flutter/material.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
-import './operation.dart';
-
-/// A [Transaction] has a list of [Operation] objects that will be applied
-/// to the editor. It is an immutable class and used to store and transmit.
-///
-/// If you want to build a new [Transaction], use [TransactionBuilder] directly.
-///
-/// There will be several ways to consume the transaction:
-/// 1. Apply to the state to update the UI.
-/// 2. Send to the backend to store and do operation transforming.
-/// 3. Used by the UndoManager to implement redo/undo.
-@immutable
-class Transaction {
-  final UnmodifiableListView<Operation> operations;
-  final Selection? beforeSelection;
-  final Selection? afterSelection;
-
-  const Transaction({
-    required this.operations,
-    this.beforeSelection,
-    this.afterSelection,
-  });
-
-  Map<String, dynamic> toJson() {
-    final Map<String, dynamic> result = {
-      "operations": operations.map((e) => e.toJson()),
-    };
-    if (beforeSelection != null) {
-      result["beforeSelection"] = beforeSelection!.toJson();
-    }
-    if (afterSelection != null) {
-      result["afterSelection"] = afterSelection!.toJson();
-    }
-    return result;
-  }
-}

+ 0 - 230
frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart

@@ -1,230 +0,0 @@
-import 'dart:collection';
-import 'dart:math';
-
-import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
-import 'package:appflowy_editor/src/document/text_delta.dart';
-import 'package:appflowy_editor/src/editor_state.dart';
-import 'package:appflowy_editor/src/operation/operation.dart';
-import 'package:appflowy_editor/src/operation/transaction.dart';
-
-/// A [TransactionBuilder] is used to build the transaction from the state.
-/// It will save a snapshot of the cursor selection state automatically.
-/// The cursor can be restored if the transaction is undo.
-class TransactionBuilder {
-  final List<Operation> operations = [];
-  EditorState state;
-  Selection? beforeSelection;
-  Selection? afterSelection;
-
-  TransactionBuilder(this.state);
-
-  /// Commits the operations to the state
-  Future<void> commit() async {
-    final transaction = finish();
-    state.apply(transaction);
-  }
-
-  /// Inserts the nodes at the position of path.
-  insertNode(Path path, Node node) {
-    insertNodes(path, [node]);
-  }
-
-  /// Inserts a sequence of nodes at the position of path.
-  insertNodes(Path path, List<Node> nodes) {
-    beforeSelection = state.cursorSelection;
-    add(InsertOperation(path, nodes.map((node) => node.copyWith()).toList()));
-  }
-
-  /// Updates the attributes of nodes.
-  updateNode(Node node, Attributes attributes) {
-    beforeSelection = state.cursorSelection;
-
-    final inverted = invertAttributes(attributes, node.attributes);
-    add(UpdateOperation(
-      node.path,
-      {...attributes},
-      inverted,
-    ));
-  }
-
-  /// Deletes a node in the document.
-  deleteNode(Node node) {
-    deleteNodesAtPath(node.path);
-  }
-
-  deleteNodes(List<Node> nodes) {
-    nodes.forEach(deleteNode);
-  }
-
-  /// Deletes a sequence of nodes at the path of the document.
-  /// The length specifies the length of the following nodes to delete(
-  /// including the start one).
-  deleteNodesAtPath(Path path, [int length = 1]) {
-    if (path.isEmpty) {
-      return;
-    }
-    final nodes = <Node>[];
-    final prefix = path.sublist(0, path.length - 1);
-    final last = path.last;
-    for (var i = 0; i < length; i++) {
-      final node = state.document.nodeAtPath(prefix + [last + i])!;
-      nodes.add(node);
-    }
-
-    add(DeleteOperation(path, nodes.map((node) => node.copyWith()).toList()));
-  }
-
-  textEdit(TextNode node, Delta Function() f) {
-    beforeSelection = state.cursorSelection;
-    final path = node.path;
-
-    final delta = f();
-
-    final inverted = delta.invert(node.delta);
-
-    add(TextEditOperation(path, delta, inverted));
-  }
-
-  setAfterSelection(Selection sel) {
-    afterSelection = sel;
-  }
-
-  mergeText(TextNode firstNode, TextNode secondNode,
-      {int? firstOffset, int secondOffset = 0}) {
-    final firstLength = firstNode.delta.length;
-    final secondLength = secondNode.delta.length;
-    textEdit(
-      firstNode,
-      () => Delta()
-        ..retain(firstOffset ?? firstLength)
-        ..delete(firstLength - (firstOffset ?? firstLength))
-        ..addAll(secondNode.delta.slice(secondOffset, secondLength)),
-    );
-    afterSelection = Selection.collapsed(
-      Position(
-        path: firstNode.path,
-        offset: firstOffset ?? firstLength,
-      ),
-    );
-  }
-
-  /// Inserts content at a specified index.
-  /// Optionally, you may specify formatting attributes that are applied to the inserted string.
-  /// By default, the formatting attributes before the insert position will be used.
-  insertText(
-    TextNode node,
-    int index,
-    String content, {
-    Attributes? attributes,
-  }) {
-    var newAttributes = attributes;
-    if (index != 0 && attributes == null) {
-      newAttributes =
-          node.delta.slice(max(index - 1, 0), index).first.attributes;
-      if (newAttributes != null) {
-        newAttributes = Attributes.from(newAttributes);
-      }
-    }
-    textEdit(
-      node,
-      () => Delta()
-        ..retain(index)
-        ..insert(
-          content,
-          newAttributes,
-        ),
-    );
-    afterSelection = Selection.collapsed(
-      Position(path: node.path, offset: index + content.length),
-    );
-  }
-
-  /// Assigns formatting attributes to a range of text.
-  formatText(TextNode node, int index, int length, Attributes attributes) {
-    textEdit(
-        node,
-        () => Delta()
-          ..retain(index)
-          ..retain(length, attributes));
-    afterSelection = beforeSelection;
-  }
-
-  /// Deletes length characters starting from index.
-  deleteText(TextNode node, int index, int length) {
-    textEdit(
-        node,
-        () => Delta()
-          ..retain(index)
-          ..delete(length));
-    afterSelection =
-        Selection.collapsed(Position(path: node.path, offset: index));
-  }
-
-  replaceText(TextNode node, int index, int length, String content,
-      [Attributes? attributes]) {
-    var newAttributes = attributes;
-    if (attributes == null) {
-      final ops = node.delta.slice(index, index + length);
-      if (ops.isNotEmpty) {
-        newAttributes = ops.first.attributes;
-      }
-    }
-    textEdit(
-      node,
-      () => Delta()
-        ..retain(index)
-        ..delete(length)
-        ..insert(content, newAttributes),
-    );
-    afterSelection = Selection.collapsed(
-      Position(
-        path: node.path,
-        offset: index + content.length,
-      ),
-    );
-  }
-
-  /// Adds an operation to the transaction.
-  /// This method will merge operations if they are both TextEdits.
-  ///
-  /// Also, this method will transform the path of the operations
-  /// to avoid conflicts.
-  add(Operation op, {bool transform = true}) {
-    final Operation? last = operations.isEmpty ? null : operations.last;
-    if (last != null) {
-      if (op is TextEditOperation &&
-          last is TextEditOperation &&
-          pathEquals(op.path, last.path)) {
-        final newOp = TextEditOperation(
-          op.path,
-          last.delta.compose(op.delta),
-          op.inverted.compose(last.inverted),
-        );
-        operations[operations.length - 1] = newOp;
-        return;
-      }
-    }
-    if (transform) {
-      for (var i = 0; i < operations.length; i++) {
-        op = transformOperation(operations[i], op);
-      }
-    }
-    if (op is TextEditOperation && op.delta.isEmpty) {
-      return;
-    }
-    operations.add(op);
-  }
-
-  /// Generates a immutable [Transaction] to apply or transmit.
-  Transaction finish() {
-    return Transaction(
-      operations: UnmodifiableListView(operations),
-      beforeSelection: beforeSelection,
-      afterSelection: afterSelection,
-    );
-  }
-}

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart

@@ -1,6 +1,6 @@
 import 'package:flutter/material.dart';
 
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/service/render_plugin_service.dart';
 

+ 11 - 15
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart

@@ -1,5 +1,4 @@
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/service/render_plugin_service.dart';
 import 'package:flutter/material.dart';
 import 'package:rich_clipboard/rich_clipboard.dart';
@@ -25,23 +24,20 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
         RichClipboard.setData(RichClipboardData(text: src));
       },
       onDelete: () {
-        TransactionBuilder(context.editorState)
-          ..deleteNode(context.node)
-          ..commit();
+        context.editorState.transaction.deleteNode(context.node);
+        context.editorState.commit();
       },
       onAlign: (alignment) {
-        TransactionBuilder(context.editorState)
-          ..updateNode(context.node, {
-            'align': _alignmentToText(alignment),
-          })
-          ..commit();
+        context.editorState.transaction.updateNode(context.node, {
+          'align': _alignmentToText(alignment),
+        });
+        context.editorState.commit();
       },
       onResize: (width) {
-        TransactionBuilder(context.editorState)
-          ..updateNode(context.node, {
-            'width': width,
-          })
-          ..commit();
+        context.editorState.transaction.updateNode(context.node, {
+          'width': width,
+        });
+        context.editorState.commit();
       },
     );
   }

+ 3 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart

@@ -1,7 +1,7 @@
 import 'package:appflowy_editor/src/extensions/object_extensions.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:flutter/material.dart';

+ 6 - 8
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart

@@ -1,9 +1,8 @@
 import 'dart:collection';
 
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
 import 'package:flutter/material.dart';
 
@@ -192,11 +191,10 @@ extension on EditorState {
         'align': 'center',
       },
     );
-    TransactionBuilder(this)
-      ..insertNode(
-        selection.start.path,
-        imageNode,
-      )
-      ..commit();
+    transaction.insertNode(
+      selection.start.path,
+      imageNode,
+    );
+    commit();
   }
 }

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart

@@ -1,5 +1,5 @@
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:flutter/material.dart';
 

+ 8 - 8
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -5,11 +5,11 @@ import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
 
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
-import 'package:appflowy_editor/src/document/text_delta.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
 import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
@@ -123,7 +123,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
   @override
   List<Rect> getRectsInSelection(Selection selection) {
     assert(selection.isSingle &&
-        pathEquals(selection.start.path, widget.textNode.path));
+        selection.start.path.equals(widget.textNode.path));
 
     final textSelection = TextSelection(
       baseOffset: selection.start.offset,
@@ -163,7 +163,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
   Widget _buildRichText(BuildContext context) {
     return MouseRegion(
       cursor: SystemMouseCursors.text,
-      child: widget.textNode.toRawString().isEmpty
+      child: widget.textNode.toPlainText().isEmpty
           ? Stack(
               children: [
                 _buildPlaceholderText(context),
@@ -257,7 +257,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
       offset += textInsert.length;
       textSpans.add(
         TextSpan(
-          text: textInsert.content,
+          text: textInsert.text,
           style: textStyle,
           recognizer: recognizer,
         ),

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
 import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
 import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
 import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart

@@ -1,5 +1,5 @@
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:flutter/material.dart';
 
 enum CursorStyle {

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart

@@ -6,7 +6,7 @@ import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.
 import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
 
 import 'package:flutter/material.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
 
 abstract class SelectionMenuService {
   Offset get topLeft;

+ 19 - 22
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart

@@ -44,14 +44,13 @@ class SelectionMenuItem {
     if (selection != null && nodes.length == 1) {
       final node = nodes.first as TextNode;
       final end = selection.start.offset;
-      final start = node.toRawString().substring(0, end).lastIndexOf('/');
-      TransactionBuilder(editorState)
-        ..deleteText(
-          node,
-          start,
-          selection.start.offset - start,
-        )
-        ..commit();
+      final start = node.toPlainText().substring(0, end).lastIndexOf('/');
+      editorState.transaction.deleteText(
+        node,
+        start,
+        selection.start.offset - start,
+      );
+      editorState.commit();
     }
   }
 }
@@ -278,13 +277,12 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
     final nodes = selectionService.currentSelectedNodes;
     if (selection != null && nodes.length == 1) {
       widget.onSelectionUpdate();
-      TransactionBuilder(widget.editorState)
-        ..deleteText(
-          nodes.first as TextNode,
-          selection.start.offset - length,
-          length,
-        )
-        ..commit();
+      widget.editorState.transaction.deleteText(
+        nodes.first as TextNode,
+        selection.start.offset - length,
+        length,
+      );
+      widget.editorState.commit();
     }
   }
 
@@ -295,13 +293,12 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
         widget.editorState.service.selectionService.currentSelectedNodes;
     if (selection != null && nodes.length == 1) {
       widget.onSelectionUpdate();
-      TransactionBuilder(widget.editorState)
-        ..insertText(
-          nodes.first as TextNode,
-          selection.end.offset,
-          text,
-        )
-        ..commit();
+      widget.editorState.transaction.insertText(
+        nodes.first as TextNode,
+        selection.end.offset,
+        text,
+      );
+      widget.editorState.commit();
     }
   }
 }

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart

@@ -1,6 +1,6 @@
 import 'package:flutter/material.dart';
 
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
 

+ 3 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -357,10 +357,9 @@ void showLinkMenu(
             _dismissLinkMenu();
           },
           onRemoveLink: () {
-            TransactionBuilder(editorState)
-              ..formatText(
-                  textNode, index, length, {BuiltInAttributeKey.href: null})
-              ..commit();
+            editorState.transaction.formatText(
+                textNode, index, length, {BuiltInAttributeKey.href: null});
+            editorState.commit();
             _dismissLinkMenu();
           },
           onFocusChange: (value) {

+ 14 - 22
frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart

@@ -1,12 +1,5 @@
-import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
-import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
-import 'package:appflowy_editor/src/extensions/path_extensions.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 
 void insertHeadingAfterSelection(EditorState editorState, String heading) {
   insertTextNodeAfterSelection(editorState, {
@@ -54,16 +47,15 @@ bool insertTextNodeAfterSelection(
     formatTextNodes(editorState, attributes);
   } else {
     final next = selection.end.path.next;
-    final builder = TransactionBuilder(editorState);
-    builder
+    editorState.transaction
       ..insertNode(
         next,
         TextNode.empty(attributes: attributes),
       )
       ..afterSelection = Selection.collapsed(
         Position(path: next, offset: 0),
-      )
-      ..commit();
+      );
+    editorState.commit();
   }
 
   return true;
@@ -107,7 +99,7 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
     return false;
   }
 
-  final builder = TransactionBuilder(editorState);
+  final transaction = editorState.transaction;
 
   for (final textNode in textNodes) {
     var newAttributes = {...textNode.attributes};
@@ -117,7 +109,7 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
       }
     }
     newAttributes.addAll(attributes);
-    builder
+    transaction
       ..updateNode(
         textNode,
         newAttributes,
@@ -125,12 +117,12 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
       ..afterSelection = Selection.collapsed(
         Position(
           path: textNode.path,
-          offset: textNode.toRawString().length,
+          offset: textNode.toPlainText().length,
         ),
       );
   }
 
-  builder.commit();
+  editorState.commit();
   return true;
 }
 
@@ -216,13 +208,13 @@ bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
     return false;
   }
 
-  final builder = TransactionBuilder(editorState);
+  final transaction = editorState.transaction;
 
   // 1. All nodes are text nodes.
   // 2. The first node is not TextNode.
   // 3. The last node is not TextNode.
   if (nodes.length == textNodes.length && textNodes.length == 1) {
-    builder.formatText(
+    transaction.formatText(
       textNodes.first,
       selection.start.offset,
       selection.end.offset - selection.start.offset,
@@ -232,14 +224,14 @@ bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
     for (var i = 0; i < textNodes.length; i++) {
       final textNode = textNodes[i];
       var index = 0;
-      var length = textNode.toRawString().length;
+      var length = textNode.toPlainText().length;
       if (i == 0 && textNode == nodes.first) {
         index = selection.start.offset;
-        length = textNode.toRawString().length - selection.start.offset;
+        length = textNode.toPlainText().length - selection.start.offset;
       } else if (i == textNodes.length - 1 && textNode == nodes.last) {
         length = selection.end.offset;
       }
-      builder.formatText(
+      transaction.formatText(
         textNode,
         index,
         length,
@@ -248,7 +240,7 @@ bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
     }
   }
 
-  builder.commit();
+  editorState.commit();
 
   return true;
 }

+ 16 - 18
frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart

@@ -1,13 +1,13 @@
 import 'package:appflowy_editor/src/infra/log.dart';
+import 'package:appflowy_editor/src/core/transform/transaction.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/extensions/node_extensions.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
 
 /// [AppFlowyInputService] is responsible for processing text input,
 ///   including text insertion, deletion and replacement.
@@ -160,13 +160,12 @@ class _AppFlowyInputState extends State<AppFlowyInput>
     }
     if (currentSelection.isSingle) {
       final textNode = selectionService.currentSelectedNodes.first as TextNode;
-      TransactionBuilder(_editorState)
-        ..insertText(
-          textNode,
-          delta.insertionOffset,
-          delta.textInserted,
-        )
-        ..commit();
+      _editorState.transaction.insertText(
+        textNode,
+        delta.insertionOffset,
+        delta.textInserted,
+      );
+      _editorState.commit();
     } else {
       // TODO: implement
     }
@@ -181,9 +180,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
     if (currentSelection.isSingle) {
       final textNode = selectionService.currentSelectedNodes.first as TextNode;
       final length = delta.deletedRange.end - delta.deletedRange.start;
-      TransactionBuilder(_editorState)
-        ..deleteText(textNode, delta.deletedRange.start, length)
-        ..commit();
+      _editorState.transaction
+          .deleteText(textNode, delta.deletedRange.start, length);
+      _editorState.commit();
     } else {
       // TODO: implement
     }
@@ -198,10 +197,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
     if (currentSelection.isSingle) {
       final textNode = selectionService.currentSelectedNodes.first as TextNode;
       final length = delta.replacedRange.end - delta.replacedRange.start;
-      TransactionBuilder(_editorState)
-        ..replaceText(
-            textNode, delta.replacedRange.start, length, delta.replacementText)
-        ..commit();
+      _editorState.transaction.replaceText(
+          textNode, delta.replacedRange.start, length, delta.replacementText);
+      _editorState.commit();
     } else {
       // TODO: implement
     }
@@ -282,7 +280,7 @@ class _AppFlowyInputState extends State<AppFlowyInput>
     // FIXME: upward and selection update.
     if (textNodes.isNotEmpty && selection != null) {
       final text = textNodes.fold<String>(
-          '', (sum, textNode) => '$sum${textNode.toRawString()}\n');
+          '', (sum, textNode) => '$sum${textNode.toPlainText()}\n');
       attach(
         TextEditingValue(
           text: text,

+ 4 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart

@@ -220,7 +220,7 @@ ShortcutEventHandler cursorEndSelect = (editorState, event) {
 KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final selection =
-      editorState.service.selectionService.currentSelection.value?.normalize;
+      editorState.service.selectionService.currentSelection.value?.normalized;
   if (nodes.isEmpty || selection == null) {
     return KeyEventResult.ignored;
   }
@@ -234,7 +234,7 @@ KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
 KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final selection =
-      editorState.service.selectionService.currentSelection.value?.normalize;
+      editorState.service.selectionService.currentSelection.value?.normalized;
   if (nodes.isEmpty || selection == null) {
     return KeyEventResult.ignored;
   }
@@ -248,7 +248,7 @@ KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
 KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final selection =
-      editorState.service.selectionService.currentSelection.value?.normalize;
+      editorState.service.selectionService.currentSelection.value?.normalized;
   if (nodes.isEmpty || selection == null) {
     return KeyEventResult.ignored;
   }
@@ -270,7 +270,7 @@ KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
 KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final selection =
-      editorState.service.selectionService.currentSelection.value?.normalize;
+      editorState.service.selectionService.currentSelection.value?.normalized;
   if (nodes.isEmpty || selection == null) {
     return KeyEventResult.ignored;
   }

+ 45 - 49
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart

@@ -28,11 +28,11 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
   final List<Node> nonTextNodes =
       nodes.where((node) => node is! TextNode).toList(growable: false);
 
-  final transactionBuilder = TransactionBuilder(editorState);
+  final transaction = editorState.transaction;
   List<int>? cancelNumberListPath;
 
   if (nonTextNodes.isNotEmpty) {
-    transactionBuilder.deleteNodes(nonTextNodes);
+    transaction.deleteNodes(nonTextNodes);
   }
 
   if (textNodes.length == 1) {
@@ -44,7 +44,7 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
         if (textNode.subtype == BuiltInAttributeKey.numberList) {
           cancelNumberListPath = textNode.path;
         }
-        transactionBuilder
+        transaction
           ..updateNode(textNode, {
             BuiltInAttributeKey.subtype: null,
             textNode.subtype!: null,
@@ -61,20 +61,20 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
         return _backDeleteToPreviousTextNode(
           editorState,
           textNode,
-          transactionBuilder,
+          transaction,
           nonTextNodes,
           selection,
         );
       }
     } else {
       if (selection.isCollapsed) {
-        transactionBuilder.deleteText(
+        transaction.deleteText(
           textNode,
           index,
           selection.start.offset - index,
         );
       } else {
-        transactionBuilder.deleteText(
+        transaction.deleteText(
           textNode,
           selection.start.offset,
           selection.end.offset - selection.start.offset,
@@ -84,33 +84,32 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
   } else {
     if (textNodes.isEmpty) {
       if (nonTextNodes.isNotEmpty) {
-        transactionBuilder.afterSelection =
-            Selection.collapsed(selection.start);
+        transaction.afterSelection = Selection.collapsed(selection.start);
       }
-      transactionBuilder.commit();
+      editorState.commit();
       return KeyEventResult.handled;
     }
     final startPosition = selection.start;
     final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
-    _deleteTextNodes(transactionBuilder, textNodes, selection);
-    transactionBuilder.commit();
+    _deleteTextNodes(transaction, textNodes, selection);
+    editorState.commit();
 
     if (nodeAtStart is TextNode &&
         nodeAtStart.subtype == BuiltInAttributeKey.numberList) {
       makeFollowingNodesIncremental(
         editorState,
         startPosition.path,
-        transactionBuilder.afterSelection!,
+        transaction.afterSelection!,
       );
     }
     return KeyEventResult.handled;
   }
 
-  if (transactionBuilder.operations.isNotEmpty) {
+  if (transaction.operations.isNotEmpty) {
     if (nonTextNodes.isNotEmpty) {
-      transactionBuilder.afterSelection = Selection.collapsed(selection.start);
+      transaction.afterSelection = Selection.collapsed(selection.start);
     }
-    transactionBuilder.commit();
+    editorState.commit();
   }
 
   if (cancelNumberListPath != null) {
@@ -128,20 +127,20 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
 KeyEventResult _backDeleteToPreviousTextNode(
   EditorState editorState,
   TextNode textNode,
-  TransactionBuilder transactionBuilder,
+  Transaction transaction,
   List<Node> nonTextNodes,
   Selection selection,
 ) {
   if (textNode.next == null &&
       textNode.children.isEmpty &&
       textNode.parent?.parent != null) {
-    transactionBuilder
+    transaction
       ..deleteNode(textNode)
       ..insertNode(textNode.parent!.path.next, textNode)
       ..afterSelection = Selection.collapsed(
         Position(path: textNode.parent!.path.next, offset: 0),
-      )
-      ..commit();
+      );
+    editorState.commit();
     return KeyEventResult.handled;
   }
 
@@ -152,32 +151,32 @@ KeyEventResult _backDeleteToPreviousTextNode(
       prevIsNumberList = true;
     }
 
-    transactionBuilder.mergeText(previousTextNode, textNode);
+    transaction.mergeText(previousTextNode, textNode);
     if (textNode.children.isNotEmpty) {
-      transactionBuilder.insertNodes(
+      transaction.insertNodes(
         previousTextNode.path.next,
         textNode.children.toList(growable: false),
       );
     }
-    transactionBuilder.deleteNode(textNode);
-    transactionBuilder.afterSelection = Selection.collapsed(
+    transaction.deleteNode(textNode);
+    transaction.afterSelection = Selection.collapsed(
       Position(
         path: previousTextNode.path,
-        offset: previousTextNode.toRawString().length,
+        offset: previousTextNode.toPlainText().length,
       ),
     );
   }
 
-  if (transactionBuilder.operations.isNotEmpty) {
+  if (transaction.operations.isNotEmpty) {
     if (nonTextNodes.isNotEmpty) {
-      transactionBuilder.afterSelection = Selection.collapsed(selection.start);
+      transaction.afterSelection = Selection.collapsed(selection.start);
     }
-    transactionBuilder.commit();
+    editorState.commit();
   }
 
   if (prevIsNumberList) {
-    makeFollowingNodesIncremental(editorState, previousTextNode!.path,
-        transactionBuilder.afterSelection!);
+    makeFollowingNodesIncremental(
+        editorState, previousTextNode!.path, transaction.afterSelection!);
   }
 
   return KeyEventResult.handled;
@@ -197,7 +196,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
     return KeyEventResult.ignored;
   }
 
-  final transactionBuilder = TransactionBuilder(editorState);
+  final transaction = editorState.transaction;
   if (textNodes.length == 1) {
     final textNode = textNodes.first;
     // The cursor is at the end of the line,
@@ -206,55 +205,52 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
       return _mergeNextLineIntoThisLine(
         editorState,
         textNode,
-        transactionBuilder,
+        transaction,
         selection,
       );
     }
     final index = textNode.delta.nextRunePosition(selection.start.offset);
     if (selection.isCollapsed) {
-      transactionBuilder.deleteText(
+      transaction.deleteText(
         textNode,
         selection.start.offset,
         index - selection.start.offset,
       );
     } else {
-      transactionBuilder.deleteText(
+      transaction.deleteText(
         textNode,
         selection.start.offset,
         selection.end.offset - selection.start.offset,
       );
     }
-    transactionBuilder.commit();
+    editorState.commit();
   } else {
     final startPosition = selection.start;
     final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
-    _deleteTextNodes(transactionBuilder, textNodes, selection);
-    transactionBuilder.commit();
+    _deleteTextNodes(transaction, textNodes, selection);
+    editorState.commit();
 
     if (nodeAtStart is TextNode &&
         nodeAtStart.subtype == BuiltInAttributeKey.numberList) {
       makeFollowingNodesIncremental(
-          editorState, startPosition.path, transactionBuilder.afterSelection!);
+          editorState, startPosition.path, transaction.afterSelection!);
     }
   }
 
   return KeyEventResult.handled;
 }
 
-KeyEventResult _mergeNextLineIntoThisLine(
-    EditorState editorState,
-    TextNode textNode,
-    TransactionBuilder transactionBuilder,
-    Selection selection) {
+KeyEventResult _mergeNextLineIntoThisLine(EditorState editorState,
+    TextNode textNode, Transaction transaction, Selection selection) {
   final nextNode = textNode.next;
   if (nextNode == null) {
     return KeyEventResult.ignored;
   }
   if (nextNode is TextNode) {
-    transactionBuilder.mergeText(textNode, nextNode);
+    transaction.mergeText(textNode, nextNode);
   }
-  transactionBuilder.deleteNode(nextNode);
-  transactionBuilder.commit();
+  transaction.deleteNode(nextNode);
+  editorState.commit();
 
   if (textNode.subtype == BuiltInAttributeKey.numberList) {
     makeFollowingNodesIncremental(editorState, textNode.path, selection);
@@ -263,15 +259,15 @@ KeyEventResult _mergeNextLineIntoThisLine(
   return KeyEventResult.handled;
 }
 
-void _deleteTextNodes(TransactionBuilder transactionBuilder,
-    List<TextNode> textNodes, Selection selection) {
+void _deleteTextNodes(
+    Transaction transaction, List<TextNode> textNodes, Selection selection) {
   final first = textNodes.first;
   final last = textNodes.last;
-  var content = textNodes.last.toRawString();
+  var content = textNodes.last.toPlainText();
   content = content.substring(selection.end.offset, content.length);
   // Merge the fist and the last text node content,
   //  and delete the all nodes expect for the first.
-  transactionBuilder
+  transaction
     ..deleteNodes(textNodes.sublist(1))
     ..mergeText(
       first,

+ 56 - 51
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart

@@ -1,6 +1,6 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/infra/html_converter.dart';
-import 'package:appflowy_editor/src/document/node_iterator.dart';
+import 'package:appflowy_editor/src/core/document/node_iterator.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
 import 'package:flutter/material.dart';
 import 'package:rich_clipboard/rich_clipboard.dart';
@@ -25,11 +25,11 @@ Selection _computeSelectionAfterPasteMultipleNodes(
 }
 
 void _handleCopy(EditorState editorState) async {
-  final selection = editorState.cursorSelection?.normalize;
+  final selection = editorState.cursorSelection?.normalized;
   if (selection == null || selection.isCollapsed) {
     return;
   }
-  if (pathEquals(selection.start.path, selection.end.path)) {
+  if (selection.start.path.equals(selection.end.path)) {
     final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!;
     if (nodeAtPath.type == "text") {
       final textNode = nodeAtPath as TextNode;
@@ -49,7 +49,11 @@ void _handleCopy(EditorState editorState) async {
   final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
   final endNode = editorState.document.nodeAtPath(selection.end.path)!;
 
-  final nodes = NodeIterator(editorState.document, beginNode, endNode).toList();
+  final nodes = NodeIterator(
+    document: editorState.document,
+    startNode: beginNode,
+    endNode: endNode,
+  ).toList();
 
   final copyString = NodesToHTMLConverter(
           nodes: nodes,
@@ -61,7 +65,7 @@ void _handleCopy(EditorState editorState) async {
 }
 
 void _pasteHTML(EditorState editorState, String html) {
-  final selection = editorState.cursorSelection?.normalize;
+  final selection = editorState.cursorSelection?.normalized;
   if (selection == null) {
     return;
   }
@@ -81,16 +85,16 @@ void _pasteHTML(EditorState editorState, String html) {
   } else if (nodes.length == 1) {
     final firstNode = nodes[0];
     final nodeAtPath = editorState.document.nodeAtPath(path)!;
-    final tb = TransactionBuilder(editorState);
+    final tb = editorState.transaction;
     final startOffset = selection.start.offset;
     if (nodeAtPath.type == "text" && firstNode.type == "text") {
       final textNodeAtPath = nodeAtPath as TextNode;
       final firstTextNode = firstNode as TextNode;
-      tb.textEdit(textNodeAtPath,
-          () => (Delta()..retain(startOffset)) + firstTextNode.delta);
-      tb.setAfterSelection(Selection.collapsed(Position(
+      tb.updateText(
+          textNodeAtPath, (Delta()..retain(startOffset)) + firstTextNode.delta);
+      tb.afterSelection = (Selection.collapsed(Position(
           path: path, offset: startOffset + firstTextNode.delta.length)));
-      tb.commit();
+      editorState.commit();
       return;
     }
   }
@@ -100,7 +104,7 @@ void _pasteHTML(EditorState editorState, String html) {
 
 void _pasteMultipleLinesInText(
     EditorState editorState, List<int> path, int offset, List<Node> nodes) {
-  final tb = TransactionBuilder(editorState);
+  final tb = editorState.transaction;
 
   final firstNode = nodes[0];
   final nodeAtPath = editorState.document.nodeAtPath(path)!;
@@ -116,10 +120,9 @@ void _pasteMultipleLinesInText(
     final firstTextNode = firstNode as TextNode;
     final remain = textNodeAtPath.delta.slice(offset);
 
-    tb.textEdit(
+    tb.updateText(
         textNodeAtPath,
-        () =>
-            (Delta()
+        (Delta()
               ..retain(offset)
               ..delete(remain.length)) +
             firstTextNode.delta);
@@ -136,15 +139,15 @@ void _pasteMultipleLinesInText(
         final tailTextNode = tailNodes.last as TextNode;
         tailTextNode.delta = tailTextNode.delta + remain;
       } else if (remain.isNotEmpty) {
-        tailNodes.add(TextNode(type: "text", delta: remain));
+        tailNodes.add(TextNode(delta: remain));
       }
     } else {
-      tailNodes.add(TextNode(type: "text", delta: remain));
+      tailNodes.add(TextNode(delta: remain));
     }
 
-    tb.setAfterSelection(afterSelection);
+    tb.afterSelection = afterSelection;
     tb.insertNodes(path, tailNodes);
-    tb.commit();
+    editorState.commit();
 
     if (startNumber != null) {
       makeFollowingNodesIncremental(editorState, originalPath, afterSelection,
@@ -157,9 +160,9 @@ void _pasteMultipleLinesInText(
       _computeSelectionAfterPasteMultipleNodes(editorState, nodes);
 
   path[path.length - 1]++;
-  tb.setAfterSelection(afterSelection);
+  tb.afterSelection = afterSelection;
   tb.insertNodes(path, nodes);
-  tb.commit();
+  editorState.commit();
 }
 
 void _handlePaste(EditorState editorState) async {
@@ -192,15 +195,15 @@ void _pasteSingleLine(
     EditorState editorState, Selection selection, String line) {
   final node = editorState.document.nodeAtPath(selection.end.path)! as TextNode;
   final beginOffset = selection.end.offset;
-  TransactionBuilder(editorState)
-    ..textEdit(
+  editorState.transaction
+    ..updateText(
         node,
-        () => Delta()
+        Delta()
           ..retain(beginOffset)
           ..addAll(_lineContentToDelta(line)))
-    ..setAfterSelection(Selection.collapsed(
-        Position(path: selection.end.path, offset: beginOffset + line.length)))
-    ..commit();
+    ..afterSelection = (Selection.collapsed(
+        Position(path: selection.end.path, offset: beginOffset + line.length)));
+  editorState.commit();
 }
 
 /// parse url from the line text
@@ -218,7 +221,7 @@ Delta _lineContentToDelta(String lineContent) {
       delta.insert(lineContent.substring(lastUrlEndOffset, match.start));
     }
     final linkContent = lineContent.substring(match.start, match.end);
-    delta.insert(linkContent, {"href": linkContent});
+    delta.insert(linkContent, attributes: {"href": linkContent});
     lastUrlEndOffset = match.end;
   }
 
@@ -230,7 +233,7 @@ Delta _lineContentToDelta(String lineContent) {
 }
 
 void _handlePastePlainText(EditorState editorState, String plainText) {
-  final selection = editorState.cursorSelection?.normalize;
+  final selection = editorState.cursorSelection?.normalized;
   if (selection == null) {
     return;
   }
@@ -260,10 +263,9 @@ void _handlePastePlainText(EditorState editorState, String plainText) {
     final insertedLineSuffix = node.delta.slice(beginOffset);
 
     path[path.length - 1]++;
-    final tb = TransactionBuilder(editorState);
-    final List<TextNode> nodes = remains
-        .map((e) => TextNode(type: "text", delta: _lineContentToDelta(e)))
-        .toList();
+    final tb = editorState.transaction;
+    final List<TextNode> nodes =
+        remains.map((e) => TextNode(delta: _lineContentToDelta(e))).toList();
 
     final afterSelection =
         _computeSelectionAfterPasteMultipleNodes(editorState, nodes);
@@ -272,20 +274,20 @@ void _handlePastePlainText(EditorState editorState, String plainText) {
     if (nodes.isNotEmpty) {
       final last = nodes.last;
       nodes[nodes.length - 1] =
-          TextNode(type: "text", delta: last.delta..addAll(insertedLineSuffix));
+          TextNode(delta: last.delta..addAll(insertedLineSuffix));
     }
 
     // insert first line
-    tb.textEdit(
+    tb.updateText(
         node,
-        () => Delta()
+        Delta()
           ..retain(beginOffset)
           ..insert(firstLine)
           ..delete(node.delta.length - beginOffset));
     // insert remains
     tb.insertNodes(path, nodes);
-    tb.setAfterSelection(afterSelection);
-    tb.commit();
+    tb.afterSelection = afterSelection;
+    editorState.commit();
   }
 }
 
@@ -297,35 +299,38 @@ void _handleCut(EditorState editorState) {
 }
 
 void _deleteSelectedContent(EditorState editorState) {
-  final selection = editorState.cursorSelection?.normalize;
+  final selection = editorState.cursorSelection?.normalized;
   if (selection == null || selection.isCollapsed) {
     return;
   }
   final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
   final endNode = editorState.document.nodeAtPath(selection.end.path)!;
-  if (pathEquals(selection.start.path, selection.end.path) &&
+  if (selection.start.path.equals(selection.end.path) &&
       beginNode.type == "text") {
     final textItem = beginNode as TextNode;
-    final tb = TransactionBuilder(editorState);
+    final tb = editorState.transaction;
     final len = selection.end.offset - selection.start.offset;
-    tb.textEdit(
+    tb.updateText(
         textItem,
-        () => Delta()
+        Delta()
           ..retain(selection.start.offset)
           ..delete(len));
-    tb.setAfterSelection(Selection.collapsed(selection.start));
-    tb.commit();
+    tb.afterSelection = Selection.collapsed(selection.start);
+    editorState.commit();
     return;
   }
-  final traverser = NodeIterator(editorState.document, beginNode, endNode);
-
-  final tb = TransactionBuilder(editorState);
+  final traverser = NodeIterator(
+    document: editorState.document,
+    startNode: beginNode,
+    endNode: endNode,
+  );
+  final tb = editorState.transaction;
   while (traverser.moveNext()) {
     final item = traverser.current;
     if (item.type == "text" && beginNode == item) {
       final textItem = item as TextNode;
       final deleteLen = textItem.delta.length - selection.start.offset;
-      tb.textEdit(textItem, () {
+      tb.updateText(textItem, () {
         final delta = Delta()
           ..retain(selection.start.offset)
           ..delete(deleteLen);
@@ -336,13 +341,13 @@ void _deleteSelectedContent(EditorState editorState) {
         }
 
         return delta;
-      });
+      }());
     } else {
       tb.deleteNode(item);
     }
   }
-  tb.setAfterSelection(Selection.collapsed(selection.start));
-  tb.commit();
+  tb.afterSelection = Selection.collapsed(selection.start);
+  editorState.commit();
 }
 
 ShortcutEventHandler copyEventHandler = (editorState, event) {

+ 21 - 21
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart

@@ -39,11 +39,11 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
     final afterSelection = Selection.collapsed(
       Position(path: textNodes.first.path.next, offset: 0),
     );
-    TransactionBuilder(editorState)
+    editorState.transaction
       ..deleteText(
         textNodes.first,
         selection.start.offset,
-        textNodes.first.toRawString().length,
+        textNodes.first.toPlainText().length,
       )
       ..deleteNodes(subTextNodes)
       ..deleteText(
@@ -51,8 +51,8 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
         0,
         selection.end.offset,
       )
-      ..afterSelection = afterSelection
-      ..commit();
+      ..afterSelection = afterSelection;
+    editorState.commit();
 
     if (startNode is TextNode &&
         startNode.subtype == BuiltInAttributeKey.numberList) {
@@ -73,16 +73,16 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
   // If selection is collapsed and position.start.offset == 0,
   //  insert a empty text node before.
   if (selection.isCollapsed && selection.start.offset == 0) {
-    if (textNode.toRawString().isEmpty && textNode.subtype != null) {
+    if (textNode.toPlainText().isEmpty && textNode.subtype != null) {
       final afterSelection = Selection.collapsed(
         Position(path: textNode.path, offset: 0),
       );
-      TransactionBuilder(editorState)
+      editorState.transaction
         ..updateNode(textNode, {
           BuiltInAttributeKey.subtype: null,
         })
-        ..afterSelection = afterSelection
-        ..commit();
+        ..afterSelection = afterSelection;
+      editorState.commit();
 
       final nextNode = textNode.next;
       if (nextNode is TextNode &&
@@ -105,13 +105,13 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
             BuiltInAttributeKey.numberList;
         newNode.attributes[BuiltInAttributeKey.number] = prevNumber;
         final insertPath = textNode.path;
-        TransactionBuilder(editorState)
+        editorState.transaction
           ..insertNode(
             insertPath,
             newNode,
           )
-          ..afterSelection = afterSelection
-          ..commit();
+          ..afterSelection = afterSelection;
+        editorState.commit();
 
         makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
             beginNum: prevNumber);
@@ -120,7 +120,7 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
           BuiltInAttributeKey.heading,
           BuiltInAttributeKey.quote,
         ].contains(subtype);
-        TransactionBuilder(editorState)
+        editorState.transaction
           ..insertNode(
             textNode.path,
             textNode.copyWith(
@@ -129,8 +129,8 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
               attributes: needCopyAttributes ? null : {},
             ),
           )
-          ..afterSelection = afterSelection
-          ..commit();
+          ..afterSelection = afterSelection;
+        editorState.commit();
       }
     }
     return KeyEventResult.handled;
@@ -145,25 +145,25 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
     Position(path: nextPath, offset: 0),
   );
 
-  final transactionBuilder = TransactionBuilder(editorState);
-  transactionBuilder.insertNode(
+  final transaction = editorState.transaction;
+  transaction.insertNode(
     textNode.path.next,
     textNode.copyWith(
       attributes: attributes,
       delta: textNode.delta.slice(selection.end.offset),
     ),
   );
-  transactionBuilder.deleteText(
+  transaction.deleteText(
     textNode,
     selection.start.offset,
-    textNode.toRawString().length - selection.start.offset,
+    textNode.toPlainText().length - selection.start.offset,
   );
   if (textNode.children.isNotEmpty) {
     final children = textNode.children.toList(growable: false);
-    transactionBuilder.deleteNodes(children);
+    transaction.deleteNodes(children);
   }
-  transactionBuilder.afterSelection = afterSelection;
-  transactionBuilder.commit();
+  transaction.afterSelection = afterSelection;
+  editorState.commit();
 
   // If the new type of a text node is number list,
   // the numbers of the following nodes should be incremental.

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/format_style_handler.dart

@@ -2,7 +2,7 @@ import 'package:appflowy_editor/src/service/default_text_operations/format_rich_
 import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 
 ShortcutEventHandler formatBoldEventHandler = (editorState, event) {
   final selection = editorState.service.selectionService.currentSelection.value;

+ 17 - 16
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart

@@ -1,6 +1,7 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
 import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
+
 import 'package:flutter/material.dart';
 
 bool _isCodeStyle(TextNode textNode, int index) {
@@ -44,7 +45,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
 
   final textNode = textNodes.first;
   final selectionText = textNode
-      .toRawString()
+      .toPlainText()
       .substring(selection.start.offset, selection.end.offset);
 
   // toggle code style when selected some text
@@ -53,7 +54,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
     return KeyEventResult.handled;
   }
 
-  final text = textNode.toRawString().substring(0, selection.end.offset);
+  final text = textNode.toPlainText().substring(0, selection.end.offset);
   final backquoteIndexes = _findBackquoteIndexes(text, textNode);
   if (backquoteIndexes.isEmpty) {
     return KeyEventResult.ignored;
@@ -72,7 +73,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
       return KeyEventResult.ignored;
     }
 
-    TransactionBuilder(editorState)
+    editorState.transaction
       ..deleteText(textNode, lastBackquoteIndex, 1)
       ..deleteText(textNode, firstBackquoteIndex, 2)
       ..formatText(
@@ -88,8 +89,8 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
           path: textNode.path,
           offset: endIndex - 3,
         ),
-      )
-      ..commit();
+      );
+    editorState.commit();
 
     return KeyEventResult.handled;
   }
@@ -103,7 +104,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
   // delete the backquote.
   // update the style of the text surround by ` ` to code.
   // and update the cursor position.
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, startIndex, 1)
     ..formatText(
       textNode,
@@ -118,8 +119,8 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
         path: textNode.path,
         offset: endIndex - 1,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
 
   return KeyEventResult.handled;
 };
@@ -134,7 +135,7 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
   }
 
   final textNode = textNodes.first;
-  final text = textNode.toRawString().substring(0, selection.end.offset);
+  final text = textNode.toPlainText().substring(0, selection.end.offset);
 
   // make sure the last two characters are ~~.
   if (text.length < 2 || text[selection.end.offset - 1] != '~') {
@@ -165,7 +166,7 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
   // delete the last three tildes.
   // update the style of the text surround by `~~ ~~` to strikethrough.
   // and update the cursor position.
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, lastTildeIndex, 1)
     ..deleteText(textNode, thirdToLastTildeIndex, 2)
     ..formatText(
@@ -181,8 +182,8 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
         path: textNode.path,
         offset: selection.end.offset - 3,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
 
   return KeyEventResult.handled;
 };
@@ -199,7 +200,7 @@ ShortcutEventHandler markdownLinkToLinkHandler = (editorState, event) {
 
   // find all of the indexs for important characters
   final textNode = textNodes.first;
-  final text = textNode.toRawString();
+  final text = textNode.toPlainText();
   final firstOpeningBracket = text.indexOf('[');
   final firstClosingBracket = text.indexOf(']');
 
@@ -219,7 +220,7 @@ ShortcutEventHandler markdownLinkToLinkHandler = (editorState, event) {
   // update the href attribute of the text surrounded by [ ] to the url,
   // delete everything after the text,
   // and update the cursor position.
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, firstOpeningBracket, 1)
     ..formatText(
       textNode,
@@ -236,8 +237,8 @@ ShortcutEventHandler markdownLinkToLinkHandler = (editorState, event) {
         path: textNode.path,
         offset: firstOpeningBracket + linkText!.length,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
 
   return KeyEventResult.handled;
 };

+ 8 - 8
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart

@@ -11,7 +11,7 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
   }
 
   final textNode = textNodes.first;
-  final text = textNode.toRawString().substring(0, selection.end.offset);
+  final text = textNode.toPlainText().substring(0, selection.end.offset);
 
   // make sure the last two characters are **.
   if (text.length < 2 || text[selection.end.offset - 1] != '*') {
@@ -42,7 +42,7 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
   // delete the last three asterisks.
   // update the style of the text surround by `** **` to bold.
   // and update the cursor position.
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, lastAsterisIndex, 1)
     ..deleteText(textNode, thirdToLastAsteriskIndex, 2)
     ..formatText(
@@ -59,8 +59,8 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
         path: textNode.path,
         offset: selection.end.offset - 3,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
 
   return KeyEventResult.handled;
 };
@@ -75,7 +75,7 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
   }
 
   final textNode = textNodes.first;
-  final text = textNode.toRawString().substring(0, selection.end.offset);
+  final text = textNode.toPlainText().substring(0, selection.end.offset);
 
   // make sure the last two characters are __.
   if (text.length < 2 || text[selection.end.offset - 1] != '_') {
@@ -108,7 +108,7 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
   // delete the last three underscores.
   // update the style of the text surround by `__ __` to bold.
   // and update the cursor position.
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, lastAsterisIndex, 1)
     ..deleteText(textNode, thirdToLastUnderscoreIndex, 2)
     ..formatText(
@@ -125,8 +125,8 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
         path: textNode.path,
         offset: selection.end.offset - 3,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
 
   return KeyEventResult.handled;
 };

+ 5 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/number_list_helper.dart

@@ -1,8 +1,7 @@
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
-import 'package:appflowy_editor/src/document/attributes.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/core/document/attributes.dart';
 
 void makeFollowingNodesIncremental(
     EditorState editorState, List<int> insertPath, Selection afterSelection,
@@ -16,7 +15,7 @@ void makeFollowingNodesIncremental(
   int numPtr = beginNum + 1;
   var ptr = insertNode.next;
 
-  final builder = TransactionBuilder(editorState);
+  final builder = editorState.transaction;
 
   while (ptr != null) {
     if (ptr.subtype != BuiltInAttributeKey.numberList) {
@@ -34,5 +33,5 @@ void makeFollowingNodesIncremental(
   }
 
   builder.afterSelection = afterSelection;
-  builder.commit();
+  editorState.commit();
 }

+ 3 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart

@@ -1,6 +1,6 @@
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 

+ 5 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart

@@ -1,5 +1,5 @@
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/transform/transaction.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
 import 'package:appflowy_editor/src/extensions/node_extensions.dart';
 import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
@@ -25,10 +25,9 @@ ShortcutEventHandler slashShortcutHandler = (editorState, event) {
   if (selection == null || context == null || selectable == null) {
     return KeyEventResult.ignored;
   }
-  TransactionBuilder(editorState)
-    ..replaceText(textNode, selection.start.offset,
-        selection.end.offset - selection.start.offset, event.character ?? '')
-    ..commit();
+  editorState.transaction.replaceText(textNode, selection.start.offset,
+      selection.end.offset - selection.start.offset, event.character ?? '');
+  editorState.commit();
 
   WidgetsBinding.instance.addPostFrameCallback((_) {
     _selectionMenuService =

+ 5 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart

@@ -15,9 +15,8 @@ ShortcutEventHandler tabHandler = (editorState, event) {
   final previous = textNode.previous;
 
   if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
-    TransactionBuilder(editorState)
-      ..insertText(textNode, selection.end.offset, ' ' * 4)
-      ..commit();
+    editorState.transaction.insertText(textNode, selection.end.offset, ' ' * 4);
+    editorState.commit();
     return KeyEventResult.handled;
   }
 
@@ -31,11 +30,11 @@ ShortcutEventHandler tabHandler = (editorState, event) {
     start: selection.start.copyWith(path: path),
     end: selection.end.copyWith(path: path),
   );
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteNode(textNode)
     ..insertNode(path, textNode)
-    ..setAfterSelection(afterSelection)
-    ..commit();
+    ..afterSelection = afterSelection;
+  editorState.commit();
 
   return KeyEventResult.handled;
 };

+ 21 - 22
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart

@@ -1,12 +1,12 @@
+import 'package:appflowy_editor/src/core/transform/transaction.dart';
 import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
 import './number_list_helper.dart';
 import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
 
@@ -44,7 +44,7 @@ ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
   }
 
   final textNode = textNodes.first;
-  final text = textNode.toRawString().substring(0, selection.end.offset);
+  final text = textNode.toPlainText().substring(0, selection.end.offset);
 
   final numberMatch = _numberRegex.firstMatch(text);
 
@@ -99,15 +99,14 @@ KeyEventResult _toNumberList(EditorState editorState, TextNode textNode,
   ));
 
   final insertPath = textNode.path;
-
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, 0, matchText.length)
     ..updateNode(textNode, {
       BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList,
       BuiltInAttributeKey.number: numValue
     })
-    ..afterSelection = afterSelection
-    ..commit();
+    ..afterSelection = afterSelection;
+  editorState.commit();
 
   makeFollowingNodesIncremental(editorState, insertPath, afterSelection);
 
@@ -118,7 +117,7 @@ KeyEventResult _toBulletedList(EditorState editorState, TextNode textNode) {
   if (textNode.subtype == BuiltInAttributeKey.bulletedList) {
     return KeyEventResult.ignored;
   }
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, 0, 1)
     ..updateNode(textNode, {
       BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
@@ -128,8 +127,8 @@ KeyEventResult _toBulletedList(EditorState editorState, TextNode textNode) {
         path: textNode.path,
         offset: 0,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
   return KeyEventResult.handled;
 }
 
@@ -140,18 +139,18 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) {
   final String symbol;
   bool check = false;
   final symbols = List<String>.from(_checkboxListSymbols)
-    ..retainWhere(textNode.toRawString().startsWith);
+    ..retainWhere(textNode.toPlainText().startsWith);
   if (symbols.isNotEmpty) {
     symbol = symbols.first;
     check = true;
   } else {
     symbol = (List<String>.from(_unCheckboxListSymbols)
-          ..retainWhere(textNode.toRawString().startsWith))
+          ..retainWhere(textNode.toPlainText().startsWith))
         .first;
     check = false;
   }
 
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, 0, symbol.length)
     ..updateNode(textNode, {
       BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
@@ -162,22 +161,22 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) {
         path: textNode.path,
         offset: 0,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
   return KeyEventResult.handled;
 }
 
 KeyEventResult _toHeadingStyle(
     EditorState editorState, TextNode textNode, Selection selection) {
   final x = _countOfSign(
-    textNode.toRawString(),
+    textNode.toPlainText(),
     selection,
   );
   final hX = 'h$x';
   if (textNode.attributes.heading == hX) {
     return KeyEventResult.ignored;
   }
-  TransactionBuilder(editorState)
+  editorState.transaction
     ..deleteText(textNode, 0, x)
     ..updateNode(textNode, {
       BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
@@ -188,8 +187,8 @@ KeyEventResult _toHeadingStyle(
         path: textNode.path,
         offset: 0,
       ),
-    )
-    ..commit();
+    );
+  editorState.commit();
   return KeyEventResult.handled;
 }
 

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/infra/log.dart';
 import 'package:flutter/material.dart';

+ 12 - 9
frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart

@@ -1,14 +1,14 @@
 import 'package:appflowy_editor/src/infra/log.dart';
 import 'package:flutter/material.dart';
 
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/node_iterator.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node_iterator.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/extensions/node_extensions.dart';
 import 'package:appflowy_editor/src/extensions/object_extensions.dart';
-import 'package:appflowy_editor/src/extensions/path_extensions.dart';
 import 'package:appflowy_editor/src/render/selection/cursor_widget.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:appflowy_editor/src/render/selection/selection_widget.dart';
@@ -179,8 +179,11 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
     final startNode = editorState.document.nodeAtPath(start);
     final endNode = editorState.document.nodeAtPath(end);
     if (startNode != null && endNode != null) {
-      final nodes =
-          NodeIterator(editorState.document, startNode, endNode).toList();
+      final nodes = NodeIterator(
+        document: editorState.document,
+        startNode: startNode,
+        endNode: endNode,
+      ).toList();
       if (selection.isBackward) {
         return nodes;
       } else {
@@ -363,7 +366,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
 
     final backwardNodes =
         selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
-    final normalizedSelection = selection.normalize;
+    final normalizedSelection = selection.normalized;
     assert(normalizedSelection.isBackward);
 
     Log.selection.debug('update selection areas, $normalizedSelection');
@@ -375,7 +378,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
         continue;
       }
 
-      var newSelection = normalizedSelection.copy();
+      var newSelection = normalizedSelection.copyWith();
 
       /// In the case of multiple selections,
       ///  we need to return a new selection for each selected node individually.

+ 5 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart

@@ -1,10 +1,9 @@
 import 'dart:collection';
 
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:appflowy_editor/src/infra/log.dart';
-import 'package:appflowy_editor/src/operation/operation.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
-import 'package:appflowy_editor/src/operation/transaction.dart';
+import 'package:appflowy_editor/src/core/transform/operation.dart';
+import 'package:appflowy_editor/src/core/transform/transaction.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 
 /// A [HistoryItem] contains list of operations committed by users.
@@ -39,7 +38,7 @@ class HistoryItem extends LinkedListEntry<HistoryItem> {
 
   /// Create a new [Transaction] by inverting the operations.
   Transaction toTransaction(EditorState state) {
-    final builder = TransactionBuilder(state);
+    final builder = Transaction(document: state.document);
     for (var i = operations.length - 1; i >= 0; i--) {
       final operation = operations[i];
       final inverted = operation.invert();
@@ -47,7 +46,7 @@ class HistoryItem extends LinkedListEntry<HistoryItem> {
     }
     builder.afterSelection = beforeSelection;
     builder.beforeSelection = afterSelection;
-    return builder.finish();
+    return builder;
   }
 }
 

+ 59 - 0
frontend/app_flowy/packages/appflowy_editor/test/core/document/attributes_test.dart

@@ -0,0 +1,59 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('attributes.dart', () {
+    test('composeAttributes', () {
+      final base = {
+        'a': 1,
+        'b': 2,
+      };
+      final other = {
+        'b': 3,
+        'c': 4,
+        'd': null,
+      };
+      expect(composeAttributes(base, other, keepNull: false), {
+        'a': 1,
+        'b': 3,
+        'c': 4,
+      });
+      expect(composeAttributes(base, other, keepNull: true), {
+        'a': 1,
+        'b': 3,
+        'c': 4,
+        'd': null,
+      });
+      expect(composeAttributes(null, other, keepNull: false), {
+        'b': 3,
+        'c': 4,
+      });
+      expect(composeAttributes(base, null, keepNull: false), {
+        'a': 1,
+        'b': 2,
+      });
+    });
+
+    test('invertAttributes', () {
+      final base = {
+        'a': 1,
+        'b': 2,
+      };
+      final other = {
+        'b': 3,
+        'c': 4,
+        'd': null,
+      };
+      expect(invertAttributes(base, other), {
+        'a': 1,
+        'b': 2,
+        'c': null,
+      });
+      expect(invertAttributes(other, base), {
+        'a': null,
+        'b': 3,
+        'c': 4,
+      });
+    });
+  });
+}

+ 77 - 0
frontend/app_flowy/packages/appflowy_editor/test/core/document/document_test.dart

@@ -0,0 +1,77 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('documemnt.dart', () {
+    test('insert', () {
+      final document = Document.empty();
+
+      expect(document.insert([-1], []), false);
+      expect(document.insert([100], []), false);
+
+      final node0 = Node(type: '0');
+      final node1 = Node(type: '1');
+      expect(document.insert([0], [node0, node1]), true);
+      expect(document.nodeAtPath([0])?.type, '0');
+      expect(document.nodeAtPath([1])?.type, '1');
+    });
+
+    test('delete', () {
+      final document = Document(root: Node(type: 'root'));
+
+      expect(document.delete([-1], 1), false);
+      expect(document.delete([100], 1), false);
+
+      for (var i = 0; i < 10; i++) {
+        final node = Node(type: '$i');
+        document.insert([i], [node]);
+      }
+
+      document.delete([0], 10);
+      expect(document.root.children.isEmpty, true);
+    });
+
+    test('update', () {
+      final node = Node(type: 'example', attributes: {'a': 'a'});
+      final document = Document(root: Node(type: 'root'));
+      document.insert([0], [node]);
+
+      final attributes = {
+        'a': 'b',
+        'b': 'c',
+      };
+
+      expect(document.update([0], attributes), true);
+      expect(document.nodeAtPath([0])?.attributes, attributes);
+
+      expect(document.update([-1], attributes), false);
+    });
+
+    test('updateText', () {
+      final delta = Delta()..insert('Editor');
+      final textNode = TextNode(delta: delta);
+      final document = Document(root: Node(type: 'root'));
+      document.insert([0], [textNode]);
+      document.updateText([0], Delta()..insert('AppFlowy'));
+      expect((document.nodeAtPath([0]) as TextNode).toPlainText(),
+          'AppFlowyEditor');
+    });
+
+    test('serialize', () {
+      final json = {
+        'document': {
+          'type': 'editor',
+          'children': [
+            {
+              'type': 'text',
+              'delta': [],
+            }
+          ],
+          'attributes': {'a': 'a'}
+        }
+      };
+      final document = Document.fromJson(json);
+      expect(document.toJson(), json);
+    });
+  });
+}

+ 33 - 0
frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart

@@ -0,0 +1,33 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/core/document/node_iterator.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('node_iterator.dart', () {
+    test('', () {
+      final root = Node(type: 'root');
+      for (var i = 1; i <= 10; i++) {
+        final node = Node(type: 'node_$i');
+        for (var j = 1; j <= i; j++) {
+          node.insert(Node(type: 'node_${i}_$j'));
+        }
+        root.insert(node);
+      }
+      final nodes = NodeIterator(
+        document: Document(root: root),
+        startNode: root.childAtPath([0])!,
+        endNode: root.childAtPath([10, 10]),
+      );
+
+      for (var i = 1; i <= 10; i++) {
+        nodes.moveNext();
+        expect(nodes.current.type, 'node_$i');
+        for (var j = 1; j <= i; j++) {
+          nodes.moveNext();
+          expect(nodes.current.type, 'node_${i}_$j');
+        }
+      }
+      expect(nodes.moveNext(), false);
+    });
+  });
+}

+ 88 - 9
frontend/app_flowy/packages/appflowy_editor/test/document/node_test.dart → frontend/app_flowy/packages/appflowy_editor/test/core/document/node_test.dart

@@ -4,10 +4,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 void main() async {
-  setUpAll(() {
-    TestWidgetsFlutterBinding.ensureInitialized();
-  });
-
   group('node.dart', () {
     test('test node copyWith', () {
       final node = Node(
@@ -57,7 +53,6 @@ void main() async {
 
     test('test textNode copyWith', () {
       final textNode = TextNode(
-        type: 'example',
         children: LinkedList(),
         attributes: {
           'example': 'example',
@@ -65,7 +60,7 @@ void main() async {
         delta: Delta()..insert('AppFlowy'),
       );
       expect(textNode.toJson(), {
-        'type': 'example',
+        'type': 'text',
         'attributes': {
           'example': 'example',
         },
@@ -79,7 +74,6 @@ void main() async {
       );
 
       final textNodeWithChildren = TextNode(
-        type: 'example',
         children: LinkedList()..add(textNode),
         attributes: {
           'example': 'example',
@@ -87,7 +81,7 @@ void main() async {
         delta: Delta()..insert('AppFlowy'),
       );
       expect(textNodeWithChildren.toJson(), {
-        'type': 'example',
+        'type': 'text',
         'attributes': {
           'example': 'example',
         },
@@ -96,7 +90,7 @@ void main() async {
         ],
         'children': [
           {
-            'type': 'example',
+            'type': 'text',
             'attributes': {
               'example': 'example',
             },
@@ -149,5 +143,90 @@ void main() async {
       expect(identical(node.children, base.children), false);
       expect(identical(node.children.first, base.children.first), false);
     });
+
+    test('test insert', () {
+      final base = Node(
+        type: 'base',
+      );
+
+      // insert at the front when node's children is empty
+      final childA = Node(
+        type: 'child',
+      );
+      base.insert(childA);
+      expect(
+        identical(base.childAtIndex(0), childA),
+        true,
+      );
+
+      // insert at the front
+      final childB = Node(
+        type: 'child',
+      );
+      base.insert(childB, index: -1);
+      expect(
+        identical(base.childAtIndex(0), childB),
+        true,
+      );
+
+      // insert at the last
+      final childC = Node(
+        type: 'child',
+      );
+      base.insert(childC, index: 1000);
+      expect(
+        identical(base.childAtIndex(base.children.length - 1), childC),
+        true,
+      );
+
+      // insert at the last
+      final childD = Node(
+        type: 'child',
+      );
+      base.insert(childD);
+      expect(
+        identical(base.childAtIndex(base.children.length - 1), childD),
+        true,
+      );
+
+      // insert at the second
+      final childE = Node(
+        type: 'child',
+      );
+      base.insert(childE, index: 1);
+      expect(
+        identical(base.childAtIndex(1), childE),
+        true,
+      );
+    });
+
+    test('test fromJson', () {
+      final node = Node.fromJson({
+        'type': 'text',
+        'delta': [
+          {'insert': 'example'},
+        ],
+        'children': [
+          {
+            'type': 'example',
+            'attributes': {
+              'example': 'example',
+            },
+          },
+        ],
+      });
+      expect(node.type, 'text');
+      expect(node is TextNode, true);
+      expect((node as TextNode).delta.toPlainText(), 'example');
+      expect(node.attributes, {});
+      expect(node.children.length, 1);
+      expect(node.children.first.type, 'example');
+      expect(node.children.first.attributes, {'example': 'example'});
+    });
+
+    test('test toPlainText', () {
+      final textNode = TextNode.empty()..delta = (Delta()..insert('AppFlowy'));
+      expect(textNode.toPlainText(), 'AppFlowy');
+    });
   });
 }

+ 33 - 0
frontend/app_flowy/packages/appflowy_editor/test/core/document/path_test.dart

@@ -0,0 +1,33 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('path.dart', () {
+    test('test path equality', () {
+      var p1 = [0, 0];
+      var p2 = [0];
+
+      expect(p1 > p2, true);
+      expect(p1 >= p2, true);
+      expect(p1 < p2, false);
+      expect(p1 <= p2, false);
+
+      p1 = [1, 1, 2];
+      p2 = [1, 1, 3];
+
+      expect(p2 > p1, true);
+      expect(p2 >= p1, true);
+      expect(p2 < p1, false);
+      expect(p2 <= p1, false);
+
+      p1 = [2, 0, 1];
+      p2 = [2, 0, 1];
+
+      expect(p2 > p1, false);
+      expect(p1 > p2, false);
+      expect(p2 >= p1, true);
+      expect(p2 <= p1, true);
+      expect(p1.equals(p2), true);
+    });
+  });
+}

+ 332 - 0
frontend/app_flowy/packages/appflowy_editor/test/core/document/text_delta_test.dart

@@ -0,0 +1,332 @@
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
+
+void main() {
+  group('text_delta.dart', () {
+    group('compose', () {
+      test('test delta', () {
+        final delta = Delta(operations: <TextOperation>[
+          TextInsert('Gandalf', attributes: {
+            'bold': true,
+          }),
+          TextInsert(' the '),
+          TextInsert('Grey', attributes: {
+            'color': '#ccc',
+          })
+        ]);
+
+        final death = Delta()
+          ..retain(12)
+          ..insert("White", attributes: {
+            'color': '#fff',
+          })
+          ..delete(4);
+
+        final restores = delta.compose(death);
+        expect(restores.toList(), <TextOperation>[
+          TextInsert('Gandalf', attributes: {'bold': true}),
+          TextInsert(' the '),
+          TextInsert('White', attributes: {'color': '#fff'}),
+        ]);
+      });
+      test('compose()', () {
+        final a = Delta()..insert('A');
+        final b = Delta()..insert('B');
+        final expected = Delta()
+          ..insert('B')
+          ..insert('A');
+        expect(a.compose(b), expected);
+      });
+      test('insert + retain', () {
+        final a = Delta()..insert('A');
+        final b = Delta()
+          ..retain(1, attributes: {
+            'bold': true,
+            'color': 'red',
+          });
+        final expected = Delta()
+          ..insert('A', attributes: {
+            'bold': true,
+            'color': 'red',
+          });
+        expect(a.compose(b), expected);
+      });
+      test('insert + delete', () {
+        final a = Delta()..insert('A');
+        final b = Delta()..delete(1);
+        final expected = Delta();
+        expect(a.compose(b), expected);
+      });
+      test('delete + insert', () {
+        final a = Delta()..delete(1);
+        final b = Delta()..insert('B');
+        final expected = Delta()
+          ..insert('B')
+          ..delete(1);
+        expect(a.compose(b), expected);
+      });
+      test('delete + retain', () {
+        final a = Delta()..delete(1);
+        final b = Delta()
+          ..retain(1, attributes: {
+            'bold': true,
+            'color': 'red',
+          });
+        final expected = Delta()
+          ..delete(1)
+          ..retain(1, attributes: {
+            'bold': true,
+            'color': 'red',
+          });
+        expect(a.compose(b), expected);
+      });
+      test('delete + delete', () {
+        final a = Delta()..delete(1);
+        final b = Delta()..delete(1);
+        final expected = Delta()..delete(2);
+        expect(a.compose(b), expected);
+      });
+      test('retain + insert', () {
+        final a = Delta()..retain(1, attributes: {'color': 'blue'});
+        final b = Delta()..insert('B');
+        final expected = Delta()
+          ..insert('B')
+          ..retain(1, attributes: {
+            'color': 'blue',
+          });
+        expect(a.compose(b), expected);
+      });
+      test('retain + retain', () {
+        final a = Delta()
+          ..retain(1, attributes: {
+            'color': 'blue',
+          });
+        final b = Delta()
+          ..retain(1, attributes: {
+            'bold': true,
+            'color': 'red',
+          });
+        final expected = Delta()
+          ..retain(1, attributes: {
+            'bold': true,
+            'color': 'red',
+          });
+        expect(a.compose(b), expected);
+      });
+      test('retain + delete', () {
+        final a = Delta()
+          ..retain(1, attributes: {
+            'color': 'blue',
+          });
+        final b = Delta()..delete(1);
+        final expected = Delta()..delete(1);
+        expect(a.compose(b), expected);
+      });
+      test('insert in middle of text', () {
+        final a = Delta()..insert('Hello');
+        final b = Delta()
+          ..retain(3)
+          ..insert('X');
+        final expected = Delta()..insert('HelXlo');
+        expect(a.compose(b), expected);
+      });
+      test('insert and delete ordering', () {
+        final a = Delta()..insert('Hello');
+        final b = Delta()..insert('Hello');
+        final insertFirst = Delta()
+          ..retain(3)
+          ..insert('X')
+          ..delete(1);
+        final deleteFirst = Delta()
+          ..retain(3)
+          ..delete(1)
+          ..insert('X');
+        final expected = Delta()..insert('HelXo');
+        expect(a.compose(insertFirst), expected);
+        expect(b.compose(deleteFirst), expected);
+      });
+      test('delete entire text', () {
+        final a = Delta()
+          ..retain(4)
+          ..insert('Hello');
+        final b = Delta()..delete(9);
+        final expected = Delta()..delete(4);
+        expect(a.compose(b), expected);
+      });
+      test('retain more than length of text', () {
+        final a = Delta()..insert('Hello');
+        final b = Delta()..retain(10);
+        final expected = Delta()..insert('Hello');
+        expect(a.compose(b), expected);
+      });
+      test('retain start optimization', () {
+        final a = Delta()
+          ..insert('A', attributes: {'bold': true})
+          ..insert('B')
+          ..insert('C', attributes: {'bold': true})
+          ..delete(1);
+        final b = Delta()
+          ..retain(3)
+          ..insert('D');
+        final expected = Delta()
+          ..insert('A', attributes: {'bold': true})
+          ..insert('B')
+          ..insert('C', attributes: {'bold': true})
+          ..insert('D')
+          ..delete(1);
+        expect(a.compose(b), expected);
+      });
+      test('retain end optimization', () {
+        final a = Delta()
+          ..insert('A', attributes: {'bold': true})
+          ..insert('B')
+          ..insert('C', attributes: {'bold': true});
+        final b = Delta()..delete(1);
+        final expected = Delta()
+          ..insert('B')
+          ..insert('C', attributes: {'bold': true});
+        expect(a.compose(b), expected);
+      });
+      test('retain end optimization join', () {
+        final a = Delta()
+          ..insert('A', attributes: {'bold': true})
+          ..insert('B')
+          ..insert('C', attributes: {'bold': true})
+          ..insert('D')
+          ..insert('E', attributes: {'bold': true})
+          ..insert('F');
+        final b = Delta()
+          ..retain(1)
+          ..delete(1);
+        final expected = Delta()
+          ..insert('AC', attributes: {'bold': true})
+          ..insert('D')
+          ..insert('E', attributes: {'bold': true})
+          ..insert('F');
+        expect(a.compose(b), expected);
+      });
+    });
+    group('invert', () {
+      test('insert', () {
+        final delta = Delta()
+          ..retain(2)
+          ..insert('A');
+        final base = Delta()..insert('12346');
+        final expected = Delta()
+          ..retain(2)
+          ..delete(1);
+        final inverted = delta.invert(base);
+        expect(expected, inverted);
+        expect(base.compose(delta).compose(inverted), base);
+      });
+      test('delete', () {
+        final delta = Delta()
+          ..retain(2)
+          ..delete(3);
+        final base = Delta()..insert('123456');
+        final expected = Delta()
+          ..retain(2)
+          ..insert('345');
+        final inverted = delta.invert(base);
+        expect(expected, inverted);
+        expect(base.compose(delta).compose(inverted), base);
+      });
+      test('retain', () {
+        final delta = Delta()
+          ..retain(2)
+          ..retain(3, attributes: {'bold': true});
+        final base = Delta()..insert('123456');
+        final expected = Delta()
+          ..retain(2)
+          ..retain(3, attributes: {'bold': null});
+        final inverted = delta.invert(base);
+        expect(expected, inverted);
+        final t = base.compose(delta).compose(inverted);
+        expect(t, base);
+      });
+    });
+    group('json', () {
+      test('toJson()', () {
+        final delta = Delta()
+          ..retain(2)
+          ..insert('A')
+          ..delete(3);
+        expect(delta.toJson(), [
+          {'retain': 2},
+          {'insert': 'A'},
+          {'delete': 3}
+        ]);
+      });
+      test('attributes', () {
+        final delta = Delta()
+          ..retain(2, attributes: {'bold': true})
+          ..insert('A', attributes: {'italic': true});
+        expect(delta.toJson(), [
+          {
+            'retain': 2,
+            'attributes': {'bold': true},
+          },
+          {
+            'insert': 'A',
+            'attributes': {'italic': true},
+          },
+        ]);
+      });
+      test('fromJson()', () {
+        final delta = Delta.fromJson([
+          {'retain': 2},
+          {'insert': 'A'},
+          {'delete': 3},
+        ]);
+        final expected = Delta()
+          ..retain(2)
+          ..insert('A')
+          ..delete(3);
+        expect(delta, expected);
+      });
+    });
+    group('runes', () {
+      test("stringIndexes", () {
+        final indexes = stringIndexes('😊');
+        expect(indexes[0], 0);
+        expect(indexes[1], 0);
+      });
+      test("next rune 1", () {
+        final delta = Delta()..insert('😊');
+        expect(delta.nextRunePosition(0), 2);
+      });
+      test("next rune 2", () {
+        final delta = Delta()..insert('😊a');
+        expect(delta.nextRunePosition(0), 2);
+      });
+      test("next rune 3", () {
+        final delta = Delta()..insert('😊陈');
+        expect(delta.nextRunePosition(2), 3);
+      });
+      test("prev rune 1", () {
+        final delta = Delta()..insert('😊陈');
+        expect(delta.prevRunePosition(2), 0);
+      });
+      test("prev rune 2", () {
+        final delta = Delta()..insert('😊');
+        expect(delta.prevRunePosition(2), 0);
+      });
+      test("prev rune 3", () {
+        final delta = Delta()..insert('😊');
+        expect(delta.prevRunePosition(0), -1);
+      });
+    });
+    group("attributes", () {
+      test("compose", () {
+        final attrs =
+            composeAttributes({'a': null}, {'b': null}, keepNull: true);
+        expect(attrs != null, true);
+        expect(attrs?.containsKey("a"), true);
+        expect(attrs?.containsKey("b"), true);
+        expect(attrs?["a"], null);
+        expect(attrs?["b"], null);
+      });
+    });
+  });
+}

+ 26 - 0
frontend/app_flowy/packages/appflowy_editor/test/core/location/position_test.dart

@@ -0,0 +1,26 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('position.dart', () {
+    test('test position equality', () {
+      final positionA = Position(path: [0, 1, 2], offset: 3);
+      final positionB = Position(path: [0, 1, 2], offset: 3);
+      expect(positionA, positionB);
+
+      final positionC = positionA.copyWith(offset: 4);
+      final positionD = positionB.copyWith(path: [1, 2, 3]);
+      expect(positionC.offset, 4);
+      expect(positionD.path, [1, 2, 3]);
+
+      expect(positionA.toJson(), {
+        'path': [0, 1, 2],
+        'offset': 3,
+      });
+      expect(positionC.toJson(), {
+        'path': [0, 1, 2],
+        'offset': 4,
+      });
+    });
+  });
+}

+ 77 - 0
frontend/app_flowy/packages/appflowy_editor/test/core/location/selection_test.dart

@@ -0,0 +1,77 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('selection.dart', () {
+    test('test selection equality', () {
+      final position = Position(path: [0, 1, 2], offset: 3);
+      final selectionA = Selection(start: position, end: position);
+      final selectionB = Selection.collapsed(position);
+      expect(selectionA, selectionB);
+      expect(selectionA.hashCode, selectionB.hashCode);
+
+      final newPosition = Position(path: [1, 2, 3], offset: 4);
+
+      final selectionC = selectionA.copyWith(start: newPosition);
+      expect(selectionC.start, newPosition);
+      expect(selectionC.end, position);
+      expect(selectionC.isCollapsed, false);
+
+      final selectionD = selectionA.copyWith(end: newPosition);
+      expect(selectionD.start, position);
+      expect(selectionD.end, newPosition);
+      expect(selectionD.isCollapsed, false);
+
+      final selectionE = Selection.single(path: [0, 1, 2], startOffset: 3);
+      expect(selectionE, selectionA);
+      expect(selectionE.isSingle, true);
+      expect(selectionE.isCollapsed, true);
+    });
+
+    test('test selection direction', () {
+      final start = Position(path: [0, 1, 2], offset: 3);
+      final end = Position(path: [1, 2, 3], offset: 3);
+      final backwardSelection = Selection(start: start, end: end);
+      expect(backwardSelection.isBackward, true);
+      final forwardSelection = Selection(start: end, end: start);
+      expect(forwardSelection.isForward, true);
+
+      expect(backwardSelection.reversed, forwardSelection);
+      expect(forwardSelection.normalized, backwardSelection);
+
+      expect(backwardSelection.startIndex, 3);
+      expect(backwardSelection.endIndex, 3);
+    });
+
+    test('test selection collapsed', () {
+      final start = Position(path: [0, 1, 2], offset: 3);
+      final end = Position(path: [1, 2, 3], offset: 3);
+      final selection = Selection(start: start, end: end);
+      final collapsedAtStart = selection.collapse(atStart: true);
+      expect(collapsedAtStart.isCollapsed, true);
+      expect(collapsedAtStart.start, start);
+      expect(collapsedAtStart.end, start);
+
+      final collapsedAtEnd = selection.collapse(atStart: false);
+      expect(collapsedAtEnd.isCollapsed, true);
+      expect(collapsedAtEnd.start, end);
+      expect(collapsedAtEnd.end, end);
+    });
+
+    test('test selection toJson', () {
+      final start = Position(path: [0, 1, 2], offset: 3);
+      final end = Position(path: [1, 2, 3], offset: 3);
+      final selection = Selection(start: start, end: end);
+      expect(selection.toJson(), {
+        'start': {
+          'path': [0, 1, 2],
+          'offset': 3
+        },
+        'end': {
+          'path': [1, 2, 3],
+          'offset': 3
+        }
+      });
+    });
+  });
+}

+ 79 - 0
frontend/app_flowy/packages/appflowy_editor/test/core/transform/operation_test.dart

@@ -0,0 +1,79 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('operation.dart', () {
+    test('test insert operation', () {
+      final node = Node(type: 'example');
+      final op = InsertOperation([0], [node]);
+      final json = op.toJson();
+      expect(json, {
+        'op': 'insert',
+        'path': [0],
+        'nodes': [
+          {
+            'type': 'example',
+          }
+        ]
+      });
+      expect(InsertOperation.fromJson(json), op);
+      expect(op.invert().invert(), op);
+      expect(op.copyWith(), op);
+    });
+
+    test('test update operation', () {
+      final op = UpdateOperation([0], {'a': 1}, {'a': 0});
+      final json = op.toJson();
+      expect(json, {
+        'op': 'update',
+        'path': [0],
+        'attributes': {'a': 1},
+        'oldAttributes': {'a': 0}
+      });
+      expect(UpdateOperation.fromJson(json), op);
+      expect(op.invert().invert(), op);
+      expect(op.copyWith(), op);
+    });
+
+    test('test delete operation', () {
+      final node = Node(type: 'example');
+      final op = DeleteOperation([0], [node]);
+      final json = op.toJson();
+      expect(json, {
+        'op': 'delete',
+        'path': [0],
+        'nodes': [
+          {
+            'type': 'example',
+          }
+        ]
+      });
+      expect(DeleteOperation.fromJson(json), op);
+      expect(op.invert().invert(), op);
+      expect(op.copyWith(), op);
+    });
+
+    test('test update text operation', () {
+      final app = Delta()..insert('App');
+      final appflowy = Delta()
+        ..retain(3)
+        ..insert('Flowy');
+      final op = UpdateTextOperation([0], app, appflowy.invert(app));
+      final json = op.toJson();
+      expect(json, {
+        'op': 'update_text',
+        'path': [0],
+        'delta': [
+          {'insert': 'App'}
+        ],
+        'inverted': [
+          {'retain': 3},
+          {'delete': 5}
+        ]
+      });
+      expect(UpdateTextOperation.fromJson(json), op);
+      expect(op.invert().invert(), op);
+      expect(op.copyWith(), op);
+    });
+  });
+}

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart

@@ -31,7 +31,7 @@ void main() async {
       expect(p1 > p2, false);
       expect(p2 >= p1, true);
       expect(p2 <= p1, true);
-      expect(pathEquals(p1, p2), true);
+      expect(p1.equals(p2), true);
     });
   });
 }

+ 0 - 1
frontend/app_flowy/packages/appflowy_editor/test/infra/infra_test.dart

@@ -15,7 +15,6 @@ void main() async {
       const text = 'Welcome to Appflowy 😁';
       TextNode textNode() {
         return TextNode(
-          type: 'text',
           delta: Delta()..insert(text),
         );
       }

+ 4 - 5
frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart

@@ -19,7 +19,7 @@ class EditorWidgetTester {
   EditorState get editorState => _editorState;
   Node get root => _editorState.document.root;
 
-  StateTree get document => _editorState.document;
+  Document get document => _editorState.document;
   int get documentLength => _editorState.document.root.children.length;
   Selection? get documentSelection =>
       _editorState.service.selectionService.currentSelection.value;
@@ -63,8 +63,7 @@ class EditorWidgetTester {
   void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) {
     insert(
       TextNode(
-        type: 'text',
-        delta: delta ?? Delta([TextInsert(text ?? 'Test')]),
+        delta: delta ?? Delta(operations: [TextInsert(text ?? 'Test')]),
         attributes: attributes,
       ),
     );
@@ -103,7 +102,7 @@ class EditorWidgetTester {
       {Selection? selection}) async {
     await apply([
       TextEditingDeltaInsertion(
-        oldText: textNode.toRawString(),
+        oldText: textNode.toPlainText(),
         textInserted: text,
         insertionOffset: offset,
         selection: selection != null
@@ -156,7 +155,7 @@ class EditorWidgetTester {
 
   EditorState _createEmptyDocument() {
     return EditorState(
-      document: StateTree(
+      document: Document(
         root: _createEmptyEditorRoot(),
       ),
     )..disableSealTimer = true;

+ 0 - 329
frontend/app_flowy/packages/appflowy_editor/test/legacy/delta_test.dart

@@ -1,329 +0,0 @@
-import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:appflowy_editor/src/document/text_delta.dart';
-
-void main() {
-  group('compose', () {
-    test('test delta', () {
-      final delta = Delta(<TextOperation>[
-        TextInsert('Gandalf', {
-          'bold': true,
-        }),
-        TextInsert(' the '),
-        TextInsert('Grey', {
-          'color': '#ccc',
-        })
-      ]);
-
-      final death = Delta()
-        ..retain(12)
-        ..insert("White", {
-          'color': '#fff',
-        })
-        ..delete(4);
-
-      final restores = delta.compose(death);
-      expect(restores.toList(), <TextOperation>[
-        TextInsert('Gandalf', {'bold': true}),
-        TextInsert(' the '),
-        TextInsert('White', {'color': '#fff'}),
-      ]);
-    });
-    test('compose()', () {
-      final a = Delta()..insert('A');
-      final b = Delta()..insert('B');
-      final expected = Delta()
-        ..insert('B')
-        ..insert('A');
-      expect(a.compose(b), expected);
-    });
-    test('insert + retain', () {
-      final a = Delta()..insert('A');
-      final b = Delta()
-        ..retain(1, {
-          'bold': true,
-          'color': 'red',
-        });
-      final expected = Delta()
-        ..insert('A', {
-          'bold': true,
-          'color': 'red',
-        });
-      expect(a.compose(b), expected);
-    });
-    test('insert + delete', () {
-      final a = Delta()..insert('A');
-      final b = Delta()..delete(1);
-      final expected = Delta();
-      expect(a.compose(b), expected);
-    });
-    test('delete + insert', () {
-      final a = Delta()..delete(1);
-      final b = Delta()..insert('B');
-      final expected = Delta()
-        ..insert('B')
-        ..delete(1);
-      expect(a.compose(b), expected);
-    });
-    test('delete + retain', () {
-      final a = Delta()..delete(1);
-      final b = Delta()
-        ..retain(1, {
-          'bold': true,
-          'color': 'red',
-        });
-      final expected = Delta()
-        ..delete(1)
-        ..retain(1, {
-          'bold': true,
-          'color': 'red',
-        });
-      expect(a.compose(b), expected);
-    });
-    test('delete + delete', () {
-      final a = Delta()..delete(1);
-      final b = Delta()..delete(1);
-      final expected = Delta()..delete(2);
-      expect(a.compose(b), expected);
-    });
-    test('retain + insert', () {
-      final a = Delta()..retain(1, {'color': 'blue'});
-      final b = Delta()..insert('B');
-      final expected = Delta()
-        ..insert('B')
-        ..retain(1, {
-          'color': 'blue',
-        });
-      expect(a.compose(b), expected);
-    });
-    test('retain + retain', () {
-      final a = Delta()
-        ..retain(1, {
-          'color': 'blue',
-        });
-      final b = Delta()
-        ..retain(1, {
-          'bold': true,
-          'color': 'red',
-        });
-      final expected = Delta()
-        ..retain(1, {
-          'bold': true,
-          'color': 'red',
-        });
-      expect(a.compose(b), expected);
-    });
-    test('retain + delete', () {
-      final a = Delta()
-        ..retain(1, {
-          'color': 'blue',
-        });
-      final b = Delta()..delete(1);
-      final expected = Delta()..delete(1);
-      expect(a.compose(b), expected);
-    });
-    test('insert in middle of text', () {
-      final a = Delta()..insert('Hello');
-      final b = Delta()
-        ..retain(3)
-        ..insert('X');
-      final expected = Delta()..insert('HelXlo');
-      expect(a.compose(b), expected);
-    });
-    test('insert and delete ordering', () {
-      final a = Delta()..insert('Hello');
-      final b = Delta()..insert('Hello');
-      final insertFirst = Delta()
-        ..retain(3)
-        ..insert('X')
-        ..delete(1);
-      final deleteFirst = Delta()
-        ..retain(3)
-        ..delete(1)
-        ..insert('X');
-      final expected = Delta()..insert('HelXo');
-      expect(a.compose(insertFirst), expected);
-      expect(b.compose(deleteFirst), expected);
-    });
-    test('delete entire text', () {
-      final a = Delta()
-        ..retain(4)
-        ..insert('Hello');
-      final b = Delta()..delete(9);
-      final expected = Delta()..delete(4);
-      expect(a.compose(b), expected);
-    });
-    test('retain more than length of text', () {
-      final a = Delta()..insert('Hello');
-      final b = Delta()..retain(10);
-      final expected = Delta()..insert('Hello');
-      expect(a.compose(b), expected);
-    });
-    test('retain start optimization', () {
-      final a = Delta()
-        ..insert('A', {'bold': true})
-        ..insert('B')
-        ..insert('C', {'bold': true})
-        ..delete(1);
-      final b = Delta()
-        ..retain(3)
-        ..insert('D');
-      final expected = Delta()
-        ..insert('A', {'bold': true})
-        ..insert('B')
-        ..insert('C', {'bold': true})
-        ..insert('D')
-        ..delete(1);
-      expect(a.compose(b), expected);
-    });
-    test('retain end optimization', () {
-      final a = Delta()
-        ..insert('A', {'bold': true})
-        ..insert('B')
-        ..insert('C', {'bold': true});
-      final b = Delta()..delete(1);
-      final expected = Delta()
-        ..insert('B')
-        ..insert('C', {'bold': true});
-      expect(a.compose(b), expected);
-    });
-    test('retain end optimization join', () {
-      final a = Delta()
-        ..insert('A', {'bold': true})
-        ..insert('B')
-        ..insert('C', {'bold': true})
-        ..insert('D')
-        ..insert('E', {'bold': true})
-        ..insert('F');
-      final b = Delta()
-        ..retain(1)
-        ..delete(1);
-      final expected = Delta()
-        ..insert('AC', {'bold': true})
-        ..insert('D')
-        ..insert('E', {'bold': true})
-        ..insert('F');
-      expect(a.compose(b), expected);
-    });
-  });
-  group('invert', () {
-    test('insert', () {
-      final delta = Delta()
-        ..retain(2)
-        ..insert('A');
-      final base = Delta()..insert('12346');
-      final expected = Delta()
-        ..retain(2)
-        ..delete(1);
-      final inverted = delta.invert(base);
-      expect(expected, inverted);
-      expect(base.compose(delta).compose(inverted), base);
-    });
-    test('delete', () {
-      final delta = Delta()
-        ..retain(2)
-        ..delete(3);
-      final base = Delta()..insert('123456');
-      final expected = Delta()
-        ..retain(2)
-        ..insert('345');
-      final inverted = delta.invert(base);
-      expect(expected, inverted);
-      expect(base.compose(delta).compose(inverted), base);
-    });
-    test('retain', () {
-      final delta = Delta()
-        ..retain(2)
-        ..retain(3, {'bold': true});
-      final base = Delta()..insert('123456');
-      final expected = Delta()
-        ..retain(2)
-        ..retain(3, {'bold': null});
-      final inverted = delta.invert(base);
-      expect(expected, inverted);
-      final t = base.compose(delta).compose(inverted);
-      expect(t, base);
-    });
-  });
-  group('json', () {
-    test('toJson()', () {
-      final delta = Delta()
-        ..retain(2)
-        ..insert('A')
-        ..delete(3);
-      expect(delta.toJson(), [
-        {'retain': 2},
-        {'insert': 'A'},
-        {'delete': 3}
-      ]);
-    });
-    test('attributes', () {
-      final delta = Delta()
-        ..retain(2, {'bold': true})
-        ..insert('A', {'italic': true});
-      expect(delta.toJson(), [
-        {
-          'retain': 2,
-          'attributes': {'bold': true},
-        },
-        {
-          'insert': 'A',
-          'attributes': {'italic': true},
-        },
-      ]);
-    });
-    test('fromJson()', () {
-      final delta = Delta.fromJson([
-        {'retain': 2},
-        {'insert': 'A'},
-        {'delete': 3},
-      ]);
-      final expected = Delta()
-        ..retain(2)
-        ..insert('A')
-        ..delete(3);
-      expect(delta, expected);
-    });
-  });
-  group('runes', () {
-    test("stringIndexes", () {
-      final indexes = stringIndexes('😊');
-      expect(indexes[0], 0);
-      expect(indexes[1], 0);
-    });
-    test("next rune 1", () {
-      final delta = Delta()..insert('😊');
-      expect(delta.nextRunePosition(0), 2);
-    });
-    test("next rune 2", () {
-      final delta = Delta()..insert('😊a');
-      expect(delta.nextRunePosition(0), 2);
-    });
-    test("next rune 3", () {
-      final delta = Delta()..insert('😊陈');
-      expect(delta.nextRunePosition(2), 3);
-    });
-    test("prev rune 1", () {
-      final delta = Delta()..insert('😊陈');
-      expect(delta.prevRunePosition(2), 0);
-    });
-    test("prev rune 2", () {
-      final delta = Delta()..insert('😊');
-      expect(delta.prevRunePosition(2), 0);
-    });
-    test("prev rune 3", () {
-      final delta = Delta()..insert('😊');
-      expect(delta.prevRunePosition(0), -1);
-    });
-  });
-  group("attributes", () {
-    test("compose", () {
-      final attrs = composeAttributes({"a": null}, {"b": null}, true);
-      expect(attrs != null, true);
-      expect(attrs!.containsKey("a"), true);
-      expect(attrs.containsKey("b"), true);
-      expect(attrs["a"], null);
-      expect(attrs["b"], null);
-    });
-  });
-}

+ 21 - 21
frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart

@@ -1,6 +1,6 @@
-import 'package:appflowy_editor/src/document/path.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/core/document/path.dart';
+import 'package:appflowy_editor/src/core/location/position.dart';
+import 'package:appflowy_editor/src/core/location/selection.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 void main() {
@@ -9,16 +9,16 @@ void main() {
   test('create state tree', () async {
     // final String response = await rootBundle.loadString('assets/document.json');
     // final data = Map<String, Object>.from(json.decode(response));
-    // final stateTree = StateTree.fromJson(data);
-    // expect(stateTree.root.type, 'root');
-    // expect(stateTree.root.toJson(), data['document']);
+    // final document = Document.fromJson(data);
+    // expect(document.root.type, 'root');
+    // expect(document.root.toJson(), data['document']);
   });
 
   test('search node by Path in state tree', () async {
     // final String response = await rootBundle.loadString('assets/document.json');
     // final data = Map<String, Object>.from(json.decode(response));
-    // final stateTree = StateTree.fromJson(data);
-    // final checkBoxNode = stateTree.root.childAtPath([1, 0]);
+    // final document = Document.fromJson(data);
+    // final checkBoxNode = document.root.childAtPath([1, 0]);
     // expect(checkBoxNode != null, true);
     // final textType = checkBoxNode!.attributes['text-type'];
     // expect(textType != null, true);
@@ -27,8 +27,8 @@ void main() {
   test('search node by Self in state tree', () async {
     // final String response = await rootBundle.loadString('assets/document.json');
     // final data = Map<String, Object>.from(json.decode(response));
-    // final stateTree = StateTree.fromJson(data);
-    // final checkBoxNode = stateTree.root.childAtPath([1, 0]);
+    // final document = Document.fromJson(data);
+    // final checkBoxNode = document.root.childAtPath([1, 0]);
     // expect(checkBoxNode != null, true);
     // final textType = checkBoxNode!.attributes['text-type'];
     // expect(textType != null, true);
@@ -39,21 +39,21 @@ void main() {
   test('insert node in state tree', () async {
     // final String response = await rootBundle.loadString('assets/document.json');
     // final data = Map<String, Object>.from(json.decode(response));
-    // final stateTree = StateTree.fromJson(data);
+    // final document = Document.fromJson(data);
     // final insertNode = Node.fromJson({
     //   'type': 'text',
     // });
-    // bool result = stateTree.insert([1, 1], [insertNode]);
+    // bool result = document.insert([1, 1], [insertNode]);
     // expect(result, true);
-    // expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true);
+    // expect(identical(insertNode, document.nodeAtPath([1, 1])), true);
   });
 
   test('delete node in state tree', () async {
     // final String response = await rootBundle.loadString('assets/document.json');
     // final data = Map<String, Object>.from(json.decode(response));
-    // final stateTree = StateTree.fromJson(data);
-    // stateTree.delete([1, 1], 1);
-    // final node = stateTree.nodeAtPath([1, 1]);
+    // final document = Document.fromJson(data);
+    // document.delete([1, 1], 1);
+    // final node = document.nodeAtPath([1, 1]);
     // expect(node != null, true);
     // expect(node!.attributes['tag'], '**');
   });
@@ -61,10 +61,10 @@ void main() {
   test('update node in state tree', () async {
     // final String response = await rootBundle.loadString('assets/document.json');
     // final data = Map<String, Object>.from(json.decode(response));
-    // final stateTree = StateTree.fromJson(data);
-    // final test = stateTree.update([1, 1], {'text-type': 'heading1'});
+    // final document = Document.fromJson(data);
+    // final test = document.update([1, 1], {'text-type': 'heading1'});
     // expect(test, true);
-    // final updatedNode = stateTree.nodeAtPath([1, 1]);
+    // final updatedNode = document.nodeAtPath([1, 1]);
     // expect(updatedNode != null, true);
     // expect(updatedNode!.attributes['text-type'], 'heading1');
   });
@@ -72,7 +72,7 @@ void main() {
   test('test path utils 1', () {
     final path1 = <int>[1];
     final path2 = <int>[1];
-    expect(pathEquals(path1, path2), true);
+    expect(path1.equals(path2), true);
 
     expect(Object.hashAll(path1), Object.hashAll(path2));
   });
@@ -80,7 +80,7 @@ void main() {
   test('test path utils 2', () {
     final path1 = <int>[1];
     final path2 = <int>[2];
-    expect(pathEquals(path1, path2), false);
+    expect(path1.equals(path2), false);
 
     expect(Object.hashAll(path1) != Object.hashAll(path2), true);
   });

+ 33 - 33
frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart

@@ -1,11 +1,10 @@
 import 'dart:collection';
 
-import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:flutter_test/flutter_test.dart';
-import 'package:appflowy_editor/src/operation/operation.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/core/transform/operation.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
-import 'package:appflowy_editor/src/document/state_tree.dart';
+import 'package:appflowy_editor/src/core/document/document.dart';
 
 void main() {
   TestWidgetsFlutterBinding.ensureInitialized();
@@ -48,25 +47,26 @@ void main() {
     final item2 = Node(type: "node", attributes: {}, children: LinkedList());
     final item3 = Node(type: "node", attributes: {}, children: LinkedList());
     final root = Node(
-        type: "root",
-        attributes: {},
-        children: LinkedList()
-          ..addAll([
-            item1,
-            item2,
-            item3,
-          ]));
-    final state = EditorState(document: StateTree(root: root));
+      type: "root",
+      attributes: {},
+      children: LinkedList()
+        ..addAll([
+          item1,
+          item2,
+          item3,
+        ]),
+    );
+    final state = EditorState(document: Document(root: root));
 
     expect(item1.path, [0]);
     expect(item2.path, [1]);
     expect(item3.path, [2]);
 
-    final tb = TransactionBuilder(state);
-    tb.deleteNode(item1);
-    tb.deleteNode(item2);
-    tb.deleteNode(item3);
-    final transaction = tb.finish();
+    final transaction = state.transaction;
+    transaction.deleteNode(item1);
+    transaction.deleteNode(item2);
+    transaction.deleteNode(item3);
+    state.commit();
     expect(transaction.operations[0].path, [0]);
     expect(transaction.operations[1].path, [0]);
     expect(transaction.operations[2].path, [0]);
@@ -74,13 +74,12 @@ void main() {
   group("toJson", () {
     test("insert", () {
       final root = Node(type: "root", attributes: {}, children: LinkedList());
-      final state = EditorState(document: StateTree(root: root));
+      final state = EditorState(document: Document(root: root));
 
       final item1 = Node(type: "node", attributes: {}, children: LinkedList());
-      final tb = TransactionBuilder(state);
-      tb.insertNode([0], item1);
-
-      final transaction = tb.finish();
+      final transaction = state.transaction;
+      transaction.insertNode([0], item1);
+      state.commit();
       expect(transaction.toJson(), {
         "operations": [
           {
@@ -94,16 +93,17 @@ void main() {
     test("delete", () {
       final item1 = Node(type: "node", attributes: {}, children: LinkedList());
       final root = Node(
-          type: "root",
-          attributes: {},
-          children: LinkedList()
-            ..addAll([
-              item1,
-            ]));
-      final state = EditorState(document: StateTree(root: root));
-      final tb = TransactionBuilder(state);
-      tb.deleteNode(item1);
-      final transaction = tb.finish();
+        type: "root",
+        attributes: {},
+        children: LinkedList()
+          ..addAll([
+            item1,
+          ]),
+      );
+      final state = EditorState(document: Document(root: root));
+      final transaction = state.transaction;
+      transaction.deleteNode(item1);
+      state.commit();
       expect(transaction.toJson(), {
         "operations": [
           {

+ 13 - 13
frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart

@@ -17,16 +17,16 @@ void main() async {
   }
 
   test("HistoryItem #1", () {
-    final document = StateTree(root: _createEmptyEditorRoot());
+    final document = Document(root: _createEmptyEditorRoot());
     final editorState = EditorState(document: document);
 
     final historyItem = HistoryItem();
-    historyItem.add(DeleteOperation(
-        [0], [TextNode(type: 'text', delta: Delta()..insert('0'))]));
-    historyItem.add(DeleteOperation(
-        [0], [TextNode(type: 'text', delta: Delta()..insert('1'))]));
-    historyItem.add(DeleteOperation(
-        [0], [TextNode(type: 'text', delta: Delta()..insert('2'))]));
+    historyItem
+        .add(DeleteOperation([0], [TextNode(delta: Delta()..insert('0'))]));
+    historyItem
+        .add(DeleteOperation([0], [TextNode(delta: Delta()..insert('1'))]));
+    historyItem
+        .add(DeleteOperation([0], [TextNode(delta: Delta()..insert('2'))]));
 
     final transaction = historyItem.toTransaction(editorState);
     assert(isInsertAndPathEqual(transaction.operations[0], [0], '2'));
@@ -35,12 +35,12 @@ void main() async {
   });
 
   test("HistoryItem #2", () {
-    final document = StateTree(root: _createEmptyEditorRoot());
+    final document = Document(root: _createEmptyEditorRoot());
     final editorState = EditorState(document: document);
 
     final historyItem = HistoryItem();
-    historyItem.add(DeleteOperation(
-        [0], [TextNode(type: 'text', delta: Delta()..insert('0'))]));
+    historyItem
+        .add(DeleteOperation([0], [TextNode(delta: Delta()..insert('0'))]));
     historyItem
         .add(UpdateOperation([0], {"subType": "number"}, {"subType": null}));
     historyItem.add(DeleteOperation([0], [TextNode.empty(), TextNode.empty()]));
@@ -59,11 +59,11 @@ bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
     return false;
   }
 
-  if (!pathEquals(operation.path, path)) {
+  if (!operation.path.equals(path)) {
     return false;
   }
 
-  final firstNode = operation.nodes[0];
+  final firstNode = operation.nodes.first;
   if (firstNode is! TextNode) {
     return false;
   }
@@ -72,5 +72,5 @@ bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
     return true;
   }
 
-  return firstNode.delta.toRawString() == content;
+  return firstNode.delta.toPlainText() == content;
 }

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart

@@ -26,8 +26,8 @@ void main() async {
             BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
             BuiltInAttributeKey.checkbox: false,
           },
-          delta: Delta([
-            TextInsert(text, {
+          delta: Delta(operations: [
+            TextInsert(text, attributes: {
               BuiltInAttributeKey.bold: true,
               BuiltInAttributeKey.italic: true,
               BuiltInAttributeKey.underline: true,

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart

@@ -147,7 +147,7 @@ Future<void> _testDefaultSelectionMenuItems(
     int index, EditorWidgetTester editor) async {
   expect(editor.documentLength, 4);
   expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0));
-  expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
+  expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
       'Welcome to Appflowy 😁');
   final node = editor.nodeAtPath([2]);
   final item = defaultSelectionMenuItems[index];

+ 12 - 14
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart

@@ -117,7 +117,7 @@ void main() async {
     expect(editor.documentLength, 1);
     expect(editor.documentSelection,
         Selection.single(path: [0], startOffset: text.length));
-    expect((editor.nodeAtPath([0]) as TextNode).toRawString(), text * 2);
+    expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), text * 2);
   });
 
   // Before
@@ -275,7 +275,6 @@ void main() async {
     //    * Welcome to Appflowy 😁
     const text = 'Welcome to Appflowy 😁';
     final node = TextNode(
-      type: 'text',
       delta: Delta()..insert(text),
       attributes: {
         BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
@@ -320,7 +319,7 @@ void main() async {
       editor.documentSelection,
       Selection.single(path: [0, 0], startOffset: text.length),
     );
-    expect((editor.nodeAtPath([0, 0]) as TextNode).toRawString(), text * 2);
+    expect((editor.nodeAtPath([0, 0]) as TextNode).toPlainText(), text * 2);
   });
 
   testWidgets('Delete the complicated nested bulleted list', (tester) async {
@@ -331,7 +330,6 @@ void main() async {
     //    * Welcome to Appflowy 😁
     const text = 'Welcome to Appflowy 😁';
     final node = TextNode(
-      type: 'text',
       delta: Delta()..insert(text),
       attributes: {
         BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
@@ -390,7 +388,7 @@ void main() async {
       true,
     );
     expect(
-      (editor.nodeAtPath([0, 0]) as TextNode).toRawString() == text * 2,
+      (editor.nodeAtPath([0, 0]) as TextNode).toPlainText() == text * 2,
       true,
     );
     expect(
@@ -496,7 +494,7 @@ Future<void> _deleteStyledTextByBackspace(
   expect(editor.documentSelection,
       Selection.single(path: [1], startOffset: text.length));
   expect(editor.nodeAtPath([1])?.subtype, style);
-  expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text * 2);
+  expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text * 2);
 
   await editor.updateSelection(
     Selection.single(path: [1], startOffset: 0),
@@ -538,7 +536,7 @@ Future<void> _deleteStyledTextByDelete(
     expect(
         editor.documentSelection, Selection.single(path: [1], startOffset: 0));
     expect(editor.nodeAtPath([1])?.subtype, style);
-    expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
+    expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
         text.safeSubString(i));
   }
 
@@ -548,7 +546,7 @@ Future<void> _deleteStyledTextByDelete(
   expect(editor.documentLength, 2);
   expect(editor.documentSelection, Selection.single(path: [1], startOffset: 0));
   expect(editor.nodeAtPath([1])?.subtype, style);
-  expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
+  expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text);
 }
 
 Future<void> _deleteTextByBackspace(
@@ -568,7 +566,7 @@ Future<void> _deleteTextByBackspace(
 
   expect(editor.documentLength, 3);
   expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9));
-  expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
+  expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
       'Welcome t Appflowy 😁');
 
   // delete 'to '
@@ -578,7 +576,7 @@ Future<void> _deleteTextByBackspace(
   await editor.pressLogicKey(LogicalKeyboardKey.backspace);
   expect(editor.documentLength, 3);
   expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8));
-  expect((editor.nodeAtPath([2]) as TextNode).toRawString(),
+  expect((editor.nodeAtPath([2]) as TextNode).toPlainText(),
       'Welcome Appflowy 😁');
 
   // delete 'Appflowy 😁
@@ -593,7 +591,7 @@ Future<void> _deleteTextByBackspace(
   expect(editor.documentLength, 1);
   expect(
       editor.documentSelection, Selection.single(path: [0], startOffset: 11));
-  expect((editor.nodeAtPath([0]) as TextNode).toRawString(),
+  expect((editor.nodeAtPath([0]) as TextNode).toPlainText(),
       'Welcome to Appflowy 😁');
 }
 
@@ -614,7 +612,7 @@ Future<void> _deleteTextByDelete(
 
   expect(editor.documentLength, 3);
   expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9));
-  expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
+  expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
       'Welcome t Appflowy 😁');
 
   // delete 'to '
@@ -624,7 +622,7 @@ Future<void> _deleteTextByDelete(
   await editor.pressLogicKey(LogicalKeyboardKey.delete);
   expect(editor.documentLength, 3);
   expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8));
-  expect((editor.nodeAtPath([2]) as TextNode).toRawString(),
+  expect((editor.nodeAtPath([2]) as TextNode).toPlainText(),
       'Welcome Appflowy 😁');
 
   // delete 'Appflowy 😁
@@ -639,6 +637,6 @@ Future<void> _deleteTextByDelete(
   expect(editor.documentLength, 1);
   expect(
       editor.documentSelection, Selection.single(path: [0], startOffset: 11));
-  expect((editor.nodeAtPath([0]) as TextNode).toRawString(),
+  expect((editor.nodeAtPath([0]) as TextNode).toPlainText(),
       'Welcome to Appflowy 😁');
 }

+ 6 - 6
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart

@@ -74,10 +74,10 @@ void main() async {
       expect(lastNode != null, true);
       expect(lastNode is TextNode, true);
       lastNode = lastNode as TextNode;
-      expect(lastNode.delta.toRawString(), text);
-      expect((lastNode.previous as TextNode).delta.toRawString(), '');
+      expect(lastNode.delta.toPlainText(), text);
+      expect((lastNode.previous as TextNode).delta.toPlainText(), '');
       expect(
-          (lastNode.previous!.previous as TextNode).delta.toRawString(), text);
+          (lastNode.previous!.previous as TextNode).delta.toPlainText(), text);
     });
 
     // Before
@@ -134,7 +134,7 @@ void main() async {
       );
       await editor.pressLogicKey(LogicalKeyboardKey.enter);
       expect(editor.documentLength, 2);
-      expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
+      expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text);
     });
   });
 }
@@ -227,6 +227,6 @@ Future<void> _testMultipleSelection(
   );
 
   expect(editor.documentLength, 2);
-  expect((editor.nodeAtPath([0]) as TextNode).toRawString(), 'Welcome');
-  expect((editor.nodeAtPath([1]) as TextNode).toRawString(), 'to Appflowy 😁');
+  expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), 'Welcome');
+  expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), 'to Appflowy 😁');
 }

+ 18 - 18
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart

@@ -39,11 +39,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allBold, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('App**Flowy** to bold AppFlowy', (tester) async {
@@ -62,11 +62,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 3,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allBold, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('***AppFlowy** to bold *AppFlowy', (tester) async {
@@ -85,11 +85,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 1,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allBold, true);
-        expect(textNode.toRawString(), '*AppFlowy');
+        expect(textNode.toPlainText(), '*AppFlowy');
       });
 
       testWidgets('**AppFlowy** application to bold AppFlowy only',
@@ -115,7 +115,7 @@ void main() async {
           ),
         );
         expect(appFlowyBold, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('**** nothing changes', (tester) async {
@@ -134,11 +134,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allBold, false);
-        expect(textNode.toRawString(), text);
+        expect(textNode.toPlainText(), text);
       });
     });
 
@@ -171,11 +171,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allBold, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('App__Flowy__ to bold AppFlowy', (tester) async {
@@ -194,11 +194,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 3,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allBold, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('___AppFlowy__ to bold _AppFlowy', (tester) async {
@@ -217,11 +217,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 1,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allBold, true);
-        expect(textNode.toRawString(), '_AppFlowy');
+        expect(textNode.toPlainText(), '_AppFlowy');
       });
 
       testWidgets('__AppFlowy__ application to bold AppFlowy only',
@@ -247,7 +247,7 @@ void main() async {
           ),
         );
         expect(appFlowyBold, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('____ nothing changes', (tester) async {
@@ -266,11 +266,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allBold, false);
-        expect(textNode.toRawString(), text);
+        expect(textNode.toPlainText(), text);
       });
     });
   });

+ 18 - 18
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart

@@ -38,11 +38,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allCode, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('App`Flowy` to code AppFlowy', (tester) async {
@@ -61,11 +61,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 3,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allCode, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('`` nothing changes', (tester) async {
@@ -84,11 +84,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allCode, false);
-        expect(textNode.toRawString(), text);
+        expect(textNode.toPlainText(), text);
       });
     });
 
@@ -120,11 +120,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 1,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allCode, true);
-        expect(textNode.toRawString(), '`AppFlowy');
+        expect(textNode.toPlainText(), '`AppFlowy');
       });
 
       testWidgets('```` nothing changes', (tester) async {
@@ -143,11 +143,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allCode, false);
-        expect(textNode.toRawString(), text);
+        expect(textNode.toPlainText(), text);
       });
     });
 
@@ -180,11 +180,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allStrikethrough, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('App~~Flowy~~ to strikethrough AppFlowy', (tester) async {
@@ -203,11 +203,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 3,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allStrikethrough, true);
-        expect(textNode.toRawString(), 'AppFlowy');
+        expect(textNode.toPlainText(), 'AppFlowy');
       });
 
       testWidgets('~~~AppFlowy~~ to bold ~AppFlowy', (tester) async {
@@ -226,11 +226,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 1,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allStrikethrough, true);
-        expect(textNode.toRawString(), '~AppFlowy');
+        expect(textNode.toPlainText(), '~AppFlowy');
       });
 
       testWidgets('~~~~ nothing changes', (tester) async {
@@ -249,11 +249,11 @@ void main() async {
           Selection.single(
             path: [0],
             startOffset: 0,
-            endOffset: textNode.toRawString().length,
+            endOffset: textNode.toPlainText().length,
           ),
         );
         expect(allStrikethrough, false);
-        expect(textNode.toRawString(), text);
+        expect(textNode.toPlainText(), text);
       });
     });
   });

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart

@@ -56,7 +56,7 @@ Future<void> _testBackspaceUndoRedo(
   }
 
   expect(editor.documentLength, 3);
-  expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
+  expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text);
   expect(editor.documentSelection, selection);
 
   if (Platform.isWindows || Platform.isLinux) {

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart

@@ -26,7 +26,7 @@ void main() async {
         );
         await editor.pressLogicKey(LogicalKeyboardKey.space);
         expect(
-          (editor.nodeAtPath([i]) as TextNode).toRawString(),
+          (editor.nodeAtPath([i]) as TextNode).toPlainText(),
           'W elcome to Appflowy 😁',
         );
       }
@@ -36,7 +36,7 @@ void main() async {
         );
         await editor.pressLogicKey(LogicalKeyboardKey.space);
         expect(
-          (editor.nodeAtPath([i]) as TextNode).toRawString(),
+          (editor.nodeAtPath([i]) as TextNode).toPlainText(),
           'W elcome to Appflowy 😁 ',
         );
       }

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart

@@ -87,7 +87,7 @@ void main() async {
         expect(textNode.subtype, BuiltInAttributeKey.heading);
         // BuiltInAttributeKey.h1 ~ BuiltInAttributeKey.h6
         expect(textNode.attributes.heading, 'h$i');
-        expect(textNode.toRawString().startsWith('##'), true);
+        expect(textNode.toPlainText().startsWith('##'), true);
       }
     });
 
@@ -211,7 +211,7 @@ void main() async {
       await editor.pressLogicKey(LogicalKeyboardKey.space);
       expect(textNode.subtype, BuiltInAttributeKey.checkbox);
       expect(textNode.attributes.check, true);
-      expect(textNode.toRawString(), insertedText);
+      expect(textNode.toPlainText(), insertedText);
     });
   });
 }

+ 4 - 4
frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart

@@ -63,9 +63,9 @@ void main() async {
         ..insertTextNode(text)
         ..insertTextNode(
           null,
-          delta: Delta([
+          delta: Delta(operations: [
             TextInsert(text),
-            TextInsert(text, attributes),
+            TextInsert(text, attributes: attributes),
             TextInsert(text),
           ]),
         );
@@ -171,8 +171,8 @@ void main() async {
             BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
             BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
           },
-          delta: Delta([
-            TextInsert(text, {
+          delta: Delta(operations: [
+            TextInsert(text, attributes: {
               BuiltInAttributeKey.bold: true,
             })
           ]),