فهرست منبع

[flutter]: add flutter_quill for test

appflowy 3 سال پیش
والد
کامیت
2193879b5f
100فایلهای تغییر یافته به همراه11466 افزوده شده و 11 حذف شده
  1. 52 0
      app_flowy/ios/Podfile.lock
  2. 68 0
      app_flowy/ios/Runner.xcodeproj/project.pbxproj
  3. 3 0
      app_flowy/ios/Runner.xcworkspace/contents.xcworkspacedata
  4. 2 2
      app_flowy/lib/workspace/domain/i_doc.dart
  5. 86 9
      app_flowy/lib/workspace/presentation/doc/doc_page.dart
  6. 2 0
      app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift
  7. 6 0
      app_flowy/macos/Podfile.lock
  8. 29 0
      app_flowy/packages/editor/.gitignore
  9. 10 0
      app_flowy/packages/editor/.metadata
  10. 3 0
      app_flowy/packages/editor/CHANGELOG.md
  11. 1 0
      app_flowy/packages/editor/LICENSE
  12. 15 0
      app_flowy/packages/editor/README.md
  13. 4 0
      app_flowy/packages/editor/analysis_options.yaml
  14. 46 0
      app_flowy/packages/editor/example/.gitignore
  15. 10 0
      app_flowy/packages/editor/example/.metadata
  16. 16 0
      app_flowy/packages/editor/example/README.md
  17. 29 0
      app_flowy/packages/editor/example/analysis_options.yaml
  18. 38 0
      app_flowy/packages/editor/example/lib/main.dart
  19. 7 0
      app_flowy/packages/editor/example/macos/.gitignore
  20. 2 0
      app_flowy/packages/editor/example/macos/Flutter/Flutter-Debug.xcconfig
  21. 2 0
      app_flowy/packages/editor/example/macos/Flutter/Flutter-Release.xcconfig
  22. 14 0
      app_flowy/packages/editor/example/macos/Flutter/GeneratedPluginRegistrant.swift
  23. 40 0
      app_flowy/packages/editor/example/macos/Podfile
  24. 572 0
      app_flowy/packages/editor/example/macos/Runner.xcodeproj/project.pbxproj
  25. 8 0
      app_flowy/packages/editor/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  26. 87 0
      app_flowy/packages/editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  27. 7 0
      app_flowy/packages/editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata
  28. 8 0
      app_flowy/packages/editor/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  29. 9 0
      app_flowy/packages/editor/example/macos/Runner/AppDelegate.swift
  30. 68 0
      app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
  31. BIN
      app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png
  32. BIN
      app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png
  33. BIN
      app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png
  34. BIN
      app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png
  35. BIN
      app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png
  36. BIN
      app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png
  37. BIN
      app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png
  38. 339 0
      app_flowy/packages/editor/example/macos/Runner/Base.lproj/MainMenu.xib
  39. 14 0
      app_flowy/packages/editor/example/macos/Runner/Configs/AppInfo.xcconfig
  40. 2 0
      app_flowy/packages/editor/example/macos/Runner/Configs/Debug.xcconfig
  41. 2 0
      app_flowy/packages/editor/example/macos/Runner/Configs/Release.xcconfig
  42. 13 0
      app_flowy/packages/editor/example/macos/Runner/Configs/Warnings.xcconfig
  43. 12 0
      app_flowy/packages/editor/example/macos/Runner/DebugProfile.entitlements
  44. 32 0
      app_flowy/packages/editor/example/macos/Runner/Info.plist
  45. 15 0
      app_flowy/packages/editor/example/macos/Runner/MainFlutterWindow.swift
  46. 8 0
      app_flowy/packages/editor/example/macos/Runner/Release.entitlements
  47. 404 0
      app_flowy/packages/editor/example/pubspec.lock
  48. 84 0
      app_flowy/packages/editor/example/pubspec.yaml
  49. 11 0
      app_flowy/packages/editor/example/test/widget_test.dart
  50. 11 0
      app_flowy/packages/editor/lib/flutter_quill.dart
  51. 3 0
      app_flowy/packages/editor/lib/models/documents/attribute.dart
  52. 3 0
      app_flowy/packages/editor/lib/models/documents/document.dart
  53. 3 0
      app_flowy/packages/editor/lib/models/documents/history.dart
  54. 3 0
      app_flowy/packages/editor/lib/models/documents/nodes/block.dart
  55. 3 0
      app_flowy/packages/editor/lib/models/documents/nodes/container.dart
  56. 3 0
      app_flowy/packages/editor/lib/models/documents/nodes/embed.dart
  57. 3 0
      app_flowy/packages/editor/lib/models/documents/nodes/leaf.dart
  58. 3 0
      app_flowy/packages/editor/lib/models/documents/nodes/line.dart
  59. 3 0
      app_flowy/packages/editor/lib/models/documents/nodes/node.dart
  60. 3 0
      app_flowy/packages/editor/lib/models/documents/style.dart
  61. 3 0
      app_flowy/packages/editor/lib/models/quill_delta.dart
  62. 3 0
      app_flowy/packages/editor/lib/models/rules/delete.dart
  63. 3 0
      app_flowy/packages/editor/lib/models/rules/format.dart
  64. 3 0
      app_flowy/packages/editor/lib/models/rules/insert.dart
  65. 3 0
      app_flowy/packages/editor/lib/models/rules/rule.dart
  66. 314 0
      app_flowy/packages/editor/lib/src/models/documents/attribute.dart
  67. 291 0
      app_flowy/packages/editor/lib/src/models/documents/document.dart
  68. 134 0
      app_flowy/packages/editor/lib/src/models/documents/history.dart
  69. 72 0
      app_flowy/packages/editor/lib/src/models/documents/nodes/block.dart
  70. 160 0
      app_flowy/packages/editor/lib/src/models/documents/nodes/container.dart
  71. 45 0
      app_flowy/packages/editor/lib/src/models/documents/nodes/embed.dart
  72. 252 0
      app_flowy/packages/editor/lib/src/models/documents/nodes/leaf.dart
  73. 414 0
      app_flowy/packages/editor/lib/src/models/documents/nodes/line.dart
  74. 134 0
      app_flowy/packages/editor/lib/src/models/documents/nodes/node.dart
  75. 128 0
      app_flowy/packages/editor/lib/src/models/documents/style.dart
  76. 803 0
      app_flowy/packages/editor/lib/src/models/quill_delta.dart
  77. 126 0
      app_flowy/packages/editor/lib/src/models/rules/delete.dart
  78. 161 0
      app_flowy/packages/editor/lib/src/models/rules/format.dart
  79. 385 0
      app_flowy/packages/editor/lib/src/models/rules/insert.dart
  80. 76 0
      app_flowy/packages/editor/lib/src/models/rules/rule.dart
  81. 125 0
      app_flowy/packages/editor/lib/src/utils/color.dart
  82. 103 0
      app_flowy/packages/editor/lib/src/utils/diff_delta.dart
  83. 4 0
      app_flowy/packages/editor/lib/src/utils/media_pick_setting.dart
  84. 16 0
      app_flowy/packages/editor/lib/src/utils/string_helper.dart
  85. 122 0
      app_flowy/packages/editor/lib/src/widgets/box.dart
  86. 255 0
      app_flowy/packages/editor/lib/src/widgets/controller.dart
  87. 341 0
      app_flowy/packages/editor/lib/src/widgets/cursor.dart
  88. 235 0
      app_flowy/packages/editor/lib/src/widgets/default_styles.dart
  89. 152 0
      app_flowy/packages/editor/lib/src/widgets/delegate.dart
  90. 1289 0
      app_flowy/packages/editor/lib/src/widgets/editor.dart
  91. 31 0
      app_flowy/packages/editor/lib/src/widgets/image.dart
  92. 129 0
      app_flowy/packages/editor/lib/src/widgets/keyboard_listener.dart
  93. 39 0
      app_flowy/packages/editor/lib/src/widgets/link_dialog.dart
  94. 303 0
      app_flowy/packages/editor/lib/src/widgets/proxy.dart
  95. 764 0
      app_flowy/packages/editor/lib/src/widgets/raw_editor.dart
  96. 367 0
      app_flowy/packages/editor/lib/src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart
  97. 122 0
      app_flowy/packages/editor/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart
  98. 204 0
      app_flowy/packages/editor/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart
  99. 362 0
      app_flowy/packages/editor/lib/src/widgets/simple_viewer.dart
  100. 772 0
      app_flowy/packages/editor/lib/src/widgets/text_block.dart

+ 52 - 0
app_flowy/ios/Podfile.lock

@@ -0,0 +1,52 @@
+PODS:
+  - flowy_editor (0.0.1):
+    - Flutter
+  - flowy_infra_ui (0.0.1):
+    - Flutter
+  - flowy_sdk (0.0.1):
+    - Flutter
+  - Flutter (1.0.0)
+  - flutter_keyboard_visibility (0.0.1):
+    - Flutter
+  - path_provider (0.0.1):
+    - Flutter
+  - url_launcher (0.0.1):
+    - Flutter
+
+DEPENDENCIES:
+  - flowy_editor (from `.symlinks/plugins/flowy_editor/ios`)
+  - flowy_infra_ui (from `.symlinks/plugins/flowy_infra_ui/ios`)
+  - flowy_sdk (from `.symlinks/plugins/flowy_sdk/ios`)
+  - Flutter (from `Flutter`)
+  - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
+  - path_provider (from `.symlinks/plugins/path_provider/ios`)
+  - url_launcher (from `.symlinks/plugins/url_launcher/ios`)
+
+EXTERNAL SOURCES:
+  flowy_editor:
+    :path: ".symlinks/plugins/flowy_editor/ios"
+  flowy_infra_ui:
+    :path: ".symlinks/plugins/flowy_infra_ui/ios"
+  flowy_sdk:
+    :path: ".symlinks/plugins/flowy_sdk/ios"
+  Flutter:
+    :path: Flutter
+  flutter_keyboard_visibility:
+    :path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
+  path_provider:
+    :path: ".symlinks/plugins/path_provider/ios"
+  url_launcher:
+    :path: ".symlinks/plugins/url_launcher/ios"
+
+SPEC CHECKSUMS:
+  flowy_editor: bf8d58894ddb03453bd4d8521c57267ad638b837
+  flowy_infra_ui: 146c88346fd55d2ee6a41ae35059a5bf095cfbb3
+  flowy_sdk: c416222c639e678828776789bf0c1a1d0d59df3c
+  Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
+  flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
+  path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c
+  url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef
+
+PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
+
+COCOAPODS: 1.10.1

+ 68 - 0
app_flowy/ios/Runner.xcodeproj/project.pbxproj

@@ -13,6 +13,7 @@
 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
 		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+		9D1D47ADD7F5DE8237063BCA /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 197F72694BED43249F1523E8 /* Pods_Runner.framework */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXCopyFilesBuildPhase section */
@@ -31,7 +32,11 @@
 /* Begin PBXFileReference section */
 		1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
 		1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
+		197F72694BED43249F1523E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
 		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
+		4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
+		580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
 		74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
 		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
 		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -49,12 +54,21 @@
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				9D1D47ADD7F5DE8237063BCA /* Pods_Runner.framework in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
+		78844014EF958DCBB6F9B4EA /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				197F72694BED43249F1523E8 /* Pods_Runner.framework */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
 		9740EEB11CF90186004384FC /* Flutter */ = {
 			isa = PBXGroup;
 			children = (
@@ -72,6 +86,8 @@
 				9740EEB11CF90186004384FC /* Flutter */,
 				97C146F01CF9000F007C117D /* Runner */,
 				97C146EF1CF9000F007C117D /* Products */,
+				9EC83BEE9154F1BD11D24F8F /* Pods */,
+				78844014EF958DCBB6F9B4EA /* Frameworks */,
 			);
 			sourceTree = "<group>";
 		};
@@ -98,6 +114,17 @@
 			path = Runner;
 			sourceTree = "<group>";
 		};
+		9EC83BEE9154F1BD11D24F8F /* Pods */ = {
+			isa = PBXGroup;
+			children = (
+				35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */,
+				580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */,
+				4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */,
+			);
+			name = Pods;
+			path = Pods;
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -105,12 +132,14 @@
 			isa = PBXNativeTarget;
 			buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
 			buildPhases = (
+				E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */,
 				9740EEB61CF901F6004384FC /* Run Script */,
 				97C146EA1CF9000F007C117D /* Sources */,
 				97C146EB1CF9000F007C117D /* Frameworks */,
 				97C146EC1CF9000F007C117D /* Resources */,
 				9705A1C41CF9048500538489 /* Embed Frameworks */,
 				3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+				08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */,
 			);
 			buildRules = (
 			);
@@ -169,6 +198,23 @@
 /* End PBXResourcesBuildPhase section */
 
 /* Begin PBXShellScriptBuildPhase section */
+		08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
 		3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
 			isa = PBXShellScriptBuildPhase;
 			buildActionMask = 2147483647;
@@ -197,6 +243,28 @@
 			shellPath = /bin/sh;
 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
 		};
+		E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
 /* End PBXShellScriptBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */

+ 3 - 0
app_flowy/ios/Runner.xcworkspace/contents.xcworkspacedata

@@ -4,4 +4,7 @@
    <FileRef
       location = "group:Runner.xcodeproj">
    </FileRef>
+   <FileRef
+      location = "group:Pods/Pods.xcodeproj">
+   </FileRef>
 </Workspace>

+ 2 - 2
app_flowy/lib/workspace/domain/i_doc.dart

@@ -1,9 +1,9 @@
 import 'dart:convert';
 import 'dart:async';
-import 'package:flowy_editor/flowy_editor.dart';
 import 'package:dartz/dartz.dart';
 // ignore: implementation_imports
-import 'package:flowy_editor/src/model/quill_delta.dart';
+import 'package:editor/flutter_quill.dart';
+// import 'package:flowy_editor/flowy_editor.dart';
 import 'package:flowy_log/flowy_log.dart';
 import 'package:flowy_sdk/protobuf/flowy-document/doc.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-workspace/errors.pb.dart';

+ 86 - 9
app_flowy/lib/workspace/presentation/doc/doc_page.dart

@@ -3,19 +3,19 @@ import 'dart:io';
 import 'package:app_flowy/startup/startup.dart';
 import 'package:app_flowy/workspace/application/doc/doc_edit_bloc.dart';
 import 'package:app_flowy/workspace/domain/i_doc.dart';
-import 'package:flowy_editor/flowy_editor.dart';
+// import 'package:flowy_editor/flowy_editor.dart';
+import 'package:editor/flutter_quill.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
-// ignore: must_be_immutable
 class DocPage extends StatefulWidget {
-  late EditorController controller;
+  late QuillController controller;
   late DocEditBloc editBloc;
   final FlowyDoc doc;
 
   DocPage({Key? key, required this.doc}) : super(key: key) {
     editBloc = getIt<DocEditBloc>(param1: doc.id);
-    controller = EditorController(
+    controller = QuillController(
       document: doc.document,
       selection: const TextSelection.collapsed(offset: 0),
     );
@@ -54,8 +54,8 @@ class _DocPageState extends State<DocPage> {
     await widget.doc.close();
   }
 
-  Widget _renderEditor(EditorController controller) {
-    final editor = FlowyEditor(
+  Widget _renderEditor(QuillController controller) {
+    final editor = QuillEditor(
       controller: controller,
       focusNode: _focusNode,
       scrollable: true,
@@ -71,10 +71,9 @@ class _DocPageState extends State<DocPage> {
     );
   }
 
-  Widget _renderToolbar(EditorController controller) {
-    return FlowyToolbar.basic(
+  Widget _renderToolbar(QuillController controller) {
+    return QuillToolbar.basic(
       controller: controller,
-      onImageSelectCallback: _onImageSelection,
     );
   }
 
@@ -82,3 +81,81 @@ class _DocPageState extends State<DocPage> {
     throw UnimplementedError();
   }
 }
+
+// import 'package:flowy_editor/flowy_editor.dart';
+
+// ignore: must_be_immutable
+// class DocPage extends StatefulWidget {
+//   late EditorController controller;
+//   late DocEditBloc editBloc;
+//   final FlowyDoc doc;
+
+//   DocPage({Key? key, required this.doc}) : super(key: key) {
+//     editBloc = getIt<DocEditBloc>(param1: doc.id);
+//     controller = EditorController(
+//       document: doc.document,
+//       selection: const TextSelection.collapsed(offset: 0),
+//     );
+//   }
+
+//   @override
+//   State<DocPage> createState() => _DocPageState();
+// }
+
+// class _DocPageState extends State<DocPage> {
+//   final FocusNode _focusNode = FocusNode();
+
+//   @override
+//   Widget build(BuildContext context) {
+//     return BlocProvider.value(
+//       value: widget.editBloc,
+//       child: BlocBuilder<DocEditBloc, DocEditState>(
+//         builder: (ctx, state) {
+//           return Column(
+//             mainAxisAlignment: MainAxisAlignment.spaceBetween,
+//             children: [
+//               _renderEditor(widget.controller),
+//               _renderToolbar(widget.controller),
+//             ],
+//           );
+//         },
+//       ),
+//     );
+//   }
+
+//   @override
+//   Future<void> dispose() async {
+//     widget.editBloc.add(const DocEditEvent.close());
+//     widget.editBloc.close();
+//     super.dispose();
+//     await widget.doc.close();
+//   }
+
+//   Widget _renderEditor(EditorController controller) {
+//     final editor = FlowyEditor(
+//       controller: controller,
+//       focusNode: _focusNode,
+//       scrollable: true,
+//       autoFocus: false,
+//       expands: false,
+//       padding: const EdgeInsets.symmetric(horizontal: 8.0),
+//       readOnly: false,
+//       scrollBottomInset: 0,
+//       scrollController: ScrollController(),
+//     );
+//     return Expanded(
+//       child: Padding(padding: const EdgeInsets.all(10), child: editor),
+//     );
+//   }
+
+//   Widget _renderToolbar(EditorController controller) {
+//     return FlowyToolbar.basic(
+//       controller: controller,
+//       onImageSelectCallback: _onImageSelection,
+//     );
+//   }
+
+//   Future<String> _onImageSelection(File file) {
+//     throw UnimplementedError();
+//   }
+// }

+ 2 - 0
app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift

@@ -5,6 +5,7 @@
 import FlutterMacOS
 import Foundation
 
+import editor
 import flowy_editor
 import flowy_infra_ui
 import flowy_sdk
@@ -13,6 +14,7 @@ import url_launcher_macos
 import window_size
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+  EditorPlugin.register(with: registry.registrar(forPlugin: "EditorPlugin"))
   FlowyEditorPlugin.register(with: registry.registrar(forPlugin: "FlowyEditorPlugin"))
   FlowyInfraUIPlugin.register(with: registry.registrar(forPlugin: "FlowyInfraUIPlugin"))
   FlowySdkPlugin.register(with: registry.registrar(forPlugin: "FlowySdkPlugin"))

+ 6 - 0
app_flowy/macos/Podfile.lock

@@ -1,4 +1,6 @@
 PODS:
+  - editor (0.0.1):
+    - FlutterMacOS
   - flowy_editor (0.0.1):
     - FlutterMacOS
   - flowy_infra_ui (0.0.1):
@@ -14,6 +16,7 @@ PODS:
     - FlutterMacOS
 
 DEPENDENCIES:
+  - editor (from `Flutter/ephemeral/.symlinks/plugins/editor/macos`)
   - flowy_editor (from `Flutter/ephemeral/.symlinks/plugins/flowy_editor/macos`)
   - flowy_infra_ui (from `Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos`)
   - flowy_sdk (from `Flutter/ephemeral/.symlinks/plugins/flowy_sdk/macos`)
@@ -23,6 +26,8 @@ DEPENDENCIES:
   - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`)
 
 EXTERNAL SOURCES:
+  editor:
+    :path: Flutter/ephemeral/.symlinks/plugins/editor/macos
   flowy_editor:
     :path: Flutter/ephemeral/.symlinks/plugins/flowy_editor/macos
   flowy_infra_ui:
@@ -39,6 +44,7 @@ EXTERNAL SOURCES:
     :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos
 
 SPEC CHECKSUMS:
+  editor: 380351c0334fbeb0e431e4e49629c9e2d925b66d
   flowy_editor: 26060a984848e6afac1f6a4455511f4114119d8d
   flowy_infra_ui: 9d5021b1610fe0476eb1191bf7cd41c4a4138d8f
   flowy_sdk: c302ac0a22dea596db0df8073b9637b2bf2ff6fd

+ 29 - 0
app_flowy/packages/editor/.gitignore

@@ -0,0 +1,29 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
+/pubspec.lock
+**/doc/api/
+.dart_tool/
+.packages
+build/

+ 10 - 0
app_flowy/packages/editor/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 4b330ddbedab445481cc73d50a4695b9154b4e4f
+  channel: dev
+
+project_type: plugin

+ 3 - 0
app_flowy/packages/editor/CHANGELOG.md

@@ -0,0 +1,3 @@
+## 0.0.1
+
+* TODO: Describe initial release.

+ 1 - 0
app_flowy/packages/editor/LICENSE

@@ -0,0 +1 @@
+TODO: Add your license here.

+ 15 - 0
app_flowy/packages/editor/README.md

@@ -0,0 +1,15 @@
+# editor
+
+A new flutter plugin project.
+
+## Getting Started
+
+This project is a starting point for a Flutter
+[plug-in package](https://flutter.dev/developing-packages/),
+a specialized package that includes platform-specific implementation code for
+Android and/or iOS.
+
+For help getting started with Flutter, view our
+[online documentation](https://flutter.dev/docs), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
+

+ 4 - 0
app_flowy/packages/editor/analysis_options.yaml

@@ -0,0 +1,4 @@
+include: package:flutter_lints/flutter.yaml
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options

+ 46 - 0
app_flowy/packages/editor/example/.gitignore

@@ -0,0 +1,46 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+/build/
+
+# Web related
+lib/generated_plugin_registrant.dart
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release

+ 10 - 0
app_flowy/packages/editor/example/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 4b330ddbedab445481cc73d50a4695b9154b4e4f
+  channel: dev
+
+project_type: app

+ 16 - 0
app_flowy/packages/editor/example/README.md

@@ -0,0 +1,16 @@
+# editor_example
+
+Demonstrates how to use the editor plugin.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab)
+- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook)
+
+For help getting started with Flutter, view our
+[online documentation](https://flutter.dev/docs), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.

+ 29 - 0
app_flowy/packages/editor/example/analysis_options.yaml

@@ -0,0 +1,29 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+  # The lint rules applied to this project can be customized in the
+  # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+  # included above or to enable additional rules. A list of all available lints
+  # and their documentation is published at
+  # https://dart-lang.github.io/linter/lints/index.html.
+  #
+  # Instead of disabling a lint rule for the entire project in the
+  # section below, it can also be suppressed for a single line of code
+  # or a specific dart file by using the `// ignore: name_of_lint` and
+  # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+  # producing the lint.
+  rules:
+    # avoid_print: false  # Uncomment to disable the `avoid_print` rule
+    # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options

+ 38 - 0
app_flowy/packages/editor/example/lib/main.dart

@@ -0,0 +1,38 @@
+import 'package:flutter/material.dart';
+import 'dart:async';
+
+import 'package:flutter/services.dart';
+
+void main() {
+  runApp(const MyApp());
+}
+
+class MyApp extends StatefulWidget {
+  const MyApp({Key? key}) : super(key: key);
+
+  @override
+  State<MyApp> createState() => _MyAppState();
+}
+
+class _MyAppState extends State<MyApp> {
+  String _platformVersion = 'Unknown';
+
+  @override
+  void initState() {
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      home: Scaffold(
+        appBar: AppBar(
+          title: const Text('Plugin example app'),
+        ),
+        body: Center(
+          child: Text('Running on: $_platformVersion\n'),
+        ),
+      ),
+    );
+  }
+}

+ 7 - 0
app_flowy/packages/editor/example/macos/.gitignore

@@ -0,0 +1,7 @@
+# Flutter-related
+**/Flutter/ephemeral/
+**/Pods/
+
+# Xcode-related
+**/dgph
+**/xcuserdata/

+ 2 - 0
app_flowy/packages/editor/example/macos/Flutter/Flutter-Debug.xcconfig

@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "ephemeral/Flutter-Generated.xcconfig"

+ 2 - 0
app_flowy/packages/editor/example/macos/Flutter/Flutter-Release.xcconfig

@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "ephemeral/Flutter-Generated.xcconfig"

+ 14 - 0
app_flowy/packages/editor/example/macos/Flutter/GeneratedPluginRegistrant.swift

@@ -0,0 +1,14 @@
+//
+//  Generated file. Do not edit.
+//
+
+import FlutterMacOS
+import Foundation
+
+import editor
+import url_launcher_macos
+
+func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+  EditorPlugin.register(with: registry.registrar(forPlugin: "EditorPlugin"))
+  UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
+}

+ 40 - 0
app_flowy/packages/editor/example/macos/Podfile

@@ -0,0 +1,40 @@
+platform :osx, '10.11'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+  'Debug' => :debug,
+  'Profile' => :release,
+  'Release' => :release,
+}
+
+def flutter_root
+  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
+  unless File.exist?(generated_xcode_build_settings_path)
+    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
+  end
+
+  File.foreach(generated_xcode_build_settings_path) do |line|
+    matches = line.match(/FLUTTER_ROOT\=(.*)/)
+    return matches[1].strip if matches
+  end
+  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_macos_podfile_setup
+
+target 'Runner' do
+  use_frameworks!
+  use_modular_headers!
+
+  flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
+end
+
+post_install do |installer|
+  installer.pods_project.targets.each do |target|
+    flutter_additional_macos_build_settings(target)
+  end
+end

+ 572 - 0
app_flowy/packages/editor/example/macos/Runner.xcodeproj/project.pbxproj

@@ -0,0 +1,572 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 51;
+	objects = {
+
+/* Begin PBXAggregateTarget section */
+		33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
+			isa = PBXAggregateTarget;
+			buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
+			buildPhases = (
+				33CC111E2044C6BF0003C045 /* ShellScript */,
+			);
+			dependencies = (
+			);
+			name = "Flutter Assemble";
+			productName = FLX;
+		};
+/* End PBXAggregateTarget section */
+
+/* Begin PBXBuildFile section */
+		335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
+		33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
+		33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
+		33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
+		33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+		33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 33CC111A2044C6BA0003C045;
+			remoteInfo = FLX;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+		33CC110E2044A8840003C045 /* Bundle Framework */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 10;
+			files = (
+			);
+			name = "Bundle Framework";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+		333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
+		335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
+		33CC10ED2044A3C60003C045 /* editor_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "editor_example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+		33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
+		33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
+		33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
+		33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
+		33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
+		33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
+		33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
+		33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
+		33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
+		33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
+		33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
+		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
+		9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		33CC10EA2044A3C60003C045 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+		33BA886A226E78AF003329D5 /* Configs */ = {
+			isa = PBXGroup;
+			children = (
+				33E5194F232828860026EE4D /* AppInfo.xcconfig */,
+				9740EEB21CF90195004384FC /* Debug.xcconfig */,
+				7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+				333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
+			);
+			path = Configs;
+			sourceTree = "<group>";
+		};
+		33CC10E42044A3C60003C045 = {
+			isa = PBXGroup;
+			children = (
+				33FAB671232836740065AC1E /* Runner */,
+				33CEB47122A05771004F2AC0 /* Flutter */,
+				33CC10EE2044A3C60003C045 /* Products */,
+				D73912EC22F37F3D000D13A0 /* Frameworks */,
+			);
+			sourceTree = "<group>";
+		};
+		33CC10EE2044A3C60003C045 /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				33CC10ED2044A3C60003C045 /* editor_example.app */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		33CC11242044D66E0003C045 /* Resources */ = {
+			isa = PBXGroup;
+			children = (
+				33CC10F22044A3C60003C045 /* Assets.xcassets */,
+				33CC10F42044A3C60003C045 /* MainMenu.xib */,
+				33CC10F72044A3C60003C045 /* Info.plist */,
+			);
+			name = Resources;
+			path = ..;
+			sourceTree = "<group>";
+		};
+		33CEB47122A05771004F2AC0 /* Flutter */ = {
+			isa = PBXGroup;
+			children = (
+				335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
+				33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
+				33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
+				33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
+			);
+			path = Flutter;
+			sourceTree = "<group>";
+		};
+		33FAB671232836740065AC1E /* Runner */ = {
+			isa = PBXGroup;
+			children = (
+				33CC10F02044A3C60003C045 /* AppDelegate.swift */,
+				33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
+				33E51913231747F40026EE4D /* DebugProfile.entitlements */,
+				33E51914231749380026EE4D /* Release.entitlements */,
+				33CC11242044D66E0003C045 /* Resources */,
+				33BA886A226E78AF003329D5 /* Configs */,
+			);
+			path = Runner;
+			sourceTree = "<group>";
+		};
+		D73912EC22F37F3D000D13A0 /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		33CC10EC2044A3C60003C045 /* Runner */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
+			buildPhases = (
+				33CC10E92044A3C60003C045 /* Sources */,
+				33CC10EA2044A3C60003C045 /* Frameworks */,
+				33CC10EB2044A3C60003C045 /* Resources */,
+				33CC110E2044A8840003C045 /* Bundle Framework */,
+				3399D490228B24CF009A79C7 /* ShellScript */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				33CC11202044C79F0003C045 /* PBXTargetDependency */,
+			);
+			name = Runner;
+			productName = Runner;
+			productReference = 33CC10ED2044A3C60003C045 /* editor_example.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		33CC10E52044A3C60003C045 /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				LastSwiftUpdateCheck = 0920;
+				LastUpgradeCheck = 1300;
+				ORGANIZATIONNAME = "";
+				TargetAttributes = {
+					33CC10EC2044A3C60003C045 = {
+						CreatedOnToolsVersion = 9.2;
+						LastSwiftMigration = 1100;
+						ProvisioningStyle = Automatic;
+						SystemCapabilities = {
+							com.apple.Sandbox = {
+								enabled = 1;
+							};
+						};
+					};
+					33CC111A2044C6BA0003C045 = {
+						CreatedOnToolsVersion = 9.2;
+						ProvisioningStyle = Manual;
+					};
+				};
+			};
+			buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
+			compatibilityVersion = "Xcode 9.3";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+				Base,
+			);
+			mainGroup = 33CC10E42044A3C60003C045;
+			productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				33CC10EC2044A3C60003C045 /* Runner */,
+				33CC111A2044C6BA0003C045 /* Flutter Assemble */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		33CC10EB2044A3C60003C045 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
+				33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		3399D490228B24CF009A79C7 /* ShellScript */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+			);
+			outputFileListPaths = (
+			);
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
+		};
+		33CC111E2044C6BF0003C045 /* ShellScript */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				Flutter/ephemeral/FlutterInputs.xcfilelist,
+			);
+			inputPaths = (
+				Flutter/ephemeral/tripwire,
+			);
+			outputFileListPaths = (
+				Flutter/ephemeral/FlutterOutputs.xcfilelist,
+			);
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		33CC10E92044A3C60003C045 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
+				33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
+				335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+		33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
+			targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+		33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
+			isa = PBXVariantGroup;
+			children = (
+				33CC10F52044A3C60003C045 /* Base */,
+			);
+			name = MainMenu.xib;
+			path = Runner;
+			sourceTree = "<group>";
+		};
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+		338D0CE9231458BD00FA5F75 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CODE_SIGN_IDENTITY = "-";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 10.11;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				SDKROOT = macosx;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+			};
+			name = Profile;
+		};
+		338D0CEA231458BD00FA5F75 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				COMBINE_HIDPI_IMAGES = YES;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+				);
+				PROVISIONING_PROFILE_SPECIFIER = "";
+				SWIFT_VERSION = 5.0;
+			};
+			name = Profile;
+		};
+		338D0CEB231458BD00FA5F75 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CODE_SIGN_STYLE = Manual;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+			};
+			name = Profile;
+		};
+		33CC10F92044A3C60003C045 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CODE_SIGN_IDENTITY = "-";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = dwarf;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_TESTABILITY = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"DEBUG=1",
+					"$(inherited)",
+				);
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 10.11;
+				MTL_ENABLE_DEBUG_INFO = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				SDKROOT = macosx;
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+			};
+			name = Debug;
+		};
+		33CC10FA2044A3C60003C045 /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CODE_SIGN_IDENTITY = "-";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 10.11;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				SDKROOT = macosx;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+			};
+			name = Release;
+		};
+		33CC10FC2044A3C60003C045 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				COMBINE_HIDPI_IMAGES = YES;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+				);
+				PROVISIONING_PROFILE_SPECIFIER = "";
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 5.0;
+			};
+			name = Debug;
+		};
+		33CC10FD2044A3C60003C045 /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				COMBINE_HIDPI_IMAGES = YES;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+				);
+				PROVISIONING_PROFILE_SPECIFIER = "";
+				SWIFT_VERSION = 5.0;
+			};
+			name = Release;
+		};
+		33CC111C2044C6BA0003C045 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CODE_SIGN_STYLE = Manual;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+			};
+			name = Debug;
+		};
+		33CC111D2044C6BA0003C045 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CODE_SIGN_STYLE = Automatic;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				33CC10F92044A3C60003C045 /* Debug */,
+				33CC10FA2044A3C60003C045 /* Release */,
+				338D0CE9231458BD00FA5F75 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				33CC10FC2044A3C60003C045 /* Debug */,
+				33CC10FD2044A3C60003C045 /* Release */,
+				338D0CEA231458BD00FA5F75 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				33CC111C2044C6BA0003C045 /* Debug */,
+				33CC111D2044C6BA0003C045 /* Release */,
+				338D0CEB231458BD00FA5F75 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = 33CC10E52044A3C60003C045 /* Project object */;
+}

+ 8 - 0
app_flowy/packages/editor/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>IDEDidComputeMac32BitWarning</key>
+	<true/>
+</dict>
+</plist>

+ 87 - 0
app_flowy/packages/editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme

@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1300"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "33CC10EC2044A3C60003C045"
+               BuildableName = "editor_example.app"
+               BlueprintName = "Runner"
+               ReferencedContainer = "container:Runner.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "33CC10EC2044A3C60003C045"
+            BuildableName = "editor_example.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <Testables>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "33CC10EC2044A3C60003C045"
+            BuildableName = "editor_example.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Profile"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "33CC10EC2044A3C60003C045"
+            BuildableName = "editor_example.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 7 - 0
app_flowy/packages/editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "group:Runner.xcodeproj">
+   </FileRef>
+</Workspace>

+ 8 - 0
app_flowy/packages/editor/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>IDEDidComputeMac32BitWarning</key>
+	<true/>
+</dict>
+</plist>

+ 9 - 0
app_flowy/packages/editor/example/macos/Runner/AppDelegate.swift

@@ -0,0 +1,9 @@
+import Cocoa
+import FlutterMacOS
+
+@NSApplicationMain
+class AppDelegate: FlutterAppDelegate {
+  override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+    return true
+  }
+}

+ 68 - 0
app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,68 @@
+{
+  "images" : [
+    {
+      "size" : "16x16",
+      "idiom" : "mac",
+      "filename" : "app_icon_16.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "16x16",
+      "idiom" : "mac",
+      "filename" : "app_icon_32.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "32x32",
+      "idiom" : "mac",
+      "filename" : "app_icon_32.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "32x32",
+      "idiom" : "mac",
+      "filename" : "app_icon_64.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "128x128",
+      "idiom" : "mac",
+      "filename" : "app_icon_128.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "128x128",
+      "idiom" : "mac",
+      "filename" : "app_icon_256.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "256x256",
+      "idiom" : "mac",
+      "filename" : "app_icon_256.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "256x256",
+      "idiom" : "mac",
+      "filename" : "app_icon_512.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "512x512",
+      "idiom" : "mac",
+      "filename" : "app_icon_512.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "512x512",
+      "idiom" : "mac",
+      "filename" : "app_icon_1024.png",
+      "scale" : "2x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

BIN
app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png


BIN
app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png


BIN
app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png


BIN
app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png


BIN
app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png


BIN
app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png


BIN
app_flowy/packages/editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png


+ 339 - 0
app_flowy/packages/editor/example/macos/Runner/Base.lproj/MainMenu.xib

@@ -0,0 +1,339 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
+    <dependencies>
+        <deployment identifier="macosx"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
+        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+    </dependencies>
+    <objects>
+        <customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
+            <connections>
+                <outlet property="delegate" destination="Voe-Tx-rLC" id="GzC-gU-4Uq"/>
+            </connections>
+        </customObject>
+        <customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
+        <customObject id="-3" userLabel="Application" customClass="NSObject"/>
+        <customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="Runner" customModuleProvider="target">
+            <connections>
+                <outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
+                <outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
+            </connections>
+        </customObject>
+        <customObject id="YLy-65-1bz" customClass="NSFontManager"/>
+        <menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
+            <items>
+                <menuItem title="APP_NAME" id="1Xt-HY-uBw">
+                    <modifierMask key="keyEquivalentModifierMask"/>
+                    <menu key="submenu" title="APP_NAME" systemMenu="apple" id="uQy-DD-JDr">
+                        <items>
+                            <menuItem title="About APP_NAME" id="5kV-Vb-QxS">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <connections>
+                                    <action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
+                            <menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
+                            <menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
+                            <menuItem title="Services" id="NMo-om-nkz">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
+                            </menuItem>
+                            <menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
+                            <menuItem title="Hide APP_NAME" keyEquivalent="h" id="Olw-nP-bQN">
+                                <connections>
+                                    <action selector="hide:" target="-1" id="PnN-Uc-m68"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
+                                <modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
+                                <connections>
+                                    <action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Show All" id="Kd2-mp-pUS">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <connections>
+                                    <action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
+                            <menuItem title="Quit APP_NAME" keyEquivalent="q" id="4sb-4s-VLi">
+                                <connections>
+                                    <action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
+                                </connections>
+                            </menuItem>
+                        </items>
+                    </menu>
+                </menuItem>
+                <menuItem title="Edit" id="5QF-Oa-p0T">
+                    <modifierMask key="keyEquivalentModifierMask"/>
+                    <menu key="submenu" title="Edit" id="W48-6f-4Dl">
+                        <items>
+                            <menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
+                                <connections>
+                                    <action selector="undo:" target="-1" id="M6e-cu-g7V"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
+                                <connections>
+                                    <action selector="redo:" target="-1" id="oIA-Rs-6OD"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
+                            <menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
+                                <connections>
+                                    <action selector="cut:" target="-1" id="YJe-68-I9s"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
+                                <connections>
+                                    <action selector="copy:" target="-1" id="G1f-GL-Joy"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
+                                <connections>
+                                    <action selector="paste:" target="-1" id="UvS-8e-Qdg"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
+                                <modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
+                                <connections>
+                                    <action selector="pasteAsPlainText:" target="-1" id="cEh-KX-wJQ"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Delete" id="pa3-QI-u2k">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <connections>
+                                    <action selector="delete:" target="-1" id="0Mk-Ml-PaM"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
+                                <connections>
+                                    <action selector="selectAll:" target="-1" id="VNm-Mi-diN"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
+                            <menuItem title="Find" id="4EN-yA-p0u">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <menu key="submenu" title="Find" id="1b7-l0-nxx">
+                                    <items>
+                                        <menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
+                                            <connections>
+                                                <action selector="performFindPanelAction:" target="-1" id="cD7-Qs-BN4"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
+                                            <modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
+                                            <connections>
+                                                <action selector="performFindPanelAction:" target="-1" id="WD3-Gg-5AJ"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
+                                            <connections>
+                                                <action selector="performFindPanelAction:" target="-1" id="NDo-RZ-v9R"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
+                                            <connections>
+                                                <action selector="performFindPanelAction:" target="-1" id="HOh-sY-3ay"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
+                                            <connections>
+                                                <action selector="performFindPanelAction:" target="-1" id="U76-nv-p5D"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
+                                            <connections>
+                                                <action selector="centerSelectionInVisibleArea:" target="-1" id="IOG-6D-g5B"/>
+                                            </connections>
+                                        </menuItem>
+                                    </items>
+                                </menu>
+                            </menuItem>
+                            <menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
+                                    <items>
+                                        <menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
+                                            <connections>
+                                                <action selector="showGuessPanel:" target="-1" id="vFj-Ks-hy3"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
+                                            <connections>
+                                                <action selector="checkSpelling:" target="-1" id="fz7-VC-reM"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
+                                        <menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleContinuousSpellChecking:" target="-1" id="7w6-Qz-0kB"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleGrammarChecking:" target="-1" id="muD-Qn-j4w"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleAutomaticSpellingCorrection:" target="-1" id="2lM-Qi-WAP"/>
+                                            </connections>
+                                        </menuItem>
+                                    </items>
+                                </menu>
+                            </menuItem>
+                            <menuItem title="Substitutions" id="9ic-FL-obx">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
+                                    <items>
+                                        <menuItem title="Show Substitutions" id="z6F-FW-3nz">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="orderFrontSubstitutionsPanel:" target="-1" id="oku-mr-iSq"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
+                                        <menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleSmartInsertDelete:" target="-1" id="3IJ-Se-DZD"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Smart Quotes" id="hQb-2v-fYv">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleAutomaticQuoteSubstitution:" target="-1" id="ptq-xd-QOA"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Smart Dashes" id="rgM-f4-ycn">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleAutomaticDashSubstitution:" target="-1" id="oCt-pO-9gS"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Smart Links" id="cwL-P1-jid">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleAutomaticLinkDetection:" target="-1" id="Gip-E3-Fov"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Data Detectors" id="tRr-pd-1PS">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleAutomaticDataDetection:" target="-1" id="R1I-Nq-Kbl"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Text Replacement" id="HFQ-gK-NFA">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="toggleAutomaticTextReplacement:" target="-1" id="DvP-Fe-Py6"/>
+                                            </connections>
+                                        </menuItem>
+                                    </items>
+                                </menu>
+                            </menuItem>
+                            <menuItem title="Transformations" id="2oI-Rn-ZJC">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <menu key="submenu" title="Transformations" id="c8a-y6-VQd">
+                                    <items>
+                                        <menuItem title="Make Upper Case" id="vmV-6d-7jI">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="uppercaseWord:" target="-1" id="sPh-Tk-edu"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Make Lower Case" id="d9M-CD-aMd">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="lowercaseWord:" target="-1" id="iUZ-b5-hil"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Capitalize" id="UEZ-Bs-lqG">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="capitalizeWord:" target="-1" id="26H-TL-nsh"/>
+                                            </connections>
+                                        </menuItem>
+                                    </items>
+                                </menu>
+                            </menuItem>
+                            <menuItem title="Speech" id="xrE-MZ-jX0">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <menu key="submenu" title="Speech" id="3rS-ZA-NoH">
+                                    <items>
+                                        <menuItem title="Start Speaking" id="Ynk-f8-cLZ">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="startSpeaking:" target="-1" id="654-Ng-kyl"/>
+                                            </connections>
+                                        </menuItem>
+                                        <menuItem title="Stop Speaking" id="Oyz-dy-DGm">
+                                            <modifierMask key="keyEquivalentModifierMask"/>
+                                            <connections>
+                                                <action selector="stopSpeaking:" target="-1" id="dX8-6p-jy9"/>
+                                            </connections>
+                                        </menuItem>
+                                    </items>
+                                </menu>
+                            </menuItem>
+                        </items>
+                    </menu>
+                </menuItem>
+                <menuItem title="View" id="H8h-7b-M4v">
+                    <modifierMask key="keyEquivalentModifierMask"/>
+                    <menu key="submenu" title="View" id="HyV-fh-RgO">
+                        <items>
+                            <menuItem title="Enter Full Screen" keyEquivalent="f" id="4J7-dP-txa">
+                                <modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
+                                <connections>
+                                    <action selector="toggleFullScreen:" target="-1" id="dU3-MA-1Rq"/>
+                                </connections>
+                            </menuItem>
+                        </items>
+                    </menu>
+                </menuItem>
+                <menuItem title="Window" id="aUF-d1-5bR">
+                    <modifierMask key="keyEquivalentModifierMask"/>
+                    <menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
+                        <items>
+                            <menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
+                                <connections>
+                                    <action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem title="Zoom" id="R4o-n2-Eq4">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <connections>
+                                    <action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
+                                </connections>
+                            </menuItem>
+                            <menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
+                            <menuItem title="Bring All to Front" id="LE2-aR-0XJ">
+                                <modifierMask key="keyEquivalentModifierMask"/>
+                                <connections>
+                                    <action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
+                                </connections>
+                            </menuItem>
+                        </items>
+                    </menu>
+                </menuItem>
+            </items>
+            <point key="canvasLocation" x="142" y="-258"/>
+        </menu>
+        <window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
+            <windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
+            <rect key="contentRect" x="335" y="390" width="800" height="600"/>
+            <rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
+            <view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
+                <rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
+                <autoresizingMask key="autoresizingMask"/>
+            </view>
+        </window>
+    </objects>
+</document>

+ 14 - 0
app_flowy/packages/editor/example/macos/Runner/Configs/AppInfo.xcconfig

@@ -0,0 +1,14 @@
+// Application-level settings for the Runner target.
+//
+// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
+// future. If not, the values below would default to using the project name when this becomes a
+// 'flutter create' template.
+
+// The application's name. By default this is also the title of the Flutter window.
+PRODUCT_NAME = editor_example
+
+// The application's bundle identifier
+PRODUCT_BUNDLE_IDENTIFIER = com.example.editorExample
+
+// The copyright displayed in application information
+PRODUCT_COPYRIGHT = Copyright © 2021 com.example. All rights reserved.

+ 2 - 0
app_flowy/packages/editor/example/macos/Runner/Configs/Debug.xcconfig

@@ -0,0 +1,2 @@
+#include "../../Flutter/Flutter-Debug.xcconfig"
+#include "Warnings.xcconfig"

+ 2 - 0
app_flowy/packages/editor/example/macos/Runner/Configs/Release.xcconfig

@@ -0,0 +1,2 @@
+#include "../../Flutter/Flutter-Release.xcconfig"
+#include "Warnings.xcconfig"

+ 13 - 0
app_flowy/packages/editor/example/macos/Runner/Configs/Warnings.xcconfig

@@ -0,0 +1,13 @@
+WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings
+GCC_WARN_UNDECLARED_SELECTOR = YES
+CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES
+CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
+CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
+CLANG_WARN_PRAGMA_PACK = YES
+CLANG_WARN_STRICT_PROTOTYPES = YES
+CLANG_WARN_COMMA = YES
+GCC_WARN_STRICT_SELECTOR_MATCH = YES
+CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES
+CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
+GCC_WARN_SHADOW = YES
+CLANG_WARN_UNREACHABLE_CODE = YES

+ 12 - 0
app_flowy/packages/editor/example/macos/Runner/DebugProfile.entitlements

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.security.app-sandbox</key>
+	<true/>
+	<key>com.apple.security.cs.allow-jit</key>
+	<true/>
+	<key>com.apple.security.network.server</key>
+	<true/>
+</dict>
+</plist>

+ 32 - 0
app_flowy/packages/editor/example/macos/Runner/Info.plist

@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIconFile</key>
+	<string></string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(FLUTTER_BUILD_NAME)</string>
+	<key>CFBundleVersion</key>
+	<string>$(FLUTTER_BUILD_NUMBER)</string>
+	<key>LSMinimumSystemVersion</key>
+	<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
+	<key>NSHumanReadableCopyright</key>
+	<string>$(PRODUCT_COPYRIGHT)</string>
+	<key>NSMainNibFile</key>
+	<string>MainMenu</string>
+	<key>NSPrincipalClass</key>
+	<string>NSApplication</string>
+</dict>
+</plist>

+ 15 - 0
app_flowy/packages/editor/example/macos/Runner/MainFlutterWindow.swift

@@ -0,0 +1,15 @@
+import Cocoa
+import FlutterMacOS
+
+class MainFlutterWindow: NSWindow {
+  override func awakeFromNib() {
+    let flutterViewController = FlutterViewController.init()
+    let windowFrame = self.frame
+    self.contentViewController = flutterViewController
+    self.setFrame(windowFrame, display: true)
+
+    RegisterGeneratedPlugins(registry: flutterViewController)
+
+    super.awakeFromNib()
+  }
+}

+ 8 - 0
app_flowy/packages/editor/example/macos/Runner/Release.entitlements

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.security.app-sandbox</key>
+	<true/>
+</dict>
+</plist>

+ 404 - 0
app_flowy/packages/editor/example/pubspec.lock

@@ -0,0 +1,404 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  async:
+    dependency: transitive
+    description:
+      name: async
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.8.2"
+  boolean_selector:
+    dependency: transitive
+    description:
+      name: boolean_selector
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.0"
+  characters:
+    dependency: transitive
+    description:
+      name: characters
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.2.0"
+  charcode:
+    dependency: transitive
+    description:
+      name: charcode
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.3.1"
+  clock:
+    dependency: transitive
+    description:
+      name: clock
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.0"
+  collection:
+    dependency: transitive
+    description:
+      name: collection
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.15.0"
+  cross_file:
+    dependency: transitive
+    description:
+      name: cross_file
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.3.1+5"
+  csslib:
+    dependency: transitive
+    description:
+      name: csslib
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.17.1"
+  cupertino_icons:
+    dependency: "direct main"
+    description:
+      name: cupertino_icons
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.3"
+  diff_match_patch:
+    dependency: transitive
+    description:
+      name: diff_match_patch
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.4.1"
+  editor:
+    dependency: "direct main"
+    description:
+      path: ".."
+      relative: true
+    source: path
+    version: "0.0.1"
+  fake_async:
+    dependency: transitive
+    description:
+      name: fake_async
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.2.0"
+  flutter:
+    dependency: "direct main"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_colorpicker:
+    dependency: transitive
+    description:
+      name: flutter_colorpicker
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.5.0"
+  flutter_inappwebview:
+    dependency: transitive
+    description:
+      name: flutter_inappwebview
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "5.3.2"
+  flutter_keyboard_visibility:
+    dependency: transitive
+    description:
+      name: flutter_keyboard_visibility
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "5.0.3"
+  flutter_keyboard_visibility_platform_interface:
+    dependency: transitive
+    description:
+      name: flutter_keyboard_visibility_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
+  flutter_keyboard_visibility_web:
+    dependency: transitive
+    description:
+      name: flutter_keyboard_visibility_web
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
+  flutter_lints:
+    dependency: "direct dev"
+    description:
+      name: flutter_lints
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.4"
+  flutter_plugin_android_lifecycle:
+    dependency: transitive
+    description:
+      name: flutter_plugin_android_lifecycle
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.3"
+  flutter_test:
+    dependency: "direct dev"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  html:
+    dependency: transitive
+    description:
+      name: html
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.15.0"
+  http:
+    dependency: transitive
+    description:
+      name: http
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.13.4"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "4.0.0"
+  image_picker:
+    dependency: transitive
+    description:
+      name: image_picker
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.8.4+2"
+  image_picker_for_web:
+    dependency: transitive
+    description:
+      name: image_picker_for_web
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.3"
+  image_picker_platform_interface:
+    dependency: transitive
+    description:
+      name: image_picker_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.4.1"
+  js:
+    dependency: transitive
+    description:
+      name: js
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.6.3"
+  lints:
+    dependency: transitive
+    description:
+      name: lints
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.1"
+  matcher:
+    dependency: transitive
+    description:
+      name: matcher
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.12.11"
+  meta:
+    dependency: transitive
+    description:
+      name: meta
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.7.0"
+  path:
+    dependency: transitive
+    description:
+      name: path
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.8.0"
+  pedantic:
+    dependency: transitive
+    description:
+      name: pedantic
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.11.1"
+  photo_view:
+    dependency: transitive
+    description:
+      name: photo_view
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.12.0"
+  plugin_platform_interface:
+    dependency: transitive
+    description:
+      name: plugin_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.2"
+  quiver:
+    dependency: transitive
+    description:
+      name: quiver
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.0.1"
+  sky_engine:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.99"
+  source_span:
+    dependency: transitive
+    description:
+      name: source_span
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.8.1"
+  stack_trace:
+    dependency: transitive
+    description:
+      name: stack_trace
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.10.0"
+  stream_channel:
+    dependency: transitive
+    description:
+      name: stream_channel
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.0"
+  string_scanner:
+    dependency: transitive
+    description:
+      name: string_scanner
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.0"
+  string_validator:
+    dependency: transitive
+    description:
+      name: string_validator
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.3.0"
+  term_glyph:
+    dependency: transitive
+    description:
+      name: term_glyph
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.2.0"
+  test_api:
+    dependency: transitive
+    description:
+      name: test_api
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.4.3"
+  tuple:
+    dependency: transitive
+    description:
+      name: tuple
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.3.0"
+  url_launcher:
+    dependency: transitive
+    description:
+      name: url_launcher
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "6.0.12"
+  url_launcher_linux:
+    dependency: transitive
+    description:
+      name: url_launcher_linux
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.2"
+  url_launcher_macos:
+    dependency: transitive
+    description:
+      name: url_launcher_macos
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.2"
+  url_launcher_platform_interface:
+    dependency: transitive
+    description:
+      name: url_launcher_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.4"
+  url_launcher_web:
+    dependency: transitive
+    description:
+      name: url_launcher_web
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.4"
+  url_launcher_windows:
+    dependency: transitive
+    description:
+      name: url_launcher_windows
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.2"
+  vector_math:
+    dependency: transitive
+    description:
+      name: vector_math
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.0"
+  video_player:
+    dependency: transitive
+    description:
+      name: video_player
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.2.5"
+  video_player_platform_interface:
+    dependency: transitive
+    description:
+      name: video_player_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "4.2.0"
+  video_player_web:
+    dependency: transitive
+    description:
+      name: video_player_web
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.4"
+  youtube_player_flutter:
+    dependency: transitive
+    description:
+      name: youtube_player_flutter
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "8.0.0"
+sdks:
+  dart: ">=2.15.0-116.0.dev <3.0.0"
+  flutter: ">=2.5.0"

+ 84 - 0
app_flowy/packages/editor/example/pubspec.yaml

@@ -0,0 +1,84 @@
+name: editor_example
+description: Demonstrates how to use the editor plugin.
+
+# The following line prevents the package from being accidentally published to
+# pub.dev using `flutter pub publish`. This is preferred for private packages.
+publish_to: 'none' # Remove this line if you wish to publish to pub.dev
+
+environment:
+  sdk: ">=2.15.0-116.0.dev <3.0.0"
+
+# Dependencies specify other packages that your package needs in order to work.
+# To automatically upgrade your package dependencies to the latest versions
+# consider running `flutter pub upgrade --major-versions`. Alternatively,
+# dependencies can be manually updated by changing the version numbers below to
+# the latest version available on pub.dev. To see which dependencies have newer
+# versions available, run `flutter pub outdated`.
+dependencies:
+  flutter:
+    sdk: flutter
+
+  editor:
+    # When depending on this package from a real application you should use:
+    #   editor: ^x.y.z
+    # See https://dart.dev/tools/pub/dependencies#version-constraints
+    # The example app is bundled with the plugin so we use a path dependency on
+    # the parent directory to use the current plugin's version.
+    path: ../
+
+  # The following adds the Cupertino Icons font to your application.
+  # Use with the CupertinoIcons class for iOS style icons.
+  cupertino_icons: ^1.0.2
+
+dev_dependencies:
+  flutter_test:
+    sdk: flutter
+
+  # The "flutter_lints" package below contains a set of recommended lints to
+  # encourage good coding practices. The lint set provided by the package is
+  # activated in the `analysis_options.yaml` file located at the root of your
+  # package. See that file for information about deactivating specific lint
+  # rules and activating additional ones.
+  flutter_lints: ^1.0.0
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter.
+flutter:
+
+  # The following line ensures that the Material Icons font is
+  # included with your application, so that you can use the icons in
+  # the material Icons class.
+  uses-material-design: true
+
+  # To add assets to your application, add an assets section, like this:
+  # assets:
+  #   - images/a_dot_burr.jpeg
+  #   - images/a_dot_ham.jpeg
+
+  # An image asset can refer to one or more resolution-specific "variants", see
+  # https://flutter.dev/assets-and-images/#resolution-aware.
+
+  # For details regarding adding assets from package dependencies, see
+  # https://flutter.dev/assets-and-images/#from-packages
+
+  # To add custom fonts to your application, add a fonts section here,
+  # in this "flutter" section. Each entry in this list should have a
+  # "family" key with the font family name, and a "fonts" key with a
+  # list giving the asset and other descriptors for the font. For
+  # example:
+  # fonts:
+  #   - family: Schyler
+  #     fonts:
+  #       - asset: fonts/Schyler-Regular.ttf
+  #       - asset: fonts/Schyler-Italic.ttf
+  #         style: italic
+  #   - family: Trajan Pro
+  #     fonts:
+  #       - asset: fonts/TrajanPro.ttf
+  #       - asset: fonts/TrajanPro_Bold.ttf
+  #         weight: 700
+  #
+  # For details regarding fonts from package dependencies,
+  # see https://flutter.dev/custom-fonts/#from-packages

+ 11 - 0
app_flowy/packages/editor/example/test/widget_test.dart

@@ -0,0 +1,11 @@
+// This is a basic Flutter widget test.
+//
+// To perform an interaction with a widget in your test, use the WidgetTester
+// utility that Flutter provides. For example, you can send tap and scroll
+// gestures. You can also use WidgetTester to find child widgets in the widget
+// tree, read text, and verify that the values of widget properties are correct.
+
+// import 'package:flutter/material.dart';
+// import 'package:flutter_test/flutter_test.dart';
+
+void main() {}

+ 11 - 0
app_flowy/packages/editor/lib/flutter_quill.dart

@@ -0,0 +1,11 @@
+library flutter_quill;
+
+export 'src/models/documents/attribute.dart';
+export 'src/models/documents/document.dart';
+export 'src/models/documents/nodes/embed.dart';
+export 'src/models/documents/nodes/leaf.dart';
+export 'src/models/quill_delta.dart';
+export 'src/widgets/controller.dart';
+export 'src/widgets/default_styles.dart';
+export 'src/widgets/editor.dart';
+export 'src/widgets/toolbar.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/attribute.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../src/models/documents/attribute.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/document.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../src/models/documents/document.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/history.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../src/models/documents/history.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/nodes/block.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../../src/models/documents/nodes/block.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/nodes/container.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../../src/models/documents/nodes/container.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/nodes/embed.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../../src/models/documents/nodes/embed.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/nodes/leaf.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../../src/models/documents/nodes/leaf.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/nodes/line.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../../src/models/documents/nodes/line.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/nodes/node.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../../src/models/documents/nodes/node.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/documents/style.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../src/models/documents/style.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/quill_delta.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../src/models/quill_delta.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/rules/delete.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../src/models/rules/delete.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/rules/format.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../src/models/rules/format.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/rules/insert.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../src/models/rules/insert.dart';

+ 3 - 0
app_flowy/packages/editor/lib/models/rules/rule.dart

@@ -0,0 +1,3 @@
+/// TODO: Remove this file in the next breaking release, because implementation
+/// files should be located in the src folder, https://bit.ly/3fA23Yz.
+export '../../src/models/rules/rule.dart';

+ 314 - 0
app_flowy/packages/editor/lib/src/models/documents/attribute.dart

@@ -0,0 +1,314 @@
+import 'dart:collection';
+
+import 'package:quiver/core.dart';
+
+enum AttributeScope {
+  INLINE, // refer to https://quilljs.com/docs/formats/#inline
+  BLOCK, // refer to https://quilljs.com/docs/formats/#block
+  EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds
+  IGNORE, // attributes that can be ignored
+}
+
+class Attribute<T> {
+  Attribute(this.key, this.scope, this.value);
+
+  final String key;
+  final AttributeScope scope;
+  final T value;
+
+  static final Map<String, Attribute> _registry = LinkedHashMap.of({
+    Attribute.bold.key: Attribute.bold,
+    Attribute.italic.key: Attribute.italic,
+    Attribute.small.key: Attribute.small,
+    Attribute.underline.key: Attribute.underline,
+    Attribute.strikeThrough.key: Attribute.strikeThrough,
+    Attribute.inlineCode.key: Attribute.inlineCode,
+    Attribute.font.key: Attribute.font,
+    Attribute.size.key: Attribute.size,
+    Attribute.link.key: Attribute.link,
+    Attribute.color.key: Attribute.color,
+    Attribute.background.key: Attribute.background,
+    Attribute.placeholder.key: Attribute.placeholder,
+    Attribute.header.key: Attribute.header,
+    Attribute.align.key: Attribute.align,
+    Attribute.list.key: Attribute.list,
+    Attribute.codeBlock.key: Attribute.codeBlock,
+    Attribute.blockQuote.key: Attribute.blockQuote,
+    Attribute.indent.key: Attribute.indent,
+    Attribute.width.key: Attribute.width,
+    Attribute.height.key: Attribute.height,
+    Attribute.style.key: Attribute.style,
+    Attribute.token.key: Attribute.token,
+  });
+
+  static final BoldAttribute bold = BoldAttribute();
+
+  static final ItalicAttribute italic = ItalicAttribute();
+
+  static final SmallAttribute small = SmallAttribute();
+
+  static final UnderlineAttribute underline = UnderlineAttribute();
+
+  static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute();
+
+  static final InlineCodeAttribute inlineCode = InlineCodeAttribute();
+
+  static final FontAttribute font = FontAttribute(null);
+
+  static final SizeAttribute size = SizeAttribute(null);
+
+  static final LinkAttribute link = LinkAttribute(null);
+
+  static final ColorAttribute color = ColorAttribute(null);
+
+  static final BackgroundAttribute background = BackgroundAttribute(null);
+
+  static final PlaceholderAttribute placeholder = PlaceholderAttribute();
+
+  static final HeaderAttribute header = HeaderAttribute();
+
+  static final IndentAttribute indent = IndentAttribute();
+
+  static final AlignAttribute align = AlignAttribute(null);
+
+  static final ListAttribute list = ListAttribute(null);
+
+  static final CodeBlockAttribute codeBlock = CodeBlockAttribute();
+
+  static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute();
+
+  static final WidthAttribute width = WidthAttribute(null);
+
+  static final HeightAttribute height = HeightAttribute(null);
+
+  static final StyleAttribute style = StyleAttribute(null);
+
+  static final TokenAttribute token = TokenAttribute('');
+
+  static final Set<String> inlineKeys = {
+    Attribute.bold.key,
+    Attribute.italic.key,
+    Attribute.small.key,
+    Attribute.underline.key,
+    Attribute.strikeThrough.key,
+    Attribute.link.key,
+    Attribute.color.key,
+    Attribute.background.key,
+    Attribute.placeholder.key,
+  };
+
+  static final Set<String> blockKeys = LinkedHashSet.of({
+    Attribute.header.key,
+    Attribute.align.key,
+    Attribute.list.key,
+    Attribute.codeBlock.key,
+    Attribute.blockQuote.key,
+    Attribute.indent.key,
+  });
+
+  static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({
+    Attribute.list.key,
+    Attribute.align.key,
+    Attribute.codeBlock.key,
+    Attribute.blockQuote.key,
+    Attribute.indent.key,
+  });
+
+  static final Set<String> exclusiveBlockKeys = LinkedHashSet.of({
+    Attribute.header.key,
+    Attribute.list.key,
+    Attribute.codeBlock.key,
+    Attribute.blockQuote.key,
+  });
+
+  static Attribute<int?> get h1 => HeaderAttribute(level: 1);
+
+  static Attribute<int?> get h2 => HeaderAttribute(level: 2);
+
+  static Attribute<int?> get h3 => HeaderAttribute(level: 3);
+
+  // "attributes":{"align":"left"}
+  static Attribute<String?> get leftAlignment => AlignAttribute('left');
+
+  // "attributes":{"align":"center"}
+  static Attribute<String?> get centerAlignment => AlignAttribute('center');
+
+  // "attributes":{"align":"right"}
+  static Attribute<String?> get rightAlignment => AlignAttribute('right');
+
+  // "attributes":{"align":"justify"}
+  static Attribute<String?> get justifyAlignment => AlignAttribute('justify');
+
+  // "attributes":{"list":"bullet"}
+  static Attribute<String?> get ul => ListAttribute('bullet');
+
+  // "attributes":{"list":"ordered"}
+  static Attribute<String?> get ol => ListAttribute('ordered');
+
+  // "attributes":{"list":"checked"}
+  static Attribute<String?> get checked => ListAttribute('checked');
+
+  // "attributes":{"list":"unchecked"}
+  static Attribute<String?> get unchecked => ListAttribute('unchecked');
+
+  // "attributes":{"indent":1"}
+  static Attribute<int?> get indentL1 => IndentAttribute(level: 1);
+
+  // "attributes":{"indent":2"}
+  static Attribute<int?> get indentL2 => IndentAttribute(level: 2);
+
+  // "attributes":{"indent":3"}
+  static Attribute<int?> get indentL3 => IndentAttribute(level: 3);
+
+  static Attribute<int?> getIndentLevel(int? level) {
+    if (level == 1) {
+      return indentL1;
+    }
+    if (level == 2) {
+      return indentL2;
+    }
+    if (level == 3) {
+      return indentL3;
+    }
+    return IndentAttribute(level: level);
+  }
+
+  bool get isInline => scope == AttributeScope.INLINE;
+
+  bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key);
+
+  Map<String, dynamic> toJson() => <String, dynamic>{key: value};
+
+  static Attribute? fromKeyValue(String key, dynamic value) {
+    final origin = _registry[key];
+    if (origin == null) {
+      return null;
+    }
+    final attribute = clone(origin, value);
+    return attribute;
+  }
+
+  static int getRegistryOrder(Attribute attribute) {
+    var order = 0;
+    for (final attr in _registry.values) {
+      if (attr.key == attribute.key) {
+        break;
+      }
+      order++;
+    }
+
+    return order;
+  }
+
+  static Attribute clone(Attribute origin, dynamic value) {
+    return Attribute(origin.key, origin.scope, value);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+    if (other is! Attribute) return false;
+    final typedOther = other;
+    return key == typedOther.key &&
+        scope == typedOther.scope &&
+        value == typedOther.value;
+  }
+
+  @override
+  int get hashCode => hash3(key, scope, value);
+
+  @override
+  String toString() {
+    return 'Attribute{key: $key, scope: $scope, value: $value}';
+  }
+}
+
+class BoldAttribute extends Attribute<bool> {
+  BoldAttribute() : super('bold', AttributeScope.INLINE, true);
+}
+
+class ItalicAttribute extends Attribute<bool> {
+  ItalicAttribute() : super('italic', AttributeScope.INLINE, true);
+}
+
+class SmallAttribute extends Attribute<bool> {
+  SmallAttribute() : super('small', AttributeScope.INLINE, true);
+}
+
+class UnderlineAttribute extends Attribute<bool> {
+  UnderlineAttribute() : super('underline', AttributeScope.INLINE, true);
+}
+
+class StrikeThroughAttribute extends Attribute<bool> {
+  StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true);
+}
+
+class InlineCodeAttribute extends Attribute<bool> {
+  InlineCodeAttribute() : super('code', AttributeScope.INLINE, true);
+}
+
+class FontAttribute extends Attribute<String?> {
+  FontAttribute(String? val) : super('font', AttributeScope.INLINE, val);
+}
+
+class SizeAttribute extends Attribute<String?> {
+  SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val);
+}
+
+class LinkAttribute extends Attribute<String?> {
+  LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val);
+}
+
+class ColorAttribute extends Attribute<String?> {
+  ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val);
+}
+
+class BackgroundAttribute extends Attribute<String?> {
+  BackgroundAttribute(String? val)
+      : super('background', AttributeScope.INLINE, val);
+}
+
+/// This is custom attribute for hint
+class PlaceholderAttribute extends Attribute<bool> {
+  PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true);
+}
+
+class HeaderAttribute extends Attribute<int?> {
+  HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level);
+}
+
+class IndentAttribute extends Attribute<int?> {
+  IndentAttribute({int? level}) : super('indent', AttributeScope.BLOCK, level);
+}
+
+class AlignAttribute extends Attribute<String?> {
+  AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val);
+}
+
+class ListAttribute extends Attribute<String?> {
+  ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val);
+}
+
+class CodeBlockAttribute extends Attribute<bool> {
+  CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true);
+}
+
+class BlockQuoteAttribute extends Attribute<bool> {
+  BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true);
+}
+
+class WidthAttribute extends Attribute<String?> {
+  WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val);
+}
+
+class HeightAttribute extends Attribute<String?> {
+  HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val);
+}
+
+class StyleAttribute extends Attribute<String?> {
+  StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val);
+}
+
+class TokenAttribute extends Attribute<String> {
+  TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val);
+}

+ 291 - 0
app_flowy/packages/editor/lib/src/models/documents/document.dart

@@ -0,0 +1,291 @@
+import 'dart:async';
+
+import 'package:tuple/tuple.dart';
+
+import '../quill_delta.dart';
+import '../rules/rule.dart';
+import 'attribute.dart';
+import 'history.dart';
+import 'nodes/block.dart';
+import 'nodes/container.dart';
+import 'nodes/embed.dart';
+import 'nodes/line.dart';
+import 'nodes/node.dart';
+import 'style.dart';
+
+/// The rich text document
+class Document {
+  Document() : _delta = Delta()..insert('\n') {
+    _loadDocument(_delta);
+  }
+
+  Document.fromJson(List data) : _delta = _transform(Delta.fromJson(data)) {
+    _loadDocument(_delta);
+  }
+
+  Document.fromDelta(Delta delta) : _delta = delta {
+    _loadDocument(delta);
+  }
+
+  /// The root node of the document tree
+  final Root _root = Root();
+
+  Root get root => _root;
+
+  int get length => _root.length;
+
+  Delta _delta;
+
+  Delta toDelta() => Delta.from(_delta);
+
+  final Rules _rules = Rules.getInstance();
+
+  void setCustomRules(List<Rule> customRules) {
+    _rules.setCustomRules(customRules);
+  }
+
+  final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer =
+      StreamController.broadcast();
+
+  final History _history = History();
+
+  Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream;
+
+  Delta insert(int index, Object? data, {int replaceLength = 0}) {
+    assert(index >= 0);
+    assert(data is String || data is Embeddable);
+    if (data is Embeddable) {
+      data = data.toJson();
+    } else if ((data as String).isEmpty) {
+      return Delta();
+    }
+
+    final delta = _rules.apply(RuleType.INSERT, this, index,
+        data: data, len: replaceLength);
+    compose(delta, ChangeSource.LOCAL);
+    return delta;
+  }
+
+  Delta delete(int index, int len) {
+    assert(index >= 0 && len > 0);
+    final delta = _rules.apply(RuleType.DELETE, this, index, len: len);
+    if (delta.isNotEmpty) {
+      compose(delta, ChangeSource.LOCAL);
+    }
+    return delta;
+  }
+
+  Delta replace(int index, int len, Object? data) {
+    assert(index >= 0);
+    assert(data is String || data is Embeddable);
+
+    final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true;
+
+    assert(dataIsNotEmpty || len > 0);
+
+    var delta = Delta();
+
+    // We have to insert before applying delete rules
+    // Otherwise delete would be operating on stale document snapshot.
+    if (dataIsNotEmpty) {
+      delta = insert(index, data, replaceLength: len);
+    }
+
+    if (len > 0) {
+      final deleteDelta = delete(index, len);
+      delta = delta.compose(deleteDelta);
+    }
+
+    return delta;
+  }
+
+  Delta format(int index, int len, Attribute? attribute) {
+    assert(index >= 0 && len >= 0 && attribute != null);
+
+    var delta = Delta();
+
+    final formatDelta = _rules.apply(RuleType.FORMAT, this, index,
+        len: len, attribute: attribute);
+    if (formatDelta.isNotEmpty) {
+      compose(formatDelta, ChangeSource.LOCAL);
+      delta = delta.compose(formatDelta);
+    }
+
+    return delta;
+  }
+
+  /// Only attributes applied to all characters within this range are
+  /// included in the result.
+  Style collectStyle(int index, int len) {
+    final res = queryChild(index);
+    return (res.node as Line).collectStyle(res.offset, len);
+  }
+
+  /// Returns all styles for any character within the specified text range.
+  List<Style> collectAllStyles(int index, int len) {
+    final res = queryChild(index);
+    return (res.node as Line).collectAllStyles(res.offset, len);
+  }
+
+  ChildQuery queryChild(int offset) {
+    final res = _root.queryChild(offset, true);
+    if (res.node is Line) {
+      return res;
+    }
+    final block = res.node as Block;
+    return block.queryChild(res.offset, true);
+  }
+
+  void compose(Delta delta, ChangeSource changeSource) {
+    assert(!_observer.isClosed);
+    delta.trim();
+    assert(delta.isNotEmpty);
+
+    var offset = 0;
+    delta = _transform(delta);
+    final originalDelta = toDelta();
+    for (final op in delta.toList()) {
+      final style =
+          op.attributes != null ? Style.fromJson(op.attributes) : null;
+
+      if (op.isInsert) {
+        _root.insert(offset, _normalize(op.data), style);
+      } else if (op.isDelete) {
+        _root.delete(offset, op.length);
+      } else if (op.attributes != null) {
+        _root.retain(offset, op.length, style);
+      }
+
+      if (!op.isDelete) {
+        offset += op.length!;
+      }
+    }
+    try {
+      _delta = _delta.compose(delta);
+    } catch (e) {
+      throw '_delta compose failed';
+    }
+
+    if (_delta != _root.toDelta()) {
+      throw 'Compose failed';
+    }
+    final change = Tuple3(originalDelta, delta, changeSource);
+    _observer.add(change);
+    _history.handleDocChange(change);
+  }
+
+  Tuple2 undo() {
+    return _history.undo(this);
+  }
+
+  Tuple2 redo() {
+    return _history.redo(this);
+  }
+
+  bool get hasUndo => _history.hasUndo;
+
+  bool get hasRedo => _history.hasRedo;
+
+  static Delta _transform(Delta delta) {
+    final res = Delta();
+    final ops = delta.toList();
+    for (var i = 0; i < ops.length; i++) {
+      final op = ops[i];
+      res.push(op);
+      _autoAppendNewlineAfterEmbeddable(i, ops, op, res, 'video');
+    }
+    return res;
+  }
+
+  static void _autoAppendNewlineAfterEmbeddable(
+      int i, List<Operation> ops, Operation op, Delta res, String type) {
+    final nextOpIsEmbed = i + 1 < ops.length &&
+        ops[i + 1].isInsert &&
+        ops[i + 1].data is Map &&
+        (ops[i + 1].data as Map).containsKey(type);
+    if (nextOpIsEmbed &&
+        op.data is String &&
+        (op.data as String).isNotEmpty &&
+        !(op.data as String).endsWith('\n')) {
+      res.push(Operation.insert('\n'));
+    }
+    // embed could be image or video
+    final opInsertEmbed =
+        op.isInsert && op.data is Map && (op.data as Map).containsKey(type);
+    final nextOpIsLineBreak = i + 1 < ops.length &&
+        ops[i + 1].isInsert &&
+        ops[i + 1].data is String &&
+        (ops[i + 1].data as String).startsWith('\n');
+    if (opInsertEmbed && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) {
+      // automatically append '\n' for embeddable
+      res.push(Operation.insert('\n'));
+    }
+  }
+
+  Object _normalize(Object? data) {
+    if (data is String) {
+      return data;
+    }
+
+    if (data is Embeddable) {
+      return data;
+    }
+    return Embeddable.fromJson(data as Map<String, dynamic>);
+  }
+
+  void close() {
+    _observer.close();
+    _history.clear();
+  }
+
+  String toPlainText() => _root.children.map((e) => e.toPlainText()).join();
+
+  void _loadDocument(Delta doc) {
+    if (doc.isEmpty) {
+      throw ArgumentError.value(doc, 'Document Delta cannot be empty.');
+    }
+
+    assert((doc.last.data as String).endsWith('\n'));
+
+    var offset = 0;
+    for (final op in doc.toList()) {
+      if (!op.isInsert) {
+        throw ArgumentError.value(doc,
+            'Document can only contain insert operations but ${op.key} found.');
+      }
+      final style =
+          op.attributes != null ? Style.fromJson(op.attributes) : null;
+      final data = _normalize(op.data);
+      _root.insert(offset, data, style);
+      offset += op.length!;
+    }
+    final node = _root.last;
+    if (node is Line &&
+        node.parent is! Block &&
+        node.style.isEmpty &&
+        _root.childCount > 1) {
+      _root.remove(node);
+    }
+  }
+
+  bool isEmpty() {
+    if (root.children.length != 1) {
+      return false;
+    }
+
+    final node = root.children.first;
+    if (!node.isLast) {
+      return false;
+    }
+
+    final delta = node.toDelta();
+    return delta.length == 1 &&
+        delta.first.data == '\n' &&
+        delta.first.key == 'insert';
+  }
+}
+
+enum ChangeSource {
+  LOCAL,
+  REMOTE,
+}

+ 134 - 0
app_flowy/packages/editor/lib/src/models/documents/history.dart

@@ -0,0 +1,134 @@
+import 'package:tuple/tuple.dart';
+
+import '../quill_delta.dart';
+import 'document.dart';
+
+class History {
+  History({
+    this.ignoreChange = false,
+    this.interval = 400,
+    this.maxStack = 100,
+    this.userOnly = false,
+    this.lastRecorded = 0,
+  });
+
+  final HistoryStack stack = HistoryStack.empty();
+
+  bool get hasUndo => stack.undo.isNotEmpty;
+
+  bool get hasRedo => stack.redo.isNotEmpty;
+
+  /// used for disable redo or undo function
+  bool ignoreChange;
+
+  int lastRecorded;
+
+  /// Collaborative editing's conditions should be true
+  final bool userOnly;
+
+  ///max operation count for undo
+  final int maxStack;
+
+  ///record delay
+  final int interval;
+
+  void handleDocChange(Tuple3<Delta, Delta, ChangeSource> change) {
+    if (ignoreChange) return;
+    if (!userOnly || change.item3 == ChangeSource.LOCAL) {
+      record(change.item2, change.item1);
+    } else {
+      transform(change.item2);
+    }
+  }
+
+  void clear() {
+    stack.clear();
+  }
+
+  void record(Delta change, Delta before) {
+    if (change.isEmpty) return;
+    stack.redo.clear();
+    var undoDelta = change.invert(before);
+    final timeStamp = DateTime.now().millisecondsSinceEpoch;
+
+    if (lastRecorded + interval > timeStamp && stack.undo.isNotEmpty) {
+      final lastDelta = stack.undo.removeLast();
+      undoDelta = undoDelta.compose(lastDelta);
+    } else {
+      lastRecorded = timeStamp;
+    }
+
+    if (undoDelta.isEmpty) return;
+    stack.undo.add(undoDelta);
+
+    if (stack.undo.length > maxStack) {
+      stack.undo.removeAt(0);
+    }
+  }
+
+  ///
+  ///It will override pre local undo delta,replaced by remote change
+  ///
+  void transform(Delta delta) {
+    transformStack(stack.undo, delta);
+    transformStack(stack.redo, delta);
+  }
+
+  void transformStack(List<Delta> stack, Delta delta) {
+    for (var i = stack.length - 1; i >= 0; i -= 1) {
+      final oldDelta = stack[i];
+      stack[i] = delta.transform(oldDelta, true);
+      delta = oldDelta.transform(delta, false);
+      if (stack[i].length == 0) {
+        stack.removeAt(i);
+      }
+    }
+  }
+
+  Tuple2 _change(Document doc, List<Delta> source, List<Delta> dest) {
+    if (source.isEmpty) {
+      return const Tuple2(false, 0);
+    }
+    final delta = source.removeLast();
+    // look for insert or delete
+    int? len = 0;
+    final ops = delta.toList();
+    for (var i = 0; i < ops.length; i++) {
+      if (ops[i].key == Operation.insertKey) {
+        len = ops[i].length;
+      } else if (ops[i].key == Operation.deleteKey) {
+        len = ops[i].length! * -1;
+      }
+    }
+    final base = Delta.from(doc.toDelta());
+    final inverseDelta = delta.invert(base);
+    dest.add(inverseDelta);
+    lastRecorded = 0;
+    ignoreChange = true;
+    doc.compose(delta, ChangeSource.LOCAL);
+    ignoreChange = false;
+    return Tuple2(true, len);
+  }
+
+  Tuple2 undo(Document doc) {
+    return _change(doc, stack.undo, stack.redo);
+  }
+
+  Tuple2 redo(Document doc) {
+    return _change(doc, stack.redo, stack.undo);
+  }
+}
+
+class HistoryStack {
+  HistoryStack.empty()
+      : undo = [],
+        redo = [];
+
+  final List<Delta> undo;
+  final List<Delta> redo;
+
+  void clear() {
+    undo.clear();
+    redo.clear();
+  }
+}

+ 72 - 0
app_flowy/packages/editor/lib/src/models/documents/nodes/block.dart

@@ -0,0 +1,72 @@
+import '../../quill_delta.dart';
+import 'container.dart';
+import 'line.dart';
+import 'node.dart';
+
+/// Represents a group of adjacent [Line]s with the same block style.
+///
+/// Block elements are:
+/// - Blockquote
+/// - Header
+/// - Indent
+/// - List
+/// - Text Alignment
+/// - Text Direction
+/// - Code Block
+class Block extends Container<Line?> {
+  /// Creates new unmounted [Block].
+  @override
+  Node newInstance() => Block();
+
+  @override
+  Line get defaultChild => Line();
+
+  @override
+  Delta toDelta() {
+    return children
+        .map((child) => child.toDelta())
+        .fold(Delta(), (a, b) => a.concat(b));
+  }
+
+  @override
+  void adjust() {
+    if (isEmpty) {
+      final sibling = previous;
+      unlink();
+      if (sibling != null) {
+        sibling.adjust();
+      }
+      return;
+    }
+
+    var block = this;
+    final prev = block.previous;
+    // merging it with previous block if style is the same
+    if (!block.isFirst &&
+        block.previous is Block &&
+        prev!.style == block.style) {
+      block
+        ..moveChildToNewParent(prev as Container<Node?>?)
+        ..unlink();
+      block = prev as Block;
+    }
+    final next = block.next;
+    // merging it with next block if style is the same
+    if (!block.isLast && block.next is Block && next!.style == block.style) {
+      (next as Block).moveChildToNewParent(block);
+      next.unlink();
+    }
+  }
+
+  @override
+  String toString() {
+    final block = style.attributes.toString();
+    final buffer = StringBuffer('§ {$block}\n');
+    for (final child in children) {
+      final tree = child.isLast ? '└' : '├';
+      buffer.write('  $tree $child');
+      if (!child.isLast) buffer.writeln();
+    }
+    return buffer.toString();
+  }
+}

+ 160 - 0
app_flowy/packages/editor/lib/src/models/documents/nodes/container.dart

@@ -0,0 +1,160 @@
+import 'dart:collection';
+
+import '../style.dart';
+import 'leaf.dart';
+import 'line.dart';
+import 'node.dart';
+
+/// Container can accommodate other nodes.
+///
+/// Delegates insert, retain and delete operations to children nodes. For each
+/// operation container looks for a child at specified index position and
+/// forwards operation to that child.
+///
+/// Most of the operation handling logic is implemented by [Line] and [Text].
+abstract class Container<T extends Node?> extends Node {
+  final LinkedList<Node> _children = LinkedList<Node>();
+
+  /// List of children.
+  LinkedList<Node> get children => _children;
+
+  /// Returns total number of child nodes in this container.
+  ///
+  /// To get text length of this container see [length].
+  int get childCount => _children.length;
+
+  /// Returns the first child [Node].
+  Node get first => _children.first;
+
+  /// Returns the last child [Node].
+  Node get last => _children.last;
+
+  /// Returns `true` if this container has no child nodes.
+  bool get isEmpty => _children.isEmpty;
+
+  /// Returns `true` if this container has at least 1 child.
+  bool get isNotEmpty => _children.isNotEmpty;
+
+  /// Returns an instance of default child for this container node.
+  ///
+  /// Always returns fresh instance.
+  T get defaultChild;
+
+  /// Adds [node] to the end of this container children list.
+  void add(T node) {
+    assert(node?.parent == null);
+    node?.parent = this;
+    _children.add(node as Node);
+  }
+
+  /// Adds [node] to the beginning of this container children list.
+  void addFirst(T node) {
+    assert(node?.parent == null);
+    node?.parent = this;
+    _children.addFirst(node as Node);
+  }
+
+  /// Removes [node] from this container.
+  void remove(T node) {
+    assert(node?.parent == this);
+    node?.parent = null;
+    _children.remove(node as Node);
+  }
+
+  /// Moves children of this node to [newParent].
+  void moveChildToNewParent(Container? newParent) {
+    if (isEmpty) {
+      return;
+    }
+
+    final last = newParent!.isEmpty ? null : newParent.last as T?;
+    while (isNotEmpty) {
+      final child = first as T;
+      child?.unlink();
+      newParent.add(child);
+    }
+
+    /// In case [newParent] already had children we need to make sure
+    /// combined list is optimized.
+    if (last != null) last.adjust();
+  }
+
+  /// Queries the child [Node] at [offset] in this container.
+  ///
+  /// The result may contain the found node or `null` if no node is found
+  /// at specified offset.
+  ///
+  /// [ChildQuery.offset] is set to relative offset within returned child node
+  /// which points at the same character position in the document as the
+  /// original [offset].
+  ChildQuery queryChild(int offset, bool inclusive) {
+    if (offset < 0 || offset > length) {
+      return ChildQuery(null, 0);
+    }
+
+    for (final node in children) {
+      final len = node.length;
+      if (offset < len || (inclusive && offset == len && node.isLast)) {
+        return ChildQuery(node, offset);
+      }
+      offset -= len;
+    }
+    return ChildQuery(null, 0);
+  }
+
+  @override
+  String toPlainText() => children.map((child) => child.toPlainText()).join();
+
+  /// Content length of this node's children.
+  ///
+  /// To get number of children in this node use [childCount].
+  @override
+  int get length => _children.fold(0, (cur, node) => cur + node.length);
+
+  @override
+  void insert(int index, Object data, Style? style) {
+    assert(index == 0 || (index > 0 && index < length));
+
+    if (isNotEmpty) {
+      final child = queryChild(index, false);
+      child.node!.insert(child.offset, data, style);
+      return;
+    }
+
+    // empty
+    assert(index == 0);
+    final node = defaultChild;
+    add(node);
+    node?.insert(index, data, style);
+  }
+
+  @override
+  void retain(int index, int? length, Style? attributes) {
+    assert(isNotEmpty);
+    final child = queryChild(index, false);
+    child.node!.retain(child.offset, length, attributes);
+  }
+
+  @override
+  void delete(int index, int? length) {
+    assert(isNotEmpty);
+    final child = queryChild(index, false);
+    child.node!.delete(child.offset, length);
+  }
+
+  @override
+  String toString() => _children.join('\n');
+}
+
+/// Result of a child query in a [Container].
+class ChildQuery {
+  ChildQuery(this.node, this.offset);
+
+  /// The child node if found, otherwise `null`.
+  final Node? node;
+
+  /// Starting offset within the child [node] which points at the same
+  /// character in the document as the original offset passed to
+  /// [Container.queryChild] method.
+  final int offset;
+}

+ 45 - 0
app_flowy/packages/editor/lib/src/models/documents/nodes/embed.dart

@@ -0,0 +1,45 @@
+/// An object which can be embedded into a Quill document.
+///
+/// See also:
+///
+/// * [BlockEmbed] which represents a block embed.
+class Embeddable {
+  const Embeddable(this.type, this.data);
+
+  /// The type of this object.
+  final String type;
+
+  /// The data payload of this object.
+  final dynamic data;
+
+  Map<String, dynamic> toJson() {
+    final m = <String, String>{type: data};
+    return m;
+  }
+
+  static Embeddable fromJson(Map<String, dynamic> json) {
+    final m = Map<String, dynamic>.from(json);
+    assert(m.length == 1, 'Embeddable map has one key');
+
+    return BlockEmbed(m.keys.first, m.values.first);
+  }
+}
+
+/// An object which occupies an entire line in a document and cannot co-exist
+/// inline with regular text.
+///
+/// There are two built-in embed types supported by Quill documents, however
+/// the document model itself does not make any assumptions about the types
+/// of embedded objects and allows users to define their own types.
+class BlockEmbed extends Embeddable {
+  const BlockEmbed(String type, String data) : super(type, data);
+
+  static const String horizontalRuleType = 'divider';
+  static BlockEmbed horizontalRule = const BlockEmbed(horizontalRuleType, 'hr');
+
+  static const String imageType = 'image';
+  static BlockEmbed image(String imageUrl) => BlockEmbed(imageType, imageUrl);
+
+  static const String videoType = 'video';
+  static BlockEmbed video(String videoUrl) => BlockEmbed(videoType, videoUrl);
+}

+ 252 - 0
app_flowy/packages/editor/lib/src/models/documents/nodes/leaf.dart

@@ -0,0 +1,252 @@
+import 'dart:math' as math;
+
+import '../../quill_delta.dart';
+import '../style.dart';
+import 'embed.dart';
+import 'line.dart';
+import 'node.dart';
+
+/// A leaf in Quill document tree.
+abstract class Leaf extends Node {
+  /// Creates a new [Leaf] with specified [data].
+  factory Leaf(Object data) {
+    if (data is Embeddable) {
+      return Embed(data);
+    }
+    final text = data as String;
+    assert(text.isNotEmpty);
+    return Text(text);
+  }
+
+  Leaf.val(Object val) : _value = val;
+
+  /// Contents of this node, either a String if this is a [Text] or an
+  /// [Embed] if this is an [BlockEmbed].
+  Object get value => _value;
+  Object _value;
+
+  @override
+  void applyStyle(Style value) {
+    assert(value.isInline || value.isIgnored || value.isEmpty,
+        'Unable to apply Style to leaf: $value');
+    super.applyStyle(value);
+  }
+
+  @override
+  Line? get parent => super.parent as Line?;
+
+  @override
+  int get length {
+    if (_value is String) {
+      return (_value as String).length;
+    }
+    // return 1 for embedded object
+    return 1;
+  }
+
+  @override
+  Delta toDelta() {
+    final data =
+        _value is Embeddable ? (_value as Embeddable).toJson() : _value;
+    return Delta()..insert(data, style.toJson());
+  }
+
+  @override
+  void insert(int index, Object data, Style? style) {
+    assert(index >= 0 && index <= length);
+    final node = Leaf(data);
+    if (index < length) {
+      splitAt(index)!.insertBefore(node);
+    } else {
+      insertAfter(node);
+    }
+    node.format(style);
+  }
+
+  @override
+  void retain(int index, int? len, Style? style) {
+    if (style == null) {
+      return;
+    }
+
+    final local = math.min(length - index, len!);
+    final remain = len - local;
+    final node = _isolate(index, local);
+
+    if (remain > 0) {
+      assert(node.next != null);
+      node.next!.retain(0, remain, style);
+    }
+    node.format(style);
+  }
+
+  @override
+  void delete(int index, int? len) {
+    assert(index < length);
+
+    final local = math.min(length - index, len!);
+    final target = _isolate(index, local);
+    final prev = target.previous as Leaf?;
+    final next = target.next as Leaf?;
+    target.unlink();
+
+    final remain = len - local;
+    if (remain > 0) {
+      assert(next != null);
+      next!.delete(0, remain);
+    }
+
+    if (prev != null) {
+      prev.adjust();
+    }
+  }
+
+  /// Adjust this text node by merging it with adjacent nodes if they share
+  /// the same style.
+  @override
+  void adjust() {
+    if (this is Embed) {
+      // Embed nodes cannot be merged with text nor other embeds (in fact,
+      // there could be no two adjacent embeds on the same line since an
+      // embed occupies an entire line).
+      return;
+    }
+
+    // This is a text node and it can only be merged with other text nodes.
+    var node = this as Text;
+
+    // Merging it with previous node if style is the same.
+    final prev = node.previous;
+    if (!node.isFirst && prev is Text && prev.style == node.style) {
+      prev._value = prev.value + node.value;
+      node.unlink();
+      node = prev;
+    }
+
+    // Merging it with next node if style is the same.
+    final next = node.next;
+    if (!node.isLast && next is Text && next.style == node.style) {
+      node._value = node.value + next.value;
+      next.unlink();
+    }
+  }
+
+  /// Splits this leaf node at [index] and returns new node.
+  ///
+  /// If this is the last node in its list and [index] equals this node's
+  /// length then this method returns `null` as there is nothing left to split.
+  /// If there is another leaf node after this one and [index] equals this
+  /// node's length then the next leaf node is returned.
+  ///
+  /// If [index] equals to `0` then this node itself is returned unchanged.
+  ///
+  /// In case a new node is actually split from this one, it inherits this
+  /// node's style.
+  Leaf? splitAt(int index) {
+    assert(index >= 0 && index <= length);
+    if (index == 0) {
+      return this;
+    }
+    if (index == length) {
+      return isLast ? null : next as Leaf?;
+    }
+
+    assert(this is Text);
+    final text = _value as String;
+    _value = text.substring(0, index);
+    final split = Leaf(text.substring(index))..applyStyle(style);
+    insertAfter(split);
+    return split;
+  }
+
+  /// Cuts a leaf from [index] to the end of this node and returns new node
+  /// in detached state (e.g. [mounted] returns `false`).
+  ///
+  /// Splitting logic is identical to one described in [splitAt], meaning this
+  /// method may return `null`.
+  Leaf? cutAt(int index) {
+    assert(index >= 0 && index <= length);
+    final cut = splitAt(index);
+    cut?.unlink();
+    return cut;
+  }
+
+  /// Formats this node and optimizes it with adjacent leaf nodes if needed.
+  void format(Style? style) {
+    if (style != null && style.isNotEmpty) {
+      applyStyle(style);
+    }
+    adjust();
+  }
+
+  /// Isolates a new leaf starting at [index] with specified [length].
+  ///
+  /// Splitting logic is identical to one described in [splitAt], with one
+  /// exception that it is required for [index] to always be less than this
+  /// node's length. As a result this method always returns a [LeafNode]
+  /// instance. Returned node may still be the same as this node
+  /// if provided [index] is `0`.
+  Leaf _isolate(int index, int length) {
+    assert(
+        index >= 0 && index < this.length && (index + length <= this.length));
+    final target = splitAt(index)!..splitAt(length);
+    return target;
+  }
+}
+
+/// A span of formatted text within a line in a Quill document.
+///
+/// Text is a leaf node of a document tree.
+///
+/// Parent of a text node is always a [Line], and as a consequence text
+/// node's [value] cannot contain any line-break characters.
+///
+/// See also:
+///
+///   * [Embed], a leaf node representing an embeddable object.
+///   * [Line], a node representing a line of text.
+class Text extends Leaf {
+  Text([String text = ''])
+      : assert(!text.contains('\n')),
+        super.val(text);
+
+  @override
+  Node newInstance() => Text(value);
+
+  @override
+  String get value => _value as String;
+
+  @override
+  String toPlainText() => value;
+}
+
+/// An embed node inside of a line in a Quill document.
+///
+/// Embed node is a leaf node similar to [Text]. It represents an arbitrary
+/// piece of non-textual content embedded into a document, such as, image,
+/// horizontal rule, video, or any other object with defined structure,
+/// like a tweet, for instance.
+///
+/// Embed node's length is always `1` character and it is represented with
+/// unicode object replacement character in the document text.
+///
+/// Any inline style can be applied to an embed, however this does not
+/// necessarily mean the embed will look according to that style. For instance,
+/// applying "bold" style to an image gives no effect, while adding a "link" to
+/// an image actually makes the image react to user's action.
+class Embed extends Leaf {
+  Embed(Embeddable data) : super.val(data);
+
+  static const kObjectReplacementCharacter = '\uFFFC';
+
+  @override
+  Node newInstance() => throw UnimplementedError();
+
+  @override
+  Embeddable get value => super.value as Embeddable;
+
+  /// // Embed nodes are represented as unicode object replacement character in
+  // plain text.
+  @override
+  String toPlainText() => kObjectReplacementCharacter;
+}

+ 414 - 0
app_flowy/packages/editor/lib/src/models/documents/nodes/line.dart

@@ -0,0 +1,414 @@
+import 'dart:math' as math;
+
+import 'package:collection/collection.dart';
+
+import '../../quill_delta.dart';
+import '../attribute.dart';
+import '../style.dart';
+import 'block.dart';
+import 'container.dart';
+import 'embed.dart';
+import 'leaf.dart';
+import 'node.dart';
+
+/// A line of rich text in a Quill document.
+///
+/// Line serves as a container for [Leaf]s, like [Text] and [Embed].
+///
+/// When a line contains an embed, it fully occupies the line, no other embeds
+/// or text nodes are allowed.
+class Line extends Container<Leaf?> {
+  @override
+  Leaf get defaultChild => Text();
+
+  @override
+  int get length => super.length + 1;
+
+  /// Returns `true` if this line contains an embedded object.
+  bool get hasEmbed {
+    return children.any((child) => child is Embed);
+  }
+
+  /// Returns next [Line] or `null` if this is the last line in the document.
+  Line? get nextLine {
+    if (!isLast) {
+      return next is Block ? (next as Block).first as Line? : next as Line?;
+    }
+    if (parent is! Block) {
+      return null;
+    }
+
+    if (parent!.isLast) {
+      return null;
+    }
+    return parent!.next is Block
+        ? (parent!.next as Block).first as Line?
+        : parent!.next as Line?;
+  }
+
+  @override
+  Node newInstance() => Line();
+
+  @override
+  Delta toDelta() {
+    final delta = children
+        .map((child) => child.toDelta())
+        .fold(Delta(), (dynamic a, b) => a.concat(b));
+    var attributes = style;
+    if (parent is Block) {
+      final block = parent as Block;
+      attributes = attributes.mergeAll(block.style);
+    }
+    delta.insert('\n', attributes.toJson());
+    return delta;
+  }
+
+  @override
+  String toPlainText() => '${super.toPlainText()}\n';
+
+  @override
+  String toString() {
+    final body = children.join(' → ');
+    final styleString = style.isNotEmpty ? ' $style' : '';
+    return '¶ $body ⏎$styleString';
+  }
+
+  @override
+  void insert(int index, Object data, Style? style) {
+    if (data is Embeddable) {
+      // We do not check whether this line already has any children here as
+      // inserting an embed into a line with other text is acceptable from the
+      // Delta format perspective.
+      // We rely on heuristic rules to ensure that embeds occupy an entire line.
+      _insertSafe(index, data, style);
+      return;
+    }
+
+    final text = data as String;
+    final lineBreak = text.indexOf('\n');
+    if (lineBreak < 0) {
+      _insertSafe(index, text, style);
+      // No need to update line or block format since those attributes can only
+      // be attached to `\n` character and we already know it's not present.
+      return;
+    }
+
+    final prefix = text.substring(0, lineBreak);
+    _insertSafe(index, prefix, style);
+    if (prefix.isNotEmpty) {
+      index += prefix.length;
+    }
+
+    // Next line inherits our format.
+    final nextLine = _getNextLine(index);
+
+    // Reset our format and unwrap from a block if needed.
+    clearStyle();
+    if (parent is Block) {
+      _unwrap();
+    }
+
+    // Now we can apply new format and re-layout.
+    _format(style);
+
+    // Continue with remaining part.
+    final remain = text.substring(lineBreak + 1);
+    nextLine.insert(0, remain, style);
+  }
+
+  @override
+  void retain(int index, int? len, Style? style) {
+    if (style == null) {
+      return;
+    }
+    final thisLength = length;
+
+    final local = math.min(thisLength - index, len!);
+    // If index is at newline character then this is a line/block style update.
+    final isLineFormat = (index + local == thisLength) && local == 1;
+
+    if (isLineFormat) {
+      assert(style.values.every((attr) => attr.scope == AttributeScope.BLOCK),
+          'It is not allowed to apply inline attributes to line itself.');
+      _format(style);
+    } else {
+      // Otherwise forward to children as it's an inline format update.
+      assert(style.values.every((attr) => attr.scope == AttributeScope.INLINE));
+      assert(index + local != thisLength);
+      super.retain(index, local, style);
+    }
+
+    final remain = len - local;
+    if (remain > 0) {
+      assert(nextLine != null);
+      nextLine!.retain(0, remain, style);
+    }
+  }
+
+  @override
+  void delete(int index, int? len) {
+    final local = math.min(length - index, len!);
+    final isLFDeleted = index + local == length; // Line feed
+    if (isLFDeleted) {
+      // Our newline character deleted with all style information.
+      clearStyle();
+      if (local > 1) {
+        // Exclude newline character from delete range for children.
+        super.delete(index, local - 1);
+      }
+    } else {
+      super.delete(index, local);
+    }
+
+    final remaining = len - local;
+    if (remaining > 0) {
+      assert(nextLine != null);
+      nextLine!.delete(0, remaining);
+    }
+
+    if (isLFDeleted && isNotEmpty) {
+      // Since we lost our line-break and still have child text nodes those must
+      // migrate to the next line.
+
+      // nextLine might have been unmounted since last assert so we need to
+      // check again we still have a line after us.
+      assert(nextLine != null);
+
+      // Move remaining children in this line to the next line so that all
+      // attributes of nextLine are preserved.
+      nextLine!.moveChildToNewParent(this);
+      moveChildToNewParent(nextLine);
+    }
+
+    if (isLFDeleted) {
+      // Now we can remove this line.
+      final block = parent!; // remember reference before un-linking.
+      unlink();
+      block.adjust();
+    }
+  }
+
+  /// Formats this line.
+  void _format(Style? newStyle) {
+    if (newStyle == null || newStyle.isEmpty) {
+      return;
+    }
+
+    applyStyle(newStyle);
+    final blockStyle = newStyle.getBlockExceptHeader();
+    if (blockStyle == null) {
+      return;
+    } // No block-level changes
+
+    if (parent is Block) {
+      final parentStyle = (parent as Block).style.getBlocksExceptHeader();
+      // Ensure that we're only unwrapping the block only if we unset a single
+      // block format in the `parentStyle` and there are no more block formats
+      // left to unset.
+      if (blockStyle.value == null &&
+          parentStyle.containsKey(blockStyle.key) &&
+          parentStyle.length == 1) {
+        _unwrap();
+      } else if (!const MapEquality()
+          .equals(newStyle.getBlocksExceptHeader(), parentStyle)) {
+        _unwrap();
+        // Block style now can contain multiple attributes
+        if (newStyle.attributes.keys
+            .any(Attribute.exclusiveBlockKeys.contains)) {
+          parentStyle.removeWhere(
+              (key, attr) => Attribute.exclusiveBlockKeys.contains(key));
+        }
+        parentStyle.removeWhere(
+            (key, attr) => newStyle?.attributes.keys.contains(key) ?? false);
+        final parentStyleToMerge = Style.attr(parentStyle);
+        newStyle = newStyle.mergeAll(parentStyleToMerge);
+        _applyBlockStyles(newStyle);
+      } // else the same style, no-op.
+    } else if (blockStyle.value != null) {
+      // Only wrap with a new block if this is not an unset
+      _applyBlockStyles(newStyle);
+    }
+  }
+
+  void _applyBlockStyles(Style newStyle) {
+    var block = Block();
+    for (final style in newStyle.getBlocksExceptHeader().values) {
+      block = block..applyAttribute(style);
+    }
+    _wrap(block);
+    block.adjust();
+  }
+
+  /// Wraps this line with new parent [block].
+  ///
+  /// This line can not be in a [Block] when this method is called.
+  void _wrap(Block block) {
+    assert(parent != null && parent is! Block);
+    insertAfter(block);
+    unlink();
+    block.add(this);
+  }
+
+  /// Unwraps this line from it's parent [Block].
+  ///
+  /// This method asserts if current [parent] of this line is not a [Block].
+  void _unwrap() {
+    if (parent is! Block) {
+      throw ArgumentError('Invalid parent');
+    }
+    final block = parent as Block;
+
+    assert(block.children.contains(this));
+
+    if (isFirst) {
+      unlink();
+      block.insertBefore(this);
+    } else if (isLast) {
+      unlink();
+      block.insertAfter(this);
+    } else {
+      final before = block.clone() as Block;
+      block.insertBefore(before);
+
+      var child = block.first as Line;
+      while (child != this) {
+        child.unlink();
+        before.add(child);
+        child = block.first as Line;
+      }
+      unlink();
+      block.insertBefore(this);
+    }
+    block.adjust();
+  }
+
+  Line _getNextLine(int index) {
+    assert(index == 0 || (index > 0 && index < length));
+
+    final line = clone() as Line;
+    insertAfter(line);
+    if (index == length - 1) {
+      return line;
+    }
+
+    final query = queryChild(index, false);
+    while (!query.node!.isLast) {
+      final next = (last as Leaf)..unlink();
+      line.addFirst(next);
+    }
+    final child = query.node as Leaf;
+    final cut = child.splitAt(query.offset);
+    cut?.unlink();
+    line.addFirst(cut);
+    return line;
+  }
+
+  void _insertSafe(int index, Object data, Style? style) {
+    assert(index == 0 || (index > 0 && index < length));
+
+    if (data is String) {
+      assert(!data.contains('\n'));
+      if (data.isEmpty) {
+        return;
+      }
+    }
+
+    if (isEmpty) {
+      final child = Leaf(data);
+      add(child);
+      child.format(style);
+    } else {
+      final result = queryChild(index, true);
+      result.node!.insert(result.offset, data, style);
+    }
+  }
+
+  /// Returns style for specified text range.
+  ///
+  /// Only attributes applied to all characters within this range are
+  /// included in the result. Inline and line level attributes are
+  /// handled separately, e.g.:
+  ///
+  /// - line attribute X is included in the result only if it exists for
+  ///   every line within this range (partially included lines are counted).
+  /// - inline attribute X is included in the result only if it exists
+  ///   for every character within this range (line-break characters excluded).
+  Style collectStyle(int offset, int len) {
+    final local = math.min(length - offset, len);
+    var result = Style();
+    final excluded = <Attribute>{};
+
+    void _handle(Style style) {
+      if (result.isEmpty) {
+        excluded.addAll(style.values);
+      } else {
+        for (final attr in result.values) {
+          if (!style.containsKey(attr.key)) {
+            excluded.add(attr);
+          }
+        }
+      }
+      final remaining = style.removeAll(excluded);
+      result = result.removeAll(excluded);
+      result = result.mergeAll(remaining);
+    }
+
+    final data = queryChild(offset, true);
+    var node = data.node as Leaf?;
+    if (node != null) {
+      result = result.mergeAll(node.style);
+      var pos = node.length - data.offset;
+      while (!node!.isLast && pos < local) {
+        node = node.next as Leaf?;
+        _handle(node!.style);
+        pos += node.length;
+      }
+    }
+
+    result = result.mergeAll(style);
+    if (parent is Block) {
+      final block = parent as Block;
+      result = result.mergeAll(block.style);
+    }
+
+    final remaining = len - local;
+    if (remaining > 0) {
+      final rest = nextLine!.collectStyle(0, remaining);
+      _handle(rest);
+    }
+
+    return result;
+  }
+
+  /// Returns all styles for any character within the specified text range.
+  List<Style> collectAllStyles(int offset, int len) {
+    final local = math.min(length - offset, len);
+    final result = <Style>[];
+
+    final data = queryChild(offset, true);
+    var node = data.node as Leaf?;
+    if (node != null) {
+      result.add(node.style);
+      var pos = node.length - data.offset;
+      while (!node!.isLast && pos < local) {
+        node = node.next as Leaf?;
+        result.add(node!.style);
+        pos += node.length;
+      }
+    }
+
+    result.add(style);
+    if (parent is Block) {
+      final block = parent as Block;
+      result.add(block.style);
+    }
+
+    final remaining = len - local;
+    if (remaining > 0) {
+      final rest = nextLine!.collectAllStyles(0, remaining);
+      result.addAll(rest);
+    }
+
+    return result;
+  }
+}

+ 134 - 0
app_flowy/packages/editor/lib/src/models/documents/nodes/node.dart

@@ -0,0 +1,134 @@
+import 'dart:collection';
+
+import '../../quill_delta.dart';
+import '../attribute.dart';
+import '../style.dart';
+import 'container.dart';
+import 'line.dart';
+
+/// An abstract node in a document tree.
+///
+/// Represents a segment of a Quill document with specified [offset]
+/// and [length].
+///
+/// The [offset] property is relative to [parent]. See also [documentOffset]
+/// which provides absolute offset of this node within the document.
+///
+/// The current parent node is exposed by the [parent] property.
+abstract class Node extends LinkedListEntry<Node> {
+  /// Current parent of this node. May be null if this node is not mounted.
+  Container? parent;
+
+  Style get style => _style;
+  Style _style = Style();
+
+  /// Returns `true` if this node is the first node in the [parent] list.
+  bool get isFirst => list!.first == this;
+
+  /// Returns `true` if this node is the last node in the [parent] list.
+  bool get isLast => list!.last == this;
+
+  /// Length of this node in characters.
+  int get length;
+
+  Node clone() => newInstance()..applyStyle(style);
+
+  /// Offset in characters of this node relative to [parent] node.
+  ///
+  /// To get offset of this node in the document see [documentOffset].
+  int get offset {
+    var offset = 0;
+
+    if (list == null || isFirst) {
+      return offset;
+    }
+
+    var cur = this;
+    do {
+      cur = cur.previous!;
+      offset += cur.length;
+    } while (!cur.isFirst);
+    return offset;
+  }
+
+  /// Offset in characters of this node in the document.
+  int get documentOffset {
+    if (parent == null) {
+      return offset;
+    }
+    final parentOffset = (parent is! Root) ? parent!.documentOffset : 0;
+    return parentOffset + offset;
+  }
+
+  /// Returns `true` if this node contains character at specified [offset] in
+  /// the document.
+  bool containsOffset(int offset) {
+    final o = documentOffset;
+    return o <= offset && offset < o + length;
+  }
+
+  void applyAttribute(Attribute attribute) {
+    _style = _style.merge(attribute);
+  }
+
+  void applyStyle(Style value) {
+    _style = _style.mergeAll(value);
+  }
+
+  void clearStyle() {
+    _style = Style();
+  }
+
+  @override
+  void insertBefore(Node entry) {
+    assert(entry.parent == null && parent != null);
+    entry.parent = parent;
+    super.insertBefore(entry);
+  }
+
+  @override
+  void insertAfter(Node entry) {
+    assert(entry.parent == null && parent != null);
+    entry.parent = parent;
+    super.insertAfter(entry);
+  }
+
+  @override
+  void unlink() {
+    assert(parent != null);
+    parent = null;
+    super.unlink();
+  }
+
+  void adjust() {/* no-op */}
+
+  /// abstract methods begin
+
+  Node newInstance();
+
+  String toPlainText();
+
+  Delta toDelta();
+
+  void insert(int index, Object data, Style? style);
+
+  void retain(int index, int? len, Style? style);
+
+  void delete(int index, int? len);
+
+  /// abstract methods end
+}
+
+/// Root node of document tree.
+class Root extends Container<Container<Node?>> {
+  @override
+  Node newInstance() => Root();
+
+  @override
+  Container<Node?> get defaultChild => Line();
+
+  @override
+  Delta toDelta() => children
+      .map((child) => child.toDelta())
+      .fold(Delta(), (a, b) => a.concat(b));
+}

+ 128 - 0
app_flowy/packages/editor/lib/src/models/documents/style.dart

@@ -0,0 +1,128 @@
+import 'package:collection/collection.dart';
+import 'package:quiver/core.dart';
+
+import 'attribute.dart';
+
+/* Collection of style attributes */
+class Style {
+  Style() : _attributes = <String, Attribute>{};
+
+  Style.attr(this._attributes);
+
+  final Map<String, Attribute> _attributes;
+
+  static Style fromJson(Map<String, dynamic>? attributes) {
+    if (attributes == null) {
+      return Style();
+    }
+
+    final result = attributes.map((key, dynamic value) {
+      final attr = Attribute.fromKeyValue(key, value);
+      return MapEntry<String, Attribute>(
+          key, attr ?? Attribute(key, AttributeScope.IGNORE, value));
+    });
+    return Style.attr(result);
+  }
+
+  Map<String, dynamic>? toJson() => _attributes.isEmpty
+      ? null
+      : _attributes.map<String, dynamic>((_, attribute) =>
+          MapEntry<String, dynamic>(attribute.key, attribute.value));
+
+  Iterable<String> get keys => _attributes.keys;
+
+  Iterable<Attribute> get values => _attributes.values.sorted(
+      (a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b));
+
+  Map<String, Attribute> get attributes => _attributes;
+
+  bool get isEmpty => _attributes.isEmpty;
+
+  bool get isNotEmpty => _attributes.isNotEmpty;
+
+  bool get isInline => isNotEmpty && values.every((item) => item.isInline);
+
+  bool get isIgnored =>
+      isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE);
+
+  Attribute get single => _attributes.values.single;
+
+  bool containsKey(String key) => _attributes.containsKey(key);
+
+  Attribute? getBlockExceptHeader() {
+    for (final val in values) {
+      if (val.isBlockExceptHeader && val.value != null) {
+        return val;
+      }
+    }
+    for (final val in values) {
+      if (val.isBlockExceptHeader) {
+        return val;
+      }
+    }
+    return null;
+  }
+
+  Map<String, Attribute> getBlocksExceptHeader() {
+    final m = <String, Attribute>{};
+    attributes.forEach((key, value) {
+      if (Attribute.blockKeysExceptHeader.contains(key)) {
+        m[key] = value;
+      }
+    });
+    return m;
+  }
+
+  Style merge(Attribute attribute) {
+    final merged = Map<String, Attribute>.from(_attributes);
+    if (attribute.value == null) {
+      merged.remove(attribute.key);
+    } else {
+      merged[attribute.key] = attribute;
+    }
+    return Style.attr(merged);
+  }
+
+  Style mergeAll(Style other) {
+    var result = Style.attr(_attributes);
+    for (final attribute in other.values) {
+      result = result.merge(attribute);
+    }
+    return result;
+  }
+
+  Style removeAll(Set<Attribute> attributes) {
+    final merged = Map<String, Attribute>.from(_attributes);
+    attributes.map((item) => item.key).forEach(merged.remove);
+    return Style.attr(merged);
+  }
+
+  Style put(Attribute attribute) {
+    final m = Map<String, Attribute>.from(attributes);
+    m[attribute.key] = attribute;
+    return Style.attr(m);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) {
+      return true;
+    }
+    if (other is! Style) {
+      return false;
+    }
+    final typedOther = other;
+    const eq = MapEquality<String, Attribute>();
+    return eq.equals(_attributes, typedOther._attributes);
+  }
+
+  @override
+  int get hashCode {
+    final hashes =
+        _attributes.entries.map((entry) => hash2(entry.key, entry.value));
+    return hashObjects(hashes);
+  }
+
+  @override
+  String toString() => "{${_attributes.values.join(', ')}}";
+}

+ 803 - 0
app_flowy/packages/editor/lib/src/models/quill_delta.dart

@@ -0,0 +1,803 @@
+// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this
+// source code is governed by a BSD-style license that can be found in the
+// LICENSE file.
+
+/// Implementation of Quill Delta format in Dart.
+library quill_delta;
+
+import 'dart:math' as math;
+
+import 'package:collection/collection.dart';
+import 'package:diff_match_patch/diff_match_patch.dart' as dmp;
+import 'package:quiver/core.dart';
+
+const _attributeEquality = DeepCollectionEquality();
+const _valueEquality = DeepCollectionEquality();
+
+/// Decoder function to convert raw `data` object into a user-defined data type.
+///
+/// Useful with embedded content.
+typedef DataDecoder = Object? Function(Object data);
+
+/// Default data decoder which simply passes through the original value.
+Object? _passThroughDataDecoder(Object? data) => data;
+
+/// Operation performed on a rich-text document.
+class Operation {
+  Operation._(this.key, this.length, this.data, Map? attributes)
+      : assert(_validKeys.contains(key), 'Invalid operation key "$key".'),
+        assert(() {
+          if (key != Operation.insertKey) return true;
+          return data is String ? data.length == length : length == 1;
+        }(), 'Length of insert operation must be equal to the data length.'),
+        _attributes =
+            attributes != null ? Map<String, dynamic>.from(attributes) : null;
+
+  /// Creates operation which deletes [length] of characters.
+  factory Operation.delete(int length) =>
+      Operation._(Operation.deleteKey, length, '', null);
+
+  /// Creates operation which inserts [text] with optional [attributes].
+  factory Operation.insert(dynamic data, [Map<String, dynamic>? attributes]) =>
+      Operation._(Operation.insertKey, data is String ? data.length : 1, data,
+          attributes);
+
+  /// Creates operation which retains [length] of characters and optionally
+  /// applies attributes.
+  factory Operation.retain(int? length, [Map<String, dynamic>? attributes]) =>
+      Operation._(Operation.retainKey, length, '', attributes);
+
+  /// Key of insert operations.
+  static const String insertKey = 'insert';
+
+  /// Key of delete operations.
+  static const String deleteKey = 'delete';
+
+  /// Key of retain operations.
+  static const String retainKey = 'retain';
+
+  /// Key of attributes collection.
+  static const String attributesKey = 'attributes';
+
+  static const List<String> _validKeys = [insertKey, deleteKey, retainKey];
+
+  /// Key of this operation, can be "insert", "delete" or "retain".
+  final String key;
+
+  /// Length of this operation.
+  final int? length;
+
+  /// Payload of "insert" operation, for other types is set to empty string.
+  final Object? data;
+
+  /// Rich-text attributes set by this operation, can be `null`.
+  Map<String, dynamic>? get attributes =>
+      _attributes == null ? null : Map<String, dynamic>.from(_attributes!);
+  final Map<String, dynamic>? _attributes;
+
+  /// Creates new [Operation] from JSON payload.
+  ///
+  /// If `dataDecoder` parameter is not null then it is used to additionally
+  /// decode the operation's data object. Only applied to insert operations.
+  static Operation fromJson(Map data, {DataDecoder? dataDecoder}) {
+    dataDecoder ??= _passThroughDataDecoder;
+    final map = Map<String, dynamic>.from(data);
+    if (map.containsKey(Operation.insertKey)) {
+      final data = dataDecoder(map[Operation.insertKey]);
+      final dataLength = data is String ? data.length : 1;
+      return Operation._(
+          Operation.insertKey, dataLength, data, map[Operation.attributesKey]);
+    } else if (map.containsKey(Operation.deleteKey)) {
+      final int? length = map[Operation.deleteKey];
+      return Operation._(Operation.deleteKey, length, '', null);
+    } else if (map.containsKey(Operation.retainKey)) {
+      final int? length = map[Operation.retainKey];
+      return Operation._(
+          Operation.retainKey, length, '', map[Operation.attributesKey]);
+    }
+    throw ArgumentError.value(data, 'Invalid data for Delta operation.');
+  }
+
+  /// Returns JSON-serializable representation of this operation.
+  Map<String, dynamic> toJson() {
+    final json = {key: value};
+    if (_attributes != null) json[Operation.attributesKey] = attributes;
+    return json;
+  }
+
+  /// Returns value of this operation.
+  ///
+  /// For insert operations this returns text, for delete and retain - length.
+  dynamic get value => (key == Operation.insertKey) ? data : length;
+
+  /// Returns `true` if this is a delete operation.
+  bool get isDelete => key == Operation.deleteKey;
+
+  /// Returns `true` if this is an insert operation.
+  bool get isInsert => key == Operation.insertKey;
+
+  /// Returns `true` if this is a retain operation.
+  bool get isRetain => key == Operation.retainKey;
+
+  /// Returns `true` if this operation has no attributes, e.g. is plain text.
+  bool get isPlain => _attributes == null || _attributes!.isEmpty;
+
+  /// Returns `true` if this operation sets at least one attribute.
+  bool get isNotPlain => !isPlain;
+
+  /// Returns `true` is this operation is empty.
+  ///
+  /// An operation is considered empty if its [length] is equal to `0`.
+  bool get isEmpty => length == 0;
+
+  /// Returns `true` is this operation is not empty.
+  bool get isNotEmpty => length! > 0;
+
+  @override
+  bool operator ==(other) {
+    if (identical(this, other)) return true;
+    if (other is! Operation) return false;
+    final typedOther = other;
+    return key == typedOther.key &&
+        length == typedOther.length &&
+        _valueEquality.equals(data, typedOther.data) &&
+        hasSameAttributes(typedOther);
+  }
+
+  /// Returns `true` if this operation has attribute specified by [name].
+  bool hasAttribute(String name) =>
+      isNotPlain && _attributes!.containsKey(name);
+
+  /// Returns `true` if [other] operation has the same attributes as this one.
+  bool hasSameAttributes(Operation other) {
+    return _attributeEquality.equals(_attributes, other._attributes);
+  }
+
+  @override
+  int get hashCode {
+    if (_attributes != null && _attributes!.isNotEmpty) {
+      final attrsHash =
+          hashObjects(_attributes!.entries.map((e) => hash2(e.key, e.value)));
+      return hash3(key, value, attrsHash);
+    }
+    return hash2(key, value);
+  }
+
+  @override
+  String toString() {
+    final attr = attributes == null ? '' : ' + $attributes';
+    final text = isInsert
+        ? (data is String
+            ? (data as String).replaceAll('\n', '⏎')
+            : data.toString())
+        : '$length';
+    return '$key⟨ $text ⟩$attr';
+  }
+}
+
+/// Delta represents a document or a modification of a document as a sequence of
+/// insert, delete and retain operations.
+///
+/// Delta consisting of only "insert" operations is usually referred to as
+/// "document delta". When delta includes also "retain" or "delete" operations
+/// it is a "change delta".
+class Delta {
+  /// Creates new empty [Delta].
+  factory Delta() => Delta._(<Operation>[]);
+
+  Delta._(List<Operation> operations) : _operations = operations;
+
+  /// Creates new [Delta] from [other].
+  factory Delta.from(Delta other) =>
+      Delta._(List<Operation>.from(other._operations));
+
+  // Placeholder char for embed in diff()
+  static final String _kNullCharacter = String.fromCharCode(0);
+
+  /// Transforms two attribute sets.
+  static Map<String, dynamic>? transformAttributes(
+      Map<String, dynamic>? a, Map<String, dynamic>? b, bool priority) {
+    if (a == null) return b;
+    if (b == null) return null;
+
+    if (!priority) return b;
+
+    final result = b.keys.fold<Map<String, dynamic>>({}, (attributes, key) {
+      if (!a.containsKey(key)) attributes[key] = b[key];
+      return attributes;
+    });
+
+    return result.isEmpty ? null : result;
+  }
+
+  /// Composes two attribute sets.
+  static Map<String, dynamic>? composeAttributes(
+      Map<String, dynamic>? a, Map<String, dynamic>? b,
+      {bool keepNull = false}) {
+    a ??= const {};
+    b ??= const {};
+
+    final result = Map<String, dynamic>.from(a)..addAll(b);
+    final keys = result.keys.toList(growable: false);
+
+    if (!keepNull) {
+      for (final key in keys) {
+        if (result[key] == null) result.remove(key);
+      }
+    }
+
+    return result.isEmpty ? null : result;
+  }
+
+  ///get anti-attr result base on base
+  static Map<String, dynamic> invertAttributes(
+      Map<String, dynamic>? attr, Map<String, dynamic>? base) {
+    attr ??= const {};
+    base ??= const {};
+
+    final baseInverted = base.keys.fold({}, (dynamic memo, key) {
+      if (base![key] != attr![key] && attr.containsKey(key)) {
+        memo[key] = base[key];
+      }
+      return memo;
+    });
+
+    final inverted =
+        Map<String, dynamic>.from(attr.keys.fold(baseInverted, (memo, key) {
+      if (base![key] != attr![key] && !base.containsKey(key)) {
+        memo[key] = null;
+      }
+      return memo;
+    }));
+    return inverted;
+  }
+
+  /// Returns diff between two attribute sets
+  static Map<String, dynamic>? diffAttributes(
+      Map<String, dynamic>? a, Map<String, dynamic>? b) {
+    a ??= const {};
+    b ??= const {};
+
+    final attributes = <String, dynamic>{};
+    (a.keys.toList()..addAll(b.keys)).forEach((key) {
+      if (a![key] != b![key]) {
+        attributes[key] = b.containsKey(key) ? b[key] : null;
+      }
+    });
+
+    return attributes.keys.isNotEmpty ? attributes : null;
+  }
+
+  final List<Operation> _operations;
+
+  int _modificationCount = 0;
+
+  /// Creates [Delta] from de-serialized JSON representation.
+  ///
+  /// If `dataDecoder` parameter is not null then it is used to additionally
+  /// decode the operation's data object. Only applied to insert operations.
+  static Delta fromJson(List data, {DataDecoder? dataDecoder}) {
+    return Delta._(data
+        .map((op) => Operation.fromJson(op, dataDecoder: dataDecoder))
+        .toList());
+  }
+
+  /// Returns list of operations in this delta.
+  List<Operation> toList() => List.from(_operations);
+
+  /// Returns JSON-serializable version of this delta.
+  List toJson() => toList().map((operation) => operation.toJson()).toList();
+
+  /// Returns `true` if this delta is empty.
+  bool get isEmpty => _operations.isEmpty;
+
+  /// Returns `true` if this delta is not empty.
+  bool get isNotEmpty => _operations.isNotEmpty;
+
+  /// Returns number of operations in this delta.
+  int get length => _operations.length;
+
+  /// Returns [Operation] at specified [index] in this delta.
+  Operation operator [](int index) => _operations[index];
+
+  /// Returns [Operation] at specified [index] in this delta.
+  Operation elementAt(int index) => _operations.elementAt(index);
+
+  /// Returns the first [Operation] in this delta.
+  Operation get first => _operations.first;
+
+  /// Returns the last [Operation] in this delta.
+  Operation get last => _operations.last;
+
+  @override
+  bool operator ==(dynamic other) {
+    if (identical(this, other)) return true;
+    if (other is! Delta) return false;
+    final typedOther = other;
+    const comparator = ListEquality<Operation>(DefaultEquality<Operation>());
+    return comparator.equals(_operations, typedOther._operations);
+  }
+
+  @override
+  int get hashCode => hashObjects(_operations);
+
+  /// Retain [count] of characters from current position.
+  void retain(int count, [Map<String, dynamic>? attributes]) {
+    assert(count >= 0);
+    if (count == 0) return; // no-op
+    push(Operation.retain(count, attributes));
+  }
+
+  /// Insert [data] at current position.
+  void insert(dynamic data, [Map<String, dynamic>? attributes]) {
+    if (data is String && data.isEmpty) return; // no-op
+    push(Operation.insert(data, attributes));
+  }
+
+  /// Delete [count] characters from current position.
+  void delete(int count) {
+    assert(count >= 0);
+    if (count == 0) return;
+    push(Operation.delete(count));
+  }
+
+  void _mergeWithTail(Operation operation) {
+    assert(isNotEmpty);
+    assert(last.key == operation.key);
+    assert(operation.data is String && last.data is String);
+
+    final length = operation.length! + last.length!;
+    final lastText = last.data as String;
+    final opText = operation.data as String;
+    final resultText = lastText + opText;
+    final index = _operations.length;
+    _operations.replaceRange(index - 1, index, [
+      Operation._(operation.key, length, resultText, operation.attributes),
+    ]);
+  }
+
+  /// Pushes new operation into this delta.
+  ///
+  /// Performs compaction by composing [operation] with current tail operation
+  /// of this delta, when possible. For instance, if current tail is
+  /// `insert('abc')` and pushed operation is `insert('123')` then existing
+  /// tail is replaced with `insert('abc123')` - a compound result of the two
+  /// operations.
+  void push(Operation operation) {
+    if (operation.isEmpty) return;
+
+    var index = _operations.length;
+    final lastOp = _operations.isNotEmpty ? _operations.last : null;
+    if (lastOp != null) {
+      if (lastOp.isDelete && operation.isDelete) {
+        _mergeWithTail(operation);
+        return;
+      }
+
+      if (lastOp.isDelete && operation.isInsert) {
+        index -= 1; // Always insert before deleting
+        final nLastOp = (index > 0) ? _operations.elementAt(index - 1) : null;
+        if (nLastOp == null) {
+          _operations.insert(0, operation);
+          return;
+        }
+      }
+
+      if (lastOp.isInsert && operation.isInsert) {
+        if (lastOp.hasSameAttributes(operation) &&
+            operation.data is String &&
+            lastOp.data is String) {
+          _mergeWithTail(operation);
+          return;
+        }
+      }
+
+      if (lastOp.isRetain && operation.isRetain) {
+        if (lastOp.hasSameAttributes(operation)) {
+          _mergeWithTail(operation);
+          return;
+        }
+      }
+    }
+    if (index == _operations.length) {
+      _operations.add(operation);
+    } else {
+      final opAtIndex = _operations.elementAt(index);
+      _operations.replaceRange(index, index + 1, [operation, opAtIndex]);
+    }
+    _modificationCount++;
+  }
+
+  /// Composes next operation from [thisIter] and [otherIter].
+  ///
+  /// Returns new operation or `null` if operations from [thisIter] and
+  /// [otherIter] nullify each other. For instance, for the pair `insert('abc')`
+  /// and `delete(3)` composition result would be empty string.
+  Operation? _composeOperation(
+      DeltaIterator thisIter, DeltaIterator otherIter) {
+    if (otherIter.isNextInsert) return otherIter.next();
+    if (thisIter.isNextDelete) return thisIter.next();
+
+    final length = math.min(thisIter.peekLength(), otherIter.peekLength());
+    final thisOp = thisIter.next(length);
+    final otherOp = otherIter.next(length);
+    assert(thisOp.length == otherOp.length);
+
+    if (otherOp.isRetain) {
+      final attributes = composeAttributes(
+        thisOp.attributes,
+        otherOp.attributes,
+        keepNull: thisOp.isRetain,
+      );
+      if (thisOp.isRetain) {
+        return Operation.retain(thisOp.length, attributes);
+      } else if (thisOp.isInsert) {
+        return Operation.insert(thisOp.data, attributes);
+      } else {
+        throw StateError('Unreachable');
+      }
+    } else {
+      // otherOp == delete && thisOp in [retain, insert]
+      assert(otherOp.isDelete);
+      if (thisOp.isRetain) return otherOp;
+      assert(thisOp.isInsert);
+      // otherOp(delete) + thisOp(insert) => null
+    }
+    return null;
+  }
+
+  /// Composes this delta with [other] and returns new [Delta].
+  ///
+  /// It is not required for this and [other] delta to represent a document
+  /// delta (consisting only of insert operations).
+  Delta compose(Delta other) {
+    final result = Delta();
+    final thisIter = DeltaIterator(this);
+    final otherIter = DeltaIterator(other);
+
+    while (thisIter.hasNext || otherIter.hasNext) {
+      final newOp = _composeOperation(thisIter, otherIter);
+      if (newOp != null) result.push(newOp);
+    }
+    return result..trim();
+  }
+
+  /// Returns a new lazy Iterable with elements that are created by calling
+  /// f on each element of this Iterable in iteration order.
+  ///
+  /// Convenience method
+  Iterable<T> map<T>(T Function(Operation) f) {
+    return _operations.map<T>(f);
+  }
+
+  /// Returns a [Delta] containing differences between 2 [Delta]s.
+  /// If [cleanupSemantic] is `true` (default), applies the following:
+  ///
+  /// The diff of "mouse" and "sofas" is
+  ///   [delete(1), insert("s"), retain(1),
+  ///   delete("u"), insert("fa"), retain(1), delete(1)].
+  /// While this is the optimum diff, it is difficult for humans to understand.
+  /// Semantic cleanup rewrites the diff,
+  /// expanding it into a more intelligible format.
+  /// The above example would become: [(-1, "mouse"), (1, "sofas")].
+  /// (source: https://github.com/google/diff-match-patch/wiki/API)
+  ///
+  /// Useful when one wishes to display difference between 2 documents
+  Delta diff(Delta other, {bool cleanupSemantic = true}) {
+    if (_operations.equals(other._operations)) {
+      return Delta();
+    }
+    final stringThis = map((op) {
+      if (op.isInsert) {
+        return op.data is String ? op.data : _kNullCharacter;
+      }
+      final prep = this == other ? 'on' : 'with';
+      throw ArgumentError('diff() call $prep non-document');
+    }).join();
+    final stringOther = other.map((op) {
+      if (op.isInsert) {
+        return op.data is String ? op.data : _kNullCharacter;
+      }
+      final prep = this == other ? 'on' : 'with';
+      throw ArgumentError('diff() call $prep non-document');
+    }).join();
+
+    final retDelta = Delta();
+    final diffResult = dmp.diff(stringThis, stringOther);
+    if (cleanupSemantic) {
+      dmp.DiffMatchPatch().diffCleanupSemantic(diffResult);
+    }
+
+    final thisIter = DeltaIterator(this);
+    final otherIter = DeltaIterator(other);
+
+    diffResult.forEach((component) {
+      var length = component.text.length;
+      while (length > 0) {
+        var opLength = 0;
+        switch (component.operation) {
+          case dmp.DIFF_INSERT:
+            opLength = math.min(otherIter.peekLength(), length);
+            retDelta.push(otherIter.next(opLength));
+            break;
+          case dmp.DIFF_DELETE:
+            opLength = math.min(length, thisIter.peekLength());
+            thisIter.next(opLength);
+            retDelta.delete(opLength);
+            break;
+          case dmp.DIFF_EQUAL:
+            opLength = math.min(
+              math.min(thisIter.peekLength(), otherIter.peekLength()),
+              length,
+            );
+            final thisOp = thisIter.next(opLength);
+            final otherOp = otherIter.next(opLength);
+            if (thisOp.data == otherOp.data) {
+              retDelta.retain(
+                opLength,
+                diffAttributes(thisOp.attributes, otherOp.attributes),
+              );
+            } else {
+              retDelta
+                ..push(otherOp)
+                ..delete(opLength);
+            }
+            break;
+        }
+        length -= opLength;
+      }
+    });
+    return retDelta..trim();
+  }
+
+  /// Transforms next operation from [otherIter] against next operation in
+  /// [thisIter].
+  ///
+  /// Returns `null` if both operations nullify each other.
+  Operation? _transformOperation(
+      DeltaIterator thisIter, DeltaIterator otherIter, bool priority) {
+    if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) {
+      return Operation.retain(thisIter.next().length);
+    } else if (otherIter.isNextInsert) {
+      return otherIter.next();
+    }
+
+    final length = math.min(thisIter.peekLength(), otherIter.peekLength());
+    final thisOp = thisIter.next(length);
+    final otherOp = otherIter.next(length);
+    assert(thisOp.length == otherOp.length);
+
+    // At this point only delete and retain operations are possible.
+    if (thisOp.isDelete) {
+      // otherOp is either delete or retain, so they nullify each other.
+      return null;
+    } else if (otherOp.isDelete) {
+      return otherOp;
+    } else {
+      // Retain otherOp which is either retain or insert.
+      return Operation.retain(
+        length,
+        transformAttributes(thisOp.attributes, otherOp.attributes, priority),
+      );
+    }
+  }
+
+  /// Transforms [other] delta against operations in this delta.
+  Delta transform(Delta other, bool priority) {
+    final result = Delta();
+    final thisIter = DeltaIterator(this);
+    final otherIter = DeltaIterator(other);
+
+    while (thisIter.hasNext || otherIter.hasNext) {
+      final newOp = _transformOperation(thisIter, otherIter, priority);
+      if (newOp != null) result.push(newOp);
+    }
+    return result..trim();
+  }
+
+  /// Removes trailing retain operation with empty attributes, if present.
+  void trim() {
+    if (isNotEmpty) {
+      final last = _operations.last;
+      if (last.isRetain && last.isPlain) _operations.removeLast();
+    }
+  }
+
+  /// Concatenates [other] with this delta and returns the result.
+  Delta concat(Delta other) {
+    final result = Delta.from(this);
+    if (other.isNotEmpty) {
+      // In case first operation of other can be merged with last operation in
+      // our list.
+      result.push(other._operations.first);
+      result._operations.addAll(other._operations.sublist(1));
+    }
+    return result;
+  }
+
+  /// Inverts this delta against [base].
+  ///
+  /// Returns new delta which negates effect of this delta when applied to
+  /// [base]. This is an equivalent of "undo" operation on deltas.
+  Delta invert(Delta base) {
+    final inverted = Delta();
+    if (base.isEmpty) return inverted;
+
+    var baseIndex = 0;
+    for (final op in _operations) {
+      if (op.isInsert) {
+        inverted.delete(op.length!);
+      } else if (op.isRetain && op.isPlain) {
+        inverted.retain(op.length!);
+        baseIndex += op.length!;
+      } else if (op.isDelete || (op.isRetain && op.isNotPlain)) {
+        final length = op.length!;
+        final sliceDelta = base.slice(baseIndex, baseIndex + length);
+        sliceDelta.toList().forEach((baseOp) {
+          if (op.isDelete) {
+            inverted.push(baseOp);
+          } else if (op.isRetain && op.isNotPlain) {
+            final invertAttr =
+                invertAttributes(op.attributes, baseOp.attributes);
+            inverted.retain(
+                baseOp.length!, invertAttr.isEmpty ? null : invertAttr);
+          }
+        });
+        baseIndex += length;
+      } else {
+        throw StateError('Unreachable');
+      }
+    }
+    inverted.trim();
+    return inverted;
+  }
+
+  /// Returns slice of this delta from [start] index (inclusive) to [end]
+  /// (exclusive).
+  Delta slice(int start, [int? end]) {
+    final delta = Delta();
+    var index = 0;
+    final opIterator = DeltaIterator(this);
+
+    final actualEnd = end ?? DeltaIterator.maxLength;
+
+    while (index < actualEnd && opIterator.hasNext) {
+      Operation op;
+      if (index < start) {
+        op = opIterator.next(start - index);
+      } else {
+        op = opIterator.next(actualEnd - index);
+        delta.push(op);
+      }
+      index += op.length!;
+    }
+    return delta;
+  }
+
+  /// Transforms [index] against this delta.
+  ///
+  /// Any "delete" operation before specified [index] shifts it backward, as
+  /// well as any "insert" operation shifts it forward.
+  ///
+  /// The [force] argument is used to resolve scenarios when there is an
+  /// insert operation at the same position as [index]. If [force] is set to
+  /// `true` (default) then position is forced to shift forward, otherwise
+  /// position stays at the same index. In other words setting [force] to
+  /// `false` gives higher priority to the transformed position.
+  ///
+  /// Useful to adjust caret or selection positions.
+  int transformPosition(int index, {bool force = true}) {
+    final iter = DeltaIterator(this);
+    var offset = 0;
+    while (iter.hasNext && offset <= index) {
+      final op = iter.next();
+      if (op.isDelete) {
+        index -= math.min(op.length!, index - offset);
+        continue;
+      } else if (op.isInsert && (offset < index || force)) {
+        index += op.length!;
+      }
+      offset += op.length!;
+    }
+    return index;
+  }
+
+  @override
+  String toString() => _operations.join('\n');
+}
+
+/// Specialized iterator for [Delta]s.
+class DeltaIterator {
+  DeltaIterator(this.delta) : _modificationCount = delta._modificationCount;
+
+  static const int maxLength = 1073741824;
+
+  final Delta delta;
+  final int _modificationCount;
+  int _index = 0;
+  int _offset = 0;
+
+  bool get isNextInsert => nextOperationKey == Operation.insertKey;
+
+  bool get isNextDelete => nextOperationKey == Operation.deleteKey;
+
+  bool get isNextRetain => nextOperationKey == Operation.retainKey;
+
+  String? get nextOperationKey {
+    if (_index < delta.length) {
+      return delta.elementAt(_index).key;
+    } else {
+      return null;
+    }
+  }
+
+  bool get hasNext => peekLength() < maxLength;
+
+  /// Returns length of next operation without consuming it.
+  ///
+  /// Returns [maxLength] if there is no more operations left to iterate.
+  int peekLength() {
+    if (_index < delta.length) {
+      final operation = delta._operations[_index];
+      return operation.length! - _offset;
+    }
+    return maxLength;
+  }
+
+  /// Consumes and returns next operation.
+  ///
+  /// Optional [length] specifies maximum length of operation to return. Note
+  /// that actual length of returned operation may be less than specified value.
+  ///
+  /// If this iterator reached the end of the Delta then returns a retain
+  /// operation with its length set to [maxLength].
+  // TODO: Note that we used double.infinity as the default value
+  // for length here
+  //       but this can now cause a type error since operation length is
+  //       expected to be an int. Changing default length to [maxLength] is
+  //       a workaround to avoid breaking changes.
+  Operation next([int length = maxLength]) {
+    if (_modificationCount != delta._modificationCount) {
+      throw ConcurrentModificationError(delta);
+    }
+
+    if (_index < delta.length) {
+      final op = delta.elementAt(_index);
+      final opKey = op.key;
+      final opAttributes = op.attributes;
+      final _currentOffset = _offset;
+      final actualLength = math.min(op.length! - _currentOffset, length);
+      if (actualLength == op.length! - _currentOffset) {
+        _index++;
+        _offset = 0;
+      } else {
+        _offset += actualLength;
+      }
+      final opData = op.isInsert && op.data is String
+          ? (op.data as String)
+              .substring(_currentOffset, _currentOffset + actualLength)
+          : op.data;
+      final opIsNotEmpty =
+          opData is String ? opData.isNotEmpty : true; // embeds are never empty
+      final opLength = opData is String ? opData.length : 1;
+      final opActualLength = opIsNotEmpty ? opLength : actualLength;
+      return Operation._(opKey, opActualLength, opData, opAttributes);
+    }
+    return Operation.retain(length);
+  }
+
+  /// Skips [length] characters in source delta.
+  ///
+  /// Returns last skipped operation, or `null` if there was nothing to skip.
+  Operation? skip(int length) {
+    var skipped = 0;
+    Operation? op;
+    while (skipped < length && hasNext) {
+      final opLength = peekLength();
+      final skip = math.min(length - skipped, opLength);
+      op = next(skip);
+      skipped += op.length!;
+    }
+    return op;
+  }
+}

+ 126 - 0
app_flowy/packages/editor/lib/src/models/rules/delete.dart

@@ -0,0 +1,126 @@
+import '../documents/attribute.dart';
+import '../quill_delta.dart';
+import 'rule.dart';
+
+abstract class DeleteRule extends Rule {
+  const DeleteRule();
+
+  @override
+  RuleType get type => RuleType.DELETE;
+
+  @override
+  void validateArgs(int? len, Object? data, Attribute? attribute) {
+    assert(len != null);
+    assert(data == null);
+    assert(attribute == null);
+  }
+}
+
+class CatchAllDeleteRule extends DeleteRule {
+  const CatchAllDeleteRule();
+
+  @override
+  Delta applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    return Delta()
+      ..retain(index)
+      ..delete(len!);
+  }
+}
+
+class PreserveLineStyleOnMergeRule extends DeleteRule {
+  const PreserveLineStyleOnMergeRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    final itr = DeltaIterator(document)..skip(index);
+    var op = itr.next(1);
+    if (op.data != '\n') {
+      return null;
+    }
+
+    final isNotPlain = op.isNotPlain;
+    final attrs = op.attributes;
+
+    itr.skip(len! - 1);
+    final delta = Delta()
+      ..retain(index)
+      ..delete(len);
+
+    while (itr.hasNext) {
+      op = itr.next();
+      final text = op.data is String ? (op.data as String?)! : '';
+      final lineBreak = text.indexOf('\n');
+      if (lineBreak == -1) {
+        delta.retain(op.length!);
+        continue;
+      }
+
+      var attributes = op.attributes == null
+          ? null
+          : op.attributes!.map<String, dynamic>(
+              (key, dynamic value) => MapEntry<String, dynamic>(key, null));
+
+      if (isNotPlain) {
+        attributes ??= <String, dynamic>{};
+        attributes.addAll(attrs!);
+      }
+      delta
+        ..retain(lineBreak)
+        ..retain(1, attributes);
+      break;
+    }
+    return delta;
+  }
+}
+
+class EnsureEmbedLineRule extends DeleteRule {
+  const EnsureEmbedLineRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    final itr = DeltaIterator(document);
+
+    var op = itr.skip(index);
+    int? indexDelta = 0, lengthDelta = 0, remain = len;
+    var embedFound = op != null && op.data is! String;
+    final hasLineBreakBefore =
+        !embedFound && (op == null || (op.data as String).endsWith('\n'));
+    if (embedFound) {
+      var candidate = itr.next(1);
+      if (remain != null) {
+        remain--;
+        if (candidate.data == '\n') {
+          indexDelta++;
+          lengthDelta--;
+
+          candidate = itr.next(1);
+          remain--;
+          if (candidate.data == '\n') {
+            lengthDelta++;
+          }
+        }
+      }
+    }
+
+    op = itr.skip(remain!);
+    if (op != null &&
+        (op.data is String ? op.data as String? : '')!.endsWith('\n')) {
+      final candidate = itr.next(1);
+      if (candidate.data is! String && !hasLineBreakBefore) {
+        embedFound = true;
+        lengthDelta--;
+      }
+    }
+
+    if (!embedFound) {
+      return null;
+    }
+
+    return Delta()
+      ..retain(index + indexDelta)
+      ..delete(len! + lengthDelta);
+  }
+}

+ 161 - 0
app_flowy/packages/editor/lib/src/models/rules/format.dart

@@ -0,0 +1,161 @@
+import '../documents/attribute.dart';
+import '../quill_delta.dart';
+import 'rule.dart';
+
+abstract class FormatRule extends Rule {
+  const FormatRule();
+
+  @override
+  RuleType get type => RuleType.FORMAT;
+
+  @override
+  void validateArgs(int? len, Object? data, Attribute? attribute) {
+    assert(len != null);
+    assert(data == null);
+    assert(attribute != null);
+  }
+}
+
+class ResolveLineFormatRule extends FormatRule {
+  const ResolveLineFormatRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (attribute!.scope != AttributeScope.BLOCK) {
+      return null;
+    }
+
+    var delta = Delta()..retain(index);
+    final itr = DeltaIterator(document)..skip(index);
+    Operation op;
+    for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) {
+      op = itr.next(len - cur);
+      if (op.data is! String || !(op.data as String).contains('\n')) {
+        delta.retain(op.length!);
+        continue;
+      }
+      final text = op.data as String;
+      final tmp = Delta();
+      var offset = 0;
+
+      // Enforce Block Format exclusivity by rule
+      final removedBlocks = Attribute.exclusiveBlockKeys.contains(attribute.key)
+          ? op.attributes?.keys
+                  .where((key) =>
+                      Attribute.exclusiveBlockKeys.contains(key) &&
+                      attribute.key != key &&
+                      attribute.value != null)
+                  .map((key) => MapEntry<String, dynamic>(key, null)) ??
+              []
+          : <MapEntry<String, dynamic>>[];
+
+      for (var lineBreak = text.indexOf('\n');
+          lineBreak >= 0;
+          lineBreak = text.indexOf('\n', offset)) {
+        tmp
+          ..retain(lineBreak - offset)
+          ..retain(1, attribute.toJson()..addEntries(removedBlocks));
+        offset = lineBreak + 1;
+      }
+      tmp.retain(text.length - offset);
+      delta = delta.concat(tmp);
+    }
+
+    while (itr.hasNext) {
+      op = itr.next();
+      final text = op.data is String ? (op.data as String?)! : '';
+      final lineBreak = text.indexOf('\n');
+      if (lineBreak < 0) {
+        delta.retain(op.length!);
+        continue;
+      }
+      // Enforce Block Format exclusivity by rule
+      final removedBlocks = Attribute.exclusiveBlockKeys.contains(attribute.key)
+          ? op.attributes?.keys
+                  .where((key) =>
+                      Attribute.exclusiveBlockKeys.contains(key) &&
+                      attribute.key != key &&
+                      attribute.value != null)
+                  .map((key) => MapEntry<String, dynamic>(key, null)) ??
+              []
+          : <MapEntry<String, dynamic>>[];
+      delta
+        ..retain(lineBreak)
+        ..retain(1, attribute.toJson()..addEntries(removedBlocks));
+      break;
+    }
+    return delta;
+  }
+}
+
+class FormatLinkAtCaretPositionRule extends FormatRule {
+  const FormatLinkAtCaretPositionRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (attribute!.key != Attribute.link.key || len! > 0) {
+      return null;
+    }
+
+    final delta = Delta();
+    final itr = DeltaIterator(document);
+    final before = itr.skip(index), after = itr.next();
+    int? beg = index, retain = 0;
+    if (before != null && before.hasAttribute(attribute.key)) {
+      beg -= before.length!;
+      retain = before.length;
+    }
+    if (after.hasAttribute(attribute.key)) {
+      if (retain != null) retain += after.length!;
+    }
+    if (retain == 0) {
+      return null;
+    }
+
+    delta
+      ..retain(beg)
+      ..retain(retain!, attribute.toJson());
+    return delta;
+  }
+}
+
+class ResolveInlineFormatRule extends FormatRule {
+  const ResolveInlineFormatRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (attribute!.scope != AttributeScope.INLINE) {
+      return null;
+    }
+
+    final delta = Delta()..retain(index);
+    final itr = DeltaIterator(document)..skip(index);
+
+    Operation op;
+    for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) {
+      op = itr.next(len - cur);
+      final text = op.data is String ? (op.data as String?)! : '';
+      var lineBreak = text.indexOf('\n');
+      if (lineBreak < 0) {
+        delta.retain(op.length!, attribute.toJson());
+        continue;
+      }
+      var pos = 0;
+      while (lineBreak >= 0) {
+        delta
+          ..retain(lineBreak - pos, attribute.toJson())
+          ..retain(1);
+        pos = lineBreak + 1;
+        lineBreak = text.indexOf('\n', pos);
+      }
+      if (pos < op.length!) {
+        delta.retain(op.length! - pos, attribute.toJson());
+      }
+    }
+
+    return delta;
+  }
+}

+ 385 - 0
app_flowy/packages/editor/lib/src/models/rules/insert.dart

@@ -0,0 +1,385 @@
+import 'package:tuple/tuple.dart';
+
+import '../documents/attribute.dart';
+import '../documents/style.dart';
+import '../quill_delta.dart';
+import 'rule.dart';
+
+abstract class InsertRule extends Rule {
+  const InsertRule();
+
+  @override
+  RuleType get type => RuleType.INSERT;
+
+  @override
+  void validateArgs(int? len, Object? data, Attribute? attribute) {
+    assert(data != null);
+    assert(attribute == null);
+  }
+}
+
+class PreserveLineStyleOnSplitRule extends InsertRule {
+  const PreserveLineStyleOnSplitRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (data is! String || data != '\n') {
+      return null;
+    }
+
+    final itr = DeltaIterator(document);
+    final before = itr.skip(index);
+    if (before == null ||
+        before.data is! String ||
+        (before.data as String).endsWith('\n')) {
+      return null;
+    }
+    final after = itr.next();
+    if (after.data is! String || (after.data as String).startsWith('\n')) {
+      return null;
+    }
+
+    final text = after.data as String;
+
+    final delta = Delta()..retain(index + (len ?? 0));
+    if (text.contains('\n')) {
+      assert(after.isPlain);
+      delta.insert('\n');
+      return delta;
+    }
+    final nextNewLine = _getNextNewLine(itr);
+    final attributes = nextNewLine.item1?.attributes;
+
+    return delta..insert('\n', attributes);
+  }
+}
+
+/// Preserves block style when user inserts text containing newlines.
+///
+/// This rule handles:
+///
+///   * inserting a new line in a block
+///   * pasting text containing multiple lines of text in a block
+///
+/// This rule may also be activated for changes triggered by auto-correct.
+class PreserveBlockStyleOnInsertRule extends InsertRule {
+  const PreserveBlockStyleOnInsertRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (data is! String || !data.contains('\n')) {
+      // Only interested in text containing at least one newline character.
+      return null;
+    }
+
+    final itr = DeltaIterator(document)..skip(index);
+
+    // Look for the next newline.
+    final nextNewLine = _getNextNewLine(itr);
+    final lineStyle =
+        Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{});
+
+    final blockStyle = lineStyle.getBlocksExceptHeader();
+    // Are we currently in a block? If not then ignore.
+    if (blockStyle.isEmpty) {
+      return null;
+    }
+
+    Map<String, dynamic>? resetStyle;
+    // If current line had heading style applied to it we'll need to move this
+    // style to the newly inserted line before it and reset style of the
+    // original line.
+    if (lineStyle.containsKey(Attribute.header.key)) {
+      resetStyle = Attribute.header.toJson();
+    }
+
+    // Go over each inserted line and ensure block style is applied.
+    final lines = data.split('\n');
+    final delta = Delta()..retain(index + (len ?? 0));
+    for (var i = 0; i < lines.length; i++) {
+      final line = lines[i];
+      if (line.isNotEmpty) {
+        delta.insert(line);
+      }
+      if (i == 0) {
+        // The first line should inherit the lineStyle entirely.
+        delta.insert('\n', lineStyle.toJson());
+      } else if (i < lines.length - 1) {
+        // we don't want to insert a newline after the last chunk of text, so -1
+        delta.insert('\n', blockStyle);
+      }
+    }
+
+    // Reset style of the original newline character if needed.
+    if (resetStyle != null) {
+      delta
+        ..retain(nextNewLine.item2!)
+        ..retain((nextNewLine.item1!.data as String).indexOf('\n'))
+        ..retain(1, resetStyle);
+    }
+
+    return delta;
+  }
+}
+
+/// Heuristic rule to exit current block when user inserts two consecutive
+/// newlines.
+///
+/// This rule is only applied when the cursor is on the last line of a block.
+/// When the cursor is in the middle of a block we allow adding empty lines
+/// and preserving the block's style.
+class AutoExitBlockRule extends InsertRule {
+  const AutoExitBlockRule();
+
+  bool _isEmptyLine(Operation? before, Operation? after) {
+    if (before == null) {
+      return true;
+    }
+    return before.data is String &&
+        (before.data as String).endsWith('\n') &&
+        after!.data is String &&
+        (after.data as String).startsWith('\n');
+  }
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (data is! String || data != '\n') {
+      return null;
+    }
+
+    final itr = DeltaIterator(document);
+    final prev = itr.skip(index), cur = itr.next();
+    final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader();
+    // We are not in a block, ignore.
+    if (cur.isPlain || blockStyle == null) {
+      return null;
+    }
+    // We are not on an empty line, ignore.
+    if (!_isEmptyLine(prev, cur)) {
+      return null;
+    }
+
+    // We are on an empty line. Now we need to determine if we are on the
+    // last line of a block.
+    // First check if `cur` length is greater than 1, this would indicate
+    // that it contains multiple newline characters which share the same style.
+    // This would mean we are not on the last line yet.
+    // `cur.value as String` is safe since we already called isEmptyLine and
+    // know it contains a newline
+    if ((cur.value as String).length > 1) {
+      // We are not on the last line of this block, ignore.
+      return null;
+    }
+
+    // Keep looking for the next newline character to see if it shares the same
+    // block style as `cur`.
+    final nextNewLine = _getNextNewLine(itr);
+    if (nextNewLine.item1 != null &&
+        nextNewLine.item1!.attributes != null &&
+        Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() ==
+            blockStyle) {
+      // We are not at the end of this block, ignore.
+      return null;
+    }
+
+    // Here we now know that the line after `cur` is not in the same block
+    // therefore we can exit this block.
+    final attributes = cur.attributes ?? <String, dynamic>{};
+    final k =
+        attributes.keys.firstWhere(Attribute.blockKeysExceptHeader.contains);
+    attributes[k] = null;
+    // retain(1) should be '\n', set it with no attribute
+    return Delta()
+      ..retain(index + (len ?? 0))
+      ..retain(1, attributes);
+  }
+}
+
+class ResetLineFormatOnNewLineRule extends InsertRule {
+  const ResetLineFormatOnNewLineRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (data is! String || data != '\n') {
+      return null;
+    }
+
+    final itr = DeltaIterator(document)..skip(index);
+    final cur = itr.next();
+    if (cur.data is! String || !(cur.data as String).startsWith('\n')) {
+      return null;
+    }
+
+    Map<String, dynamic>? resetStyle;
+    if (cur.attributes != null &&
+        cur.attributes!.containsKey(Attribute.header.key)) {
+      resetStyle = Attribute.header.toJson();
+    }
+    return Delta()
+      ..retain(index + (len ?? 0))
+      ..insert('\n', cur.attributes)
+      ..retain(1, resetStyle)
+      ..trim();
+  }
+}
+
+class InsertEmbedsRule extends InsertRule {
+  const InsertEmbedsRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (data is String) {
+      return null;
+    }
+
+    final delta = Delta()..retain(index + (len ?? 0));
+    final itr = DeltaIterator(document);
+    final prev = itr.skip(index), cur = itr.next();
+
+    final textBefore = prev?.data is String ? prev!.data as String? : '';
+    final textAfter = cur.data is String ? (cur.data as String?)! : '';
+
+    final isNewlineBefore = prev == null || textBefore!.endsWith('\n');
+    final isNewlineAfter = textAfter.startsWith('\n');
+
+    if (isNewlineBefore && isNewlineAfter) {
+      return delta..insert(data);
+    }
+
+    Map<String, dynamic>? lineStyle;
+    if (textAfter.contains('\n')) {
+      lineStyle = cur.attributes;
+    } else {
+      while (itr.hasNext) {
+        final op = itr.next();
+        if ((op.data is String ? op.data as String? : '')!.contains('\n')) {
+          lineStyle = op.attributes;
+          break;
+        }
+      }
+    }
+
+    if (!isNewlineBefore) {
+      delta.insert('\n', lineStyle);
+    }
+    delta.insert(data);
+    if (!isNewlineAfter) {
+      delta.insert('\n');
+    }
+    return delta;
+  }
+}
+
+class AutoFormatLinksRule extends InsertRule {
+  const AutoFormatLinksRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (data is! String || data != ' ') {
+      return null;
+    }
+
+    final itr = DeltaIterator(document);
+    final prev = itr.skip(index);
+    if (prev == null || prev.data is! String) {
+      return null;
+    }
+
+    try {
+      final cand = (prev.data as String).split('\n').last.split(' ').last;
+      final link = Uri.parse(cand);
+      if (!['https', 'http'].contains(link.scheme)) {
+        return null;
+      }
+      final attributes = prev.attributes ?? <String, dynamic>{};
+
+      if (attributes.containsKey(Attribute.link.key)) {
+        return null;
+      }
+
+      attributes.addAll(LinkAttribute(link.toString()).toJson());
+      return Delta()
+        ..retain(index + (len ?? 0) - cand.length)
+        ..retain(cand.length, attributes)
+        ..insert(data, prev.attributes);
+    } on FormatException {
+      return null;
+    }
+  }
+}
+
+class PreserveInlineStylesRule extends InsertRule {
+  const PreserveInlineStylesRule();
+
+  @override
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    if (data is! String || data.contains('\n')) {
+      return null;
+    }
+
+    final itr = DeltaIterator(document);
+    final prev = itr.skip(index);
+    if (prev == null ||
+        prev.data is! String ||
+        (prev.data as String).contains('\n')) {
+      return null;
+    }
+
+    final attributes = prev.attributes;
+    final text = data;
+    if (attributes == null || !attributes.containsKey(Attribute.link.key)) {
+      return Delta()
+        ..retain(index + (len ?? 0))
+        ..insert(text, attributes);
+    }
+
+    attributes.remove(Attribute.link.key);
+    final delta = Delta()
+      ..retain(index + (len ?? 0))
+      ..insert(text, attributes.isEmpty ? null : attributes);
+    final next = itr.next();
+
+    final nextAttributes = next.attributes ?? const <String, dynamic>{};
+    if (!nextAttributes.containsKey(Attribute.link.key)) {
+      return delta;
+    }
+    if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) {
+      return Delta()
+        ..retain(index + (len ?? 0))
+        ..insert(text, attributes);
+    }
+    return delta;
+  }
+}
+
+class CatchAllInsertRule extends InsertRule {
+  const CatchAllInsertRule();
+
+  @override
+  Delta applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    return Delta()
+      ..retain(index + (len ?? 0))
+      ..insert(data);
+  }
+}
+
+Tuple2<Operation?, int?> _getNextNewLine(DeltaIterator iterator) {
+  Operation op;
+  for (var skipped = 0; iterator.hasNext; skipped += op.length!) {
+    op = iterator.next();
+    final lineBreak =
+        (op.data is String ? op.data as String? : '')!.indexOf('\n');
+    if (lineBreak >= 0) {
+      return Tuple2(op, skipped);
+    }
+  }
+  return const Tuple2(null, null);
+}

+ 76 - 0
app_flowy/packages/editor/lib/src/models/rules/rule.dart

@@ -0,0 +1,76 @@
+import '../documents/attribute.dart';
+import '../documents/document.dart';
+import '../quill_delta.dart';
+import 'delete.dart';
+import 'format.dart';
+import 'insert.dart';
+
+enum RuleType { INSERT, DELETE, FORMAT }
+
+abstract class Rule {
+  const Rule();
+
+  Delta? apply(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    validateArgs(len, data, attribute);
+    return applyRule(document, index,
+        len: len, data: data, attribute: attribute);
+  }
+
+  void validateArgs(int? len, Object? data, Attribute? attribute);
+
+  Delta? applyRule(Delta document, int index,
+      {int? len, Object? data, Attribute? attribute});
+
+  RuleType get type;
+}
+
+class Rules {
+  Rules(this._rules);
+
+  List<Rule> _customRules = [];
+
+  final List<Rule> _rules;
+  static final Rules _instance = Rules([
+    const FormatLinkAtCaretPositionRule(),
+    const ResolveLineFormatRule(),
+    const ResolveInlineFormatRule(),
+    const InsertEmbedsRule(),
+    const AutoExitBlockRule(),
+    const PreserveBlockStyleOnInsertRule(),
+    const PreserveLineStyleOnSplitRule(),
+    const ResetLineFormatOnNewLineRule(),
+    const AutoFormatLinksRule(),
+    const PreserveInlineStylesRule(),
+    const CatchAllInsertRule(),
+    const EnsureEmbedLineRule(),
+    const PreserveLineStyleOnMergeRule(),
+    const CatchAllDeleteRule(),
+  ]);
+
+  static Rules getInstance() => _instance;
+
+  void setCustomRules(List<Rule> customRules) {
+    _customRules = customRules;
+  }
+
+  Delta apply(RuleType ruleType, Document document, int index,
+      {int? len, Object? data, Attribute? attribute}) {
+    final delta = document.toDelta();
+    for (final rule in _customRules + _rules) {
+      if (rule.type != ruleType) {
+        continue;
+      }
+      try {
+        final result = rule.apply(delta, index,
+            len: len, data: data, attribute: attribute);
+        if (result != null) {
+          return result..trim();
+        }
+      } catch (e) {
+        rethrow;
+      }
+    }
+    throw 'Apply rules failed';
+  }
+}

+ 125 - 0
app_flowy/packages/editor/lib/src/utils/color.dart

@@ -0,0 +1,125 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+
+Color stringToColor(String? s) {
+  switch (s) {
+    case 'transparent':
+      return Colors.transparent;
+    case 'black':
+      return Colors.black;
+    case 'black12':
+      return Colors.black12;
+    case 'black26':
+      return Colors.black26;
+    case 'black38':
+      return Colors.black38;
+    case 'black45':
+      return Colors.black45;
+    case 'black54':
+      return Colors.black54;
+    case 'black87':
+      return Colors.black87;
+    case 'white':
+      return Colors.white;
+    case 'white10':
+      return Colors.white10;
+    case 'white12':
+      return Colors.white12;
+    case 'white24':
+      return Colors.white24;
+    case 'white30':
+      return Colors.white30;
+    case 'white38':
+      return Colors.white38;
+    case 'white54':
+      return Colors.white54;
+    case 'white60':
+      return Colors.white60;
+    case 'white70':
+      return Colors.white70;
+    case 'red':
+      return Colors.red;
+    case 'redAccent':
+      return Colors.redAccent;
+    case 'amber':
+      return Colors.amber;
+    case 'amberAccent':
+      return Colors.amberAccent;
+    case 'yellow':
+      return Colors.yellow;
+    case 'yellowAccent':
+      return Colors.yellowAccent;
+    case 'teal':
+      return Colors.teal;
+    case 'tealAccent':
+      return Colors.tealAccent;
+    case 'purple':
+      return Colors.purple;
+    case 'purpleAccent':
+      return Colors.purpleAccent;
+    case 'pink':
+      return Colors.pink;
+    case 'pinkAccent':
+      return Colors.pinkAccent;
+    case 'orange':
+      return Colors.orange;
+    case 'orangeAccent':
+      return Colors.orangeAccent;
+    case 'deepOrange':
+      return Colors.deepOrange;
+    case 'deepOrangeAccent':
+      return Colors.deepOrangeAccent;
+    case 'indigo':
+      return Colors.indigo;
+    case 'indigoAccent':
+      return Colors.indigoAccent;
+    case 'lime':
+      return Colors.lime;
+    case 'limeAccent':
+      return Colors.limeAccent;
+    case 'grey':
+      return Colors.grey;
+    case 'blueGrey':
+      return Colors.blueGrey;
+    case 'green':
+      return Colors.green;
+    case 'greenAccent':
+      return Colors.greenAccent;
+    case 'lightGreen':
+      return Colors.lightGreen;
+    case 'lightGreenAccent':
+      return Colors.lightGreenAccent;
+    case 'blue':
+      return Colors.blue;
+    case 'blueAccent':
+      return Colors.blueAccent;
+    case 'lightBlue':
+      return Colors.lightBlue;
+    case 'lightBlueAccent':
+      return Colors.lightBlueAccent;
+    case 'cyan':
+      return Colors.cyan;
+    case 'cyanAccent':
+      return Colors.cyanAccent;
+    case 'brown':
+      return Colors.brown;
+  }
+
+  if (s!.startsWith('rgba')) {
+    s = s.substring(5); // trim left 'rgba('
+    s = s.substring(0, s.length - 1); // trim right ')'
+    final arr = s.split(',').map((e) => e.trim()).toList();
+    return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]),
+        int.parse(arr[2]), double.parse(arr[3]));
+  }
+
+  if (!s.startsWith('#')) {
+    throw 'Color code not supported';
+  }
+
+  var hex = s.replaceFirst('#', '');
+  hex = hex.length == 6 ? 'ff$hex' : hex;
+  final val = int.parse(hex, radix: 16);
+  return Color(val);
+}

+ 103 - 0
app_flowy/packages/editor/lib/src/utils/diff_delta.dart

@@ -0,0 +1,103 @@
+import 'dart:math' as math;
+
+import '../models/quill_delta.dart';
+
+const Set<int> WHITE_SPACE = {
+  0x9,
+  0xA,
+  0xB,
+  0xC,
+  0xD,
+  0x1C,
+  0x1D,
+  0x1E,
+  0x1F,
+  0x20,
+  0xA0,
+  0x1680,
+  0x2000,
+  0x2001,
+  0x2002,
+  0x2003,
+  0x2004,
+  0x2005,
+  0x2006,
+  0x2007,
+  0x2008,
+  0x2009,
+  0x200A,
+  0x202F,
+  0x205F,
+  0x3000
+};
+
+// Diff between two texts - old text and new text
+class Diff {
+  Diff(this.start, this.deleted, this.inserted);
+
+  // Start index in old text at which changes begin.
+  final int start;
+
+  /// The deleted text
+  final String deleted;
+
+  // The inserted text
+  final String inserted;
+
+  @override
+  String toString() {
+    return 'Diff[$start, "$deleted", "$inserted"]';
+  }
+}
+
+/* Get diff operation between old text and new text */
+Diff getDiff(String oldText, String newText, int cursorPosition) {
+  var end = oldText.length;
+  final delta = newText.length - end;
+  for (final limit = math.max(0, cursorPosition - delta);
+      end > limit && oldText[end - 1] == newText[end + delta - 1];
+      end--) {}
+  var start = 0;
+  for (final startLimit = cursorPosition - math.max(0, delta);
+      start < startLimit && oldText[start] == newText[start];
+      start++) {}
+  final deleted = (start >= end) ? '' : oldText.substring(start, end);
+  final inserted = newText.substring(start, end + delta);
+  return Diff(start, deleted, inserted);
+}
+
+int getPositionDelta(Delta user, Delta actual) {
+  if (actual.isEmpty) {
+    return 0;
+  }
+
+  final userItr = DeltaIterator(user);
+  final actualItr = DeltaIterator(actual);
+  var diff = 0;
+  while (userItr.hasNext || actualItr.hasNext) {
+    final length = math.min(userItr.peekLength(), actualItr.peekLength());
+    final userOperation = userItr.next(length);
+    final actualOperation = actualItr.next(length);
+    if (userOperation.length != actualOperation.length) {
+      throw 'userOp ${userOperation.length} does not match actualOp '
+          '${actualOperation.length}';
+    }
+    if (userOperation.key == actualOperation.key) {
+      continue;
+    } else if (userOperation.isInsert && actualOperation.isRetain) {
+      diff -= userOperation.length!;
+    } else if (userOperation.isDelete && actualOperation.isRetain) {
+      diff += userOperation.length!;
+    } else if (userOperation.isRetain && actualOperation.isInsert) {
+      String? operationTxt = '';
+      if (actualOperation.data is String) {
+        operationTxt = actualOperation.data as String?;
+      }
+      if (operationTxt!.startsWith('\n')) {
+        continue;
+      }
+      diff += actualOperation.length!;
+    }
+  }
+  return diff;
+}

+ 4 - 0
app_flowy/packages/editor/lib/src/utils/media_pick_setting.dart

@@ -0,0 +1,4 @@
+enum MediaPickSetting {
+  Gallery,
+  Link,
+}

+ 16 - 0
app_flowy/packages/editor/lib/src/utils/string_helper.dart

@@ -0,0 +1,16 @@
+Map<String, String> parseKeyValuePairs(String s, Set<String> targetKeys) {
+  final result = <String, String>{};
+  final pairs = s.split(';');
+  for (final pair in pairs) {
+    final _index = pair.indexOf(':');
+    if (_index < 0) {
+      continue;
+    }
+    final _key = pair.substring(0, _index).trim();
+    if (targetKeys.contains(_key)) {
+      result[_key] = pair.substring(_index + 1).trim();
+    }
+  }
+
+  return result;
+}

+ 122 - 0
app_flowy/packages/editor/lib/src/widgets/box.dart

@@ -0,0 +1,122 @@
+import 'package:flutter/rendering.dart';
+
+import '../models/documents/nodes/container.dart';
+
+abstract class RenderContentProxyBox implements RenderBox {
+  double getPreferredLineHeight();
+
+  Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype);
+
+  TextPosition getPositionForOffset(Offset offset);
+
+  double? getFullHeightForCaret(TextPosition position);
+
+  TextRange getWordBoundary(TextPosition position);
+
+  List<TextBox> getBoxesForSelection(TextSelection textSelection);
+}
+
+/// Base class for render boxes of editable content.
+///
+/// Implementations of this class usually work as a wrapper around
+/// regular (non-editable) render boxes which implement
+/// [RenderContentProxyBox].
+abstract class RenderEditableBox extends RenderBox {
+  /// The document node represented by this render box.
+  Container getContainer();
+
+  /// Returns preferred line height at specified `position` in text.
+  ///
+  /// The `position` parameter must be relative to the [node]'s content.
+  double preferredLineHeight(TextPosition position);
+
+  /// Returns the offset at which to paint the caret.
+  ///
+  /// The `position` parameter must be relative to the [node]'s content.
+  ///
+  /// Valid only after [layout].
+  Offset getOffsetForCaret(TextPosition position);
+
+  /// Returns the position within the text for the given pixel offset.
+  ///
+  /// The `offset` parameter must be local to this box coordinate system.
+  ///
+  /// Valid only after [layout].
+  TextPosition getPositionForOffset(Offset offset);
+
+  /// Returns the position relative to the [node] content
+  ///
+  /// The `position` must be within the [node] content
+  TextPosition globalToLocalPosition(TextPosition position);
+
+  /// Returns the position within the text which is on the line above the given
+  /// `position`.
+  ///
+  /// The `position` parameter must be relative to the [node] content.
+  ///
+  /// Primarily used with multi-line or soft-wrapping text.
+  ///
+  /// Can return `null` which indicates that the `position` is at the topmost
+  /// line in the text already.
+  TextPosition? getPositionAbove(TextPosition position);
+
+  /// Returns the position within the text which is on the line below the given
+  /// `position`.
+  ///
+  /// The `position` parameter must be relative to the [node] content.
+  ///
+  /// Primarily used with multi-line or soft-wrapping text.
+  ///
+  /// Can return `null` which indicates that the `position` is at the bottommost
+  /// line in the text already.
+  TextPosition? getPositionBelow(TextPosition position);
+
+  /// Returns the text range of the word at the given offset. Characters not
+  /// part of a word, such as spaces, symbols, and punctuation, have word breaks
+  /// on both sides. In such cases, this method will return a text range that
+  /// contains the given text position.
+  ///
+  /// Word boundaries are defined more precisely in Unicode Standard Annex #29
+  /// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
+  ///
+  /// The `position` parameter must be relative to the [node]'s content.
+  ///
+  /// Valid only after [layout].
+  TextRange getWordBoundary(TextPosition position);
+
+  /// Returns the text range of the line at the given offset.
+  ///
+  /// The newline, if any, is included in the range.
+  ///
+  /// The `position` parameter must be relative to the [node]'s content.
+  ///
+  /// Valid only after [layout].
+  TextRange getLineBoundary(TextPosition position);
+
+  /// Returns a list of rects that bound the given selection.
+  ///
+  /// A given selection might have more than one rect if this text painter
+  /// contains bidirectional text because logically contiguous text might not be
+  /// visually contiguous.
+  ///
+  /// Valid only after [layout].
+  // List<TextBox> getBoxesForSelection(TextSelection selection);
+
+  /// Returns a point for the base selection handle used on touch-oriented
+  /// devices.
+  ///
+  /// The `selection` parameter is expected to be in local offsets to this
+  /// render object's [node].
+  TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection);
+
+  /// Returns a point for the extent selection handle used on touch-oriented
+  /// devices.
+  ///
+  /// The `selection` parameter is expected to be in local offsets to this
+  /// render object's [node].
+  TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection);
+
+  /// Returns the [Rect] in local coordinates for the caret at the given text
+  /// position.
+  Rect getLocalRectForCaret(TextPosition position);
+}

+ 255 - 0
app_flowy/packages/editor/lib/src/widgets/controller.dart

@@ -0,0 +1,255 @@
+import 'dart:math' as math;
+
+import 'package:flutter/cupertino.dart';
+import 'package:tuple/tuple.dart';
+
+import '../models/documents/attribute.dart';
+import '../models/documents/document.dart';
+import '../models/documents/nodes/embed.dart';
+import '../models/documents/style.dart';
+import '../models/quill_delta.dart';
+import '../utils/diff_delta.dart';
+
+class QuillController extends ChangeNotifier {
+  QuillController({
+    required this.document,
+    required TextSelection selection,
+    bool keepStyleOnNewLine = false,
+  })  : _selection = selection,
+        _keepStyleOnNewLine = keepStyleOnNewLine;
+
+  factory QuillController.basic() {
+    return QuillController(
+      document: Document(),
+      selection: const TextSelection.collapsed(offset: 0),
+    );
+  }
+
+  /// Document managed by this controller.
+  final Document document;
+
+  /// Tells whether to keep or reset the [toggledStyle]
+  /// when user adds a new line.
+  final bool _keepStyleOnNewLine;
+
+  /// Currently selected text within the [document].
+  TextSelection get selection => _selection;
+  TextSelection _selection;
+
+  /// Store any styles attribute that got toggled by the tap of a button
+  /// and that has not been applied yet.
+  /// It gets reset after each format action within the [document].
+  Style toggledStyle = Style();
+
+  bool ignoreFocusOnTextChange = false;
+
+  /// True when this [QuillController] instance has been disposed.
+  ///
+  /// A safety mechanism to ensure that listeners don't crash when adding,
+  /// removing or listeners to this instance.
+  bool _isDisposed = false;
+
+  // item1: Document state before [change].
+  //
+  // item2: Change delta applied to the document.
+  //
+  // item3: The source of this change.
+  Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => document.changes;
+
+  TextEditingValue get plainTextEditingValue => TextEditingValue(
+        text: document.toPlainText(),
+        selection: selection,
+      );
+
+  /// Only attributes applied to all characters within this range are
+  /// included in the result.
+  Style getSelectionStyle() {
+    return document
+        .collectStyle(selection.start, selection.end - selection.start)
+        .mergeAll(toggledStyle);
+  }
+
+  /// Returns all styles for any character within the specified text range.
+  List<Style> getAllSelectionStyles() {
+    final styles = document.collectAllStyles(
+        selection.start, selection.end - selection.start)
+      ..add(toggledStyle);
+    return styles;
+  }
+
+  void undo() {
+    final tup = document.undo();
+    if (tup.item1) {
+      _handleHistoryChange(tup.item2);
+    }
+  }
+
+  void _handleHistoryChange(int? len) {
+    if (len! != 0) {
+      // if (this.selection.extentOffset >= document.length) {
+      // // cursor exceeds the length of document, position it in the end
+      // updateSelection(
+      // TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL);
+      updateSelection(
+          TextSelection.collapsed(offset: selection.baseOffset + len),
+          ChangeSource.LOCAL);
+    } else {
+      // no need to move cursor
+      notifyListeners();
+    }
+  }
+
+  void redo() {
+    final tup = document.redo();
+    if (tup.item1) {
+      _handleHistoryChange(tup.item2);
+    }
+  }
+
+  bool get hasUndo => document.hasUndo;
+
+  bool get hasRedo => document.hasRedo;
+
+  void replaceText(
+      int index, int len, Object? data, TextSelection? textSelection,
+      {bool ignoreFocus = false}) {
+    assert(data is String || data is Embeddable);
+
+    Delta? delta;
+    if (len > 0 || data is! String || data.isNotEmpty) {
+      delta = document.replace(index, len, data);
+      var shouldRetainDelta = toggledStyle.isNotEmpty &&
+          delta.isNotEmpty &&
+          delta.length <= 2 &&
+          delta.last.isInsert;
+      if (shouldRetainDelta &&
+          toggledStyle.isNotEmpty &&
+          delta.length == 2 &&
+          delta.last.data == '\n') {
+        // if all attributes are inline, shouldRetainDelta should be false
+        final anyAttributeNotInline =
+            toggledStyle.values.any((attr) => !attr.isInline);
+        if (!anyAttributeNotInline) {
+          shouldRetainDelta = false;
+        }
+      }
+      if (shouldRetainDelta) {
+        final retainDelta = Delta()
+          ..retain(index)
+          ..retain(data is String ? data.length : 1, toggledStyle.toJson());
+        document.compose(retainDelta, ChangeSource.LOCAL);
+      }
+    }
+
+    if (_keepStyleOnNewLine) {
+      final style = getSelectionStyle();
+      final notInlineStyle = style.attributes.values.where((s) => !s.isInline);
+      toggledStyle = style.removeAll(notInlineStyle.toSet());
+    } else {
+      toggledStyle = Style();
+    }
+
+    if (textSelection != null) {
+      if (delta == null || delta.isEmpty) {
+        _updateSelection(textSelection, ChangeSource.LOCAL);
+      } else {
+        final user = Delta()
+          ..retain(index)
+          ..insert(data)
+          ..delete(len);
+        final positionDelta = getPositionDelta(user, delta);
+        _updateSelection(
+          textSelection.copyWith(
+            baseOffset: textSelection.baseOffset + positionDelta,
+            extentOffset: textSelection.extentOffset + positionDelta,
+          ),
+          ChangeSource.LOCAL,
+        );
+      }
+    }
+
+    if (ignoreFocus) {
+      ignoreFocusOnTextChange = true;
+    }
+    notifyListeners();
+    ignoreFocusOnTextChange = false;
+  }
+
+  void formatText(int index, int len, Attribute? attribute) {
+    if (len == 0 &&
+        attribute!.isInline &&
+        attribute.key != Attribute.link.key) {
+      toggledStyle = toggledStyle.put(attribute);
+    }
+
+    final change = document.format(index, len, attribute);
+    final adjustedSelection = selection.copyWith(
+        baseOffset: change.transformPosition(selection.baseOffset),
+        extentOffset: change.transformPosition(selection.extentOffset));
+    if (selection != adjustedSelection) {
+      _updateSelection(adjustedSelection, ChangeSource.LOCAL);
+    }
+    notifyListeners();
+  }
+
+  void formatSelection(Attribute? attribute) {
+    formatText(selection.start, selection.end - selection.start, attribute);
+  }
+
+  void updateSelection(TextSelection textSelection, ChangeSource source) {
+    _updateSelection(textSelection, source);
+    notifyListeners();
+  }
+
+  void compose(Delta delta, TextSelection textSelection, ChangeSource source) {
+    if (delta.isNotEmpty) {
+      document.compose(delta, source);
+    }
+
+    textSelection = selection.copyWith(
+        baseOffset: delta.transformPosition(selection.baseOffset, force: false),
+        extentOffset:
+            delta.transformPosition(selection.extentOffset, force: false));
+    if (selection != textSelection) {
+      _updateSelection(textSelection, source);
+    }
+
+    notifyListeners();
+  }
+
+  @override
+  void addListener(VoidCallback listener) {
+    // By using `_isDisposed`, make sure that `addListener` won't be called on a
+    // disposed `ChangeListener`
+    if (!_isDisposed) {
+      super.addListener(listener);
+    }
+  }
+
+  @override
+  void removeListener(VoidCallback listener) {
+    // By using `_isDisposed`, make sure that `removeListener` won't be called
+    // on a disposed `ChangeListener`
+    if (!_isDisposed) {
+      super.removeListener(listener);
+    }
+  }
+
+  @override
+  void dispose() {
+    if (!_isDisposed) {
+      document.close();
+    }
+
+    _isDisposed = true;
+    super.dispose();
+  }
+
+  void _updateSelection(TextSelection textSelection, ChangeSource source) {
+    _selection = textSelection;
+    final end = document.length - 1;
+    _selection = selection.copyWith(
+        baseOffset: math.min(selection.baseOffset, end),
+        extentOffset: math.min(selection.extentOffset, end));
+  }
+}

+ 341 - 0
app_flowy/packages/editor/lib/src/widgets/cursor.dart

@@ -0,0 +1,341 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'box.dart';
+
+/// Style properties of editing cursor.
+class CursorStyle {
+  const CursorStyle({
+    required this.color,
+    required this.backgroundColor,
+    this.width = 1.0,
+    this.height,
+    this.radius,
+    this.offset,
+    this.opacityAnimates = false,
+    this.paintAboveText = false,
+  });
+
+  /// The color to use when painting the cursor.
+  final Color color;
+
+  /// The color to use when painting the background cursor aligned with the text
+  /// while rendering the floating cursor.
+  final Color backgroundColor;
+
+  /// How thick the cursor will be.
+  ///
+  /// The cursor will draw under the text. The cursor width will extend
+  /// to the right of the boundary between characters for left-to-right text
+  /// and to the left for right-to-left text. This corresponds to extending
+  /// downstream relative to the selected position. Negative values may be used
+  /// to reverse this behavior.
+  final double width;
+
+  /// How tall the cursor will be.
+  ///
+  /// By default, the cursor height is set to the preferred line height of the
+  /// text.
+  final double? height;
+
+  /// How rounded the corners of the cursor should be.
+  ///
+  /// By default, the cursor has no radius.
+  final Radius? radius;
+
+  /// The offset that is used, in pixels, when painting the cursor on screen.
+  ///
+  /// By default, the cursor position should be set to an offset of
+  /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android
+  /// platforms. The origin from where the offset is applied to is the arbitrary
+  /// location where the cursor ends up being rendered from by default.
+  final Offset? offset;
+
+  /// Whether the cursor will animate from fully transparent to fully opaque
+  /// during each cursor blink.
+  ///
+  /// By default, the cursor opacity will animate on iOS platforms and will not
+  /// animate on Android platforms.
+  final bool opacityAnimates;
+
+  /// If the cursor should be painted on top of the text or underneath it.
+  ///
+  /// By default, the cursor should be painted on top for iOS platforms and
+  /// underneath for Android platforms.
+  final bool paintAboveText;
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is CursorStyle &&
+          runtimeType == other.runtimeType &&
+          color == other.color &&
+          backgroundColor == other.backgroundColor &&
+          width == other.width &&
+          height == other.height &&
+          radius == other.radius &&
+          offset == other.offset &&
+          opacityAnimates == other.opacityAnimates &&
+          paintAboveText == other.paintAboveText;
+
+  @override
+  int get hashCode =>
+      color.hashCode ^
+      backgroundColor.hashCode ^
+      width.hashCode ^
+      height.hashCode ^
+      radius.hashCode ^
+      offset.hashCode ^
+      opacityAnimates.hashCode ^
+      paintAboveText.hashCode;
+}
+
+/// Controls the cursor of an editable widget.
+///
+/// This class is a [ChangeNotifier] and allows to listen for updates on the
+/// cursor [style].
+class CursorCont extends ChangeNotifier {
+  CursorCont({
+    required this.show,
+    required CursorStyle style,
+    required TickerProvider tickerProvider,
+  })  : _style = style,
+        blink = ValueNotifier(false),
+        color = ValueNotifier(style.color) {
+    _blinkOpacityController =
+        AnimationController(vsync: tickerProvider, duration: _fadeDuration);
+    _blinkOpacityController.addListener(_onColorTick);
+  }
+
+  // The time it takes for the cursor to fade from fully opaque to fully
+  // transparent and vice versa. A full cursor blink, from transparent to opaque
+  // to transparent, is twice this duration.
+  static const Duration _blinkHalfPeriod = Duration(milliseconds: 500);
+
+  // The time the cursor is static in opacity before animating to become
+  // transparent.
+  static const Duration _blinkWaitForStart = Duration(milliseconds: 150);
+
+  // This value is an eyeball estimation of the time it takes for the iOS cursor
+  // to ease in and out.
+  static const Duration _fadeDuration = Duration(milliseconds: 250);
+
+  final ValueNotifier<bool> show;
+  final ValueNotifier<Color> color;
+  final ValueNotifier<bool> blink;
+
+  late final AnimationController _blinkOpacityController;
+
+  Timer? _cursorTimer;
+  bool _targetCursorVisibility = false;
+
+  CursorStyle _style;
+  CursorStyle get style => _style;
+  set style(CursorStyle value) {
+    if (_style == value) return;
+    _style = value;
+    notifyListeners();
+  }
+
+  /// True when this [CursorCont] instance has been disposed.
+  ///
+  /// A safety mechanism to prevent the value of a disposed controller from
+  /// getting set.
+  bool _isDisposed = false;
+
+  @override
+  void dispose() {
+    _blinkOpacityController.removeListener(_onColorTick);
+    stopCursorTimer();
+
+    _isDisposed = true;
+    _blinkOpacityController.dispose();
+    show.dispose();
+    blink.dispose();
+    color.dispose();
+    assert(_cursorTimer == null);
+    super.dispose();
+  }
+
+  void _cursorTick(Timer timer) {
+    _targetCursorVisibility = !_targetCursorVisibility;
+    final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0;
+    if (style.opacityAnimates) {
+      // If we want to show the cursor, we will animate the opacity to the value
+      // of 1.0, and likewise if we want to make it disappear, to 0.0. An easing
+      // curve is used for the animation to mimic the aesthetics of the native
+      // iOS cursor.
+      //
+      // These values and curves have been obtained through eyeballing, so are
+      // likely not exactly the same as the values for native iOS.
+      _blinkOpacityController.animateTo(targetOpacity, curve: Curves.easeOut);
+    } else {
+      _blinkOpacityController.value = targetOpacity;
+    }
+  }
+
+  void _waitForStart(Timer timer) {
+    _cursorTimer?.cancel();
+    _cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick);
+  }
+
+  void startCursorTimer() {
+    if (_isDisposed) {
+      return;
+    }
+
+    _targetCursorVisibility = true;
+    _blinkOpacityController.value = 1.0;
+
+    if (style.opacityAnimates) {
+      _cursorTimer = Timer.periodic(_blinkWaitForStart, _waitForStart);
+    } else {
+      _cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick);
+    }
+  }
+
+  void stopCursorTimer({bool resetCharTicks = true}) {
+    _cursorTimer?.cancel();
+    _cursorTimer = null;
+    _targetCursorVisibility = false;
+    _blinkOpacityController.value = 0.0;
+
+    if (style.opacityAnimates) {
+      _blinkOpacityController
+        ..stop()
+        ..value = 0.0;
+    }
+  }
+
+  void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) {
+    if (show.value &&
+        _cursorTimer == null &&
+        hasFocus &&
+        selection.isCollapsed) {
+      startCursorTimer();
+    } else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) {
+      stopCursorTimer();
+    }
+  }
+
+  void _onColorTick() {
+    color.value = _style.color.withOpacity(_blinkOpacityController.value);
+    blink.value = show.value && _blinkOpacityController.value > 0;
+  }
+}
+
+/// Paints the editing cursor.
+class CursorPainter {
+  CursorPainter(
+    this.editable,
+    this.style,
+    this.prototype,
+    this.color,
+    this.devicePixelRatio,
+  );
+
+  final RenderContentProxyBox? editable;
+  final CursorStyle style;
+  final Rect prototype;
+  final Color color;
+  final double devicePixelRatio;
+
+  /// Paints cursor on [canvas] at specified [position].
+  /// [offset] is global top left (x, y) of text line
+  /// [position] is relative (x) in text line
+  void paint(
+      Canvas canvas, Offset offset, TextPosition position, bool lineHasEmbed) {
+    // relative (x, y) to global offset
+    var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype);
+    if (lineHasEmbed && relativeCaretOffset == Offset.zero) {
+      relativeCaretOffset = editable!.getOffsetForCaret(
+          TextPosition(
+              offset: position.offset - 1, affinity: position.affinity),
+          prototype);
+      // Hardcoded 6 as estimate of the width of a character
+      relativeCaretOffset =
+          Offset(relativeCaretOffset.dx + 6, relativeCaretOffset.dy);
+    }
+
+    final caretOffset = relativeCaretOffset + offset;
+    var caretRect = prototype.shift(caretOffset);
+    if (style.offset != null) {
+      caretRect = caretRect.shift(style.offset!);
+    }
+
+    if (caretRect.left < 0.0) {
+      // For iOS the cursor may get clipped by the scroll view when
+      // it's located at a beginning of a line. We ensure that this
+      // does not happen here. This may result in the cursor being painted
+      // closer to the character on the right, but it's arguably better
+      // then painting clipped cursor (or even cursor completely hidden).
+      caretRect = caretRect.shift(Offset(-caretRect.left, 0));
+    }
+
+    final caretHeight = editable!.getFullHeightForCaret(position);
+    if (caretHeight != null) {
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.android:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          // Override the height to take the full height of the glyph at the
+          // TextPosition when not on iOS. iOS has special handling that
+          // creates a taller caret.
+          caretRect = Rect.fromLTWH(
+            caretRect.left,
+            caretRect.top - 2.0,
+            caretRect.width,
+            caretHeight,
+          );
+          break;
+        case TargetPlatform.iOS:
+        case TargetPlatform.macOS:
+          // Center the caret vertically along the text.
+          caretRect = Rect.fromLTWH(
+            caretRect.left,
+            caretRect.top + (caretHeight - caretRect.height) / 2,
+            caretRect.width,
+            caretRect.height,
+          );
+          break;
+        default:
+          throw UnimplementedError();
+      }
+    }
+
+    final pixelPerfectOffset = _getPixelPerfectCursorOffset(caretRect);
+    if (!pixelPerfectOffset.isFinite) {
+      return;
+    }
+    caretRect = caretRect.shift(pixelPerfectOffset);
+
+    final paint = Paint()..color = color;
+    if (style.radius == null) {
+      canvas.drawRect(caretRect, paint);
+    } else {
+      final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!);
+      canvas.drawRRect(caretRRect, paint);
+    }
+  }
+
+  Offset _getPixelPerfectCursorOffset(
+    Rect caretRect,
+  ) {
+    final caretPosition = editable!.localToGlobal(caretRect.topLeft);
+    final pixelMultiple = 1.0 / devicePixelRatio;
+
+    final pixelPerfectOffsetX = caretPosition.dx.isFinite
+        ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple -
+            caretPosition.dx
+        : caretPosition.dx;
+    final pixelPerfectOffsetY = caretPosition.dy.isFinite
+        ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple -
+            caretPosition.dy
+        : caretPosition.dy;
+
+    return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY);
+  }
+}

+ 235 - 0
app_flowy/packages/editor/lib/src/widgets/default_styles.dart

@@ -0,0 +1,235 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/widgets.dart';
+import 'package:tuple/tuple.dart';
+
+class QuillStyles extends InheritedWidget {
+  const QuillStyles({
+    required this.data,
+    required Widget child,
+    Key? key,
+  }) : super(key: key, child: child);
+
+  final DefaultStyles data;
+
+  @override
+  bool updateShouldNotify(QuillStyles oldWidget) {
+    return data != oldWidget.data;
+  }
+
+  static DefaultStyles? getStyles(BuildContext context, bool nullOk) {
+    final widget = context.dependOnInheritedWidgetOfExactType<QuillStyles>();
+    if (widget == null && nullOk) {
+      return null;
+    }
+    assert(widget != null);
+    return widget!.data;
+  }
+}
+
+class DefaultTextBlockStyle {
+  DefaultTextBlockStyle(
+    this.style,
+    this.verticalSpacing,
+    this.lineSpacing,
+    this.decoration,
+  );
+
+  final TextStyle style;
+
+  final Tuple2<double, double> verticalSpacing;
+
+  final Tuple2<double, double> lineSpacing;
+
+  final BoxDecoration? decoration;
+}
+
+class DefaultStyles {
+  DefaultStyles({
+    this.h1,
+    this.h2,
+    this.h3,
+    this.paragraph,
+    this.bold,
+    this.italic,
+    this.small,
+    this.underline,
+    this.strikeThrough,
+    this.inlineCode,
+    this.link,
+    this.color,
+    this.placeHolder,
+    this.lists,
+    this.quote,
+    this.code,
+    this.indent,
+    this.align,
+    this.leading,
+    this.sizeSmall,
+    this.sizeLarge,
+    this.sizeHuge,
+  });
+
+  final DefaultTextBlockStyle? h1;
+  final DefaultTextBlockStyle? h2;
+  final DefaultTextBlockStyle? h3;
+  final DefaultTextBlockStyle? paragraph;
+  final TextStyle? bold;
+  final TextStyle? italic;
+  final TextStyle? small;
+  final TextStyle? underline;
+  final TextStyle? strikeThrough;
+  final TextStyle? inlineCode;
+  final TextStyle? sizeSmall; // 'small'
+  final TextStyle? sizeLarge; // 'large'
+  final TextStyle? sizeHuge; // 'huge'
+  final TextStyle? link;
+  final Color? color;
+  final DefaultTextBlockStyle? placeHolder;
+  final DefaultTextBlockStyle? lists;
+  final DefaultTextBlockStyle? quote;
+  final DefaultTextBlockStyle? code;
+  final DefaultTextBlockStyle? indent;
+  final DefaultTextBlockStyle? align;
+  final DefaultTextBlockStyle? leading;
+
+  static DefaultStyles getInstance(BuildContext context) {
+    final themeData = Theme.of(context);
+    final defaultTextStyle = DefaultTextStyle.of(context);
+    final baseStyle = defaultTextStyle.style.copyWith(
+      fontSize: 16,
+      height: 1.3,
+    );
+    const baseSpacing = Tuple2<double, double>(6, 0);
+    String fontFamily;
+    switch (themeData.platform) {
+      case TargetPlatform.iOS:
+      case TargetPlatform.macOS:
+        fontFamily = 'Menlo';
+        break;
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.windows:
+      case TargetPlatform.linux:
+        fontFamily = 'Roboto Mono';
+        break;
+      default:
+        throw UnimplementedError();
+    }
+
+    return DefaultStyles(
+        h1: DefaultTextBlockStyle(
+            defaultTextStyle.style.copyWith(
+              fontSize: 34,
+              color: defaultTextStyle.style.color!.withOpacity(0.70),
+              height: 1.15,
+              fontWeight: FontWeight.w300,
+            ),
+            const Tuple2(16, 0),
+            const Tuple2(0, 0),
+            null),
+        h2: DefaultTextBlockStyle(
+            defaultTextStyle.style.copyWith(
+              fontSize: 24,
+              color: defaultTextStyle.style.color!.withOpacity(0.70),
+              height: 1.15,
+              fontWeight: FontWeight.normal,
+            ),
+            const Tuple2(8, 0),
+            const Tuple2(0, 0),
+            null),
+        h3: DefaultTextBlockStyle(
+            defaultTextStyle.style.copyWith(
+              fontSize: 20,
+              color: defaultTextStyle.style.color!.withOpacity(0.70),
+              height: 1.25,
+              fontWeight: FontWeight.w500,
+            ),
+            const Tuple2(8, 0),
+            const Tuple2(0, 0),
+            null),
+        paragraph: DefaultTextBlockStyle(
+            baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
+        bold: const TextStyle(fontWeight: FontWeight.bold),
+        italic: const TextStyle(fontStyle: FontStyle.italic),
+        small: const TextStyle(fontSize: 12, color: Colors.black45),
+        underline: const TextStyle(decoration: TextDecoration.underline),
+        strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough),
+        inlineCode: TextStyle(
+          color: Colors.blue.shade900.withOpacity(0.9),
+          fontFamily: fontFamily,
+          fontSize: 13,
+        ),
+        link: TextStyle(
+          color: themeData.colorScheme.secondary,
+          decoration: TextDecoration.underline,
+        ),
+        placeHolder: DefaultTextBlockStyle(
+            defaultTextStyle.style.copyWith(
+              fontSize: 20,
+              height: 1.5,
+              color: Colors.grey.withOpacity(0.6),
+            ),
+            const Tuple2(0, 0),
+            const Tuple2(0, 0),
+            null),
+        lists: DefaultTextBlockStyle(
+            baseStyle, baseSpacing, const Tuple2(0, 6), null),
+        quote: DefaultTextBlockStyle(
+            TextStyle(color: baseStyle.color!.withOpacity(0.6)),
+            baseSpacing,
+            const Tuple2(6, 2),
+            BoxDecoration(
+              border: Border(
+                left: BorderSide(width: 4, color: Colors.grey.shade300),
+              ),
+            )),
+        code: DefaultTextBlockStyle(
+            TextStyle(
+              color: Colors.blue.shade900.withOpacity(0.9),
+              fontFamily: fontFamily,
+              fontSize: 13,
+              height: 1.15,
+            ),
+            baseSpacing,
+            const Tuple2(0, 0),
+            BoxDecoration(
+              color: Colors.grey.shade50,
+              borderRadius: BorderRadius.circular(2),
+            )),
+        indent: DefaultTextBlockStyle(
+            baseStyle, baseSpacing, const Tuple2(0, 6), null),
+        align: DefaultTextBlockStyle(
+            baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
+        leading: DefaultTextBlockStyle(
+            baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
+        sizeSmall: const TextStyle(fontSize: 10),
+        sizeLarge: const TextStyle(fontSize: 18),
+        sizeHuge: const TextStyle(fontSize: 22));
+  }
+
+  DefaultStyles merge(DefaultStyles other) {
+    return DefaultStyles(
+        h1: other.h1 ?? h1,
+        h2: other.h2 ?? h2,
+        h3: other.h3 ?? h3,
+        paragraph: other.paragraph ?? paragraph,
+        bold: other.bold ?? bold,
+        italic: other.italic ?? italic,
+        small: other.small ?? small,
+        underline: other.underline ?? underline,
+        strikeThrough: other.strikeThrough ?? strikeThrough,
+        inlineCode: other.inlineCode ?? inlineCode,
+        link: other.link ?? link,
+        color: other.color ?? color,
+        placeHolder: other.placeHolder ?? placeHolder,
+        lists: other.lists ?? lists,
+        quote: other.quote ?? quote,
+        code: other.code ?? code,
+        indent: other.indent ?? indent,
+        align: other.align ?? align,
+        leading: other.leading ?? leading,
+        sizeSmall: other.sizeSmall ?? sizeSmall,
+        sizeLarge: other.sizeLarge ?? sizeLarge,
+        sizeHuge: other.sizeHuge ?? sizeHuge);
+  }
+}

+ 152 - 0
app_flowy/packages/editor/lib/src/widgets/delegate.dart

@@ -0,0 +1,152 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import '../../flutter_quill.dart';
+
+import '../models/documents/nodes/leaf.dart';
+import 'editor.dart';
+import 'text_selection.dart';
+
+typedef EmbedBuilder = Widget Function(
+    BuildContext context, Embed node, bool readOnly);
+
+typedef CustomStyleBuilder = TextStyle Function(Attribute attribute);
+
+abstract class EditorTextSelectionGestureDetectorBuilderDelegate {
+  GlobalKey<EditorState> getEditableTextKey();
+
+  bool getForcePressEnabled();
+
+  bool getSelectionEnabled();
+}
+
+class EditorTextSelectionGestureDetectorBuilder {
+  EditorTextSelectionGestureDetectorBuilder(this.delegate);
+
+  final EditorTextSelectionGestureDetectorBuilderDelegate delegate;
+  bool shouldShowSelectionToolbar = true;
+
+  EditorState? getEditor() {
+    return delegate.getEditableTextKey().currentState;
+  }
+
+  RenderEditor? getRenderEditor() {
+    return getEditor()!.getRenderEditor();
+  }
+
+  void onTapDown(TapDownDetails details) {
+    getRenderEditor()!.handleTapDown(details);
+
+    final kind = details.kind;
+    shouldShowSelectionToolbar = kind == null ||
+        kind == PointerDeviceKind.touch ||
+        kind == PointerDeviceKind.stylus;
+  }
+
+  void onForcePressStart(ForcePressDetails details) {
+    assert(delegate.getForcePressEnabled());
+    shouldShowSelectionToolbar = true;
+    if (delegate.getSelectionEnabled()) {
+      getRenderEditor()!.selectWordsInRange(
+        details.globalPosition,
+        null,
+        SelectionChangedCause.forcePress,
+      );
+    }
+  }
+
+  void onForcePressEnd(ForcePressDetails details) {
+    assert(delegate.getForcePressEnabled());
+    getRenderEditor()!.selectWordsInRange(
+      details.globalPosition,
+      null,
+      SelectionChangedCause.forcePress,
+    );
+    if (shouldShowSelectionToolbar) {
+      getEditor()!.showToolbar();
+    }
+  }
+
+  void onSingleTapUp(TapUpDetails details) {
+    if (delegate.getSelectionEnabled()) {
+      getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap);
+    }
+  }
+
+  void onSingleTapCancel() {}
+
+  void onSingleLongTapStart(LongPressStartDetails details) {
+    if (delegate.getSelectionEnabled()) {
+      getRenderEditor()!.selectPositionAt(
+        details.globalPosition,
+        null,
+        SelectionChangedCause.longPress,
+      );
+    }
+  }
+
+  void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
+    if (delegate.getSelectionEnabled()) {
+      getRenderEditor()!.selectPositionAt(
+        details.globalPosition,
+        null,
+        SelectionChangedCause.longPress,
+      );
+    }
+  }
+
+  void onSingleLongTapEnd(LongPressEndDetails details) {
+    if (shouldShowSelectionToolbar) {
+      getEditor()!.showToolbar();
+    }
+  }
+
+  void onDoubleTapDown(TapDownDetails details) {
+    if (delegate.getSelectionEnabled()) {
+      getRenderEditor()!.selectWord(SelectionChangedCause.tap);
+      if (shouldShowSelectionToolbar) {
+        getEditor()!.showToolbar();
+      }
+    }
+  }
+
+  void onDragSelectionStart(DragStartDetails details) {
+    getRenderEditor()!.selectPositionAt(
+      details.globalPosition,
+      null,
+      SelectionChangedCause.drag,
+    );
+  }
+
+  void onDragSelectionUpdate(
+      DragStartDetails startDetails, DragUpdateDetails updateDetails) {
+    getRenderEditor()!.selectPositionAt(
+      startDetails.globalPosition,
+      updateDetails.globalPosition,
+      SelectionChangedCause.drag,
+    );
+  }
+
+  void onDragSelectionEnd(DragEndDetails details) {}
+
+  Widget build(HitTestBehavior behavior, Widget child) {
+    return EditorTextSelectionGestureDetector(
+      onTapDown: onTapDown,
+      onForcePressStart:
+          delegate.getForcePressEnabled() ? onForcePressStart : null,
+      onForcePressEnd: delegate.getForcePressEnabled() ? onForcePressEnd : null,
+      onSingleTapUp: onSingleTapUp,
+      onSingleTapCancel: onSingleTapCancel,
+      onSingleLongTapStart: onSingleLongTapStart,
+      onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate,
+      onSingleLongTapEnd: onSingleLongTapEnd,
+      onDoubleTapDown: onDoubleTapDown,
+      onDragSelectionStart: onDragSelectionStart,
+      onDragSelectionUpdate: onDragSelectionUpdate,
+      onDragSelectionEnd: onDragSelectionEnd,
+      behavior: behavior,
+      child: child,
+    );
+  }
+}

+ 1289 - 0
app_flowy/packages/editor/lib/src/widgets/editor.dart

@@ -0,0 +1,1289 @@
+import 'dart:convert';
+import 'dart:io' as io;
+import 'dart:math' as math;
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:string_validator/string_validator.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../models/documents/attribute.dart';
+import '../models/documents/document.dart';
+import '../models/documents/nodes/container.dart' as container_node;
+import '../models/documents/nodes/embed.dart';
+import '../models/documents/nodes/leaf.dart' as leaf;
+import '../models/documents/nodes/line.dart';
+import '../utils/string_helper.dart';
+import 'box.dart';
+import 'controller.dart';
+import 'cursor.dart';
+import 'default_styles.dart';
+import 'delegate.dart';
+import 'image.dart';
+import 'raw_editor.dart';
+import 'text_selection.dart';
+import 'video_app.dart';
+import 'youtube_video_app.dart';
+
+const linkPrefixes = [
+  'mailto:', // email
+  'tel:', // telephone
+  'sms:', // SMS
+  'callto:',
+  'wtai:',
+  'market:',
+  'geopoint:',
+  'ymsgr:',
+  'msnim:',
+  'gtalk:', // Google Talk
+  'skype:',
+  'sip:', // Lync
+  'whatsapp:',
+  'http'
+];
+
+abstract class EditorState extends State<RawEditor> {
+  ScrollController get scrollController;
+
+  TextEditingValue getTextEditingValue();
+
+  void setTextEditingValue(TextEditingValue value);
+
+  RenderEditor? getRenderEditor();
+
+  EditorTextSelectionOverlay? getSelectionOverlay();
+
+  bool showToolbar();
+
+  void hideToolbar();
+
+  void requestKeyboard();
+}
+
+/// Base interface for editable render objects.
+abstract class RenderAbstractEditor {
+  TextSelection selectWordAtPosition(TextPosition position);
+
+  TextSelection selectLineAtPosition(TextPosition position);
+
+  /// Returns preferred line height at specified `position` in text.
+  double preferredLineHeight(TextPosition position);
+
+  /// Returns [Rect] for caret in local coordinates
+  ///
+  /// Useful to enforce visibility of full caret at given position
+  Rect getLocalRectForCaret(TextPosition position);
+
+  /// Returns the local coordinates of the endpoints of the given selection.
+  ///
+  /// If the selection is collapsed (and therefore occupies a single point), the
+  /// returned list is of length one. Otherwise, the selection is not collapsed
+  /// and the returned list is of length two. In this case, however, the two
+  /// points might actually be co-located (e.g., because of a bidirectional
+  /// selection that contains some text but whose ends meet in the middle).
+  TextPosition getPositionForOffset(Offset offset);
+
+  List<TextSelectionPoint> getEndpointsForSelection(
+      TextSelection textSelection);
+
+  /// If [ignorePointer] is false (the default) then this method is called by
+  /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown]
+  /// callback.
+  ///
+  /// When [ignorePointer] is true, an ancestor widget must respond to tap
+  /// down events by calling this method.
+  void handleTapDown(TapDownDetails details);
+
+  /// Selects the set words of a paragraph in a given range of global positions.
+  ///
+  /// The first and last endpoints of the selection will always be at the
+  /// beginning and end of a word respectively.
+  ///
+  /// {@macro flutter.rendering.editable.select}
+  void selectWordsInRange(
+    Offset from,
+    Offset to,
+    SelectionChangedCause cause,
+  );
+
+  /// Move the selection to the beginning or end of a word.
+  ///
+  /// {@macro flutter.rendering.editable.select}
+  void selectWordEdge(SelectionChangedCause cause);
+
+  /// Select text between the global positions [from] and [to].
+  void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause);
+
+  /// Select a word around the location of the last tap down.
+  ///
+  /// {@macro flutter.rendering.editable.select}
+  void selectWord(SelectionChangedCause cause);
+
+  /// Move selection to the location of the last tap down.
+  ///
+  /// {@template flutter.rendering.editable.select}
+  /// This method is mainly used to translate user inputs in global positions
+  /// into a [TextSelection]. When used in conjunction with a [EditableText],
+  /// the selection change is fed back into [TextEditingController.selection].
+  ///
+  /// If you have a [TextEditingController], it's generally easier to
+  /// programmatically manipulate its `value` or `selection` directly.
+  /// {@endtemplate}
+  void selectPosition(SelectionChangedCause cause);
+}
+
+String _standardizeImageUrl(String url) {
+  if (url.contains('base64')) {
+    return url.split(',')[1];
+  }
+  return url;
+}
+
+bool _isMobile() => io.Platform.isAndroid || io.Platform.isIOS;
+
+Widget _defaultEmbedBuilder(
+    BuildContext context, leaf.Embed node, bool readOnly) {
+  assert(!kIsWeb, 'Please provide EmbedBuilder for Web');
+  switch (node.value.type) {
+    case 'image':
+      final imageUrl = _standardizeImageUrl(node.value.data);
+
+      final style = node.style.attributes['style'];
+      if (_isMobile() && style != null) {
+        final _attrs = parseKeyValuePairs(style.value.toString(),
+            {'mobileWidth', 'mobileHeight', 'mobileMargin', 'mobileAlignment'});
+        if (_attrs.isNotEmpty) {
+          assert(
+              _attrs['mobileWidth'] != null && _attrs['mobileHeight'] != null,
+              'mobileWidth and mobileHeight must be specified');
+          final w = double.parse(_attrs['mobileWidth']!);
+          final h = double.parse(_attrs['mobileHeight']!);
+          final m = _attrs['mobileMargin'] == null
+              ? 0.0
+              : double.parse(_attrs['mobileMargin']!);
+          var a = Alignment.center;
+          if (_attrs['mobileAlignment'] != null) {
+            final _index = [
+              'topLeft',
+              'topCenter',
+              'topRight',
+              'centerLeft',
+              'center',
+              'centerRight',
+              'bottomLeft',
+              'bottomCenter',
+              'bottomRight'
+            ].indexOf(_attrs['mobileAlignment']!);
+            if (_index >= 0) {
+              a = [
+                Alignment.topLeft,
+                Alignment.topCenter,
+                Alignment.topRight,
+                Alignment.centerLeft,
+                Alignment.center,
+                Alignment.centerRight,
+                Alignment.bottomLeft,
+                Alignment.bottomCenter,
+                Alignment.bottomRight
+              ][_index];
+            }
+          }
+          return Padding(
+              padding: EdgeInsets.all(m),
+              child: imageUrl.startsWith('http')
+                  ? Image.network(imageUrl, width: w, height: h, alignment: a)
+                  : isBase64(imageUrl)
+                      ? Image.memory(base64.decode(imageUrl),
+                          width: w, height: h, alignment: a)
+                      : Image.file(io.File(imageUrl),
+                          width: w, height: h, alignment: a));
+        }
+      }
+      return imageUrl.startsWith('http')
+          ? Image.network(imageUrl)
+          : isBase64(imageUrl)
+              ? Image.memory(base64.decode(imageUrl))
+              : Image.file(io.File(imageUrl));
+    case 'video':
+      final videoUrl = node.value.data;
+      if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) {
+        return YoutubeVideoApp(
+            videoUrl: videoUrl, context: context, readOnly: readOnly);
+      }
+      return VideoApp(videoUrl: videoUrl, context: context, readOnly: readOnly);
+    default:
+      throw UnimplementedError(
+        'Embeddable type "${node.value.type}" is not supported by default '
+        'embed builder of QuillEditor. You must pass your own builder function '
+        'to embedBuilder property of QuillEditor or QuillField widgets.',
+      );
+  }
+}
+
+class QuillEditor extends StatefulWidget {
+  const QuillEditor(
+      {required this.controller,
+      required this.focusNode,
+      required this.scrollController,
+      required this.scrollable,
+      required this.padding,
+      required this.autoFocus,
+      required this.readOnly,
+      required this.expands,
+      this.showCursor,
+      this.paintCursorAboveText,
+      this.placeholder,
+      this.enableInteractiveSelection = true,
+      this.scrollBottomInset = 0,
+      this.minHeight,
+      this.maxHeight,
+      this.customStyles,
+      this.textCapitalization = TextCapitalization.sentences,
+      this.keyboardAppearance = Brightness.light,
+      this.scrollPhysics,
+      this.onLaunchUrl,
+      this.onTapDown,
+      this.onTapUp,
+      this.onSingleLongTapStart,
+      this.onSingleLongTapMoveUpdate,
+      this.onSingleLongTapEnd,
+      this.embedBuilder = _defaultEmbedBuilder,
+      this.customStyleBuilder,
+      Key? key});
+
+  factory QuillEditor.basic({
+    required QuillController controller,
+    required bool readOnly,
+  }) {
+    return QuillEditor(
+        controller: controller,
+        scrollController: ScrollController(),
+        scrollable: true,
+        focusNode: FocusNode(),
+        autoFocus: true,
+        readOnly: readOnly,
+        expands: false,
+        padding: EdgeInsets.zero);
+  }
+
+  final QuillController controller;
+  final FocusNode focusNode;
+  final ScrollController scrollController;
+  final bool scrollable;
+  final double scrollBottomInset;
+  final EdgeInsetsGeometry padding;
+  final bool autoFocus;
+  final bool? showCursor;
+  final bool? paintCursorAboveText;
+  final bool readOnly;
+  final String? placeholder;
+  final bool enableInteractiveSelection;
+  final double? minHeight;
+  final double? maxHeight;
+  final DefaultStyles? customStyles;
+  final bool expands;
+  final TextCapitalization textCapitalization;
+  final Brightness keyboardAppearance;
+  final ScrollPhysics? scrollPhysics;
+  final ValueChanged<String>? onLaunchUrl;
+  // Returns whether gesture is handled
+  final bool Function(
+      TapDownDetails details, TextPosition Function(Offset offset))? onTapDown;
+
+  // Returns whether gesture is handled
+  final bool Function(
+      TapUpDetails details, TextPosition Function(Offset offset))? onTapUp;
+
+  // Returns whether gesture is handled
+  final bool Function(
+          LongPressStartDetails details, TextPosition Function(Offset offset))?
+      onSingleLongTapStart;
+
+  // Returns whether gesture is handled
+  final bool Function(LongPressMoveUpdateDetails details,
+      TextPosition Function(Offset offset))? onSingleLongTapMoveUpdate;
+  // Returns whether gesture is handled
+  final bool Function(
+          LongPressEndDetails details, TextPosition Function(Offset offset))?
+      onSingleLongTapEnd;
+
+  final EmbedBuilder embedBuilder;
+  final CustomStyleBuilder? customStyleBuilder;
+
+  @override
+  _QuillEditorState createState() => _QuillEditorState();
+}
+
+class _QuillEditorState extends State<QuillEditor>
+    implements EditorTextSelectionGestureDetectorBuilderDelegate {
+  final GlobalKey<EditorState> _editorKey = GlobalKey<EditorState>();
+  late EditorTextSelectionGestureDetectorBuilder
+      _selectionGestureDetectorBuilder;
+
+  @override
+  void initState() {
+    super.initState();
+    _selectionGestureDetectorBuilder =
+        _QuillEditorSelectionGestureDetectorBuilder(this);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    final selectionTheme = TextSelectionTheme.of(context);
+
+    TextSelectionControls textSelectionControls;
+    bool paintCursorAboveText;
+    bool cursorOpacityAnimates;
+    Offset? cursorOffset;
+    Color? cursorColor;
+    Color selectionColor;
+    Radius? cursorRadius;
+
+    switch (theme.platform) {
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        textSelectionControls = materialTextSelectionControls;
+        paintCursorAboveText = false;
+        cursorOpacityAnimates = false;
+        cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary;
+        selectionColor = selectionTheme.selectionColor ??
+            theme.colorScheme.primary.withOpacity(0.40);
+        break;
+      case TargetPlatform.iOS:
+      case TargetPlatform.macOS:
+        final cupertinoTheme = CupertinoTheme.of(context);
+        textSelectionControls = cupertinoTextSelectionControls;
+        paintCursorAboveText = true;
+        cursorOpacityAnimates = true;
+        cursorColor ??=
+            selectionTheme.cursorColor ?? cupertinoTheme.primaryColor;
+        selectionColor = selectionTheme.selectionColor ??
+            cupertinoTheme.primaryColor.withOpacity(0.40);
+        cursorRadius ??= const Radius.circular(2);
+        cursorOffset = Offset(
+            iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
+        break;
+      default:
+        throw UnimplementedError();
+    }
+
+    return _selectionGestureDetectorBuilder.build(
+      HitTestBehavior.translucent,
+      RawEditor(
+        _editorKey,
+        widget.controller,
+        widget.focusNode,
+        widget.scrollController,
+        widget.scrollable,
+        widget.scrollBottomInset,
+        widget.padding,
+        widget.readOnly,
+        widget.placeholder,
+        widget.onLaunchUrl,
+        ToolbarOptions(
+          copy: widget.enableInteractiveSelection,
+          cut: widget.enableInteractiveSelection,
+          paste: widget.enableInteractiveSelection,
+          selectAll: widget.enableInteractiveSelection,
+        ),
+        theme.platform == TargetPlatform.iOS ||
+            theme.platform == TargetPlatform.android,
+        widget.showCursor,
+        CursorStyle(
+          color: cursorColor,
+          backgroundColor: Colors.grey,
+          width: 2,
+          radius: cursorRadius,
+          offset: cursorOffset,
+          paintAboveText: widget.paintCursorAboveText ?? paintCursorAboveText,
+          opacityAnimates: cursorOpacityAnimates,
+        ),
+        widget.textCapitalization,
+        widget.maxHeight,
+        widget.minHeight,
+        widget.customStyles,
+        widget.expands,
+        widget.autoFocus,
+        selectionColor,
+        textSelectionControls,
+        widget.keyboardAppearance,
+        widget.enableInteractiveSelection,
+        widget.scrollPhysics,
+        widget.embedBuilder,
+        widget.customStyleBuilder,
+      ),
+    );
+  }
+
+  @override
+  GlobalKey<EditorState> getEditableTextKey() {
+    return _editorKey;
+  }
+
+  @override
+  bool getForcePressEnabled() {
+    return false;
+  }
+
+  @override
+  bool getSelectionEnabled() {
+    return widget.enableInteractiveSelection;
+  }
+
+  void _requestKeyboard() {
+    _editorKey.currentState!.requestKeyboard();
+  }
+}
+
+class _QuillEditorSelectionGestureDetectorBuilder
+    extends EditorTextSelectionGestureDetectorBuilder {
+  _QuillEditorSelectionGestureDetectorBuilder(this._state) : super(_state);
+
+  final _QuillEditorState _state;
+
+  @override
+  void onForcePressStart(ForcePressDetails details) {
+    super.onForcePressStart(details);
+    if (delegate.getSelectionEnabled() && shouldShowSelectionToolbar) {
+      getEditor()!.showToolbar();
+    }
+  }
+
+  @override
+  void onForcePressEnd(ForcePressDetails details) {}
+
+  @override
+  void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
+    if (_state.widget.onSingleLongTapMoveUpdate != null) {
+      final renderEditor = getRenderEditor();
+      if (renderEditor != null) {
+        if (_state.widget.onSingleLongTapMoveUpdate!(
+            details, renderEditor.getPositionForOffset)) {
+          return;
+        }
+      }
+    }
+    if (!delegate.getSelectionEnabled()) {
+      return;
+    }
+    switch (Theme.of(_state.context).platform) {
+      case TargetPlatform.iOS:
+      case TargetPlatform.macOS:
+        getRenderEditor()!.selectPositionAt(
+          details.globalPosition,
+          null,
+          SelectionChangedCause.longPress,
+        );
+        break;
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        getRenderEditor()!.selectWordsInRange(
+          details.globalPosition - details.offsetFromOrigin,
+          details.globalPosition,
+          SelectionChangedCause.longPress,
+        );
+        break;
+      default:
+        throw 'Invalid platform';
+    }
+  }
+
+  bool _onTapping(TapUpDetails details) {
+    if (_state.widget.controller.document.isEmpty()) {
+      return false;
+    }
+    final pos = getRenderEditor()!.getPositionForOffset(details.globalPosition);
+    final result =
+        getEditor()!.widget.controller.document.queryChild(pos.offset);
+    if (result.node == null) {
+      return false;
+    }
+    final line = result.node as Line;
+    final segmentResult = line.queryChild(result.offset, false);
+    if (segmentResult.node == null) {
+      if (line.length == 1) {
+        getEditor()!.widget.controller.updateSelection(
+            TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL);
+        return true;
+      }
+      return false;
+    }
+    final segment = segmentResult.node as leaf.Leaf;
+    if (segment.style.containsKey(Attribute.link.key)) {
+      var launchUrl = getEditor()!.widget.onLaunchUrl;
+      launchUrl ??= _launchUrl;
+      String? link = segment.style.attributes[Attribute.link.key]!.value;
+      if (getEditor()!.widget.readOnly && link != null) {
+        link = link.trim();
+        if (!linkPrefixes
+            .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
+          link = 'https://$link';
+        }
+        launchUrl(link);
+      }
+      return false;
+    }
+    if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) {
+      final blockEmbed = segment.value as BlockEmbed;
+      if (blockEmbed.type == 'image') {
+        final imageUrl = _standardizeImageUrl(blockEmbed.data);
+        Navigator.push(
+          getEditor()!.context,
+          MaterialPageRoute(
+            builder: (context) => ImageTapWrapper(
+              imageProvider: imageUrl.startsWith('http')
+                  ? NetworkImage(imageUrl)
+                  : isBase64(imageUrl)
+                      ? Image.memory(base64.decode(imageUrl))
+                          as ImageProvider<Object>?
+                      : FileImage(io.File(imageUrl)),
+            ),
+          ),
+        );
+      }
+    }
+
+    return false;
+  }
+
+  Future<void> _launchUrl(String url) async {
+    await launch(url);
+  }
+
+  @override
+  void onTapDown(TapDownDetails details) {
+    if (_state.widget.onTapDown != null) {
+      final renderEditor = getRenderEditor();
+      if (renderEditor != null) {
+        if (_state.widget.onTapDown!(
+            details, renderEditor.getPositionForOffset)) {
+          return;
+        }
+      }
+    }
+    super.onTapDown(details);
+  }
+
+  @override
+  void onSingleTapUp(TapUpDetails details) {
+    if (_state.widget.onTapUp != null) {
+      final renderEditor = getRenderEditor();
+      if (renderEditor != null) {
+        if (_state.widget.onTapUp!(
+            details, renderEditor.getPositionForOffset)) {
+          return;
+        }
+      }
+    }
+
+    getEditor()!.hideToolbar();
+
+    final positionSelected = _onTapping(details);
+
+    if (delegate.getSelectionEnabled() && !positionSelected) {
+      switch (Theme.of(_state.context).platform) {
+        case TargetPlatform.iOS:
+        case TargetPlatform.macOS:
+          switch (details.kind) {
+            case PointerDeviceKind.mouse:
+            case PointerDeviceKind.stylus:
+            case PointerDeviceKind.invertedStylus:
+              getRenderEditor()!.selectPosition(SelectionChangedCause.tap);
+              break;
+            case PointerDeviceKind.touch:
+            case PointerDeviceKind.unknown:
+              getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap);
+              break;
+          }
+          break;
+        case TargetPlatform.android:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          getRenderEditor()!.selectPosition(SelectionChangedCause.tap);
+          break;
+      }
+    }
+    _state._requestKeyboard();
+  }
+
+  @override
+  void onSingleLongTapStart(LongPressStartDetails details) {
+    if (_state.widget.onSingleLongTapStart != null) {
+      final renderEditor = getRenderEditor();
+      if (renderEditor != null) {
+        if (_state.widget.onSingleLongTapStart!(
+            details, renderEditor.getPositionForOffset)) {
+          return;
+        }
+      }
+    }
+
+    if (delegate.getSelectionEnabled()) {
+      switch (Theme.of(_state.context).platform) {
+        case TargetPlatform.iOS:
+        case TargetPlatform.macOS:
+          getRenderEditor()!.selectPositionAt(
+            details.globalPosition,
+            null,
+            SelectionChangedCause.longPress,
+          );
+          break;
+        case TargetPlatform.android:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          getRenderEditor()!.selectWord(SelectionChangedCause.longPress);
+          Feedback.forLongPress(_state.context);
+          break;
+        default:
+          throw 'Invalid platform';
+      }
+    }
+  }
+
+  @override
+  void onSingleLongTapEnd(LongPressEndDetails details) {
+    if (_state.widget.onSingleLongTapEnd != null) {
+      final renderEditor = getRenderEditor();
+      if (renderEditor != null) {
+        if (_state.widget.onSingleLongTapEnd!(
+            details, renderEditor.getPositionForOffset)) {
+          return;
+        }
+      }
+    }
+    super.onSingleLongTapEnd(details);
+  }
+}
+
+typedef TextSelectionChangedHandler = void Function(
+    TextSelection selection, SelectionChangedCause cause);
+
+class RenderEditor extends RenderEditableContainerBox
+    implements RenderAbstractEditor {
+  RenderEditor(
+    List<RenderEditableBox>? children,
+    TextDirection textDirection,
+    double scrollBottomInset,
+    EdgeInsetsGeometry padding,
+    this.document,
+    this.selection,
+    this._hasFocus,
+    this.onSelectionChanged,
+    this._startHandleLayerLink,
+    this._endHandleLayerLink,
+    EdgeInsets floatingCursorAddedMargin,
+  ) : super(
+          children,
+          document.root,
+          textDirection,
+          scrollBottomInset,
+          padding,
+        );
+
+  Document document;
+  TextSelection selection;
+  bool _hasFocus = false;
+  LayerLink _startHandleLayerLink;
+  LayerLink _endHandleLayerLink;
+  TextSelectionChangedHandler onSelectionChanged;
+  final ValueNotifier<bool> _selectionStartInViewport =
+      ValueNotifier<bool>(true);
+
+  ValueListenable<bool> get selectionStartInViewport =>
+      _selectionStartInViewport;
+
+  ValueListenable<bool> get selectionEndInViewport => _selectionEndInViewport;
+  final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(true);
+
+  void setDocument(Document doc) {
+    if (document == doc) {
+      return;
+    }
+    document = doc;
+    markNeedsLayout();
+  }
+
+  void setHasFocus(bool h) {
+    if (_hasFocus == h) {
+      return;
+    }
+    _hasFocus = h;
+    markNeedsSemanticsUpdate();
+  }
+
+  void setSelection(TextSelection t) {
+    if (selection == t) {
+      return;
+    }
+    selection = t;
+    markNeedsPaint();
+  }
+
+  void setStartHandleLayerLink(LayerLink value) {
+    if (_startHandleLayerLink == value) {
+      return;
+    }
+    _startHandleLayerLink = value;
+    markNeedsPaint();
+  }
+
+  void setEndHandleLayerLink(LayerLink value) {
+    if (_endHandleLayerLink == value) {
+      return;
+    }
+    _endHandleLayerLink = value;
+    markNeedsPaint();
+  }
+
+  void setScrollBottomInset(double value) {
+    if (scrollBottomInset == value) {
+      return;
+    }
+    scrollBottomInset = value;
+    markNeedsPaint();
+  }
+
+  @override
+  List<TextSelectionPoint> getEndpointsForSelection(
+      TextSelection textSelection) {
+    if (textSelection.isCollapsed) {
+      final child = childAtPosition(textSelection.extent);
+      final localPosition = TextPosition(
+          offset: textSelection.extentOffset - child.getContainer().offset);
+      final localOffset = child.getOffsetForCaret(localPosition);
+      final parentData = child.parentData as BoxParentData;
+      return <TextSelectionPoint>[
+        TextSelectionPoint(
+            Offset(0, child.preferredLineHeight(localPosition)) +
+                localOffset +
+                parentData.offset,
+            null)
+      ];
+    }
+
+    final baseNode = _container.queryChild(textSelection.start, false).node;
+
+    var baseChild = firstChild;
+    while (baseChild != null) {
+      if (baseChild.getContainer() == baseNode) {
+        break;
+      }
+      baseChild = childAfter(baseChild);
+    }
+    assert(baseChild != null);
+
+    final baseParentData = baseChild!.parentData as BoxParentData;
+    final baseSelection =
+        localSelection(baseChild.getContainer(), textSelection, true);
+    var basePoint = baseChild.getBaseEndpointForSelection(baseSelection);
+    basePoint = TextSelectionPoint(
+        basePoint.point + baseParentData.offset, basePoint.direction);
+
+    final extentNode = _container.queryChild(textSelection.end, false).node;
+    RenderEditableBox? extentChild = baseChild;
+    while (extentChild != null) {
+      if (extentChild.getContainer() == extentNode) {
+        break;
+      }
+      extentChild = childAfter(extentChild);
+    }
+    assert(extentChild != null);
+
+    final extentParentData = extentChild!.parentData as BoxParentData;
+    final extentSelection =
+        localSelection(extentChild.getContainer(), textSelection, true);
+    var extentPoint =
+        extentChild.getExtentEndpointForSelection(extentSelection);
+    extentPoint = TextSelectionPoint(
+        extentPoint.point + extentParentData.offset, extentPoint.direction);
+
+    return <TextSelectionPoint>[basePoint, extentPoint];
+  }
+
+  Offset? _lastTapDownPosition;
+
+  @override
+  void handleTapDown(TapDownDetails details) {
+    _lastTapDownPosition = details.globalPosition;
+  }
+
+  @override
+  void selectWordsInRange(
+    Offset from,
+    Offset? to,
+    SelectionChangedCause cause,
+  ) {
+    final firstPosition = getPositionForOffset(from);
+    final firstWord = selectWordAtPosition(firstPosition);
+    final lastWord =
+        to == null ? firstWord : selectWordAtPosition(getPositionForOffset(to));
+
+    _handleSelectionChange(
+      TextSelection(
+        baseOffset: firstWord.base.offset,
+        extentOffset: lastWord.extent.offset,
+        affinity: firstWord.affinity,
+      ),
+      cause,
+    );
+  }
+
+  void _handleSelectionChange(
+    TextSelection nextSelection,
+    SelectionChangedCause cause,
+  ) {
+    final focusingEmpty = nextSelection.baseOffset == 0 &&
+        nextSelection.extentOffset == 0 &&
+        !_hasFocus;
+    if (nextSelection == selection &&
+        cause != SelectionChangedCause.keyboard &&
+        !focusingEmpty) {
+      return;
+    }
+    onSelectionChanged(nextSelection, cause);
+  }
+
+  @override
+  void selectWordEdge(SelectionChangedCause cause) {
+    assert(_lastTapDownPosition != null);
+    final position = getPositionForOffset(_lastTapDownPosition!);
+    final child = childAtPosition(position);
+    final nodeOffset = child.getContainer().offset;
+    final localPosition = TextPosition(
+      offset: position.offset - nodeOffset,
+      affinity: position.affinity,
+    );
+    final localWord = child.getWordBoundary(localPosition);
+    final word = TextRange(
+      start: localWord.start + nodeOffset,
+      end: localWord.end + nodeOffset,
+    );
+    if (position.offset - word.start <= 1) {
+      _handleSelectionChange(
+        TextSelection.collapsed(offset: word.start),
+        cause,
+      );
+    } else {
+      _handleSelectionChange(
+        TextSelection.collapsed(
+            offset: word.end, affinity: TextAffinity.upstream),
+        cause,
+      );
+    }
+  }
+
+  @override
+  void selectPositionAt(
+    Offset from,
+    Offset? to,
+    SelectionChangedCause cause,
+  ) {
+    final fromPosition = getPositionForOffset(from);
+    final toPosition = to == null ? null : getPositionForOffset(to);
+
+    var baseOffset = fromPosition.offset;
+    var extentOffset = fromPosition.offset;
+    if (toPosition != null) {
+      baseOffset = math.min(fromPosition.offset, toPosition.offset);
+      extentOffset = math.max(fromPosition.offset, toPosition.offset);
+    }
+
+    final newSelection = TextSelection(
+      baseOffset: baseOffset,
+      extentOffset: extentOffset,
+      affinity: fromPosition.affinity,
+    );
+    _handleSelectionChange(newSelection, cause);
+  }
+
+  @override
+  void selectWord(SelectionChangedCause cause) {
+    selectWordsInRange(_lastTapDownPosition!, null, cause);
+  }
+
+  @override
+  void selectPosition(SelectionChangedCause cause) {
+    selectPositionAt(_lastTapDownPosition!, null, cause);
+  }
+
+  @override
+  TextSelection selectWordAtPosition(TextPosition position) {
+    final child = childAtPosition(position);
+    final nodeOffset = child.getContainer().offset;
+    final localPosition = TextPosition(
+        offset: position.offset - nodeOffset, affinity: position.affinity);
+    final localWord = child.getWordBoundary(localPosition);
+    final word = TextRange(
+      start: localWord.start + nodeOffset,
+      end: localWord.end + nodeOffset,
+    );
+    if (position.offset >= word.end) {
+      return TextSelection.fromPosition(position);
+    }
+    return TextSelection(baseOffset: word.start, extentOffset: word.end);
+  }
+
+  @override
+  TextSelection selectLineAtPosition(TextPosition position) {
+    final child = childAtPosition(position);
+    final nodeOffset = child.getContainer().offset;
+    final localPosition = TextPosition(
+        offset: position.offset - nodeOffset, affinity: position.affinity);
+    final localLineRange = child.getLineBoundary(localPosition);
+    final line = TextRange(
+      start: localLineRange.start + nodeOffset,
+      end: localLineRange.end + nodeOffset,
+    );
+
+    if (position.offset >= line.end) {
+      return TextSelection.fromPosition(position);
+    }
+    return TextSelection(baseOffset: line.start, extentOffset: line.end);
+  }
+
+  @override
+  void paint(PaintingContext context, Offset offset) {
+    defaultPaint(context, offset);
+    _paintHandleLayers(context, getEndpointsForSelection(selection));
+  }
+
+  @override
+  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
+    return defaultHitTestChildren(result, position: position);
+  }
+
+  void _paintHandleLayers(
+      PaintingContext context, List<TextSelectionPoint> endpoints) {
+    var startPoint = endpoints[0].point;
+    startPoint = Offset(
+      startPoint.dx.clamp(0.0, size.width),
+      startPoint.dy.clamp(0.0, size.height),
+    );
+    context.pushLayer(
+      LeaderLayer(link: _startHandleLayerLink, offset: startPoint),
+      super.paint,
+      Offset.zero,
+    );
+    if (endpoints.length == 2) {
+      var endPoint = endpoints[1].point;
+      endPoint = Offset(
+        endPoint.dx.clamp(0.0, size.width),
+        endPoint.dy.clamp(0.0, size.height),
+      );
+      context.pushLayer(
+        LeaderLayer(link: _endHandleLayerLink, offset: endPoint),
+        super.paint,
+        Offset.zero,
+      );
+    }
+  }
+
+  @override
+  double preferredLineHeight(TextPosition position) {
+    final child = childAtPosition(position);
+    return child.preferredLineHeight(
+        TextPosition(offset: position.offset - child.getContainer().offset));
+  }
+
+  @override
+  TextPosition getPositionForOffset(Offset offset) {
+    final local = globalToLocal(offset);
+    final child = childAtOffset(local)!;
+
+    final parentData = child.parentData as BoxParentData;
+    final localOffset = local - parentData.offset;
+    final localPosition = child.getPositionForOffset(localOffset);
+    return TextPosition(
+      offset: localPosition.offset + child.getContainer().offset,
+      affinity: localPosition.affinity,
+    );
+  }
+
+  /// Returns the y-offset of the editor at which [selection] is visible.
+  ///
+  /// The offset is the distance from the top of the editor and is the minimum
+  /// from the current scroll position until [selection] becomes visible.
+  /// Returns null if [selection] is already visible.
+  double? getOffsetToRevealCursor(
+      double viewportHeight, double scrollOffset, double offsetInViewport) {
+    final endpoints = getEndpointsForSelection(selection);
+
+    // when we drag the right handle, we should get the last point
+    TextSelectionPoint endpoint;
+    if (selection.isCollapsed) {
+      endpoint = endpoints.first;
+    } else {
+      if (selection is DragTextSelection) {
+        endpoint = (selection as DragTextSelection).first
+            ? endpoints.first
+            : endpoints.last;
+      } else {
+        endpoint = endpoints.first;
+      }
+    }
+
+    final child = childAtPosition(selection.extent);
+    const kMargin = 8.0;
+
+    final caretTop = endpoint.point.dy -
+        child.preferredLineHeight(TextPosition(
+            offset:
+                selection.extentOffset - child.getContainer().documentOffset)) -
+        kMargin +
+        offsetInViewport +
+        scrollBottomInset;
+    final caretBottom =
+        endpoint.point.dy + kMargin + offsetInViewport + scrollBottomInset;
+    double? dy;
+    if (caretTop < scrollOffset) {
+      dy = caretTop;
+    } else if (caretBottom > scrollOffset + viewportHeight) {
+      dy = caretBottom - viewportHeight;
+    }
+    if (dy == null) {
+      return null;
+    }
+    return math.max(dy, 0);
+  }
+
+  @override
+  Rect getLocalRectForCaret(TextPosition position) {
+    final targetChild = childAtPosition(position);
+    final localPosition = targetChild.globalToLocalPosition(position);
+
+    final childLocalRect = targetChild.getLocalRectForCaret(localPosition);
+
+    final boxParentData = targetChild.parentData as BoxParentData;
+    return childLocalRect.shift(Offset(0, boxParentData.offset.dy));
+  }
+}
+
+class EditableContainerParentData
+    extends ContainerBoxParentData<RenderEditableBox> {}
+
+class RenderEditableContainerBox extends RenderBox
+    with
+        ContainerRenderObjectMixin<RenderEditableBox,
+            EditableContainerParentData>,
+        RenderBoxContainerDefaultsMixin<RenderEditableBox,
+            EditableContainerParentData> {
+  RenderEditableContainerBox(
+    List<RenderEditableBox>? children,
+    this._container,
+    this.textDirection,
+    this.scrollBottomInset,
+    this._padding,
+  ) : assert(_padding.isNonNegative) {
+    addAll(children);
+  }
+
+  container_node.Container _container;
+  TextDirection textDirection;
+  EdgeInsetsGeometry _padding;
+  double scrollBottomInset;
+  EdgeInsets? _resolvedPadding;
+
+  container_node.Container getContainer() {
+    return _container;
+  }
+
+  void setContainer(container_node.Container c) {
+    if (_container == c) {
+      return;
+    }
+    _container = c;
+    markNeedsLayout();
+  }
+
+  EdgeInsetsGeometry getPadding() => _padding;
+
+  void setPadding(EdgeInsetsGeometry value) {
+    assert(value.isNonNegative);
+    if (_padding == value) {
+      return;
+    }
+    _padding = value;
+    _markNeedsPaddingResolution();
+  }
+
+  EdgeInsets? get resolvedPadding => _resolvedPadding;
+
+  void _resolvePadding() {
+    if (_resolvedPadding != null) {
+      return;
+    }
+    _resolvedPadding = _padding.resolve(textDirection);
+    _resolvedPadding = _resolvedPadding!.copyWith(left: _resolvedPadding!.left);
+
+    assert(_resolvedPadding!.isNonNegative);
+  }
+
+  RenderEditableBox childAtPosition(TextPosition position) {
+    assert(firstChild != null);
+
+    final targetNode = _container.queryChild(position.offset, false).node;
+
+    var targetChild = firstChild;
+    while (targetChild != null) {
+      if (targetChild.getContainer() == targetNode) {
+        break;
+      }
+      targetChild = childAfter(targetChild);
+    }
+    if (targetChild == null) {
+      throw 'targetChild should not be null';
+    }
+    return targetChild;
+  }
+
+  void _markNeedsPaddingResolution() {
+    _resolvedPadding = null;
+    markNeedsLayout();
+  }
+
+  RenderEditableBox? childAtOffset(Offset offset) {
+    assert(firstChild != null);
+    _resolvePadding();
+
+    if (offset.dy <= _resolvedPadding!.top) {
+      return firstChild;
+    }
+    if (offset.dy >= size.height - _resolvedPadding!.bottom) {
+      return lastChild;
+    }
+
+    var child = firstChild;
+    final dx = -offset.dx;
+    var dy = _resolvedPadding!.top;
+    while (child != null) {
+      if (child.size.contains(offset.translate(dx, -dy))) {
+        return child;
+      }
+      dy += child.size.height;
+      child = childAfter(child);
+    }
+    throw 'No child';
+  }
+
+  @override
+  void setupParentData(RenderBox child) {
+    if (child.parentData is EditableContainerParentData) {
+      return;
+    }
+
+    child.parentData = EditableContainerParentData();
+  }
+
+  @override
+  void performLayout() {
+    assert(constraints.hasBoundedWidth);
+    _resolvePadding();
+    assert(_resolvedPadding != null);
+
+    var mainAxisExtent = _resolvedPadding!.top;
+    var child = firstChild;
+    final innerConstraints =
+        BoxConstraints.tightFor(width: constraints.maxWidth)
+            .deflate(_resolvedPadding!);
+    while (child != null) {
+      child.layout(innerConstraints, parentUsesSize: true);
+      final childParentData = (child.parentData as EditableContainerParentData)
+        ..offset = Offset(_resolvedPadding!.left, mainAxisExtent);
+      mainAxisExtent += child.size.height;
+      assert(child.parentData == childParentData);
+      child = childParentData.nextSibling;
+    }
+    mainAxisExtent += _resolvedPadding!.bottom;
+    size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent));
+
+    assert(size.isFinite);
+  }
+
+  double _getIntrinsicCrossAxis(double Function(RenderBox child) childSize) {
+    var extent = 0.0;
+    var child = firstChild;
+    while (child != null) {
+      extent = math.max(extent, childSize(child));
+      final childParentData = child.parentData as EditableContainerParentData;
+      child = childParentData.nextSibling;
+    }
+    return extent;
+  }
+
+  double _getIntrinsicMainAxis(double Function(RenderBox child) childSize) {
+    var extent = 0.0;
+    var child = firstChild;
+    while (child != null) {
+      extent += childSize(child);
+      final childParentData = child.parentData as EditableContainerParentData;
+      child = childParentData.nextSibling;
+    }
+    return extent;
+  }
+
+  @override
+  double computeMinIntrinsicWidth(double height) {
+    _resolvePadding();
+    return _getIntrinsicCrossAxis((child) {
+      final childHeight = math.max<double>(
+          0, height - _resolvedPadding!.top + _resolvedPadding!.bottom);
+      return child.getMinIntrinsicWidth(childHeight) +
+          _resolvedPadding!.left +
+          _resolvedPadding!.right;
+    });
+  }
+
+  @override
+  double computeMaxIntrinsicWidth(double height) {
+    _resolvePadding();
+    return _getIntrinsicCrossAxis((child) {
+      final childHeight = math.max<double>(
+          0, height - _resolvedPadding!.top + _resolvedPadding!.bottom);
+      return child.getMaxIntrinsicWidth(childHeight) +
+          _resolvedPadding!.left +
+          _resolvedPadding!.right;
+    });
+  }
+
+  @override
+  double computeMinIntrinsicHeight(double width) {
+    _resolvePadding();
+    return _getIntrinsicMainAxis((child) {
+      final childWidth = math.max<double>(
+          0, width - _resolvedPadding!.left + _resolvedPadding!.right);
+      return child.getMinIntrinsicHeight(childWidth) +
+          _resolvedPadding!.top +
+          _resolvedPadding!.bottom;
+    });
+  }
+
+  @override
+  double computeMaxIntrinsicHeight(double width) {
+    _resolvePadding();
+    return _getIntrinsicMainAxis((child) {
+      final childWidth = math.max<double>(
+          0, width - _resolvedPadding!.left + _resolvedPadding!.right);
+      return child.getMaxIntrinsicHeight(childWidth) +
+          _resolvedPadding!.top +
+          _resolvedPadding!.bottom;
+    });
+  }
+
+  @override
+  double computeDistanceToActualBaseline(TextBaseline baseline) {
+    _resolvePadding();
+    return defaultComputeDistanceToFirstActualBaseline(baseline)! +
+        _resolvedPadding!.top;
+  }
+}

+ 31 - 0
app_flowy/packages/editor/lib/src/widgets/image.dart

@@ -0,0 +1,31 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:photo_view/photo_view.dart';
+
+class ImageTapWrapper extends StatelessWidget {
+  const ImageTapWrapper({
+    this.imageProvider,
+  });
+
+  final ImageProvider? imageProvider;
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: Container(
+        constraints: BoxConstraints.expand(
+          height: MediaQuery.of(context).size.height,
+        ),
+        child: GestureDetector(
+          onTapDown: (_) {
+            Navigator.pop(context);
+          },
+          child: PhotoView(
+            imageProvider: imageProvider,
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 129 - 0
app_flowy/packages/editor/lib/src/widgets/keyboard_listener.dart

@@ -0,0 +1,129 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+
+//fixme workaround flutter MacOS issue https://github.com/flutter/flutter/issues/75595
+extension _LogicalKeyboardKeyCaseExt on LogicalKeyboardKey {
+  static const _kUpperToLowerDist = 0x20;
+  static final _kLowerCaseA = LogicalKeyboardKey.keyA.keyId;
+  static final _kLowerCaseZ = LogicalKeyboardKey.keyZ.keyId;
+
+  LogicalKeyboardKey toUpperCase() {
+    if (keyId < _kLowerCaseA || keyId > _kLowerCaseZ) return this;
+    return LogicalKeyboardKey(keyId - _kUpperToLowerDist);
+  }
+}
+
+enum InputShortcut { CUT, COPY, PASTE, SELECT_ALL, UNDO, REDO }
+
+typedef CursorMoveCallback = void Function(
+    LogicalKeyboardKey key, bool wordModifier, bool lineModifier, bool shift);
+typedef InputShortcutCallback = void Function(InputShortcut? shortcut);
+typedef OnDeleteCallback = void Function(bool forward);
+
+class KeyboardEventHandler {
+  KeyboardEventHandler(this.onCursorMove, this.onShortcut, this.onDelete);
+
+  final CursorMoveCallback onCursorMove;
+  final InputShortcutCallback onShortcut;
+  final OnDeleteCallback onDelete;
+
+  static final Set<LogicalKeyboardKey> _moveKeys = <LogicalKeyboardKey>{
+    LogicalKeyboardKey.arrowRight,
+    LogicalKeyboardKey.arrowLeft,
+    LogicalKeyboardKey.arrowUp,
+    LogicalKeyboardKey.arrowDown,
+  };
+
+  static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{
+    LogicalKeyboardKey.keyA,
+    LogicalKeyboardKey.keyC,
+    LogicalKeyboardKey.keyV,
+    LogicalKeyboardKey.keyX,
+    LogicalKeyboardKey.keyZ.toUpperCase(),
+    LogicalKeyboardKey.keyZ,
+    LogicalKeyboardKey.delete,
+    LogicalKeyboardKey.backspace,
+  };
+
+  static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{
+    ..._shortcutKeys,
+    ..._moveKeys,
+  };
+
+  static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{
+    LogicalKeyboardKey.shift,
+    LogicalKeyboardKey.control,
+    LogicalKeyboardKey.alt,
+  };
+
+  static final Set<LogicalKeyboardKey> _macOsModifierKeys =
+      <LogicalKeyboardKey>{
+    LogicalKeyboardKey.shift,
+    LogicalKeyboardKey.meta,
+    LogicalKeyboardKey.alt,
+  };
+
+  static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{
+    ..._modifierKeys,
+    ..._macOsModifierKeys,
+    ..._nonModifierKeys,
+  };
+
+  static final Map<LogicalKeyboardKey, InputShortcut> _keyToShortcut = {
+    LogicalKeyboardKey.keyX: InputShortcut.CUT,
+    LogicalKeyboardKey.keyC: InputShortcut.COPY,
+    LogicalKeyboardKey.keyV: InputShortcut.PASTE,
+    LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL,
+  };
+
+  KeyEventResult handleRawKeyEvent(RawKeyEvent event) {
+    if (kIsWeb) {
+      // On web platform, we ignore the key because it's already processed.
+      return KeyEventResult.ignored;
+    }
+
+    if (event is! RawKeyDownEvent) {
+      return KeyEventResult.ignored;
+    }
+
+    final keysPressed =
+        LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed);
+    final key = event.logicalKey;
+    final isMacOS = event.data is RawKeyEventDataMacOs;
+    if (!_nonModifierKeys.contains(key) ||
+        keysPressed
+                .difference(isMacOS ? _macOsModifierKeys : _modifierKeys)
+                .length >
+            1 ||
+        keysPressed.difference(_interestingKeys).isNotEmpty) {
+      return KeyEventResult.ignored;
+    }
+
+    final isShortcutModifierPressed =
+        isMacOS ? event.isMetaPressed : event.isControlPressed;
+
+    if (_moveKeys.contains(key)) {
+      onCursorMove(
+          key,
+          isMacOS ? event.isAltPressed : event.isControlPressed,
+          isMacOS ? event.isMetaPressed : event.isAltPressed,
+          event.isShiftPressed);
+    } else if (isShortcutModifierPressed && (_shortcutKeys.contains(key))) {
+      if (key == LogicalKeyboardKey.keyZ ||
+          key == LogicalKeyboardKey.keyZ.toUpperCase()) {
+        onShortcut(
+            event.isShiftPressed ? InputShortcut.REDO : InputShortcut.UNDO);
+      } else {
+        onShortcut(_keyToShortcut[key]);
+      }
+    } else if (key == LogicalKeyboardKey.delete) {
+      onDelete(true);
+    } else if (key == LogicalKeyboardKey.backspace) {
+      onDelete(false);
+    } else {
+      return KeyEventResult.ignored;
+    }
+    return KeyEventResult.handled;
+  }
+}

+ 39 - 0
app_flowy/packages/editor/lib/src/widgets/link_dialog.dart

@@ -0,0 +1,39 @@
+import 'package:flutter/material.dart';
+
+class LinkDialog extends StatefulWidget {
+  const LinkDialog({Key? key}) : super(key: key);
+
+  @override
+  LinkDialogState createState() => LinkDialogState();
+}
+
+class LinkDialogState extends State<LinkDialog> {
+  String _link = '';
+
+  @override
+  Widget build(BuildContext context) {
+    return AlertDialog(
+      content: TextField(
+        decoration: const InputDecoration(labelText: 'Paste a link'),
+        autofocus: true,
+        onChanged: _linkChanged,
+      ),
+      actions: [
+        TextButton(
+          onPressed: _link.isNotEmpty ? _applyLink : null,
+          child: const Text('Ok'),
+        ),
+      ],
+    );
+  }
+
+  void _linkChanged(String value) {
+    setState(() {
+      _link = value;
+    });
+  }
+
+  void _applyLink() {
+    Navigator.pop(context, _link);
+  }
+}

+ 303 - 0
app_flowy/packages/editor/lib/src/widgets/proxy.dart

@@ -0,0 +1,303 @@
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+import 'box.dart';
+
+class BaselineProxy extends SingleChildRenderObjectWidget {
+  const BaselineProxy({Key? key, Widget? child, this.textStyle, this.padding})
+      : super(key: key, child: child);
+
+  final TextStyle? textStyle;
+  final EdgeInsets? padding;
+
+  @override
+  RenderBaselineProxy createRenderObject(BuildContext context) {
+    return RenderBaselineProxy(
+      null,
+      textStyle!,
+      padding,
+    );
+  }
+
+  @override
+  void updateRenderObject(
+      BuildContext context, covariant RenderBaselineProxy renderObject) {
+    renderObject
+      ..textStyle = textStyle!
+      ..padding = padding!;
+  }
+}
+
+class RenderBaselineProxy extends RenderProxyBox {
+  RenderBaselineProxy(
+    RenderParagraph? child,
+    TextStyle textStyle,
+    EdgeInsets? padding,
+  )   : _prototypePainter = TextPainter(
+            text: TextSpan(text: ' ', style: textStyle),
+            textDirection: TextDirection.ltr,
+            strutStyle:
+                StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)),
+        super(child);
+
+  final TextPainter _prototypePainter;
+
+  set textStyle(TextStyle value) {
+    if (_prototypePainter.text!.style == value) {
+      return;
+    }
+    _prototypePainter.text = TextSpan(text: ' ', style: value);
+    markNeedsLayout();
+  }
+
+  EdgeInsets? _padding;
+
+  set padding(EdgeInsets value) {
+    if (_padding == value) {
+      return;
+    }
+    _padding = value;
+    markNeedsLayout();
+  }
+
+  @override
+  double computeDistanceToActualBaseline(TextBaseline baseline) =>
+      _prototypePainter.computeDistanceToActualBaseline(baseline);
+  // SEE What happens + _padding?.top;
+
+  @override
+  void performLayout() {
+    super.performLayout();
+    _prototypePainter.layout();
+  }
+}
+
+class EmbedProxy extends SingleChildRenderObjectWidget {
+  const EmbedProxy(Widget child) : super(child: child);
+
+  @override
+  RenderEmbedProxy createRenderObject(BuildContext context) =>
+      RenderEmbedProxy(null);
+}
+
+class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox {
+  RenderEmbedProxy(RenderBox? child) : super(child);
+
+  @override
+  List<TextBox> getBoxesForSelection(TextSelection selection) {
+    if (!selection.isCollapsed) {
+      return <TextBox>[
+        TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr)
+      ];
+    }
+
+    final left = selection.extentOffset == 0 ? 0.0 : size.width;
+    final right = selection.extentOffset == 0 ? 0.0 : size.width;
+    return <TextBox>[
+      TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr)
+    ];
+  }
+
+  @override
+  double getFullHeightForCaret(TextPosition position) => size.height;
+
+  @override
+  Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) {
+    assert(
+        position.offset == 1 || position.offset == 0 || position.offset == -1);
+    return position.offset <= 0
+        ? Offset.zero
+        : Offset(
+            size.width - (caretPrototype == null ? 0 : caretPrototype.width),
+            0);
+  }
+
+  @override
+  TextPosition getPositionForOffset(Offset offset) =>
+      TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0);
+
+  @override
+  TextRange getWordBoundary(TextPosition position) =>
+      const TextRange(start: 0, end: 1);
+
+  @override
+  double getPreferredLineHeight() {
+    return size.height;
+  }
+}
+
+class RichTextProxy extends SingleChildRenderObjectWidget {
+  const RichTextProxy(
+    RichText child,
+    this.textStyle,
+    this.textAlign,
+    this.textDirection,
+    this.textScaleFactor,
+    this.locale,
+    this.strutStyle,
+    this.textWidthBasis,
+    this.textHeightBehavior,
+  ) : super(child: child);
+
+  final TextStyle textStyle;
+  final TextAlign textAlign;
+  final TextDirection textDirection;
+  final double textScaleFactor;
+  final Locale locale;
+  final StrutStyle strutStyle;
+  final TextWidthBasis textWidthBasis;
+  final TextHeightBehavior? textHeightBehavior;
+
+  @override
+  RenderParagraphProxy createRenderObject(BuildContext context) {
+    return RenderParagraphProxy(
+        null,
+        textStyle,
+        textAlign,
+        textDirection,
+        textScaleFactor,
+        strutStyle,
+        locale,
+        textWidthBasis,
+        textHeightBehavior);
+  }
+
+  @override
+  void updateRenderObject(
+      BuildContext context, covariant RenderParagraphProxy renderObject) {
+    renderObject
+      ..textStyle = textStyle
+      ..textAlign = textAlign
+      ..textDirection = textDirection
+      ..textScaleFactor = textScaleFactor
+      ..locale = locale
+      ..strutStyle = strutStyle
+      ..textWidthBasis = textWidthBasis
+      ..textHeightBehavior = textHeightBehavior;
+  }
+}
+
+class RenderParagraphProxy extends RenderProxyBox
+    implements RenderContentProxyBox {
+  RenderParagraphProxy(
+    RenderParagraph? child,
+    TextStyle textStyle,
+    TextAlign textAlign,
+    TextDirection textDirection,
+    double textScaleFactor,
+    StrutStyle strutStyle,
+    Locale locale,
+    TextWidthBasis textWidthBasis,
+    TextHeightBehavior? textHeightBehavior,
+  )   : _prototypePainter = TextPainter(
+            text: TextSpan(text: ' ', style: textStyle),
+            textAlign: textAlign,
+            textDirection: textDirection,
+            textScaleFactor: textScaleFactor,
+            strutStyle: strutStyle,
+            locale: locale,
+            textWidthBasis: textWidthBasis,
+            textHeightBehavior: textHeightBehavior),
+        super(child);
+
+  final TextPainter _prototypePainter;
+
+  set textStyle(TextStyle value) {
+    if (_prototypePainter.text!.style == value) {
+      return;
+    }
+    _prototypePainter.text = TextSpan(text: ' ', style: value);
+    markNeedsLayout();
+  }
+
+  set textAlign(TextAlign value) {
+    if (_prototypePainter.textAlign == value) {
+      return;
+    }
+    _prototypePainter.textAlign = value;
+    markNeedsLayout();
+  }
+
+  set textDirection(TextDirection value) {
+    if (_prototypePainter.textDirection == value) {
+      return;
+    }
+    _prototypePainter.textDirection = value;
+    markNeedsLayout();
+  }
+
+  set textScaleFactor(double value) {
+    if (_prototypePainter.textScaleFactor == value) {
+      return;
+    }
+    _prototypePainter.textScaleFactor = value;
+    markNeedsLayout();
+  }
+
+  set strutStyle(StrutStyle value) {
+    if (_prototypePainter.strutStyle == value) {
+      return;
+    }
+    _prototypePainter.strutStyle = value;
+    markNeedsLayout();
+  }
+
+  set locale(Locale value) {
+    if (_prototypePainter.locale == value) {
+      return;
+    }
+    _prototypePainter.locale = value;
+    markNeedsLayout();
+  }
+
+  set textWidthBasis(TextWidthBasis value) {
+    if (_prototypePainter.textWidthBasis == value) {
+      return;
+    }
+    _prototypePainter.textWidthBasis = value;
+    markNeedsLayout();
+  }
+
+  set textHeightBehavior(TextHeightBehavior? value) {
+    if (_prototypePainter.textHeightBehavior == value) {
+      return;
+    }
+    _prototypePainter.textHeightBehavior = value;
+    markNeedsLayout();
+  }
+
+  @override
+  RenderParagraph? get child => super.child as RenderParagraph?;
+
+  @override
+  double getPreferredLineHeight() {
+    return _prototypePainter.preferredLineHeight;
+  }
+
+  @override
+  Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) =>
+      child!.getOffsetForCaret(position, caretPrototype!);
+
+  @override
+  TextPosition getPositionForOffset(Offset offset) =>
+      child!.getPositionForOffset(offset);
+
+  @override
+  double? getFullHeightForCaret(TextPosition position) =>
+      child!.getFullHeightForCaret(position);
+
+  @override
+  TextRange getWordBoundary(TextPosition position) =>
+      child!.getWordBoundary(position);
+
+  @override
+  List<TextBox> getBoxesForSelection(TextSelection selection) =>
+      child!.getBoxesForSelection(selection);
+
+  @override
+  void performLayout() {
+    super.performLayout();
+    _prototypePainter.layout(
+        minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
+  }
+}

+ 764 - 0
app_flowy/packages/editor/lib/src/widgets/raw_editor.dart

@@ -0,0 +1,764 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:math' as math;
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
+import 'package:tuple/tuple.dart';
+
+import '../models/documents/attribute.dart';
+import '../models/documents/document.dart';
+import '../models/documents/nodes/block.dart';
+import '../models/documents/nodes/line.dart';
+import 'controller.dart';
+import 'cursor.dart';
+import 'default_styles.dart';
+import 'delegate.dart';
+import 'editor.dart';
+import 'keyboard_listener.dart';
+import 'proxy.dart';
+import 'raw_editor/raw_editor_state_keyboard_mixin.dart';
+import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart';
+import 'raw_editor/raw_editor_state_text_input_client_mixin.dart';
+import 'text_block.dart';
+import 'text_line.dart';
+import 'text_selection.dart';
+
+class RawEditor extends StatefulWidget {
+  const RawEditor(
+    Key key,
+    this.controller,
+    this.focusNode,
+    this.scrollController,
+    this.scrollable,
+    this.scrollBottomInset,
+    this.padding,
+    this.readOnly,
+    this.placeholder,
+    this.onLaunchUrl,
+    this.toolbarOptions,
+    this.showSelectionHandles,
+    bool? showCursor,
+    this.cursorStyle,
+    this.textCapitalization,
+    this.maxHeight,
+    this.minHeight,
+    this.customStyles,
+    this.expands,
+    this.autoFocus,
+    this.selectionColor,
+    this.selectionCtrls,
+    this.keyboardAppearance,
+    this.enableInteractiveSelection,
+    this.scrollPhysics,
+    this.embedBuilder,
+    this.customStyleBuilder,
+  )   : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
+        assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'),
+        assert(maxHeight == null || minHeight == null || maxHeight >= minHeight,
+            'maxHeight cannot be null'),
+        showCursor = showCursor ?? true,
+        super(key: key);
+
+  final QuillController controller;
+  final FocusNode focusNode;
+  final ScrollController scrollController;
+  final bool scrollable;
+  final double scrollBottomInset;
+  final EdgeInsetsGeometry padding;
+  final bool readOnly;
+  final String? placeholder;
+  final ValueChanged<String>? onLaunchUrl;
+  final ToolbarOptions toolbarOptions;
+  final bool showSelectionHandles;
+  final bool showCursor;
+  final CursorStyle cursorStyle;
+  final TextCapitalization textCapitalization;
+  final double? maxHeight;
+  final double? minHeight;
+  final DefaultStyles? customStyles;
+  final bool expands;
+  final bool autoFocus;
+  final Color selectionColor;
+  final TextSelectionControls selectionCtrls;
+  final Brightness keyboardAppearance;
+  final bool enableInteractiveSelection;
+  final ScrollPhysics? scrollPhysics;
+  final EmbedBuilder embedBuilder;
+  final CustomStyleBuilder? customStyleBuilder;
+  @override
+  State<StatefulWidget> createState() => RawEditorState();
+}
+
+class RawEditorState extends EditorState
+    with
+        AutomaticKeepAliveClientMixin<RawEditor>,
+        WidgetsBindingObserver,
+        TickerProviderStateMixin<RawEditor>,
+        RawEditorStateKeyboardMixin,
+        RawEditorStateTextInputClientMixin,
+        RawEditorStateSelectionDelegateMixin {
+  final GlobalKey _editorKey = GlobalKey();
+
+  // Keyboard
+  late KeyboardEventHandler _keyboardListener;
+  KeyboardVisibilityController? _keyboardVisibilityController;
+  StreamSubscription<bool>? _keyboardVisibilitySubscription;
+  bool _keyboardVisible = false;
+
+  // Selection overlay
+  @override
+  EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay;
+  EditorTextSelectionOverlay? _selectionOverlay;
+
+  @override
+  ScrollController get scrollController => _scrollController;
+  late ScrollController _scrollController;
+
+  late CursorCont _cursorCont;
+
+  // Focus
+  bool _didAutoFocus = false;
+  FocusAttachment? _focusAttachment;
+  bool get _hasFocus => widget.focusNode.hasFocus;
+
+  DefaultStyles? _styles;
+
+  final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier();
+  final LayerLink _toolbarLayerLink = LayerLink();
+  final LayerLink _startHandleLayerLink = LayerLink();
+  final LayerLink _endHandleLayerLink = LayerLink();
+
+  TextDirection get _textDirection => Directionality.of(context);
+
+  @override
+  Widget build(BuildContext context) {
+    assert(debugCheckHasMediaQuery(context));
+    _focusAttachment!.reparent();
+    super.build(context);
+
+    var _doc = widget.controller.document;
+    if (_doc.isEmpty() && widget.placeholder != null) {
+      _doc = Document.fromJson(jsonDecode(
+          '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]'));
+    }
+
+    Widget child = CompositedTransformTarget(
+      link: _toolbarLayerLink,
+      child: Semantics(
+        child: _Editor(
+          key: _editorKey,
+          document: _doc,
+          selection: widget.controller.selection,
+          hasFocus: _hasFocus,
+          textDirection: _textDirection,
+          startHandleLayerLink: _startHandleLayerLink,
+          endHandleLayerLink: _endHandleLayerLink,
+          onSelectionChanged: _handleSelectionChanged,
+          scrollBottomInset: widget.scrollBottomInset,
+          padding: widget.padding,
+          children: _buildChildren(_doc, context),
+        ),
+      ),
+    );
+
+    if (widget.scrollable) {
+      final baselinePadding =
+          EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1);
+      child = BaselineProxy(
+        textStyle: _styles!.paragraph!.style,
+        padding: baselinePadding,
+        child: SingleChildScrollView(
+          controller: _scrollController,
+          physics: widget.scrollPhysics,
+          child: child,
+        ),
+      );
+    }
+
+    final constraints = widget.expands
+        ? const BoxConstraints.expand()
+        : BoxConstraints(
+            minHeight: widget.minHeight ?? 0.0,
+            maxHeight: widget.maxHeight ?? double.infinity);
+
+    return QuillStyles(
+      data: _styles!,
+      child: MouseRegion(
+        cursor: SystemMouseCursors.text,
+        child: Container(
+          constraints: constraints,
+          child: child,
+        ),
+      ),
+    );
+  }
+
+  void _handleSelectionChanged(
+      TextSelection selection, SelectionChangedCause cause) {
+    widget.controller.updateSelection(selection, ChangeSource.LOCAL);
+
+    _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
+
+    if (!_keyboardVisible) {
+      requestKeyboard();
+    }
+  }
+
+  /// Updates the checkbox positioned at [offset] in document
+  /// by changing its attribute according to [value].
+  void _handleCheckboxTap(int offset, bool value) {
+    if (!widget.readOnly) {
+      if (value) {
+        widget.controller.formatText(offset, 0, Attribute.checked);
+      } else {
+        widget.controller.formatText(offset, 0, Attribute.unchecked);
+      }
+    }
+  }
+
+  List<Widget> _buildChildren(Document doc, BuildContext context) {
+    final result = <Widget>[];
+    final indentLevelCounts = <int, int>{};
+    for (final node in doc.root.children) {
+      if (node is Line) {
+        final editableTextLine = _getEditableTextLineFromNode(node, context);
+        result.add(editableTextLine);
+      } else if (node is Block) {
+        final attrs = node.style.attributes;
+        final editableTextBlock = EditableTextBlock(
+            block: node,
+            textDirection: _textDirection,
+            scrollBottomInset: widget.scrollBottomInset,
+            verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
+            textSelection: widget.controller.selection,
+            color: widget.selectionColor,
+            styles: _styles,
+            enableInteractiveSelection: widget.enableInteractiveSelection,
+            hasFocus: _hasFocus,
+            contentPadding: attrs.containsKey(Attribute.codeBlock.key)
+                ? const EdgeInsets.all(16)
+                : null,
+            embedBuilder: widget.embedBuilder,
+            cursorCont: _cursorCont,
+            indentLevelCounts: indentLevelCounts,
+            onCheckboxTap: _handleCheckboxTap,
+            readOnly: widget.readOnly,
+            customStyleBuilder: widget.customStyleBuilder);
+        result.add(editableTextBlock);
+      } else {
+        throw StateError('Unreachable.');
+      }
+    }
+    return result;
+  }
+
+  EditableTextLine _getEditableTextLineFromNode(
+      Line node, BuildContext context) {
+    final textLine = TextLine(
+      line: node,
+      textDirection: _textDirection,
+      embedBuilder: widget.embedBuilder,
+      customStyleBuilder: widget.customStyleBuilder,
+      styles: _styles!,
+      readOnly: widget.readOnly,
+    );
+    final editableTextLine = EditableTextLine(
+        node,
+        null,
+        textLine,
+        0,
+        _getVerticalSpacingForLine(node, _styles),
+        _textDirection,
+        widget.controller.selection,
+        widget.selectionColor,
+        widget.enableInteractiveSelection,
+        _hasFocus,
+        MediaQuery.of(context).devicePixelRatio,
+        _cursorCont);
+    return editableTextLine;
+  }
+
+  Tuple2<double, double> _getVerticalSpacingForLine(
+      Line line, DefaultStyles? defaultStyles) {
+    final attrs = line.style.attributes;
+    if (attrs.containsKey(Attribute.header.key)) {
+      final int? level = attrs[Attribute.header.key]!.value;
+      switch (level) {
+        case 1:
+          return defaultStyles!.h1!.verticalSpacing;
+        case 2:
+          return defaultStyles!.h2!.verticalSpacing;
+        case 3:
+          return defaultStyles!.h3!.verticalSpacing;
+        default:
+          throw 'Invalid level $level';
+      }
+    }
+
+    return defaultStyles!.paragraph!.verticalSpacing;
+  }
+
+  Tuple2<double, double> _getVerticalSpacingForBlock(
+      Block node, DefaultStyles? defaultStyles) {
+    final attrs = node.style.attributes;
+    if (attrs.containsKey(Attribute.blockQuote.key)) {
+      return defaultStyles!.quote!.verticalSpacing;
+    } else if (attrs.containsKey(Attribute.codeBlock.key)) {
+      return defaultStyles!.code!.verticalSpacing;
+    } else if (attrs.containsKey(Attribute.indent.key)) {
+      return defaultStyles!.indent!.verticalSpacing;
+    } else if (attrs.containsKey(Attribute.list.key)) {
+      return defaultStyles!.lists!.verticalSpacing;
+    } else if (attrs.containsKey(Attribute.align.key)) {
+      return defaultStyles!.align!.verticalSpacing;
+    }
+    return const Tuple2(0, 0);
+  }
+
+  @override
+  void initState() {
+    super.initState();
+
+    _clipboardStatus.addListener(_onChangedClipboardStatus);
+
+    widget.controller.addListener(() {
+      _didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange);
+    });
+
+    _scrollController = widget.scrollController;
+    _scrollController.addListener(_updateSelectionOverlayForScroll);
+
+    _cursorCont = CursorCont(
+      show: ValueNotifier<bool>(widget.showCursor),
+      style: widget.cursorStyle,
+      tickerProvider: this,
+    );
+
+    _keyboardListener = KeyboardEventHandler(
+      handleCursorMovement,
+      handleShortcut,
+      handleDelete,
+    );
+
+    if (defaultTargetPlatform == TargetPlatform.windows ||
+        defaultTargetPlatform == TargetPlatform.macOS ||
+        defaultTargetPlatform == TargetPlatform.linux ||
+        defaultTargetPlatform == TargetPlatform.fuchsia) {
+      _keyboardVisible = true;
+    } else {
+      _keyboardVisibilityController = KeyboardVisibilityController();
+      _keyboardVisible = _keyboardVisibilityController!.isVisible;
+      _keyboardVisibilitySubscription =
+          _keyboardVisibilityController?.onChange.listen((visible) {
+        _keyboardVisible = visible;
+        if (visible) {
+          _onChangeTextEditingValue();
+        }
+      });
+    }
+
+    _focusAttachment = widget.focusNode.attach(context,
+        onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
+    widget.focusNode.addListener(_handleFocusChanged);
+  }
+
+  @override
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+    final parentStyles = QuillStyles.getStyles(context, true);
+    final defaultStyles = DefaultStyles.getInstance(context);
+    _styles = (parentStyles != null)
+        ? defaultStyles.merge(parentStyles)
+        : defaultStyles;
+
+    if (widget.customStyles != null) {
+      _styles = _styles!.merge(widget.customStyles!);
+    }
+
+    if (!_didAutoFocus && widget.autoFocus) {
+      FocusScope.of(context).autofocus(widget.focusNode);
+      _didAutoFocus = true;
+    }
+  }
+
+  @override
+  void didUpdateWidget(RawEditor oldWidget) {
+    super.didUpdateWidget(oldWidget);
+
+    _cursorCont.show.value = widget.showCursor;
+    _cursorCont.style = widget.cursorStyle;
+
+    if (widget.controller != oldWidget.controller) {
+      oldWidget.controller.removeListener(_didChangeTextEditingValue);
+      widget.controller.addListener(_didChangeTextEditingValue);
+      updateRemoteValueIfNeeded();
+    }
+
+    if (widget.scrollController != _scrollController) {
+      _scrollController.removeListener(_updateSelectionOverlayForScroll);
+      _scrollController = widget.scrollController;
+      _scrollController.addListener(_updateSelectionOverlayForScroll);
+    }
+
+    if (widget.focusNode != oldWidget.focusNode) {
+      oldWidget.focusNode.removeListener(_handleFocusChanged);
+      _focusAttachment?.detach();
+      _focusAttachment = widget.focusNode.attach(context,
+          onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
+      widget.focusNode.addListener(_handleFocusChanged);
+      updateKeepAlive();
+    }
+
+    if (widget.controller.selection != oldWidget.controller.selection) {
+      _selectionOverlay?.update(textEditingValue);
+    }
+
+    _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
+    if (!shouldCreateInputConnection) {
+      closeConnectionIfNeeded();
+    } else {
+      if (oldWidget.readOnly && _hasFocus) {
+        openConnectionIfNeeded();
+      }
+    }
+  }
+
+  bool _shouldShowSelectionHandles() {
+    return widget.showSelectionHandles &&
+        !widget.controller.selection.isCollapsed;
+  }
+
+  @override
+  void dispose() {
+    closeConnectionIfNeeded();
+    _keyboardVisibilitySubscription?.cancel();
+    assert(!hasConnection);
+    _selectionOverlay?.dispose();
+    _selectionOverlay = null;
+    widget.controller.removeListener(_didChangeTextEditingValue);
+    widget.focusNode.removeListener(_handleFocusChanged);
+    _focusAttachment!.detach();
+    _cursorCont.dispose();
+    _clipboardStatus
+      ..removeListener(_onChangedClipboardStatus)
+      ..dispose();
+    super.dispose();
+  }
+
+  void _updateSelectionOverlayForScroll() {
+    _selectionOverlay?.markNeedsBuild();
+  }
+
+  void _didChangeTextEditingValue([bool ignoreFocus = false]) {
+    if (kIsWeb) {
+      _onChangeTextEditingValue(ignoreFocus);
+      if (!ignoreFocus) {
+        requestKeyboard();
+      }
+      return;
+    }
+
+    if (ignoreFocus || _keyboardVisible) {
+      _onChangeTextEditingValue(ignoreFocus);
+    } else {
+      requestKeyboard();
+      if (mounted) {
+        setState(() {
+          // Use widget.controller.value in build()
+          // Trigger build and updateChildren
+        });
+      }
+    }
+  }
+
+  void _onChangeTextEditingValue([bool ignoreCaret = false]) {
+    updateRemoteValueIfNeeded();
+    if (ignoreCaret) {
+      return;
+    }
+    _showCaretOnScreen();
+    _cursorCont.startOrStopCursorTimerIfNeeded(
+        _hasFocus, widget.controller.selection);
+    if (hasConnection) {
+      _cursorCont
+        ..stopCursorTimer(resetCharTicks: false)
+        ..startCursorTimer();
+    }
+
+    SchedulerBinding.instance!.addPostFrameCallback((_) {
+      if (!mounted) {
+        return;
+      }
+      _updateOrDisposeSelectionOverlayIfNeeded();
+    });
+    if (mounted) {
+      setState(() {
+        // Use widget.controller.value in build()
+        // Trigger build and updateChildren
+      });
+    }
+  }
+
+  void _updateOrDisposeSelectionOverlayIfNeeded() {
+    if (_selectionOverlay != null) {
+      if (_hasFocus) {
+        _selectionOverlay!.update(textEditingValue);
+      } else {
+        _selectionOverlay!.dispose();
+        _selectionOverlay = null;
+      }
+    } else if (_hasFocus) {
+      _selectionOverlay?.hide();
+      _selectionOverlay = null;
+
+      _selectionOverlay = EditorTextSelectionOverlay(
+        textEditingValue,
+        false,
+        context,
+        widget,
+        _toolbarLayerLink,
+        _startHandleLayerLink,
+        _endHandleLayerLink,
+        getRenderEditor(),
+        widget.selectionCtrls,
+        this,
+        DragStartBehavior.start,
+        null,
+        _clipboardStatus,
+      );
+      _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles();
+      _selectionOverlay!.showHandles();
+    }
+  }
+
+  void _handleFocusChanged() {
+    openOrCloseConnection();
+    _cursorCont.startOrStopCursorTimerIfNeeded(
+        _hasFocus, widget.controller.selection);
+    _updateOrDisposeSelectionOverlayIfNeeded();
+    if (_hasFocus) {
+      WidgetsBinding.instance!.addObserver(this);
+      _showCaretOnScreen();
+    } else {
+      WidgetsBinding.instance!.removeObserver(this);
+    }
+    updateKeepAlive();
+  }
+
+  void _onChangedClipboardStatus() {
+    if (!mounted) return;
+    setState(() {
+      // Inform the widget that the value of clipboardStatus has changed.
+      // Trigger build and updateChildren
+    });
+  }
+
+  bool _showCaretOnScreenScheduled = false;
+
+  void _showCaretOnScreen() {
+    if (!widget.showCursor || _showCaretOnScreenScheduled) {
+      return;
+    }
+
+    _showCaretOnScreenScheduled = true;
+    SchedulerBinding.instance!.addPostFrameCallback((_) {
+      if (widget.scrollable || _scrollController.hasClients) {
+        _showCaretOnScreenScheduled = false;
+
+        final renderEditor = getRenderEditor();
+        if (renderEditor == null) {
+          return;
+        }
+
+        final viewport = RenderAbstractViewport.of(renderEditor);
+        final editorOffset =
+            renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport);
+        final offsetInViewport = _scrollController.offset + editorOffset.dy;
+
+        final offset = renderEditor.getOffsetToRevealCursor(
+          _scrollController.position.viewportDimension,
+          _scrollController.offset,
+          offsetInViewport,
+        );
+
+        if (offset != null) {
+          _scrollController.animateTo(
+            math.min(offset, _scrollController.position.maxScrollExtent),
+            duration: const Duration(milliseconds: 100),
+            curve: Curves.fastOutSlowIn,
+          );
+        }
+      }
+    });
+  }
+
+  @override
+  RenderEditor? getRenderEditor() {
+    return _editorKey.currentContext?.findRenderObject() as RenderEditor?;
+  }
+
+  @override
+  TextEditingValue getTextEditingValue() {
+    return widget.controller.plainTextEditingValue;
+  }
+
+  @override
+  void requestKeyboard() {
+    if (_hasFocus) {
+      openConnectionIfNeeded();
+      _showCaretOnScreen();
+    } else {
+      widget.focusNode.requestFocus();
+    }
+  }
+
+  @override
+  void setTextEditingValue(TextEditingValue value) {
+    if (value.text == textEditingValue.text) {
+      widget.controller.updateSelection(value.selection, ChangeSource.LOCAL);
+    } else {
+      _setEditingValue(value);
+    }
+  }
+
+  // set editing value from clipboard for mobile
+  Future<void> _setEditingValue(TextEditingValue value) async {
+    if (await _isItCut(value)) {
+      widget.controller.replaceText(
+        textEditingValue.selection.start,
+        textEditingValue.text.length - value.text.length,
+        '',
+        value.selection,
+      );
+    } else {
+      final value = textEditingValue;
+      final data = await Clipboard.getData(Clipboard.kTextPlain);
+      if (data != null) {
+        final length =
+            textEditingValue.selection.end - textEditingValue.selection.start;
+        var str = data.text!;
+        final codes = data.text!.codeUnits;
+        // For clip from editor, it may contain image, a.k.a 65532.
+        // For clip from browser, image is directly ignore.
+        // Here we skip image when pasting.
+        if (codes.contains(65532)) {
+          final sb = StringBuffer();
+          for (var i = 0; i < str.length; i++) {
+            if (str.codeUnitAt(i) == 65532) {
+              continue;
+            }
+            sb.write(str[i]);
+          }
+          str = sb.toString();
+        }
+        widget.controller.replaceText(
+          value.selection.start,
+          length,
+          str,
+          value.selection,
+        );
+        // move cursor to the end of pasted text selection
+        widget.controller.updateSelection(
+            TextSelection.collapsed(
+                offset: value.selection.start + data.text!.length),
+            ChangeSource.LOCAL);
+      }
+    }
+  }
+
+  Future<bool> _isItCut(TextEditingValue value) async {
+    final data = await Clipboard.getData(Clipboard.kTextPlain);
+    if (data == null) {
+      return false;
+    }
+    return textEditingValue.text.length - value.text.length ==
+        data.text!.length;
+  }
+
+  @override
+  bool showToolbar() {
+    // Web is using native dom elements to enable clipboard functionality of the
+    // toolbar: copy, paste, select, cut. It might also provide additional
+    // functionality depending on the browser (such as translate). Due to this
+    // we should not show a Flutter toolbar for the editable text elements.
+    if (kIsWeb) {
+      return false;
+    }
+    if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) {
+      return false;
+    }
+
+    _selectionOverlay!.update(textEditingValue);
+    _selectionOverlay!.showToolbar();
+    return true;
+  }
+
+  @override
+  bool get wantKeepAlive => widget.focusNode.hasFocus;
+}
+
+class _Editor extends MultiChildRenderObjectWidget {
+  _Editor({
+    required Key key,
+    required List<Widget> children,
+    required this.document,
+    required this.textDirection,
+    required this.hasFocus,
+    required this.selection,
+    required this.startHandleLayerLink,
+    required this.endHandleLayerLink,
+    required this.onSelectionChanged,
+    required this.scrollBottomInset,
+    this.padding = EdgeInsets.zero,
+  }) : super(key: key, children: children);
+
+  final Document document;
+  final TextDirection textDirection;
+  final bool hasFocus;
+  final TextSelection selection;
+  final LayerLink startHandleLayerLink;
+  final LayerLink endHandleLayerLink;
+  final TextSelectionChangedHandler onSelectionChanged;
+  final double scrollBottomInset;
+  final EdgeInsetsGeometry padding;
+
+  @override
+  RenderEditor createRenderObject(BuildContext context) {
+    return RenderEditor(
+      null,
+      textDirection,
+      scrollBottomInset,
+      padding,
+      document,
+      selection,
+      hasFocus,
+      onSelectionChanged,
+      startHandleLayerLink,
+      endHandleLayerLink,
+      const EdgeInsets.fromLTRB(4, 4, 4, 5),
+    );
+  }
+
+  @override
+  void updateRenderObject(
+      BuildContext context, covariant RenderEditor renderObject) {
+    renderObject
+      ..document = document
+      ..setContainer(document.root)
+      ..textDirection = textDirection
+      ..setHasFocus(hasFocus)
+      ..setSelection(selection)
+      ..setStartHandleLayerLink(startHandleLayerLink)
+      ..setEndHandleLayerLink(endHandleLayerLink)
+      ..onSelectionChanged = onSelectionChanged
+      ..setScrollBottomInset(scrollBottomInset)
+      ..setPadding(padding);
+  }
+}

+ 367 - 0
app_flowy/packages/editor/lib/src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart

@@ -0,0 +1,367 @@
+import 'dart:ui';
+
+import 'package:characters/characters.dart';
+import 'package:flutter/services.dart';
+
+import '../../models/documents/document.dart';
+import '../../utils/diff_delta.dart';
+import '../editor.dart';
+import '../keyboard_listener.dart';
+
+mixin RawEditorStateKeyboardMixin on EditorState {
+  // Holds the last cursor location the user selected in the case the user tries
+  // to select vertically past the end or beginning of the field. If they do,
+  // then we need to keep the old cursor location so that we can go back to it
+  // if they change their minds. Only used for moving selection up and down in a
+  // multiline text field when selecting using the keyboard.
+  int _cursorResetLocation = -1;
+
+  // Whether we should reset the location of the cursor in the case the user
+  // tries to select vertically past the end or beginning of the field. If they
+  // do, then we need to keep the old cursor location so that we can go back to
+  // it if they change their minds. Only used for resetting selection up and
+  // down in a multiline text field when selecting using the keyboard.
+  bool _wasSelectingVerticallyWithKeyboard = false;
+
+  void handleCursorMovement(
+    LogicalKeyboardKey key,
+    bool wordModifier,
+    bool lineModifier,
+    bool shift,
+  ) {
+    if (wordModifier && lineModifier) {
+      // If both modifiers are down, nothing happens on any of the platforms.
+      return;
+    }
+    final selection = widget.controller.selection;
+
+    var newSelection = widget.controller.selection;
+
+    final plainText = getTextEditingValue().text;
+
+    final rightKey = key == LogicalKeyboardKey.arrowRight,
+        leftKey = key == LogicalKeyboardKey.arrowLeft,
+        upKey = key == LogicalKeyboardKey.arrowUp,
+        downKey = key == LogicalKeyboardKey.arrowDown;
+
+    if ((rightKey || leftKey) && !(rightKey && leftKey)) {
+      newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier,
+          leftKey, rightKey, plainText, lineModifier, shift);
+    }
+
+    if (downKey || upKey) {
+      newSelection = _handleMovingCursorVertically(
+          upKey, downKey, shift, selection, newSelection, plainText);
+    }
+
+    if (!shift) {
+      newSelection =
+          _placeCollapsedSelection(selection, newSelection, leftKey, rightKey);
+    }
+
+    widget.controller.updateSelection(newSelection, ChangeSource.LOCAL);
+  }
+
+  // Handles shortcut functionality including cut, copy, paste and select all
+  // using control/command + (X, C, V, A).
+  // TODO: Add support for formatting shortcuts: Cmd+B (bold), Cmd+I (italic)
+  // set editing value from clipboard for web
+  Future<void> handleShortcut(InputShortcut? shortcut) async {
+    final selection = widget.controller.selection;
+    final plainText = getTextEditingValue().text;
+    if (shortcut == InputShortcut.COPY) {
+      if (!selection.isCollapsed) {
+        await Clipboard.setData(
+            ClipboardData(text: selection.textInside(plainText)));
+      }
+      return;
+    }
+    if (shortcut == InputShortcut.UNDO) {
+      if (widget.controller.hasUndo) {
+        widget.controller.undo();
+      }
+      return;
+    }
+    if (shortcut == InputShortcut.REDO) {
+      if (widget.controller.hasRedo) {
+        widget.controller.redo();
+      }
+      return;
+    }
+    if (shortcut == InputShortcut.CUT && !widget.readOnly) {
+      if (!selection.isCollapsed) {
+        final data = selection.textInside(plainText);
+        await Clipboard.setData(ClipboardData(text: data));
+
+        widget.controller.replaceText(
+          selection.start,
+          data.length,
+          '',
+          TextSelection.collapsed(offset: selection.start),
+        );
+
+        setTextEditingValue(TextEditingValue(
+          text:
+              selection.textBefore(plainText) + selection.textAfter(plainText),
+          selection: TextSelection.collapsed(offset: selection.start),
+        ));
+      }
+      return;
+    }
+    if (shortcut == InputShortcut.PASTE && !widget.readOnly) {
+      final data = await Clipboard.getData(Clipboard.kTextPlain);
+      if (data != null) {
+        widget.controller.replaceText(
+          selection.start,
+          selection.end - selection.start,
+          data.text,
+          TextSelection.collapsed(offset: selection.start + data.text!.length),
+        );
+      }
+      return;
+    }
+    if (shortcut == InputShortcut.SELECT_ALL &&
+        widget.enableInteractiveSelection) {
+      widget.controller.updateSelection(
+          selection.copyWith(
+            baseOffset: 0,
+            extentOffset: getTextEditingValue().text.length,
+          ),
+          ChangeSource.REMOTE);
+      return;
+    }
+  }
+
+  void handleDelete(bool forward) {
+    final selection = widget.controller.selection;
+    final plainText = getTextEditingValue().text;
+    var cursorPosition = selection.start;
+    var textBefore = selection.textBefore(plainText);
+    var textAfter = selection.textAfter(plainText);
+    if (selection.isCollapsed) {
+      if (!forward && textBefore.isNotEmpty) {
+        final characterBoundary =
+            _previousCharacter(textBefore.length, textBefore, true);
+        textBefore = textBefore.substring(0, characterBoundary);
+        cursorPosition = characterBoundary;
+      }
+      if (forward && textAfter.isNotEmpty && textAfter != '\n') {
+        final deleteCount = _nextCharacter(0, textAfter, true);
+        textAfter = textAfter.substring(deleteCount);
+      }
+    }
+    final newSelection = TextSelection.collapsed(offset: cursorPosition);
+    final newText = textBefore + textAfter;
+    final size = plainText.length - newText.length;
+    widget.controller.replaceText(
+      cursorPosition,
+      size,
+      '',
+      newSelection,
+    );
+  }
+
+  TextSelection _jumpToBeginOrEndOfWord(
+      TextSelection newSelection,
+      bool wordModifier,
+      bool leftKey,
+      bool rightKey,
+      String plainText,
+      bool lineModifier,
+      bool shift) {
+    if (wordModifier) {
+      if (leftKey) {
+        final textSelection = getRenderEditor()!.selectWordAtPosition(
+            TextPosition(
+                offset: _previousCharacter(
+                    newSelection.extentOffset, plainText, false)));
+        return newSelection.copyWith(extentOffset: textSelection.baseOffset);
+      }
+      final textSelection = getRenderEditor()!.selectWordAtPosition(
+          TextPosition(
+              offset:
+                  _nextCharacter(newSelection.extentOffset, plainText, false)));
+      return newSelection.copyWith(extentOffset: textSelection.extentOffset);
+    } else if (lineModifier) {
+      if (leftKey) {
+        final textSelection = getRenderEditor()!.selectLineAtPosition(
+            TextPosition(
+                offset: _previousCharacter(
+                    newSelection.extentOffset, plainText, false)));
+        return newSelection.copyWith(extentOffset: textSelection.baseOffset);
+      }
+      final startPoint = newSelection.extentOffset;
+      if (startPoint < plainText.length) {
+        final textSelection = getRenderEditor()!
+            .selectLineAtPosition(TextPosition(offset: startPoint));
+        return newSelection.copyWith(extentOffset: textSelection.extentOffset);
+      }
+      return newSelection;
+    }
+
+    if (rightKey && newSelection.extentOffset < plainText.length) {
+      final nextExtent =
+          _nextCharacter(newSelection.extentOffset, plainText, true);
+      final distance = nextExtent - newSelection.extentOffset;
+      newSelection = newSelection.copyWith(extentOffset: nextExtent);
+      if (shift) {
+        _cursorResetLocation += distance;
+      }
+      return newSelection;
+    }
+
+    if (leftKey && newSelection.extentOffset > 0) {
+      final previousExtent =
+          _previousCharacter(newSelection.extentOffset, plainText, true);
+      final distance = newSelection.extentOffset - previousExtent;
+      newSelection = newSelection.copyWith(extentOffset: previousExtent);
+      if (shift) {
+        _cursorResetLocation -= distance;
+      }
+      return newSelection;
+    }
+    return newSelection;
+  }
+
+  /// Returns the index into the string of the next character boundary after the
+  /// given index.
+  ///
+  /// The character boundary is determined by the characters package, so
+  /// surrogate pairs and extended grapheme clusters are considered.
+  ///
+  /// The index must be between 0 and string.length, inclusive. If given
+  /// string.length, string.length is returned.
+  ///
+  /// Setting includeWhitespace to false will only return the index of non-space
+  /// characters.
+  int _nextCharacter(int index, String string, bool includeWhitespace) {
+    assert(index >= 0 && index <= string.length);
+    if (index == string.length) {
+      return string.length;
+    }
+
+    var count = 0;
+    final remain = string.characters.skipWhile((currentString) {
+      if (count <= index) {
+        count += currentString.length;
+        return true;
+      }
+      if (includeWhitespace) {
+        return false;
+      }
+      return WHITE_SPACE.contains(currentString.codeUnitAt(0));
+    });
+    return string.length - remain.toString().length;
+  }
+
+  /// Returns the index into the string of the previous character boundary
+  /// before the given index.
+  ///
+  /// The character boundary is determined by the characters package, so
+  /// surrogate pairs and extended grapheme clusters are considered.
+  ///
+  /// The index must be between 0 and string.length, inclusive. If index is 0,
+  /// 0 will be returned.
+  ///
+  /// Setting includeWhitespace to false will only return the index of non-space
+  /// characters.
+  int _previousCharacter(int index, String string, includeWhitespace) {
+    assert(index >= 0 && index <= string.length);
+    if (index == 0) {
+      return 0;
+    }
+
+    var count = 0;
+    int? lastNonWhitespace;
+    for (final currentString in string.characters) {
+      if (!includeWhitespace &&
+          !WHITE_SPACE.contains(
+              currentString.characters.first.toString().codeUnitAt(0))) {
+        lastNonWhitespace = count;
+      }
+      if (count + currentString.length >= index) {
+        return includeWhitespace ? count : lastNonWhitespace ?? 0;
+      }
+      count += currentString.length;
+    }
+    return 0;
+  }
+
+  TextSelection _handleMovingCursorVertically(
+      bool upKey,
+      bool downKey,
+      bool shift,
+      TextSelection selection,
+      TextSelection newSelection,
+      String plainText) {
+    final originPosition = TextPosition(
+        offset: upKey ? selection.baseOffset : selection.extentOffset);
+
+    final child = getRenderEditor()!.childAtPosition(originPosition);
+    final localPosition = TextPosition(
+        offset: originPosition.offset - child.getContainer().documentOffset);
+
+    var position = upKey
+        ? child.getPositionAbove(localPosition)
+        : child.getPositionBelow(localPosition);
+
+    if (position == null) {
+      final sibling = upKey
+          ? getRenderEditor()!.childBefore(child)
+          : getRenderEditor()!.childAfter(child);
+      if (sibling == null) {
+        position = TextPosition(offset: upKey ? 0 : plainText.length - 1);
+      } else {
+        final finalOffset = Offset(
+            child.getOffsetForCaret(localPosition).dx,
+            sibling
+                .getOffsetForCaret(TextPosition(
+                    offset: upKey ? sibling.getContainer().length - 1 : 0))
+                .dy);
+        final siblingPosition = sibling.getPositionForOffset(finalOffset);
+        position = TextPosition(
+            offset:
+                sibling.getContainer().documentOffset + siblingPosition.offset);
+      }
+    } else {
+      position = TextPosition(
+          offset: child.getContainer().documentOffset + position.offset);
+    }
+
+    if (position.offset == newSelection.extentOffset) {
+      if (downKey) {
+        newSelection = newSelection.copyWith(extentOffset: plainText.length);
+      } else if (upKey) {
+        newSelection = newSelection.copyWith(extentOffset: 0);
+      }
+      _wasSelectingVerticallyWithKeyboard = shift;
+      return newSelection;
+    }
+
+    if (_wasSelectingVerticallyWithKeyboard && shift) {
+      newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation);
+      _wasSelectingVerticallyWithKeyboard = false;
+      return newSelection;
+    }
+    newSelection = newSelection.copyWith(extentOffset: position.offset);
+    _cursorResetLocation = newSelection.extentOffset;
+    return newSelection;
+  }
+
+  TextSelection _placeCollapsedSelection(TextSelection selection,
+      TextSelection newSelection, bool leftKey, bool rightKey) {
+    var newOffset = newSelection.extentOffset;
+    if (!selection.isCollapsed) {
+      if (leftKey) {
+        newOffset = newSelection.baseOffset < newSelection.extentOffset
+            ? newSelection.baseOffset
+            : newSelection.extentOffset;
+      } else if (rightKey) {
+        newOffset = newSelection.baseOffset > newSelection.extentOffset
+            ? newSelection.baseOffset
+            : newSelection.extentOffset;
+      }
+    }
+    return TextSelection.fromPosition(TextPosition(offset: newOffset));
+  }
+}

+ 122 - 0
app_flowy/packages/editor/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart

@@ -0,0 +1,122 @@
+import 'dart:math';
+
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+import '../editor.dart';
+
+mixin RawEditorStateSelectionDelegateMixin on EditorState
+    implements TextSelectionDelegate {
+  @override
+  TextEditingValue get textEditingValue {
+    return getTextEditingValue();
+  }
+
+  @override
+  set textEditingValue(TextEditingValue value) {
+    setTextEditingValue(value);
+  }
+
+  @override
+  void bringIntoView(TextPosition position) {
+    final localRect = getRenderEditor()!.getLocalRectForCaret(position);
+    final targetOffset = _getOffsetToRevealCaret(localRect, position);
+
+    scrollController.jumpTo(targetOffset.offset);
+    getRenderEditor()!.showOnScreen(rect: targetOffset.rect);
+  }
+
+  @override
+  void copySelection(SelectionChangedCause cause) {
+    // TODO: implement copySelection
+  }
+
+  @override
+  void cutSelection(SelectionChangedCause cause) {
+    // TODO: implement cutSelection
+  }
+
+  @override
+  Future<void> pasteText(SelectionChangedCause cause) {
+    // TODO: implement pasteText
+    throw UnimplementedError();
+  }
+
+  @override
+  void selectAll(SelectionChangedCause cause) {
+    // TODO: implement selectAll
+  }
+
+  // Finds the closest scroll offset to the current scroll offset that fully
+  // reveals the given caret rect. If the given rect's main axis extent is too
+  // large to be fully revealed in `renderEditable`, it will be centered along
+  // the main axis.
+  //
+  // If this is a multiline EditableText (which means the Editable can only
+  // scroll vertically), the given rect's height will first be extended to match
+  // `renderEditable.preferredLineHeight`, before the target scroll offset is
+  // calculated.
+  RevealedOffset _getOffsetToRevealCaret(Rect rect, TextPosition position) {
+    if (!scrollController.position.allowImplicitScrolling) {
+      return RevealedOffset(offset: scrollController.offset, rect: rect);
+    }
+
+    final editableSize = getRenderEditor()!.size;
+    final double additionalOffset;
+    final Offset unitOffset;
+
+    // The caret is vertically centered within the line. Expand the caret's
+    // height so that it spans the line because we're going to ensure that the
+    // entire expanded caret is scrolled into view.
+    final expandedRect = Rect.fromCenter(
+      center: rect.center,
+      width: rect.width,
+      height:
+          max(rect.height, getRenderEditor()!.preferredLineHeight(position)),
+    );
+
+    additionalOffset = expandedRect.height >= editableSize.height
+        ? editableSize.height / 2 - expandedRect.center.dy
+        : 0.0
+            .clamp(expandedRect.bottom - editableSize.height, expandedRect.top);
+    unitOffset = const Offset(0, 1);
+
+    // No overscrolling when encountering tall fonts/scripts that extend past
+    // the ascent.
+    final targetOffset = (additionalOffset + scrollController.offset).clamp(
+      scrollController.position.minScrollExtent,
+      scrollController.position.maxScrollExtent,
+    );
+
+    final offsetDelta = scrollController.offset - targetOffset;
+    return RevealedOffset(
+        rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
+  }
+
+  @override
+  void hideToolbar([bool hideHandles = true]) {
+    if (getSelectionOverlay()?.toolbar != null) {
+      getSelectionOverlay()?.hideToolbar();
+    }
+  }
+
+  @override
+  void userUpdateTextEditingValue(
+    TextEditingValue value,
+    SelectionChangedCause cause,
+  ) {
+    setTextEditingValue(value);
+  }
+
+  @override
+  bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly;
+
+  @override
+  bool get copyEnabled => widget.toolbarOptions.copy;
+
+  @override
+  bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
+
+  @override
+  bool get selectAllEnabled => widget.toolbarOptions.selectAll;
+}

+ 204 - 0
app_flowy/packages/editor/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart

@@ -0,0 +1,204 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+
+import '../../utils/diff_delta.dart';
+import '../editor.dart';
+
+mixin RawEditorStateTextInputClientMixin on EditorState
+    implements TextInputClient {
+  final List<TextEditingValue> _sentRemoteValues = [];
+  TextInputConnection? _textInputConnection;
+  TextEditingValue? _lastKnownRemoteTextEditingValue;
+
+  /// Whether to create an input connection with the platform for text editing
+  /// or not.
+  ///
+  /// Read-only input fields do not need a connection with the platform since
+  /// there's no need for text editing capabilities (e.g. virtual keyboard).
+  ///
+  /// On the web, we always need a connection because we want some browser
+  /// functionalities to continue to work on read-only input fields like:
+  ///
+  /// - Relevant context menu.
+  /// - cmd/ctrl+c shortcut to copy.
+  /// - cmd/ctrl+a to select all.
+  /// - Changing the selection using a physical keyboard.
+  bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly;
+
+  /// Returns `true` if there is open input connection.
+  bool get hasConnection =>
+      _textInputConnection != null && _textInputConnection!.attached;
+
+  /// Opens or closes input connection based on the current state of
+  /// [focusNode] and [value].
+  void openOrCloseConnection() {
+    if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) {
+      openConnectionIfNeeded();
+    } else if (!widget.focusNode.hasFocus) {
+      closeConnectionIfNeeded();
+    }
+  }
+
+  void openConnectionIfNeeded() {
+    if (!shouldCreateInputConnection) {
+      return;
+    }
+
+    if (!hasConnection) {
+      _lastKnownRemoteTextEditingValue = getTextEditingValue();
+      _textInputConnection = TextInput.attach(
+        this,
+        TextInputConfiguration(
+          inputType: TextInputType.multiline,
+          readOnly: widget.readOnly,
+          inputAction: TextInputAction.newline,
+          enableSuggestions: !widget.readOnly,
+          keyboardAppearance: widget.keyboardAppearance,
+          textCapitalization: widget.textCapitalization,
+        ),
+      );
+
+      _textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!);
+      // _sentRemoteValues.add(_lastKnownRemoteTextEditingValue);
+    }
+
+    _textInputConnection!.show();
+  }
+
+  /// Closes input connection if it's currently open. Otherwise does nothing.
+  void closeConnectionIfNeeded() {
+    if (!hasConnection) {
+      return;
+    }
+    _textInputConnection!.close();
+    _textInputConnection = null;
+    _lastKnownRemoteTextEditingValue = null;
+    _sentRemoteValues.clear();
+  }
+
+  /// Updates remote value based on current state of [document] and
+  /// [selection].
+  ///
+  /// This method may not actually send an update to native side if it thinks
+  /// remote value is up to date or identical.
+  void updateRemoteValueIfNeeded() {
+    if (!hasConnection) {
+      return;
+    }
+
+    // Since we don't keep track of the composing range in value provided
+    // by the Controller we need to add it here manually before comparing
+    // with the last known remote value.
+    // It is important to prevent excessive remote updates as it can cause
+    // race conditions.
+    final actualValue = getTextEditingValue().copyWith(
+      composing: _lastKnownRemoteTextEditingValue!.composing,
+    );
+
+    if (actualValue == _lastKnownRemoteTextEditingValue) {
+      return;
+    }
+
+    final shouldRemember =
+        getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text;
+    _lastKnownRemoteTextEditingValue = actualValue;
+    _textInputConnection!.setEditingState(
+      // Set composing to (-1, -1), otherwise an exception will be thrown if
+      // the values are different.
+      actualValue.copyWith(composing: const TextRange(start: -1, end: -1)),
+    );
+    if (shouldRemember) {
+      // Only keep track if text changed (selection changes are not relevant)
+      _sentRemoteValues.add(actualValue);
+    }
+  }
+
+  @override
+  TextEditingValue? get currentTextEditingValue =>
+      _lastKnownRemoteTextEditingValue;
+
+  // autofill is not needed
+  @override
+  AutofillScope? get currentAutofillScope => null;
+
+  @override
+  void updateEditingValue(TextEditingValue value) {
+    if (!shouldCreateInputConnection) {
+      return;
+    }
+
+    if (_sentRemoteValues.contains(value)) {
+      /// There is a race condition in Flutter text input plugin where sending
+      /// updates to native side too often results in broken behavior.
+      /// TextInputConnection.setEditingValue is an async call to native side.
+      /// For each such call native side _always_ sends an update which triggers
+      /// this method (updateEditingValue) with the same value we've sent it.
+      /// If multiple calls to setEditingValue happen too fast and we only
+      /// track the last sent value then there is no way for us to filter out
+      /// automatic callbacks from native side.
+      /// Therefore we have to keep track of all values we send to the native
+      /// side and when we see this same value appear here we skip it.
+      /// This is fragile but it's probably the only available option.
+      _sentRemoteValues.remove(value);
+      return;
+    }
+
+    if (_lastKnownRemoteTextEditingValue == value) {
+      // There is no difference between this value and the last known value.
+      return;
+    }
+
+    // Check if only composing range changed.
+    if (_lastKnownRemoteTextEditingValue!.text == value.text &&
+        _lastKnownRemoteTextEditingValue!.selection == value.selection) {
+      // This update only modifies composing range. Since we don't keep track
+      // of composing range we just need to update last known value here.
+      // This check fixes an issue on Android when it sends
+      // composing updates separately from regular changes for text and
+      // selection.
+      _lastKnownRemoteTextEditingValue = value;
+      return;
+    }
+
+    final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!;
+    _lastKnownRemoteTextEditingValue = value;
+    final oldText = effectiveLastKnownValue.text;
+    final text = value.text;
+    final cursorPosition = value.selection.extentOffset;
+    final diff = getDiff(oldText, text, cursorPosition);
+    widget.controller.replaceText(
+        diff.start, diff.deleted.length, diff.inserted, value.selection);
+  }
+
+  @override
+  void performAction(TextInputAction action) {
+    // no-op
+  }
+
+  @override
+  void performPrivateCommand(String action, Map<String, dynamic> data) {
+    // no-op
+  }
+
+  @override
+  void updateFloatingCursor(RawFloatingCursorPoint point) {
+    throw UnimplementedError();
+  }
+
+  @override
+  void showAutocorrectionPromptRect(int start, int end) {
+    throw UnimplementedError();
+  }
+
+  @override
+  void connectionClosed() {
+    if (!hasConnection) {
+      return;
+    }
+    _textInputConnection!.connectionClosedReceived();
+    _textInputConnection = null;
+    _lastKnownRemoteTextEditingValue = null;
+    _sentRemoteValues.clear();
+  }
+}

+ 362 - 0
app_flowy/packages/editor/lib/src/widgets/simple_viewer.dart

@@ -0,0 +1,362 @@
+import 'dart:convert';
+import 'dart:io' as io;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:string_validator/string_validator.dart';
+import 'package:tuple/tuple.dart';
+
+import '../models/documents/attribute.dart';
+import '../models/documents/document.dart';
+import '../models/documents/nodes/block.dart';
+import '../models/documents/nodes/leaf.dart' as leaf;
+import '../models/documents/nodes/line.dart';
+import 'controller.dart';
+import 'cursor.dart';
+import 'default_styles.dart';
+import 'delegate.dart';
+import 'editor.dart';
+import 'text_block.dart';
+import 'text_line.dart';
+import 'video_app.dart';
+import 'youtube_video_app.dart';
+
+class QuillSimpleViewer extends StatefulWidget {
+  const QuillSimpleViewer({
+    required this.controller,
+    required this.readOnly,
+    this.customStyles,
+    this.truncate = false,
+    this.truncateScale,
+    this.truncateAlignment,
+    this.truncateHeight,
+    this.truncateWidth,
+    this.scrollBottomInset = 0,
+    this.padding = EdgeInsets.zero,
+    this.embedBuilder,
+    Key? key,
+  })  : assert(truncate ||
+            ((truncateScale == null) &&
+                (truncateAlignment == null) &&
+                (truncateHeight == null) &&
+                (truncateWidth == null))),
+        super(key: key);
+
+  final QuillController controller;
+  final DefaultStyles? customStyles;
+  final bool truncate;
+  final double? truncateScale;
+  final Alignment? truncateAlignment;
+  final double? truncateHeight;
+  final double? truncateWidth;
+  final double scrollBottomInset;
+  final EdgeInsetsGeometry padding;
+  final EmbedBuilder? embedBuilder;
+  final bool readOnly;
+
+  @override
+  _QuillSimpleViewerState createState() => _QuillSimpleViewerState();
+}
+
+class _QuillSimpleViewerState extends State<QuillSimpleViewer>
+    with SingleTickerProviderStateMixin {
+  late DefaultStyles _styles;
+  final LayerLink _toolbarLayerLink = LayerLink();
+  final LayerLink _startHandleLayerLink = LayerLink();
+  final LayerLink _endHandleLayerLink = LayerLink();
+  late CursorCont _cursorCont;
+
+  @override
+  void initState() {
+    super.initState();
+
+    _cursorCont = CursorCont(
+      show: ValueNotifier<bool>(false),
+      style: const CursorStyle(
+        color: Colors.black,
+        backgroundColor: Colors.grey,
+        width: 2,
+        radius: Radius.zero,
+        offset: Offset.zero,
+      ),
+      tickerProvider: this,
+    );
+  }
+
+  @override
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+    final parentStyles = QuillStyles.getStyles(context, true);
+    final defaultStyles = DefaultStyles.getInstance(context);
+    _styles = (parentStyles != null)
+        ? defaultStyles.merge(parentStyles)
+        : defaultStyles;
+
+    if (widget.customStyles != null) {
+      _styles = _styles.merge(widget.customStyles!);
+    }
+  }
+
+  EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder;
+
+  Widget _defaultEmbedBuilder(
+      BuildContext context, leaf.Embed node, bool readOnly) {
+    assert(!kIsWeb, 'Please provide EmbedBuilder for Web');
+    switch (node.value.type) {
+      case 'image':
+        final imageUrl = _standardizeImageUrl(node.value.data);
+        return imageUrl.startsWith('http')
+            ? Image.network(imageUrl)
+            : isBase64(imageUrl)
+                ? Image.memory(base64.decode(imageUrl))
+                : Image.file(io.File(imageUrl));
+      case 'video':
+        final videoUrl = node.value.data;
+        if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) {
+          return YoutubeVideoApp(
+              videoUrl: videoUrl, context: context, readOnly: readOnly);
+        }
+        return VideoApp(
+            videoUrl: videoUrl, context: context, readOnly: readOnly);
+      default:
+        throw UnimplementedError(
+          'Embeddable type "${node.value.type}" is not supported by default '
+          'embed builder of QuillEditor. You must pass your own builder '
+          'function to embedBuilder property of QuillEditor or QuillField '
+          'widgets.',
+        );
+    }
+  }
+
+  String _standardizeImageUrl(String url) {
+    if (url.contains('base64')) {
+      return url.split(',')[1];
+    }
+    return url;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final _doc = widget.controller.document;
+    // if (_doc.isEmpty() &&
+    //     !widget.focusNode.hasFocus &&
+    //     widget.placeholder != null) {
+    //   _doc = Document.fromJson(jsonDecode(
+    //       '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]'));
+    // }
+
+    Widget child = CompositedTransformTarget(
+      link: _toolbarLayerLink,
+      child: Semantics(
+        child: _SimpleViewer(
+          document: _doc,
+          textDirection: _textDirection,
+          startHandleLayerLink: _startHandleLayerLink,
+          endHandleLayerLink: _endHandleLayerLink,
+          onSelectionChanged: _nullSelectionChanged,
+          scrollBottomInset: widget.scrollBottomInset,
+          padding: widget.padding,
+          children: _buildChildren(_doc, context),
+        ),
+      ),
+    );
+
+    if (widget.truncate) {
+      if (widget.truncateScale != null) {
+        child = Container(
+            height: widget.truncateHeight,
+            child: Align(
+                heightFactor: widget.truncateScale,
+                widthFactor: widget.truncateScale,
+                alignment: widget.truncateAlignment ?? Alignment.topLeft,
+                child: Container(
+                    width: widget.truncateWidth! / widget.truncateScale!,
+                    child: SingleChildScrollView(
+                        physics: const NeverScrollableScrollPhysics(),
+                        child: Transform.scale(
+                            scale: widget.truncateScale!,
+                            alignment:
+                                widget.truncateAlignment ?? Alignment.topLeft,
+                            child: child)))));
+      } else {
+        child = Container(
+            height: widget.truncateHeight,
+            width: widget.truncateWidth,
+            child: SingleChildScrollView(
+                physics: const NeverScrollableScrollPhysics(), child: child));
+      }
+    }
+
+    return QuillStyles(data: _styles, child: child);
+  }
+
+  List<Widget> _buildChildren(Document doc, BuildContext context) {
+    final result = <Widget>[];
+    final indentLevelCounts = <int, int>{};
+    for (final node in doc.root.children) {
+      if (node is Line) {
+        final editableTextLine = _getEditableTextLineFromNode(node, context);
+        result.add(editableTextLine);
+      } else if (node is Block) {
+        final attrs = node.style.attributes;
+        final editableTextBlock = EditableTextBlock(
+            block: node,
+            textDirection: _textDirection,
+            scrollBottomInset: widget.scrollBottomInset,
+            verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
+            textSelection: widget.controller.selection,
+            color: Colors.black,
+            styles: _styles,
+            enableInteractiveSelection: false,
+            hasFocus: false,
+            contentPadding: attrs.containsKey(Attribute.codeBlock.key)
+                ? const EdgeInsets.all(16)
+                : null,
+            embedBuilder: embedBuilder,
+            cursorCont: _cursorCont,
+            indentLevelCounts: indentLevelCounts,
+            onCheckboxTap: _handleCheckboxTap,
+            readOnly: widget.readOnly);
+        result.add(editableTextBlock);
+      } else {
+        throw StateError('Unreachable.');
+      }
+    }
+    return result;
+  }
+
+  /// Updates the checkbox positioned at [offset] in document
+  /// by changing its attribute according to [value].
+  void _handleCheckboxTap(int offset, bool value) {
+    // readonly - do nothing
+  }
+
+  TextDirection get _textDirection {
+    final result = Directionality.of(context);
+    return result;
+  }
+
+  EditableTextLine _getEditableTextLineFromNode(
+      Line node, BuildContext context) {
+    final textLine = TextLine(
+      line: node,
+      textDirection: _textDirection,
+      embedBuilder: embedBuilder,
+      styles: _styles,
+      readOnly: widget.readOnly,
+    );
+    final editableTextLine = EditableTextLine(
+        node,
+        null,
+        textLine,
+        0,
+        _getVerticalSpacingForLine(node, _styles),
+        _textDirection,
+        widget.controller.selection,
+        Colors.black,
+        //widget.selectionColor,
+        false,
+        //enableInteractiveSelection,
+        false,
+        //_hasFocus,
+        MediaQuery.of(context).devicePixelRatio,
+        _cursorCont);
+    return editableTextLine;
+  }
+
+  Tuple2<double, double> _getVerticalSpacingForLine(
+      Line line, DefaultStyles? defaultStyles) {
+    final attrs = line.style.attributes;
+    if (attrs.containsKey(Attribute.header.key)) {
+      final int? level = attrs[Attribute.header.key]!.value;
+      switch (level) {
+        case 1:
+          return defaultStyles!.h1!.verticalSpacing;
+        case 2:
+          return defaultStyles!.h2!.verticalSpacing;
+        case 3:
+          return defaultStyles!.h3!.verticalSpacing;
+        default:
+          throw 'Invalid level $level';
+      }
+    }
+
+    return defaultStyles!.paragraph!.verticalSpacing;
+  }
+
+  Tuple2<double, double> _getVerticalSpacingForBlock(
+      Block node, DefaultStyles? defaultStyles) {
+    final attrs = node.style.attributes;
+    if (attrs.containsKey(Attribute.blockQuote.key)) {
+      return defaultStyles!.quote!.verticalSpacing;
+    } else if (attrs.containsKey(Attribute.codeBlock.key)) {
+      return defaultStyles!.code!.verticalSpacing;
+    } else if (attrs.containsKey(Attribute.indent.key)) {
+      return defaultStyles!.indent!.verticalSpacing;
+    } else if (attrs.containsKey(Attribute.list.key)) {
+      return defaultStyles!.lists!.verticalSpacing;
+    } else if (attrs.containsKey(Attribute.align.key)) {
+      return defaultStyles!.align!.verticalSpacing;
+    }
+    return const Tuple2(0, 0);
+  }
+
+  void _nullSelectionChanged(
+      TextSelection selection, SelectionChangedCause cause) {}
+}
+
+class _SimpleViewer extends MultiChildRenderObjectWidget {
+  _SimpleViewer({
+    required List<Widget> children,
+    required this.document,
+    required this.textDirection,
+    required this.startHandleLayerLink,
+    required this.endHandleLayerLink,
+    required this.onSelectionChanged,
+    required this.scrollBottomInset,
+    this.padding = EdgeInsets.zero,
+    Key? key,
+  }) : super(key: key, children: children);
+
+  final Document document;
+  final TextDirection textDirection;
+  final LayerLink startHandleLayerLink;
+  final LayerLink endHandleLayerLink;
+  final TextSelectionChangedHandler onSelectionChanged;
+  final double scrollBottomInset;
+  final EdgeInsetsGeometry padding;
+
+  @override
+  RenderEditor createRenderObject(BuildContext context) {
+    return RenderEditor(
+      null,
+      textDirection,
+      scrollBottomInset,
+      padding,
+      document,
+      const TextSelection(baseOffset: 0, extentOffset: 0),
+      false,
+      // hasFocus,
+      onSelectionChanged,
+      startHandleLayerLink,
+      endHandleLayerLink,
+      const EdgeInsets.fromLTRB(4, 4, 4, 5),
+    );
+  }
+
+  @override
+  void updateRenderObject(
+      BuildContext context, covariant RenderEditor renderObject) {
+    renderObject
+      ..document = document
+      ..setContainer(document.root)
+      ..textDirection = textDirection
+      ..setStartHandleLayerLink(startHandleLayerLink)
+      ..setEndHandleLayerLink(endHandleLayerLink)
+      ..onSelectionChanged = onSelectionChanged
+      ..setScrollBottomInset(scrollBottomInset)
+      ..setPadding(padding);
+  }
+}

+ 772 - 0
app_flowy/packages/editor/lib/src/widgets/text_block.dart

@@ -0,0 +1,772 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:tuple/tuple.dart';
+
+import '../models/documents/attribute.dart';
+import '../models/documents/nodes/block.dart';
+import '../models/documents/nodes/line.dart';
+import 'box.dart';
+import 'cursor.dart';
+import 'default_styles.dart';
+import 'delegate.dart';
+import 'editor.dart';
+import 'text_line.dart';
+import 'text_selection.dart';
+
+const List<int> arabianRomanNumbers = [
+  1000,
+  900,
+  500,
+  400,
+  100,
+  90,
+  50,
+  40,
+  10,
+  9,
+  5,
+  4,
+  1
+];
+
+const List<String> romanNumbers = [
+  'M',
+  'CM',
+  'D',
+  'CD',
+  'C',
+  'XC',
+  'L',
+  'XL',
+  'X',
+  'IX',
+  'V',
+  'IV',
+  'I'
+];
+
+class EditableTextBlock extends StatelessWidget {
+  const EditableTextBlock(
+      {required this.block,
+      required this.textDirection,
+      required this.scrollBottomInset,
+      required this.verticalSpacing,
+      required this.textSelection,
+      required this.color,
+      required this.styles,
+      required this.enableInteractiveSelection,
+      required this.hasFocus,
+      required this.contentPadding,
+      required this.embedBuilder,
+      required this.cursorCont,
+      required this.indentLevelCounts,
+      required this.onCheckboxTap,
+      required this.readOnly,
+      this.customStyleBuilder,
+      Key? key});
+
+  final Block block;
+  final TextDirection textDirection;
+  final double scrollBottomInset;
+  final Tuple2 verticalSpacing;
+  final TextSelection textSelection;
+  final Color color;
+  final DefaultStyles? styles;
+  final bool enableInteractiveSelection;
+  final bool hasFocus;
+  final EdgeInsets? contentPadding;
+  final EmbedBuilder embedBuilder;
+  final CustomStyleBuilder? customStyleBuilder;
+  final CursorCont cursorCont;
+  final Map<int, int> indentLevelCounts;
+  final Function(int, bool) onCheckboxTap;
+  final bool readOnly;
+
+  @override
+  Widget build(BuildContext context) {
+    assert(debugCheckHasMediaQuery(context));
+
+    final defaultStyles = QuillStyles.getStyles(context, false);
+    return _EditableBlock(
+        block,
+        textDirection,
+        verticalSpacing as Tuple2<double, double>,
+        scrollBottomInset,
+        _getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(),
+        contentPadding,
+        _buildChildren(context, indentLevelCounts));
+  }
+
+  BoxDecoration? _getDecorationForBlock(
+      Block node, DefaultStyles? defaultStyles) {
+    final attrs = block.style.attributes;
+    if (attrs.containsKey(Attribute.blockQuote.key)) {
+      return defaultStyles!.quote!.decoration;
+    }
+    if (attrs.containsKey(Attribute.codeBlock.key)) {
+      return defaultStyles!.code!.decoration;
+    }
+    return null;
+  }
+
+  List<Widget> _buildChildren(
+      BuildContext context, Map<int, int> indentLevelCounts) {
+    final defaultStyles = QuillStyles.getStyles(context, false);
+    final count = block.children.length;
+    final children = <Widget>[];
+    var index = 0;
+    for (final line in Iterable.castFrom<dynamic, Line>(block.children)) {
+      index++;
+      final editableTextLine = EditableTextLine(
+          line,
+          _buildLeading(context, line, index, indentLevelCounts, count),
+          TextLine(
+            line: line,
+            textDirection: textDirection,
+            embedBuilder: embedBuilder,
+            customStyleBuilder: customStyleBuilder,
+            styles: styles!,
+            readOnly: readOnly,
+          ),
+          _getIndentWidth(),
+          _getSpacingForLine(line, index, count, defaultStyles),
+          textDirection,
+          textSelection,
+          color,
+          enableInteractiveSelection,
+          hasFocus,
+          MediaQuery.of(context).devicePixelRatio,
+          cursorCont);
+      children.add(editableTextLine);
+    }
+    return children.toList(growable: false);
+  }
+
+  Widget? _buildLeading(BuildContext context, Line line, int index,
+      Map<int, int> indentLevelCounts, int count) {
+    final defaultStyles = QuillStyles.getStyles(context, false);
+    final attrs = line.style.attributes;
+    if (attrs[Attribute.list.key] == Attribute.ol) {
+      return _NumberPoint(
+        index: index,
+        indentLevelCounts: indentLevelCounts,
+        count: count,
+        style: defaultStyles!.leading!.style,
+        attrs: attrs,
+        width: 32,
+        padding: 8,
+      );
+    }
+
+    if (attrs[Attribute.list.key] == Attribute.ul) {
+      return _BulletPoint(
+        style:
+            defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold),
+        width: 32,
+      );
+    }
+
+    if (attrs[Attribute.list.key] == Attribute.checked) {
+      return _Checkbox(
+        key: UniqueKey(),
+        style: defaultStyles!.leading!.style,
+        width: 32,
+        isChecked: true,
+        offset: block.offset + line.offset,
+        onTap: onCheckboxTap,
+      );
+    }
+
+    if (attrs[Attribute.list.key] == Attribute.unchecked) {
+      return _Checkbox(
+        key: UniqueKey(),
+        style: defaultStyles!.leading!.style,
+        width: 32,
+        offset: block.offset + line.offset,
+        onTap: onCheckboxTap,
+      );
+    }
+
+    if (attrs.containsKey(Attribute.codeBlock.key)) {
+      return _NumberPoint(
+        index: index,
+        indentLevelCounts: indentLevelCounts,
+        count: count,
+        style: defaultStyles!.code!.style
+            .copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)),
+        width: 32,
+        attrs: attrs,
+        padding: 16,
+        withDot: false,
+      );
+    }
+    return null;
+  }
+
+  double _getIndentWidth() {
+    final attrs = block.style.attributes;
+
+    final indent = attrs[Attribute.indent.key];
+    var extraIndent = 0.0;
+    if (indent != null && indent.value != null) {
+      extraIndent = 16.0 * indent.value;
+    }
+
+    if (attrs.containsKey(Attribute.blockQuote.key)) {
+      return 16.0 + extraIndent;
+    }
+
+    var baseIndent = 0.0;
+
+    if (attrs.containsKey(Attribute.list.key) ||
+        attrs.containsKey(Attribute.codeBlock.key)) {
+      baseIndent = 32.0;
+    }
+
+    return baseIndent + extraIndent;
+  }
+
+  Tuple2 _getSpacingForLine(
+      Line node, int index, int count, DefaultStyles? defaultStyles) {
+    var top = 0.0, bottom = 0.0;
+
+    final attrs = block.style.attributes;
+    if (attrs.containsKey(Attribute.header.key)) {
+      final level = attrs[Attribute.header.key]!.value;
+      switch (level) {
+        case 1:
+          top = defaultStyles!.h1!.verticalSpacing.item1;
+          bottom = defaultStyles.h1!.verticalSpacing.item2;
+          break;
+        case 2:
+          top = defaultStyles!.h2!.verticalSpacing.item1;
+          bottom = defaultStyles.h2!.verticalSpacing.item2;
+          break;
+        case 3:
+          top = defaultStyles!.h3!.verticalSpacing.item1;
+          bottom = defaultStyles.h3!.verticalSpacing.item2;
+          break;
+        default:
+          throw 'Invalid level $level';
+      }
+    } else {
+      late Tuple2 lineSpacing;
+      if (attrs.containsKey(Attribute.blockQuote.key)) {
+        lineSpacing = defaultStyles!.quote!.lineSpacing;
+      } else if (attrs.containsKey(Attribute.indent.key)) {
+        lineSpacing = defaultStyles!.indent!.lineSpacing;
+      } else if (attrs.containsKey(Attribute.list.key)) {
+        lineSpacing = defaultStyles!.lists!.lineSpacing;
+      } else if (attrs.containsKey(Attribute.codeBlock.key)) {
+        lineSpacing = defaultStyles!.code!.lineSpacing;
+      } else if (attrs.containsKey(Attribute.align.key)) {
+        lineSpacing = defaultStyles!.align!.lineSpacing;
+      }
+      top = lineSpacing.item1;
+      bottom = lineSpacing.item2;
+    }
+
+    if (index == 1) {
+      top = 0.0;
+    }
+
+    if (index == count) {
+      bottom = 0.0;
+    }
+
+    return Tuple2(top, bottom);
+  }
+}
+
+class RenderEditableTextBlock extends RenderEditableContainerBox
+    implements RenderEditableBox {
+  RenderEditableTextBlock({
+    required Block block,
+    required TextDirection textDirection,
+    required EdgeInsetsGeometry padding,
+    required double scrollBottomInset,
+    required Decoration decoration,
+    List<RenderEditableBox>? children,
+    ImageConfiguration configuration = ImageConfiguration.empty,
+    EdgeInsets contentPadding = EdgeInsets.zero,
+  })  : _decoration = decoration,
+        _configuration = configuration,
+        _savedPadding = padding,
+        _contentPadding = contentPadding,
+        super(
+          children,
+          block,
+          textDirection,
+          scrollBottomInset,
+          padding.add(contentPadding),
+        );
+
+  EdgeInsetsGeometry _savedPadding;
+  EdgeInsets _contentPadding;
+
+  set contentPadding(EdgeInsets value) {
+    if (_contentPadding == value) return;
+    _contentPadding = value;
+    super.setPadding(_savedPadding.add(_contentPadding));
+  }
+
+  @override
+  void setPadding(EdgeInsetsGeometry value) {
+    super.setPadding(value.add(_contentPadding));
+    _savedPadding = value;
+  }
+
+  BoxPainter? _painter;
+
+  Decoration get decoration => _decoration;
+  Decoration _decoration;
+
+  set decoration(Decoration value) {
+    if (value == _decoration) return;
+    _painter?.dispose();
+    _painter = null;
+    _decoration = value;
+    markNeedsPaint();
+  }
+
+  ImageConfiguration get configuration => _configuration;
+  ImageConfiguration _configuration;
+
+  set configuration(ImageConfiguration value) {
+    if (value == _configuration) return;
+    _configuration = value;
+    markNeedsPaint();
+  }
+
+  @override
+  TextRange getLineBoundary(TextPosition position) {
+    final child = childAtPosition(position);
+    final rangeInChild = child.getLineBoundary(TextPosition(
+      offset: position.offset - child.getContainer().offset,
+      affinity: position.affinity,
+    ));
+    return TextRange(
+      start: rangeInChild.start + child.getContainer().offset,
+      end: rangeInChild.end + child.getContainer().offset,
+    );
+  }
+
+  @override
+  Offset getOffsetForCaret(TextPosition position) {
+    final child = childAtPosition(position);
+    return child.getOffsetForCaret(TextPosition(
+          offset: position.offset - child.getContainer().offset,
+          affinity: position.affinity,
+        )) +
+        (child.parentData as BoxParentData).offset;
+  }
+
+  @override
+  TextPosition getPositionForOffset(Offset offset) {
+    final child = childAtOffset(offset)!;
+    final parentData = child.parentData as BoxParentData;
+    final localPosition =
+        child.getPositionForOffset(offset - parentData.offset);
+    return TextPosition(
+      offset: localPosition.offset + child.getContainer().offset,
+      affinity: localPosition.affinity,
+    );
+  }
+
+  @override
+  TextRange getWordBoundary(TextPosition position) {
+    final child = childAtPosition(position);
+    final nodeOffset = child.getContainer().offset;
+    final childWord = child
+        .getWordBoundary(TextPosition(offset: position.offset - nodeOffset));
+    return TextRange(
+      start: childWord.start + nodeOffset,
+      end: childWord.end + nodeOffset,
+    );
+  }
+
+  @override
+  TextPosition? getPositionAbove(TextPosition position) {
+    assert(position.offset < getContainer().length);
+
+    final child = childAtPosition(position);
+    final childLocalPosition =
+        TextPosition(offset: position.offset - child.getContainer().offset);
+    final result = child.getPositionAbove(childLocalPosition);
+    if (result != null) {
+      return TextPosition(offset: result.offset + child.getContainer().offset);
+    }
+
+    final sibling = childBefore(child);
+    if (sibling == null) {
+      return null;
+    }
+
+    final caretOffset = child.getOffsetForCaret(childLocalPosition);
+    final testPosition =
+        TextPosition(offset: sibling.getContainer().length - 1);
+    final testOffset = sibling.getOffsetForCaret(testPosition);
+    final finalOffset = Offset(caretOffset.dx, testOffset.dy);
+    return TextPosition(
+        offset: sibling.getContainer().offset +
+            sibling.getPositionForOffset(finalOffset).offset);
+  }
+
+  @override
+  TextPosition? getPositionBelow(TextPosition position) {
+    assert(position.offset < getContainer().length);
+
+    final child = childAtPosition(position);
+    final childLocalPosition =
+        TextPosition(offset: position.offset - child.getContainer().offset);
+    final result = child.getPositionBelow(childLocalPosition);
+    if (result != null) {
+      return TextPosition(offset: result.offset + child.getContainer().offset);
+    }
+
+    final sibling = childAfter(child);
+    if (sibling == null) {
+      return null;
+    }
+
+    final caretOffset = child.getOffsetForCaret(childLocalPosition);
+    final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0));
+    final finalOffset = Offset(caretOffset.dx, testOffset.dy);
+    return TextPosition(
+        offset: sibling.getContainer().offset +
+            sibling.getPositionForOffset(finalOffset).offset);
+  }
+
+  @override
+  double preferredLineHeight(TextPosition position) {
+    final child = childAtPosition(position);
+    return child.preferredLineHeight(
+        TextPosition(offset: position.offset - child.getContainer().offset));
+  }
+
+  @override
+  TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) {
+    if (selection.isCollapsed) {
+      return TextSelectionPoint(
+          Offset(0, preferredLineHeight(selection.extent)) +
+              getOffsetForCaret(selection.extent),
+          null);
+    }
+
+    final baseNode = getContainer().queryChild(selection.start, false).node;
+    var baseChild = firstChild;
+    while (baseChild != null) {
+      if (baseChild.getContainer() == baseNode) {
+        break;
+      }
+      baseChild = childAfter(baseChild);
+    }
+    assert(baseChild != null);
+
+    final basePoint = baseChild!.getBaseEndpointForSelection(
+        localSelection(baseChild.getContainer(), selection, true));
+    return TextSelectionPoint(
+        basePoint.point + (baseChild.parentData as BoxParentData).offset,
+        basePoint.direction);
+  }
+
+  @override
+  TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) {
+    if (selection.isCollapsed) {
+      return TextSelectionPoint(
+          Offset(0, preferredLineHeight(selection.extent)) +
+              getOffsetForCaret(selection.extent),
+          null);
+    }
+
+    final extentNode = getContainer().queryChild(selection.end, false).node;
+
+    var extentChild = firstChild;
+    while (extentChild != null) {
+      if (extentChild.getContainer() == extentNode) {
+        break;
+      }
+      extentChild = childAfter(extentChild);
+    }
+    assert(extentChild != null);
+
+    final extentPoint = extentChild!.getExtentEndpointForSelection(
+        localSelection(extentChild.getContainer(), selection, true));
+    return TextSelectionPoint(
+        extentPoint.point + (extentChild.parentData as BoxParentData).offset,
+        extentPoint.direction);
+  }
+
+  @override
+  void detach() {
+    _painter?.dispose();
+    _painter = null;
+    super.detach();
+    markNeedsPaint();
+  }
+
+  @override
+  void paint(PaintingContext context, Offset offset) {
+    _paintDecoration(context, offset);
+    defaultPaint(context, offset);
+  }
+
+  void _paintDecoration(PaintingContext context, Offset offset) {
+    _painter ??= _decoration.createBoxPainter(markNeedsPaint);
+
+    final decorationPadding = resolvedPadding! - _contentPadding;
+
+    final filledConfiguration =
+        configuration.copyWith(size: decorationPadding.deflateSize(size));
+    final debugSaveCount = context.canvas.getSaveCount();
+
+    final decorationOffset =
+        offset.translate(decorationPadding.left, decorationPadding.top);
+    _painter!.paint(context.canvas, decorationOffset, filledConfiguration);
+    if (debugSaveCount != context.canvas.getSaveCount()) {
+      throw '${_decoration.runtimeType} painter had mismatching save and  '
+          'restore calls.';
+    }
+    if (decoration.isComplex) {
+      context.setIsComplexHint();
+    }
+  }
+
+  @override
+  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
+    return defaultHitTestChildren(result, position: position);
+  }
+
+  @override
+  Rect getLocalRectForCaret(TextPosition position) {
+    final child = childAtPosition(position);
+    final localPosition = TextPosition(
+      offset: position.offset - child.getContainer().offset,
+      affinity: position.affinity,
+    );
+    final parentData = child.parentData as BoxParentData;
+    return child.getLocalRectForCaret(localPosition).shift(parentData.offset);
+  }
+
+  @override
+  TextPosition globalToLocalPosition(TextPosition position) {
+    assert(getContainer().containsOffset(position.offset),
+        'The provided text position is not in the current node');
+    return TextPosition(
+      offset: position.offset - getContainer().documentOffset,
+      affinity: position.affinity,
+    );
+  }
+}
+
+class _EditableBlock extends MultiChildRenderObjectWidget {
+  _EditableBlock(
+      this.block,
+      this.textDirection,
+      this.padding,
+      this.scrollBottomInset,
+      this.decoration,
+      this.contentPadding,
+      List<Widget> children)
+      : super(children: children);
+
+  final Block block;
+  final TextDirection textDirection;
+  final Tuple2<double, double> padding;
+  final double scrollBottomInset;
+  final Decoration decoration;
+  final EdgeInsets? contentPadding;
+
+  EdgeInsets get _padding =>
+      EdgeInsets.only(top: padding.item1, bottom: padding.item2);
+
+  EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero;
+
+  @override
+  RenderEditableTextBlock createRenderObject(BuildContext context) {
+    return RenderEditableTextBlock(
+      block: block,
+      textDirection: textDirection,
+      padding: _padding,
+      scrollBottomInset: scrollBottomInset,
+      decoration: decoration,
+      contentPadding: _contentPadding,
+    );
+  }
+
+  @override
+  void updateRenderObject(
+      BuildContext context, covariant RenderEditableTextBlock renderObject) {
+    renderObject
+      ..setContainer(block)
+      ..textDirection = textDirection
+      ..scrollBottomInset = scrollBottomInset
+      ..setPadding(_padding)
+      ..decoration = decoration
+      ..contentPadding = _contentPadding;
+  }
+}
+
+class _NumberPoint extends StatelessWidget {
+  const _NumberPoint({
+    required this.index,
+    required this.indentLevelCounts,
+    required this.count,
+    required this.style,
+    required this.width,
+    required this.attrs,
+    this.withDot = true,
+    this.padding = 0.0,
+    Key? key,
+  }) : super(key: key);
+
+  final int index;
+  final Map<int?, int> indentLevelCounts;
+  final int count;
+  final TextStyle style;
+  final double width;
+  final Map<String, Attribute> attrs;
+  final bool withDot;
+  final double padding;
+
+  @override
+  Widget build(BuildContext context) {
+    var s = index.toString();
+    int? level = 0;
+    if (!attrs.containsKey(Attribute.indent.key) &&
+        !indentLevelCounts.containsKey(1)) {
+      indentLevelCounts.clear();
+      return Container(
+        alignment: AlignmentDirectional.topEnd,
+        width: width,
+        padding: EdgeInsetsDirectional.only(end: padding),
+        child: Text(withDot ? '$s.' : s, style: style),
+      );
+    }
+    if (attrs.containsKey(Attribute.indent.key)) {
+      level = attrs[Attribute.indent.key]!.value;
+    } else {
+      // first level but is back from previous indent level
+      // supposed to be "2."
+      indentLevelCounts[0] = 1;
+    }
+    if (indentLevelCounts.containsKey(level! + 1)) {
+      // last visited level is done, going up
+      indentLevelCounts.remove(level + 1);
+    }
+    final count = (indentLevelCounts[level] ?? 0) + 1;
+    indentLevelCounts[level] = count;
+
+    s = count.toString();
+    if (level % 3 == 1) {
+      // a. b. c. d. e. ...
+      s = _toExcelSheetColumnTitle(count);
+    } else if (level % 3 == 2) {
+      // i. ii. iii. ...
+      s = _intToRoman(count);
+    }
+    // level % 3 == 0 goes back to 1. 2. 3.
+
+    return Container(
+      alignment: AlignmentDirectional.topEnd,
+      width: width,
+      padding: EdgeInsetsDirectional.only(end: padding),
+      child: Text(withDot ? '$s.' : s, style: style),
+    );
+  }
+
+  String _toExcelSheetColumnTitle(int n) {
+    final result = StringBuffer();
+    while (n > 0) {
+      n--;
+      result.write(String.fromCharCode((n % 26).floor() + 97));
+      n = (n / 26).floor();
+    }
+
+    return result.toString().split('').reversed.join();
+  }
+
+  String _intToRoman(int input) {
+    var num = input;
+
+    if (num < 0) {
+      return '';
+    } else if (num == 0) {
+      return 'nulla';
+    }
+
+    final builder = StringBuffer();
+    for (var a = 0; a < arabianRomanNumbers.length; a++) {
+      final times = (num / arabianRomanNumbers[a])
+          .truncate(); // equals 1 only when arabianRomanNumbers[a] = num
+      // executes n times where n is the number of times you have to add
+      // the current roman number value to reach current num.
+      builder.write(romanNumbers[a] * times);
+      num -= times *
+          arabianRomanNumbers[
+              a]; // subtract previous roman number value from num
+    }
+
+    return builder.toString().toLowerCase();
+  }
+}
+
+class _BulletPoint extends StatelessWidget {
+  const _BulletPoint({
+    required this.style,
+    required this.width,
+    Key? key,
+  }) : super(key: key);
+
+  final TextStyle style;
+  final double width;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      alignment: AlignmentDirectional.topEnd,
+      width: width,
+      padding: const EdgeInsetsDirectional.only(end: 13),
+      child: Text('•', style: style),
+    );
+  }
+}
+
+class _Checkbox extends StatelessWidget {
+  const _Checkbox({
+    Key? key,
+    this.style,
+    this.width,
+    this.isChecked = false,
+    this.offset,
+    this.onTap,
+  }) : super(key: key);
+  final TextStyle? style;
+  final double? width;
+  final bool isChecked;
+  final int? offset;
+  final Function(int, bool)? onTap;
+
+  void _onCheckboxClicked(bool? newValue) {
+    if (onTap != null && newValue != null && offset != null) {
+      onTap!(offset!, newValue);
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      alignment: AlignmentDirectional.topEnd,
+      width: width,
+      padding: const EdgeInsetsDirectional.only(end: 13),
+      child: GestureDetector(
+        onLongPress: () => _onCheckboxClicked(!isChecked),
+        child: Checkbox(
+          value: isChecked,
+          onChanged: _onCheckboxClicked,
+        ),
+      ),
+    );
+  }
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است