瀏覽代碼

Refactor text block delta and across block selection (#2671)

* fix: add block menu comment

* refactor: separation of abstract delta and editor, and optimization across block selections
Kilu.He 1 年之前
父節點
當前提交
8cee792b94
共有 99 個文件被更改,包括 3497 次插入2636 次删除
  1. 6 2
      frontend/appflowy_tauri/package.json
  2. 143 21
      frontend/appflowy_tauri/pnpm-lock.yaml
  3. 106 40
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
  4. 8 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts
  5. 6 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx
  6. 119 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts
  7. 4 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx
  8. 14 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx
  9. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx
  10. 7 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx
  11. 2 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  12. 6 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx
  13. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts
  14. 0 86
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts
  15. 6 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx
  16. 18 20
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx
  17. 51 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts
  18. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx
  19. 4 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  20. 7 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx
  21. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx
  22. 31 14
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts
  23. 28 29
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
  24. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  25. 6 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx
  26. 10 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts
  27. 29 23
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx
  28. 0 40
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx
  29. 0 14
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
  30. 0 134
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts
  31. 0 102
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts
  32. 19 21
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  33. 105 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
  34. 185 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts
  35. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts
  36. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts
  37. 2 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx
  38. 6 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx
  39. 33 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts
  40. 79 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts
  41. 43 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts
  42. 55 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts
  43. 23 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.css
  44. 30 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.tsx
  45. 100 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/useEditor.ts
  46. 34 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx
  47. 4 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx
  48. 28 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx
  49. 65 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx
  50. 61 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
  51. 2 25
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/decorateCode.ts
  52. 142 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
  53. 43 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
  54. 5 56
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  55. 43 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts
  56. 0 111
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts
  57. 0 134
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts
  58. 0 98
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts
  59. 0 73
      frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts
  60. 32 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts
  61. 0 13
      frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts
  62. 68 46
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  63. 1 1
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
  64. 10 9
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts
  65. 2 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts
  66. 4 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts
  67. 12 3
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts
  68. 49 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts
  69. 0 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts
  70. 0 44
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts
  71. 0 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts
  72. 0 82
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts
  73. 0 74
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts
  74. 0 32
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts
  75. 5 5
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
  76. 0 109
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts
  77. 28 43
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
  78. 2 13
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts
  79. 288 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
  80. 32 12
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
  81. 221 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
  82. 0 119
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts
  83. 5 5
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts
  84. 31 25
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts
  85. 63 20
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  86. 307 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
  87. 92 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts
  88. 0 34
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/index.ts
  89. 0 220
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts
  90. 0 132
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts
  91. 0 22
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/selection.ts
  92. 0 378
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts
  93. 0 79
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts
  94. 71 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts
  95. 232 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
  96. 59 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts
  97. 134 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts
  98. 19 5
      frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts
  99. 4 0
      frontend/appflowy_tauri/tailwind.config.cjs

+ 6 - 2
frontend/appflowy_tauri/package.json

@@ -22,6 +22,7 @@
     "@mui/icons-material": "^5.11.11",
     "@mui/icons-material": "^5.11.11",
     "@mui/material": "^5.11.12",
     "@mui/material": "^5.11.12",
     "@reduxjs/toolkit": "^1.9.2",
     "@reduxjs/toolkit": "^1.9.2",
+    "@slate-yjs/core": "^1.0.0",
     "@tanstack/react-virtual": "3.0.0-beta.54",
     "@tanstack/react-virtual": "3.0.0-beta.54",
     "@tauri-apps/api": "^1.2.0",
     "@tauri-apps/api": "^1.2.0",
     "dayjs": "^1.11.7",
     "dayjs": "^1.11.7",
@@ -35,6 +36,8 @@
     "nanoid": "^4.0.0",
     "nanoid": "^4.0.0",
     "prismjs": "^1.29.0",
     "prismjs": "^1.29.0",
     "protoc-gen-ts": "^0.8.5",
     "protoc-gen-ts": "^0.8.5",
+    "quill": "^1.3.7",
+    "quill-delta": "^5.1.0",
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-beautiful-dnd": "^13.1.1",
     "react-beautiful-dnd": "^13.1.1",
     "react-calendar": "^4.1.0",
     "react-calendar": "^4.1.0",
@@ -46,8 +49,8 @@
     "react18-input-otp": "^1.1.2",
     "react18-input-otp": "^1.1.2",
     "redux": "^4.2.1",
     "redux": "^4.2.1",
     "rxjs": "^7.8.0",
     "rxjs": "^7.8.0",
-    "slate": "^0.91.4",
-    "slate-react": "^0.91.9",
+    "slate": "^0.94.1",
+    "slate-react": "^0.94.2",
     "ts-results": "^3.3.0",
     "ts-results": "^3.3.0",
     "utf8": "^3.0.0",
     "utf8": "^3.0.0",
     "y-indexeddb": "^9.0.9",
     "y-indexeddb": "^9.0.9",
@@ -59,6 +62,7 @@
     "@types/is-hotkey": "^0.1.7",
     "@types/is-hotkey": "^0.1.7",
     "@types/node": "^18.7.10",
     "@types/node": "^18.7.10",
     "@types/prismjs": "^1.26.0",
     "@types/prismjs": "^1.26.0",
+    "@types/quill": "^2.0.10",
     "@types/react": "^18.0.15",
     "@types/react": "^18.0.15",
     "@types/react-beautiful-dnd": "^13.1.3",
     "@types/react-beautiful-dnd": "^13.1.3",
     "@types/react-dom": "^18.0.6",
     "@types/react-dom": "^18.0.6",

+ 143 - 21
frontend/appflowy_tauri/pnpm-lock.yaml

@@ -22,6 +22,9 @@ dependencies:
   '@reduxjs/toolkit':
   '@reduxjs/toolkit':
     specifier: ^1.9.2
     specifier: ^1.9.2
     version: 1.9.5([email protected])([email protected])
     version: 1.9.5([email protected])([email protected])
+  '@slate-yjs/core':
+    specifier: ^1.0.0
+    version: 1.0.0([email protected])([email protected])
   '@tanstack/react-virtual':
   '@tanstack/react-virtual':
     specifier: 3.0.0-beta.54
     specifier: 3.0.0-beta.54
     version: 3.0.0-beta.54([email protected])
     version: 3.0.0-beta.54([email protected])
@@ -61,6 +64,12 @@ dependencies:
   protoc-gen-ts:
   protoc-gen-ts:
     specifier: ^0.8.5
     specifier: ^0.8.5
     version: 0.8.6([email protected])([email protected])
     version: 0.8.6([email protected])([email protected])
+  quill:
+    specifier: ^1.3.7
+    version: 1.3.7
+  quill-delta:
+    specifier: ^5.1.0
+    version: 5.1.0
   react:
   react:
     specifier: ^18.2.0
     specifier: ^18.2.0
     version: 18.2.0
     version: 18.2.0
@@ -95,11 +104,11 @@ dependencies:
     specifier: ^7.8.0
     specifier: ^7.8.0
     version: 7.8.1
     version: 7.8.1
   slate:
   slate:
-    specifier: ^0.91.4
-    version: 0.91.4
+    specifier: ^0.94.1
+    version: 0.94.1
   slate-react:
   slate-react:
-    specifier: ^0.91.9
-    version: 0.91.11([email protected])([email protected])([email protected])
+    specifier: ^0.94.2
+    version: 0.94.2([email protected])([email protected])([email protected])
   ts-results:
   ts-results:
     specifier: ^3.3.0
     specifier: ^3.3.0
     version: 3.3.0
     version: 3.3.0
@@ -129,6 +138,9 @@ devDependencies:
   '@types/prismjs':
   '@types/prismjs':
     specifier: ^1.26.0
     specifier: ^1.26.0
     version: 1.26.0
     version: 1.26.0
+  '@types/quill':
+    specifier: ^2.0.10
+    version: 2.0.10
   '@types/react':
   '@types/react':
     specifier: ^18.0.15
     specifier: ^18.0.15
     version: 18.2.6
     version: 18.2.6
@@ -1425,6 +1437,17 @@ packages:
       '@sinonjs/commons': 3.0.0
       '@sinonjs/commons': 3.0.0
     dev: false
     dev: false
 
 
+  /@slate-yjs/[email protected]([email protected])([email protected]):
+    resolution: {integrity: sha512-G83+qvXtsMTP3kWu216GjhyeHlvKHX5kWaPf2JiG2uF5/YShUqjAVjDr/htKoKJsOl+IqK679lvLKeBYh7SYZQ==}
+    peerDependencies:
+      slate: '>=0.70.0'
+      yjs: ^13.5.29
+    dependencies:
+      slate: 0.94.1
+      y-protocols: 1.0.5
+      yjs: 13.6.1
+    dev: false
+
   /@tanstack/[email protected]([email protected]):
   /@tanstack/[email protected]([email protected]):
     resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
     resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
     peerDependencies:
     peerDependencies:
@@ -1637,6 +1660,13 @@ packages:
   /@types/[email protected]:
   /@types/[email protected]:
     resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
     resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
 
 
+  /@types/[email protected]:
+    resolution: {integrity: sha512-L6OHONEj2v4NRbWQOsn7j1N0SyzhRR3M4g1M6j/uuIwIsIW2ShWHhwbqNvH8hSmVktzqu0lITfdnqVOQ4qkrhA==}
+    dependencies:
+      parchment: 1.1.4
+      quill-delta: 4.2.2
+    dev: true
+
   /@types/[email protected]:
   /@types/[email protected]:
     resolution: {integrity: sha512-4bIBdzOr0aavN+88q3C7Pgz+xkb7tz3whORYrmSj77wfVEMfiWiooIwVWFR7KM2e+uGTe5BVrXqSfb0aHeflJA==}
     resolution: {integrity: sha512-4bIBdzOr0aavN+88q3C7Pgz+xkb7tz3whORYrmSj77wfVEMfiWiooIwVWFR7KM2e+uGTe5BVrXqSfb0aHeflJA==}
     dependencies:
     dependencies:
@@ -2133,7 +2163,6 @@ packages:
     dependencies:
     dependencies:
       function-bind: 1.1.1
       function-bind: 1.1.1
       get-intrinsic: 1.2.1
       get-intrinsic: 1.2.1
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
@@ -2210,6 +2239,11 @@ packages:
       wrap-ansi: 7.0.0
       wrap-ansi: 7.0.0
     dev: false
     dev: false
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
+    engines: {node: '>=0.8'}
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
     resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
     engines: {node: '>=6'}
     engines: {node: '>=6'}
@@ -2313,6 +2347,17 @@ packages:
     resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
     resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
     dev: false
     dev: false
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==}
+    dependencies:
+      is-arguments: 1.1.1
+      is-date-object: 1.0.5
+      is-regex: 1.1.4
+      object-is: 1.1.5
+      object-keys: 1.1.1
+      regexp.prototype.flags: 1.5.0
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
     resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
     dev: true
     dev: true
@@ -2328,7 +2373,6 @@ packages:
     dependencies:
     dependencies:
       has-property-descriptors: 1.0.0
       has-property-descriptors: 1.0.0
       object-keys: 1.1.1
       object-keys: 1.1.1
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
     resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
@@ -2661,6 +2705,10 @@ packages:
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
     dev: true
     dev: true
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==}
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
     resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
     engines: {node: '>=0.8.x'}
     engines: {node: '>=0.8.x'}
@@ -2697,10 +2745,26 @@ packages:
       jest-util: 29.5.0
       jest-util: 29.5.0
     dev: false
     dev: false
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
     resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
     dev: true
     dev: true
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==}
+    dev: false
+
+  /[email protected]:
+    resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
+    dev: true
+
+  /[email protected]:
+    resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==}
     resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==}
     engines: {node: '>=8.6.0'}
     engines: {node: '>=8.6.0'}
@@ -2811,7 +2875,6 @@ packages:
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
     resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
     resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
@@ -2829,7 +2892,6 @@ packages:
       has: 1.0.3
       has: 1.0.3
       has-proto: 1.0.1
       has-proto: 1.0.1
       has-symbols: 1.0.3
       has-symbols: 1.0.3
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
     resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
@@ -2955,24 +3017,20 @@ packages:
     resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==}
     resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==}
     dependencies:
     dependencies:
       get-intrinsic: 1.2.1
       get-intrinsic: 1.2.1
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
     resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
     resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==}
     resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
     dependencies:
     dependencies:
       has-symbols: 1.0.3
       has-symbols: 1.0.3
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
     resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
@@ -3060,6 +3118,14 @@ packages:
       side-channel: 1.0.4
       side-channel: 1.0.4
     dev: true
     dev: true
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==}
+    engines: {node: '>= 0.4'}
+    dependencies:
+      call-bind: 1.0.2
+      has-tostringtag: 1.0.0
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==}
     resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==}
     dependencies:
     dependencies:
@@ -3108,7 +3174,6 @@ packages:
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
     dependencies:
     dependencies:
       has-tostringtag: 1.0.0
       has-tostringtag: 1.0.0
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
@@ -3172,7 +3237,6 @@ packages:
     dependencies:
     dependencies:
       call-bind: 1.0.2
       call-bind: 1.0.2
       has-tostringtag: 1.0.0
       has-tostringtag: 1.0.0
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
     resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
@@ -3783,6 +3847,12 @@ packages:
       p-locate: 5.0.0
       p-locate: 5.0.0
     dev: true
     dev: true
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
+
+  /[email protected]:
+    resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
     resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
     dev: false
     dev: false
@@ -3928,10 +3998,17 @@ packages:
     resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
     resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
     dev: true
     dev: true
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==}
+    engines: {node: '>= 0.4'}
+    dependencies:
+      call-bind: 1.0.2
+      define-properties: 1.2.0
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
     resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==}
     resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==}
@@ -4033,6 +4110,9 @@ packages:
     engines: {node: '>=6'}
     engines: {node: '>=6'}
     dev: false
     dev: false
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==}
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
     engines: {node: '>=6'}
     engines: {node: '>=6'}
@@ -4277,6 +4357,43 @@ packages:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
     dev: true
     dev: true
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==}
+    engines: {node: '>=0.10'}
+    dependencies:
+      deep-equal: 1.1.1
+      extend: 3.0.2
+      fast-diff: 1.1.2
+    dev: false
+
+  /[email protected]:
+    resolution: {integrity: sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==}
+    dependencies:
+      fast-diff: 1.2.0
+      lodash.clonedeep: 4.5.0
+      lodash.isequal: 4.5.0
+    dev: true
+
+  /[email protected]:
+    resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==}
+    engines: {node: '>= 12.0.0'}
+    dependencies:
+      fast-diff: 1.3.0
+      lodash.clonedeep: 4.5.0
+      lodash.isequal: 4.5.0
+    dev: false
+
+  /[email protected]:
+    resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==}
+    dependencies:
+      clone: 2.1.2
+      deep-equal: 1.1.1
+      eventemitter3: 2.0.3
+      extend: 3.0.2
+      parchment: 1.1.4
+      quill-delta: 3.6.3
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
     resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
     dev: false
     dev: false
@@ -4519,7 +4636,6 @@ packages:
       call-bind: 1.0.2
       call-bind: 1.0.2
       define-properties: 1.2.0
       define-properties: 1.2.0
       functions-have-names: 1.2.3
       functions-have-names: 1.2.3
-    dev: true
 
 
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
@@ -4661,8 +4777,8 @@ packages:
     resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
     resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
     engines: {node: '>=8'}
     engines: {node: '>=8'}
 
 
-  /[email protected]1.11([email protected])([email protected])([email protected]):
-    resolution: {integrity: sha512-2nS29rc2kuTTJrEUOXGyTkFATmTEw/R9KuUXadUYiz+UVwuFOUMnBKuwJWyuIBOsFipS+06SkIayEf5CKdARRQ==}
+  /[email protected]4.2([email protected])([email protected])([email protected]):
+    resolution: {integrity: sha512-4wDSuTuGBkdQ609CS55uc2Yhfa5but21usBgAtCVhPJQazL85kzN2vUUYTmGb7d/mpP9tdnJiVPopIyhqlRJ8Q==}
     peerDependencies:
     peerDependencies:
       react: '>=16.8.0'
       react: '>=16.8.0'
       react-dom: '>=16.8.0'
       react-dom: '>=16.8.0'
@@ -4678,12 +4794,12 @@ packages:
       react: 18.2.0
       react: 18.2.0
       react-dom: 18.2.0([email protected])
       react-dom: 18.2.0([email protected])
       scroll-into-view-if-needed: 2.2.31
       scroll-into-view-if-needed: 2.2.31
-      slate: 0.91.4
+      slate: 0.94.1
       tiny-invariant: 1.0.6
       tiny-invariant: 1.0.6
     dev: false
     dev: false
 
 
-  /[email protected]1.4:
-    resolution: {integrity: sha512-aUJ3rpjrdi5SbJ5G1Qjr3arytfRkEStTmHjBfWq2A2Q8MybacIzkScSvGJjQkdTk3djCK9C9SEOt39sSeZFwTw==}
+  /[email protected].1:
+    resolution: {integrity: sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==}
     dependencies:
     dependencies:
       immer: 9.0.21
       immer: 9.0.21
       is-plain-object: 5.0.0
       is-plain-object: 5.0.0
@@ -5154,6 +5270,12 @@ packages:
       yjs: 13.6.1
       yjs: 13.6.1
     dev: false
     dev: false
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==}
+    dependencies:
+      lib0: 0.2.74
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
     resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}

+ 106 - 40
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts

@@ -1,31 +1,93 @@
 import { useCallback, useEffect, useRef, useState } from 'react';
 import { useCallback, useEffect, useRef, useState } from 'react';
-import { getBlockIdByPoint } from '$app/utils/document/blocks/selection';
-import { rangeSelectionActions } from '$app_reducers/document/slice';
-import { useAppDispatch } from '$app/stores/store';
-import { getNodesInRange } from '$app/utils/document/blocks/common';
-import { setRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
+import { rangeActions } from '$app_reducers/document/slice';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import {
+  getBlockIdByPoint,
+  getNodeTextBoxByBlockId,
+  isFocused,
+  setCursorAtEndOfNode,
+  setCursorAtStartOfNode,
+} from '$app/utils/document/node';
+import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
 
 
 export function useBlockRangeSelection(container: HTMLDivElement) {
 export function useBlockRangeSelection(container: HTMLDivElement) {
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
+  const onKeyDown = useRangeKeyDown();
+  const range = useAppSelector((state) => state.documentRange);
+  const isDragging = range.isDragging;
+
   const anchorRef = useRef<{
   const anchorRef = useRef<{
     id: string;
     id: string;
     point: { x: number; y: number };
     point: { x: number; y: number };
-    range?: Range;
   } | null>(null);
   } | null>(null);
 
 
-  const [isDragging, setDragging] = useState(false);
+  const [focus, setFocus] = useState<{
+    id: string;
+    point: { x: number; y: number };
+  } | null>(null);
+
+  const [isForward, setForward] = useState(true);
 
 
   const reset = useCallback(() => {
   const reset = useCallback(() => {
-    dispatch(rangeSelectionActions.clearRange());
+    dispatch(rangeActions.clearRange());
   }, [dispatch]);
   }, [dispatch]);
 
 
+  // display caret color
   useEffect(() => {
   useEffect(() => {
-    dispatch(rangeSelectionActions.setDragging(isDragging));
-  }, [dispatch, isDragging]);
+    const { anchor, focus } = range;
+    if (!anchor || !focus) {
+      container.classList.remove('caret-transparent');
+      return;
+    }
+    // if the focus block is different from the anchor block, we need to set the caret transparent
+    if (focus.id !== anchor.id) {
+      container.classList.add('caret-transparent');
+    } else {
+      container.classList.remove('caret-transparent');
+    }
+  }, [container.classList, range]);
+
+  useEffect(() => {
+    const anchor = anchorRef.current;
+    if (!anchor || !focus) return;
+    const selection = window.getSelection();
+    if (!selection) return;
+    // update focus point
+    dispatch(rangeActions.setFocusPoint(focus));
+
+    const focused = isFocused(focus.id);
+    // if the focus block is not focused, we need to set the cursor position
+    if (!focused) {
+      // if the focus block is the same as the anchor block, we just update the anchor's range
+      if (anchor.id === focus.id) {
+        const range = document.caretRangeFromPoint(
+          anchor.point.x - container.scrollLeft,
+          anchor.point.y - container.scrollTop
+        );
+        if (!range) return;
+        const selection = window.getSelection();
+        selection?.removeAllRanges();
+        selection?.addRange(range);
+        return;
+      }
+
+      const node = getNodeTextBoxByBlockId(focus.id);
+      if (!node) return;
+      // if the selection is forward, we set the cursor position to the start of the focus block
+      if (isForward) {
+        setCursorAtStartOfNode(node);
+      } else {
+        // if the selection is backward, we set the cursor position to the end of the focus block
+        setCursorAtEndOfNode(node);
+      }
+    }
+  }, [container, dispatch, focus, isForward]);
 
 
   const handleDragStart = useCallback(
   const handleDragStart = useCallback(
     (e: MouseEvent) => {
     (e: MouseEvent) => {
+      // reset the range
       reset();
       reset();
+      // skip if the target is not a block
       const blockId = getBlockIdByPoint(e.target as HTMLElement);
       const blockId = getBlockIdByPoint(e.target as HTMLElement);
       if (!blockId) {
       if (!blockId) {
         return;
         return;
@@ -33,72 +95,76 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
 
 
       const startX = e.clientX + container.scrollLeft;
       const startX = e.clientX + container.scrollLeft;
       const startY = e.clientY + container.scrollTop;
       const startY = e.clientY + container.scrollTop;
-      anchorRef.current = {
+
+      const anchor = {
         id: blockId,
         id: blockId,
         point: {
         point: {
           x: startX,
           x: startX,
           y: startY,
           y: startY,
         },
         },
       };
       };
-      setDragging(true);
+
+      anchorRef.current = {
+        ...anchor,
+      };
+      // set the anchor point and focus point
+      dispatch(rangeActions.setAnchorPoint({ ...anchor }));
+      dispatch(rangeActions.setFocusPoint({ ...anchor }));
+      dispatch(rangeActions.setDragging(true));
     },
     },
-    [container.scrollLeft, container.scrollTop, reset]
+    [container.scrollLeft, container.scrollTop, dispatch, reset]
   );
   );
 
 
   const handleDraging = useCallback(
   const handleDraging = useCallback(
     (e: MouseEvent) => {
     (e: MouseEvent) => {
       if (!isDragging || !anchorRef.current) return;
       if (!isDragging || !anchorRef.current) return;
 
 
+      // skip if the target is not a block
       const blockId = getBlockIdByPoint(e.target as HTMLElement);
       const blockId = getBlockIdByPoint(e.target as HTMLElement);
       if (!blockId) {
       if (!blockId) {
         return;
         return;
       }
       }
 
 
+      const endX = e.clientX + container.scrollLeft;
+      const endY = e.clientY + container.scrollTop;
+      // set the focus point
+      setFocus({
+        id: blockId,
+        point: {
+          x: endX,
+          y: endY,
+        },
+      });
+      // set forward
       const anchorId = anchorRef.current.id;
       const anchorId = anchorRef.current.id;
       if (anchorId === blockId) {
       if (anchorId === blockId) {
-        const endX = e.clientX + container.scrollTop;
-        const isForward = endX > anchorRef.current.point.x;
-        dispatch(rangeSelectionActions.setForward(isForward));
+        const startX = anchorRef.current.point.x;
+        setForward(startX < endX);
         return;
         return;
       }
       }
-
-      const endY = e.clientY + container.scrollTop;
-      const isForward = endY > anchorRef.current.point.y;
-      dispatch(rangeSelectionActions.setForward(isForward));
+      const startY = anchorRef.current.point.y;
+      setForward(startY < endY);
     },
     },
-    [container.scrollTop, dispatch, isDragging]
+    [container.scrollLeft, container.scrollTop, isDragging]
   );
   );
 
 
   const handleDragEnd = useCallback(() => {
   const handleDragEnd = useCallback(() => {
     if (!isDragging) return;
     if (!isDragging) return;
-    setDragging(false);
-    dispatch(setRangeSelectionThunk());
+    dispatch(rangeActions.setDragging(false));
   }, [dispatch, isDragging]);
   }, [dispatch, isDragging]);
 
 
-  // TODO: This is a hack to fix the issue that the selection is lost when scrolling
-  const handleScroll = useCallback(() => {
-    if (isDragging || !anchorRef.current) return;
-    const selection = window.getSelection();
-    if (!selection?.rangeCount && anchorRef.current.range) {
-      selection?.addRange(anchorRef.current.range);
-    } else {
-      anchorRef.current.range = selection?.getRangeAt(0);
-    }
-  }, [isDragging]);
-
   useEffect(() => {
   useEffect(() => {
     document.addEventListener('mousedown', handleDragStart);
     document.addEventListener('mousedown', handleDragStart);
-    document.addEventListener('mousemove', handleDraging, true);
+    document.addEventListener('mousemove', handleDraging);
     document.addEventListener('mouseup', handleDragEnd);
     document.addEventListener('mouseup', handleDragEnd);
-    container.addEventListener('scroll', handleScroll);
-
+    container.addEventListener('keydown', onKeyDown, true);
     return () => {
     return () => {
       document.removeEventListener('mousedown', handleDragStart);
       document.removeEventListener('mousedown', handleDragStart);
-      document.removeEventListener('mousemove', handleDraging, true);
+      document.removeEventListener('mousemove', handleDraging);
       document.removeEventListener('mouseup', handleDragEnd);
       document.removeEventListener('mouseup', handleDragEnd);
-      container.removeEventListener('scroll', handleScroll);
+      container.removeEventListener('keydown', onKeyDown, true);
     };
     };
-  }, [handleDragStart, handleDragEnd, handleDraging, container, handleScroll]);
+  }, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
 
 
   return null;
   return null;
 }
 }

+ 8 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts

@@ -1,18 +1,21 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useAppDispatch } from '$app/stores/store';
 import { useAppDispatch } from '$app/stores/store';
 import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/slice';
 import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/slice';
-import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks';
 import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
 import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
-import { isPointInBlock } from '$app/utils/document/blocks/selection';
 
 
-export function useBlockRectSelection({ container }: { container: HTMLDivElement }) {
+import { isPointInBlock } from '$app/utils/document/node';
+
+export interface BlockRectSelectionProps {
+  container: HTMLDivElement;
+  getIntersectedBlockIds: (rect: { startX: number; startY: number; endX: number; endY: number }) => string[];
+}
+
+export function useBlockRectSelection({ container, getIntersectedBlockIds }: BlockRectSelectionProps) {
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
 
 
   const [isDragging, setDragging] = useState(false);
   const [isDragging, setDragging] = useState(false);
   const startPointRef = useRef<number[]>([]);
   const startPointRef = useRef<number[]>([]);
 
 
-  const { getIntersectedBlockIds } = useNodesRect(container);
-
   useEffect(() => {
   useEffect(() => {
     dispatch(rectSelectionActions.setDragging(isDragging));
     dispatch(rectSelectionActions.setDragging(isDragging));
   }, [dispatch, isDragging]);
   }, [dispatch, isDragging]);

+ 6 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx

@@ -1,10 +1,11 @@
 import React from 'react';
 import React from 'react';
-import { useBlockRectSelection } from '$app/components/document/BlockSelection/BlockRectSelection.hooks';
+import {
+  BlockRectSelectionProps,
+  useBlockRectSelection,
+} from '$app/components/document/BlockSelection/BlockRectSelection.hooks';
 
 
-function BlockRectSelection({ container }: { container: HTMLDivElement }) {
-  const { isDragging, style } = useBlockRectSelection({
-    container,
-  });
+function BlockRectSelection(props: BlockRectSelectionProps) {
+  const { isDragging, style } = useBlockRectSelection(props);
 
 
   if (!isDragging) return null;
   if (!isDragging) return null;
   return <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} />;
   return <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} />;

+ 119 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts

@@ -0,0 +1,119 @@
+import { useCallback, useContext, useMemo } from 'react';
+import { Keyboard } from '$app/constants/document/keyboard';
+import { useAppDispatch } from '$app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions';
+import Delta from 'quill-delta';
+import isHotkey from 'is-hotkey';
+import { deleteRangeAndInsertEnterThunk } from '$app_reducers/document/async-actions/range';
+import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import { isPrintableKeyEvent } from '$app/utils/document/action';
+
+export function useRangeKeyDown() {
+  const rangeRef = useRangeRef();
+
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+  const interceptEvents = useMemo(
+    () => [
+      {
+        // handle backspace and delete
+        canHandle: (e: KeyboardEvent) => {
+          return isHotkey(Keyboard.keys.BACKSPACE, e) || isHotkey(Keyboard.keys.DELETE, e);
+        },
+        handler: (_: KeyboardEvent) => {
+          if (!controller) return;
+          dispatch(
+            deleteRangeAndInsertThunk({
+              controller,
+            })
+          );
+        },
+      },
+      {
+        // handle char input
+        canHandle: (e: KeyboardEvent) => {
+          return isPrintableKeyEvent(e);
+        },
+        handler: (e: KeyboardEvent) => {
+          if (!controller) return;
+          const insertDelta = new Delta().insert(e.key);
+          dispatch(
+            deleteRangeAndInsertThunk({
+              controller,
+              insertDelta,
+            })
+          );
+        },
+      },
+      {
+        // handle shift + enter
+        canHandle: (e: KeyboardEvent) => {
+          return isHotkey(Keyboard.keys.SHIFT_ENTER, e);
+        },
+        handler: (e: KeyboardEvent) => {
+          if (!controller) return;
+          dispatch(
+            deleteRangeAndInsertEnterThunk({
+              controller,
+              shiftKey: true,
+            })
+          );
+        },
+      },
+      {
+        // handle enter
+        canHandle: (e: KeyboardEvent) => {
+          return isHotkey(Keyboard.keys.ENTER, e);
+        },
+        handler: (e: KeyboardEvent) => {
+          if (!controller) return;
+          dispatch(
+            deleteRangeAndInsertEnterThunk({
+              controller,
+              shiftKey: false,
+            })
+          );
+        },
+      },
+      {
+        // handle arrows
+        canHandle: (e: KeyboardEvent) => {
+          return (
+            isHotkey(Keyboard.keys.LEFT, e) ||
+            isHotkey(Keyboard.keys.RIGHT, e) ||
+            isHotkey(Keyboard.keys.UP, e) ||
+            isHotkey(Keyboard.keys.DOWN, e)
+          );
+        },
+        handler: (e: KeyboardEvent) => {
+          dispatch(
+            arrowActionForRangeThunk({
+              key: e.key,
+            })
+          );
+        },
+      },
+    ],
+    [controller, dispatch]
+  );
+
+  const onKeyDown = useCallback(
+    (e: KeyboardEvent) => {
+      if (!rangeRef.current) {
+        return;
+      }
+      const { anchor, focus } = rangeRef.current;
+      if (anchor?.id === focus?.id) {
+        return;
+      }
+      e.stopPropagation();
+      e.preventDefault();
+      const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
+      filteredEvents.forEach((event) => event.handler(e));
+    },
+    [interceptEvents, rangeRef]
+  );
+
+  return onKeyDown;
+}

+ 4 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx

@@ -1,12 +1,15 @@
 import React from 'react';
 import React from 'react';
 import BlockRectSelection from '$app/components/document/BlockSelection/BlockRectSelection';
 import BlockRectSelection from '$app/components/document/BlockSelection/BlockRectSelection';
 import { useBlockRangeSelection } from '$app/components/document/BlockSelection/BlockRangeSelection.hooks';
 import { useBlockRangeSelection } from '$app/components/document/BlockSelection/BlockRangeSelection.hooks';
+import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks';
 
 
 function BlockSelection({ container }: { container: HTMLDivElement }) {
 function BlockSelection({ container }: { container: HTMLDivElement }) {
+  const { getIntersectedBlockIds } = useNodesRect(container);
+
   useBlockRangeSelection(container);
   useBlockRangeSelection(container);
   return (
   return (
     <div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
     <div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
-      <BlockRectSelection container={container} />
+      <BlockRectSelection getIntersectedBlockIds={getIntersectedBlockIds} container={container} />
     </div>
     </div>
   );
   );
 }
 }

+ 14 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useState } from 'react';
 import { List } from '@mui/material';
 import { List } from '@mui/material';
 import { ContentCopy, Delete } from '@mui/icons-material';
 import { ContentCopy, Delete } from '@mui/icons-material';
 import MenuItem from './MenuItem';
 import MenuItem from './MenuItem';
@@ -8,7 +8,7 @@ import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMe
 function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
 function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
   const { handleDelete, handleDuplicate } = useBlockMenu(id);
   const { handleDelete, handleDuplicate } = useBlockMenu(id);
 
 
-  const [turnIntoPup, setTurnIntoPup] = React.useState<boolean>(false);
+  const [turnIntoOptionHovered, setTurnIntoOptionHorvered] = useState<boolean>(false);
   const handleClick = useCallback(
   const handleClick = useCallback(
     async ({ operate }: { operate: () => Promise<void> }) => {
     async ({ operate }: { operate: () => Promise<void> }) => {
       await operate();
       await operate();
@@ -20,10 +20,12 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
   return (
   return (
     <List
     <List
       onMouseDown={(e) => {
       onMouseDown={(e) => {
+        // Prevent the block from being selected.
         e.preventDefault();
         e.preventDefault();
         e.stopPropagation();
         e.stopPropagation();
       }}
       }}
     >
     >
+      {/** Delete option in the BlockMenu. */}
       <MenuItem
       <MenuItem
         title='Delete'
         title='Delete'
         icon={<Delete />}
         icon={<Delete />}
@@ -34,10 +36,11 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
         }
         }
         onHover={(isHovered) => {
         onHover={(isHovered) => {
           if (isHovered) {
           if (isHovered) {
-            setTurnIntoPup(false);
+            setTurnIntoOptionHorvered(false);
           }
           }
         }}
         }}
       />
       />
+      {/** Duplicate option in the BlockMenu. */}
       <MenuItem
       <MenuItem
         title='Duplicate'
         title='Duplicate'
         icon={<ContentCopy />}
         icon={<ContentCopy />}
@@ -48,11 +51,17 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
         }
         }
         onHover={(isHovered) => {
         onHover={(isHovered) => {
           if (isHovered) {
           if (isHovered) {
-            setTurnIntoPup(false);
+            setTurnIntoOptionHorvered(false);
           }
           }
         }}
         }}
       />
       />
-      <BlockMenuTurnInto onHovered={() => setTurnIntoPup(true)} isHovered={turnIntoPup} onClose={onClose} id={id} />
+      {/** Turn Into option in the BlockMenu. */}
+      <BlockMenuTurnInto
+        onHovered={() => setTurnIntoOptionHorvered(true)}
+        isHovered={turnIntoOptionHovered}
+        onClose={onClose}
+        id={id}
+      />
     </List>
     </List>
   );
   );
 }
 }

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
 import { ArrowRight, Transform } from '@mui/icons-material';
 import { ArrowRight, Transform } from '@mui/icons-material';
 import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
 import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
 import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
 import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
+
 function BlockMenuTurnInto({
 function BlockMenuTurnInto({
   id,
   id,
   onClose,
   onClose,

+ 7 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx

@@ -1,13 +1,13 @@
-import { BlockType, HeadingBlockData, NestedBlock } from '@/appflowy_app/interfaces/document';
+import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document';
 import { useAppDispatch } from '@/appflowy_app/stores/store';
 import { useAppDispatch } from '@/appflowy_app/stores/store';
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { getBlockByIdThunk } from '$app_reducers/document/async-actions';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { PopoverOrigin } from '@mui/material/Popover/Popover';
 import { PopoverOrigin } from '@mui/material/Popover/Popover';
+import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
 
 
 const headingBlockTopOffset: Record<number, number> = {
 const headingBlockTopOffset: Record<number, number> = {
   1: 7,
   1: 7,
-  2: 6,
-  3: 3,
+  2: 5,
+  3: 4,
 };
 };
 export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
 export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
   const [nodeId, setHoverNodeId] = useState<string | null>(null);
   const [nodeId, setHoverNodeId] = useState<string | null>(null);
@@ -19,9 +19,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
     const el = ref.current;
     const el = ref.current;
     if (!el || !nodeId) return;
     if (!el || !nodeId) return;
     void (async () => {
     void (async () => {
-      const { payload: node } = (await dispatch(getBlockByIdThunk(nodeId))) as {
-        payload: NestedBlock;
-      };
+      const node = getBlock(nodeId);
       if (!node) {
       if (!node) {
         setStyle({
         setStyle({
           opacity: '0',
           opacity: '0',
@@ -29,7 +27,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
         });
         });
         return;
         return;
       } else {
       } else {
-        let top = 1;
+        let top = 2;
 
 
         if (node.type === BlockType.HeadingBlock) {
         if (node.type === BlockType.HeadingBlock) {
           const nodeData = node.data as HeadingBlockData;
           const nodeData = node.data as HeadingBlockData;

+ 2 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useContext, useState } from 'react';
+import React, { useContext } from 'react';
 import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
 import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
 import Portal from '../BlockPortal';
 import Portal from '../BlockPortal';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
@@ -15,9 +15,7 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
   const controller = useContext(DocumentControllerContext);
   const controller = useContext(DocumentControllerContext);
   const { nodeId, style, ref } = useBlockSideToolbar({ container });
   const { nodeId, style, ref } = useBlockSideToolbar({ container });
-  const isDragging = useAppSelector(
-    (state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging
-  );
+  const isDragging = useAppSelector((state) => state.documentRange.isDragging || state.documentRectSelection.isDragging);
   const { handleOpen, ...popoverProps } = usePopover();
   const { handleOpen, ...popoverProps } = usePopover();
 
 
   // prevent popover from showing when anchorEl is not in DOM
   // prevent popover from showing when anchorEl is not in DOM

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

@@ -10,6 +10,7 @@ import {
   Lightbulb,
   Lightbulb,
   TextFields,
   TextFields,
   Title,
   Title,
+  SafetyDivider,
 } from '@mui/icons-material';
 } from '@mui/icons-material';
 import { List } from '@mui/material';
 import { List } from '@mui/material';
 import { BlockData, BlockType } from '$app/interfaces/document';
 import { BlockData, BlockType } from '$app/interfaces/document';
@@ -107,6 +108,11 @@ function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: ()
           title: 'Callout',
           title: 'Callout',
           icon: <Lightbulb />,
           icon: <Lightbulb />,
         },
         },
+        {
+          type: BlockType.DividerBlock,
+          title: 'Divider',
+          icon: <SafetyDivider />,
+        },
       ],
       ],
     ],
     ],
     []
     []

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

@@ -2,7 +2,7 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import React, { useCallback, useEffect, useMemo } from 'react';
 import React, { useCallback, useEffect, useMemo } from 'react';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
-import { TextDelta } from '$app/interfaces/document';
+import { Op } from 'quill-delta';
 
 
 export function useBlockSlash() {
 export function useBlockSlash() {
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
@@ -54,7 +54,7 @@ export function useSubscribeSlash() {
     if (!node) return '';
     if (!node) return '';
     const delta = node.data.delta || [];
     const delta = node.data.delta || [];
     return delta
     return delta
-      .map((op: TextDelta) => {
+      .map((op: Op) => {
         if (typeof op.insert === 'string') {
         if (typeof op.insert === 'string') {
           return op.insert;
           return op.insert;
         } else {
         } else {

+ 0 - 86
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts

@@ -1,86 +0,0 @@
-import { useTextInput } from '$app/components/document/_shared/Text/TextInput.hooks';
-import isHotkey from 'is-hotkey';
-import { useCallback, useContext, useMemo } from 'react';
-import { Editor } from 'slate';
-import { BlockType, NestedBlock, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
-import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { splitNodeThunk } from '$app_reducers/document/async-actions';
-import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
-import { indent, outdent } from '$app/utils/document/blocks/code';
-
-export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {
-  const id = node.id;
-  const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
-  const { editor, ...rest } = useTextInput(id);
-  const defaultTextInputEvents = useDefaultTextInputEvents(id);
-
-  const customEvents = useMemo(() => {
-    return [
-      {
-        // Here custom tab key event for TextBlock to insert 2 spaces
-        triggerEventKey: keyBoardEventKeyMap.Tab,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, editor] = args;
-          e.preventDefault();
-          indent(editor, 2);
-        },
-      },
-      {
-        // Here custom shift+tab key event for TextBlock to delete 2 spaces
-        triggerEventKey: keyBoardEventKeyMap.Tab,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, editor] = args;
-          e.preventDefault();
-          outdent(editor, 2);
-        },
-      },
-      {
-        // Here custom enter key event for TextBlock
-        triggerEventKey: keyBoardEventKeyMap.Enter,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, editor] = args;
-          e.preventDefault();
-          Editor.insertText(editor, '\n');
-        },
-      },
-      {
-        // Here custom shift+enter key event for TextBlock
-        triggerEventKey: keyBoardEventKeyMap.Enter,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, editor] = args;
-          e.preventDefault();
-          void (async () => {
-            if (!controller) return;
-            await dispatch(splitNodeThunk({ id, controller, editor }));
-          })();
-        },
-      },
-    ];
-  }, [controller, dispatch, id]);
-
-  const onKeyDown = useCallback<React.KeyboardEventHandler<HTMLDivElement>>(
-    (e) => {
-      const keyEvents = [...defaultTextInputEvents, ...customEvents];
-      keyEvents.forEach((keyEvent) => {
-        // Here we check if the key event can be handled by the current key event
-        if (keyEvent.canHandle(e, editor)) {
-          keyEvent.handler(e, editor);
-        }
-      });
-    },
-    [defaultTextInputEvents, customEvents, editor]
-  );
-
-  return {
-    editor,
-    onKeyDown,
-    ...rest
-  };
-}

+ 6 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx

@@ -30,7 +30,12 @@ function SelectLanguage({ id, language }: { id: string; language: string }) {
 
 
   return (
   return (
     <FormControl variant='standard'>
     <FormControl variant='standard'>
-      <Select className={'h-[28px] w-[150px]'} value={language} onChange={onLanguageSelect} label='Language'>
+      <Select
+        className={'h-[28px] w-[150px]'}
+        value={language || 'javascript'}
+        onChange={onLanguageSelect}
+        label='Language'
+      >
         {supportLanguage.map((item) => (
         {supportLanguage.map((item) => (
           <MenuItem key={item.id} value={item.id}>
           <MenuItem key={item.id} value={item.id}>
             {item.title}
             {item.title}

+ 18 - 20
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx

@@ -1,39 +1,37 @@
 import { BlockType, NestedBlock } from '$app/interfaces/document';
 import { BlockType, NestedBlock } from '$app/interfaces/document';
-import { useCodeBlock } from './CodeBlock.hooks';
-import { Editable, Slate } from 'slate-react';
 import React from 'react';
 import React from 'react';
-import { CodeLeaf, CodeBlockElement } from './elements';
 import SelectLanguage from './SelectLanguage';
 import SelectLanguage from './SelectLanguage';
-import { decorateCodeFunc } from '$app/utils/document/blocks/code/decorate';
+import { useChange } from '$app/components/document/_shared/EditorHooks/useChange';
+import { useKeyDown } from './useKeyDown';
+import CodeEditor from '$app/components/document/_shared/SlateEditor/CodeEditor';
+import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection';
 
 
 export default function CodeBlock({
 export default function CodeBlock({
   node,
   node,
   placeholder,
   placeholder,
   ...props
   ...props
 }: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) {
 }: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) {
-  const { editor, value, onChange, ...rest } = useCodeBlock(node);
-
-  const className = props.className ? ` ${props.className}` : '';
   const id = node.id;
   const id = node.id;
   const language = node.data.language;
   const language = node.data.language;
+  const onKeyDown = useKeyDown(id);
+  const className = props.className ? ` ${props.className}` : '';
+  const { value, onChange } = useChange(node);
+  const { onSelectionChange, selection, lastSelection } = useSelection(id);
   return (
   return (
     <div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
     <div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
       <div className={'mb-2 w-[100%]'}>
       <div className={'mb-2 w-[100%]'}>
         <SelectLanguage id={id} language={language} />
         <SelectLanguage id={id} language={language} />
       </div>
       </div>
-      <Slate editor={editor} onChange={onChange} value={value}>
-        <Editable
-          {...rest}
-          decorate={(entry) => {
-            const codeRange = decorateCodeFunc(entry, language);
-            const range = rest.decorate(entry);
-            return [...range, ...codeRange];
-          }}
-          renderLeaf={CodeLeaf}
-          renderElement={CodeBlockElement}
-          placeholder={placeholder || 'Please enter some text...'}
-        />
-      </Slate>
+      <CodeEditor
+        value={value}
+        onChange={onChange}
+        placeholder={placeholder}
+        language={language}
+        onKeyDown={onKeyDown}
+        onSelectionChange={onSelectionChange}
+        selection={selection}
+        lastSelection={lastSelection}
+      />
     </div>
     </div>
   );
   );
 }
 }

+ 51 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts

@@ -0,0 +1,51 @@
+import isHotkey from 'is-hotkey';
+import { useCallback, useContext, useMemo } from 'react';
+import { useAppDispatch } from '$app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { Keyboard } from '$app/constants/document/keyboard';
+import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents';
+import { enterActionForBlockThunk } from '$app_reducers/document/async-actions';
+
+export function useKeyDown(id: string) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const commonKeyEvents = useCommonKeyEvents(id);
+  const customEvents = useMemo(() => {
+    return [
+      ...commonKeyEvents,
+
+      {
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return isHotkey(Keyboard.keys.SHIFT_ENTER, e);
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          if (!controller) return;
+          dispatch(
+            enterActionForBlockThunk({
+              id,
+              controller,
+            })
+          );
+        },
+      },
+    ];
+  }, [commonKeyEvents, controller, dispatch, id]);
+
+  const onKeyDown = useCallback<React.KeyboardEventHandler<HTMLDivElement>>(
+    (e) => {
+      e.stopPropagation();
+      const keyEvents = [...customEvents];
+      keyEvents.forEach((keyEvent) => {
+        // Here we check if the key event can be handled by the current key event
+        if (keyEvent.canHandle(e)) {
+          keyEvent.handler(e);
+        }
+      });
+    },
+    [customEvents]
+  );
+
+  return onKeyDown;
+}

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx

@@ -1,4 +1,4 @@
-import TextBlock from '../TextBlock';
+import TextBlock from '$app/components/document/TextBlock';
 import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document';
 import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document';
 
 
 const fontSize: Record<string, string> = {
 const fontSize: Record<string, string> = {

+ 4 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx

@@ -14,6 +14,7 @@ import NumberedListBlock from '$app/components/document/NumberedListBlock';
 import ToggleListBlock from '$app/components/document/ToggleListBlock';
 import ToggleListBlock from '$app/components/document/ToggleListBlock';
 import DividerBlock from '$app/components/document/DividerBlock';
 import DividerBlock from '$app/components/document/DividerBlock';
 import CalloutBlock from '$app/components/document/CalloutBlock';
 import CalloutBlock from '$app/components/document/CalloutBlock';
+import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
 import CodeBlock from '$app/components/document/CodeBlock';
 import CodeBlock from '$app/components/document/CodeBlock';
 
 
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
@@ -55,12 +56,13 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
     }
     }
   }, [node, childIds]);
   }, [node, childIds]);
 
 
+  const className = props.className ? ` ${props.className}` : '';
   if (!node) return null;
   if (!node) return null;
 
 
   return (
   return (
-    <div {...props} ref={ref} data-block-id={node.id} className={`relative ${props.className}`}>
+    <div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
       {renderBlock()}
       {renderBlock()}
-      <div className='block-overlay' />
+      <BlockOverlay id={id} />
       {isSelected ? (
       {isSelected ? (
         <div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
         <div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
       ) : null}
       ) : null}

+ 7 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx

@@ -0,0 +1,7 @@
+import React from 'react';
+
+function BlockOverlay({ id }: { id: string }) {
+  return <div className='block-overlay' />;
+}
+
+export default BlockOverlay;

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx

@@ -20,7 +20,7 @@ function Root({ documentData }: { documentData: DocumentData }) {
 
 
   return (
   return (
     <>
     <>
-      <div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
+      <div id='appflowy-block-doc' className='h-[100%] overflow-hidden caret-custom-caret'>
         <VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
         <VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
       </div>
       </div>
     </>
     </>

+ 31 - 14
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts

@@ -1,20 +1,21 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { calcToolbarPosition } from '$app/utils/document/toolbar';
 import { calcToolbarPosition } from '$app/utils/document/toolbar';
 import { useAppSelector } from '$app/stores/store';
 import { useAppSelector } from '$app/stores/store';
+import { getNode } from '$app/utils/document/node';
+import { debounce } from '$app/utils/tool';
 
 
 export function useMenuStyle(container: HTMLDivElement) {
 export function useMenuStyle(container: HTMLDivElement) {
   const ref = useRef<HTMLDivElement | null>(null);
   const ref = useRef<HTMLDivElement | null>(null);
-  const range = useAppSelector((state) => state.documentRangeSelection);
+  const id = useAppSelector((state) => state.documentRange.focus?.id);
+  const [isScrolling, setIsScrolling] = useState(false);
 
 
-  const [scrollTop, setScrollTop] = useState(container.scrollTop);
-  useEffect(() => {
+  const reCalculatePosition = useCallback(() => {
     const el = ref.current;
     const el = ref.current;
-    if (!el) return;
-
-    const id = range.focus?.id;
-    if (!id) return;
+    if (!el || !id) return;
 
 
-    const position = calcToolbarPosition(el);
+    const node = getNode(id);
+    if (!node) return;
+    const position = calcToolbarPosition(el, node, container);
 
 
     if (!position) {
     if (!position) {
       el.style.opacity = '0';
       el.style.opacity = '0';
@@ -22,22 +23,38 @@ export function useMenuStyle(container: HTMLDivElement) {
     } else {
     } else {
       el.style.opacity = '1';
       el.style.opacity = '1';
       el.style.pointerEvents = 'auto';
       el.style.pointerEvents = 'auto';
-      el.style.top = position.top;
-      el.style.left = position.left;
+      el.style.top = position.top + 'px';
+      el.style.left = position.left + 'px';
     }
     }
-  });
+  }, [container, id]);
+
+  useEffect(() => {
+    // recalculating toolbar position when scrolling is finished
+    if (isScrolling) return;
+    reCalculatePosition();
+  }, [container, id, isScrolling, reCalculatePosition]);
+
+  const debounceScrollEnd = useMemo(() => {
+    return debounce(() => {
+      // set isScrolling to false after 20ms
+      setIsScrolling(false);
+    }, 20);
+  }, []);
 
 
   useEffect(() => {
   useEffect(() => {
     const handleScroll = () => {
     const handleScroll = () => {
-      setScrollTop(container.scrollTop);
+      setIsScrolling(true);
+      debounceScrollEnd();
     };
     };
     container.addEventListener('scroll', handleScroll);
     container.addEventListener('scroll', handleScroll);
     return () => {
     return () => {
+      debounceScrollEnd.cancel();
       container.removeEventListener('scroll', handleScroll);
       container.removeEventListener('scroll', handleScroll);
     };
     };
-  }, [container]);
+  }, [container, debounceScrollEnd]);
 
 
   return {
   return {
     ref,
     ref,
+    id,
   };
   };
 }
 }

+ 28 - 29
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx

@@ -1,46 +1,45 @@
 import { useMenuStyle } from './index.hooks';
 import { useMenuStyle } from './index.hooks';
 import { useAppSelector } from '$app/stores/store';
 import { useAppSelector } from '$app/stores/store';
-import { isEqual } from '$app/utils/tool';
 import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
 import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
+import BlockPortal from '$app/components/document/BlockPortal';
 
 
 const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
 const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
-  const { ref } = useMenuStyle(container);
+  const { ref, id } = useMenuStyle(container);
 
 
+  if (!id) return null;
   return (
   return (
-    <div
-      ref={ref}
-      style={{
-        opacity: 0,
-      }}
-      className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-[#333] leading-tight shadow-lg transition-opacity duration-200'
-      onMouseDown={(e) => {
-        // prevent toolbar from taking focus away from editor
-        e.preventDefault();
-        e.stopPropagation();
-      }}
-    >
-      <TextActionMenuList />
-    </div>
+    <BlockPortal blockId={id}>
+      <div
+        ref={ref}
+        style={{
+          opacity: 0,
+        }}
+        className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-[#333] leading-tight shadow-lg transition-opacity duration-100'
+        onMouseDown={(e) => {
+          // prevent toolbar from taking focus away from editor
+          e.preventDefault();
+          e.stopPropagation();
+        }}
+      >
+        <TextActionMenuList />
+      </div>
+    </BlockPortal>
   );
   );
 };
 };
 const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
 const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
   const canShow = useAppSelector((state) => {
   const canShow = useAppSelector((state) => {
-    const range = state.documentRangeSelection;
-    if (range.isDragging) return false;
-    const anchorNode = range.anchor;
-    const focusNode = range.focus;
-    if (!anchorNode || !focusNode) return false;
-    const isSameLine = anchorNode.id === focusNode.id;
-    const isCollapsed = isEqual(anchorNode.selection.anchor, anchorNode.selection.focus);
-    return !(isSameLine && isCollapsed);
+    const { isDragging, focus, anchor, ranges } = state.documentRange;
+    if (isDragging) return false;
+    if (!focus || !anchor) return false;
+    const isSameLine = anchor.id === focus.id;
+    const anchorRange = ranges[anchor.id];
+    if (!anchorRange) return false;
+    const isCollapsed = isSameLine && anchorRange.length === 0;
+    return !isCollapsed;
   });
   });
   if (!canShow) return null;
   if (!canShow) return null;
 
 
-  return (
-    <div className='appflowy-block-toolbar-overlay pointer-events-none fixed inset-0 overflow-hidden'>
-      <TextActionComponent container={container} />
-    </div>
-  );
+  return <TextActionComponent container={container} />;
 };
 };
 
 
 export default TextActionMenu;
 export default TextActionMenu;

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

@@ -12,7 +12,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
   const controller = useContext(DocumentControllerContext);
   const controller = useContext(DocumentControllerContext);
 
 
-  const focusId = useAppSelector((state) => state.documentRangeSelection.focus?.id || '');
+  const focusId = useAppSelector((state) => state.documentRange.focus?.id || '');
   const { node: focusNode } = useSubscribeNode(focusId);
   const { node: focusNode } = useSubscribeNode(focusId);
 
 
   const [isActive, setIsActive] = React.useState(false);
   const [isActive, setIsActive] = React.useState(false);

+ 6 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx

@@ -1,18 +1,19 @@
 import React from 'react';
 import React from 'react';
 import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
 import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
+import { TextAction } from '$app/interfaces/document';
 export const iconSize = { width: 18, height: 18 };
 export const iconSize = { width: 18, height: 18 };
 
 
 export default function FormatIcon({ icon }: { icon: string }) {
 export default function FormatIcon({ icon }: { icon: string }) {
   switch (icon) {
   switch (icon) {
-    case 'bold':
+    case TextAction.Bold:
       return <FormatBold sx={iconSize} />;
       return <FormatBold sx={iconSize} />;
-    case 'underlined':
+    case TextAction.Underline:
       return <FormatUnderlined sx={iconSize} />;
       return <FormatUnderlined sx={iconSize} />;
-    case 'italic':
+    case TextAction.Italic:
       return <FormatItalic sx={iconSize} />;
       return <FormatItalic sx={iconSize} />;
-    case 'code':
+    case TextAction.Code:
       return <CodeOutlined sx={iconSize} />;
       return <CodeOutlined sx={iconSize} />;
-    case 'strikethrough':
+    case TextAction.Strikethrough:
       return <StrikethroughSOutlined sx={iconSize} />;
       return <StrikethroughSOutlined sx={iconSize} />;
     default:
     default:
       return null;
       return null;

+ 10 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts

@@ -11,17 +11,17 @@ import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode
 import { TextAction } from '$app/interfaces/document';
 import { TextAction } from '$app/interfaces/document';
 
 
 export function useTextActionMenu() {
 export function useTextActionMenu() {
-  const range = useAppSelector((state) => state.documentRangeSelection);
-
-  const id = useMemo(() => {
-    return range.anchor?.id === range.focus?.id ? range.anchor?.id : undefined;
+  const range = useAppSelector((state) => state.documentRange);
+  const isSingleLine = useMemo(() => {
+    return range.focus?.id === range.anchor?.id;
   }, [range]);
   }, [range]);
+  const focusId = range.focus?.id;
 
 
-  const { node } = useSubscribeNode(id || '');
+  const { node } = useSubscribeNode(focusId || '');
 
 
   const items = useMemo(() => {
   const items = useMemo(() => {
-    if (node) {
-      const config = blockConfig[node.type];
+    if (isSingleLine) {
+      const config = blockConfig[node?.type];
       const { customItems, excludeItems } = {
       const { customItems, excludeItems } = {
         ...defaultTextActionProps,
         ...defaultTextActionProps,
         ...config.textActionMenuProps,
         ...config.textActionMenuProps,
@@ -30,7 +30,7 @@ export function useTextActionMenu() {
     } else {
     } else {
       return multiLineTextActionProps.customItems || [];
       return multiLineTextActionProps.customItems || [];
     }
     }
-  }, [node]);
+  }, [isSingleLine, node?.type]);
 
 
   // the groups have default items, so we need to filter the items if this node has excluded items
   // the groups have default items, so we need to filter the items if this node has excluded items
   const groupItems: TextAction[][] = useMemo(() => {
   const groupItems: TextAction[][] = useMemo(() => {
@@ -42,6 +42,7 @@ export function useTextActionMenu() {
 
 
   return {
   return {
     groupItems,
     groupItems,
-    id,
+    isSingleLine,
+    focusId,
   };
   };
 }
 }

+ 29 - 23
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx

@@ -5,33 +5,39 @@ import FormatButton from '$app/components/document/TextActionMenu/menu/FormatBut
 import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks';
 import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks';
 
 
 function TextActionMenuList() {
 function TextActionMenuList() {
-  const { groupItems, id } = useTextActionMenu();
-  const renderNode = useCallback((action: TextAction, id?: string) => {
-    switch (action) {
-      case TextAction.Turn:
-        return id ? <TurnIntoSelect id={id} /> : null;
-      case TextAction.Bold:
-      case TextAction.Italic:
-      case TextAction.Underline:
-      case TextAction.Strikethrough:
-      case TextAction.Code:
-        return <FormatButton format={action} icon={action} />;
-      default:
-        return null;
-    }
-  }, []);
+  const { groupItems, isSingleLine, focusId } = useTextActionMenu();
+  const renderNode = useCallback(
+    (action: TextAction) => {
+      switch (action) {
+        case TextAction.Turn:
+          return isSingleLine && focusId ? <TurnIntoSelect id={focusId} /> : null;
+        case TextAction.Bold:
+        case TextAction.Italic:
+        case TextAction.Underline:
+        case TextAction.Strikethrough:
+        case TextAction.Code:
+          return <FormatButton format={action} icon={action} />;
+        default:
+          return null;
+      }
+    },
+    [isSingleLine, focusId]
+  );
 
 
   return (
   return (
     <div className={'flex px-1'}>
     <div className={'flex px-1'}>
-      {groupItems.map((group, i: number) => (
-        <div className={'flex border-r border-solid border-shade-2 px-1 last:border-r-0'} key={i}>
-          {group.map((item) => (
-            <div key={item} className={'flex items-center'}>
-              {renderNode(item, id)}
+      {groupItems.map(
+        (group, i: number) =>
+          group.length > 0 && (
+            <div className={'flex border-r border-solid border-shade-2 px-1 last:border-r-0'} key={i}>
+              {group.map((item) => (
+                <div key={item} className={'flex items-center'}>
+                  {renderNode(item)}
+                </div>
+              ))}
             </div>
             </div>
-          ))}
-        </div>
-      ))}
+          )
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 0 - 40
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx

@@ -1,40 +0,0 @@
-import { BaseText } from 'slate';
-import { RenderLeafProps } from 'slate-react';
-interface LeafProps extends RenderLeafProps {
-  leaf: BaseText & {
-    bold?: boolean;
-    code?: boolean;
-    italic?: boolean;
-    underlined?: boolean;
-    strikethrough?: boolean;
-    selectionHighlighted?: boolean;
-  };
-}
-const Leaf = ({ attributes, children, leaf }: LeafProps) => {
-  let newChildren = children;
-  if (leaf.bold) {
-    newChildren = <strong>{children}</strong>;
-  }
-
-  if (leaf.italic) {
-    newChildren = <em>{newChildren}</em>;
-  }
-
-  if (leaf.underlined) {
-    newChildren = <u>{newChildren}</u>;
-  }
-
-  const className = [
-    leaf.strikethrough && 'line-through',
-    leaf.selectionHighlighted && 'bg-main-secondary',
-    leaf.code && 'bg-main-selector',
-  ].filter(Boolean);
-
-  return (
-    <span {...attributes} className={className.join(' ')}>
-      {newChildren}
-    </span>
-  );
-};
-
-export default Leaf;

+ 0 - 14
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts

@@ -1,14 +0,0 @@
-import { useTextInput } from '../_shared/Text/TextInput.hooks';
-import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
-
-export function useTextBlock(id: string) {
-  const { editor, ...props } = useTextInput(id);
-
-  const { onKeyDown } = useTextBlockKeyEvent(id, editor);
-
-  return {
-    onKeyDown,
-    editor,
-    ...props,
-  };
-}

+ 0 - 134
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts

@@ -1,134 +0,0 @@
-import { Editor } from 'slate';
-import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
-import { useCallback, useContext, useMemo } from 'react';
-import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-import { BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
-import isHotkey from 'is-hotkey';
-import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
-import { ReactEditor } from 'slate-react';
-import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
-import { slashCommandActions } from '$app_reducers/document/slice';
-import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
-
-export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
-  const controller = useContext(DocumentControllerContext);
-  const dispatch = useAppDispatch();
-  const defaultTextInputEvents = useDefaultTextInputEvents(id);
-  const isFocusCurrentNode = useAppSelector((state) => {
-    const { anchor, focus } = state.documentRangeSelection;
-    if (!anchor || !focus) return false;
-    return anchor.id === id && focus.id === id;
-  });
-
-  const { node } = useSubscribeNode(id);
-  const nodeType = node?.type;
-
-  const { turnIntoBlockEvents } = useTurnIntoBlock(id);
-
-  // Here custom key events for TextBlock
-  const events = useMemo(
-    () => [
-      ...defaultTextInputEvents,
-      {
-        // Here custom enter key event for TextBlock
-        triggerEventKey: keyBoardEventKeyMap.Enter,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, editor] = args;
-          e.preventDefault();
-          void (async () => {
-            if (!controller) return;
-            await dispatch(splitNodeThunk({ id, controller, editor }));
-          })();
-        },
-      },
-      {
-        // Here custom shift+enter key event for TextBlock
-        triggerEventKey: keyBoardEventKeyMap.Enter,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, editor] = args;
-          e.preventDefault();
-          Editor.insertText(editor, '\n');
-        },
-      },
-      {
-        // Here custom tab key event for TextBlock
-        triggerEventKey: keyBoardEventKeyMap.Tab,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, _] = args;
-          e.preventDefault();
-          if (!controller) return;
-          dispatch(
-            indentNodeThunk({
-              id,
-              controller,
-            })
-          );
-        },
-      },
-      {
-        // Here custom shift+tab key event for TextBlock
-        triggerEventKey: keyBoardEventKeyMap.Tab,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, _] = args;
-          e.preventDefault();
-          if (!controller) return;
-          dispatch(
-            outdentNodeThunk({
-              id,
-              controller,
-            })
-          );
-        },
-      },
-      {
-        // Here custom slash key event for TextBlock
-        triggerEventKey: keyBoardEventKeyMap.Slash,
-        canHandle: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, editor] = args;
-          if (!editor.selection) return false;
-
-          return isHotkey('/', e) && Editor.string(editor, getBeforeRangeAt(editor, editor.selection)) === '';
-        },
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          const [e, _] = args;
-          if (!controller) return;
-          dispatch(
-            slashCommandActions.openSlashCommand({
-              blockId: id,
-            })
-          );
-        },
-      },
-    ],
-    [defaultTextInputEvents, controller, dispatch, id, nodeType]
-  );
-
-  const onKeyDown = useCallback(
-    (event: React.KeyboardEvent<HTMLDivElement>) => {
-      if (!isFocusCurrentNode) {
-        event.preventDefault();
-        return;
-      }
-
-      event.stopPropagation();
-      // This is list of key events that can be handled by TextBlock
-      const keyEvents = [...events, ...turnIntoBlockEvents];
-
-      const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
-
-      matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
-    },
-    [editor, events, turnIntoBlockEvents, isFocusCurrentNode]
-  );
-
-  return {
-    onKeyDown,
-  };
-}

+ 0 - 102
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts

@@ -1,102 +0,0 @@
-import { useContext, useMemo } from 'react';
-import { BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
-import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { turnToBlockThunk, turnToDividerBlockThunk } from '$app_reducers/document/async-actions';
-import { blockConfig } from '$app/constants/document/config';
-import { Editor } from 'slate';
-import { getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta';
-import {
-  getHeadingDataFromEditor,
-  getQuoteDataFromEditor,
-  getTodoListDataFromEditor,
-  getBulletedDataFromEditor,
-  getNumberedListDataFromEditor,
-  getToggleListDataFromEditor,
-  getCalloutDataFromEditor,
-  getCodeBlockDataFromEditor,
-} from '$app/utils/document/blocks';
-
-export function useTurnIntoBlock(id: string) {
-  const controller = useContext(DocumentControllerContext);
-  const dispatch = useAppDispatch();
-
-  const turnIntoBlockEvents = useMemo(() => {
-    const spaceTriggerEvents = Object.entries({
-      [BlockType.HeadingBlock]: getHeadingDataFromEditor,
-      [BlockType.TodoListBlock]: getTodoListDataFromEditor,
-      [BlockType.QuoteBlock]: getQuoteDataFromEditor,
-      [BlockType.BulletedListBlock]: getBulletedDataFromEditor,
-      [BlockType.NumberedListBlock]: getNumberedListDataFromEditor,
-      [BlockType.ToggleListBlock]: getToggleListDataFromEditor,
-      [BlockType.CalloutBlock]: getCalloutDataFromEditor,
-    }).map(([type, getData]) => {
-      const blockType = type as BlockType;
-      const triggerKey = keyBoardEventKeyMap.Space;
-      return {
-        triggerEventKey: keyBoardEventKeyMap.Space,
-        canHandle: canHandle(blockType, triggerKey),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          if (!controller) return;
-          const [_event, editor] = args;
-          const data = getData(editor);
-          if (!data) return;
-          dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
-        },
-      };
-    });
-    return [
-      ...spaceTriggerEvents,
-      {
-        triggerEventKey: keyBoardEventKeyMap.Reduce,
-        canHandle: canHandle(BlockType.DividerBlock, keyBoardEventKeyMap.Reduce),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          if (!controller) return;
-          const [_event, editor] = args;
-          const delta = getDeltaAfterSelection(editor) || [];
-          dispatch(turnToDividerBlockThunk({ id, controller, delta }));
-        },
-      },
-      {
-        triggerEventKey: keyBoardEventKeyMap.Backquote,
-        canHandle: canHandle(BlockType.CodeBlock, keyBoardEventKeyMap.Backquote),
-        handler: (...args: TextBlockKeyEventHandlerParams) => {
-          if (!controller) return;
-          const [_event, editor] = args;
-          const data = getCodeBlockDataFromEditor(editor);
-          dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
-        },
-      },
-    ];
-  }, [controller, dispatch, id]);
-
-  return {
-    turnIntoBlockEvents,
-  };
-}
-
-function canHandle(type: BlockType, triggerKey: string) {
-  const config = blockConfig[type];
-
-  const regex = config.markdownRegexps;
-  // This error will be thrown if the block type is not in the config, and it will happen in development environment
-  if (!regex) {
-    throw new Error(`canHandle: block type ${type} is not supported`);
-  }
-
-  return (...args: TextBlockKeyEventHandlerParams) => {
-    const [event, editor] = args;
-    const isTrigger = event.key === triggerKey;
-    const selection = editor.selection;
-
-    if (!isTrigger || !selection) {
-      return false;
-    }
-
-    const flag = Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
-    if (flag === null) return false;
-
-    return regex.some((r) => r.test(`${flag}${triggerKey}`));
-  };
-}

+ 19 - 21
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx

@@ -1,34 +1,32 @@
-import { Slate, Editable } from 'slate-react';
-import Leaf from './Leaf';
-import { useTextBlock } from './TextBlock.hooks';
 import React from 'react';
 import React from 'react';
 import { NestedBlock } from '$app/interfaces/document';
 import { NestedBlock } from '$app/interfaces/document';
+import Editor from '../_shared/SlateEditor/TextEditor';
+import { useChange } from '$app/components/document/_shared/EditorHooks/useChange';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
+import { useKeyDown } from '$app/components/document/TextBlock/useKeyDown';
+import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection';
 
 
-function TextBlock({
-  node,
-  childIds,
-  placeholder,
-  className = '',
-}: {
+interface Props {
   node: NestedBlock;
   node: NestedBlock;
   childIds?: string[];
   childIds?: string[];
   placeholder?: string;
   placeholder?: string;
-  className?: string;
-}) {
-  const { editor, value, onChange, ...rest } = useTextBlock(node.id);
+}
+function TextBlock({ node, childIds, placeholder }: Props) {
+  const { value, onChange } = useChange(node);
+  const { onSelectionChange, selection, lastSelection } = useSelection(node.id);
+  const { onKeyDown } = useKeyDown(node.id);
 
 
   return (
   return (
     <>
     <>
-      <div className={`px-1 py-[2px] ${className}`}>
-        <Slate editor={editor} onChange={onChange} value={value}>
-          <Editable
-            {...rest}
-            renderLeaf={(leafProps) => <Leaf {...leafProps} />}
-            placeholder={placeholder || 'Please enter some text...'}
-          />
-        </Slate>
-      </div>
+      <Editor
+        value={value}
+        onChange={onChange}
+        onSelectionChange={onSelectionChange}
+        selection={selection}
+        lastSelection={lastSelection}
+        onKeyDown={onKeyDown}
+        placeholder={placeholder}
+      />
       <NodeChildren className='pl-[1.5em]' childIds={childIds} />
       <NodeChildren className='pl-[1.5em]' childIds={childIds} />
     </>
     </>
   );
   );

+ 105 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts

@@ -0,0 +1,105 @@
+import { useCallback, useContext, useMemo } from 'react';
+import { Keyboard } from '$app/constants/document/keyboard';
+import isHotkey from 'is-hotkey';
+import { useAppDispatch } from '@/appflowy_app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import {
+  enterActionForBlockThunk,
+  tabActionForBlockThunk,
+  shiftTabActionForBlockThunk,
+} from '$app_reducers/document/async-actions';
+import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents';
+import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents';
+
+export function useKeyDown(id: string) {
+  const controller = useContext(DocumentControllerContext);
+  const dispatch = useAppDispatch();
+  const turnIntoEvents = useTurnIntoBlockEvents(id);
+  const commonKeyEvents = useCommonKeyEvents(id);
+  const interceptEvents = useMemo(() => {
+    return [
+      ...commonKeyEvents,
+      {
+        // Prevent all enter key
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return e.key === Keyboard.keys.ENTER;
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+        },
+      },
+      {
+        // handle enter key and no other key is pressed
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return isHotkey(Keyboard.keys.ENTER, e);
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          if (!controller) return;
+          dispatch(
+            enterActionForBlockThunk({
+              id,
+              controller,
+            })
+          );
+        },
+      },
+
+      {
+        // Prevent tab key from indenting
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return e.key === Keyboard.keys.TAB;
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+        },
+      },
+      {
+        // handle tab key and no other key is pressed
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return isHotkey(Keyboard.keys.TAB, e);
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          if (!controller) return;
+          dispatch(
+            tabActionForBlockThunk({
+              id,
+              controller,
+            })
+          );
+        },
+      },
+      {
+        // handle shift + tab key and no other key is pressed
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return isHotkey(Keyboard.keys.SHIFT_TAB, e);
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          if (!controller) return;
+          dispatch(
+            shiftTabActionForBlockThunk({
+              id,
+              controller,
+            })
+          );
+        },
+      },
+
+      ...turnIntoEvents,
+    ];
+  }, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
+
+  const onKeyDown = useCallback(
+    (e: React.KeyboardEvent<HTMLDivElement>) => {
+
+      e.stopPropagation();
+
+      const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
+      filteredEvents.forEach((event) => event.handler(e));
+    },
+    [interceptEvents]
+  );
+
+  return {
+    onKeyDown,
+  };
+}

+ 185 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts

@@ -0,0 +1,185 @@
+import { useCallback, useContext, useMemo } from 'react';
+import { BlockType } from '$app/interfaces/document';
+import { useAppDispatch } from '$app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { turnToBlockThunk } from '$app_reducers/document/async-actions';
+import { blockConfig } from '$app/constants/document/config';
+
+import Delta, { Op } from 'quill-delta';
+import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
+import isHotkey from 'is-hotkey';
+import { slashCommandActions } from '$app_reducers/document/slice';
+import { Keyboard } from '$app/constants/document/keyboard';
+import { getDeltaText } from '$app/utils/document/delta';
+
+export function useTurnIntoBlockEvents(id: string) {
+  const controller = useContext(DocumentControllerContext);
+  const dispatch = useAppDispatch();
+  const rangeRef = useRangeRef();
+
+  const getFlag = useCallback(() => {
+    const range = rangeRef.current?.caret;
+    if (!range || range.id !== id) return;
+    const node = getBlock(id);
+    const delta = new Delta(node.data.delta || []);
+    const flag = getDeltaText(delta.slice(0, range.index));
+    return flag;
+  }, [id, rangeRef]);
+
+  const getDeltaContent = useCallback(() => {
+    const range = rangeRef.current?.caret;
+    if (!range || range.id !== id) return;
+    const node = getBlock(id);
+    const delta = new Delta(node.data.delta || []);
+    const content = delta.slice(range.index);
+    return new Delta(content);
+  }, [id, rangeRef]);
+
+  const canHandle = useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
+      {
+        const config = blockConfig[type];
+
+        const regex = config.markdownRegexps;
+        // This error will be thrown if the block type is not in the config, and it will happen in development environment
+        if (!regex) {
+          throw new Error(`canHandle: block type ${type} is not supported`);
+        }
+
+        const isTrigger = event.key === triggerKey;
+
+        if (!isTrigger) {
+          return false;
+        }
+        const flag = getFlag();
+        if (!flag) return false;
+
+        return regex.some((r) => r.test(`${flag}${triggerKey}`));
+      }
+    },
+    [getFlag]
+  );
+
+  const getTurnIntoBlockDelta = useCallback(() => {
+    const content = getDeltaContent();
+    if (!content) return;
+    return {
+      delta: content.ops,
+    };
+  }, [getDeltaContent]);
+
+  const spaceTriggerMap = useMemo(() => {
+    return {
+      [BlockType.HeadingBlock]: () => {
+        const flag = getFlag();
+        if (!flag) return;
+        return {
+          level: flag.match(/#/g)?.length,
+          ...getTurnIntoBlockDelta(),
+        };
+      },
+      [BlockType.TodoListBlock]: () => {
+        const flag = getFlag();
+        if (!flag) return;
+
+        return {
+          checked: flag.includes('[x]'),
+          ...getTurnIntoBlockDelta(),
+        };
+      },
+      [BlockType.QuoteBlock]: getTurnIntoBlockDelta,
+      [BlockType.BulletedListBlock]: getTurnIntoBlockDelta,
+      [BlockType.NumberedListBlock]: getTurnIntoBlockDelta,
+      [BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
+      [BlockType.CalloutBlock]: () => {
+        const flag = getFlag();
+        if (!flag) return;
+        const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
+        if (!tag) return;
+        const iconMap: Record<string, string> = {
+          TIP: '💡',
+          INFO: '❗',
+          WARNING: '⚠️',
+          DANGER: '‼️',
+        };
+        return {
+          icon: iconMap[tag],
+          ...getTurnIntoBlockDelta(),
+        };
+      },
+    };
+  }, [getFlag, getTurnIntoBlockDelta]);
+
+  const turnIntoBlockEvents = useMemo(() => {
+    const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
+      const blockType = type as BlockType;
+      const triggerKey = Keyboard.keys.Space;
+
+      return {
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType, triggerKey),
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          if (!controller) return;
+          const data = getData();
+          if (!data) return;
+          dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
+        },
+      };
+    });
+    return [
+      ...spaceTriggerEvents,
+      {
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
+          canHandle(e, BlockType.DividerBlock, Keyboard.keys.Reduce),
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          if (!controller) return;
+          const delta = getDeltaContent();
+
+          dispatch(
+            turnToBlockThunk({
+              id,
+              controller,
+              type: BlockType.DividerBlock,
+              data: {
+                delta: delta?.ops as Op[],
+              },
+            })
+          );
+        },
+      },
+      {
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
+          canHandle(e, BlockType.CodeBlock, Keyboard.keys.BackQuote),
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          if (!controller) return;
+          const defaultData = blockConfig[BlockType.CodeBlock].defaultData;
+          const data = {
+            ...defaultData,
+            delta: getDeltaContent()?.ops as Op[],
+          };
+          dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
+        },
+      },
+      {
+        // Here custom slash key event for TextBlock
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          const flag = getFlag();
+          return isHotkey('/', e) && flag === '';
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          if (!controller) return;
+          dispatch(
+            slashCommandActions.openSlashCommand({
+              blockId: id,
+            })
+          );
+        },
+      },
+    ];
+  }, [canHandle, controller, dispatch, getDeltaContent, getFlag, id, spaceTriggerMap]);
+
+  return turnIntoBlockEvents;
+}

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts

@@ -1,7 +1,7 @@
 import { useAppDispatch } from '$app/stores/store';
 import { useAppDispatch } from '$app/stores/store';
 import { useCallback, useContext } from 'react';
 import { useCallback, useContext } from 'react';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/text/update';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
 import { BlockData, BlockType } from '$app/interfaces/document';
 import { BlockData, BlockType } from '$app/interfaces/document';
 import isHotkey from 'is-hotkey';
 import isHotkey from 'is-hotkey';
 
 

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts

@@ -1,7 +1,7 @@
 import { useAppDispatch } from '$app/stores/store';
 import { useAppDispatch } from '$app/stores/store';
 import { useCallback, useContext } from 'react';
 import { useCallback, useContext } from 'react';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/text/update';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
 import { BlockData, BlockType } from '$app/interfaces/document';
 import { BlockData, BlockType } from '$app/interfaces/document';
 import isHotkey from 'is-hotkey';
 import isHotkey from 'is-hotkey';
 
 

+ 2 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx

@@ -1,7 +1,7 @@
 import { useVirtualizer } from '@tanstack/react-virtual';
 import { useVirtualizer } from '@tanstack/react-virtual';
 import { useRef } from 'react';
 import { useRef } from 'react';
 
 
-const defaultSize = 60;
+const defaultSize = 30;
 
 
 export function useVirtualizedList(count: number) {
 export function useVirtualizedList(count: number) {
   const parentRef = useRef<HTMLDivElement>(null);
   const parentRef = useRef<HTMLDivElement>(null);
@@ -9,6 +9,7 @@ export function useVirtualizedList(count: number) {
   const virtualize = useVirtualizer({
   const virtualize = useVirtualizer({
     count,
     count,
     getScrollElement: () => parentRef.current,
     getScrollElement: () => parentRef.current,
+    overscan: 5,
     estimateSize: () => {
     estimateSize: () => {
       return defaultSize;
       return defaultSize;
     },
     },

+ 6 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx

@@ -43,7 +43,12 @@ export default function VirtualizedList({
               {virtualItems.map((virtualRow) => {
               {virtualItems.map((virtualRow) => {
                 const id = childIds[virtualRow.index];
                 const id = childIds[virtualRow.index];
                 return (
                 return (
-                  <div className='pt-0.5' key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
+                  <div
+                    className='mt-[-0.5px] pt-[0.5px]'
+                    key={id}
+                    data-index={virtualRow.index}
+                    ref={virtualize.measureElement}
+                  >
                     {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
                     {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
                     {renderNode(id)}
                     {renderNode(id)}
                   </div>
                   </div>

+ 33 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts

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

+ 79 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts

@@ -0,0 +1,79 @@
+import isHotkey from 'is-hotkey';
+import { Keyboard } from '$app/constants/document/keyboard';
+import {
+  backspaceDeleteActionForBlockThunk,
+  leftActionForBlockThunk,
+  rightActionForBlockThunk,
+  upDownActionForBlockThunk,
+} from '$app_reducers/document/async-actions';
+import { useContext, useMemo } from 'react';
+import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useAppDispatch } from '$app/stores/store';
+
+export function useCommonKeyEvents(id: string) {
+  const { focused, caretRef } = useFocused(id);
+  const controller = useContext(DocumentControllerContext);
+  const dispatch = useAppDispatch();
+  const commonKeyEvents = useMemo(() => {
+    return [
+      {
+        // handle backspace and delete key and the caret is at the beginning of the block
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return (
+            (isHotkey(Keyboard.keys.BACKSPACE, e) || isHotkey(Keyboard.keys.DELETE, e)) &&
+            focused &&
+            caretRef.current?.index === 0 &&
+            caretRef.current?.length === 0
+          );
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          if (!controller) return;
+          dispatch(backspaceDeleteActionForBlockThunk({ id, controller }));
+        },
+      },
+      {
+        // handle up arrow key and no other key is pressed
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return isHotkey(Keyboard.keys.UP, e);
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          dispatch(upDownActionForBlockThunk({ id }));
+        },
+      },
+      {
+        // handle down arrow key and no other key is pressed
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return isHotkey(Keyboard.keys.DOWN, e);
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          dispatch(upDownActionForBlockThunk({ id, down: true }));
+        },
+      },
+      {
+        // handle left arrow key and no other key is pressed
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return isHotkey(Keyboard.keys.LEFT, e);
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          dispatch(leftActionForBlockThunk({ id }));
+        },
+      },
+      {
+        // handle right arrow key and no other key is pressed
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return isHotkey(Keyboard.keys.RIGHT, e);
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          e.preventDefault();
+          dispatch(rightActionForBlockThunk({ id }));
+        },
+      },
+    ];
+  }, [caretRef, controller, dispatch, focused, id]);
+  return commonKeyEvents;
+}

+ 43 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts

@@ -0,0 +1,43 @@
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useAppDispatch } from '$app/stores/store';
+import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions';
+import Delta from 'quill-delta';
+
+export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) {
+  const controller = useContext(DocumentControllerContext);
+  const dispatch = useAppDispatch();
+  const penddingRef = useRef(false);
+  const { node } = useSubscribeNode(id);
+
+  const delta = useMemo(() => {
+    if (!node || !node.data.delta) return new Delta();
+    return new Delta(node.data.delta);
+  }, [node]);
+
+  useEffect(() => {
+    onDeltaChange?.(delta);
+  }, [delta, onDeltaChange]);
+
+  const update = useCallback(
+    async (delta: Delta) => {
+      if (!controller) return;
+      await dispatch(
+        updateNodeDeltaThunk({
+          id,
+          delta: delta.ops,
+          controller,
+        })
+      );
+      // reset pendding flag
+      penddingRef.current = false;
+    },
+    [controller, dispatch, id]
+  );
+
+  return {
+    update,
+    delta,
+  };
+}

+ 55 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts

@@ -0,0 +1,55 @@
+import { useCallback, useEffect, useState } from 'react';
+import { RangeStatic } from 'quill';
+import { useAppDispatch } from '$app/stores/store';
+import { rangeActions } from '$app_reducers/document/slice';
+import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
+
+export function useSelection(id: string) {
+  const rangeRef = useRangeRef();
+  const { focusCaret, lastSelection } = useFocused(id);
+  const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
+  const dispatch = useAppDispatch();
+
+  const storeRange = useCallback(
+    (range: RangeStatic) => {
+      dispatch(storeRangeThunk({ id, range }));
+    },
+    [id, dispatch]
+  );
+
+  const onSelectionChange = useCallback(
+    (range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => {
+      if (!range) return;
+
+      dispatch(
+        rangeActions.setCaret({
+          id,
+          index: range.index,
+          length: range.length,
+        })
+      );
+      storeRange(range);
+    },
+    [id, dispatch, storeRange]
+  );
+
+  useEffect(() => {
+    if (rangeRef.current && rangeRef.current?.isDragging) return;
+    const caret = focusCaret;
+    if (!caret) {
+      return;
+    }
+
+    setSelection({
+      index: caret.index,
+      length: caret.length,
+    });
+  }, [rangeRef, focusCaret]);
+
+  return {
+    onSelectionChange,
+    selection,
+    lastSelection,
+  };
+}

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

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

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

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

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

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

+ 34 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx

@@ -0,0 +1,34 @@
+import React from 'react';
+import { CodeEditorProps } from '$app/interfaces/document';
+import { Editable, Slate } from 'slate-react';
+import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
+import { decorateCode } from '$app/components/document/_shared/SlateEditor/decorateCode';
+import { CodeLeaf, CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements';
+
+function CodeEditor({ language, ...props }: CodeEditorProps) {
+  const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor({
+    ...props,
+    isCodeBlock: true,
+  });
+
+  return (
+    <div ref={ref}>
+      <Slate editor={editor} onChange={onChange} value={value}>
+        <Editable
+          decorate={(entry) => {
+            const codeRange = decorateCode(entry, language);
+            const range = decorate?.(entry) || [];
+            return [...range, ...codeRange];
+          }}
+          renderLeaf={CodeLeaf}
+          renderElement={CodeBlockElement}
+          onKeyDown={onKeyDown}
+          onDOMBeforeInput={onDOMBeforeInput}
+          onBlur={onBlur}
+        />
+      </Slate>
+    </div>
+  );
+}
+
+export default React.memo(CodeEditor);

+ 4 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/elements.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx

@@ -5,10 +5,10 @@ interface CodeLeafProps extends RenderLeafProps {
   leaf: BaseText & {
   leaf: BaseText & {
     bold?: boolean;
     bold?: boolean;
     italic?: boolean;
     italic?: boolean;
-    underlined?: boolean;
+    underline?: boolean;
     strikethrough?: boolean;
     strikethrough?: boolean;
     prism_token?: string;
     prism_token?: string;
-    selectionHighlighted?: boolean;
+    selection_high_lighted?: boolean;
   };
   };
 }
 }
 
 
@@ -24,7 +24,7 @@ export const CodeLeaf = (props: CodeLeafProps) => {
     newChildren = <em>{newChildren}</em>;
     newChildren = <em>{newChildren}</em>;
   }
   }
 
 
-  if (leaf.underlined) {
+  if (leaf.underline) {
     newChildren = <u>{newChildren}</u>;
     newChildren = <u>{newChildren}</u>;
   }
   }
 
 
@@ -32,7 +32,7 @@ export const CodeLeaf = (props: CodeLeafProps) => {
     'token',
     'token',
     leaf.prism_token && leaf.prism_token,
     leaf.prism_token && leaf.prism_token,
     leaf.strikethrough && 'line-through',
     leaf.strikethrough && 'line-through',
-    leaf.selectionHighlighted && 'bg-main-secondary',
+    leaf.selection_high_lighted && 'bg-main-secondary',
   ].filter(Boolean);
   ].filter(Boolean);
 
 
   return (
   return (

+ 28 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+import { EditorProps } from '$app/interfaces/document';
+import { Editable, Slate } from 'slate-react';
+import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
+import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf';
+import { TextElement } from '$app/components/document/_shared/SlateEditor/TextElement';
+
+function TextEditor({ placeholder = "Type '/' for commands", ...props }: EditorProps) {
+  const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor(props);
+
+  return (
+    <div ref={ref} className={'py-0.5'}>
+      <Slate editor={editor} onChange={onChange} value={value}>
+        <Editable
+          onKeyDown={onKeyDown}
+          onDOMBeforeInput={onDOMBeforeInput}
+          decorate={decorate}
+          renderLeaf={TextLeaf}
+          placeholder={placeholder}
+          onBlur={onBlur}
+          renderElement={TextElement}
+        />
+      </Slate>
+    </div>
+  );
+}
+
+export default React.memo(TextEditor);

+ 65 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx

@@ -0,0 +1,65 @@
+import { RenderElementProps } from 'slate-react';
+import React, { useEffect, useRef } from 'react';
+
+export function TextElement(props: RenderElementProps) {
+  const ref = useRef<HTMLDivElement | null>(null);
+  useEffect(() => {
+    if (!ref.current) return;
+    amendCodeLeafs(ref.current);
+  });
+
+  return (
+    <div
+      {...props.attributes}
+      ref={(e) => {
+        ref.current = e;
+        props.attributes.ref(e);
+      }}
+    >
+      {props.children}
+    </div>
+  );
+}
+
+function amendCodeLeafs(textElement: Element) {
+  const leafNodes = textElement.querySelectorAll(`[data-slate-leaf="true"]`);
+  let codeLeafNodes: Element[] = [];
+  leafNodes?.forEach((leafNode, index) => {
+    const isCodeLeaf = leafNode.classList.contains('inline-code');
+    if (isCodeLeaf) {
+      codeLeafNodes.push(leafNode);
+    } else {
+      if (codeLeafNodes.length > 0) {
+        addStyleToCodeLeafs(codeLeafNodes);
+        codeLeafNodes = [];
+      }
+    }
+    if (codeLeafNodes.length > 0 && index === leafNodes.length - 1) {
+      addStyleToCodeLeafs(codeLeafNodes);
+      codeLeafNodes = [];
+    }
+  });
+}
+
+function addStyleToCodeLeafs(codeLeafs: Element[]) {
+  if (codeLeafs.length === 0) return;
+  if (codeLeafs.length === 1) {
+    const codeNode = codeLeafs[0].firstChild as Element;
+    codeNode.classList.add('rounded', 'px-1.5');
+    return;
+  }
+  codeLeafs.forEach((codeLeaf, index) => {
+    const codeNode = codeLeaf.firstChild as Element;
+    codeNode.classList.remove('rounded', 'px-1.5');
+    codeNode.classList.remove('rounded-l', 'pl-1.5');
+    codeNode.classList.remove('rounded-r', 'pr-1.5');
+    if (index === 0) {
+      codeNode.classList.add('rounded-l', 'pl-1.5');
+      return;
+    }
+    if (index === codeLeafs.length - 1) {
+      codeNode.classList.add('rounded-r', 'pr-1.5');
+      return;
+    }
+  });
+}

+ 61 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx

@@ -0,0 +1,61 @@
+import { RenderLeafProps } from 'slate-react';
+import { BaseText } from 'slate';
+import { useRef } from 'react';
+
+interface TextLeafProps extends RenderLeafProps {
+  leaf: BaseText & {
+    bold?: boolean;
+    italic?: boolean;
+    underline?: boolean;
+    strikethrough?: boolean;
+    code?: string;
+    selection_high_lighted?: boolean;
+  };
+}
+
+const TextLeaf = (props: TextLeafProps) => {
+  const { attributes, children, leaf } = props;
+
+  const ref = useRef<HTMLSpanElement>(null);
+
+  let newChildren = children;
+  if (leaf.bold) {
+    newChildren = <strong>{children}</strong>;
+  }
+
+  if (leaf.italic) {
+    newChildren = <em>{newChildren}</em>;
+  }
+
+  if (leaf.underline) {
+    newChildren = <u>{newChildren}</u>;
+  }
+
+  if (leaf.code) {
+    newChildren = (
+      <span
+        className={`bg-custom-code text-main-hovered`}
+        style={{
+          fontSize: '85%',
+          lineHeight: 'normal',
+        }}
+      >
+        {newChildren}
+      </span>
+    );
+  }
+
+  const className = [
+    leaf.strikethrough && 'line-through',
+    leaf.selection_high_lighted && 'bg-main-secondary',
+    leaf.code && 'inline-code',
+  ].filter(Boolean);
+
+  return (
+    <span ref={ref} {...attributes} className={className.join(' ')}>
+      {newChildren}
+    </span>
+  );
+};
+
+export default TextLeaf;

+ 2 - 25
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/decorate.ts → frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/decorateCode.ts

@@ -1,34 +1,11 @@
 import Prism from 'prismjs';
 import Prism from 'prismjs';
 import 'prismjs/themes/prism.css';
 import 'prismjs/themes/prism.css';
-import 'prismjs/components/prism-bash';
-import 'prismjs/components/prism-c';
-import 'prismjs/components/prism-cpp';
-import 'prismjs/components/prism-csharp';
-import 'prismjs/components/prism-css';
-import 'prismjs/components/prism-dart';
-import 'prismjs/components/prism-docker';
-import 'prismjs/components/prism-go';
-import 'prismjs/components/prism-graphql';
-import 'prismjs/components/prism-groovy';
-import 'prismjs/components/prism-http';
-import 'prismjs/components/prism-java';
 import 'prismjs/components/prism-javascript';
 import 'prismjs/components/prism-javascript';
 import 'prismjs/components/prism-json';
 import 'prismjs/components/prism-json';
-import 'prismjs/components/prism-less';
 import 'prismjs/components/prism-typescript';
 import 'prismjs/components/prism-typescript';
-import 'prismjs/components/prism-markdown';
-import 'prismjs/components/prism-python';
-import 'prismjs/components/prism-yaml';
-import 'prismjs/components/prism-regex';
-import 'prismjs/components/prism-ruby';
 import 'prismjs/components/prism-rust';
 import 'prismjs/components/prism-rust';
-import 'prismjs/components/prism-sass';
-import 'prismjs/components/prism-swift';
-import 'prismjs/components/prism-php';
-import 'prismjs/components/prism-sql';
-import 'prismjs/components/prism-visual-basic';
 
 
-import { BaseRange, NodeEntry, Text, Path, Range, Editor } from 'slate';
+import { BaseRange, NodeEntry, Text, Path } from 'slate';
 
 
 const push_string = (
 const push_string = (
   token: string | Prism.Token,
   token: string | Prism.Token,
@@ -75,7 +52,7 @@ const recurseTokenize = (
   }
   }
 };
 };
 
 
-export const decorateCodeFunc = ([node, path]: NodeEntry, language: string) => {
+export const decorateCode = ([node, path]: NodeEntry, language: string) => {
   const ranges: BaseRange[] = [];
   const ranges: BaseRange[] = [];
   if (!Text.isText(node)) {
   if (!Text.isText(node)) {
     return ranges;
     return ranges;

+ 142 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts

@@ -0,0 +1,142 @@
+import { EditorProps } from "$app/interfaces/document";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { ReactEditor } from "slate-react";
+import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection } from "slate";
+import {
+  converToIndexLength,
+  convertToDelta,
+  convertToSlateSelection,
+  indent,
+  outdent
+} from "$app/utils/document/slate_editor";
+import { focusNodeByIndex } from "$app/utils/document/node";
+import { Keyboard } from "$app/constants/document/keyboard";
+import Delta from "quill-delta";
+import isHotkey from "is-hotkey";
+import { useSlateYjs } from "$app/components/document/_shared/SlateEditor/useSlateYjs";
+
+export function useEditor({
+  onChange,
+  onSelectionChange,
+  selection,
+  value: delta,
+  lastSelection,
+  onKeyDown,
+  isCodeBlock,
+}: EditorProps) {
+  const editor = useSlateYjs({ delta });
+  const ref = useRef<HTMLDivElement | null>(null);
+
+  const newValue = useMemo(() => [], []);
+  const onSelectionChangeHandler = useCallback(
+    (slateSelection: Selection) => {
+      const rangeStatic = converToIndexLength(editor, slateSelection);
+      onSelectionChange?.(rangeStatic, null);
+    },
+    [editor, onSelectionChange]
+  );
+
+  const onChangeHandler = useCallback(
+    (slateValue: Descendant[]) => {
+      const oldContents = delta || new Delta();
+      onChange?.(convertToDelta(slateValue), oldContents);
+      onSelectionChangeHandler(editor.selection);
+    },
+    [delta, editor.selection, onChange, onSelectionChangeHandler]
+  );
+
+  const onDOMBeforeInput = useCallback((e: InputEvent) => {
+    // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
+    // It will cause repeated characters when inputting Chinese.
+    // Here, prevent the beforeInput event and wait for the compositionend event to take effect.
+    if (e.inputType === 'insertFromComposition') {
+      e.preventDefault();
+    }
+  }, []);
+
+  const decorate = useCallback(
+    (entry: NodeEntry) => {
+      const [node, path] = entry;
+      if (!lastSelection) return [];
+      const slateSelection = convertToSlateSelection(lastSelection.index, lastSelection.length, editor.children);
+      if (slateSelection && !Range.isCollapsed(slateSelection as BaseRange)) {
+        const intersection = Range.intersection(slateSelection, Editor.range(editor, path));
+
+        if (!intersection) {
+          return [];
+        }
+        const range = {
+          selection_high_lighted: true,
+          ...intersection,
+        };
+
+        return [range];
+      }
+      return [];
+    },
+    [editor, lastSelection]
+  );
+
+  const onKeyDownRewrite = useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>) => {
+      onKeyDown?.(event);
+      const insertBreak = () => {
+        event.preventDefault();
+        editor.insertText('\n');
+      };
+      // There is different behavior for code block and normal text
+      // In code block, we press enter to insert a new line
+      // In normal text, we press shift + enter to insert a new line
+      if (isCodeBlock) {
+        if (isHotkey(Keyboard.keys.ENTER, event)) {
+          insertBreak();
+          return;
+        }
+        if (isHotkey(Keyboard.keys.TAB, event)) {
+          event.preventDefault();
+          indent(editor, 2);
+          return;
+        }
+        if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
+          event.preventDefault();
+          outdent(editor, 2);
+          return;
+        }
+      } else if (isHotkey(Keyboard.keys.SHIFT_ENTER, event)) {
+        insertBreak();
+      }
+    },
+    [editor, onKeyDown, isCodeBlock]
+  );
+
+
+  const onBlur = useCallback(
+    (_event: React.FocusEvent<HTMLDivElement>) => {
+      editor.deselect();
+    },
+    [editor]
+  );
+
+  useEffect(() => {
+    if (!selection || !ref.current) return;
+    const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
+    if (!slateSelection) return;
+    const isFocused = ReactEditor.isFocused(editor);
+
+    if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
+
+    focusNodeByIndex(ref.current, selection.index, selection.length);
+  }, [editor, selection]);
+
+  return {
+    editor,
+    value: newValue,
+    onChange: onChangeHandler,
+    onDOMBeforeInput,
+    decorate,
+    ref,
+    onKeyDown: onKeyDownRewrite,
+    onBlur,
+  };
+}
+

+ 43 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts

@@ -0,0 +1,43 @@
+import Delta from "quill-delta";
+import { useEffect, useMemo, useRef } from "react";
+import * as Y from "yjs";
+import { convertToSlateValue } from "$app/utils/document/slate_editor";
+import { slateNodesToInsertDelta, withYjs, YjsEditor } from "@slate-yjs/core";
+import { withReact } from "slate-react";
+import { createEditor } from "slate";
+
+export function useSlateYjs({ delta }: { delta?: Delta }) {
+  const yTextRef = useRef<Y.Text>();
+  const sharedType = useMemo(() => {
+    const yDoc = new Y.Doc();
+    const sharedType = yDoc.get("content", Y.XmlText) as Y.XmlText;
+    const value = convertToSlateValue(delta || new Delta());
+    const insertDelta = slateNodesToInsertDelta(value);
+    sharedType.applyDelta(insertDelta);
+    yTextRef.current = insertDelta[0].insert as Y.Text;
+    return sharedType;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
+
+  // Connect editor in useEffect to comply with concurrent mode requirements.
+  useEffect(() => {
+    YjsEditor.connect(editor);
+    return () => {
+      yTextRef.current = undefined;
+      YjsEditor.disconnect(editor);
+    };
+  }, [editor]);
+
+  useEffect(() => {
+    const yText = yTextRef.current;
+    if (!yText) return;
+    const oldContents = new Delta(yText.toDelta());
+    const diffDelta = oldContents.diff(delta || new Delta());
+    if (diffDelta.ops.length === 0) return;
+    yText.applyDelta(diffDelta.ops);
+  }, [delta, editor]);
+
+  return editor;
+}

+ 5 - 56
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts

@@ -1,8 +1,6 @@
-import { useAppSelector } from '@/appflowy_app/stores/store';
-import { useMemo, useRef } from 'react';
-import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
-import { nodeInRange } from '$app/utils/document/blocks/common';
-import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
+import { store, useAppSelector } from '@/appflowy_app/stores/store';
+import { useEffect, useMemo, useRef } from 'react';
+import { Node } from '$app/interfaces/document';
 
 
 /**
 /**
  * Subscribe node information
  * Subscribe node information
@@ -34,55 +32,6 @@ export function useSubscribeNode(id: string) {
   };
   };
 }
 }
 
 
-/**
- * Subscribe selection information
- * @param id
- */
-export function useSubscribeRangeSelection(id: string) {
-  const rangeRef = useRef<RangeSelectionState>();
-
-  const currentSelection = useAppSelector((state) => {
-    const range = state.documentRangeSelection;
-    rangeRef.current = range;
-    if (range.anchor?.id === id) {
-      return range.anchor.selection;
-    }
-    if (range.focus?.id === id) {
-      return range.focus.selection;
-    }
-
-    return getAmendInRangeNodeSelection(id, range, state.document);
-  });
-
-  return {
-    rangeRef,
-    currentSelection,
-  };
-}
-
-function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) {
-  if (!range.anchor || !range.focus || range.anchor.id === range.focus.id || range.isForward === undefined) {
-    return null;
-  }
-
-  const isNodeInRange = nodeInRange(
-    id,
-    {
-      startId: range.anchor.id,
-      endId: range.focus.id,
-    },
-    range.isForward,
-    document
-  );
-
-  if (isNodeInRange) {
-    const delta = document.nodes[id].data.delta;
-    return {
-      anchor: {
-        path: [0, 0],
-        offset: 0,
-      },
-      focus: getNodeEndSelection(delta).anchor,
-    };
-  }
+export function getBlock(id: string) {
+  return store.getState().document.nodes[id];
 }
 }

+ 43 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts

@@ -0,0 +1,43 @@
+import { useAppSelector } from '$app/stores/store';
+import { RangeState, RangeStatic } from '$app/interfaces/document';
+import { useMemo, useRef } from 'react';
+
+export function useFocused(id: string) {
+  const caretRef = useRef<RangeStatic>();
+  const focusCaret = useAppSelector((state) => {
+    const currentCaret = state.documentRange.caret;
+    caretRef.current = currentCaret;
+    if (currentCaret?.id === id) {
+      return currentCaret;
+    }
+    return null;
+  });
+
+  const lastSelection = useAppSelector((state) => {
+    return state.documentRange.ranges[id];
+  });
+
+  const focused = useMemo(() => {
+    return focusCaret && focusCaret?.id === id;
+  }, [focusCaret, id]);
+
+  const memoizedLastSelection = useMemo(() => {
+    return lastSelection;
+  }, [JSON.stringify(lastSelection)]);
+
+  return {
+    focused,
+    caretRef,
+    focusCaret,
+    lastSelection: memoizedLastSelection,
+  };
+}
+
+export function useRangeRef() {
+  const rangeRef = useRef<RangeState>();
+  useAppSelector((state) => {
+    const currentRange = state.documentRange;
+    rangeRef.current = currentRange;
+  });
+  return rangeRef;
+}

+ 0 - 111
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts

@@ -1,111 +0,0 @@
-import { useAppDispatch } from '$app/stores/store';
-import { useCallback, useContext } from 'react';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { backspaceNodeThunk, setCursorNextLineThunk, setCursorPreLineThunk } from '$app_reducers/document/async-actions';
-import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-import {
-  canHandleBackspaceKey,
-  canHandleDownKey,
-  canHandleLeftKey,
-  canHandleRightKey,
-  canHandleUpKey,
-} from '$app/utils/document/blocks/text/hotkey';
-import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
-import { ReactEditor } from 'slate-react';
-
-export function useDefaultTextInputEvents(id: string) {
-  const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
-
-  const focusPreLineAction = useCallback(
-    async (params: { editor: ReactEditor; focusEnd?: boolean }) => {
-      await dispatch(setCursorPreLineThunk({ id, ...params }));
-    },
-    [dispatch, id]
-  );
-
-  const focusNextLineAction = useCallback(
-    async (params: { editor: ReactEditor; focusStart?: boolean }) => {
-      await dispatch(setCursorNextLineThunk({ id, ...params }));
-    },
-    [dispatch, id]
-  );
-  return [
-    {
-      triggerEventKey: keyBoardEventKeyMap.Up,
-      canHandle: canHandleUpKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e, _] = args;
-        e.preventDefault();
-        void focusPreLineAction({
-          editor: args[1],
-        });
-      },
-    },
-    {
-      triggerEventKey: keyBoardEventKeyMap.Down,
-      canHandle: canHandleDownKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e, _] = args;
-        e.preventDefault();
-        void focusNextLineAction({
-          editor: args[1],
-        });
-      },
-    },
-    {
-      triggerEventKey: keyBoardEventKeyMap.Left,
-      canHandle: canHandleLeftKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e, _] = args;
-        e.preventDefault();
-        void focusPreLineAction({
-          editor: args[1],
-          focusEnd: true,
-        });
-      },
-    },
-    {
-      triggerEventKey: keyBoardEventKeyMap.Right,
-      canHandle: canHandleRightKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e, _] = args;
-        e.preventDefault();
-        void focusNextLineAction({
-          editor: args[1],
-          focusStart: true,
-        });
-      },
-    },
-    {
-      triggerEventKey: keyBoardEventKeyMap.Backspace,
-      canHandle: canHandleBackspaceKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e, editor] = args;
-        e.preventDefault();
-        void (async () => {
-          if (!controller) return;
-          await dispatch(backspaceNodeThunk({ id, controller, editor }));
-        })();
-      },
-    },
-    // Here prevent the default behavior of the enter key
-    {
-      triggerEventKey: keyBoardEventKeyMap.Enter,
-      canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Enter',
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e] = args;
-        e.preventDefault();
-      },
-    },
-    // Here prevent the default behavior of the tab key
-    {
-      triggerEventKey: keyBoardEventKeyMap.Tab,
-      canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Tab',
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e] = args;
-        e.preventDefault();
-      },
-    },
-  ];
-}

+ 0 - 134
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts

@@ -1,134 +0,0 @@
-import { createEditor, Descendant, Editor, Transforms } from 'slate';
-import { withReact } from 'slate-react';
-import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
-
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { TextDelta } from '$app/interfaces/document';
-import { useAppDispatch } from '$app/stores/store';
-import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
-import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
-import { isSameDelta } from '$app/utils/document/blocks/text/delta';
-import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
-import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
-
-export function useTextInput(id: string) {
-  const { node } = useSubscribeNode(id);
-
-  const [editor] = useState(() => withReact(createEditor()));
-  const isComposition = useRef(false);
-  const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
-
-  const delta = useMemo(() => {
-    if (!node || !('delta' in node.data)) {
-      return [];
-    }
-    return node.data.delta;
-  }, [node]);
-  const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
-
-  const { sync, receive } = useUpdateDelta(id, editor);
-
-  // Update the editor's value when the node's delta changes.
-  useEffect(() => {
-    // If composition is in progress, do nothing.
-    if (isComposition.current) return;
-    receive(delta, setValue);
-  }, [delta, receive]);
-
-  // Update the node's delta when the editor's value changes.
-  const onChange = useCallback(
-    (e: Descendant[]) => {
-      // Update the editor's value and selection.
-      setValue(e);
-      // If the selection is not null, update the last active selection.
-      if (editor.selection !== null) setLastActiveSelection(editor.selection);
-      // If composition is in progress, do nothing.
-      if (isComposition.current) return;
-      sync();
-    },
-    [editor.selection, setLastActiveSelection, sync]
-  );
-
-  const onDOMBeforeInput = useCallback((e: InputEvent) => {
-    // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
-    // It will cause repeated characters when inputting Chinese.
-    // Here, prevent the beforeInput event and wait for the compositionend event to take effect.
-    if (e.inputType === 'insertFromComposition') {
-      e.preventDefault();
-    }
-  }, []);
-
-  const onCompositionStart = useCallback(() => {
-    isComposition.current = true;
-  }, []);
-
-  const onCompositionUpdate = useCallback(() => {
-    isComposition.current = true;
-  }, []);
-
-  const onCompositionEnd = useCallback(() => {
-    isComposition.current = false;
-  }, []);
-
-  return {
-    editor,
-    onChange,
-    value,
-    ...selectionProps,
-    onDOMBeforeInput,
-    onCompositionStart,
-    onCompositionUpdate,
-    onCompositionEnd,
-  };
-}
-
-function useUpdateDelta(id: string, editor: Editor) {
-  const controller = useContext(DocumentControllerContext);
-  const dispatch = useAppDispatch();
-  const penddingRef = useRef(false);
-
-  const update = useCallback(() => {
-    if (!controller) return;
-    const delta = slateValueToDelta(editor.children);
-    void (async () => {
-      await dispatch(
-        updateNodeDeltaThunk({
-          id,
-          delta,
-          controller,
-        })
-      );
-      // reset pendding flag
-      penddingRef.current = false;
-    })();
-  }, [controller, dispatch, editor, id]);
-
-  const sync = useCallback(() => {
-    // set pendding flag
-    penddingRef.current = true;
-    update();
-  }, [update]);
-
-  const receive = useCallback(
-    (delta: TextDelta[], setValue: (children: Descendant[]) => void) => {
-      // if pendding, do nothing
-      if (penddingRef.current) return;
-
-      // If the delta is the same as the editor's value, do nothing.
-      const localDelta = slateValueToDelta(editor.children);
-      const isSame = isSameDelta(delta, localDelta);
-      if (isSame) return;
-
-      Transforms.deselect(editor);
-      const slateValue = deltaToSlateValue(delta);
-      editor.children = slateValue;
-      setValue(slateValue);
-    },
-    [editor]
-  );
-
-  return {
-    sync,
-    receive,
-  };
-}

+ 0 - 98
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts

@@ -1,98 +0,0 @@
-import { MouseEvent, useCallback, useEffect, useRef } from 'react';
-import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate';
-import { EditableProps } from 'slate-react/dist/components/editable';
-import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
-import { useAppDispatch } from '$app/stores/store';
-import { TextSelection } from '$app/interfaces/document';
-import { ReactEditor } from 'slate-react';
-import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
-import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
-import { slateValueToDelta } from '$app/utils/document/blocks/common';
-import { isEqual } from '$app/utils/tool';
-
-export function useTextSelections(id: string, editor: ReactEditor) {
-  const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
-  const dispatch = useAppDispatch();
-
-  useEffect(() => {
-    if (!rangeRef.current) return;
-    if (!currentSelection) {
-      ReactEditor.deselect(editor);
-      ReactEditor.blur(editor);
-      return;
-    }
-
-    const { isDragging, focus } = rangeRef.current;
-    if (isDragging || focus?.id !== id) return;
-    if (!ReactEditor.isFocused(editor)) {
-      ReactEditor.focus(editor);
-    }
-    if (!isEqual(editor.selection, currentSelection)) {
-      Transforms.select(editor, currentSelection);
-    }
-  }, [currentSelection, editor, id, rangeRef]);
-
-  const decorate: EditableProps['decorate'] = useCallback(
-    (entry: [Node, Path]) => {
-      const [node, path] = entry;
-
-      if (currentSelection && !Range.isCollapsed(currentSelection as BaseRange)) {
-        const intersection = Range.intersection(currentSelection, Editor.range(editor, path));
-
-        if (!intersection) {
-          return [];
-        }
-        const range = {
-          selectionHighlighted: true,
-          ...intersection,
-        };
-
-        return [range];
-      }
-      return [];
-    },
-    [editor, currentSelection]
-  );
-
-  const setLastActiveSelection = useCallback(
-    (lastActiveSelection: Range) => {
-      const selection = lastActiveSelection as TextSelection;
-      dispatch(syncRangeSelectionThunk({ id, selection }));
-    },
-    [dispatch, id]
-  );
-
-  const onBlur = useCallback(() => {
-    ReactEditor.deselect(editor);
-  }, [editor]);
-
-  const onMouseMove = useCallback(
-    (e: MouseEvent) => {
-      if (!rangeRef.current) return;
-      const { isDragging, isForward, anchor } = rangeRef.current;
-      if (!isDragging || !anchor) return;
-      if (ReactEditor.isFocused(editor)) {
-        return;
-      }
-
-      if (anchor.id === id) {
-        Transforms.select(editor, anchor.selection);
-      } else if (!isForward) {
-        const endSelection = getNodeEndSelection(slateValueToDelta(editor.children));
-        Transforms.select(editor, {
-          anchor: endSelection.anchor,
-          focus: editor.selection?.focus || endSelection.focus,
-        });
-      }
-      ReactEditor.focus(editor);
-    },
-    [editor, id, rangeRef]
-  );
-
-  return {
-    decorate,
-    onBlur,
-    onMouseMove,
-    setLastActiveSelection,
-  };
-}

+ 0 - 73
frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts

@@ -1,12 +1,4 @@
 export const supportLanguage = [
 export const supportLanguage = [
-  {
-    id: 'css',
-    title: 'CSS',
-  },
-  {
-    id: 'html',
-    title: 'HTML',
-  },
   {
   {
     id: 'javascript',
     id: 'javascript',
     title: 'JavaScript',
     title: 'JavaScript',
@@ -15,78 +7,13 @@ export const supportLanguage = [
     id: 'json',
     id: 'json',
     title: 'JSON',
     title: 'JSON',
   },
   },
-  {
-    id: 'markdown',
-    title: 'Markdown',
-  },
-  {
-    id: 'python',
-    title: 'Python',
-  },
   {
   {
     id: 'typescript',
     id: 'typescript',
     title: 'TypeScript',
     title: 'TypeScript',
   },
   },
-  {
-    id: 'xml',
-    title: 'XML',
-  },
-  {
-    id: 'yaml',
-    title: 'YAML',
-  },
-  {
-    id: 'bash',
-    title: 'Bash',
-  },
-  {
-    id: 'c',
-    title: 'C',
-  },
-  {
-    id: 'cpp',
-    title: 'C++',
-  },
-  {
-    id: 'csharp',
-    title: 'C#',
-  },
-  {
-    id: 'go',
-    title: 'Go',
-  },
-  {
-    id: 'java',
-    title: 'Java',
-  },
 
 
-  {
-    id: 'php',
-    title: 'PHP',
-  },
-  {
-    id: 'ruby',
-    title: 'Ruby',
-  },
   {
   {
     id: 'rust',
     id: 'rust',
     title: 'Rust',
     title: 'Rust',
   },
   },
-
-  {
-    id: 'swift',
-    title: 'Swift',
-  },
-  {
-    id: 'sql',
-    title: 'SQL',
-  },
-  {
-    id: 'vb',
-    title: 'Visual Basic',
-  },
-  {
-    id: 'dart',
-    title: 'Dart',
-  },
 ];
 ];

+ 32 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts

@@ -0,0 +1,32 @@
+export const Keyboard = {
+  codes: {
+    BACKSPACE: 8,
+    TAB: 9,
+    ENTER: 13,
+    ESCAPE: 27,
+    SPACE: 32,
+    LEFT: 37,
+    UP: 38,
+    RIGHT: 39,
+    DOWN: 40,
+    DELETE: 46,
+  },
+  keys: {
+    BACKSPACE: 'Backspace',
+    TAB: 'Tab',
+    ENTER: 'Enter',
+    ESCAPE: 'Escape',
+    SPACE: ' ',
+    LEFT: 'ArrowLeft',
+    UP: 'ArrowUp',
+    RIGHT: 'ArrowRight',
+    DOWN: 'ArrowDown',
+    DELETE: 'Delete',
+    SHIFT_ENTER: 'Shift+Enter',
+    SHIFT_TAB: 'Shift+Tab',
+    Slash: '/',
+    Space: ' ',
+    Reduce: '-',
+    BackQuote: '`',
+  },
+};

+ 0 - 13
frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts

@@ -1,13 +0,0 @@
-export const keyBoardEventKeyMap = {
-  Enter: 'Enter',
-  Backspace: 'Backspace',
-  Tab: 'Tab',
-  Up: 'ArrowUp',
-  Down: 'ArrowDown',
-  Left: 'ArrowLeft',
-  Right: 'ArrowRight',
-  Space: ' ',
-  Reduce: '-',
-  Backquote: '`',
-  Slash: '/',
-};

+ 68 - 46
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -1,6 +1,13 @@
-import { Editor } from 'slate';
-import { RegionGrid } from '$app/utils/region_grid';
-import { ReactEditor } from 'slate-react';
+import Delta, { Op } from 'quill-delta';
+import { BlockActionTypePB } from '@/services/backend';
+import { Sources } from 'quill';
+import React from 'react';
+
+export interface RangeStatic {
+  id: string;
+  length: number;
+  index: number;
+}
 
 
 export enum BlockType {
 export enum BlockType {
   PageBlock = 'page',
   PageBlock = 'page',
@@ -50,7 +57,7 @@ export interface CalloutBlockData extends TextBlockData {
 }
 }
 
 
 export interface TextBlockData {
 export interface TextBlockData {
-  delta: TextDelta[];
+  delta: Op[];
 }
 }
 
 
 export interface DividerBlockData {}
 export interface DividerBlockData {}
@@ -86,38 +93,9 @@ export interface NestedBlock<Type = any> {
   parent: string | null;
   parent: string | null;
   children: string;
   children: string;
 }
 }
-export interface TextDelta {
-  insert: string;
-  attributes?: Record<string, string | boolean | undefined>;
-}
-
-export enum BlockActionType {
-  Insert = 0,
-  Update = 1,
-  Delete = 2,
-  Move = 3,
-}
-
-export interface DeltaItem {
-  action: 'inserted' | 'removed' | 'updated';
-  payload: {
-    id: string;
-    value?: NestedBlock | string[];
-  };
-}
 
 
 export type Node = NestedBlock;
 export type Node = NestedBlock;
 
 
-export interface SelectionPoint {
-  path: [number, number];
-  offset: number;
-}
-
-export interface TextSelection {
-  anchor: SelectionPoint;
-  focus: SelectionPoint;
-}
-
 export interface DocumentData {
 export interface DocumentData {
   rootId: string;
   rootId: string;
   // map of block id to block
   // map of block id to block
@@ -140,17 +118,35 @@ export interface RectSelectionState {
   selection: string[];
   selection: string[];
   isDragging: boolean;
   isDragging: boolean;
 }
 }
-export interface RangeSelectionState {
-  anchor?: PointState;
-  focus?: PointState;
-  isForward?: boolean;
-  isDragging: boolean;
-  selection: string[];
-}
 
 
-export interface PointState {
-  id: string;
-  selection: TextSelection;
+export interface RangeState {
+  anchor?: {
+    id: string;
+    point: {
+      x: number;
+      y: number;
+      index?: number;
+      length?: number;
+    };
+  };
+  focus?: {
+    id: string;
+    point: {
+      x: number;
+      y: number;
+    };
+  };
+  ranges: Partial<
+    Record<
+      string,
+      {
+        index: number;
+        length: number;
+      }
+    >
+  >;
+  isDragging: boolean;
+  caret?: RangeStatic;
 }
 }
 
 
 export enum ChangeType {
 export enum ChangeType {
@@ -170,8 +166,6 @@ export interface BlockPBValue {
   data: string;
   data: string;
 }
 }
 
 
-export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor];
-
 export enum SplitRelationship {
 export enum SplitRelationship {
   NextSibling,
   NextSibling,
   FirstChild,
   FirstChild,
@@ -180,7 +174,7 @@ export enum TextAction {
   Turn = 'turn',
   Turn = 'turn',
   Bold = 'bold',
   Bold = 'bold',
   Italic = 'italic',
   Italic = 'italic',
-  Underline = 'underlined',
+  Underline = 'underline',
   Strikethrough = 'strikethrough',
   Strikethrough = 'strikethrough',
   Code = 'code',
   Code = 'code',
   Equation = 'equation',
   Equation = 'equation',
@@ -230,3 +224,31 @@ export interface BlockConfig {
    */
    */
   textActionMenuProps?: TextActionMenuProps;
   textActionMenuProps?: TextActionMenuProps;
 }
 }
+
+export interface ControllerAction {
+  action: BlockActionTypePB;
+  payload: {
+    block: { id: string; parent_id: string; children_id: string; data: string; ty: BlockType };
+    parent_id: string;
+    prev_id: string;
+  };
+}
+
+export interface RangeStaticNoId {
+  index: number;
+  length: number;
+}
+
+export interface CodeEditorProps extends EditorProps {
+  language: string;
+}
+export interface EditorProps {
+  isCodeBlock?: boolean;
+  placeholder?: string;
+  value?: Delta;
+  selection?: RangeStaticNoId;
+  lastSelection?: RangeStaticNoId;
+  onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
+  onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
+  onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
+}

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

@@ -14,7 +14,7 @@ import { DocumentObserver } from './document_observer';
 import * as Y from 'yjs';
 import * as Y from 'yjs';
 import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
 import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
 import { get } from '@/appflowy_app/utils/tool';
 import { get } from '@/appflowy_app/utils/tool';
-import { blockPB2Node } from '$app/utils/document/blocks/common';
+import { blockPB2Node } from '$app/utils/document/block';
 import { Log } from '$app/utils/log';
 import { Log } from '$app/utils/log';
 
 
 export const DocumentControllerContext = createContext<DocumentController | null>(null);
 export const DocumentControllerContext = createContext<DocumentController | null>(null);

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

@@ -1,22 +1,23 @@
 import { DocumentState } from '$app/interfaces/document';
 import { DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { newBlock } from '$app/utils/document/blocks/common';
+import { newBlock } from '$app/utils/document/block';
+import { rectSelectionActions } from '$app_reducers/document/slice';
+import { getDuplicateActions } from '$app/utils/document/action';
 
 
 export const duplicateBelowNodeThunk = createAsyncThunk(
 export const duplicateBelowNodeThunk = createAsyncThunk(
   'document/duplicateBelowNode',
   'document/duplicateBelowNode',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
     const { id, controller } = payload;
-    const { getState } = thunkAPI;
+    const { getState, dispatch } = thunkAPI;
     const state = getState() as { document: DocumentState };
     const state = getState() as { document: DocumentState };
     const node = state.document.nodes[id];
     const node = state.document.nodes[id];
-    if (!node) return;
-    const parentId = node.parent;
-    if (!parentId) return;
-    // duplicate new node
-    const newNode = newBlock<any>(node.type, parentId, node.data);
-    await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
+    if (!node || !node.parent) return;
+    const duplicateActions = getDuplicateActions(id, node.parent, state.document, controller);
 
 
-    return newNode.id;
+    if (!duplicateActions) return;
+    await controller.applyActions(duplicateActions.actions);
+
+    dispatch(rectSelectionActions.updateSelections([duplicateActions.newNodeId]));
   }
   }
 );
 );

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts → frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts

@@ -2,7 +2,7 @@ import { DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { blockConfig } from '$app/constants/document/config';
 import { blockConfig } from '$app/constants/document/config';
-import { getPrevNodeId } from "$app/utils/document/blocks/common";
+import { getPrevNodeId } from '$app/utils/document/block';
 
 
 /**
 /**
  * indent node
  * indent node
@@ -33,7 +33,7 @@ export const indentNodeThunk = createAsyncThunk(
     const newPrevId = newParentChildren[newParentChildren.length - 1];
     const newPrevId = newParentChildren[newParentChildren.length - 1];
 
 
     const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId);
     const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId);
-    const childrenNodes = state.children[node.children].map(id => state.nodes[id]);
+    const childrenNodes = state.children[node.children].map((id) => state.nodes[id]);
     const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
     const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
 
 
     await controller.applyActions([moveAction, ...moveChildrenActions]);
     await controller.applyActions([moveAction, ...moveChildrenActions]);

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

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

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

@@ -1,7 +1,7 @@
 import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
 import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { newBlock } from '$app/utils/document/blocks/common';
+import { newBlock } from '$app/utils/document/block';
 
 
 export const insertAfterNodeThunk = createAsyncThunk(
 export const insertAfterNodeThunk = createAsyncThunk(
   'document/insertAfterNode',
   'document/insertAfterNode',
@@ -21,8 +21,17 @@ export const insertAfterNodeThunk = createAsyncThunk(
     if (!parentId) return;
     if (!parentId) return;
     // create new node
     // create new node
     const newNode = newBlock<any>(type, parentId, data);
     const newNode = newBlock<any>(type, parentId, data);
-    await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
+    let nodeId = newNode.id;
+    const actions = [controller.getInsertAction(newNode, node.id)];
+    if (type === BlockType.DividerBlock) {
+      const newTextNode = newBlock<any>(BlockType.TextBlock, parentId, {
+        delta: [],
+      });
+      nodeId = newTextNode.id;
+      actions.push(controller.getInsertAction(newTextNode, newNode.id));
+    }
+    await controller.applyActions(actions);
 
 
-    return newNode.id;
+    return nodeId;
   }
   }
 );
 );

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

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

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts → frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts


+ 0 - 44
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts

@@ -1,44 +0,0 @@
-import { BlockType, DocumentState } from '$app/interfaces/document';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { outdentNodeThunk } from './outdent';
-import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
-import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge';
-import { ReactEditor } from 'slate-react';
-
-/**
- * 1. If current node is not text block, turn it to text block
- * 2. If current node is text block
- *    2.1 If the current node has next node, merge it to the previous line
- *    2.2 If the parent is root, merge it to the previous line
- *    2.3 If the parent is not root and has no next node, outdent it
- */
-export const backspaceNodeThunk = createAsyncThunk(
-  'document/backspaceNode',
-  async (payload: { id: string; controller: DocumentController; editor: ReactEditor }, thunkAPI) => {
-    const { id, controller, editor } = payload;
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
-    if (!node.parent) return;
-    const parent = state.nodes[node.parent];
-    const children = state.children[parent.children];
-    const index = children.indexOf(id);
-    const nextNodeId = children[index + 1];
-    // turn to text block
-    if (node.type !== BlockType.TextBlock) {
-      await dispatch(turnToTextBlockThunk({ id, controller }));
-      return;
-    }
-    const parentIsRoot = !parent.parent;
-    // merge to previous line when parent is root
-    if (parentIsRoot || nextNodeId) {
-      // merge to previous line
-      ReactEditor.deselect(editor);
-      await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
-      return;
-    }
-    // outdent
-    await dispatch(outdentNodeThunk({ id, controller }));
-  }
-);

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

@@ -1,6 +0,0 @@
-export * from './indent';
-export * from './backspace';
-export * from './outdent';
-export * from './split';
-export * from './turn_to';
-export * from './update';

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

@@ -1,82 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { DocumentState } from '$app/interfaces/document';
-import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common";
-import { rangeSelectionActions } from "$app_reducers/document/slice";
-import { blockConfig } from '$app/constants/document/config';
-import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
-
-/**
- * It will merge delta to the prev line
- * 1. find the prev line and has delta
- *    1.1 Set cursor after the prev line
- *    1.2 merge delta
- * 2. If deleteCurrentNode is true, delete the current node and move children
- *    2.2.1 if the prev line can add children, move children to the prev line.
- *    2.2.2 Otherwise, move children to the parent and below the prev line
- * 3. If deleteCurrentNode is false, clear the current node delta
- */
-export const mergeToPrevLineThunk = createAsyncThunk(
-  'document/codeBlockBackspace',
-  async (payload: { id: string; controller: DocumentController; deleteCurrentNode?: boolean }, thunkAPI) => {
-    const { id, controller, deleteCurrentNode = false } = payload;
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
-    const prevLineId = getPrevLineId(state, id);
-    if (!prevLineId) return;
-    let prevLine = state.nodes[prevLineId];
-    // Find the prev line that has delta
-    while (prevLine && !prevLine.data.delta) {
-      const id = getPrevLineId(state, prevLine.id);
-      if (!id) return;
-      prevLine = state.nodes[id];
-    }
-    if (!prevLine) return;
-
-    const prevLineDelta = prevLine.data.delta;
-
-    const selection = getNodeEndSelection(prevLineDelta);
-
-    const mergeDelta = [...prevLineDelta, ...node.data.delta];
-
-    const updateAction = controller.getUpdateAction({
-      ...prevLine,
-      data: {
-        ...prevLine.data,
-        delta: mergeDelta,
-      },
-    });
-
-    const actions = [updateAction];
-
-    if (deleteCurrentNode) {
-      // move children
-      const config = blockConfig[prevLine.type];
-      const children = state.children[node.children].map((id) => state.nodes[id]);
-      const targetParentId = config.canAddChild ? prevLine.id : prevLine.parent;
-      if (!targetParentId) return;
-      const targetPrevId = targetParentId === prevLine.id ? '' : prevLine.id;
-      const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
-      actions.push(...moveActions);
-      // delete current block
-      const deleteAction = controller.getDeleteAction(node);
-      actions.push(deleteAction);
-    } else {
-      // clear current block delta
-      const updateAction = controller.getUpdateAction({
-        ...node,
-        data: {
-          ...node.data,
-          delta: [],
-        },
-      });
-      actions.push(updateAction);
-    }
-    await controller.applyActions(actions);
-
-    // set cursor after the prev line
-    const range = getCollapsedRange(prevLine.id, selection);
-    dispatch(rangeSelectionActions.setRange(range));
-  }
-);

+ 0 - 74
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts

@@ -1,74 +0,0 @@
-import { DocumentState, SplitRelationship } from '$app/interfaces/document';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { setCursorBeforeThunk } from '../../cursor';
-import { newBlock } from '$app/utils/document/blocks/common';
-import { blockConfig } from '$app/constants/document/config';
-import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
-import { ReactEditor } from 'slate-react';
-
-export const splitNodeThunk = createAsyncThunk(
-  'document/splitNode',
-  async (payload: { id: string; editor: ReactEditor; controller: DocumentController }, thunkAPI) => {
-    const { id, controller, editor } = payload;
-    // get the split content
-    const { retain, insert } = getSplitDelta(editor);
-
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
-    if (!node.parent) return;
-    const children = state.children[node.children];
-    const parent = state.nodes[node.parent];
-
-    const config = blockConfig[node.type].splitProps;
-    // Here we are using the splitProps property of the blockConfig object to determine the type of the new node.
-    // if the splitProps property is not defined for the block type, we throw an error.
-    if (!config) {
-      throw new Error(`Cannot split node of type ${node.type}`);
-    }
-    const newNodeType = config.nextLineBlockType;
-    const relationShip = config.nextLineRelationShip;
-    const defaultData = blockConfig[newNodeType].defaultData;
-    // if the defaultData property is not defined for the new block type, we throw an error.
-    if (!defaultData) {
-      throw new Error(`Cannot split node of type ${node.type} to ${newNodeType}`);
-    }
-
-    // if the next line is a sibling, parent is the same as the current node, and prev is the current node.
-    // otherwise, parent is the current node, and prev is empty.
-    const newParentId = relationShip === SplitRelationship.NextSibling ? parent.id : node.id;
-    const newPrevId = relationShip === SplitRelationship.NextSibling ? node.id : '';
-
-    const newNode = newBlock<any>(newNodeType, newParentId, {
-      ...defaultData,
-      delta: insert,
-    });
-    const retainNode = {
-      ...node,
-      data: {
-        ...node.data,
-        delta: retain,
-      },
-    };
-    const insertAction = controller.getInsertAction(newNode, newPrevId);
-    const updateAction = controller.getUpdateAction(retainNode);
-
-    // if the next line is a sibling, we need to move the children of the current node to the new node.
-    // otherwise, we don't need to do anything.
-    const moveChildrenAction =
-      relationShip === SplitRelationship.NextSibling
-        ? controller.getMoveChildrenAction(
-            children.map((id) => state.nodes[id]),
-            newNode.id,
-            ''
-          )
-        : [];
-
-    await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]);
-
-    ReactEditor.deselect(editor);
-    // set cursor
-    await dispatch(setCursorBeforeThunk({ id: newNode.id }));
-  }
-);

+ 0 - 32
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts

@@ -1,32 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockType, DocumentState } from '$app/interfaces/document';
-import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
-
-/**
- * transform to text block
- * 1. insert text block after current block
- * 2. move children to text block
- * 3. delete current block
- */
-export const turnToTextBlockThunk = createAsyncThunk(
-  'document/turnToTextBlock',
-  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
-    const { id, controller } = payload;
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
-    const data = {
-      delta: node.data.delta,
-    };
-
-    await dispatch(
-      turnToBlockThunk({
-        id,
-        controller,
-        type: BlockType.TextBlock,
-        data,
-      })
-    );
-  }
-);

+ 5 - 5
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts → frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts

@@ -1,18 +1,18 @@
-import { TextDelta, DocumentState, BlockData } from '$app/interfaces/document';
+import { DocumentState, BlockData } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { isSameDelta } from '$app/utils/document/blocks/text/delta';
+import Delta, { Op } from 'quill-delta';
 
 
 export const updateNodeDeltaThunk = createAsyncThunk(
 export const updateNodeDeltaThunk = createAsyncThunk(
   'document/updateNodeDelta',
   'document/updateNodeDelta',
-  async (payload: { id: string; delta: TextDelta[]; controller: DocumentController }, thunkAPI) => {
+  async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
     const { id, delta, controller } = payload;
     const { id, delta, controller } = payload;
     const { getState } = thunkAPI;
     const { getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
     const node = state.nodes[id];
-    const isSame = isSameDelta(delta, node.data.delta || []);
+    const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
+    if (diffDelta.ops.length === 0) return;
 
 
-    if (isSame) return;
     const newData = { ...node.data, delta };
     const newData = { ...node.data, delta };
 
 
     await controller.applyActions([
     await controller.applyActions([

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

@@ -1,109 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { rangeSelectionActions } from "../slice";
-import { DocumentState, TextSelection } from '$app/interfaces/document';
-import { Editor } from 'slate';
-import {
-  getBeforeRangeAt,
-  getEndLineSelectionByOffset,
-  getLastLineOffsetByDelta,
-  getNodeBeginSelection,
-  getNodeEndSelection,
-  getStartLineSelectionByOffset,
-} from '$app/utils/document/blocks/text/delta';
-import { getCollapsedRange, getNextLineId, getPrevLineId } from "$app/utils/document/blocks/common";
-import { ReactEditor } from "slate-react";
-
-export const setCursorBeforeThunk = createAsyncThunk(
-  'document/setCursorBefore',
-  async (payload: { id: string }, thunkAPI) => {
-    const { id } = payload;
-    const { dispatch } = thunkAPI;
-    const selection = getNodeBeginSelection();
-
-    const range = getCollapsedRange(id, selection);
-    dispatch(rangeSelectionActions.setRange(range));
-  }
-);
-
-export const setCursorAfterThunk = createAsyncThunk(
-  'document/setCursorAfter',
-  async (payload: { id: string }, thunkAPI) => {
-    const { id } = payload;
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
-    const selection = getNodeEndSelection(node.data.delta);
-    const range = getCollapsedRange(id, selection);
-    dispatch(rangeSelectionActions.setRange(range));
-  }
-);
-
-export const setCursorPreLineThunk = createAsyncThunk(
-  'document/setCursorPreLine',
-  async (payload: { id: string; editor: ReactEditor; focusEnd?: boolean }, thunkAPI) => {
-    const { id, editor, focusEnd } = payload;
-    const selection = editor.selection as TextSelection;
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const prevId = getPrevLineId(state, id);
-    if (!prevId) return;
-
-    let prevLineNode = state.nodes[prevId];
-    // Find the prev line that has delta
-    while (prevLineNode && !prevLineNode.data.delta) {
-      const id = getPrevLineId(state, prevLineNode.id);
-      if (!id) return;
-      prevLineNode = state.nodes[id];
-    }
-    if (!prevLineNode) return;
-
-    // whatever the selection is, set cursor to the end of prev line when focusEnd is true
-    if (focusEnd) {
-      await dispatch(setCursorAfterThunk({ id: prevLineNode.id }));
-      return;
-    }
-
-    const range = getBeforeRangeAt(editor, selection);
-    const textOffset = Editor.string(editor, range).length;
-
-    // set the cursor to prev line with the relative offset
-    const newSelection = getEndLineSelectionByOffset(prevLineNode.data.delta, textOffset);
-    dispatch(rangeSelectionActions.setRange(getCollapsedRange(prevLineNode.id, newSelection)));
-  }
-);
-
-export const setCursorNextLineThunk = createAsyncThunk(
-  'document/setCursorNextLine',
-  async (payload: { id: string; editor: ReactEditor; focusStart?: boolean }, thunkAPI) => {
-    const { id, editor, focusStart } = payload;
-    const selection = editor.selection as TextSelection;
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
-    const nextId = getNextLineId(state, id);
-    if (!nextId) return;
-    let nextLineNode = state.nodes[nextId];
-    // Find the next line that has delta
-    while (nextLineNode && !nextLineNode.data.delta) {
-      const id = getNextLineId(state, nextLineNode.id);
-      if (!id) return;
-      nextLineNode = state.nodes[id];
-    }
-    if (!nextLineNode) return;
-
-    const delta = nextLineNode.data.delta;
-    // whatever the selection is, set cursor to the start of next line when focusStart is true
-    if (focusStart) {
-      await dispatch(setCursorBeforeThunk({ id: nextLineNode.id }));
-      return;
-    }
-
-    const range = getBeforeRangeAt(editor, selection);
-    const textOffset = Editor.string(editor, range).length - getLastLineOffsetByDelta(node.data.delta);
-
-    // set the cursor to next line with the relative offset
-    const newSelection = getStartLineSelectionByOffset(delta, textOffset);
-
-    dispatch(rangeSelectionActions.setRange(getCollapsedRange(nextLineNode.id, newSelection)));
-  }
-);

+ 28 - 43
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts

@@ -1,31 +1,28 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
-import { TextAction, TextDelta, TextSelection } from '$app/interfaces/document';
-import { getAfterRangeDelta, getBeforeRangeDelta, getRangeDelta } from '$app/utils/document/blocks/text/delta';
+import { TextAction } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
+import Delta from 'quill-delta';
+import { rangeActions } from '$app_reducers/document/slice';
 
 
 export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
 export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
   'document/getFormatActive',
   'document/getFormatActive',
   async (format, thunkAPI) => {
   async (format, thunkAPI) => {
     const { getState } = thunkAPI;
     const { getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const { document } = state;
-    const { selection, anchor, focus } = state.documentRangeSelection;
-
-    const match = (delta: TextDelta[], format: TextAction) => {
-      return delta.every((op) => op.attributes?.[format] === true);
+    const { document, documentRange } = state;
+    const { ranges } = documentRange;
+    const match = (delta: Delta, format: TextAction) => {
+      return delta.ops.every((op) => op.attributes?.[format] === true);
     };
     };
-    return selection.every((id) => {
+    return Object.entries(ranges).every(([id, range]) => {
       const node = document.nodes[id];
       const node = document.nodes[id];
-      let delta = node.data?.delta as TextDelta[];
-      if (!delta) return false;
+      const delta = new Delta(node.data?.delta);
+      const index = range?.index || 0;
+      const length = range?.length || 0;
+      const rangeDelta = delta.slice(index, index + length);
 
 
-      if (id === anchor?.id) {
-        delta = getRangeDelta(delta, anchor.selection);
-      } else if (id === focus?.id) {
-        delta = getRangeDelta(delta, focus.selection);
-      }
-      return match(delta, format);
+      return match(rangeDelta, format);
     });
     });
   }
   }
 );
 );
@@ -33,15 +30,14 @@ export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
 export const toggleFormatThunk = createAsyncThunk(
 export const toggleFormatThunk = createAsyncThunk(
   'document/toggleFormat',
   'document/toggleFormat',
   async (payload: { format: TextAction; controller: DocumentController; isActive: boolean }, thunkAPI) => {
   async (payload: { format: TextAction; controller: DocumentController; isActive: boolean }, thunkAPI) => {
-    const { getState } = thunkAPI;
+    const { getState, dispatch } = thunkAPI;
     const { format, controller, isActive } = payload;
     const { format, controller, isActive } = payload;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const { document } = state;
     const { document } = state;
-    const { selection, anchor, focus } = state.documentRangeSelection;
-    const ids = Array.from(new Set(selection));
+    const { ranges, caret } = state.documentRange;
 
 
-    const toggle = (delta: TextDelta[], format: TextAction) => {
-      return delta.map((op) => {
+    const toggle = (delta: Delta, format: TextAction) => {
+      const newOps = delta.ops.map((op) => {
         const attributes = {
         const attributes = {
           ...op.attributes,
           ...op.attributes,
           [format]: isActive ? undefined : true,
           [format]: isActive ? undefined : true,
@@ -51,36 +47,25 @@ export const toggleFormatThunk = createAsyncThunk(
           attributes: attributes,
           attributes: attributes,
         };
         };
       });
       });
+      return new Delta(newOps);
     };
     };
 
 
-    const splitDelta = (delta: TextDelta[], selection: TextSelection) => {
-      const before = getBeforeRangeDelta(delta, selection);
-      const after = getAfterRangeDelta(delta, selection);
-      let middle = getRangeDelta(delta, selection);
-
-      middle = toggle(middle, format);
-
-      return [...before, ...middle, ...after];
-    };
-
-    const actions = ids.map((id) => {
+    const actions = Object.entries(ranges).map(([id, range]) => {
       const node = document.nodes[id];
       const node = document.nodes[id];
-      let delta = node.data?.delta as TextDelta[];
-      if (!delta) return controller.getUpdateAction(node);
-
-      if (id === anchor?.id) {
-        delta = splitDelta(delta, anchor.selection);
-      } else if (id === focus?.id) {
-        delta = splitDelta(delta, focus.selection);
-      } else {
-        delta = toggle(delta, format);
-      }
+      const delta = new Delta(node.data?.delta);
+      const index = range?.index || 0;
+      const length = range?.length || 0;
+      const beforeDelta = delta.slice(0, index);
+      const afterDelta = delta.slice(index + length);
+      const rangeDelta = delta.slice(index, index + length);
+      const toggleFormatDelta = toggle(rangeDelta, format);
+      const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
 
 
       return controller.getUpdateAction({
       return controller.getUpdateAction({
         ...node,
         ...node,
         data: {
         data: {
           ...node.data,
           ...node.data,
-          delta,
+          delta: newDelta.ops,
         },
         },
       });
       });
     });
     });

+ 2 - 13
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts

@@ -1,15 +1,4 @@
-import { createAsyncThunk } from "@reduxjs/toolkit";
-import { DocumentState, NestedBlock } from "$app/interfaces/document";
-
-export * from './cursor';
 export * from './blocks';
 export * from './blocks';
 export * from './turn_to';
 export * from './turn_to';
-
-export const getBlockByIdThunk = createAsyncThunk<NestedBlock, string>(
-  'document/getBlockById',
-  async (id, thunkAPI) => {
-    const { getState } = thunkAPI;
-    const state = getState() as { document: DocumentState };
-    const node = state.document.nodes[id] as NestedBlock;
-    return node;
-  });
+export * from './keydown';
+export * from './range';

+ 288 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts

@@ -0,0 +1,288 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { BlockType, DocumentState, SplitRelationship } from '$app/interfaces/document';
+import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
+import {
+  findNextHasDeltaNode,
+  findPrevHasDeltaNode,
+  getInsertEnterNodeAction,
+  getLeftCaretByRange,
+  getRightCaretByRange,
+  transformToNextLineCaret,
+  transformToPrevLineCaret,
+} from '$app/utils/document/action';
+import Delta from 'quill-delta';
+import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks';
+import { rangeActions } from '$app_reducers/document/slice';
+import { RootState } from '$app/stores/store';
+import { blockConfig } from '$app/constants/document/config';
+import { Keyboard } from '$app/constants/document/keyboard';
+
+/**
+ * Delete a block by backspace or delete key
+ * 1. If the block is not a text block, turn it to a text block
+ * 2. If the block is a text block
+ *   2.1 If the block has next node or is top level, merge it to the previous line
+ *   2.2 If the block has no next node and is not top level, outdent it
+ */
+export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
+  'document/backspaceDeleteActionForBlock',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    if (!node.parent) return;
+    const parent = state.nodes[node.parent];
+    const children = state.children[parent.children];
+    const index = children.indexOf(id);
+    const nextNodeId = children[index + 1];
+    // turn to text block
+    if (node.type !== BlockType.TextBlock) {
+      await dispatch(turnToTextBlockThunk({ id, controller }));
+      return;
+    }
+    const isTopLevel = parent.type === BlockType.PageBlock;
+    if (isTopLevel || nextNodeId) {
+      // merge to previous line
+      const prevLine = findPrevHasDeltaNode(state, id);
+      if (!prevLine) return;
+      const caretIndex = new Delta(prevLine.data.delta).length();
+      const caret = {
+        id: prevLine.id,
+        index: caretIndex,
+        length: 0,
+      };
+      await dispatch(
+        mergeDeltaThunk({
+          sourceId: id,
+          targetId: prevLine.id,
+          controller,
+        })
+      );
+      dispatch(rangeActions.setCaret(caret));
+      return;
+    }
+    // outdent
+    await dispatch(outdentNodeThunk({ id, controller }));
+  }
+);
+
+/**
+ * Insert a new node after the current node by pressing enter.
+ * 1. Split the current node into two nodes.
+ * 2. Insert a new node after the current node.
+ * 3. Move the children of the current node to the new node if needed.
+ */
+export const enterActionForBlockThunk = createAsyncThunk(
+  'document/insertNodeByEnter',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { getState, dispatch } = thunkAPI;
+    const state = getState() as RootState;
+    const node = state.document.nodes[id];
+    const caret = state.documentRange.caret;
+    if (!node || !caret || caret.id !== id) return;
+
+    const nodeDelta = new Delta(node.data.delta).slice(0, caret.index);
+    const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
+
+    const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
+    if (!insertNodeAction) return;
+    const updateNode = {
+      ...node,
+      data: {
+        ...node.data,
+        delta: nodeDelta.ops,
+      },
+    };
+
+    const children = state.document.children[node.children];
+    const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
+    console.log('needMoveChildren', needMoveChildren);
+    const moveChildrenAction = needMoveChildren
+      ? controller.getMoveChildrenAction(
+          children.map((id) => state.document.nodes[id]),
+          insertNodeAction.id,
+          ''
+        )
+      : [];
+    const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
+    await controller.applyActions(actions);
+
+    dispatch(rangeActions.clearRange());
+    dispatch(
+      rangeActions.setCaret({
+        id: insertNodeAction.id,
+        index: 0,
+        length: 0,
+      })
+    );
+  }
+);
+
+export const tabActionForBlockThunk = createAsyncThunk(
+  'document/tabActionForBlock',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { dispatch } = thunkAPI;
+    return dispatch(indentNodeThunk(payload));
+  }
+);
+
+export const upDownActionForBlockThunk = createAsyncThunk(
+  'document/upActionForBlock',
+  async (payload: { id: string; down?: boolean }, thunkAPI) => {
+    const { id, down } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as RootState;
+    const rangeState = state.documentRange;
+    const caret = rangeState.caret;
+    const node = state.document.nodes[id];
+    if (!node || !caret || id !== caret.id) return;
+
+    let newCaret;
+
+    if (down) {
+      newCaret = transformToNextLineCaret(state.document, caret);
+    } else {
+      newCaret = transformToPrevLineCaret(state.document, caret);
+    }
+    if (!newCaret) {
+      return;
+    }
+    dispatch(rangeActions.setCaret(newCaret));
+  }
+);
+
+export const leftActionForBlockThunk = createAsyncThunk(
+  'document/leftActionForBlock',
+  async (payload: { id: string }, thunkAPI) => {
+    const { id } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as RootState;
+    const rangeState = state.documentRange;
+    const caret = rangeState.caret;
+    const node = state.document.nodes[id];
+    if (!node || !caret || id !== caret.id) return;
+    let newCaret;
+    if (caret.length > 0) {
+      newCaret = {
+        id,
+        index: caret.index,
+        length: 0,
+      };
+    } else {
+      if (caret.index > 0) {
+        newCaret = {
+          id,
+          index: caret.index - 1,
+          length: 0,
+        };
+      } else {
+        const prevNode = findPrevHasDeltaNode(state.document, id);
+        if (!prevNode) return;
+        const prevDelta = new Delta(prevNode.data.delta);
+        newCaret = {
+          id: prevNode.id,
+          index: prevDelta.length(),
+          length: 0,
+        };
+      }
+    }
+
+    if (!newCaret) {
+      return;
+    }
+    dispatch(rangeActions.setCaret(newCaret));
+  }
+);
+
+export const rightActionForBlockThunk = createAsyncThunk(
+  'document/rightActionForBlock',
+  async (payload: { id: string }, thunkAPI) => {
+    const { id } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as RootState;
+    const rangeState = state.documentRange;
+    const caret = rangeState.caret;
+    const node = state.document.nodes[id];
+    if (!node || !caret || id !== caret.id) return;
+    let newCaret;
+    const delta = new Delta(node.data.delta);
+    const deltaLength = delta.length();
+    if (caret.length > 0) {
+      newCaret = {
+        id,
+        index: caret.index + caret.length,
+        length: 0,
+      };
+    } else {
+      if (caret.index < deltaLength) {
+        const newIndex = caret.index + caret.length + 1;
+        newCaret = {
+          id,
+          index: newIndex > deltaLength ? deltaLength : newIndex,
+          length: 0,
+        };
+      } else {
+        const nextNode = findNextHasDeltaNode(state.document, id);
+        if (!nextNode) return;
+        newCaret = {
+          id: nextNode.id,
+          index: 0,
+          length: 0,
+        };
+      }
+    }
+
+    if (!newCaret) {
+      return;
+    }
+    dispatch(rangeActions.setCaret(newCaret));
+  }
+);
+
+export const shiftTabActionForBlockThunk = createAsyncThunk(
+  'document/shiftTabActionForBlock',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { dispatch } = thunkAPI;
+    return dispatch(outdentNodeThunk(payload));
+  }
+);
+
+export const arrowActionForRangeThunk = createAsyncThunk(
+  'document/arrowLeftActionForRange',
+  async (
+    payload: {
+      key: string;
+    },
+    thunkAPI
+  ) => {
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as RootState;
+    const rangeState = state.documentRange;
+    let caret;
+    const leftCaret = getLeftCaretByRange(rangeState);
+    const rightCaret = getRightCaretByRange(rangeState);
+
+    if (!leftCaret || !rightCaret) return;
+
+    switch (payload.key) {
+      case Keyboard.keys.LEFT:
+        caret = leftCaret;
+        break;
+      case Keyboard.keys.RIGHT:
+        caret = rightCaret;
+        break;
+      case Keyboard.keys.UP:
+        caret = transformToPrevLineCaret(state.document, leftCaret);
+        break;
+      case Keyboard.keys.DOWN:
+        caret = transformToNextLineCaret(state.document, rightCaret);
+        break;
+    }
+    if (!caret) return;
+    dispatch(rangeActions.clearRange());
+    dispatch(rangeActions.setCaret(caret));
+  }
+);

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

@@ -1,11 +1,13 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { BlockData, BlockType, DocumentState, TextDelta } from '$app/interfaces/document';
+import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
 import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
 import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { slashCommandActions } from '$app_reducers/document/slice';
-import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
+import { rangeActions, slashCommandActions } from '$app_reducers/document/slice';
 import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
 import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
 import { blockConfig } from '$app/constants/document/config';
 import { blockConfig } from '$app/constants/document/config';
+import Delta, { Op } from 'quill-delta';
+import { getDeltaText } from '$app/utils/document/delta';
+import { RootState } from '$app/stores/store';
 
 
 /**
 /**
  * add block below click
  * add block below click
@@ -20,7 +22,7 @@ export const addBlockBelowClickThunk = createAsyncThunk(
     const state = (getState() as { document: DocumentState }).document;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
     const node = state.nodes[id];
     if (!node) return;
     if (!node) return;
-    const delta = (node.data.delta as TextDelta[]) || [];
+    const delta = (node.data.delta as Op[]) || [];
     const text = delta.map((d) => d.insert).join('');
     const text = delta.map((d) => d.insert).join('');
 
 
     // if current block is not empty, insert a new block after current block
     // if current block is not empty, insert a new block after current block
@@ -29,13 +31,14 @@ export const addBlockBelowClickThunk = createAsyncThunk(
         insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
         insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
       );
       );
       if (newBlockId) {
       if (newBlockId) {
-        await dispatch(setCursorBeforeThunk({ id: newBlockId as string }));
+        dispatch(rangeActions.setCaret({ id: newBlockId as string, index: 0, length: 0 }));
         dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string }));
         dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string }));
       }
       }
       return;
       return;
     }
     }
     // if current block is empty, open slash command
     // if current block is empty, open slash command
-    await dispatch(setCursorBeforeThunk({ id }));
+    dispatch(rangeActions.setCaret({ id, index: 0, length: 0 }));
+
     dispatch(slashCommandActions.openSlashCommand({ blockId: id }));
     dispatch(slashCommandActions.openSlashCommand({ blockId: id }));
   }
   }
 );
 );
@@ -60,12 +63,14 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
   ) => {
   ) => {
     const { id, controller, props } = payload;
     const { id, controller, props } = payload;
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
+    const state = getState() as RootState;
+    const { document } = state;
+    const node = document.nodes[id];
     if (!node) return;
     if (!node) return;
-    const delta = (node.data.delta as TextDelta[]) || [];
-    const text = delta.map((d) => d.insert).join('');
+    const delta = new Delta(node.data.delta);
+    const text = getDeltaText(delta);
     const defaultData = blockConfig[props.type].defaultData;
     const defaultData = blockConfig[props.type].defaultData;
+
     if (node.type === BlockType.TextBlock && (text === '' || text === '/')) {
     if (node.type === BlockType.TextBlock && (text === '' || text === '/')) {
       dispatch(
       dispatch(
         turnToBlockThunk({
         turnToBlockThunk({
@@ -80,7 +85,20 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
       );
       );
       return;
       return;
     }
     }
-    const { payload: newBlockId } = await dispatch(
+
+    // if current block has slash command, remove slash command
+    if (text.slice(0, 1) === '/') {
+      const updateNode = {
+        ...node,
+        data: {
+          ...node.data,
+          delta: delta.slice(1, delta.length()).ops,
+        },
+      };
+      await controller.applyActions([controller.getUpdateAction(updateNode)]);
+    }
+
+    const insertNodePayload = await dispatch(
       insertAfterNodeThunk({
       insertAfterNodeThunk({
         id,
         id,
         controller,
         controller,
@@ -91,6 +109,8 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
         },
         },
       })
       })
     );
     );
-    dispatch(setCursorBeforeThunk({ id: newBlockId as string }));
+    const newBlockId = insertNodePayload.payload as string;
+
+    dispatch(rangeActions.setCaret({ id: newBlockId, index: 0, length: 0 }));
   }
   }
 );
 );

+ 221 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts

@@ -0,0 +1,221 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { RootState } from '$app/stores/store';
+import { rangeActions } from '$app_reducers/document/slice';
+import { getNextLineId } from '$app/utils/document/block';
+import Delta from 'quill-delta';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import {
+  getAfterMergeCaretByRange,
+  getInsertEnterNodeAction,
+  getMergeEndDeltaToStartActionsByRange,
+  getMiddleIdsByRange,
+  getStartAndEndDeltaExpectRange,
+} from '$app/utils/document/action';
+import { RangeState, SplitRelationship } from '$app/interfaces/document';
+import { blockConfig } from '$app/constants/document/config';
+
+interface storeRangeThunkPayload {
+  id: string;
+  range: {
+    index: number;
+    length: number;
+  };
+}
+
+/**
+ * store range to redux store
+ * 1. if isDragging is false, just store range
+ * 2. if isDragging is true, we need amend range between anchor and focus
+ */
+export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: storeRangeThunkPayload, thunkAPI) => {
+  const { id, range } = payload;
+  const { dispatch, getState } = thunkAPI;
+  const state = getState() as RootState;
+  const rangeState = state.documentRange;
+  // we need amend range between anchor and focus
+  const { anchor, focus, isDragging } = rangeState;
+  if (!isDragging || !anchor || !focus) return;
+
+  const ranges: RangeState['ranges'] = {};
+  ranges[id] = range;
+  // pin anchor index
+  let anchorIndex = anchor.point.index;
+  let anchorLength = anchor.point.length;
+  if (anchorIndex === undefined || anchorLength === undefined) {
+    dispatch(rangeActions.setAnchorPointRange(range));
+    anchorIndex = range.index;
+    anchorLength = range.length;
+  }
+
+  // if anchor and focus are in the same node, we don't need to amend range
+  if (anchor.id === id) {
+    dispatch(rangeActions.setRanges(ranges));
+    return;
+  }
+
+  // amend anchor range because slatejs will stop update selection when dragging quickly
+  const isForward = anchor.point.y < focus.point.y;
+  const anchorDelta = new Delta(state.document.nodes[anchor.id].data.delta);
+  if (isForward) {
+    const selectedDelta = anchorDelta.slice(anchorIndex);
+    ranges[anchor.id] = {
+      index: anchorIndex,
+      length: selectedDelta.length(),
+    };
+  } else {
+    const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
+    ranges[anchor.id] = {
+      index: 0,
+      length: selectedDelta.length(),
+    };
+  }
+
+  // select all ids between anchor and focus
+  const startId = isForward ? anchor.id : focus.id;
+  const endId = isForward ? focus.id : anchor.id;
+
+  let currentId: string | undefined = startId;
+  while (currentId && currentId !== endId) {
+    const nextId = getNextLineId(state.document, currentId);
+    if (nextId && nextId !== endId) {
+      const node = state.document.nodes[nextId];
+
+      if (!node || !node.data.delta) return;
+      const delta = new Delta(node.data.delta);
+
+      // set full range
+      const rangeStatic = {
+        index: 0,
+        length: delta.length(),
+      };
+
+      ranges[nextId] = rangeStatic;
+    }
+    currentId = nextId;
+  }
+
+  dispatch(rangeActions.setRanges(ranges));
+});
+
+/**
+ * delete range and insert delta
+ * 1. merge start and end delta to start node and delete end node
+ * 2. delete middle nodes
+ * 3. clear range
+ */
+export const deleteRangeAndInsertThunk = createAsyncThunk(
+  'document/deleteRange',
+  async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => {
+    const { controller, insertDelta } = payload;
+    const { getState, dispatch } = thunkAPI;
+    const state = getState() as RootState;
+    const rangeState = state.documentRange;
+    const actions = [];
+    // get merge actions
+    const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
+    if (mergeActions) {
+      actions.push(...mergeActions);
+    }
+    // get middle nodes
+    const middleIds = getMiddleIdsByRange(rangeState, state.document);
+    // delete middle nodes
+    const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || [];
+    actions.push(...deleteMiddleNodesActions);
+
+    const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
+
+    // apply actions
+    await controller.applyActions(actions);
+
+    // clear range
+    dispatch(rangeActions.clearRange());
+    if (caret) {
+      dispatch(rangeActions.setCaret(caret));
+    }
+  }
+);
+
+/**
+ * delete range and insert enter
+ * 1. if shift key, insert '\n' to start node and concat end node delta
+ * 2. if not shift key
+ *    2.1 insert node under start node, and concat end node delta to insert node
+ *    2.2 filter rest children and move to insert node, if need
+ * 3. delete middle nodes
+ * 4. clear range
+ */
+export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
+  'document/deleteRangeAndInsertEnter',
+  async (payload: { controller: DocumentController; shiftKey: boolean }, thunkAPI) => {
+    const { controller, shiftKey } = payload;
+    const { getState, dispatch } = thunkAPI;
+    const state = getState() as RootState;
+    const rangeState = state.documentRange;
+    const actions = [];
+
+    const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {};
+    if (!startDelta || !endDelta || !endNode || !startNode) return;
+
+    // get middle nodes
+    const middleIds = getMiddleIdsByRange(rangeState, state.document);
+
+    let newStartDelta = new Delta(startDelta);
+    let caret = null;
+    if (shiftKey) {
+      newStartDelta = newStartDelta.insert('\n').concat(endDelta);
+      caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
+    } else {
+      const insertNodeDelta = new Delta(endDelta);
+      const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
+      if (!insertNodeAction) return;
+      actions.push(insertNodeAction.action);
+      caret = {
+        id: insertNodeAction.id,
+        index: 0,
+        length: 0,
+      };
+      // move start node children to insert node
+      const needMoveChildren =
+        blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
+      if (needMoveChildren) {
+        // filter children by delete middle ids
+        const children = state.document.children[startNode.children].filter((id) => middleIds?.includes(id));
+        const moveChildrenAction = needMoveChildren
+          ? controller.getMoveChildrenAction(
+              children.map((id) => state.document.nodes[id]),
+              insertNodeAction.id,
+              ''
+            )
+          : [];
+        actions.push(...moveChildrenAction);
+      }
+    }
+
+    // udpate start node
+    const updateAction = controller.getUpdateAction({
+      ...startNode,
+      data: {
+        ...startNode.data,
+        delta: newStartDelta.ops,
+      },
+    });
+    if (endNode.id !== startNode.id) {
+      // delete end node
+      const deleteAction = controller.getDeleteAction(endNode);
+      actions.push(updateAction, deleteAction);
+    }
+
+    // delete middle nodes
+    const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || [];
+    actions.push(...deleteMiddleNodesActions);
+
+    // apply actions
+    await controller.applyActions(actions);
+
+    // clear range
+    dispatch(rangeActions.clearRange());
+    if (caret) {
+      dispatch(rangeActions.setCaret(caret));
+    }
+  }
+);

+ 0 - 119
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts

@@ -1,119 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentState, TextSelection } from '$app/interfaces/document';
-import { rangeSelectionActions } from '$app_reducers/document/slice';
-import { getNodeBeginSelection, getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
-import { isEqual } from '$app/utils/tool';
-import { RootState } from '$app/stores/store';
-import { getNodesInRange } from '$app/utils/document/blocks/common';
-
-const amendAnchorNodeThunk = createAsyncThunk(
-  'document/amendAnchorNode',
-  async (
-    payload: {
-      id: string;
-    },
-    thunkAPI
-  ) => {
-    const { id } = payload;
-    const { getState, dispatch } = thunkAPI;
-    const nodes = (getState() as { document: DocumentState }).document.nodes;
-
-    const state = getState() as RootState;
-    const { isDragging, isForward, ...range } = state.documentRangeSelection;
-    const { anchor: anchorNode, focus: focusNode } = range;
-
-    if (!isDragging || !anchorNode || anchorNode.id !== id) return;
-    const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
-    if (isCollapsed) return;
-
-    const selection = anchorNode.selection;
-    const node = nodes[id];
-    const focus = isForward ? getNodeEndSelection(node.data.delta).anchor : getNodeBeginSelection().anchor;
-    if (isEqual(focus, selection.focus)) return;
-    const newSelection = {
-      anchor: selection.anchor,
-      focus,
-    };
-
-    dispatch(
-      rangeSelectionActions.setRange({
-        anchor: {
-          id,
-          selection: newSelection as TextSelection,
-        },
-      })
-    );
-  }
-);
-
-export const syncRangeSelectionThunk = createAsyncThunk(
-  'document/syncRangeSelection',
-  async (
-    payload: {
-      id: string;
-      selection: TextSelection;
-    },
-    thunkAPI
-  ) => {
-    const { getState, dispatch } = thunkAPI;
-    const state = getState() as RootState;
-    const range = state.documentRangeSelection;
-    const isDragging = range.isDragging;
-
-    const { id, selection } = payload;
-
-    const updateRange = {
-      focus: {
-        id,
-        selection,
-      },
-    };
-
-    if (!isDragging && range.anchor?.id === id) {
-      Object.assign(updateRange, {
-        anchor: {
-          id,
-          selection: { ...selection },
-        },
-      });
-      dispatch(rangeSelectionActions.setRange(updateRange));
-      return;
-    }
-    if (!range.anchor || range.anchor.id === id) {
-      Object.assign(updateRange, {
-        anchor: {
-          id,
-          selection: {
-            anchor: !range.anchor ? selection.anchor : range.anchor.selection.anchor,
-            focus: selection.focus,
-          },
-        },
-      });
-    }
-
-    dispatch(rangeSelectionActions.setRange(updateRange));
-
-    const anchorId = range.anchor?.id;
-    // more than one node is selected
-    if (anchorId && anchorId !== id) {
-      dispatch(amendAnchorNodeThunk({ id: anchorId }));
-    }
-  }
-);
-
-export const setRangeSelectionThunk = createAsyncThunk('document/setRangeSelection', async (payload, thunkAPI) => {
-  const { getState, dispatch } = thunkAPI;
-  const state = getState() as RootState;
-  const { anchor, focus, isForward } = state.documentRangeSelection;
-  const document = state.document;
-  if (!anchor || !focus || isForward === undefined) return;
-  const rangeIds = getNodesInRange(
-    {
-      startId: anchor.id,
-      endId: focus.id,
-    },
-    isForward,
-    document
-  );
-  dispatch(rangeSelectionActions.setSelection(rangeIds));
-});

+ 5 - 5
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts

@@ -1,7 +1,7 @@
-import { createAsyncThunk } from "@reduxjs/toolkit";
-import { getNextNodeId, getPrevNodeId } from "$app/utils/document/blocks/common";
-import { DocumentState } from "$app/interfaces/document";
-import { rectSelectionActions } from "$app_reducers/document/slice";
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block';
+import { DocumentState } from '$app/interfaces/document';
+import { rectSelectionActions } from '$app_reducers/document/slice';
 
 
 export const setRectSelectionThunk = createAsyncThunk(
 export const setRectSelectionThunk = createAsyncThunk(
   'document/setRectSelection',
   'document/setRectSelection',
@@ -22,6 +22,6 @@ export const setRectSelectionThunk = createAsyncThunk(
         selected[node.parent] = true;
         selected[node.parent] = true;
       }
       }
     });
     });
-    dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id])))
+    dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id])));
   }
   }
 );
 );

+ 31 - 25
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts

@@ -1,10 +1,9 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document';
-import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
+import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
 import { blockConfig } from '$app/constants/document/config';
-import { newBlock } from '$app/utils/document/blocks/common';
-import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
+import { newBlock } from '$app/utils/document/block';
+import { rangeActions } from '$app_reducers/document/slice';
 
 
 /**
 /**
  * transform to block
  * transform to block
@@ -27,10 +26,15 @@ export const turnToBlockThunk = createAsyncThunk(
     const parent = state.nodes[node.parent];
     const parent = state.nodes[node.parent];
     const children = state.children[node.children].map((id) => state.nodes[id]);
     const children = state.children[node.children].map((id) => state.nodes[id]);
 
 
-    const block = newBlock<any>(type, parent.id, data);
+    const block = newBlock<any>(type, parent.id, type === BlockType.DividerBlock ? {} : data);
+    let caretId = block.id;
     // insert new block after current block
     // insert new block after current block
-    const insertHeadingAction = controller.getInsertAction(block, node.id);
-
+    let insertActions = [controller.getInsertAction(block, node.id)];
+    if (type === BlockType.DividerBlock) {
+      const newTextNode = newBlock<any>(BlockType.TextBlock, parent.id, data);
+      insertActions.push(controller.getInsertAction(newTextNode, block.id));
+      caretId = newTextNode.id;
+    }
     // check if prev node is allowed to have children
     // check if prev node is allowed to have children
     const config = blockConfig[block.type];
     const config = blockConfig[block.type];
     // if new block is not allowed to have children, move children to parent
     // if new block is not allowed to have children, move children to parent
@@ -43,34 +47,36 @@ export const turnToBlockThunk = createAsyncThunk(
     const deleteAction = controller.getDeleteAction(node);
     const deleteAction = controller.getDeleteAction(node);
 
 
     // submit actions
     // submit actions
-    await controller.applyActions([insertHeadingAction, ...moveChildrenActions, deleteAction]);
+    await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]);
     // set cursor in new block
     // set cursor in new block
-    await dispatch(setCursorBeforeThunk({ id: block.id }));
+    dispatch(rangeActions.setCaret({ id: caretId, index: 0, length: 0 }));
   }
   }
 );
 );
 
 
 /**
 /**
- * turn to divider block
- * 1. insert text block with delta after current block
- * 2. turn current block to divider block
+ * transform to text block
+ * 1. insert text block after current block
+ * 2. move children to text block
+ * 3. delete current block
  */
  */
-export const turnToDividerBlockThunk = createAsyncThunk(
-  'document/turnToDividerBlock',
-  async (payload: { id: string; controller: DocumentController; delta: TextDelta[] }, thunkAPI) => {
-    const { id, controller, delta } = payload;
-    const { dispatch } = thunkAPI;
-    const { payload: newNodeId } = await dispatch(
-      insertAfterNodeThunk({
+export const turnToTextBlockThunk = createAsyncThunk(
+  'document/turnToTextBlock',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    const data = {
+      delta: node.data.delta,
+    };
+
+    await dispatch(
+      turnToBlockThunk({
         id,
         id,
         controller,
         controller,
         type: BlockType.TextBlock,
         type: BlockType.TextBlock,
-        data: {
-          delta,
-        },
+        data,
       })
       })
     );
     );
-    if (!newNodeId) return;
-    await dispatch(turnToBlockThunk({ id, type: BlockType.DividerBlock, controller, data: {} }));
-    dispatch(setCursorBeforeThunk({ id: newNodeId as string }));
   }
   }
 );
 );

+ 63 - 20
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -1,10 +1,10 @@
 import {
 import {
   DocumentState,
   DocumentState,
   Node,
   Node,
-  PointState,
-  RangeSelectionState,
   RectSelectionState,
   RectSelectionState,
   SlashCommandState,
   SlashCommandState,
+  RangeState,
+  RangeStatic,
 } from '@/appflowy_app/interfaces/document';
 } from '@/appflowy_app/interfaces/document';
 import { BlockEventPayloadPB } from '@/services/backend';
 import { BlockEventPayloadPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
@@ -20,9 +20,9 @@ const rectSelectionInitialState: RectSelectionState = {
   isDragging: false,
   isDragging: false,
 };
 };
 
 
-const rangeSelectionInitialState: RangeSelectionState = {
+const rangeInitialState: RangeState = {
   isDragging: false,
   isDragging: false,
-  selection: [],
+  ranges: {},
 };
 };
 
 
 const slashCommandInitialState: SlashCommandState = {
 const slashCommandInitialState: SlashCommandState = {
@@ -99,37 +99,81 @@ export const rectSelectionSlice = createSlice({
   },
   },
 });
 });
 
 
-export const rangeSelectionSlice = createSlice({
-  name: 'documentRangeSelection',
-  initialState: rangeSelectionInitialState,
+export const rangeSlice = createSlice({
+  name: 'documentRange',
+  initialState: rangeInitialState,
   reducers: {
   reducers: {
+    setRanges: (state, action: PayloadAction<RangeState['ranges']>) => {
+      state.ranges = action.payload;
+    },
     setRange: (
     setRange: (
       state,
       state,
       action: PayloadAction<{
       action: PayloadAction<{
-        anchor?: PointState;
-        focus?: PointState;
+        id: string;
+        rangeStatic: {
+          index: number;
+          length: number;
+        };
       }>
       }>
     ) => {
     ) => {
-      return {
-        ...state,
+      const { id, rangeStatic } = action.payload;
+      state.ranges[id] = rangeStatic;
+    },
+    removeRange: (state, action: PayloadAction<string>) => {
+      const id = action.payload;
+      delete state.ranges[id];
+    },
+    setAnchorPoint: (
+      state,
+      action: PayloadAction<{
+        id: string;
+        point: { x: number; y: number };
+      }>
+    ) => {
+      state.anchor = action.payload;
+    },
+    setAnchorPointRange: (
+      state,
+      action: PayloadAction<{
+        index: number;
+        length: number;
+      }>
+    ) => {
+      const anchor = state.anchor;
+      if (!anchor) return;
+      anchor.point = {
+        ...anchor.point,
         ...action.payload,
         ...action.payload,
       };
       };
     },
     },
-    setSelection: (state, action: PayloadAction<string[]>) => {
-      state.selection = action.payload;
+    setFocusPoint: (
+      state,
+      action: PayloadAction<{
+        id: string;
+        point: { x: number; y: number };
+      }>
+    ) => {
+      state.focus = action.payload;
     },
     },
     setDragging: (state, action: PayloadAction<boolean>) => {
     setDragging: (state, action: PayloadAction<boolean>) => {
       state.isDragging = action.payload;
       state.isDragging = action.payload;
     },
     },
-    setForward: (state, action: PayloadAction<boolean>) => {
-      state.isForward = action.payload;
+    setCaret: (state, action: PayloadAction<RangeStatic>) => {
+      const id = action.payload.id;
+      state.ranges[id] = {
+        index: action.payload.index,
+        length: action.payload.length,
+      };
+      state.caret = action.payload;
     },
     },
     clearRange: (state, _: PayloadAction) => {
     clearRange: (state, _: PayloadAction) => {
-      return rangeSelectionInitialState;
+      state.isDragging = false;
+      state.ranges = {};
+      state.anchor = undefined;
+      state.focus = undefined;
     },
     },
   },
   },
 });
 });
-
 export const slashCommandSlice = createSlice({
 export const slashCommandSlice = createSlice({
   name: 'documentSlashCommand',
   name: 'documentSlashCommand',
   initialState: slashCommandInitialState,
   initialState: slashCommandInitialState,
@@ -156,12 +200,11 @@ export const slashCommandSlice = createSlice({
 export const documentReducers = {
 export const documentReducers = {
   [documentSlice.name]: documentSlice.reducer,
   [documentSlice.name]: documentSlice.reducer,
   [rectSelectionSlice.name]: rectSelectionSlice.reducer,
   [rectSelectionSlice.name]: rectSelectionSlice.reducer,
-  [rangeSelectionSlice.name]: rangeSelectionSlice.reducer,
+  [rangeSlice.name]: rangeSlice.reducer,
   [slashCommandSlice.name]: slashCommandSlice.reducer,
   [slashCommandSlice.name]: slashCommandSlice.reducer,
 };
 };
 
 
 export const documentActions = documentSlice.actions;
 export const documentActions = documentSlice.actions;
 export const rectSelectionActions = rectSelectionSlice.actions;
 export const rectSelectionActions = rectSelectionSlice.actions;
-export const rangeSelectionActions = rangeSelectionSlice.actions;
-
+export const rangeActions = rangeSlice.actions;
 export const slashCommandActions = slashCommandSlice.actions;
 export const slashCommandActions = slashCommandSlice.actions;

+ 307 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts

@@ -0,0 +1,307 @@
+import {
+  BlockType,
+  ControllerAction,
+  DocumentState,
+  NestedBlock,
+  RangeState,
+  RangeStatic,
+  SplitRelationship,
+} from '$app/interfaces/document';
+import { getNextLineId, getPrevLineId, newBlock } from '$app/utils/document/block';
+import Delta from 'quill-delta';
+import { RootState } from '$app/stores/store';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { blockConfig } from '$app/constants/document/config';
+import {
+  caretInBottomEdgeByDelta,
+  caretInTopEdgeByDelta,
+  getDeltaText,
+  getIndexRelativeEnter,
+  getLastLineIndex,
+  transformIndexToNextLine,
+  transformIndexToPrevLine,
+} from '$app/utils/document/delta';
+
+export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
+  const { anchor, focus } = rangeState;
+  if (!anchor || !focus) return;
+  if (anchor.id === focus.id) return;
+  const isForward = anchor.point.y < focus.point.y;
+  // get all ids between anchor and focus
+  const amendIds = [];
+  const startId = isForward ? anchor.id : focus.id;
+  const endId = isForward ? focus.id : anchor.id;
+
+  let currentId: string | undefined = startId;
+  while (currentId && currentId !== endId) {
+    const nextId = getNextLineId(document, currentId);
+    if (nextId && nextId !== endId) {
+      amendIds.push(nextId);
+    }
+    currentId = nextId;
+  }
+  return amendIds;
+}
+
+export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
+  const { anchor, focus, ranges } = rangeState;
+  if (!anchor || !focus) return;
+  if (anchor.id === focus.id) return;
+
+  const isForward = anchor.point.y < focus.point.y;
+  const startId = isForward ? anchor.id : focus.id;
+  const startRange = ranges[startId];
+  if (!startRange) return;
+  const offset = insertDelta ? insertDelta.length() : 0;
+
+  return {
+    id: startId,
+    index: startRange.index + offset,
+    length: 0,
+  };
+}
+
+export function getStartAndEndDeltaExpectRange(state: RootState) {
+  const rangeState = state.documentRange;
+  const { anchor, focus, ranges } = rangeState;
+  if (!anchor || !focus) return;
+  if (anchor.id === focus.id) return;
+
+  const isForward = anchor.point.y < focus.point.y;
+  const startId = isForward ? anchor.id : focus.id;
+  const endId = isForward ? focus.id : anchor.id;
+
+  // get start and end delta
+  const startRange = ranges[startId];
+  const endRange = ranges[endId];
+  if (!startRange || !endRange) return;
+  const startNode = state.document.nodes[startId];
+  let startDelta = new Delta(startNode.data.delta);
+  startDelta = startDelta.slice(0, startRange.index);
+
+  const endNode = state.document.nodes[endId];
+  let endDelta = new Delta(endNode.data.delta);
+  endDelta = endDelta.slice(endRange.index + endRange.length);
+
+  return {
+    startNode,
+    endNode,
+    startDelta,
+    endDelta,
+  };
+}
+export function getMergeEndDeltaToStartActionsByRange(
+  state: RootState,
+  controller: DocumentController,
+  insertDelta?: Delta
+) {
+  const actions = [];
+  const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {};
+  if (!startDelta || !endDelta || !endNode || !startNode) return;
+  // merge start and end nodes
+  const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
+  actions.push(
+    controller.getUpdateAction({
+      ...startNode,
+      data: {
+        delta: mergeDelta.ops,
+      },
+    })
+  );
+  if (endNode.id !== startNode.id) {
+    // delete end node
+    actions.push(controller.getDeleteAction(endNode));
+  }
+
+  return actions;
+}
+
+export function getInsertEnterNodeFields(sourceNode: NestedBlock) {
+  if (!sourceNode.parent) return;
+  const parentId = sourceNode.parent;
+
+  const config = blockConfig[sourceNode.type].splitProps || {
+    nextLineRelationShip: SplitRelationship.NextSibling,
+    nextLineBlockType: BlockType.TextBlock,
+  };
+
+  const newNodeType = config.nextLineBlockType;
+  const relationShip = config.nextLineRelationShip;
+  const defaultData = blockConfig[newNodeType].defaultData;
+  // if the defaultData property is not defined for the new block type, we throw an error.
+  if (!defaultData) {
+    throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`);
+  }
+  const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id;
+  const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : '';
+
+  return {
+    parentId: newParentId,
+    prevId: newPrevId,
+    type: newNodeType,
+    data: defaultData,
+  };
+}
+
+export function getInsertEnterNodeAction(
+  sourceNode: NestedBlock,
+  insertNodeDelta: Delta,
+  controller: DocumentController
+) {
+  const insertNodeFields = getInsertEnterNodeFields(sourceNode);
+  if (!insertNodeFields) return;
+  const { type, data, parentId, prevId } = insertNodeFields;
+  const insertNode = newBlock<any>(type, parentId, {
+    ...data,
+    delta: insertNodeDelta.ops,
+  });
+
+  return {
+    id: insertNode.id,
+    action: controller.getInsertAction(insertNode, prevId),
+  };
+}
+
+export function findPrevHasDeltaNode(state: DocumentState, id: string) {
+  const prevLineId = getPrevLineId(state, id);
+  if (!prevLineId) return;
+  let prevLine = state.nodes[prevLineId];
+  // Find the prev line that has delta
+  while (prevLine && !prevLine.data.delta) {
+    const id = getPrevLineId(state, prevLine.id);
+    if (!id) return;
+    prevLine = state.nodes[id];
+  }
+  return prevLine;
+}
+
+export function findNextHasDeltaNode(state: DocumentState, id: string) {
+  const nextLineId = getNextLineId(state, id);
+  if (!nextLineId) return;
+  let nextLine = state.nodes[nextLineId];
+  // Find the next line that has delta
+  while (nextLine && !nextLine.data.delta) {
+    const id = getNextLineId(state, nextLine.id);
+    if (!id) return;
+    nextLine = state.nodes[id];
+  }
+  return nextLine;
+}
+
+export function isPrintableKeyEvent(event: KeyboardEvent) {
+  const key = event.key;
+  const isPrintable = key.length === 1;
+
+  return isPrintable;
+}
+
+export function getLeftCaretByRange(rangeState: RangeState) {
+  const { anchor, ranges, focus } = rangeState;
+  if (!anchor || !focus) return;
+  const isForward = anchor.point.y < focus.point.y;
+  const startId = isForward ? anchor.id : focus.id;
+
+  const range = ranges[startId];
+  if (!range) return;
+  return {
+    id: startId,
+    index: range.index,
+    length: 0,
+  };
+}
+
+export function getRightCaretByRange(rangeState: RangeState) {
+  const { anchor, focus, ranges, caret } = rangeState;
+  if (!anchor || !focus) return;
+  const isForward = anchor.point.y < focus.point.y;
+  const endId = isForward ? focus.id : anchor.id;
+
+  const range = ranges[endId];
+  if (!range) return;
+
+  return {
+    id: endId,
+    index: range.index + range.length,
+    length: 0,
+  };
+}
+
+export function transformToPrevLineCaret(document: DocumentState, caret: RangeStatic) {
+  const delta = new Delta(document.nodes[caret.id].data.delta);
+  const inTopEdge = caretInTopEdgeByDelta(delta, caret.index);
+
+  if (!inTopEdge) {
+    const index = transformIndexToPrevLine(delta, caret.index);
+    return {
+      id: caret.id,
+      index,
+      length: 0,
+    };
+  }
+  const prevLine = findPrevHasDeltaNode(document, caret.id);
+  if (!prevLine) return;
+  const relativeIndex = getIndexRelativeEnter(delta, caret.index);
+  const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta));
+  const prevLineText = getDeltaText(new Delta(prevLine.data.delta));
+  const newPrevLineIndex = prevLineIndex + relativeIndex;
+  const prevLineLength = prevLineText.length;
+  const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex;
+  return {
+    id: prevLine.id,
+    index,
+    length: 0,
+  };
+}
+
+export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) {
+  const delta = new Delta(document.nodes[caret.id].data.delta);
+  const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index);
+  if (!inBottomEdge) {
+    const index = transformIndexToNextLine(delta, caret.index);
+    return {
+      id: caret.id,
+      index,
+      length: 0,
+    };
+    return;
+  }
+
+  const nextLine = findNextHasDeltaNode(document, caret.id);
+  if (!nextLine) return;
+  const nextLineText = getDeltaText(new Delta(nextLine.data.delta));
+  const relativeIndex = getIndexRelativeEnter(delta, caret.index);
+  const index = relativeIndex >= nextLineText.length ? nextLineText.length : relativeIndex;
+
+  return {
+    id: nextLine.id,
+    index,
+    length: 0,
+  };
+}
+
+export function getDuplicateActions(
+  id: string,
+  parentId: string,
+  document: DocumentState,
+  controller: DocumentController
+) {
+  const actions: ControllerAction[] = [];
+  const node = document.nodes[id];
+  if (!node) return;
+  // duplicate new node
+  const newNode = newBlock<any>(node.type, parentId, {
+    ...node.data,
+  });
+  actions.push(controller.getInsertAction(newNode, node.id));
+  const children = document.children[node.children];
+  children.forEach((child) => {
+    const duplicateChildActions = getDuplicateActions(child, newNode.id, document, controller);
+    if (!duplicateChildActions) return;
+    actions.push(...duplicateChildActions.actions);
+  });
+
+  return {
+    actions,
+    newNodeId: newNode.id,
+  };
+}

+ 92 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts

@@ -0,0 +1,92 @@
+import { BlockData, BlockType, DocumentState, NestedBlock } from '$app/interfaces/document';
+import { BlockPB } from '@/services/backend';
+import { Log } from '$app/utils/log';
+import { nanoid } from 'nanoid';
+
+export function blockPB2Node(block: BlockPB) {
+  let data = {};
+  try {
+    data = JSON.parse(block.data);
+  } catch {
+    Log.error('[Document Open] json parse error', block.data);
+  }
+  const node = {
+    id: block.id,
+    type: block.ty as BlockType,
+    parent: block.parent_id,
+    children: block.children_id,
+    data,
+  };
+  return node;
+}
+
+export function generateId() {
+  return nanoid(10);
+}
+
+export function getPrevLineId(state: DocumentState, id: string) {
+  const node = state.nodes[id];
+  if (!node.parent) return;
+  const parent = state.nodes[node.parent];
+  const children = state.children[parent.children];
+  const index = children.indexOf(id);
+  const prevNodeId = children[index - 1];
+  const prevNode = state.nodes[prevNodeId];
+  if (!prevNode) {
+    return parent.id;
+  }
+  // find prev line
+  let prevLineId = prevNode.id;
+  while (prevLineId) {
+    const prevLineChildren = state.children[state.nodes[prevLineId].children];
+    if (prevLineChildren.length === 0) break;
+    prevLineId = prevLineChildren[prevLineChildren.length - 1];
+  }
+  return prevLineId || parent.id;
+}
+
+export function getNextLineId(state: DocumentState, id: string) {
+  const node = state.nodes[id];
+  if (!node.parent) return;
+
+  const firstChild = state.children[node.children][0];
+  if (firstChild) return firstChild;
+
+  let nextNodeId = getNextNodeId(state, id);
+  let parent: NestedBlock | null = state.nodes[node.parent];
+  while (!nextNodeId && parent) {
+    nextNodeId = getNextNodeId(state, parent.id);
+    parent = parent.parent ? state.nodes[parent.parent] : null;
+  }
+  return nextNodeId;
+}
+
+export function getNextNodeId(state: DocumentState, id: string) {
+  const node = state.nodes[id];
+  if (!node.parent) return;
+  const parent = state.nodes[node.parent];
+  const children = state.children[parent.children];
+  const index = children.indexOf(id);
+  const nextNodeId = children[index + 1];
+  return nextNodeId;
+}
+
+export function getPrevNodeId(state: DocumentState, id: string) {
+  const node = state.nodes[id];
+  if (!node.parent) return;
+  const parent = state.nodes[node.parent];
+  const children = state.children[parent.children];
+  const index = children.indexOf(id);
+  const prevNodeId = children[index - 1];
+  return prevNodeId;
+}
+
+export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
+  return {
+    id: generateId(),
+    type,
+    parent: parentId,
+    children: generateId(),
+    data,
+  };
+}

+ 0 - 34
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/index.ts

@@ -1,34 +0,0 @@
-import { getPointOfCurrentLineBeginning } from '$app/utils/document/blocks/text/delta';
-import { Editor, Transforms } from 'slate';
-
-export function indent(editor: Editor, distance: number) {
-  const beginPoint = getPointOfCurrentLineBeginning(editor);
-  const emptyStr = ''.padStart(distance);
-
-  Transforms.insertText(editor, emptyStr, {
-    at: beginPoint,
-  });
-}
-export function outdent(editor: Editor, distance: number) {
-  const beginPoint = getPointOfCurrentLineBeginning(editor);
-  if (!beginPoint) return;
-  const afterBeginPoint = Editor.after(editor, beginPoint, {
-    distance,
-  });
-  if (!afterBeginPoint) return;
-  const deleteChar = Editor.string(editor, {
-    anchor: beginPoint,
-    focus: afterBeginPoint,
-  });
-  const emptyStr = ''.padStart(distance);
-  if (deleteChar !== emptyStr) {
-    if (distance > 1) {
-      outdent(editor, distance - 1);
-    }
-    return;
-  }
-  Transforms.delete(editor, {
-    at: beginPoint,
-    distance,
-  });
-}

+ 0 - 220
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts

@@ -1,220 +0,0 @@
-import {
-  BlockData,
-  BlockType,
-  DocumentState,
-  NestedBlock,
-  RangeSelectionState,
-  TextDelta,
-  TextSelection,
-} from '$app/interfaces/document';
-import { Descendant, Element, Text } from 'slate';
-import { BlockPB } from '@/services/backend';
-import { Log } from '$app/utils/log';
-import { nanoid } from 'nanoid';
-import { clone } from '$app/utils/tool';
-
-export function slateValueToDelta(slateNodes: Descendant[]) {
-  const element = slateNodes[0] as Element;
-  const children = element.children as Text[];
-  return children.map((child) => {
-    const { text, ...attributes } = child;
-    return {
-      insert: text,
-      attributes,
-    };
-  });
-}
-
-export function deltaToSlateValue(delta: TextDelta[]) {
-  const slateNode = {
-    type: 'paragraph',
-    children: [{ text: '' }],
-  };
-  const slateNodes = [slateNode];
-  if (delta.length > 0) {
-    slateNode.children = delta.map((d) => {
-      return {
-        ...d.attributes,
-        text: d.insert,
-      };
-    });
-  }
-  return slateNodes;
-}
-
-export function getDeltaFromSlateNodes(slateNodes: Descendant[]) {
-  const element = slateNodes[0] as Element;
-  const children = element.children as Text[];
-  return children.map((child) => {
-    const { text, ...attributes } = child;
-    return {
-      insert: text,
-      attributes,
-    };
-  });
-}
-
-export function blockPB2Node(block: BlockPB) {
-  let data = {};
-  try {
-    data = JSON.parse(block.data);
-  } catch {
-    Log.error('[Document Open] json parse error', block.data);
-  }
-  const node = {
-    id: block.id,
-    type: block.ty as BlockType,
-    parent: block.parent_id,
-    children: block.children_id,
-    data,
-  };
-  return node;
-}
-
-export function generateId() {
-  return nanoid(10);
-}
-
-export function getPrevLineId(state: DocumentState, id: string) {
-  const node = state.nodes[id];
-  if (!node.parent) return;
-  const parent = state.nodes[node.parent];
-  const children = state.children[parent.children];
-  const index = children.indexOf(id);
-  const prevNodeId = children[index - 1];
-  const prevNode = state.nodes[prevNodeId];
-  if (!prevNode) {
-    return parent.id;
-  }
-  // find prev line
-  let prevLineId = prevNode.id;
-  while (prevLineId) {
-    const prevLineChildren = state.children[state.nodes[prevLineId].children];
-    if (prevLineChildren.length === 0) break;
-    prevLineId = prevLineChildren[prevLineChildren.length - 1];
-  }
-  return prevLineId || parent.id;
-}
-
-export function getNextLineId(state: DocumentState, id: string) {
-  const node = state.nodes[id];
-  if (!node.parent) return;
-
-  const firstChild = state.children[node.children][0];
-  if (firstChild) return firstChild;
-
-  let nextNodeId = getNextNodeId(state, id);
-  let parent: NestedBlock | null = state.nodes[node.parent];
-  while (!nextNodeId && parent) {
-    nextNodeId = getNextNodeId(state, parent.id);
-    parent = parent.parent ? state.nodes[parent.parent] : null;
-  }
-  return nextNodeId;
-}
-
-export function getNextNodeId(state: DocumentState, id: string) {
-  const node = state.nodes[id];
-  if (!node.parent) return;
-  const parent = state.nodes[node.parent];
-  const children = state.children[parent.children];
-  const index = children.indexOf(id);
-  const nextNodeId = children[index + 1];
-  return nextNodeId;
-}
-
-export function getPrevNodeId(state: DocumentState, id: string) {
-  const node = state.nodes[id];
-  if (!node.parent) return;
-  const parent = state.nodes[node.parent];
-  const children = state.children[parent.children];
-  const index = children.indexOf(id);
-  const prevNodeId = children[index - 1];
-  return prevNodeId;
-}
-
-export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
-  return {
-    id: generateId(),
-    type,
-    parent: parentId,
-    children: generateId(),
-    data,
-  };
-}
-
-export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState {
-  const point = {
-    id,
-    selection,
-  };
-  return {
-    anchor: clone(point),
-    focus: clone(point),
-    isDragging: false,
-    selection: [],
-  };
-}
-
-export function iterateNodes(
-  range: {
-    startId: string;
-    endId: string;
-  },
-  isForward: boolean,
-  document: DocumentState,
-  callback: (nodeId?: string) => boolean
-) {
-  const { startId, endId } = range;
-  let currentId = startId;
-  while (currentId && currentId !== endId) {
-    if (isForward) {
-      currentId = getNextLineId(document, currentId) || '';
-    } else {
-      currentId = getPrevLineId(document, currentId) || '';
-    }
-    if (callback(currentId)) {
-      break;
-    }
-  }
-}
-export function getNodesInRange(
-  range: {
-    startId: string;
-    endId: string;
-  },
-  isForward: boolean,
-  document: DocumentState
-) {
-  const nodeIds: string[] = [];
-  nodeIds.push(range.startId);
-  iterateNodes(range, isForward, document, (nodeId) => {
-    if (nodeId) {
-      nodeIds.push(nodeId);
-      return false;
-    } else {
-      return true;
-    }
-  });
-  nodeIds.push(range.endId);
-  return nodeIds;
-}
-
-export function nodeInRange(
-  id: string,
-  range: {
-    startId: string;
-    endId: string;
-  },
-  isForward: boolean,
-  document: DocumentState
-) {
-  let match = false;
-  iterateNodes(range, isForward, document, (nodeId) => {
-    if (nodeId === id) {
-      match = true;
-      return true;
-    }
-    return false;
-  });
-  return match;
-}

+ 0 - 132
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts

@@ -1,132 +0,0 @@
-import { Editor } from 'slate';
-import {
-  BulletListBlockData,
-  CalloutBlockData,
-  HeadingBlockData,
-  NumberedListBlockData,
-  TodoListBlockData,
-  ToggleListBlockData,
-} from '$app/interfaces/document';
-import { getAfterRangeAt, getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta';
-
-/**
- * get heading data from editor, only support markdown
- * @param editor
- */
-export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
-  const selection = editor.selection;
-  if (!selection) return;
-  const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
-  const level = hashTags.match(/#/g)?.length;
-  if (!level) return;
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    level,
-    delta,
-  };
-}
-
-/**
- * get quote data from editor, only support markdown
- * @param editor
- */
-export function getQuoteDataFromEditor(editor: Editor) {
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    delta,
-    size: 'default',
-  };
-}
-
-/**
- * get todo_list data from editor, only support markdown
- * @param editor
- */
-export function getTodoListDataFromEditor(editor: Editor): TodoListBlockData | undefined {
-  const selection = editor.selection;
-  if (!selection) return;
-  const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
-  const checked = hashTags.match(/x/g)?.length;
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    delta,
-    checked: !!checked,
-  };
-}
-
-/**
- * get bulleted_list data from editor, only support markdown
- * @param editor
- */
-export function getBulletedDataFromEditor(editor: Editor): BulletListBlockData | undefined {
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    delta,
-    format: 'default',
-  };
-}
-
-/**
- * get numbered_list data from editor, only support markdown
- * @param editor
- */
-export function getNumberedListDataFromEditor(editor: Editor): NumberedListBlockData | undefined {
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    delta,
-    format: 'default',
-  };
-}
-
-/**
- * get toggle_list data from editor, only support markdown
- */
-export function getToggleListDataFromEditor(editor: Editor): ToggleListBlockData | undefined {
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    delta,
-    collapsed: false,
-  };
-}
-
-/**
- * get callout data from editor, only support markdown
- */
-export function getCalloutDataFromEditor(editor: Editor): CalloutBlockData | undefined {
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  const selection = editor.selection;
-  if (!selection) return;
-  const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
-  const tag = hashTags.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
-  if (!tag) return;
-  const iconMap: Record<string, string> = {
-    TIP: '💡',
-    INFO: '❗',
-    WARNING: '⚠️',
-    DANGER: '‼️',
-  };
-  return {
-    delta,
-    icon: iconMap[tag],
-  };
-}
-
-/**
- * get code block data from editor, only support markdown
- */
-export function getCodeBlockDataFromEditor(editor: Editor) {
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    delta,
-    language: 'javascript',
-    wrap: true,
-  };
-}

+ 0 - 22
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/selection.ts

@@ -1,22 +0,0 @@
-export function isPointInBlock(target: HTMLElement | null) {
-  let node = target;
-  while (node) {
-    if (node.getAttribute('data-block-id')) {
-      return true;
-    }
-    node = node.parentElement;
-  }
-  return false;
-}
-
-export function getBlockIdByPoint(target: HTMLElement | null) {
-  let node = target;
-  while (node) {
-    const id = node.getAttribute('data-block-id');
-    if (id) {
-      return id;
-    }
-    node = node.parentElement;
-  }
-  return null;
-}

+ 0 - 378
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts

@@ -1,378 +0,0 @@
-import { Editor, Element, Location, Text, Range } from 'slate';
-import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
-import * as Y from 'yjs';
-import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
-
-export function getDelta(editor: Editor, at: Location): TextDelta[] {
-  const baseElement = Editor.fragment(editor, at)[0] as Element;
-  return baseElement.children.map((item) => {
-    const { text, ...attributes } = item as Text;
-    return {
-      insert: text,
-      attributes,
-    };
-  });
-}
-
-export function getBeforeRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
-  const anchor = Range.start(range);
-  const sliceNodes = delta.slice(0, anchor.path[1] + 1);
-  const sliceEnd = sliceNodes[sliceNodes.length - 1];
-  const sliceEndText = sliceEnd.insert.slice(0, anchor.offset);
-  const sliceEndAttributes = sliceEnd.attributes;
-  const sliceEndNode =
-    sliceEndText.length > 0
-      ? {
-          insert: sliceEndText,
-          attributes: sliceEndAttributes,
-        }
-      : null;
-  const sliceMiddleNodes = sliceNodes.slice(0, sliceNodes.length - 1);
-
-  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-  // @ts-ignore
-  return [...sliceMiddleNodes, sliceEndNode].filter((item) => item);
-}
-
-export function getAfterRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
-  const focus = Range.end(range);
-  const sliceNodes = delta.slice(focus.path[1], delta.length);
-  const sliceStart = sliceNodes[0];
-  const sliceStartText = sliceStart.insert.slice(focus.offset);
-  const sliceStartAttributes = sliceStart.attributes;
-  const sliceStartNode =
-    sliceStartText.length > 0
-      ? {
-          insert: sliceStartText,
-          attributes: sliceStartAttributes,
-        }
-      : null;
-  const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length);
-  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-  // @ts-ignore
-  return [sliceStartNode, ...sliceMiddleNodes].filter((item) => item);
-}
-
-export function getRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
-  const anchor = Range.start(range);
-  const focus = Range.end(range);
-  const sliceNodes = delta.slice(anchor.path[1], focus.path[1] + 1);
-  if (anchor.path[1] === focus.path[1]) {
-    return sliceNodes.map((item) => {
-      const { insert, attributes } = item;
-      const text = insert.slice(anchor.offset, focus.offset);
-      return {
-        insert: text,
-        attributes,
-      };
-    });
-  }
-  const sliceStart = sliceNodes[0];
-  const sliceEnd = sliceNodes[sliceNodes.length - 1];
-  const sliceStartText = sliceStart.insert.slice(anchor.offset);
-  const sliceEndText = sliceEnd.insert.slice(0, focus.offset);
-  const sliceStartAttributes = sliceStart.attributes;
-  const sliceEndAttributes = sliceEnd.attributes;
-  const sliceStartNode =
-    sliceStartText.length > 0
-      ? {
-          insert: sliceStartText,
-          attributes: sliceStartAttributes,
-        }
-      : null;
-
-  const sliceEndNode =
-    sliceEndText.length > 0
-      ? {
-          insert: sliceEndText,
-          attributes: sliceEndAttributes,
-        }
-      : null;
-  const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length - 1);
-
-  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-  // @ts-ignore
-  return [sliceStartNode, ...sliceMiddleNodes, sliceEndNode].filter((item) => item);
-}
-/**
- * get the selection between the beginning of the editor and the point
- * form 0 to point
- * @param editor
- * @param at
- */
-export function getBeforeRangeAt(editor: Editor, at: Location) {
-  const start = Editor.start(editor, at);
-  return {
-    anchor: { path: [0, 0], offset: 0 },
-    focus: start,
-  };
-}
-
-/**
- * get the selection between the point and the end of the editor
- * from point to end
- * @param editor
- * @param at
- */
-export function getAfterRangeAt(editor: Editor, at: Location) {
-  const end = Editor.end(editor, at);
-  const fragment = (editor.children[0] as Element).children;
-  const lastIndex = fragment.length - 1;
-  const lastNode = fragment[lastIndex] as Text;
-  return {
-    anchor: end,
-    focus: { path: [0, lastIndex], offset: lastNode.text.length },
-  };
-}
-
-/**
- * check if the point is in the beginning of the editor
- * @param editor
- * @param at
- */
-export function pointInBegin(editor: Editor, at: Location) {
-  const start = Editor.start(editor, at);
-  return Editor.before(editor, start) === undefined;
-}
-
-/**
- * check if the point is in the end of the editor
- * @param editor
- * @param at
- */
-export function pointInEnd(editor: Editor, at: Location) {
-  const end = Editor.end(editor, at);
-  return Editor.after(editor, end) === undefined;
-}
-
-/**
- * get the selection of the beginning of the node
- */
-export function getNodeBeginSelection(): TextSelection {
-  const point: SelectionPoint = {
-    path: [0, 0],
-    offset: 0,
-  };
-  const selection: TextSelection = {
-    anchor: clonePoint(point),
-    focus: clonePoint(point),
-  };
-  return selection;
-}
-
-export function getEditorEndPoint(editor: Editor): SelectionPoint {
-  const fragment = (editor.children[0] as Element).children;
-  const lastIndex = fragment.length - 1;
-  const lastNode = fragment[lastIndex] as Text;
-  return { path: [0, lastIndex], offset: lastNode.text.length };
-}
-
-/**
- * get the selection of the end of the node
- * @param delta
- */
-export function getNodeEndSelection(delta: TextDelta[]) {
-  const len = delta.length;
-  const offset = len > 0 ? delta[len - 1].insert.length : 0;
-
-  const cursorPoint: SelectionPoint = {
-    path: [0, Math.max(len - 1, 0)],
-    offset,
-  };
-
-  const selection: TextSelection = {
-    anchor: clonePoint(cursorPoint),
-    focus: clonePoint(cursorPoint),
-  };
-  return selection;
-}
-
-/**
- * get lines by delta
- * @param delta
- */
-export function getLinesByDelta(delta: TextDelta[]): string[] {
-  const text = delta.map((item) => item.insert).join('');
-  return text.split('\n');
-}
-
-/**
- * get the offset of the last line
- * @param delta
- */
-export function getLastLineOffsetByDelta(delta: TextDelta[]): number {
-  const text = delta.map((item) => item.insert).join('');
-  const index = text.lastIndexOf('\n');
-  return index === -1 ? 0 : index + 1;
-}
-
-/**
- * get the offset of per line beginning
- * @param editor
- */
-export function getOffsetOfPerLineBeginning(editor: Editor): number[] {
-  const delta = getDeltaFromSlateNodes(editor.children);
-  const lines = getLinesByDelta(delta);
-  const offsets: number[] = [];
-  let offset = 0;
-  for (let i = 0; i < lines.length; i++) {
-    const lineText = lines[i] + '\n';
-    offsets.push(offset);
-    offset += lineText.length;
-  }
-  return offsets;
-}
-
-/**
- * get the selection of the end line by offset
- * @param delta
- * @param offset relative offset of the end line
- */
-export function getEndLineSelectionByOffset(delta: TextDelta[], offset: number) {
-  const lines = getLinesByDelta(delta);
-  const endLine = lines[lines.length - 1];
-  // if the offset is greater than the length of the end line, set cursor to the end of prev line
-  if (offset >= endLine.length) {
-    return getNodeEndSelection(delta);
-  }
-
-  const textOffset = getLastLineOffsetByDelta(delta) + offset;
-  return getSelectionByTextOffset(delta, textOffset);
-}
-
-/**
- * get the selection of the start line by offset
- * @param delta
- * @param offset relative offset of the start line
- */
-export function getStartLineSelectionByOffset(delta: TextDelta[], offset: number) {
-  const lines = getLinesByDelta(delta);
-  if (lines.length === 0) {
-    return getNodeBeginSelection();
-  }
-  const startLine = lines[0];
-  // if the offset is greater than the length of the end line, set cursor to the end of prev line
-  if (offset >= startLine.length) {
-    return getSelectionByTextOffset(delta, startLine.length);
-  }
-
-  return getSelectionByTextOffset(delta, offset);
-}
-
-/**
- * get the selection by text offset
- * @param delta
- * @param offset absolute offset
- */
-export function getSelectionByTextOffset(delta: TextDelta[], offset: number) {
-  const point = getPointByTextOffset(delta, offset);
-  const selection: TextSelection = {
-    anchor: clonePoint(point),
-    focus: clonePoint(point),
-  };
-  return selection;
-}
-
-/**
- * get the text offset by selection
- * @param delta
- * @param point
- */
-export function getTextOffsetBySelection(delta: TextDelta[], point: SelectionPoint) {
-  let textOffset = 0;
-  for (let i = 0; i < point.path[1]; i++) {
-    const item = delta[i];
-    textOffset += item.insert.length;
-  }
-  textOffset += point.offset;
-  return textOffset;
-}
-
-/**
- * get the point by text offset
- * @param delta
- * @param offset absolute offset
- */
-export function getPointByTextOffset(delta: TextDelta[], offset: number): SelectionPoint {
-  let textOffset = 0;
-  let path: [number, number] = [0, 0];
-  let textLength = 0;
-  for (let i = 0; i < delta.length; i++) {
-    const item = delta[i];
-    if (textOffset + item.insert.length >= offset) {
-      path = [0, i];
-      textLength = offset - textOffset;
-      break;
-    }
-    textOffset += item.insert.length;
-  }
-
-  return {
-    path,
-    offset: textLength,
-  };
-}
-
-export function clonePoint(point: SelectionPoint): SelectionPoint {
-  return {
-    path: [...point.path],
-    offset: point.offset,
-  };
-}
-
-export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
-  const ydoc = new Y.Doc();
-  const yText = ydoc.getText('1');
-  const yTextRefer = ydoc.getText('2');
-  yText.applyDelta(delta);
-  yTextRefer.applyDelta(referDelta);
-  return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
-}
-
-export function getDeltaBeforeSelection(editor: Editor) {
-  const selection = editor.selection;
-  if (!selection) return;
-  const beforeRange = getBeforeRangeAt(editor, selection);
-  return getDelta(editor, beforeRange);
-}
-
-export function getDeltaAfterSelection(editor: Editor): TextDelta[] | undefined {
-  const selection = editor.selection;
-  if (!selection) return;
-  const afterRange = getAfterRangeAt(editor, selection);
-  return getDelta(editor, afterRange);
-}
-
-export function getSplitDelta(editor: Editor) {
-  // get the retain content
-  const retain = getDeltaBeforeSelection(editor) || [];
-  // get the insert content
-  const insert = getDeltaAfterSelection(editor) || [];
-  return { retain, insert };
-}
-
-export function getPointOfCurrentLineBeginning(editor: Editor) {
-  const { selection } = editor;
-  if (!selection) return;
-  const delta = getDeltaFromSlateNodes(editor.children);
-  const textOffset = getTextOffsetBySelection(delta, selection.anchor as SelectionPoint);
-  const offsets = getOffsetOfPerLineBeginning(editor);
-  let lineNumber = offsets.findIndex((item) => item > textOffset);
-  if (lineNumber === -1) {
-    lineNumber = offsets.length - 1;
-  } else {
-    lineNumber -= 1;
-  }
-
-  const lineBeginOffset = offsets[lineNumber];
-
-  const beginPoint = getPointByTextOffset(delta, lineBeginOffset);
-  return beginPoint;
-}
-
-export function selectionIsForward(selection: TextSelection | null) {
-  if (!selection) return false;
-  const { anchor, focus } = selection;
-  if (!anchor || !focus) return false;
-  return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset);
-}

+ 0 - 79
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts

@@ -1,79 +0,0 @@
-import isHotkey from 'is-hotkey';
-import { Editor, Range } from 'slate';
-import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
-import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-
-const HOTKEYS: Record<string, string> = {
-  'mod+b': 'bold',
-  'mod+i': 'italic',
-  'mod+u': 'underline',
-  'mod+e': 'code',
-  'mod+shift+X': 'strikethrough',
-  'mod+shift+S': 'strikethrough',
-};
-
-export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const isBackspaceKey = isHotkey('backspace', event);
-  const selection = editor.selection;
-
-  if (!isBackspaceKey || !selection) {
-    return false;
-  }
-  // It should be handled if the selection is collapsed and the cursor is at the beginning of the block
-  const isCollapsed = Range.isCollapsed(selection);
-  return isCollapsed && pointInBegin(editor, selection);
-}
-
-export function canHandleUpKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const isUpKey = event.key === keyBoardEventKeyMap.Up;
-  const selection = editor.selection;
-  if (!isUpKey || !selection) {
-    return false;
-  }
-  // It should be handled if the selection is collapsed and the cursor is at the first line of the block
-  const isCollapsed = Range.isCollapsed(selection);
-
-  const beforeString = Editor.string(editor, getBeforeRangeAt(editor, selection));
-  const isTopEdge = !beforeString.includes('\n');
-
-  return isCollapsed && isTopEdge;
-}
-
-export function canHandleDownKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const isDownKey = event.key === keyBoardEventKeyMap.Down;
-  const selection = editor.selection;
-  if (!isDownKey || !selection) {
-    return false;
-  }
-  // It should be handled if the selection is collapsed and the cursor is at the last line of the block
-  const isCollapsed = Range.isCollapsed(selection);
-
-  const afterString = Editor.string(editor, getAfterRangeAt(editor, selection));
-  const isBottomEdge = !afterString.includes('\n');
-
-  return isCollapsed && isBottomEdge;
-}
-
-export function canHandleLeftKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const isLeftKey = event.key === keyBoardEventKeyMap.Left;
-  const selection = editor.selection;
-  if (!isLeftKey || !selection) {
-    return false;
-  }
-
-  // It should be handled if the selection is collapsed and the cursor is at the beginning of the block
-  const isCollapsed = Range.isCollapsed(selection);
-
-  return isCollapsed && pointInBegin(editor, selection);
-}
-
-export function canHandleRightKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const isRightKey = event.key === keyBoardEventKeyMap.Right;
-  const selection = editor.selection;
-  if (!isRightKey || !selection) {
-    return false;
-  }
-  // It should be handled if the selection is collapsed and the cursor is at the end of the block
-  const isCollapsed = Range.isCollapsed(selection);
-  return isCollapsed && pointInEnd(editor, selection);
-}

+ 71 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts

@@ -0,0 +1,71 @@
+import Delta from "quill-delta";
+
+export function getDeltaText(delta: Delta) {
+  const text = delta
+    .filter((op) => typeof op.insert === "string")
+    .map((op) => op.insert)
+    .join("");
+  return text;
+}
+
+export function caretInTopEdgeByDelta(delta: Delta, index: number) {
+  const text = getDeltaText(delta.slice(0, index));
+  if (!text) return true;
+
+  const firstLine = text.split("\n")[0];
+  return index <= firstLine.length;
+}
+
+export function caretInBottomEdgeByDelta(delta: Delta, index: number) {
+  const text = getDeltaText(delta.slice(index));
+
+  if (!text) return true;
+  return !text.includes("\n");
+}
+
+export function getLineByIndex(delta: Delta, index: number) {
+  const beforeText = getDeltaText(delta.slice(0, index));
+  const afterText = getDeltaText(delta.slice(index));
+  const beforeLines = beforeText.split("\n");
+  const afterLines = afterText.split("\n");
+
+  const startLineText = beforeLines[beforeLines.length - 1];
+  const currentLineText = startLineText + afterLines[0];
+  return {
+    text: currentLineText,
+    index: beforeText.length - startLineText.length,
+  };
+}
+
+export function transformIndexToPrevLine(delta: Delta, index: number) {
+  const text = getDeltaText(delta.slice(0, index));
+  const lines = text.split("\n");
+  if (lines.length < 2) return 0;
+  const prevLineText = lines[lines.length - 2];
+  const transformedIndex = index - prevLineText.length - 1;
+  return transformedIndex > 0 ? transformedIndex : 0;
+}
+
+function getCurrentLineText(delta: Delta, index: number) {
+  return getLineByIndex(delta, index).text;
+}
+
+export function transformIndexToNextLine(delta: Delta, index: number) {
+  const text = getDeltaText(delta);
+  const currentLineText = getCurrentLineText(delta, index);
+  const transformedIndex = index + currentLineText.length + 1;
+  return transformedIndex > text.length ? text.length : transformedIndex;
+}
+
+export function getIndexRelativeEnter(delta: Delta, index: number) {
+  const text = getDeltaText(delta.slice(0, index));
+  const beforeLines = text.split("\n");
+  const beforeLineText = beforeLines[beforeLines.length - 1];
+  return beforeLineText.length;
+}
+
+export function getLastLineIndex(delta: Delta) {
+  const text = getDeltaText(delta);
+  const lastIndex = text.lastIndexOf("\n");
+  return lastIndex === -1 ? 0 : lastIndex + 1;
+}

+ 232 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts

@@ -0,0 +1,232 @@
+function isTextNode(node: Node): boolean {
+  return node.nodeType === Node.TEXT_NODE;
+}
+
+export function exclude(node: Element) {
+  let isPlaceholder = false;
+  try {
+    isPlaceholder = !!node.getAttribute('data-slate-placeholder');
+  } catch (e) {
+    // ignore
+  }
+  return isPlaceholder;
+}
+
+function findFirstTextNode(node: Node): Node | null {
+  if (isTextNode(node)) {
+    return node;
+  }
+  if (exclude && exclude(node as Element)) {
+    return null;
+  }
+
+  const children = node.childNodes;
+  for (let i = 0; i < children.length; i++) {
+    const textNode = findFirstTextNode(children[i]);
+    if (textNode) {
+      return textNode;
+    }
+  }
+
+  return null;
+}
+
+export function setCursorAtStartOfNode(node: Node): void {
+  const range = document.createRange();
+  const textNode = findFirstTextNode(node);
+
+  if (textNode) {
+    range.setStart(textNode, 0);
+    range.collapse(true); // 将选区折叠到起始位置
+  }
+
+  const selection = window.getSelection();
+  selection?.removeAllRanges();
+  selection?.addRange(range);
+}
+
+function findLastTextNode(node: Node): Node | null {
+  if (isTextNode(node)) {
+    return node;
+  }
+
+  if (exclude && exclude(node as Element)) {
+    return null;
+  }
+
+  const children = node.childNodes;
+  for (let i = children.length - 1; i >= 0; i--) {
+    const textNode = findLastTextNode(children[i]);
+    if (textNode) {
+      return textNode;
+    }
+  }
+
+  return null;
+}
+
+export function setCursorAtEndOfNode(node: Node): void {
+  const range = document.createRange();
+  const textNode = findLastTextNode(node);
+
+  if (textNode) {
+    const textLength = textNode.textContent?.length || 0;
+    range.setStart(textNode, textLength);
+    range.setEnd(textNode, textLength);
+  }
+
+  const selection = window.getSelection();
+  selection?.removeAllRanges();
+  selection?.addRange(range);
+}
+
+export function setFullRangeAtNode(node: Node): void {
+  const range = document.createRange();
+  const firstTextNode = findFirstTextNode(node);
+  const lastTextNode = findLastTextNode(node);
+  if (!firstTextNode || !lastTextNode) return;
+  range.setStart(firstTextNode, 0);
+  range.setEnd(lastTextNode, lastTextNode.textContent?.length || 0);
+  const selection = window.getSelection();
+  selection?.removeAllRanges();
+  selection?.addRange(range);
+}
+
+export function getBlockIdByPoint(target: HTMLElement | null) {
+  let node = target;
+  while (node) {
+    const id = node.getAttribute('data-block-id');
+    if (id) {
+      return id;
+    }
+    node = node.parentElement;
+  }
+  return null;
+}
+
+export function findTextBoxParent(target: HTMLElement | null) {
+  let node = target;
+  while (node) {
+    if (node.getAttribute('role') === 'textbox') {
+      return node;
+    }
+    node = node.parentElement;
+  }
+  return null;
+}
+
+export function isFocused(blockId: string) {
+  const selection = window.getSelection();
+  if (!selection) return false;
+  const { anchorNode, focusNode } = selection;
+  if (!anchorNode || !focusNode) return false;
+  const anchorElement = anchorNode.parentElement;
+  const focusElement = focusNode.parentElement;
+  if (!anchorElement || !focusElement) return false;
+  const anchorBlockId = getBlockIdByPoint(anchorElement);
+  const focusBlockId = getBlockIdByPoint(focusElement);
+  return anchorBlockId === blockId || focusBlockId === blockId;
+}
+
+export function getNode(id: string) {
+  return document.querySelector(`[data-block-id="${id}"]`);
+}
+
+export function isPointInBlock(target: HTMLElement | null) {
+  let node = target;
+  while (node) {
+    if (node.getAttribute('data-block-id')) {
+      return true;
+    }
+    node = node.parentElement;
+  }
+  return false;
+}
+
+export function findTextNode(
+  node: Element,
+  index: number,
+): {
+  node?: Node;
+  offset?: number;
+  remainingIndex?: number;
+} {
+  if (isTextNode(node)) {
+    const textLength = node.textContent?.length || 0;
+    if (index <= textLength) {
+      return { node, offset: index };
+    }
+    return { remainingIndex: index - textLength };
+  }
+
+  if (exclude && exclude(node)) {
+    return { remainingIndex: index };
+  }
+  let remainingIndex = index;
+  for (const childNode of node.childNodes) {
+    const result = findTextNode(childNode as Element, remainingIndex);
+    if (result.node) {
+      return result;
+    }
+    remainingIndex = result.remainingIndex || index;
+  }
+
+  return { remainingIndex };
+}
+
+export function focusNodeByIndex(node: Element, index: number, length: number) {
+  const textBoxNode = node.querySelector(`[role="textbox"]`);
+  if (!textBoxNode) return;
+  const anchorNode = findTextNode(textBoxNode, index);
+  const focusNode = findTextNode(textBoxNode, index + length);
+
+  if (!anchorNode?.node || !focusNode?.node) return;
+
+  const range = document.createRange();
+  range.setStart(anchorNode.node, anchorNode.offset || 0);
+  range.setEnd(focusNode.node, focusNode.offset || 0);
+
+  const selection = window.getSelection();
+  selection?.removeAllRanges();
+  selection?.addRange(range);
+}
+
+
+export function getNodeTextBoxByBlockId(blockId: string) {
+  const node = getNode(blockId);
+  return node?.querySelector(`[role="textbox"]`);
+}
+
+export function getNodeText(node: Element) {
+  if (isTextNode(node)) {
+    return node.textContent || '';
+  }
+  if (exclude && exclude(node)) {
+    return '';
+  }
+  let text = '';
+  for (const childNode of node.childNodes) {
+    text += getNodeText(childNode as Element);
+  }
+  return replaceZeroWidthSpace(text);
+}
+
+export function replaceZeroWidthSpace(text: string) {
+  // Unicode has the following characters that are invisible and have no width:
+  // \u200B - zero width space
+  // \u200C - zero width non-joiner
+  // \u200D - zero width joiner
+  // \uFEFF - zero width no-break space
+  return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
+}
+
+export function findParent(node: Element, parentSelector: string) {
+  let parentNode: Element | null = node;
+  while (parentNode) {
+    if (parentNode.matches(parentSelector)) {
+      return parentNode;
+    }
+    parentNode = parentNode.parentElement;
+  }
+  return null;
+}

+ 59 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts

@@ -0,0 +1,59 @@
+import { Op } from 'quill-delta';
+import { TextAction } from '$app/interfaces/document';
+
+export function adaptDeltaForQuill(inputOps: Op[], isOutput = false): Op[] {
+  if (inputOps.length === 0) {
+    return inputOps;
+  }
+
+  // quill attribute -> custom attribute
+  const attributeMapping = {
+    strike: TextAction.Strikethrough,
+  };
+
+  const newOps = inputOps.map((op) => {
+    if (!op.attributes) return op;
+    const newOpAttributes = { ...op.attributes };
+
+    Object.entries(attributeMapping).forEach(([attribute, customAttribute]) => {
+      if (isOutput) {
+        if (attribute in newOpAttributes) {
+          newOpAttributes[customAttribute] = newOpAttributes[attribute];
+          delete newOpAttributes[attribute];
+        }
+      } else {
+        if (customAttribute in newOpAttributes) {
+          newOpAttributes[attribute] = newOpAttributes[customAttribute];
+          delete newOpAttributes[customAttribute];
+        }
+      }
+    });
+
+    return {
+      ...op,
+      attributes: newOpAttributes,
+    };
+  });
+
+  const lastOpIndex = newOps.length - 1;
+  const lastOp = newOps[lastOpIndex];
+  const text = lastOp.insert as string;
+  const endsWithNewline = text.endsWith('\n');
+
+  if (isOutput && !endsWithNewline) {
+    return newOps;
+  }
+
+  if (isOutput) {
+    const newText = text.slice(0, -1);
+    if (newText !== '') {
+      newOps[lastOpIndex] = { ...lastOp, insert: newText };
+    } else {
+      newOps.pop();
+    }
+  } else {
+    newOps.push({ insert: '\n' });
+  }
+
+  return newOps;
+}

+ 134 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts

@@ -0,0 +1,134 @@
+import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from "slate";
+import Delta from "quill-delta";
+import { getLineByIndex } from "$app/utils/document/delta";
+
+export function convertToSlateSelection(index: number, length: number, slateValue: Descendant[]){
+  if (!slateValue || slateValue.length === 0) return null;
+  const texts = (slateValue[0] as BaseElement).children.map((child) => (child as Text).text);
+  const anchorIndex = index;
+  const focusIndex = index + length;
+  let anchorPath: number[] = [];
+  let focusPath: number[] = [];
+  let anchorOffset = 0;
+  let focusOffset = 0;
+  let charCount = 0;
+  texts.forEach((text, i) => {
+    const endOffset = charCount + text.length;
+    if (anchorIndex >= charCount && anchorIndex <= endOffset) {
+      anchorPath = [0, i];
+      anchorOffset = anchorIndex - charCount;
+    }
+    if (focusIndex >= charCount && focusIndex <= endOffset) {
+      focusPath = [0, i];
+      focusOffset = focusIndex - charCount;
+    }
+    charCount += text.length;
+  });
+  return {
+    anchor: {
+      path: anchorPath,
+      offset: anchorOffset,
+    },
+    focus: {
+      path: focusPath,
+      offset: focusOffset,
+    },
+  };
+}
+
+export function converToIndexLength(editor: Editor, range: Selection) {
+  if (!range) return null;
+  const start = Editor.start(editor, [0, 0]);
+  const before = Editor.start(editor, range);
+  const after = Editor.end(editor, range);
+  const index = Editor.string(editor, {
+    anchor: start,
+    focus: before,
+  }).length;
+  const focusIndex = Editor.string(editor, {
+    anchor: start,
+    focus: after,
+  }).length;
+  const length = focusIndex - index;
+  return { index, length };
+}
+
+export function convertToSlateValue(delta: Delta): Descendant[] {
+  const ops = delta.ops;
+  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+  // @ts-ignore
+  const children: Text[] =
+    ops.length === 0
+      ? [
+          {
+            text: '',
+          },
+        ]
+      : ops.map((op) => ({
+          text: op.insert || '',
+          ...op.attributes,
+        }));
+
+  return [
+    {
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      type: 'paragraph',
+      children,
+    },
+  ];
+}
+
+export function convertToDelta(slateValue: Descendant[]) {
+  const ops = (slateValue[0] as Element).children.map((child) => {
+    const { text, ...attributes } = child as Text;
+    return {
+      insert: text,
+      attributes,
+    };
+  });
+  return new Delta(ops);
+}
+
+function getBreakLineBeginPoint(editor: Editor, at: Selection): BasePoint | undefined {
+  const delta = convertToDelta(editor.children);
+  const currentSelection = converToIndexLength(editor, at);
+  if (!currentSelection) return;
+  const { index } = getLineByIndex(delta, currentSelection.index);
+  const selection = convertToSlateSelection(index, 0, editor.children);
+  return selection?.anchor;
+}
+
+export function indent(editor: Editor, distance: number) {
+  const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
+  if (!beginPoint) return;
+  const emptyStr = "".padStart(distance);
+
+  editor.insertText(emptyStr, {
+    at: beginPoint
+  });
+}
+
+export function outdent(editor: Editor, distance: number) {
+  const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
+  if (!beginPoint) return;
+  const afterBeginPoint = Editor.after(editor, beginPoint, {
+    distance
+  });
+  if (!afterBeginPoint) return;
+  const deleteChar = Editor.string(editor, {
+    anchor: beginPoint,
+    focus: afterBeginPoint
+  });
+  const emptyStr = "".padStart(distance);
+  if (deleteChar !== emptyStr) {
+    if (distance > 1) {
+      outdent(editor, distance - 1);
+    }
+    return;
+  }
+  editor.delete({
+    at: beginPoint,
+    distance
+  });
+}

+ 19 - 5
frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts

@@ -1,4 +1,4 @@
-export function calcToolbarPosition(toolbarDom: HTMLDivElement) {
+export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, container: HTMLDivElement) {
   const domSelection = window.getSelection();
   const domSelection = window.getSelection();
   let domRange;
   let domRange;
   if (domSelection?.rangeCount === 0) {
   if (domSelection?.rangeCount === 0) {
@@ -7,13 +7,27 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement) {
     domRange = domSelection?.getRangeAt(0);
     domRange = domSelection?.getRangeAt(0);
   }
   }
 
 
+  const nodeRect = node.getBoundingClientRect();
   const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
   const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
 
 
-  let top = rect.top - toolbarDom.offsetHeight;
-  let left = rect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
+  const top = rect.top - nodeRect.top - toolbarDom.offsetHeight;
+  let left = rect.left - nodeRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
+
+  // fix toolbar position when it is out of the container
+  const containerRect = container.getBoundingClientRect();
+  const leftBound = containerRect.left - nodeRect.left;
+  const rightBound = containerRect.right;
+
+  const rightThreshold = 20;
+  if (left < leftBound) {
+    left = leftBound;
+  } else if (left + nodeRect.left + toolbarDom.offsetWidth > rightBound) {
+    left = rightBound - toolbarDom.offsetWidth - nodeRect.left - rightThreshold;
+  }
+
 
 
   return {
   return {
-    top: top + 'px',
-    left: left + 'px',
+    top,
+    left,
   };
   };
 }
 }

+ 4 - 0
frontend/appflowy_tauri/tailwind.config.cjs

@@ -46,6 +46,10 @@ module.exports = {
           3: '#E2E4EB',
           3: '#E2E4EB',
           fiol: '#2C144B',
           fiol: '#2C144B',
         },
         },
+        custom: {
+          code: 'rgba(221, 221, 221, 0.4)',
+          caret: 'rgb(55, 53, 47)'
+        }
       },
       },
       boxShadow: {
       boxShadow: {
         md: '0px 0px 20px rgba(0, 0, 0, 0.1);',
         md: '0px 0px 20px rgba(0, 0, 0, 0.1);',