Explorar o código

feat: support text equation (#2876)

Kilu.He hai 1 ano
pai
achega
5e0a0f92ff
Modificáronse 67 ficheiros con 1674 adicións e 329 borrados
  1. 5 0
      frontend/appflowy_tauri/package.json
  2. 52 0
      frontend/appflowy_tauri/pnpm-lock.yaml
  3. 55 22
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
  4. 2 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  5. 2 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  6. 31 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts
  7. 4 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts
  8. 5 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
  9. 79 25
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  10. 0 34
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx
  11. 9 12
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts
  12. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx
  13. 75 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/shortchut.ts
  14. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
  15. 30 13
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts
  16. 5 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts
  17. 10 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts
  18. 144 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx
  19. 31 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/inline.css
  20. 9 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.tsx
  21. 5 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx
  22. 35 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
  23. 156 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/markdown.ts
  24. 21 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
  25. 4 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
  26. 4 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts
  27. 2 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts
  28. 7 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  29. 3 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts
  30. 21 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts
  31. 2 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts
  32. 11 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeTemporary.hooks.ts
  33. 50 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/EquationEditContent.tsx
  34. 19 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx
  35. 135 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx
  36. 77 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx
  37. 1 12
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx
  38. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx
  39. 0 3
      frontend/appflowy_tauri/src/appflowy_app/constants/document/block.ts
  40. 4 81
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  41. 7 4
      frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts
  42. 12 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts
  43. 23 18
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  44. 1 1
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
  45. 3 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts
  46. 3 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts
  47. 5 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts
  48. 7 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts
  49. 5 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts
  50. 10 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts
  51. 4 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
  52. 22 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts
  53. 12 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
  54. 44 11
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
  55. 13 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts
  56. 8 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
  57. 25 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
  58. 115 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts
  59. 41 19
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  60. 37 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/temporary_slice.ts
  61. 3 2
      frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
  62. 53 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts
  63. 48 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
  64. 46 11
      frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts
  65. 1 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts
  66. 14 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/temporary.ts
  67. 3 0
      frontend/appflowy_tauri/src/appflowy_app/utils/env.ts

+ 5 - 0
frontend/appflowy_tauri/package.json

@@ -27,12 +27,14 @@
     "@tauri-apps/api": "^1.2.0",
     "@tauri-apps/api": "^1.2.0",
     "dayjs": "^1.11.7",
     "dayjs": "^1.11.7",
     "emoji-mart": "^5.5.2",
     "emoji-mart": "^5.5.2",
+    "emoji-regex": "^10.2.1",
     "events": "^3.3.0",
     "events": "^3.3.0",
     "google-protobuf": "^3.21.2",
     "google-protobuf": "^3.21.2",
     "i18next": "^22.4.10",
     "i18next": "^22.4.10",
     "i18next-browser-languagedetector": "^7.0.1",
     "i18next-browser-languagedetector": "^7.0.1",
     "is-hotkey": "^0.2.0",
     "is-hotkey": "^0.2.0",
     "jest": "^29.5.0",
     "jest": "^29.5.0",
+    "katex": "^0.16.7",
     "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",
@@ -44,6 +46,7 @@
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-error-boundary": "^3.1.4",
     "react-error-boundary": "^3.1.4",
     "react-i18next": "^12.2.0",
     "react-i18next": "^12.2.0",
+    "react-katex": "^3.0.1",
     "react-redux": "^8.0.5",
     "react-redux": "^8.0.5",
     "react-router-dom": "^6.8.0",
     "react-router-dom": "^6.8.0",
     "react18-input-otp": "^1.1.2",
     "react18-input-otp": "^1.1.2",
@@ -60,12 +63,14 @@
     "@tauri-apps/cli": "^1.2.2",
     "@tauri-apps/cli": "^1.2.2",
     "@types/google-protobuf": "^3.15.6",
     "@types/google-protobuf": "^3.15.6",
     "@types/is-hotkey": "^0.1.7",
     "@types/is-hotkey": "^0.1.7",
+    "@types/katex": "^0.16.0",
     "@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/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",
+    "@types/react-katex": "^3.0.0",
     "@types/utf8": "^3.0.1",
     "@types/utf8": "^3.0.1",
     "@types/uuid": "^9.0.1",
     "@types/uuid": "^9.0.1",
     "@typescript-eslint/eslint-plugin": "^5.51.0",
     "@typescript-eslint/eslint-plugin": "^5.51.0",

+ 52 - 0
frontend/appflowy_tauri/pnpm-lock.yaml

@@ -37,6 +37,9 @@ dependencies:
   emoji-mart:
   emoji-mart:
     specifier: ^5.5.2
     specifier: ^5.5.2
     version: 5.5.2
     version: 5.5.2
+  emoji-regex:
+    specifier: ^10.2.1
+    version: 10.2.1
   events:
   events:
     specifier: ^3.3.0
     specifier: ^3.3.0
     version: 3.3.0
     version: 3.3.0
@@ -55,6 +58,9 @@ dependencies:
   jest:
   jest:
     specifier: ^29.5.0
     specifier: ^29.5.0
     version: 29.5.0(@types/[email protected])
     version: 29.5.0(@types/[email protected])
+  katex:
+    specifier: ^0.16.7
+    version: 0.16.7
   nanoid:
   nanoid:
     specifier: ^4.0.0
     specifier: ^4.0.0
     version: 4.0.2
     version: 4.0.2
@@ -88,6 +94,9 @@ dependencies:
   react-i18next:
   react-i18next:
     specifier: ^12.2.0
     specifier: ^12.2.0
     version: 12.2.2([email protected])([email protected])([email protected])
     version: 12.2.2([email protected])([email protected])([email protected])
+  react-katex:
+    specifier: ^3.0.1
+    version: 3.0.1([email protected])([email protected])
   react-redux:
   react-redux:
     specifier: ^8.0.5
     specifier: ^8.0.5
     version: 8.0.5(@types/[email protected])(@types/[email protected])([email protected])([email protected])([email protected])
     version: 8.0.5(@types/[email protected])(@types/[email protected])([email protected])([email protected])([email protected])
@@ -132,6 +141,9 @@ devDependencies:
   '@types/is-hotkey':
   '@types/is-hotkey':
     specifier: ^0.1.7
     specifier: ^0.1.7
     version: 0.1.7
     version: 0.1.7
+  '@types/katex':
+    specifier: ^0.16.0
+    version: 0.16.0
   '@types/node':
   '@types/node':
     specifier: ^18.7.10
     specifier: ^18.7.10
     version: 18.16.9
     version: 18.16.9
@@ -150,6 +162,9 @@ devDependencies:
   '@types/react-dom':
   '@types/react-dom':
     specifier: ^18.0.6
     specifier: ^18.0.6
     version: 18.2.4
     version: 18.2.4
+  '@types/react-katex':
+    specifier: ^3.0.0
+    version: 3.0.0
   '@types/utf8':
   '@types/utf8':
     specifier: ^3.0.1
     specifier: ^3.0.1
     version: 3.0.1
     version: 3.0.1
@@ -1632,6 +1647,10 @@ packages:
     resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
     resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
     dev: true
     dev: true
 
 
+  /@types/[email protected]:
+    resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==}
+    dev: true
+
   /@types/[email protected]:
   /@types/[email protected]:
     resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==}
     resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==}
     dependencies:
     dependencies:
@@ -1684,6 +1703,12 @@ packages:
       '@types/react': 17.0.59
       '@types/react': 17.0.59
     dev: false
     dev: false
 
 
+  /@types/[email protected]:
+    resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==}
+    dependencies:
+      '@types/react': 18.2.6
+    dev: true
+
   /@types/[email protected]:
   /@types/[email protected]:
     resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==}
     resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==}
     dependencies:
     dependencies:
@@ -2280,6 +2305,11 @@ packages:
     engines: {node: '>= 6'}
     engines: {node: '>= 6'}
     dev: true
     dev: true
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+    engines: {node: '>= 12'}
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
     resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
     dev: false
     dev: false
@@ -2437,6 +2467,10 @@ packages:
     resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
     resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
     dev: false
     dev: false
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==}
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
     dev: false
     dev: false
@@ -3799,6 +3833,13 @@ packages:
       object.assign: 4.1.4
       object.assign: 4.1.4
     dev: true
     dev: true
 
 
+  /[email protected]:
+    resolution: {integrity: sha512-Xk9C6oGKRwJTfqfIbtr0Kes9OSv6IFsuhFGc7tW4urlpMJtuh+7YhzU6YEG9n8gmWKcMAFzkp7nr+r69kV0zrA==}
+    hasBin: true
+    dependencies:
+      commander: 8.3.0
+    dev: false
+
   /[email protected]:
   /[email protected]:
     resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
     resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
     engines: {node: '>=6'}
     engines: {node: '>=6'}
@@ -4483,6 +4524,17 @@ packages:
     resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
     resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
     dev: false
     dev: false
 
 
+  /[email protected]([email protected])([email protected]):
+    resolution: {integrity: sha512-wIUW1fU5dHlkKvq4POfDkHruQsYp3fM8xNb/jnc8dnQ+nNCnaj0sx5pw7E6UyuEdLRyFKK0HZjmXBo+AtXXy0A==}
+    peerDependencies:
+      prop-types: ^15.8.1
+      react: '>=15.3.2 <=18'
+    dependencies:
+      katex: 0.16.7
+      prop-types: 15.8.1
+      react: 18.2.0
+    dev: false
+
   /[email protected]([email protected])([email protected]):
   /[email protected]([email protected])([email protected]):
     resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
     resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
     peerDependencies:
     peerDependencies:

+ 55 - 22
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts

@@ -11,8 +11,13 @@ import {
 import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
 import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
 import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import { isApple } from '$app/utils/env';
+
+const onFrameTime = 1000 / 60;
 
 
 export function useBlockRangeSelection(container: HTMLDivElement) {
 export function useBlockRangeSelection(container: HTMLDivElement) {
+  const timeStampRef = useRef(0);
+
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
   const onKeyDown = useRangeKeyDown();
   const onKeyDown = useRangeKeyDown();
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
@@ -36,10 +41,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
   useEffect(() => {
   useEffect(() => {
     if (!range) return;
     if (!range) return;
     const { anchor, focus } = range;
     const { anchor, focus } = range;
+
     if (!anchor || !focus) {
     if (!anchor || !focus) {
       container.classList.remove('caret-transparent');
       container.classList.remove('caret-transparent');
       return;
       return;
     }
     }
+
     // if the focus block is different from the anchor block, we need to set the caret transparent
     // if the focus block is different from the anchor block, we need to set the caret transparent
     if (focus.id !== anchor.id) {
     if (focus.id !== anchor.id) {
       container.classList.add('caret-transparent');
       container.classList.add('caret-transparent');
@@ -50,18 +57,21 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
 
 
   useEffect(() => {
   useEffect(() => {
     const anchor = anchorRef.current;
     const anchor = anchorRef.current;
+
     if (!anchor || !focus) return;
     if (!anchor || !focus) return;
     const selection = window.getSelection();
     const selection = window.getSelection();
+
     if (!selection) return;
     if (!selection) return;
     // update focus point
     // update focus point
     dispatch(
     dispatch(
       rangeActions.setFocusPoint({
       rangeActions.setFocusPoint({
-        ...focus,
+        focusPoint: focus,
         docId,
         docId,
       })
       })
     );
     );
 
 
     const focused = isFocused(focus.id);
     const focused = isFocused(focus.id);
+
     // if the focus block is not focused, we need to set the cursor position
     // if the focus block is not focused, we need to set the cursor position
     if (!focused) {
     if (!focused) {
       // if the focus block is the same as the anchor block, we just update the anchor's range
       // if the focus block is the same as the anchor block, we just update the anchor's range
@@ -70,14 +80,17 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
           anchor.point.x - container.scrollLeft,
           anchor.point.x - container.scrollLeft,
           anchor.point.y - container.scrollTop
           anchor.point.y - container.scrollTop
         );
         );
+
         if (!range) return;
         if (!range) return;
         const selection = window.getSelection();
         const selection = window.getSelection();
+
         selection?.removeAllRanges();
         selection?.removeAllRanges();
         selection?.addRange(range);
         selection?.addRange(range);
         return;
         return;
       }
       }
 
 
       const node = getNodeTextBoxByBlockId(focus.id);
       const node = getNodeTextBoxByBlockId(focus.id);
+
       if (!node) return;
       if (!node) return;
       // if the selection is forward, we set the cursor position to the start of the focus block
       // if the selection is forward, we set the cursor position to the start of the focus block
       if (isForward) {
       if (isForward) {
@@ -89,15 +102,33 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
     }
     }
   }, [container, dispatch, docId, focus, isForward]);
   }, [container, dispatch, docId, focus, isForward]);
 
 
-  const handleDragStart = useCallback(
+  const handleDragEnd = useCallback(() => {
+    timeStampRef.current = Date.now();
+    if (!isDragging) return;
+    setFocus(null);
+    anchorRef.current = null;
+    dispatch(
+      rangeActions.setDragging({
+        isDragging: false,
+        docId,
+      })
+    );
+  }, [docId, dispatch, isDragging]);
+
+  const handleMouseDown = useCallback(
     (e: MouseEvent) => {
     (e: MouseEvent) => {
-      setForward(true);
+      const isTapToClick = isApple() && timeStampRef.current > 0 && Date.now() - timeStampRef.current < onFrameTime;
+
       // skip if the target is not a block
       // 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) {
         dispatch(rangeActions.initialState(docId));
         dispatch(rangeActions.initialState(docId));
         return;
         return;
       }
       }
+
+      setForward(true);
+
       dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
       dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
       const startX = e.clientX + container.scrollLeft;
       const startX = e.clientX + container.scrollLeft;
       const startY = e.clientY + container.scrollTop;
       const startY = e.clientY + container.scrollTop;
@@ -113,18 +144,25 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
       anchorRef.current = {
       anchorRef.current = {
         ...anchor,
         ...anchor,
       };
       };
+
       // set the anchor point and focus point
       // set the anchor point and focus point
-      dispatch(rangeActions.setAnchorPoint({ ...anchor, docId }));
-      dispatch(rangeActions.setFocusPoint({ ...anchor, docId }));
+      dispatch(rangeActions.setAnchorPoint({ docId, anchorPoint: anchor }));
+      dispatch(rangeActions.setFocusPoint({ docId, focusPoint: anchor }));
+
+      // This is a workaround for a bug in Safari where the mouseup event is not fired
+      if (isTapToClick) {
+        handleDragEnd();
+        return;
+      }
+
       dispatch(
       dispatch(
         rangeActions.setDragging({
         rangeActions.setDragging({
           isDragging: true,
           isDragging: true,
           docId,
           docId,
         })
         })
       );
       );
-      return;
     },
     },
-    [container.scrollLeft, container.scrollTop, dispatch, docId]
+    [container.scrollLeft, container.scrollTop, dispatch, docId, handleDragEnd]
   );
   );
 
 
   const handleDraging = useCallback(
   const handleDraging = useCallback(
@@ -133,12 +171,14 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
 
 
       // skip if the target is not a block
       // 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 endX = e.clientX + container.scrollLeft;
       const endY = e.clientY + container.scrollTop;
       const endY = e.clientY + container.scrollTop;
+
       // set the focus point
       // set the focus point
       setFocus({
       setFocus({
         id: blockId,
         id: blockId,
@@ -149,42 +189,35 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
       });
       });
       // set forward
       // set forward
       const anchorId = anchorRef.current.id;
       const anchorId = anchorRef.current.id;
+
       if (anchorId === blockId) {
       if (anchorId === blockId) {
         const startX = anchorRef.current.point.x;
         const startX = anchorRef.current.point.x;
+
         setForward(startX < endX);
         setForward(startX < endX);
         return;
         return;
       }
       }
+
       const startY = anchorRef.current.point.y;
       const startY = anchorRef.current.point.y;
+
       setForward(startY < endY);
       setForward(startY < endY);
     },
     },
     [container.scrollLeft, container.scrollTop, isDragging]
     [container.scrollLeft, container.scrollTop, isDragging]
   );
   );
 
 
-  const handleDragEnd = useCallback(() => {
-    if (!isDragging) return;
-    setFocus(null);
-    anchorRef.current = null;
-    dispatch(
-      rangeActions.setDragging({
-        isDragging: false,
-        docId,
-      })
-    );
-  }, [docId, dispatch, isDragging]);
-
   useEffect(() => {
   useEffect(() => {
-    document.addEventListener('mousedown', handleDragStart);
+    document.addEventListener('mousedown', handleMouseDown);
     document.addEventListener('mousemove', handleDraging);
     document.addEventListener('mousemove', handleDraging);
     document.addEventListener('mouseup', handleDragEnd);
     document.addEventListener('mouseup', handleDragEnd);
+
     container.addEventListener('keydown', onKeyDown, true);
     container.addEventListener('keydown', onKeyDown, true);
     return () => {
     return () => {
-      document.removeEventListener('mousedown', handleDragStart);
+      document.removeEventListener('mousedown', handleMouseDown);
       document.removeEventListener('mousemove', handleDraging);
       document.removeEventListener('mousemove', handleDraging);
       document.removeEventListener('mouseup', handleDragEnd);
       document.removeEventListener('mouseup', handleDragEnd);
 
 
       container.removeEventListener('keydown', onKeyDown, true);
       container.removeEventListener('keydown', onKeyDown, true);
     };
     };
-  }, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
+  }, [handleMouseDown, handleDragEnd, handleDraging, container, onKeyDown]);
 
 
   return null;
   return null;
 }
 }

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

@@ -10,6 +10,7 @@ import ToolbarButton from './ToolbarButton';
 import { rectSelectionActions } from '$app_reducers/document/slice';
 import { rectSelectionActions } from '$app_reducers/document/slice';
 import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
 import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
 
 
 export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
 export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
@@ -17,7 +18,7 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
 
 
   const { nodeId, style, ref } = useBlockSideToolbar({ container });
   const { nodeId, style, ref } = useBlockSideToolbar({ container });
   const isDragging = useAppSelector(
   const isDragging = useAppSelector(
-    (state) => state.documentRange[docId]?.isDragging || state.documentRectSelection[docId]?.isDragging
+    (state) => state[RANGE_NAME][docId]?.isDragging || state[RECT_RANGE_NAME][docId]?.isDragging
   );
   );
   const { handleOpen, ...popoverProps } = usePopover();
   const { handleOpen, ...popoverProps } = usePopover();
 
 

+ 2 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx

@@ -7,6 +7,7 @@ import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy
 import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
 import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
 import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
 import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
 import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
 import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
+import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover';
 
 
 export default function Overlay({ container }: { container: HTMLDivElement }) {
 export default function Overlay({ container }: { container: HTMLDivElement }) {
   useCopy(container);
   useCopy(container);
@@ -19,6 +20,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
       <BlockSelection container={container} />
       <BlockSelection container={container} />
       <BlockSlash container={container} />
       <BlockSlash container={container} />
       <LinkEditPopover />
       <LinkEditPopover />
+      <TemporaryPopover />
     </>
     </>
   );
   );
 }
 }

+ 31 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts

@@ -0,0 +1,31 @@
+import { TextAction, TextActionMenuProps } from '$app/interfaces/document';
+
+export const defaultTextActionItems = [
+  TextAction.Turn,
+  TextAction.Link,
+  TextAction.Bold,
+  TextAction.Italic,
+  TextAction.Underline,
+  TextAction.Strikethrough,
+  TextAction.Code,
+  TextAction.Equation,
+];
+const groupKeys = {
+  comment: [],
+  format: [
+    TextAction.Bold,
+    TextAction.Italic,
+    TextAction.Underline,
+    TextAction.Strikethrough,
+    TextAction.Code,
+    TextAction.Equation,
+  ],
+  link: [TextAction.Link],
+  turn: [TextAction.Turn],
+};
+
+export const multiLineTextActionProps: TextActionMenuProps = {
+  customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
+};
+export const multiLineTextActionGroups = [groupKeys.format];
+export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];

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

@@ -1,6 +1,5 @@
-import { useCallback, useContext, useEffect, useMemo, 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 { getNode } from '$app/utils/document/node';
 import { getNode } from '$app/utils/document/node';
 import { debounce } from '$app/utils/tool';
 import { debounce } from '$app/utils/tool';
 import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
 import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
@@ -15,9 +14,11 @@ export function useMenuStyle(container: HTMLDivElement) {
 
 
   const reCalculatePosition = useCallback(() => {
   const reCalculatePosition = useCallback(() => {
     const el = ref.current;
     const el = ref.current;
+
     if (!el || !id) return;
     if (!el || !id) return;
 
 
     const node = getNode(id);
     const node = getNode(id);
+
     if (!node) return;
     if (!node) return;
     const position = calcToolbarPosition(el, node, container);
     const position = calcToolbarPosition(el, node, container);
 
 
@@ -50,6 +51,7 @@ export function useMenuStyle(container: HTMLDivElement) {
       setIsScrolling(true);
       setIsScrolling(true);
       debounceScrollEnd();
       debounceScrollEnd();
     };
     };
+
     container.addEventListener('scroll', handleScroll);
     container.addEventListener('scroll', handleScroll);
     return () => {
     return () => {
       debounceScrollEnd.cancel();
       debounceScrollEnd.cancel();

+ 5 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx

@@ -27,10 +27,12 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
     </BlockPortal>
     </BlockPortal>
   );
   );
 };
 };
+
 const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
 const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
   const range = useSubscribeRanges();
   const range = useSubscribeRanges();
   const canShow = useMemo(() => {
   const canShow = useMemo(() => {
     const { isDragging, focus, anchor, ranges, caret } = range;
     const { isDragging, focus, anchor, ranges, caret } = range;
+
     // don't show if dragging
     // don't show if dragging
     if (isDragging) return false;
     if (isDragging) return false;
     // don't show if no focus or anchor
     // don't show if no focus or anchor
@@ -39,9 +41,10 @@ const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
 
 
     // show toolbar if range has multiple nodes
     // show toolbar if range has multiple nodes
     if (!isSameLine) return true;
     if (!isSameLine) return true;
-    const caretRange = ranges[caret.id];
-    // don't show if no caret range
+    const caretRange = ranges?.[caret.id];
+
     if (!caretRange) return false;
     if (!caretRange) return false;
+
     // show toolbar if range is not collapsed
     // show toolbar if range is not collapsed
     return caretRange.length > 0;
     return caretRange.length > 0;
   }, [range]);
   }, [range]);

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

@@ -1,37 +1,36 @@
 import IconButton from '@mui/material/IconButton';
 import IconButton from '@mui/material/IconButton';
-import FormatIcon from './FormatIcon';
-import React, { useCallback, useEffect, useMemo, useContext } from 'react';
-import { TextAction } from '$app/interfaces/document';
+import React, { useCallback, useEffect, useMemo } from 'react';
+import { TemporaryType, TextAction } from '$app/interfaces/document';
 import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
 import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
 import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
 import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { newLinkThunk } from '$app_reducers/document/async-actions/link';
 import { newLinkThunk } from '$app_reducers/document/async-actions/link';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { RANGE_NAME } from '$app/constants/document/name';
+import { createTemporary } from '$app_reducers/document/async-actions/temporary';
+import {
+  CodeOutlined,
+  FormatBold,
+  FormatItalic,
+  FormatUnderlined,
+  Functions,
+  StrikethroughSOutlined,
+} from '@mui/icons-material';
+import LinkIcon from '@mui/icons-material/AddLink';
+
+export const iconSize = { width: 18, height: 18 };
 
 
 const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
 const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
   const { docId, controller } = useSubscribeDocument();
   const { docId, controller } = useSubscribeDocument();
 
 
-  const focusId = useAppSelector((state) => state.documentRange[docId]?.focus?.id || '');
+  const focusId = useAppSelector((state) => state[RANGE_NAME][docId]?.focus?.id || '');
   const { node: focusNode } = useSubscribeNode(focusId);
   const { node: focusNode } = useSubscribeNode(focusId);
 
 
   const [isActive, setIsActive] = React.useState(false);
   const [isActive, setIsActive] = React.useState(false);
   const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
   const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
 
 
-  const formatTooltips: Record<string, string> = useMemo(
-    () => ({
-      [TextAction.Bold]: 'Bold',
-      [TextAction.Italic]: 'Italic',
-      [TextAction.Underline]: 'Underline',
-      [TextAction.Strikethrough]: 'Strike through',
-      [TextAction.Code]: 'Mark as Code',
-      [TextAction.Link]: 'Add Link',
-    }),
-    []
-  );
-
   const isFormatActive = useCallback(async () => {
   const isFormatActive = useCallback(async () => {
     if (!focusNode) return false;
     if (!focusNode) return false;
     const { payload: isActive } = await dispatch(
     const { payload: isActive } = await dispatch(
@@ -40,6 +39,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
         docId,
         docId,
       })
       })
     );
     );
+
     return !!isActive;
     return !!isActive;
   }, [docId, dispatch, format, focusNode]);
   }, [docId, dispatch, format, focusNode]);
 
 
@@ -65,6 +65,34 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
     );
     );
   }, [dispatch, docId]);
   }, [dispatch, docId]);
 
 
+  const addTemporaryInput = useCallback(
+    (type: TemporaryType) => {
+      dispatch(createTemporary({ type, docId }));
+    },
+    [dispatch, docId]
+  );
+
+  useEffect(() => {
+    void (async () => {
+      const isActive = await isFormatActive();
+
+      setIsActive(isActive);
+    })();
+  }, [isFormatActive]);
+
+  const formatTooltips: Record<string, string> = useMemo(
+    () => ({
+      [TextAction.Bold]: 'Bold',
+      [TextAction.Italic]: 'Italic',
+      [TextAction.Underline]: 'Underline',
+      [TextAction.Strikethrough]: 'Strike through',
+      [TextAction.Code]: 'Mark as Code',
+      [TextAction.Link]: 'Add Link',
+      [TextAction.Equation]: 'Create equation',
+    }),
+    []
+  );
+
   const formatClick = useCallback(
   const formatClick = useCallback(
     (format: TextAction) => {
     (format: TextAction) => {
       switch (format) {
       switch (format) {
@@ -76,22 +104,48 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
           return toggleFormat(format);
           return toggleFormat(format);
         case TextAction.Link:
         case TextAction.Link:
           return addLink();
           return addLink();
+        case TextAction.Equation:
+          return addTemporaryInput(TemporaryType.Equation);
       }
       }
     },
     },
-    [addLink, toggleFormat]
+    [addLink, addTemporaryInput, toggleFormat]
   );
   );
 
 
-  useEffect(() => {
-    void (async () => {
-      const isActive = await isFormatActive();
-      setIsActive(isActive);
-    })();
-  }, [isFormatActive]);
+  const formatIcon = useMemo(() => {
+    switch (icon) {
+      case TextAction.Bold:
+        return <FormatBold sx={iconSize} />;
+      case TextAction.Underline:
+        return <FormatUnderlined sx={iconSize} />;
+      case TextAction.Italic:
+        return <FormatItalic sx={iconSize} />;
+      case TextAction.Code:
+        return <CodeOutlined sx={iconSize} />;
+      case TextAction.Strikethrough:
+        return <StrikethroughSOutlined sx={iconSize} />;
+      case TextAction.Link:
+        return (
+          <div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
+            <LinkIcon
+              sx={{
+                fontSize: '1.2rem',
+                marginRight: '0.25rem',
+              }}
+            />
+            <div className={'underline'}>Link</div>
+          </div>
+        );
+      case TextAction.Equation:
+        return <Functions sx={iconSize} />;
+      default:
+        return null;
+    }
+  }, [icon]);
 
 
   return (
   return (
     <MenuTooltip title={formatTooltips[format]}>
     <MenuTooltip title={formatTooltips[format]}>
       <IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
       <IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
-        <FormatIcon icon={icon} />
+        {formatIcon}
       </IconButton>
       </IconButton>
     </MenuTooltip>
     </MenuTooltip>
   );
   );

+ 0 - 34
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx

@@ -1,34 +0,0 @@
-import React from 'react';
-import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
-import { TextAction } from '$app/interfaces/document';
-import LinkIcon from '@mui/icons-material/AddLink';
-export const iconSize = { width: 18, height: 18 };
-
-export default function FormatIcon({ icon }: { icon: string }) {
-  switch (icon) {
-    case TextAction.Bold:
-      return <FormatBold sx={iconSize} />;
-    case TextAction.Underline:
-      return <FormatUnderlined sx={iconSize} />;
-    case TextAction.Italic:
-      return <FormatItalic sx={iconSize} />;
-    case TextAction.Code:
-      return <CodeOutlined sx={iconSize} />;
-    case TextAction.Strikethrough:
-      return <StrikethroughSOutlined sx={iconSize} />;
-    case TextAction.Link:
-      return (
-        <div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
-          <LinkIcon
-            sx={{
-              fontSize: '1.2rem',
-              marginRight: '0.25rem',
-            }}
-          />
-          <div className={'underline'}>Link</div>
-        </div>
-      );
-    default:
-      return null;
-  }
-}

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

@@ -1,14 +1,13 @@
 import { useMemo } from 'react';
 import { useMemo } from 'react';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { BlockType, TextAction } from '$app/interfaces/document';
+import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
 import {
 import {
-  blockConfig,
-  defaultTextActionProps,
+  defaultTextActionItems,
   multiLineTextActionGroups,
   multiLineTextActionGroups,
   multiLineTextActionProps,
   multiLineTextActionProps,
   textActionGroups,
   textActionGroups,
-} from '$app/constants/document/config';
-import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
-import { TextAction } from '$app/interfaces/document';
-import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
+} from '$app/components/document/TextActionMenu/config';
 
 
 export function useTextActionMenu() {
 export function useTextActionMenu() {
   const range = useSubscribeRanges();
   const range = useSubscribeRanges();
@@ -22,12 +21,9 @@ export function useTextActionMenu() {
   const items = useMemo(() => {
   const items = useMemo(() => {
     if (!node) return [];
     if (!node) return [];
     if (isSingleLine) {
     if (isSingleLine) {
-      const config = blockConfig[node.type];
-      const { customItems, excludeItems } = {
-        ...defaultTextActionProps,
-        ...config.textActionMenuProps,
-      };
-      return customItems?.filter((item) => !excludeItems?.includes(item)) || [];
+      const excludeItems = node.type === BlockType.CodeBlock ? [TextAction.Code] : [];
+
+      return defaultTextActionItems?.filter((item) => !excludeItems?.includes(item)) || [];
     } else {
     } else {
       return multiLineTextActionProps.customItems || [];
       return multiLineTextActionProps.customItems || [];
     }
     }
@@ -36,6 +32,7 @@ export function useTextActionMenu() {
   // 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(() => {
     const groups = node ? textActionGroups : multiLineTextActionGroups;
     const groups = node ? textActionGroups : multiLineTextActionGroups;
+
     return groups.map((group) => {
     return groups.map((group) => {
       return group.filter((item) => items.includes(item));
       return group.filter((item) => items.includes(item));
     });
     });

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

@@ -17,6 +17,7 @@ function TextActionMenuList() {
         case TextAction.Underline:
         case TextAction.Underline:
         case TextAction.Strikethrough:
         case TextAction.Strikethrough:
         case TextAction.Code:
         case TextAction.Code:
+        case TextAction.Equation:
           return <FormatButton format={action} icon={action} />;
           return <FormatButton format={action} icon={action} />;
         default:
         default:
           return null;
           return null;

+ 75 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/shortchut.ts

@@ -0,0 +1,75 @@
+import { Keyboard } from '$app/constants/document/keyboard';
+import { BlockType } from '$app/interfaces/document';
+
+export const turnIntoShortcuts = {
+  [Keyboard.keys.SPACE]: [
+    {
+      type: BlockType.HeadingBlock,
+      /**
+       * # or ## or ###
+       */
+      markdownRegexp: /^(#{1,3})(\s)+$/,
+    },
+    {
+      type: BlockType.TodoListBlock,
+      /**
+       * -[] or -[x] or -[ ] or [] or [x] or [ ]
+       */
+      markdownRegexp: /^((-)?\[(x|\s)?\])(\s)+$/,
+    },
+    {
+      type: BlockType.BulletedListBlock,
+      /**
+       * - or + or *
+       */
+      markdownRegexp: /^(\s*[-+*])(\s)+$/,
+    },
+    {
+      type: BlockType.NumberedListBlock,
+      /**
+       * 1. or 2. or 3.
+       * a. or b. or c.
+       */
+      markdownRegexp: /^(\s*[\d|a-zA-Z]+\.)(\s)+$/,
+    },
+    {
+      type: BlockType.QuoteBlock,
+      /**
+       * " or “ or ”
+       */
+      markdownRegexp: /^("|“|”)(\s)+$/,
+    },
+    {
+      type: BlockType.CalloutBlock,
+      /**
+       * [!TIP] or [!INFO] or [!WARNING] or [!DANGER]
+       */
+      markdownRegexp: /^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/,
+    },
+    {
+      type: BlockType.ToggleListBlock,
+      /**
+       * >
+       */
+      markdownRegexp: /^(>)(\s)+$/,
+    },
+  ],
+  [Keyboard.keys.BACK_QUOTE]: [
+    {
+      type: BlockType.CodeBlock,
+      /**
+       * ```
+       */
+      markdownRegexp: /^(```)$/,
+    },
+  ],
+  [Keyboard.keys.REDUCE]: [
+    {
+      type: BlockType.DividerBlock,
+      /**
+       * ---
+       */
+      markdownRegexp: /^(-{3,})$/,
+    },
+  ],
+};

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

@@ -1,8 +1,7 @@
-import { useCallback, useContext, useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
 import { Keyboard } from '$app/constants/document/keyboard';
 import { Keyboard } from '$app/constants/document/keyboard';
 import isHotkey from 'is-hotkey';
 import isHotkey from 'is-hotkey';
 import { useAppDispatch } from '@/appflowy_app/stores/store';
 import { useAppDispatch } from '@/appflowy_app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import {
 import {
   enterActionForBlockThunk,
   enterActionForBlockThunk,
   tabActionForBlockThunk,
   tabActionForBlockThunk,
@@ -90,6 +89,7 @@ export function useKeyDown(id: string) {
   const onKeyDown = useCallback(
   const onKeyDown = useCallback(
     (e: React.KeyboardEvent<HTMLDivElement>) => {
     (e: React.KeyboardEvent<HTMLDivElement>) => {
       const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
       const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
+
       filteredEvents.forEach((event) => {
       filteredEvents.forEach((event) => {
         e.stopPropagation();
         e.stopPropagation();
         event.handler(e);
         event.handler(e);

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

@@ -1,7 +1,6 @@
-import { useCallback, useContext, useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
 import { BlockType } from '$app/interfaces/document';
 import { BlockType } from '$app/interfaces/document';
 import { useAppDispatch } from '$app/stores/store';
 import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { turnToBlockThunk } from '$app_reducers/document/async-actions';
 import { turnToBlockThunk } from '$app_reducers/document/async-actions';
 import { blockConfig } from '$app/constants/document/config';
 import { blockConfig } from '$app/constants/document/config';
 
 
@@ -10,9 +9,9 @@ import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection
 import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
 import isHotkey from 'is-hotkey';
 import isHotkey from 'is-hotkey';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { slashCommandActions } from '$app_reducers/document/slice';
-import { Keyboard } from '$app/constants/document/keyboard';
 import { getDeltaText } from '$app/utils/document/delta';
 import { getDeltaText } from '$app/utils/document/delta';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { turnIntoShortcuts } from './shortchut';
 
 
 export function useTurnIntoBlockEvents(id: string) {
 export function useTurnIntoBlockEvents(id: string) {
   const { docId, controller } = useSubscribeDocument();
   const { docId, controller } = useSubscribeDocument();
@@ -22,27 +21,35 @@ export function useTurnIntoBlockEvents(id: string) {
 
 
   const getFlag = useCallback(() => {
   const getFlag = useCallback(() => {
     const range = rangeRef.current?.caret;
     const range = rangeRef.current?.caret;
+
     if (!range || range.id !== id) return;
     if (!range || range.id !== id) return;
     const node = getBlock(docId, id);
     const node = getBlock(docId, id);
     const delta = new Delta(node.data.delta || []);
     const delta = new Delta(node.data.delta || []);
+
     return getDeltaText(delta.slice(0, range.index));
     return getDeltaText(delta.slice(0, range.index));
   }, [docId, id, rangeRef]);
   }, [docId, id, rangeRef]);
 
 
   const getDeltaContent = useCallback(() => {
   const getDeltaContent = useCallback(() => {
     const range = rangeRef.current?.caret;
     const range = rangeRef.current?.caret;
+
     if (!range || range.id !== id) return;
     if (!range || range.id !== id) return;
     const node = getBlock(docId, id);
     const node = getBlock(docId, id);
     const delta = new Delta(node.data.delta || []);
     const delta = new Delta(node.data.delta || []);
     const content = delta.slice(range.index);
     const content = delta.slice(range.index);
+
     return new Delta(content);
     return new Delta(content);
   }, [docId, id, rangeRef]);
   }, [docId, id, rangeRef]);
 
 
   const canHandle = useCallback(
   const canHandle = useCallback(
-    (event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
+    (event: React.KeyboardEvent<HTMLDivElement>, type: BlockType) => {
       {
       {
-        const config = blockConfig[type];
+        const triggerKey = event.key;
+        const shortcutItem = turnIntoShortcuts[triggerKey]?.find((item) => item.type === type);
+
+        if (!shortcutItem) return false;
+
+        const regex = shortcutItem.markdownRegexp;
 
 
-        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
         // This error will be thrown if the block type is not in the config, and it will happen in development environment
         if (!regex) {
         if (!regex) {
           throw new Error(`canHandle: block type ${type} is not supported`);
           throw new Error(`canHandle: block type ${type} is not supported`);
@@ -53,10 +60,12 @@ export function useTurnIntoBlockEvents(id: string) {
         if (!isTrigger) {
         if (!isTrigger) {
           return false;
           return false;
         }
         }
+
         const flag = getFlag();
         const flag = getFlag();
+
         if (!flag) return false;
         if (!flag) return false;
 
 
-        return regex.some((r) => r.test(`${flag}${triggerKey}`));
+        return regex.test(`${flag}${triggerKey}`);
       }
       }
     },
     },
     [getFlag]
     [getFlag]
@@ -64,6 +73,7 @@ export function useTurnIntoBlockEvents(id: string) {
 
 
   const getTurnIntoBlockDelta = useCallback(() => {
   const getTurnIntoBlockDelta = useCallback(() => {
     const content = getDeltaContent();
     const content = getDeltaContent();
+
     if (!content) return;
     if (!content) return;
     return {
     return {
       delta: content.ops,
       delta: content.ops,
@@ -74,8 +84,10 @@ export function useTurnIntoBlockEvents(id: string) {
     return {
     return {
       [BlockType.HeadingBlock]: () => {
       [BlockType.HeadingBlock]: () => {
         const flag = getFlag();
         const flag = getFlag();
+
         if (!flag) return;
         if (!flag) return;
         const level = flag.match(/#/g)?.length;
         const level = flag.match(/#/g)?.length;
+
         if (!level || level > 3) return;
         if (!level || level > 3) return;
         return {
         return {
           level,
           level,
@@ -84,6 +96,7 @@ export function useTurnIntoBlockEvents(id: string) {
       },
       },
       [BlockType.TodoListBlock]: () => {
       [BlockType.TodoListBlock]: () => {
         const flag = getFlag();
         const flag = getFlag();
+
         if (!flag) return;
         if (!flag) return;
 
 
         return {
         return {
@@ -97,8 +110,10 @@ export function useTurnIntoBlockEvents(id: string) {
       [BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
       [BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
       [BlockType.CalloutBlock]: () => {
       [BlockType.CalloutBlock]: () => {
         const flag = getFlag();
         const flag = getFlag();
+
         if (!flag) return;
         if (!flag) return;
         const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
         const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
+
         if (!tag) return;
         if (!tag) return;
         const iconMap: Record<string, string> = {
         const iconMap: Record<string, string> = {
           TIP: '💡',
           TIP: '💡',
@@ -106,6 +121,7 @@ export function useTurnIntoBlockEvents(id: string) {
           WARNING: '⚠️',
           WARNING: '⚠️',
           DANGER: '‼️',
           DANGER: '‼️',
         };
         };
+
         return {
         return {
           icon: iconMap[tag],
           icon: iconMap[tag],
           ...getTurnIntoBlockDelta(),
           ...getTurnIntoBlockDelta(),
@@ -117,24 +133,24 @@ export function useTurnIntoBlockEvents(id: string) {
   const turnIntoBlockEvents = useMemo(() => {
   const turnIntoBlockEvents = useMemo(() => {
     const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
     const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
       const blockType = type as BlockType;
       const blockType = type as BlockType;
-      const triggerKey = Keyboard.keys.Space;
 
 
       return {
       return {
-        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType, triggerKey),
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType),
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
           e.preventDefault();
           if (!controller) return;
           if (!controller) return;
           const data = getData();
           const data = getData();
+
           if (!data) return;
           if (!data) return;
           dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
           dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
         },
         },
       };
       };
     });
     });
+
     return [
     return [
       ...spaceTriggerEvents,
       ...spaceTriggerEvents,
       {
       {
-        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
-          canHandle(e, BlockType.DividerBlock, Keyboard.keys.Reduce),
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.DividerBlock),
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
           e.preventDefault();
           if (!controller) return;
           if (!controller) return;
@@ -153,8 +169,7 @@ export function useTurnIntoBlockEvents(id: string) {
         },
         },
       },
       },
       {
       {
-        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
-          canHandle(e, BlockType.CodeBlock, Keyboard.keys.BackQuote),
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.CodeBlock),
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
           e.preventDefault();
           if (!controller) return;
           if (!controller) return;
@@ -163,6 +178,7 @@ export function useTurnIntoBlockEvents(id: string) {
             ...defaultData,
             ...defaultData,
             delta: getDeltaContent()?.ops as Op[],
             delta: getDeltaContent()?.ops as Op[],
           };
           };
+
           dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
           dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
         },
         },
       },
       },
@@ -170,6 +186,7 @@ export function useTurnIntoBlockEvents(id: string) {
         // Here custom slash key event for TextBlock
         // Here custom slash key event for TextBlock
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
           const flag = getFlag();
           const flag = getFlag();
+
           return isHotkey('/', e) && flag === '';
           return isHotkey('/', e) && flag === '';
         },
         },
         handler: (_: React.KeyboardEvent<HTMLDivElement>) => {
         handler: (_: React.KeyboardEvent<HTMLDivElement>) => {

+ 5 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts

@@ -61,19 +61,18 @@ export function useCommonKeyEvents(id: string) {
       {
       {
         // handle left arrow key and no other key is pressed
         // handle left arrow key and no other key is pressed
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
-          return isHotkey(Keyboard.keys.LEFT, e) && caretRef.current?.index === 0 && caretRef.current?.length === 0;
+          return isHotkey(Keyboard.keys.LEFT, e);
         },
         },
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
           e.preventDefault();
+          e.stopPropagation();
           dispatch(leftActionForBlockThunk({ docId, id }));
           dispatch(leftActionForBlockThunk({ docId, id }));
         },
         },
       },
       },
       {
       {
         // handle right arrow key and no other key is pressed
         // handle right arrow key and no other key is pressed
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
-          const block = getBlock(docId, id);
-          const isEndOfBlock = caretRef.current?.index === new Delta(block.data.delta).length();
-          return isHotkey(Keyboard.keys.RIGHT, e) && isEndOfBlock && caretRef.current?.length === 0;
+          return isHotkey(Keyboard.keys.RIGHT, e);
         },
         },
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
           e.preventDefault();
@@ -86,6 +85,7 @@ export function useCommonKeyEvents(id: string) {
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           if (!controller) return;
           if (!controller) return;
           const format = parseFormat(e);
           const format = parseFormat(e);
+
           if (!format) return;
           if (!format) return;
           dispatch(
           dispatch(
             toggleFormatThunk({
             toggleFormatThunk({
@@ -97,5 +97,6 @@ export function useCommonKeyEvents(id: string) {
       },
       },
     ];
     ];
   }, [docId, caretRef, controller, dispatch, focused, id]);
   }, [docId, caretRef, controller, dispatch, focused, id]);
+
   return commonKeyEvents;
   return commonKeyEvents;
 }
 }

+ 10 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts

@@ -1,4 +1,4 @@
-import { useCallback, useContext, useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import { RangeStatic } from 'quill';
 import { RangeStatic } from 'quill';
 import { useAppDispatch } from '$app/stores/store';
 import { useAppDispatch } from '$app/stores/store';
 import { rangeActions } from '$app_reducers/document/slice';
 import { rangeActions } from '$app_reducers/document/slice';
@@ -44,7 +44,15 @@ export function useSelection(id: string) {
   );
   );
 
 
   useEffect(() => {
   useEffect(() => {
-    if (rangeRef.current && rangeRef.current?.isDragging) return;
+    if (rangeRef.current) {
+      const { isDragging, anchor, focus } = rangeRef.current;
+      const mouseDownFocused = anchor?.point.x === focus?.point.x && anchor?.point.y === focus?.point.y;
+
+      if (isDragging && !mouseDownFocused) {
+        return;
+      }
+    }
+
     if (!focusCaret) {
     if (!focusCaret) {
       setSelection(undefined);
       setSelection(undefined);
       return;
       return;

+ 144 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx

@@ -0,0 +1,144 @@
+import React, { useCallback, useContext, useEffect, useRef } from 'react';
+import './inline.css';
+import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
+import { useAppDispatch } from '$app/stores/store';
+import { createTemporary } from '$app_reducers/document/async-actions/temporary';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import KatexMath from '$app/components/document/_shared/KatexMath';
+import { rangeActions } from '$app_reducers/document/slice';
+
+const LEFT_CARET_CLASS = 'inline-block-with-cursor-left';
+const RIGHT_CARET_CLASS = 'inline-block-with-cursor-right';
+
+function InlineContainer({
+  isFirst,
+  isLast,
+  children,
+  getSelection,
+  selectedText,
+  data,
+  temporaryType,
+}: {
+  getSelection: (node: Element) => RangeStaticNoId | null;
+  children: React.ReactNode;
+  formula: string;
+  selectedText: string;
+  isLast: boolean;
+  isFirst: boolean;
+  data: {
+    latex?: string;
+  };
+  temporaryType: TemporaryType;
+}) {
+  const id = useContext(NodeIdContext);
+  const { docId } = useSubscribeDocument();
+  const { focused, focusCaret } = useFocused(id);
+  const rangeRef = useRangeRef();
+  const ref = useRef<HTMLSpanElement>(null);
+  const dispatch = useAppDispatch();
+  const onClick = useCallback(
+    (node: HTMLSpanElement) => {
+      const selection = getSelection(node);
+
+      if (!selection) return;
+      const temporaryData = temporaryType === TemporaryType.Equation ? { latex: data.latex } : {};
+
+      dispatch(
+        createTemporary({
+          docId,
+          state: {
+            id,
+            selection,
+            selectedText,
+            type: temporaryType,
+            data: temporaryData as { latex: string },
+          },
+        })
+      );
+    },
+    [getSelection, temporaryType, data.latex, dispatch, docId, id, selectedText]
+  );
+
+  const renderNode = useCallback(() => {
+    switch (temporaryType) {
+      case TemporaryType.Equation:
+        return <KatexMath latex={data.latex!} isInline />;
+      default:
+        return null;
+    }
+  }, [data, temporaryType]);
+
+  const resetCaret = useCallback(() => {
+    if (!ref.current) return;
+    ref.current.classList.remove(RIGHT_CARET_CLASS);
+    ref.current.classList.remove(LEFT_CARET_CLASS);
+  }, []);
+
+  useEffect(() => {
+    resetCaret();
+    if (!ref.current) return;
+    if (!focused || !focusCaret || rangeRef.current?.isDragging) {
+      return;
+    }
+
+    const inlineBlockSelection = getSelection(ref.current);
+
+    if (!inlineBlockSelection) return;
+    const distance = inlineBlockSelection.index - focusCaret.index;
+
+    if (distance === 0 && isFirst) {
+      ref.current.classList.add(LEFT_CARET_CLASS);
+      return;
+    }
+
+    if (distance === -1) {
+      ref.current.classList.add(RIGHT_CARET_CLASS);
+      return;
+    }
+  }, [focused, focusCaret, getSelection, resetCaret, isFirst, rangeRef]);
+
+  useEffect(() => {
+    if (!ref.current) return;
+    const onMouseDown = (e: MouseEvent) => {
+      if (e.target === ref.current) {
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    };
+
+    // prevent page scroll when the caret change by mouse down
+    document.addEventListener('mousedown', onMouseDown, true);
+    return () => {
+      document.removeEventListener('mousedown', onMouseDown, true);
+    };
+  }, []);
+
+  if (!selectedText) return null;
+
+  return (
+    <span className={'inline-block-with-cursor relative'} ref={ref} onClick={() => onClick(ref.current!)}>
+      <span
+        style={{
+          pointerEvents: 'none',
+        }}
+        className={`absolute caret-transparent opacity-0`}
+      >
+        {children}
+      </span>
+      <span
+        data-slate-placeholder={true}
+        contentEditable={false}
+        style={{
+          pointerEvents: 'none',
+        }}
+      >
+        {renderNode()}
+      </span>
+      {isLast && <span data-slate-string={false}>&#xFEFF;</span>}
+    </span>
+  );
+}
+
+export default InlineContainer;

+ 31 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/inline.css

@@ -0,0 +1,31 @@
+.inline-block-with-cursor {
+    position: relative;
+    display: inline-block;
+    padding: 0 2px;
+}
+
+.inline-block-with-cursor-left::before,
+.inline-block-with-cursor-right::after {
+    content: '';
+    position: absolute;
+    top: 0px;
+    width: 1px;
+    height: 100%;
+    background-color: rgb(55, 53, 47);
+    opacity: 0.5;
+    animation: cursor-blink 1s infinite;
+}
+
+.inline-block-with-cursor-left::before {
+    left: -1px;
+}
+
+.inline-block-with-cursor-right::after {
+    right: -1px;
+}
+
+@keyframes cursor-blink {
+    0% { opacity: 0; }
+    50% { opacity: 1; }
+    100% { opacity: 0; }
+}

+ 9 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.tsx

@@ -0,0 +1,9 @@
+import React from 'react';
+import 'katex/dist/katex.min.css';
+import { BlockMath, InlineMath } from 'react-katex';
+
+function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
+  return isInline ? <InlineMath math={latex} /> : <BlockMath math={latex} />;
+}
+
+export default KatexMath;

+ 5 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx

@@ -49,7 +49,11 @@ const MenuItem = forwardRef(function (
         }}
         }}
       >
       >
         <div
         <div
-          className={`mr-2 flex h-[${imgSize.height}px] w-[${imgSize.width}px] items-center justify-center rounded border border-shade-5`}
+          style={{
+            width: imgSize.width,
+            height: imgSize.height,
+          }}
+          className={`mr-2 flex items-center justify-center rounded border border-shade-5`}
         >
         >
           {icon}
           {icon}
         </div>
         </div>

+ 35 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx

@@ -4,6 +4,9 @@ import { useCallback, useRef } from 'react';
 import TextLink from '../TextLink';
 import TextLink from '../TextLink';
 import { converToIndexLength } from '$app/utils/document/slate_editor';
 import { converToIndexLength } from '$app/utils/document/slate_editor';
 import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
 import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
+import TemporaryInput from '$app/components/document/_shared/TemporaryInput';
+import InlineContainer from '$app/components/document/_shared/InlineBlock/InlineContainer';
+import { TemporaryType } from '$app/interfaces/document';
 
 
 interface Attributes {
 interface Attributes {
   bold?: boolean;
   bold?: boolean;
@@ -16,6 +19,8 @@ interface Attributes {
   prism_token?: string;
   prism_token?: string;
   link_selection_lighted?: boolean;
   link_selection_lighted?: boolean;
   link_placeholder?: string;
   link_placeholder?: string;
+  temporary?: boolean;
+  formula?: string;
 }
 }
 interface TextLeafProps extends RenderLeafProps {
 interface TextLeafProps extends RenderLeafProps {
   leaf: BaseText & Attributes;
   leaf: BaseText & Attributes;
@@ -27,6 +32,9 @@ const TextLeaf = (props: TextLeafProps) => {
   const { attributes, children, leaf, isCodeBlock, editor } = props;
   const { attributes, children, leaf, isCodeBlock, editor } = props;
   const ref = useRef<HTMLSpanElement>(null);
   const ref = useRef<HTMLSpanElement>(null);
 
 
+  const customAttributes = {
+    ...attributes,
+  };
   let newChildren = children;
   let newChildren = children;
 
 
   if (leaf.code) {
   if (leaf.code) {
@@ -51,6 +59,7 @@ const TextLeaf = (props: TextLeafProps) => {
         anchor: { path, offset: 0 },
         anchor: { path, offset: 0 },
         focus: { path, offset: leaf.text.length },
         focus: { path, offset: leaf.text.length },
       });
       });
+
       return selection;
       return selection;
     },
     },
     [editor, leaf]
     [editor, leaf]
@@ -64,6 +73,26 @@ const TextLeaf = (props: TextLeafProps) => {
     );
     );
   }
   }
 
 
+  if (leaf.formula) {
+    const { isLast, text, parent } = children.props;
+    const temporaryType = TemporaryType.Equation;
+    const data = { latex: leaf.formula };
+
+    newChildren = (
+      <InlineContainer
+        isLast={isLast}
+        isFirst={text === parent.children[0]}
+        getSelection={getSelection}
+        formula={leaf.formula}
+        data={data}
+        temporaryType={temporaryType}
+        selectedText={leaf.text}
+      >
+        {newChildren}
+      </InlineContainer>
+    );
+  }
+
   const className = [
   const className = [
     isCodeBlock && 'token',
     isCodeBlock && 'token',
     leaf.prism_token && leaf.prism_token,
     leaf.prism_token && leaf.prism_token,
@@ -83,8 +112,13 @@ const TextLeaf = (props: TextLeafProps) => {
       </LinkHighLight>
       </LinkHighLight>
     );
     );
   }
   }
+
+  if (leaf.temporary) {
+    newChildren = <TemporaryInput leaf={leaf}>{newChildren}</TemporaryInput>;
+  }
+
   return (
   return (
-    <span ref={ref} {...attributes} className={className.join(' ')}>
+    <span ref={ref} {...customAttributes} className={className.join(' ')}>
       {newChildren}
       {newChildren}
     </span>
     </span>
   );
   );

+ 156 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/markdown.ts

@@ -0,0 +1,156 @@
+import { TextAction } from '$app/interfaces/document';
+import { Keyboard } from '$app/constants/document/keyboard';
+import { ReactEditor } from 'slate-react';
+import { Editor, Range } from 'slate';
+import { converToSlatePoint } from '$app/utils/document/slate_editor';
+import { EQUATION_PLACEHOLDER } from '$app/constants/document/name';
+
+const bold = {
+  type: TextAction.Bold,
+  /**
+   * ** or __
+   */
+  markdownRegexp: /(\*\*|__)([^\s](?:[^\s]*?[^\s])?)(\*\*|__)$/,
+};
+const italic = {
+  type: TextAction.Italic,
+  /**
+   * * or _
+   */
+  markdownRegexp: /(\*|_)([^\s](?:[^\s]*?[^\s])?)(\*|_)$/,
+};
+const strikethrough = {
+  type: TextAction.Strikethrough,
+
+  /**
+   * ~~
+   */
+  markdownRegexp: /(~~)([^\s](?:[^\s]*?[^\s])?)(~~)$/,
+};
+const inlineCode = {
+  type: TextAction.Code,
+  /**
+   * `
+   */
+  markdownRegexp: /(`)([^\s](?:[^\s]*?[^\s])?)(`)$/,
+};
+const inlineEquation = {
+  type: TextAction.Equation,
+  /**
+   * $
+   */
+  markdownRegexp: /(\$)([^\s](?:[^\s]*?[^\s])?)(\$)$/,
+};
+const config: Record<
+  string,
+  {
+    type: TextAction;
+    getValue?: (matchStr: string) => string | boolean;
+    markdownRegexp: RegExp;
+  }[]
+> = {
+  [Keyboard.keys.ASTERISK]: [bold, italic],
+  [Keyboard.keys.UNDER_SCORE]: [bold, italic],
+  [Keyboard.keys.TILDE]: [strikethrough],
+  [Keyboard.keys.BACK_QUOTE]: [inlineCode],
+  [Keyboard.keys.DOLLAR]: [inlineEquation],
+};
+
+export const withMarkdown = (editor: ReactEditor) => {
+  const { insertText } = editor;
+
+  editor.insertText = (text) => {
+    const { selection } = editor;
+    const char = text.charAt(text.length - 1);
+    const matchFormatTypes = config[char];
+
+    if (matchFormatTypes && matchFormatTypes.length > 0 && selection && Range.isCollapsed(selection)) {
+      const { anchor } = selection;
+      const start = Editor.start(editor, []);
+      const range = { anchor, focus: start };
+      const textString = Editor.string(editor, range) + text;
+      const prevChar = textString.charAt(textString.length - 2);
+
+      // If the previous character is a space, we don't want to trigger the markdown
+      if (prevChar === ' ') {
+        return insertText(text);
+      }
+
+      for (const formatType of matchFormatTypes) {
+        const match = textString.match(formatType.markdownRegexp);
+
+        if (match) {
+          const pluralStart = match[0].substring(0, 2) === char.padStart(2, char);
+          const pluralEnd = prevChar === char;
+
+          if (pluralStart && !pluralEnd) {
+            break;
+          }
+
+          const matchIndex = match.index || 0;
+
+          if (formatType.type === TextAction.Equation) {
+            formatEquation(editor, matchIndex, match[2]);
+            return;
+          }
+
+          // format already applied
+          editor.select({
+            anchor,
+            focus: converToSlatePoint(editor, matchIndex),
+          });
+          if (isMarkAction(editor, formatType.type)) {
+            editor.select(anchor);
+            break;
+          }
+
+          Editor.addMark(editor, formatType.type, true);
+
+          // delete extra characters
+          editor.select(converToSlatePoint(editor, matchIndex));
+          editor.delete({
+            distance: pluralStart ? 2 : 1,
+          });
+
+          editor.select(converToSlatePoint(editor, matchIndex + match[2].length));
+          if (pluralStart) {
+            editor.delete({
+              distance: 1,
+            });
+          }
+
+          return;
+        }
+      }
+    }
+
+    insertText(text);
+  };
+
+  return editor;
+};
+
+function isMarkAction(editor: Editor, format: string) {
+  const marks = Editor.marks(editor) as Record<string, boolean> | null;
+
+  return marks ? !!marks[format] : false;
+}
+
+function formatEquation(editor: Editor, index: number, latex: string) {
+  editor.select(converToSlatePoint(editor, index));
+  editor.delete({
+    distance: latex.length + 1,
+  });
+
+  editor.insertNode(
+    {
+      text: EQUATION_PLACEHOLDER,
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      formula: latex,
+    },
+    {
+      select: true,
+    }
+  );
+}

+ 21 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts

@@ -15,6 +15,8 @@ import Delta from 'quill-delta';
 import isHotkey from 'is-hotkey';
 import isHotkey from 'is-hotkey';
 import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
 import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
 
 
+const AFTER_RENDER_DELAY = 100;
+
 export function useEditor({
 export function useEditor({
   onChange,
   onChange,
   onSelectionChange,
   onSelectionChange,
@@ -24,6 +26,7 @@ export function useEditor({
   onKeyDown,
   onKeyDown,
   isCodeBlock,
   isCodeBlock,
   linkDecorateSelection,
   linkDecorateSelection,
+  temporarySelection,
 }: EditorProps) {
 }: EditorProps) {
   const { editor } = useSlateYjs({ delta });
   const { editor } = useSlateYjs({ delta });
   const ref = useRef<HTMLDivElement | null>(null);
   const ref = useRef<HTMLDivElement | null>(null);
@@ -31,6 +34,7 @@ export function useEditor({
   const onSelectionChangeHandler = useCallback(
   const onSelectionChangeHandler = useCallback(
     (slateSelection: Selection) => {
     (slateSelection: Selection) => {
       const rangeStatic = converToIndexLength(editor, slateSelection);
       const rangeStatic = converToIndexLength(editor, slateSelection);
+
       onSelectionChange?.(rangeStatic, null);
       onSelectionChange?.(rangeStatic, null);
     },
     },
     [editor, onSelectionChange]
     [editor, onSelectionChange]
@@ -39,6 +43,7 @@ export function useEditor({
   const onChangeHandler = useCallback(
   const onChangeHandler = useCallback(
     (slateValue: Descendant[]) => {
     (slateValue: Descendant[]) => {
       const oldContents = delta || new Delta();
       const oldContents = delta || new Delta();
+
       onChange?.(convertToDelta(slateValue), oldContents);
       onChange?.(convertToDelta(slateValue), oldContents);
       onSelectionChangeHandler(editor.selection);
       onSelectionChangeHandler(editor.selection);
     },
     },
@@ -67,8 +72,10 @@ export function useEditor({
     ) => {
     ) => {
       if (!selection) return null;
       if (!selection) return null;
       const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
       const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
+
       if (range && !Range.isCollapsed(range)) {
       if (range && !Range.isCollapsed(range)) {
         const intersection = Range.intersection(range, Editor.range(editor, path));
         const intersection = Range.intersection(range, Editor.range(editor, path));
+
         if (intersection) {
         if (intersection) {
           return {
           return {
             ...intersection,
             ...intersection,
@@ -76,6 +83,7 @@ export function useEditor({
           };
           };
         }
         }
       }
       }
+
       return null;
       return null;
     },
     },
     [editor]
     [editor]
@@ -93,11 +101,14 @@ export function useEditor({
           link_selection_lighted: true,
           link_selection_lighted: true,
           link_placeholder: linkDecorateSelection?.placeholder,
           link_placeholder: linkDecorateSelection?.placeholder,
         }),
         }),
+        getDecorateRange(path, temporarySelection, {
+          temporary: true,
+        }),
       ].filter((range) => range !== null) as Range[];
       ].filter((range) => range !== null) as Range[];
 
 
       return ranges;
       return ranges;
     },
     },
-    [decorateSelection, linkDecorateSelection, getDecorateRange]
+    [temporarySelection, decorateSelection, linkDecorateSelection, getDecorateRange]
   );
   );
 
 
   const onKeyDownRewrite = useCallback(
   const onKeyDownRewrite = useCallback(
@@ -107,6 +118,7 @@ export function useEditor({
         event.preventDefault();
         event.preventDefault();
         editor.insertText('\n');
         editor.insertText('\n');
       };
       };
+
       // There is different behavior for code block and normal text
       // There is different behavior for code block and normal text
       // In code block, we press enter to insert a new line
       // In code block, we press enter to insert a new line
       // In normal text, we press shift + enter to insert a new line
       // In normal text, we press shift + enter to insert a new line
@@ -115,11 +127,13 @@ export function useEditor({
           insertBreak();
           insertBreak();
           return;
           return;
         }
         }
+
         if (isHotkey(Keyboard.keys.TAB, event)) {
         if (isHotkey(Keyboard.keys.TAB, event)) {
           event.preventDefault();
           event.preventDefault();
           indent(editor, 2);
           indent(editor, 2);
           return;
           return;
         }
         }
+
         if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
         if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
           event.preventDefault();
           event.preventDefault();
           outdent(editor, 2);
           outdent(editor, 2);
@@ -141,13 +155,16 @@ export function useEditor({
 
 
   useEffect(() => {
   useEffect(() => {
     if (!ref.current) return;
     if (!ref.current) return;
+
     const isFocused = ReactEditor.isFocused(editor);
     const isFocused = ReactEditor.isFocused(editor);
+
     if (!selection) {
     if (!selection) {
       isFocused && editor.deselect();
       isFocused && editor.deselect();
       return;
       return;
     }
     }
 
 
     const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
     const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
+
     if (!slateSelection) return;
     if (!slateSelection) return;
 
 
     if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
     if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
@@ -156,15 +173,16 @@ export function useEditor({
     // because the slate must be focused before change selection,
     // because the slate must be focused before change selection,
     // but then it will trigger selection change, and the selection is not what we want
     // but then it will trigger selection change, and the selection is not what we want
     const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
     const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
+
     if (!isSuccess) {
     if (!isSuccess) {
       Transforms.select(editor, slateSelection);
       Transforms.select(editor, slateSelection);
     } else {
     } else {
       // Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
       // Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
-      requestAnimationFrame(() => {
+      setTimeout(() => {
         if (window.getSelection()?.type === 'None' && !editor.selection) {
         if (window.getSelection()?.type === 'None' && !editor.selection) {
           Transforms.select(editor, slateSelection);
           Transforms.select(editor, slateSelection);
         }
         }
-      });
+      }, AFTER_RENDER_DELAY);
     }
     }
   }, [editor, selection]);
   }, [editor, selection]);
 
 

+ 4 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts

@@ -5,6 +5,7 @@ import { convertToSlateValue } from '$app/utils/document/slate_editor';
 import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
 import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
 import { withReact } from 'slate-react';
 import { withReact } from 'slate-react';
 import { createEditor } from 'slate';
 import { createEditor } from 'slate';
+import { withMarkdown } from '$app/components/document/_shared/SlateEditor/markdown';
 
 
 export function useSlateYjs({ delta }: { delta?: Delta }) {
 export function useSlateYjs({ delta }: { delta?: Delta }) {
   const [yText, setYText] = useState<Y.Text | undefined>(undefined);
   const [yText, setYText] = useState<Y.Text | undefined>(undefined);
@@ -13,13 +14,14 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
     const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
     const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
     const value = convertToSlateValue(delta || new Delta());
     const value = convertToSlateValue(delta || new Delta());
     const insertDelta = slateNodesToInsertDelta(value);
     const insertDelta = slateNodesToInsertDelta(value);
+
     sharedType.applyDelta(insertDelta);
     sharedType.applyDelta(insertDelta);
     setYText(insertDelta[0].insert as Y.Text);
     setYText(insertDelta[0].insert as Y.Text);
     return sharedType;
     return sharedType;
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
   }, []);
 
 
-  const editor = useMemo(() => withReact(withYjs(createEditor(), sharedType)), []);
+  const editor = useMemo(() => withYjs(withMarkdown(withReact(createEditor())), sharedType), []);
 
 
   // Connect editor in useEffect to comply with concurrent mode requirements.
   // Connect editor in useEffect to comply with concurrent mode requirements.
   useEffect(() => {
   useEffect(() => {
@@ -33,6 +35,7 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
     if (!yText) return;
     if (!yText) return;
     const oldContents = new Delta(yText.toDelta());
     const oldContents = new Delta(yText.toDelta());
     const diffDelta = oldContents.diff(delta || new Delta());
     const diffDelta = oldContents.diff(delta || new Delta());
+
     if (diffDelta.ops.length === 0) return;
     if (diffDelta.ops.length === 0) return;
     yText.applyDelta(diffDelta.ops);
     yText.applyDelta(diffDelta.ops);
   }, [delta, editor, yText]);
   }, [delta, editor, yText]);

+ 4 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts

@@ -1,10 +1,12 @@
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { useContext } from 'react';
 import { useContext } from 'react';
 import { useAppSelector } from '$app/stores/store';
 import { useAppSelector } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 export function useSubscribeDocument() {
 export function useSubscribeDocument() {
   const controller = useContext(DocumentControllerContext);
   const controller = useContext(DocumentControllerContext);
   const docId = controller.documentId;
   const docId = controller.documentId;
+
   return {
   return {
     docId,
     docId,
     controller,
     controller,
@@ -14,7 +16,8 @@ export function useSubscribeDocument() {
 export function useSubscribeDocumentData() {
 export function useSubscribeDocumentData() {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
   const data = useAppSelector((state) => {
   const data = useAppSelector((state) => {
-    return state.document[docId];
+    return state[DOCUMENT_NAME][docId];
   });
   });
+
   return data;
   return data;
 }
 }

+ 2 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts

@@ -1,11 +1,12 @@
 import { useAppSelector } from '$app/stores/store';
 import { useAppSelector } from '$app/stores/store';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { TEXT_LINK_NAME } from '$app/constants/document/name';
 
 
 export function useSubscribeLinkPopover() {
 export function useSubscribeLinkPopover() {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
 
 
   const linkPopover = useAppSelector((state) => {
   const linkPopover = useAppSelector((state) => {
-    return state.documentLinkPopover[docId];
+    return state[TEXT_LINK_NAME][docId];
   });
   });
 
 
   return linkPopover;
   return linkPopover;

+ 7 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts

@@ -2,6 +2,7 @@ import { store, useAppSelector } from '@/appflowy_app/stores/store';
 import { createContext, useMemo } from 'react';
 import { createContext, useMemo } from 'react';
 import { Node } from '$app/interfaces/document';
 import { Node } from '$app/interfaces/document';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
 
 
 /**
 /**
  * Subscribe node information
  * Subscribe node information
@@ -11,20 +12,23 @@ export function useSubscribeNode(id: string) {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
 
 
   const node = useAppSelector<Node>((state) => {
   const node = useAppSelector<Node>((state) => {
-    const documentState = state.document[docId];
+    const documentState = state[DOCUMENT_NAME][docId];
+
     return documentState?.nodes[id];
     return documentState?.nodes[id];
   });
   });
 
 
   const childIds = useAppSelector<string[] | undefined>((state) => {
   const childIds = useAppSelector<string[] | undefined>((state) => {
-    const documentState = state.document[docId];
+    const documentState = state[DOCUMENT_NAME][docId];
+
     if (!documentState) return;
     if (!documentState) return;
     const childrenId = documentState.nodes[id]?.children;
     const childrenId = documentState.nodes[id]?.children;
+
     if (!childrenId) return;
     if (!childrenId) return;
     return documentState.children[childrenId];
     return documentState.children[childrenId];
   });
   });
 
 
   const isSelected = useAppSelector<boolean>((state) => {
   const isSelected = useAppSelector<boolean>((state) => {
-    return state.documentRectSelection[docId]?.selection.includes(id) || false;
+    return state[RECT_RANGE_NAME][docId]?.selection.includes(id) || false;
   });
   });
 
 
   // Memoize the node and its children
   // Memoize the node and its children

+ 3 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts

@@ -1,10 +1,12 @@
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useAppSelector } from '$app/stores/store';
 import { useAppSelector } from '$app/stores/store';
+import { RECT_RANGE_NAME } from '$app/constants/document/name';
 
 
 export function useSubscribeRectRange() {
 export function useSubscribeRectRange() {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
   const rectRange = useAppSelector((state) => {
   const rectRange = useAppSelector((state) => {
-    return state.documentRectSelection[docId];
+    return state[RECT_RANGE_NAME][docId];
   });
   });
+
   return rectRange;
   return rectRange;
 }
 }

+ 21 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts

@@ -2,16 +2,25 @@ import { useAppSelector } from '$app/stores/store';
 import { RangeState, RangeStatic } from '$app/interfaces/document';
 import { RangeState, RangeStatic } from '$app/interfaces/document';
 import { useMemo, useRef } from 'react';
 import { useMemo, useRef } from 'react';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { RANGE_NAME, TEMPORARY_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
 
 
 export function useSubscribeDecorate(id: string) {
 export function useSubscribeDecorate(id: string) {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
 
 
   const decorateSelection = useAppSelector((state) => {
   const decorateSelection = useAppSelector((state) => {
-    return state.documentRange[docId]?.ranges[id];
+    return state[RANGE_NAME][docId]?.ranges[id];
+  });
+
+  const temporarySelection = useAppSelector((state) => {
+    const temporary = state[TEMPORARY_NAME][docId];
+
+    if (!temporary || temporary.id !== id) return;
+    return temporary.selection;
   });
   });
 
 
   const linkDecorateSelection = useAppSelector((state) => {
   const linkDecorateSelection = useAppSelector((state) => {
-    const linkPopoverState = state.documentLinkPopover[docId];
+    const linkPopoverState = state[TEXT_LINK_NAME][docId];
+
     if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
     if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
     return {
     return {
       selection: linkPopoverState.selection,
       selection: linkPopoverState.selection,
@@ -22,18 +31,22 @@ export function useSubscribeDecorate(id: string) {
   return {
   return {
     decorateSelection,
     decorateSelection,
     linkDecorateSelection,
     linkDecorateSelection,
+    temporarySelection,
   };
   };
 }
 }
+
 export function useFocused(id: string) {
 export function useFocused(id: string) {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
 
 
   const caretRef = useRef<RangeStatic>();
   const caretRef = useRef<RangeStatic>();
   const focusCaret = useAppSelector((state) => {
   const focusCaret = useAppSelector((state) => {
-    const currentCaret = state.documentRange[docId]?.caret;
+    const currentCaret = state[RANGE_NAME][docId]?.caret;
+
     caretRef.current = currentCaret;
     caretRef.current = currentCaret;
     if (currentCaret?.id === id) {
     if (currentCaret?.id === id) {
       return currentCaret;
       return currentCaret;
     }
     }
+
     return null;
     return null;
   });
   });
 
 
@@ -52,8 +65,10 @@ export function useRangeRef() {
   const { docId, controller } = useSubscribeDocument();
   const { docId, controller } = useSubscribeDocument();
 
 
   const rangeRef = useRef<RangeState>();
   const rangeRef = useRef<RangeState>();
+
   useAppSelector((state) => {
   useAppSelector((state) => {
-    const currentRange = state.documentRange[docId];
+    const currentRange = state[RANGE_NAME][docId];
+
     rangeRef.current = currentRange;
     rangeRef.current = currentRange;
   });
   });
   return rangeRef;
   return rangeRef;
@@ -63,7 +78,7 @@ export function useSubscribeRanges() {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
 
 
   const rangeState = useAppSelector((state) => {
   const rangeState = useAppSelector((state) => {
-    return state.documentRange[docId];
+    return state[RANGE_NAME][docId];
   });
   });
 
 
   return rangeState;
   return rangeState;
@@ -73,7 +88,7 @@ export function useSubscribeCaret() {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
 
 
   const caret = useAppSelector((state) => {
   const caret = useAppSelector((state) => {
-    return state.documentRange[docId]?.caret;
+    return state[RANGE_NAME][docId]?.caret;
   });
   });
 
 
   return caret;
   return caret;

+ 2 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts

@@ -1,11 +1,12 @@
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useAppSelector } from '$app/stores/store';
 import { useAppSelector } from '$app/stores/store';
+import { SLASH_COMMAND_NAME } from '$app/constants/document/name';
 
 
 export function useSubscribeSlashState() {
 export function useSubscribeSlashState() {
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
 
 
   const slashCommandState = useAppSelector((state) => {
   const slashCommandState = useAppSelector((state) => {
-    return state.documentSlashCommand[docId];
+    return state[SLASH_COMMAND_NAME][docId];
   });
   });
 
 
   return slashCommandState;
   return slashCommandState;

+ 11 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeTemporary.hooks.ts

@@ -0,0 +1,11 @@
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useAppSelector } from '$app/stores/store';
+import { TemporaryState } from '$app/interfaces/document';
+import { TEMPORARY_NAME } from '$app/constants/document/name';
+
+export function useSubscribeTemporary(): TemporaryState {
+  const { docId } = useSubscribeDocument();
+  const temporaryState = useAppSelector((state) => state[TEMPORARY_NAME][docId]);
+
+  return temporaryState;
+}

+ 50 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/EquationEditContent.tsx

@@ -0,0 +1,50 @@
+import React from 'react';
+import TextField from '@mui/material/TextField';
+import { CheckOutlined, FunctionsOutlined } from '@mui/icons-material';
+import { Divider, IconButton, InputAdornment } from '@mui/material';
+
+function EquationEditContent({
+  value,
+  onChange,
+  onConfirm,
+}: {
+  value: string;
+  onChange: (newVal: string) => void;
+  onConfirm: () => void;
+}) {
+  return (
+    <div className={'flex p-2'}>
+      <TextField
+        placeholder={'E = mc^2'}
+        autoFocus={true}
+        label='Equation'
+        onKeyDown={(e) => {
+          if (e.key === 'Enter') {
+            onConfirm();
+          }
+        }}
+        InputProps={{
+          startAdornment: (
+            <InputAdornment position='start'>
+              <FunctionsOutlined />
+            </InputAdornment>
+          ),
+        }}
+        variant='standard'
+        value={value}
+        onChange={(e) => {
+          const newVal = e.target.value;
+
+          if (newVal === value) return;
+          onChange(newVal);
+        }}
+      />
+      <Divider sx={{ height: 'initial', marginLeft: '10px' }} orientation='vertical' />
+      <IconButton onClick={onConfirm} color='primary' sx={{ p: '10px' }} aria-label='directions'>
+        <CheckOutlined />
+      </IconButton>
+    </div>
+  );
+}
+
+export default EquationEditContent;

+ 19 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx

@@ -0,0 +1,19 @@
+import React, { useRef } from 'react';
+import { Functions } from '@mui/icons-material';
+import KatexMath from '$app/components/document/_shared/KatexMath';
+
+function TemporaryEquation({ latex }: { latex: string }) {
+  return (
+    <span className={'rounded bg-shade-6 px-1 py-0.5'} contentEditable={false}>
+      {latex ? (
+        <KatexMath latex={latex} isInline />
+      ) : (
+        <span className={'text-shade-3'}>
+          <Functions /> {'New equation'}
+        </span>
+      )}
+    </span>
+  );
+}
+
+export default TemporaryEquation;

+ 135 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx

@@ -0,0 +1,135 @@
+import React, { useCallback, useMemo } from 'react';
+import Popover from '@mui/material/Popover';
+import { RangeStaticNoId, TemporaryData, TemporaryState, TemporaryType } from '$app/interfaces/document';
+import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
+import { temporaryActions } from '$app_reducers/document/temporary_slice';
+import { rangeActions } from '$app_reducers/document/slice';
+import { formatTemporary } from '$app_reducers/document/async-actions/temporary';
+import { useAppDispatch } from '$app/stores/store';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
+
+const AFTER_RENDER_DELAY = 100;
+
+function TemporaryPopover() {
+  const temporaryState = useSubscribeTemporary();
+  const anchorPosition = useMemo(() => temporaryState?.popoverPosition, [temporaryState]);
+  const open = Boolean(anchorPosition);
+  const id = temporaryState?.id;
+  const dispatch = useAppDispatch();
+  const { docId, controller } = useSubscribeDocument();
+
+  const onChangeData = useCallback(
+    (data: TemporaryData) => {
+      dispatch(
+        temporaryActions.updateTemporaryState({
+          id: docId,
+          state: {
+            data,
+            id,
+          },
+        })
+      );
+    },
+    [dispatch, docId, id]
+  );
+
+  const resetCaret = useCallback(
+    (id: string, selection: RangeStaticNoId) => {
+      dispatch(
+        rangeActions.setCaret({
+          docId,
+          caret: {
+            id,
+            index: selection.index + selection.length,
+            length: 0,
+          },
+        })
+      );
+    },
+    [dispatch, docId]
+  );
+
+  const onClose = useCallback(() => {
+    dispatch(
+      temporaryActions.updateTemporaryState({
+        id: docId,
+        state: {
+          id,
+          popoverPosition: null,
+        },
+      })
+    );
+  }, [dispatch, docId, id]);
+
+  const handleClose = useCallback(() => {
+    if (!temporaryState) return;
+    onClose();
+    dispatch(temporaryActions.deleteTemporaryState(docId));
+    resetCaret(temporaryState.id, temporaryState.selection);
+  }, [dispatch, docId, onClose, resetCaret, temporaryState]);
+
+  const onConfirm = useCallback(async () => {
+    const res = await dispatch(
+      formatTemporary({
+        controller,
+      })
+    );
+    const state = res.payload as TemporaryState;
+
+    if (!state) return;
+    const { id, selection } = state;
+
+    onClose();
+    dispatch(rangeActions.clearRanges({ docId }));
+    dispatch(temporaryActions.deleteTemporaryState(docId));
+    // wait slate to update the dom
+    setTimeout(() => {
+      resetCaret(id, selection);
+    }, AFTER_RENDER_DELAY);
+  }, [dispatch, controller, onClose, docId, resetCaret]);
+
+  const renderPopoverContent = useCallback(() => {
+    if (!temporaryState) return null;
+    const { type, data } = temporaryState;
+
+    switch (type) {
+      case TemporaryType.Equation:
+        return (
+          <EquationEditContent
+            value={data.latex}
+            onChange={(latex: string) =>
+              onChangeData({
+                latex,
+              })
+            }
+            onConfirm={onConfirm}
+          />
+        );
+    }
+  }, [onChangeData, onConfirm, temporaryState]);
+
+  return (
+    <Popover
+      onClose={handleClose}
+      open={open}
+      anchorPosition={anchorPosition ? anchorPosition : undefined}
+      onMouseDown={(e) => e.stopPropagation()}
+      disableAutoFocus={true}
+      disableRestoreFocus={true}
+      anchorReference={'anchorPosition'}
+      anchorOrigin={{
+        vertical: 'bottom',
+        horizontal: 'center',
+      }}
+      transformOrigin={{
+        vertical: 'top',
+        horizontal: 'center',
+      }}
+    >
+      {renderPopoverContent()}
+    </Popover>
+  );
+}
+
+export default TemporaryPopover;

+ 77 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx

@@ -0,0 +1,77 @@
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import { TemporaryType } from '$app/interfaces/document';
+import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation';
+import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
+import { isOverlappingPrefix } from '$app/utils/document/temporary';
+import { PopoverPosition } from '@mui/material';
+import { useAppDispatch } from '$app/stores/store';
+import { temporaryActions } from '$app_reducers/document/temporary_slice';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+
+function TemporaryInput({ leaf, children }: { leaf: { text: string }; children: React.ReactNode }) {
+  const temporaryState = useSubscribeTemporary();
+  const id = temporaryState?.id;
+  const dispatch = useAppDispatch();
+  const ref = useRef<HTMLSpanElement>(null);
+  const { docId } = useSubscribeDocument();
+  const match = useMemo(() => {
+    if (!leaf.text) return false;
+    if (!temporaryState) return false;
+    const { selectedText, type } = temporaryState;
+
+    switch (type) {
+      case TemporaryType.Equation:
+        // when the leaf is split, the placeholder is not the same as the leaf text,
+        // so we can only check for overlapping prefix and hidden other leafs
+        return leaf.text === selectedText || isOverlappingPrefix(leaf.text, selectedText);
+      default:
+        return false;
+    }
+  }, [temporaryState, leaf.text]);
+
+  const renderPlaceholder = useCallback(() => {
+    if (!temporaryState) return null;
+    const { type, data } = temporaryState;
+
+    switch (type) {
+      case TemporaryType.Equation:
+        return <TemporaryEquation latex={data.latex} />;
+      default:
+        return null;
+    }
+  }, [temporaryState]);
+
+  const setAnchorPosition = useCallback(
+    (position: PopoverPosition | null) => {
+      dispatch(
+        temporaryActions.updateTemporaryState({
+          id: docId,
+          state: {
+            id,
+            popoverPosition: position,
+          },
+        })
+      );
+    },
+    [dispatch, docId, id]
+  );
+
+  useEffect(() => {
+    if (!ref.current || !match) return;
+    const { width, height, top, left } = ref.current.getBoundingClientRect();
+
+    setAnchorPosition({
+      top: top + height,
+      left: left + width / 2,
+    });
+  }, [dispatch, docId, id, match, setAnchorPosition]);
+
+  return (
+    <span ref={ref}>
+      {match ? renderPlaceholder() : null}
+      <span className={'absolute opacity-0'}>{children}</span>
+    </span>
+  );
+}
+
+export default TemporaryInput;

+ 1 - 12
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
 import React from 'react';
+import { isOverlappingPrefix } from '$app/utils/document/temporary';
 
 
 function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
 function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
   return (
   return (
@@ -19,15 +20,3 @@ function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; titl
 }
 }
 
 
 export default LinkHighLight;
 export default LinkHighLight;
-
-function isOverlappingPrefix(first: string, second: string): boolean {
-  if (first.length === 0 || second.length === 0) return false;
-  let i = 0;
-  while (i < first.length) {
-    const chars = first.substring(i);
-    if (chars.length > second.length) return false;
-    if (second.startsWith(chars)) return true;
-    i++;
-  }
-  return false;
-}

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

@@ -122,7 +122,7 @@ const TurnIntoPopover = ({
       },
       },
       // {
       // {
       //   type: BlockType.EquationBlock,
       //   type: BlockType.EquationBlock,
-      //   title: 'Block Equation',
+      //   title: 'Block KatexMath',
       //   icon: <Functions />,
       //   icon: <Functions />,
       // },
       // },
     ],
     ],

+ 0 - 3
frontend/appflowy_tauri/src/appflowy_app/constants/document/block.ts

@@ -1,3 +0,0 @@
-export const BLOCK_MAP_NAME = 'blocks';
-export const META_NAME = 'meta';
-export const CHILDREN_MAP_NAME = 'children_map';

+ 4 - 81
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -1,4 +1,4 @@
-import { BlockConfig, BlockType, SplitRelationship, TextAction, TextActionMenuProps } from '$app/interfaces/document';
+import { BlockConfig, BlockType, SplitRelationship } from '$app/interfaces/document';
 
 
 /**
 /**
  * If the block type is not in the config, it will be thrown an error in development env
  * If the block type is not in the config, it will be thrown an error in development env
@@ -20,10 +20,6 @@ export const blockConfig: Record<string, BlockConfig> = {
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineBlockType: BlockType.TextBlock,
       nextLineBlockType: BlockType.TextBlock,
     },
     },
-    /**
-     * # or ## or ###
-     */
-    markdownRegexps: [/^(#{1,3})(\s)+$/],
   },
   },
   [BlockType.TodoListBlock]: {
   [BlockType.TodoListBlock]: {
     canAddChild: true,
     canAddChild: true,
@@ -35,10 +31,6 @@ export const blockConfig: Record<string, BlockConfig> = {
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineBlockType: BlockType.TodoListBlock,
       nextLineBlockType: BlockType.TodoListBlock,
     },
     },
-    /**
-     * -[] or -[x] or -[ ] or [] or [x] or [ ]
-     */
-    markdownRegexps: [/^((-)?\[(x|\s)?\])(\s)+$/],
   },
   },
   [BlockType.BulletedListBlock]: {
   [BlockType.BulletedListBlock]: {
     canAddChild: true,
     canAddChild: true,
@@ -50,10 +42,6 @@ export const blockConfig: Record<string, BlockConfig> = {
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineBlockType: BlockType.BulletedListBlock,
       nextLineBlockType: BlockType.BulletedListBlock,
     },
     },
-    /**
-     * - or + or *
-     */
-    markdownRegexps: [/^(\s*[-+*])(\s)+$/],
   },
   },
   [BlockType.NumberedListBlock]: {
   [BlockType.NumberedListBlock]: {
     canAddChild: true,
     canAddChild: true,
@@ -65,11 +53,6 @@ export const blockConfig: Record<string, BlockConfig> = {
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineBlockType: BlockType.NumberedListBlock,
       nextLineBlockType: BlockType.NumberedListBlock,
     },
     },
-    /**
-     * 1. or 2. or 3.
-     * a. or b. or c.
-     */
-    markdownRegexps: [/^(\s*[\d|a-zA-Z]+\.)(\s)+$/],
   },
   },
   [BlockType.QuoteBlock]: {
   [BlockType.QuoteBlock]: {
     canAddChild: true,
     canAddChild: true,
@@ -81,10 +64,6 @@ export const blockConfig: Record<string, BlockConfig> = {
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineBlockType: BlockType.TextBlock,
       nextLineBlockType: BlockType.TextBlock,
     },
     },
-    /**
-     * " or “ or ”
-     */
-    markdownRegexps: [/^("|“|”)(\s)+$/],
   },
   },
   [BlockType.CalloutBlock]: {
   [BlockType.CalloutBlock]: {
     canAddChild: true,
     canAddChild: true,
@@ -96,10 +75,6 @@ export const blockConfig: Record<string, BlockConfig> = {
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineRelationShip: SplitRelationship.NextSibling,
       nextLineBlockType: BlockType.TextBlock,
       nextLineBlockType: BlockType.TextBlock,
     },
     },
-    /**
-     * [!TIP] or [!INFO] or [!WARNING] or [!DANGER]
-     */
-    markdownRegexps: [/^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/],
   },
   },
   [BlockType.ToggleListBlock]: {
   [BlockType.ToggleListBlock]: {
     canAddChild: true,
     canAddChild: true,
@@ -111,17 +86,6 @@ export const blockConfig: Record<string, BlockConfig> = {
       nextLineRelationShip: SplitRelationship.FirstChild,
       nextLineRelationShip: SplitRelationship.FirstChild,
       nextLineBlockType: BlockType.TextBlock,
       nextLineBlockType: BlockType.TextBlock,
     },
     },
-    /**
-     * >
-     */
-    markdownRegexps: [/^(>)(\s)+$/],
-  },
-  [BlockType.DividerBlock]: {
-    canAddChild: false,
-    /**
-     * ---
-     */
-    markdownRegexps: [/^(-{3,})$/],
   },
   },
 
 
   [BlockType.CodeBlock]: {
   [BlockType.CodeBlock]: {
@@ -130,49 +94,8 @@ export const blockConfig: Record<string, BlockConfig> = {
       delta: [],
       delta: [],
       language: 'javascript',
       language: 'javascript',
     },
     },
-    /**
-     * ```
-     */
-    markdownRegexps: [/^(```)$/],
-
-    textActionMenuProps: {
-      excludeItems: [TextAction.Code],
-    },
+  },
+  [BlockType.DividerBlock]: {
+    canAddChild: false,
   },
   },
 };
 };
-
-export const defaultTextActionProps: TextActionMenuProps = {
-  customItems: [
-    TextAction.Turn,
-    TextAction.Link,
-    TextAction.Bold,
-    TextAction.Italic,
-    TextAction.Underline,
-    TextAction.Strikethrough,
-    TextAction.Code,
-    TextAction.Equation,
-  ],
-  excludeItems: [],
-};
-
-const groupKeys = {
-  comment: [],
-  format: [
-    TextAction.Bold,
-    TextAction.Italic,
-    TextAction.Underline,
-    TextAction.Strikethrough,
-    TextAction.Code,
-    TextAction.Equation,
-  ],
-  link: [TextAction.Link],
-  turn: [TextAction.Turn],
-};
-
-export const multiLineTextActionProps: TextActionMenuProps = {
-  customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
-};
-
-export const multiLineTextActionGroups = [groupKeys.format];
-
-export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];

+ 7 - 4
frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts

@@ -24,10 +24,13 @@ export const Keyboard = {
     DELETE: 'Delete',
     DELETE: 'Delete',
     SHIFT_ENTER: 'Shift+Enter',
     SHIFT_ENTER: 'Shift+Enter',
     SHIFT_TAB: 'Shift+Tab',
     SHIFT_TAB: 'Shift+Tab',
-    Slash: '/',
-    Space: ' ',
-    Reduce: '-',
-    BackQuote: '`',
+    SLASH: '/',
+    REDUCE: '-',
+    BACK_QUOTE: '`',
+    UNDER_SCORE: '_',
+    ASTERISK: '*',
+    TILDE: '~',
+    DOLLAR: '$',
     FORMAT: {
     FORMAT: {
       BOLD: 'Mod+b',
       BOLD: 'Mod+b',
       ITALIC: 'Mod+i',
       ITALIC: 'Mod+i',

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

@@ -0,0 +1,12 @@
+export const DOCUMENT_NAME = 'document';
+export const TEMPORARY_NAME = 'document/temporary';
+export const RANGE_NAME = 'document/range';
+
+export const RECT_RANGE_NAME = 'document/rect_range';
+export const SLASH_COMMAND_NAME = 'document/slash_command';
+export const TEXT_LINK_NAME = 'document/text_link';
+export const BLOCK_MAP_NAME = 'blocks';
+export const META_NAME = 'meta';
+export const CHILDREN_MAP_NAME = 'children_map';
+
+export const EQUATION_PLACEHOLDER = '$';

+ 23 - 18
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -213,7 +213,7 @@ export enum TextAction {
   Underline = 'underline',
   Underline = 'underline',
   Strikethrough = 'strikethrough',
   Strikethrough = 'strikethrough',
   Code = 'code',
   Code = 'code',
-  Equation = 'equation',
+  Equation = 'formula',
   Link = 'href',
   Link = 'href',
 }
 }
 export interface TextActionMenuProps {
 export interface TextActionMenuProps {
@@ -232,10 +232,6 @@ export interface BlockConfig {
    * Whether the block can have children
    * Whether the block can have children
    */
    */
   canAddChild: boolean;
   canAddChild: boolean;
-  /**
-   * The regexps that will be used to match the markdown flag
-   */
-  markdownRegexps?: RegExp[];
 
 
   /**
   /**
    * The default data of the block
    * The default data of the block
@@ -255,11 +251,6 @@ export interface BlockConfig {
      */
      */
     nextLineBlockType: BlockType;
     nextLineBlockType: BlockType;
   };
   };
-
-  /**
-   * The props that will be passed to the text action menu
-   */
-  textActionMenuProps?: TextActionMenuProps;
 }
 }
 
 
 export interface ControllerAction {
 export interface ControllerAction {
@@ -286,12 +277,10 @@ export interface EditorProps {
   selection?: RangeStaticNoId;
   selection?: RangeStaticNoId;
   decorateSelection?: RangeStaticNoId;
   decorateSelection?: RangeStaticNoId;
   linkDecorateSelection?: {
   linkDecorateSelection?: {
-    selection?: {
-      index: number;
-      length: number;
-    };
+    selection?: RangeStaticNoId;
     placeholder?: string;
     placeholder?: string;
   };
   };
+  temporarySelection?: RangeStaticNoId;
   onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
   onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
   onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
   onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
   onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
   onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
@@ -306,11 +295,27 @@ export interface BlockCopyData {
 export interface LinkPopoverState {
 export interface LinkPopoverState {
   anchorPosition?: { top: number; left: number };
   anchorPosition?: { top: number; left: number };
   id?: string;
   id?: string;
-  selection?: {
-    index: number;
-    length: number;
-  };
+  selection?: RangeStaticNoId;
   open?: boolean;
   open?: boolean;
   href?: string;
   href?: string;
   title?: string;
   title?: string;
 }
 }
+
+export interface TemporaryState {
+  id: string;
+  type: TemporaryType;
+  selectedText: string;
+  data: TemporaryData;
+  selection: RangeStaticNoId;
+  popoverPosition?: { top: number; left: number } | null;
+}
+
+export enum TemporaryType {
+  Equation = 'equation',
+}
+
+export type TemporaryData = InlineEquationData;
+
+export interface InlineEquationData {
+  latex: string;
+}

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

@@ -11,10 +11,10 @@ import {
 } from '@/services/backend';
 } from '@/services/backend';
 import { DocumentObserver } from './document_observer';
 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 { get } from '@/appflowy_app/utils/tool';
 import { get } from '@/appflowy_app/utils/tool';
 import { blockPB2Node } from '$app/utils/document/block';
 import { blockPB2Node } from '$app/utils/document/block';
 import { Log } from '$app/utils/log';
 import { Log } from '$app/utils/log';
+import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
 
 
 export class DocumentController {
 export class DocumentController {
   private readonly backendService: DocumentBackendService;
   private readonly backendService: DocumentBackendService;

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

@@ -2,6 +2,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 
 
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 export const deleteNodeThunk = createAsyncThunk(
 export const deleteNodeThunk = createAsyncThunk(
   'document/deleteNode',
   'document/deleteNode',
@@ -10,8 +11,9 @@ export const deleteNodeThunk = createAsyncThunk(
     const { getState } = thunkAPI;
     const { getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docId = controller.documentId;
-    const docState = state.document[docId];
+    const docState = state[DOCUMENT_NAME][docId];
     const node = docState.nodes[id];
     const node = docState.nodes[id];
+
     if (!node) return;
     if (!node) return;
     await controller.applyActions([controller.getDeleteAction(node)]);
     await controller.applyActions([controller.getDeleteAction(node)]);
   }
   }

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

@@ -3,6 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { rectSelectionActions } from '$app_reducers/document/slice';
 import { rectSelectionActions } from '$app_reducers/document/slice';
 import { getDuplicateActions } from '$app/utils/document/action';
 import { getDuplicateActions } from '$app/utils/document/action';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 export const duplicateBelowNodeThunk = createAsyncThunk(
 export const duplicateBelowNodeThunk = createAsyncThunk(
   'document/duplicateBelowNode',
   'document/duplicateBelowNode',
@@ -11,8 +12,9 @@ export const duplicateBelowNodeThunk = createAsyncThunk(
     const { getState, dispatch } = thunkAPI;
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docId = controller.documentId;
-    const docState = state.document[docId];
+    const docState = state[DOCUMENT_NAME][docId];
     const node = docState.nodes[id];
     const node = docState.nodes[id];
+
     if (!node || !node.parent) return;
     if (!node || !node.parent) return;
     const duplicateActions = getDuplicateActions(id, node.parent, docState, controller);
     const duplicateActions = getDuplicateActions(id, node.parent, docState, controller);
 
 

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

@@ -4,6 +4,7 @@ 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/block';
 import { getPrevNodeId } from '$app/utils/document/block';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 /**
 /**
  * indent node
  * indent node
@@ -19,16 +20,19 @@ export const indentNodeThunk = createAsyncThunk(
     const { getState } = thunkAPI;
     const { getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docId = controller.documentId;
-    const docState = state.document[docId];
+    const docState = state[DOCUMENT_NAME][docId];
     const node = docState.nodes[id];
     const node = docState.nodes[id];
+
     if (!node.parent) return;
     if (!node.parent) return;
 
 
     // get prev node
     // get prev node
     const prevNodeId = getPrevNodeId(docState, id);
     const prevNodeId = getPrevNodeId(docState, id);
+
     if (!prevNodeId) return;
     if (!prevNodeId) return;
     const newParentNode = docState.nodes[prevNodeId];
     const newParentNode = docState.nodes[prevNodeId];
     // check if prev node is allowed to have children
     // check if prev node is allowed to have children
     const config = blockConfig[newParentNode.type];
     const config = blockConfig[newParentNode.type];
+
     if (!config.canAddChild) return;
     if (!config.canAddChild) return;
 
 
     // check if prev node has children and get last child for new prev node
     // check if prev node has children and get last child for new prev node

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

@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { newBlock } from '$app/utils/document/block';
 import { newBlock } from '$app/utils/document/block';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 export const insertAfterNodeThunk = createAsyncThunk(
 export const insertAfterNodeThunk = createAsyncThunk(
   'document/insertAfterNode',
   'document/insertAfterNode',
@@ -18,22 +19,27 @@ export const insertAfterNodeThunk = createAsyncThunk(
     const { getState } = thunkAPI;
     const { getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docId = controller.documentId;
-    const docState = state.document[docId];
+    const docState = state[DOCUMENT_NAME][docId];
     const node = docState.nodes[id];
     const node = docState.nodes[id];
+
     if (!node) return;
     if (!node) return;
     const parentId = node.parent;
     const parentId = node.parent;
+
     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);
     let nodeId = newNode.id;
     let nodeId = newNode.id;
     const actions = [controller.getInsertAction(newNode, node.id)];
     const actions = [controller.getInsertAction(newNode, node.id)];
+
     if (type === BlockType.DividerBlock) {
     if (type === BlockType.DividerBlock) {
       const newTextNode = newBlock<any>(BlockType.TextBlock, parentId, {
       const newTextNode = newBlock<any>(BlockType.TextBlock, parentId, {
         delta: [],
         delta: [],
       });
       });
+
       nodeId = newTextNode.id;
       nodeId = newTextNode.id;
       actions.push(controller.getInsertAction(newTextNode, newNode.id));
       actions.push(controller.getInsertAction(newTextNode, newNode.id));
     }
     }
+
     await controller.applyActions(actions);
     await controller.applyActions(actions);
 
 
     return nodeId;
     return nodeId;

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

@@ -5,6 +5,7 @@ import Delta from 'quill-delta';
 import { blockConfig } from '$app/constants/document/config';
 import { blockConfig } from '$app/constants/document/config';
 import { getMoveChildrenActions } from '$app/utils/document/action';
 import { getMoveChildrenActions } from '$app/utils/document/action';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 /**
 /**
  * Merge two blocks
  * Merge two blocks
@@ -19,9 +20,10 @@ export const mergeDeltaThunk = createAsyncThunk(
     const { getState } = thunkAPI;
     const { getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docId = controller.documentId;
-    const docState = state.document[docId];
+    const docState = state[DOCUMENT_NAME][docId];
     const target = docState.nodes[targetId];
     const target = docState.nodes[targetId];
     const source = docState.nodes[sourceId];
     const source = docState.nodes[sourceId];
+
     if (!target || !source) return;
     if (!target || !source) return;
     const targetDelta = new Delta(target.data.delta);
     const targetDelta = new Delta(target.data.delta);
     const sourceDelta = new Delta(source.data.delta);
     const sourceDelta = new Delta(source.data.delta);
@@ -43,9 +45,11 @@ export const mergeDeltaThunk = createAsyncThunk(
       children,
       children,
       target,
       target,
     });
     });
+
     actions.push(...moveActions);
     actions.push(...moveActions);
     // delete current block
     // delete current block
     const deleteAction = controller.getDeleteAction(source);
     const deleteAction = controller.getDeleteAction(source);
+
     actions.push(deleteAction);
     actions.push(deleteAction);
 
 
     await controller.applyActions(actions);
     await controller.applyActions(actions);

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

@@ -2,6 +2,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 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 { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 /**
 /**
  * outdent node
  * outdent node
@@ -19,11 +20,13 @@ export const outdentNodeThunk = createAsyncThunk(
     const { getState } = thunkAPI;
     const { getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docId = controller.documentId;
-    const docState = state.document[docId];
+    const docState = state[DOCUMENT_NAME][docId];
     const node = docState.nodes[id];
     const node = docState.nodes[id];
     const parentId = node.parent;
     const parentId = node.parent;
+
     if (!parentId) return;
     if (!parentId) return;
     const ancestorId = docState.nodes[parentId].parent;
     const ancestorId = docState.nodes[parentId].parent;
+
     if (!ancestorId) return;
     if (!ancestorId) return;
 
 
     const parent = docState.nodes[parentId];
     const parent = docState.nodes[parentId];
@@ -32,25 +35,31 @@ export const outdentNodeThunk = createAsyncThunk(
 
 
     const actions = [];
     const actions = [];
     const moveAction = controller.getMoveAction(node, ancestorId, parentId);
     const moveAction = controller.getMoveAction(node, ancestorId, parentId);
+
     actions.push(moveAction);
     actions.push(moveAction);
 
 
     const config = blockConfig[node.type];
     const config = blockConfig[node.type];
+
     if (nextSiblingIds.length > 0) {
     if (nextSiblingIds.length > 0) {
       if (config.canAddChild) {
       if (config.canAddChild) {
         const children = docState.children[node.children];
         const children = docState.children[node.children];
         let lastChildId: string | null = null;
         let lastChildId: string | null = null;
         const lastIndex = children.length - 1;
         const lastIndex = children.length - 1;
+
         if (lastIndex >= 0) {
         if (lastIndex >= 0) {
           lastChildId = children[lastIndex];
           lastChildId = children[lastIndex];
         }
         }
+
         const moveChildrenActions = nextSiblingIds
         const moveChildrenActions = nextSiblingIds
           .reverse()
           .reverse()
           .map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId));
           .map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId));
+
         actions.push(...moveChildrenActions);
         actions.push(...moveChildrenActions);
       } else {
       } else {
         const moveChildrenActions = nextSiblingIds
         const moveChildrenActions = nextSiblingIds
           .reverse()
           .reverse()
           .map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id));
           .map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id));
+
         actions.push(...moveChildrenActions);
         actions.push(...moveChildrenActions);
       }
       }
     }
     }

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

@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import Delta, { Op } from 'quill-delta';
 import Delta, { Op } from 'quill-delta';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 export const updateNodeDeltaThunk = createAsyncThunk(
 export const updateNodeDeltaThunk = createAsyncThunk(
   'document/updateNodeDelta',
   'document/updateNodeDelta',
@@ -11,9 +12,10 @@ export const updateNodeDeltaThunk = createAsyncThunk(
     const { getState } = thunkAPI;
     const { getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docId = controller.documentId;
-    const docState = state.document[docId];
+    const docState = state[DOCUMENT_NAME][docId];
     const node = docState.nodes[id];
     const node = docState.nodes[id];
     const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
     const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
+
     if (diffDelta.ops.length === 0) return;
     if (diffDelta.ops.length === 0) return;
 
 
     const newData = { ...node.data, delta };
     const newData = { ...node.data, delta };
@@ -39,7 +41,7 @@ export const updateNodeDataThunk = createAsyncThunk<
   const { getState } = thunkAPI;
   const { getState } = thunkAPI;
   const state = getState() as RootState;
   const state = getState() as RootState;
   const docId = controller.documentId;
   const docId = controller.documentId;
-  const docState = state.document[docId];
+  const docState = state[DOCUMENT_NAME][docId];
   const node = docState.nodes[id];
   const node = docState.nodes[id];
 
 
   const newData = { ...node.data, ...data };
   const newData = { ...node.data, ...data };

+ 22 - 4
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts

@@ -13,6 +13,7 @@ import {
   getInsertBlockActions,
   getInsertBlockActions,
 } from '$app/utils/document/copy_paste';
 } from '$app/utils/document/copy_paste';
 import { rangeActions } from '$app_reducers/document/slice';
 import { rangeActions } from '$app_reducers/document/slice';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
 
 
 export const copyThunk = createAsyncThunk<
 export const copyThunk = createAsyncThunk<
   void,
   void,
@@ -26,11 +27,13 @@ export const copyThunk = createAsyncThunk<
   const { setClipboardData, isCut = false, controller } = payload;
   const { setClipboardData, isCut = false, controller } = payload;
   const docId = controller.documentId;
   const docId = controller.documentId;
   const state = getState() as RootState;
   const state = getState() as RootState;
-  const document = state.document[docId];
-  const documentRange = state.documentRange[docId];
+  const document = state[DOCUMENT_NAME][docId];
+  const documentRange = state[RANGE_NAME][docId];
   const startAndEndIds = getStartAndEndIdsByRange(documentRange);
   const startAndEndIds = getStartAndEndIdsByRange(documentRange);
+
   if (startAndEndIds.length === 0) return;
   if (startAndEndIds.length === 0) return;
   const result: DocumentBlockJSON[] = [];
   const result: DocumentBlockJSON[] = [];
+
   if (startAndEndIds.length === 1) {
   if (startAndEndIds.length === 1) {
     // copy single block
     // copy single block
     const id = startAndEndIds[0];
     const id = startAndEndIds[0];
@@ -38,6 +41,7 @@ export const copyThunk = createAsyncThunk<
     const nodeDelta = new Delta(node.data.delta);
     const nodeDelta = new Delta(node.data.delta);
     const range = documentRange.ranges[id] || { index: 0, length: 0 };
     const range = documentRange.ranges[id] || { index: 0, length: 0 };
     const isFull = range.index === 0 && range.length === nodeDelta.length();
     const isFull = range.index === 0 && range.length === nodeDelta.length();
+
     if (isFull) {
     if (isFull) {
       result.push(getCopyBlock(id, document, documentRange));
       result.push(getCopyBlock(id, document, documentRange));
     } else {
     } else {
@@ -54,13 +58,17 @@ export const copyThunk = createAsyncThunk<
     const copyIds: string[] = [];
     const copyIds: string[] = [];
     const [startId, endId] = startAndEndIds;
     const [startId, endId] = startAndEndIds;
     const middleIds = getMiddleIds(document, startId, endId);
     const middleIds = getMiddleIds(document, startId, endId);
+
     copyIds.push(startId, ...middleIds, endId);
     copyIds.push(startId, ...middleIds, endId);
     const map = new Map<string, DocumentBlockJSON>();
     const map = new Map<string, DocumentBlockJSON>();
+
     copyIds.forEach((id) => {
     copyIds.forEach((id) => {
       const block = getCopyBlock(id, document, documentRange);
       const block = getCopyBlock(id, document, documentRange);
+
       map.set(id, block);
       map.set(id, block);
       const node = document.nodes[id];
       const node = document.nodes[id];
       const parent = node.parent;
       const parent = node.parent;
+
       if (parent && map.has(parent)) {
       if (parent && map.has(parent)) {
         map.get(parent)!.children.push(block);
         map.get(parent)!.children.push(block);
       } else {
       } else {
@@ -68,6 +76,7 @@ export const copyThunk = createAsyncThunk<
       }
       }
     });
     });
   }
   }
+
   setClipboardData({
   setClipboardData({
     json: JSON.stringify(result),
     json: JSON.stringify(result),
     // TODO: implement plain text and html
     // TODO: implement plain text and html
@@ -99,15 +108,17 @@ export const pasteThunk = createAsyncThunk<
 >('document/paste', async (payload, thunkAPI) => {
 >('document/paste', async (payload, thunkAPI) => {
   const { getState, dispatch } = thunkAPI;
   const { getState, dispatch } = thunkAPI;
   const { data, controller } = payload;
   const { data, controller } = payload;
+
   // delete range blocks
   // delete range blocks
   await dispatch(deleteRangeAndInsertThunk({ controller }));
   await dispatch(deleteRangeAndInsertThunk({ controller }));
 
 
   const state = getState() as RootState;
   const state = getState() as RootState;
   const docId = controller.documentId;
   const docId = controller.documentId;
-  const document = state.document[docId];
-  const documentRange = state.documentRange[docId];
+  const document = state[DOCUMENT_NAME][docId];
+  const documentRange = state[RANGE_NAME][docId];
 
 
   let pasteData;
   let pasteData;
+
   if (data.json) {
   if (data.json) {
     pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
     pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
   } else if (data.text) {
   } else if (data.text) {
@@ -115,10 +126,13 @@ export const pasteThunk = createAsyncThunk<
   } else if (data.html) {
   } else if (data.html) {
     // TODO: implement html
     // TODO: implement html
   }
   }
+
   if (!pasteData) return;
   if (!pasteData) return;
   const { caret } = documentRange;
   const { caret } = documentRange;
+
   if (!caret) return;
   if (!caret) return;
   const currentBlock = document.nodes[caret.id];
   const currentBlock = document.nodes[caret.id];
+
   if (!currentBlock.parent) return;
   if (!currentBlock.parent) return;
   const pasteBlocks = generateBlocks(pasteData, currentBlock.parent);
   const pasteBlocks = generateBlocks(pasteData, currentBlock.parent);
   const currentBlockDelta = new Delta(currentBlock.data.delta);
   const currentBlockDelta = new Delta(currentBlock.data.delta);
@@ -128,6 +142,7 @@ export const pasteThunk = createAsyncThunk<
   const firstPasteBlockChildren = pasteBlocks.filter((block) => block.parent === firstPasteBlock.id);
   const firstPasteBlockChildren = pasteBlocks.filter((block) => block.parent === firstPasteBlock.id);
 
 
   const lastPasteBlock = pasteBlocks[pasteBlocks.length - 1];
   const lastPasteBlock = pasteBlocks[pasteBlocks.length - 1];
+
   if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) {
   if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) {
     // move current block children to first paste block
     // move current block children to first paste block
     const children = document.children[currentBlock.children].map((id) => document.nodes[id]);
     const children = document.children[currentBlock.children].map((id) => document.nodes[id]);
@@ -140,6 +155,7 @@ export const pasteThunk = createAsyncThunk<
       controller,
       controller,
       prevId,
       prevId,
     });
     });
+
     actions.push(...moveChildrenActions);
     actions.push(...moveChildrenActions);
     // delete current block
     // delete current block
     actions.push(controller.getDeleteAction(currentBlock));
     actions.push(controller.getDeleteAction(currentBlock));
@@ -173,6 +189,7 @@ export const pasteThunk = createAsyncThunk<
   const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
   const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
   const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
   const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
   let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
   let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
+
   if (firstPasteBlock.id !== lastPasteBlock.id) {
   if (firstPasteBlock.id !== lastPasteBlock.id) {
     // update the last block of paste data
     // update the last block of paste data
     actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller));
     actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller));
@@ -208,6 +225,7 @@ export const pasteThunk = createAsyncThunk<
       children: firstPasteBlockChildren,
       children: firstPasteBlockChildren,
       controller,
       controller,
     });
     });
+
     actions.push(...moveChildrenActions);
     actions.push(...moveChildrenActions);
   }
   }
 
 

+ 12 - 4
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts

@@ -3,6 +3,7 @@ import { RootState } from '$app/stores/store';
 import { TextAction } from '$app/interfaces/document';
 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 Delta from 'quill-delta';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
 
 
 export const getFormatActiveThunk = createAsyncThunk<
 export const getFormatActiveThunk = createAsyncThunk<
   boolean,
   boolean,
@@ -13,12 +14,13 @@ export const getFormatActiveThunk = createAsyncThunk<
 >('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
 >('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
   const { getState } = thunkAPI;
   const { getState } = thunkAPI;
   const state = getState() as RootState;
   const state = getState() as RootState;
-  const document = state.document[docId];
-  const documentRange = state.documentRange[docId];
+  const document = state[DOCUMENT_NAME][docId];
+  const documentRange = state[RANGE_NAME][docId];
   const { ranges } = documentRange;
   const { ranges } = documentRange;
   const match = (delta: Delta, format: TextAction) => {
   const match = (delta: Delta, format: TextAction) => {
     return delta.ops.every((op) => op.attributes?.[format]);
     return delta.ops.every((op) => op.attributes?.[format]);
   };
   };
+
   return Object.entries(ranges).every(([id, range]) => {
   return Object.entries(ranges).every(([id, range]) => {
     const node = document.nodes[id];
     const node = document.nodes[id];
     const delta = new Delta(node.data?.delta);
     const delta = new Delta(node.data?.delta);
@@ -37,6 +39,7 @@ export const toggleFormatThunk = createAsyncThunk(
     const { format, controller } = payload;
     const { format, controller } = payload;
     const docId = controller.documentId;
     const docId = controller.documentId;
     let isActive = payload.isActive;
     let isActive = payload.isActive;
+
     if (isActive === undefined) {
     if (isActive === undefined) {
       const { payload: active } = await dispatch(
       const { payload: active } = await dispatch(
         getFormatActiveThunk({
         getFormatActiveThunk({
@@ -44,12 +47,14 @@ export const toggleFormatThunk = createAsyncThunk(
           docId,
           docId,
         })
         })
       );
       );
+
       isActive = !!active;
       isActive = !!active;
     }
     }
+
     const formatValue = isActive ? undefined : true;
     const formatValue = isActive ? undefined : true;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const document = state.document[docId];
-    const documentRange = state.documentRange[docId];
+    const document = state[DOCUMENT_NAME][docId];
+    const documentRange = state[RANGE_NAME][docId];
     const { ranges } = documentRange;
     const { ranges } = documentRange;
 
 
     const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
     const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
@@ -58,11 +63,13 @@ export const toggleFormatThunk = createAsyncThunk(
           ...op.attributes,
           ...op.attributes,
           [format]: value,
           [format]: value,
         };
         };
+
         return {
         return {
           insert: op.insert,
           insert: op.insert,
           attributes: attributes,
           attributes: attributes,
         };
         };
       });
       });
+
       return new Delta(newOps);
       return new Delta(newOps);
     };
     };
 
 
@@ -85,6 +92,7 @@ export const toggleFormatThunk = createAsyncThunk(
         },
         },
       });
       });
     });
     });
+
     await controller.applyActions(actions);
     await controller.applyActions(actions);
   }
   }
 );
 );

+ 44 - 11
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts

@@ -17,6 +17,8 @@ import { rangeActions } from '$app_reducers/document/slice';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
 import { blockConfig } from '$app/constants/document/config';
 import { blockConfig } from '$app/constants/document/config';
 import { Keyboard } from '$app/constants/document/keyboard';
 import { Keyboard } from '$app/constants/document/keyboard';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
+import { getPreviousWordIndex } from '$app/utils/document/delta';
 
 
 /**
 /**
  * Delete a block by backspace or delete key
  * Delete a block by backspace or delete key
@@ -33,20 +35,25 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as RootState).document[docId];
     const state = (getState() as RootState).document[docId];
     const node = state.nodes[id];
     const node = state.nodes[id];
+
     if (!node.parent) return;
     if (!node.parent) return;
     const parent = state.nodes[node.parent];
     const parent = state.nodes[node.parent];
     const children = state.children[parent.children];
     const children = state.children[parent.children];
     const index = children.indexOf(id);
     const index = children.indexOf(id);
     const nextNodeId = children[index + 1];
     const nextNodeId = children[index + 1];
+
     // turn to text block
     // turn to text block
     if (node.type !== BlockType.TextBlock) {
     if (node.type !== BlockType.TextBlock) {
       await dispatch(turnToTextBlockThunk({ id, controller }));
       await dispatch(turnToTextBlockThunk({ id, controller }));
       return;
       return;
     }
     }
+
     const isTopLevel = parent.type === BlockType.PageBlock;
     const isTopLevel = parent.type === BlockType.PageBlock;
+
     if (isTopLevel || nextNodeId) {
     if (isTopLevel || nextNodeId) {
       // merge to previous line
       // merge to previous line
       const prevLine = findPrevHasDeltaNode(state, id);
       const prevLine = findPrevHasDeltaNode(state, id);
+
       if (!prevLine) return;
       if (!prevLine) return;
       const caretIndex = new Delta(prevLine.data.delta).length();
       const caretIndex = new Delta(prevLine.data.delta).length();
       const caret = {
       const caret = {
@@ -54,6 +61,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
         index: caretIndex,
         index: caretIndex,
         length: 0,
         length: 0,
       };
       };
+
       await dispatch(
       await dispatch(
         mergeDeltaThunk({
         mergeDeltaThunk({
           sourceId: id,
           sourceId: id,
@@ -70,6 +78,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
       );
       );
       return;
       return;
     }
     }
+
     // outdent
     // outdent
     await dispatch(outdentNodeThunk({ id, controller }));
     await dispatch(outdentNodeThunk({ id, controller }));
   }
   }
@@ -88,21 +97,25 @@ export const enterActionForBlockThunk = createAsyncThunk(
     const { getState, dispatch } = thunkAPI;
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docId = controller.documentId;
-    const documentState = state.document[docId];
+    const documentState = state[DOCUMENT_NAME][docId];
     const node = documentState.nodes[id];
     const node = documentState.nodes[id];
-    const caret = state.documentRange[docId]?.caret;
+    const caret = state[RANGE_NAME][docId]?.caret;
+
     if (!node || !caret || caret.id !== id) return;
     if (!node || !caret || caret.id !== id) return;
     const delta = new Delta(node.data.delta);
     const delta = new Delta(node.data.delta);
+
     if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
     if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
       // If the node is not a text block, turn it to a text block
       // If the node is not a text block, turn it to a text block
       await dispatch(turnToTextBlockThunk({ id, controller }));
       await dispatch(turnToTextBlockThunk({ id, controller }));
       return;
       return;
     }
     }
+
     const nodeDelta = delta.slice(0, caret.index);
     const nodeDelta = delta.slice(0, caret.index);
 
 
     const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
     const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
 
 
     const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
     const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
+
     if (!insertNodeAction) return;
     if (!insertNodeAction) return;
     const updateNode = {
     const updateNode = {
       ...node,
       ...node,
@@ -122,6 +135,7 @@ export const enterActionForBlockThunk = createAsyncThunk(
         )
         )
       : [];
       : [];
     const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
     const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
+
     await controller.applyActions(actions);
     await controller.applyActions(actions);
 
 
     dispatch(rangeActions.initialState(docId));
     dispatch(rangeActions.initialState(docId));
@@ -142,6 +156,7 @@ export const tabActionForBlockThunk = createAsyncThunk(
   'document/tabActionForBlock',
   'document/tabActionForBlock',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { dispatch } = thunkAPI;
     const { dispatch } = thunkAPI;
+
     return dispatch(indentNodeThunk(payload));
     return dispatch(indentNodeThunk(payload));
   }
   }
 );
 );
@@ -152,10 +167,11 @@ export const upDownActionForBlockThunk = createAsyncThunk(
     const { docId, id, down } = payload;
     const { docId, id, down } = payload;
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const documentState = state.document[docId];
-    const rangeState = state.documentRange[docId];
+    const documentState = state[DOCUMENT_NAME][docId];
+    const rangeState = state[RANGE_NAME][docId];
     const caret = rangeState.caret;
     const caret = rangeState.caret;
     const node = documentState.nodes[id];
     const node = documentState.nodes[id];
+
     if (!node || !caret || id !== caret.id) return;
     if (!node || !caret || id !== caret.id) return;
 
 
     let newCaret;
     let newCaret;
@@ -165,9 +181,11 @@ export const upDownActionForBlockThunk = createAsyncThunk(
     } else {
     } else {
       newCaret = transformToPrevLineCaret(documentState, caret);
       newCaret = transformToPrevLineCaret(documentState, caret);
     }
     }
+
     if (!newCaret) {
     if (!newCaret) {
       return;
       return;
     }
     }
+
     dispatch(rangeActions.initialState(docId));
     dispatch(rangeActions.initialState(docId));
     dispatch(
     dispatch(
       rangeActions.setCaret({
       rangeActions.setCaret({
@@ -184,12 +202,14 @@ export const leftActionForBlockThunk = createAsyncThunk(
     const { id, docId } = payload;
     const { id, docId } = payload;
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const documentState = state.document[docId];
-    const rangeState = state.documentRange[docId];
+    const documentState = state[DOCUMENT_NAME][docId];
+    const rangeState = state[RANGE_NAME][docId];
     const caret = rangeState.caret;
     const caret = rangeState.caret;
     const node = documentState.nodes[id];
     const node = documentState.nodes[id];
+
     if (!node || !caret || id !== caret.id) return;
     if (!node || !caret || id !== caret.id) return;
     let newCaret: RangeStatic;
     let newCaret: RangeStatic;
+
     if (caret.length > 0) {
     if (caret.length > 0) {
       newCaret = {
       newCaret = {
         id,
         id,
@@ -198,15 +218,20 @@ export const leftActionForBlockThunk = createAsyncThunk(
       };
       };
     } else {
     } else {
       if (caret.index > 0) {
       if (caret.index > 0) {
+        const delta = new Delta(node.data.delta);
+        const newIndex = getPreviousWordIndex(delta, caret.index);
+
         newCaret = {
         newCaret = {
           id,
           id,
-          index: caret.index - 1,
+          index: newIndex,
           length: 0,
           length: 0,
         };
         };
       } else {
       } else {
         const prevNode = findPrevHasDeltaNode(documentState, id);
         const prevNode = findPrevHasDeltaNode(documentState, id);
+
         if (!prevNode) return;
         if (!prevNode) return;
         const prevDelta = new Delta(prevNode.data.delta);
         const prevDelta = new Delta(prevNode.data.delta);
+
         newCaret = {
         newCaret = {
           id: prevNode.id,
           id: prevNode.id,
           index: prevDelta.length(),
           index: prevDelta.length(),
@@ -218,6 +243,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
     if (!newCaret) {
     if (!newCaret) {
       return;
       return;
     }
     }
+
     dispatch(rangeActions.initialState(docId));
     dispatch(rangeActions.initialState(docId));
     dispatch(
     dispatch(
       rangeActions.setCaret({
       rangeActions.setCaret({
@@ -234,14 +260,16 @@ export const rightActionForBlockThunk = createAsyncThunk(
     const { id, docId } = payload;
     const { id, docId } = payload;
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const documentState = state.document[docId];
-    const rangeState = state.documentRange[docId];
+    const documentState = state[DOCUMENT_NAME][docId];
+    const rangeState = state[RANGE_NAME][docId];
     const caret = rangeState.caret;
     const caret = rangeState.caret;
     const node = documentState.nodes[id];
     const node = documentState.nodes[id];
+
     if (!node || !caret || id !== caret.id) return;
     if (!node || !caret || id !== caret.id) return;
     let newCaret: RangeStatic;
     let newCaret: RangeStatic;
     const delta = new Delta(node.data.delta);
     const delta = new Delta(node.data.delta);
     const deltaLength = delta.length();
     const deltaLength = delta.length();
+
     if (caret.length > 0) {
     if (caret.length > 0) {
       newCaret = {
       newCaret = {
         id,
         id,
@@ -251,6 +279,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
     } else {
     } else {
       if (caret.index < deltaLength) {
       if (caret.index < deltaLength) {
         const newIndex = caret.index + caret.length + 1;
         const newIndex = caret.index + caret.length + 1;
+
         newCaret = {
         newCaret = {
           id,
           id,
           index: newIndex > deltaLength ? deltaLength : newIndex,
           index: newIndex > deltaLength ? deltaLength : newIndex,
@@ -258,6 +287,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
         };
         };
       } else {
       } else {
         const nextNode = findNextHasDeltaNode(documentState, id);
         const nextNode = findNextHasDeltaNode(documentState, id);
+
         if (!nextNode) return;
         if (!nextNode) return;
         newCaret = {
         newCaret = {
           id: nextNode.id,
           id: nextNode.id,
@@ -270,6 +300,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
     if (!newCaret) {
     if (!newCaret) {
       return;
       return;
     }
     }
+
     dispatch(rangeActions.initialState(docId));
     dispatch(rangeActions.initialState(docId));
 
 
     dispatch(
     dispatch(
@@ -285,6 +316,7 @@ export const shiftTabActionForBlockThunk = createAsyncThunk(
   'document/shiftTabActionForBlock',
   'document/shiftTabActionForBlock',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { dispatch } = thunkAPI;
     const { dispatch } = thunkAPI;
+
     return dispatch(outdentNodeThunk(payload));
     return dispatch(outdentNodeThunk(payload));
   }
   }
 );
 );
@@ -301,8 +333,8 @@ export const arrowActionForRangeThunk = createAsyncThunk(
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
     const { key, docId } = payload;
     const { key, docId } = payload;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const documentState = state.document[docId];
-    const rangeState = state.documentRange[docId];
+    const documentState = state[DOCUMENT_NAME][docId];
+    const rangeState = state[RANGE_NAME][docId];
     let caret;
     let caret;
     const leftCaret = getLeftCaretByRange(rangeState);
     const leftCaret = getLeftCaretByRange(rangeState);
     const rightCaret = getRightCaretByRange(rangeState);
     const rightCaret = getRightCaretByRange(rangeState);
@@ -323,6 +355,7 @@ export const arrowActionForRangeThunk = createAsyncThunk(
         caret = transformToNextLineCaret(documentState, rightCaret);
         caret = transformToNextLineCaret(documentState, rightCaret);
         break;
         break;
     }
     }
+
     if (!caret) return;
     if (!caret) return;
     dispatch(rangeActions.initialState(docId));
     dispatch(rangeActions.initialState(docId));
     dispatch(
     dispatch(

+ 13 - 4
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts

@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import Delta from 'quill-delta';
 import Delta from 'quill-delta';
 import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
 import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME, RANGE_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
 
 
 export const formatLinkThunk = createAsyncThunk<
 export const formatLinkThunk = createAsyncThunk<
   boolean,
   boolean,
@@ -14,16 +15,19 @@ export const formatLinkThunk = createAsyncThunk<
   const { getState } = thunkAPI;
   const { getState } = thunkAPI;
   const docId = controller.documentId;
   const docId = controller.documentId;
   const state = getState() as RootState;
   const state = getState() as RootState;
-  const documentState = state.document[docId];
-  const linkPopover = state.documentLinkPopover[docId];
+  const documentState = state[DOCUMENT_NAME][docId];
+  const linkPopover = state[TEXT_LINK_NAME][docId];
+
   if (!linkPopover) return false;
   if (!linkPopover) return false;
   const { selection, id, href, title = '' } = linkPopover;
   const { selection, id, href, title = '' } = linkPopover;
+
   if (!selection || !id) return false;
   if (!selection || !id) return false;
   const node = documentState.nodes[id];
   const node = documentState.nodes[id];
   const nodeDelta = new Delta(node.data?.delta);
   const nodeDelta = new Delta(node.data?.delta);
   const index = selection.index || 0;
   const index = selection.index || 0;
   const length = selection.length || 0;
   const length = selection.length || 0;
   const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
   const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
+
   if (href !== undefined && !regex.test(href)) {
   if (href !== undefined && !regex.test(href)) {
     return false;
     return false;
   }
   }
@@ -41,6 +45,7 @@ export const formatLinkThunk = createAsyncThunk<
       delta: newDelta.ops,
       delta: newDelta.ops,
     },
     },
   });
   });
+
   await controller.applyActions([updateAction]);
   await controller.applyActions([updateAction]);
   return true;
   return true;
 });
 });
@@ -53,10 +58,11 @@ export const newLinkThunk = createAsyncThunk<
 >('document/newLink', async ({ docId }, thunkAPI) => {
 >('document/newLink', async ({ docId }, thunkAPI) => {
   const { getState, dispatch } = thunkAPI;
   const { getState, dispatch } = thunkAPI;
   const state = getState() as RootState;
   const state = getState() as RootState;
-  const documentState = state.document[docId];
-  const documentRange = state.documentRange[docId];
+  const documentState = state[DOCUMENT_NAME][docId];
+  const documentRange = state[RANGE_NAME][docId];
 
 
   const { caret } = documentRange;
   const { caret } = documentRange;
+
   if (!caret) return;
   if (!caret) return;
   const { index, length, id } = caret;
   const { index, length, id } = caret;
 
 
@@ -66,11 +72,14 @@ export const newLinkThunk = createAsyncThunk<
   const href = op?.attributes?.href as string;
   const href = op?.attributes?.href as string;
 
 
   const domSelection = window.getSelection();
   const domSelection = window.getSelection();
+
   if (!domSelection) return;
   if (!domSelection) return;
   const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
   const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
+
   if (!domRange) return;
   if (!domRange) return;
   const title = domSelection.toString();
   const title = domSelection.toString();
   const { top, left, height, width } = domRange.getBoundingClientRect();
   const { top, left, height, width } = domRange.getBoundingClientRect();
+
   dispatch(rangeActions.initialState(docId));
   dispatch(rangeActions.initialState(docId));
   dispatch(
   dispatch(
     linkPopoverActions.setLinkPopover({
     linkPopoverActions.setLinkPopover({

+ 8 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts

@@ -8,6 +8,7 @@ import { blockConfig } from '$app/constants/document/config';
 import Delta, { Op } from 'quill-delta';
 import Delta, { Op } from 'quill-delta';
 import { getDeltaText } from '$app/utils/document/delta';
 import { getDeltaText } from '$app/utils/document/delta';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
 
 
 /**
 /**
  * add block below click
  * add block below click
@@ -22,6 +23,7 @@ export const addBlockBelowClickThunk = createAsyncThunk(
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as RootState).document[docId];
     const state = (getState() as RootState).document[docId];
     const node = state.nodes[id];
     const node = state.nodes[id];
+
     if (!node) return;
     if (!node) return;
     const delta = (node.data.delta as Op[]) || [];
     const delta = (node.data.delta as Op[]) || [];
     const text = delta.map((d) => d.insert).join('');
     const text = delta.map((d) => d.insert).join('');
@@ -31,6 +33,7 @@ export const addBlockBelowClickThunk = createAsyncThunk(
       const { payload: newBlockId } = await dispatch(
       const { payload: newBlockId } = await dispatch(
         insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
         insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
       );
       );
+
       if (newBlockId) {
       if (newBlockId) {
         dispatch(
         dispatch(
           rangeActions.setCaret({
           rangeActions.setCaret({
@@ -40,8 +43,10 @@ export const addBlockBelowClickThunk = createAsyncThunk(
         );
         );
         dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string }));
         dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string }));
       }
       }
+
       return;
       return;
     }
     }
+
     // if current block is empty, open slash command
     // if current block is empty, open slash command
     dispatch(
     dispatch(
       rangeActions.setCaret({
       rangeActions.setCaret({
@@ -76,8 +81,9 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
     const docId = controller.documentId;
     const docId = controller.documentId;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const document = state.document[docId];
+    const document = state[DOCUMENT_NAME][docId];
     const node = document.nodes[id];
     const node = document.nodes[id];
+
     if (!node) return;
     if (!node) return;
     const delta = new Delta(node.data.delta);
     const delta = new Delta(node.data.delta);
     const text = getDeltaText(delta);
     const text = getDeltaText(delta);
@@ -107,6 +113,7 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
           delta: delta.slice(1, delta.length()).ops,
           delta: delta.slice(1, delta.length()).ops,
         },
         },
       };
       };
+
       await controller.applyActions([controller.getUpdateAction(updateNode)]);
       await controller.applyActions([controller.getUpdateAction(updateNode)]);
     }
     }
 
 

+ 25 - 6
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts

@@ -13,6 +13,7 @@ import {
 } from '$app/utils/document/action';
 } from '$app/utils/document/action';
 import { RangeState, SplitRelationship } from '$app/interfaces/document';
 import { RangeState, SplitRelationship } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
 import { blockConfig } from '$app/constants/document/config';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
 
 
 interface storeRangeThunkPayload {
 interface storeRangeThunkPayload {
   docId: string;
   docId: string;
@@ -32,17 +33,20 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
   const { docId, id, range } = payload;
   const { docId, id, range } = payload;
   const { dispatch, getState } = thunkAPI;
   const { dispatch, getState } = thunkAPI;
   const state = getState() as RootState;
   const state = getState() as RootState;
-  const rangeState = state.documentRange[docId];
-  const documentState = state.document[docId];
+  const rangeState = state[RANGE_NAME][docId];
+  const documentState = state[DOCUMENT_NAME][docId];
   // we need amend range between anchor and focus
   // we need amend range between anchor and focus
   const { anchor, focus, isDragging } = rangeState;
   const { anchor, focus, isDragging } = rangeState;
+
   if (!isDragging || !anchor || !focus) return;
   if (!isDragging || !anchor || !focus) return;
 
 
   const ranges: RangeState['ranges'] = {};
   const ranges: RangeState['ranges'] = {};
+
   ranges[id] = range;
   ranges[id] = range;
   // pin anchor index
   // pin anchor index
   let anchorIndex = anchor.point.index;
   let anchorIndex = anchor.point.index;
   let anchorLength = anchor.point.length;
   let anchorLength = anchor.point.length;
+
   if (anchorIndex === undefined || anchorLength === undefined) {
   if (anchorIndex === undefined || anchorLength === undefined) {
     dispatch(
     dispatch(
       rangeActions.setAnchorPointRange({
       rangeActions.setAnchorPointRange({
@@ -68,14 +72,17 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
   // amend anchor range because slatejs will stop update selection when dragging quickly
   // amend anchor range because slatejs will stop update selection when dragging quickly
   const isForward = anchor.point.y < focus.point.y;
   const isForward = anchor.point.y < focus.point.y;
   const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
   const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
+
   if (isForward) {
   if (isForward) {
     const selectedDelta = anchorDelta.slice(anchorIndex);
     const selectedDelta = anchorDelta.slice(anchorIndex);
+
     ranges[anchor.id] = {
     ranges[anchor.id] = {
       index: anchorIndex,
       index: anchorIndex,
       length: selectedDelta.length(),
       length: selectedDelta.length(),
     };
     };
   } else {
   } else {
     const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
     const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
+
     ranges[anchor.id] = {
     ranges[anchor.id] = {
       index: 0,
       index: 0,
       length: selectedDelta.length(),
       length: selectedDelta.length(),
@@ -87,6 +94,7 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
   const endId = isForward ? focus.id : anchor.id;
   const endId = isForward ? focus.id : anchor.id;
 
 
   const middleIds = getMiddleIds(documentState, startId, endId);
   const middleIds = getMiddleIds(documentState, startId, endId);
+
   middleIds.forEach((id) => {
   middleIds.forEach((id) => {
     const node = documentState.nodes[id];
     const node = documentState.nodes[id];
 
 
@@ -121,19 +129,22 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
     const docId = controller.documentId;
     const docId = controller.documentId;
     const { getState, dispatch } = thunkAPI;
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const rangeState = state.documentRange[docId];
-    const documentState = state.document[docId];
+    const rangeState = state[RANGE_NAME][docId];
+    const documentState = state[DOCUMENT_NAME][docId];
 
 
     const actions = [];
     const actions = [];
     // get merge actions
     // get merge actions
     const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
     const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
+
     if (mergeActions) {
     if (mergeActions) {
       actions.push(...mergeActions);
       actions.push(...mergeActions);
     }
     }
+
     // get middle nodes
     // get middle nodes
     const middleIds = getMiddleIdsByRange(rangeState, documentState);
     const middleIds = getMiddleIdsByRange(rangeState, documentState);
     // delete middle nodes
     // delete middle nodes
     const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
     const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
+
     actions.push(...deleteMiddleNodesActions);
     actions.push(...deleteMiddleNodesActions);
 
 
     const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
     const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
@@ -170,11 +181,12 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
     const { getState, dispatch } = thunkAPI;
     const { getState, dispatch } = thunkAPI;
     const docId = controller.documentId;
     const docId = controller.documentId;
     const state = getState() as RootState;
     const state = getState() as RootState;
-    const rangeState = state.documentRange[docId];
-    const documentState = state.document[docId];
+    const rangeState = state[RANGE_NAME][docId];
+    const documentState = state[DOCUMENT_NAME][docId];
     const actions = [];
     const actions = [];
 
 
     const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
     const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
+
     if (!startDelta || !endDelta || !endNode || !startNode) return;
     if (!startDelta || !endDelta || !endNode || !startNode) return;
 
 
     // get middle nodes
     // get middle nodes
@@ -182,12 +194,14 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
 
 
     let newStartDelta = new Delta(startDelta);
     let newStartDelta = new Delta(startDelta);
     let caret = null;
     let caret = null;
+
     if (shiftKey) {
     if (shiftKey) {
       newStartDelta = newStartDelta.insert('\n').concat(endDelta);
       newStartDelta = newStartDelta.insert('\n').concat(endDelta);
       caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
       caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
     } else {
     } else {
       const insertNodeDelta = new Delta(endDelta);
       const insertNodeDelta = new Delta(endDelta);
       const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
       const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
+
       if (!insertNodeAction) return;
       if (!insertNodeAction) return;
       actions.push(insertNodeAction.action);
       actions.push(insertNodeAction.action);
       caret = {
       caret = {
@@ -198,6 +212,7 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
       // move start node children to insert node
       // move start node children to insert node
       const needMoveChildren =
       const needMoveChildren =
         blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
         blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
+
       if (needMoveChildren) {
       if (needMoveChildren) {
         // filter children by delete middle ids
         // filter children by delete middle ids
         const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
         const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
@@ -208,6 +223,7 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
               ''
               ''
             )
             )
           : [];
           : [];
+
         actions.push(...moveChildrenAction);
         actions.push(...moveChildrenAction);
       }
       }
     }
     }
@@ -220,14 +236,17 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
         delta: newStartDelta.ops,
         delta: newStartDelta.ops,
       },
       },
     });
     });
+
     if (endNode.id !== startNode.id) {
     if (endNode.id !== startNode.id) {
       // delete end node
       // delete end node
       const deleteAction = controller.getDeleteAction(endNode);
       const deleteAction = controller.getDeleteAction(endNode);
+
       actions.push(updateAction, deleteAction);
       actions.push(updateAction, deleteAction);
     }
     }
 
 
     // delete middle nodes
     // delete middle nodes
     const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
     const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
+
     actions.push(...deleteMiddleNodesActions);
     actions.push(...deleteMiddleNodesActions);
 
 
     // apply actions
     // apply actions

+ 115 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts

@@ -0,0 +1,115 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME, EQUATION_PLACEHOLDER, RANGE_NAME, TEMPORARY_NAME } from '$app/constants/document/name';
+import { getDeltaByRange, getDeltaText } from '$app/utils/document/delta';
+import Delta from 'quill-delta';
+import { TemporaryState, TemporaryType } from '$app/interfaces/document';
+import { temporaryActions } from '$app_reducers/document/temporary_slice';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { rangeActions } from '$app_reducers/document/slice';
+
+export const createTemporary = createAsyncThunk(
+  'document/temporary/create',
+  async (payload: { docId: string; type?: TemporaryType; state?: TemporaryState }, thunkAPI) => {
+    const { docId, type } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as RootState;
+    let temporaryState = payload.state;
+
+    if (!temporaryState && type) {
+      const caret = state[RANGE_NAME][docId].caret;
+
+      if (!caret) {
+        return;
+      }
+
+      const { id, index, length } = caret;
+      const selection = {
+        index,
+        length,
+      };
+      const node = state[DOCUMENT_NAME][docId].nodes[id];
+      const nodeDelta = new Delta(node.data?.delta);
+      const rangeDelta = getDeltaByRange(nodeDelta, selection);
+      const text = getDeltaText(rangeDelta);
+
+      temporaryState = {
+        id,
+        selection,
+        selectedText: text,
+        type,
+        data: {
+          latex: text,
+        },
+      };
+    }
+
+    if (!temporaryState) return;
+    dispatch(rangeActions.initialState(docId));
+
+    dispatch(temporaryActions.setTemporaryState({ id: docId, state: temporaryState }));
+  }
+);
+
+export const formatTemporary = createAsyncThunk(
+  'document/temporary/format',
+  async (payload: { controller: DocumentController }, thunkAPI) => {
+    const { controller } = payload;
+    const docId = controller.documentId;
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as RootState;
+    const temporaryState = state[TEMPORARY_NAME][docId];
+
+    if (!temporaryState) {
+      return;
+    }
+
+    const { id, selection, type, data } = temporaryState;
+    const node = state[DOCUMENT_NAME][docId].nodes[id];
+    const nodeDelta = new Delta(node.data?.delta);
+    const { index, length } = selection;
+    const diffDelta: Delta = new Delta();
+    let newSelection;
+
+    switch (type) {
+      case TemporaryType.Equation: {
+        if (data.latex) {
+          newSelection = {
+            index: selection.index,
+            length: 1,
+          };
+          diffDelta.retain(index).delete(length).insert(EQUATION_PLACEHOLDER, {
+            formula: data.latex,
+          });
+        } else {
+          newSelection = {
+            index: selection.index,
+            length: 0,
+          };
+          diffDelta.retain(index).delete(length);
+        }
+
+        break;
+      }
+
+      default:
+        break;
+    }
+
+    const newDelta = nodeDelta.compose(diffDelta);
+
+    const updateAction = controller.getUpdateAction({
+      ...node,
+      data: {
+        ...node.data,
+        delta: newDelta.ops,
+      },
+    });
+
+    await controller.applyActions([updateAction]);
+    return {
+      ...temporaryState,
+      selection: newSelection,
+    };
+  }
+);

+ 41 - 19
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -11,6 +11,14 @@ import {
 import { BlockEventPayloadPB } from '@/services/backend';
 import { BlockEventPayloadPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
+import { temporarySlice } from '$app_reducers/document/temporary_slice';
+import {
+  DOCUMENT_NAME,
+  RANGE_NAME,
+  RECT_RANGE_NAME,
+  SLASH_COMMAND_NAME,
+  TEXT_LINK_NAME,
+} from '$app/constants/document/name';
 
 
 const initialState: Record<string, DocumentState> = {};
 const initialState: Record<string, DocumentState> = {};
 
 
@@ -23,7 +31,7 @@ const slashCommandInitialState: Record<string, SlashCommandState> = {};
 const linkPopoverState: Record<string, LinkPopoverState> = {};
 const linkPopoverState: Record<string, LinkPopoverState> = {};
 
 
 export const documentSlice = createSlice({
 export const documentSlice = createSlice({
-  name: 'document',
+  name: DOCUMENT_NAME,
   initialState: initialState,
   initialState: initialState,
   // Here we can't offer actions to update the document state.
   // Here we can't offer actions to update the document state.
   // Because the document state is updated by the `onDataChange`
   // Because the document state is updated by the `onDataChange`
@@ -91,7 +99,7 @@ export const documentSlice = createSlice({
 });
 });
 
 
 export const rectSelectionSlice = createSlice({
 export const rectSelectionSlice = createSlice({
-  name: 'documentRectSelection',
+  name: RECT_RANGE_NAME,
   initialState: rectSelectionInitialState,
   initialState: rectSelectionInitialState,
   reducers: {
   reducers: {
     initialState: (state, action: PayloadAction<string>) => {
     initialState: (state, action: PayloadAction<string>) => {
@@ -150,7 +158,7 @@ export const rectSelectionSlice = createSlice({
 });
 });
 
 
 export const rangeSlice = createSlice({
 export const rangeSlice = createSlice({
-  name: 'documentRange',
+  name: RANGE_NAME,
   initialState: rangeInitialState,
   initialState: rangeInitialState,
   reducers: {
   reducers: {
     initialState: (state, action: PayloadAction<string>) => {
     initialState: (state, action: PayloadAction<string>) => {
@@ -208,16 +216,19 @@ export const rangeSlice = createSlice({
       state,
       state,
       action: PayloadAction<{
       action: PayloadAction<{
         docId: string;
         docId: string;
-        id: string;
-        point: { x: number; y: number };
+        anchorPoint?: {
+          id: string;
+          point: { x: number; y: number };
+        };
       }>
       }>
     ) => {
     ) => {
-      const { docId, id, point } = action.payload;
+      const { docId, anchorPoint } = action.payload;
 
 
-      state[docId].anchor = {
-        id,
-        point,
-      };
+      if (anchorPoint) {
+        state[docId].anchor = { ...anchorPoint };
+      } else {
+        delete state[docId].anchor;
+      }
     },
     },
     setAnchorPointRange: (
     setAnchorPointRange: (
       state,
       state,
@@ -241,17 +252,21 @@ export const rangeSlice = createSlice({
       state,
       state,
       action: PayloadAction<{
       action: PayloadAction<{
         docId: string;
         docId: string;
-        id: string;
-        point: { x: number; y: number };
+        focusPoint?: {
+          id: string;
+          point: { x: number; y: number };
+        };
       }>
       }>
     ) => {
     ) => {
-      const { docId, id, point } = action.payload;
+      const { docId, focusPoint } = action.payload;
 
 
-      state[docId].focus = {
-        id,
-        point,
-      };
+      if (focusPoint) {
+        state[docId].focus = { ...focusPoint };
+      } else {
+        delete state[docId].focus;
+      }
     },
     },
+
     setDragging: (
     setDragging: (
       state,
       state,
       action: PayloadAction<{
       action: PayloadAction<{
@@ -295,6 +310,12 @@ export const rangeSlice = createSlice({
     ) => {
     ) => {
       const { docId, exclude } = action.payload;
       const { docId, exclude } = action.payload;
       const ranges = state[docId].ranges;
       const ranges = state[docId].ranges;
+
+      if (!exclude) {
+        state[docId].ranges = {};
+        return;
+      }
+
       const newRanges = Object.keys(ranges).reduce((acc, id) => {
       const newRanges = Object.keys(ranges).reduce((acc, id) => {
         if (id !== exclude) return { ...acc };
         if (id !== exclude) return { ...acc };
         return {
         return {
@@ -309,7 +330,7 @@ export const rangeSlice = createSlice({
 });
 });
 
 
 export const slashCommandSlice = createSlice({
 export const slashCommandSlice = createSlice({
-  name: 'documentSlashCommand',
+  name: SLASH_COMMAND_NAME,
   initialState: slashCommandInitialState,
   initialState: slashCommandInitialState,
   reducers: {
   reducers: {
     initialState: (state, action: PayloadAction<string>) => {
     initialState: (state, action: PayloadAction<string>) => {
@@ -365,7 +386,7 @@ export const slashCommandSlice = createSlice({
 });
 });
 
 
 export const linkPopoverSlice = createSlice({
 export const linkPopoverSlice = createSlice({
-  name: 'documentLinkPopover',
+  name: TEXT_LINK_NAME,
   initialState: linkPopoverState,
   initialState: linkPopoverState,
   reducers: {
   reducers: {
     initialState: (state, action: PayloadAction<string>) => {
     initialState: (state, action: PayloadAction<string>) => {
@@ -418,6 +439,7 @@ export const documentReducers = {
   [rangeSlice.name]: rangeSlice.reducer,
   [rangeSlice.name]: rangeSlice.reducer,
   [slashCommandSlice.name]: slashCommandSlice.reducer,
   [slashCommandSlice.name]: slashCommandSlice.reducer,
   [linkPopoverSlice.name]: linkPopoverSlice.reducer,
   [linkPopoverSlice.name]: linkPopoverSlice.reducer,
+  [temporarySlice.name]: temporarySlice.reducer,
 };
 };
 
 
 export const documentActions = documentSlice.actions;
 export const documentActions = documentSlice.actions;

+ 37 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/temporary_slice.ts

@@ -0,0 +1,37 @@
+import { TemporaryState } from '$app/interfaces/document';
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { TEMPORARY_NAME } from '$app/constants/document/name';
+
+const initialState: Record<string, TemporaryState> = {};
+
+export const temporarySlice = createSlice({
+  name: TEMPORARY_NAME,
+  initialState,
+  reducers: {
+    setTemporaryState: (state, action: PayloadAction<{ id: string; state: TemporaryState }>) => {
+      const { id, state: temporaryState } = action.payload;
+
+      state[id] = temporaryState;
+    },
+    updateTemporaryState: (state, action: PayloadAction<{ id: string; state: Partial<TemporaryState> }>) => {
+      const { id, state: temporaryState } = action.payload;
+
+      if (!state[id]) {
+        return;
+      }
+
+      if (temporaryState.id !== state[id].id) {
+        return;
+      }
+
+      state[id] = { ...state[id], ...temporaryState };
+    },
+    deleteTemporaryState: (state, action: PayloadAction<string>) => {
+      const id = action.payload;
+
+      delete state[id];
+    },
+  },
+});
+
+export const temporaryActions = temporarySlice.actions;

+ 3 - 2
frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts

@@ -23,6 +23,7 @@ import {
   transformIndexToNextLine,
   transformIndexToNextLine,
   transformIndexToPrevLine,
   transformIndexToPrevLine,
 } from '$app/utils/document/delta';
 } from '$app/utils/document/delta';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
 
 
 export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
 export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
   const middleIds = [];
   const middleIds = [];
@@ -116,8 +117,8 @@ export function getMergeEndDeltaToStartActionsByRange(
 ) {
 ) {
   const actions = [];
   const actions = [];
   const docId = controller.documentId;
   const docId = controller.documentId;
-  const documentState = state.document[docId];
-  const rangeState = state.documentRange[docId];
+  const documentState = state[DOCUMENT_NAME][docId];
+  const rangeState = state[RANGE_NAME][docId];
   const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
   const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
 
 
   if (!startDelta || !endDelta || !endNode || !startNode) return;
   if (!startDelta || !endDelta || !endNode || !startNode) return;

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

@@ -1,18 +1,22 @@
 import Delta from 'quill-delta';
 import Delta from 'quill-delta';
+import emojiRegex from 'emoji-regex';
 
 
 export function getDeltaText(delta: Delta) {
 export function getDeltaText(delta: Delta) {
   const text = delta
   const text = delta
     .filter((op) => typeof op.insert === 'string')
     .filter((op) => typeof op.insert === 'string')
     .map((op) => op.insert)
     .map((op) => op.insert)
     .join('');
     .join('');
+
   return text;
   return text;
 }
 }
 
 
 export function caretInTopEdgeByDelta(delta: Delta, index: number) {
 export function caretInTopEdgeByDelta(delta: Delta, index: number) {
   const text = getDeltaText(delta.slice(0, index));
   const text = getDeltaText(delta.slice(0, index));
+
   if (!text) return true;
   if (!text) return true;
 
 
   const firstLine = text.split('\n')[0];
   const firstLine = text.split('\n')[0];
+
   return index <= firstLine.length;
   return index <= firstLine.length;
 }
 }
 
 
@@ -31,6 +35,7 @@ export function getLineByIndex(delta: Delta, index: number) {
 
 
   const startLineText = beforeLines[beforeLines.length - 1];
   const startLineText = beforeLines[beforeLines.length - 1];
   const currentLineText = startLineText + afterLines[0];
   const currentLineText = startLineText + afterLines[0];
+
   return {
   return {
     text: currentLineText,
     text: currentLineText,
     index: beforeText.length - startLineText.length,
     index: beforeText.length - startLineText.length,
@@ -40,9 +45,11 @@ export function getLineByIndex(delta: Delta, index: number) {
 export function transformIndexToPrevLine(delta: Delta, index: number) {
 export function transformIndexToPrevLine(delta: Delta, index: number) {
   const text = getDeltaText(delta.slice(0, index));
   const text = getDeltaText(delta.slice(0, index));
   const lines = text.split('\n');
   const lines = text.split('\n');
+
   if (lines.length < 2) return 0;
   if (lines.length < 2) return 0;
   const prevLineText = lines[lines.length - 2];
   const prevLineText = lines[lines.length - 2];
   const transformedIndex = index - prevLineText.length - 1;
   const transformedIndex = index - prevLineText.length - 1;
+
   return transformedIndex > 0 ? transformedIndex : 0;
   return transformedIndex > 0 ? transformedIndex : 0;
 }
 }
 
 
@@ -54,6 +61,7 @@ export function transformIndexToNextLine(delta: Delta, index: number) {
   const text = getDeltaText(delta);
   const text = getDeltaText(delta);
   const currentLineText = getCurrentLineText(delta, index);
   const currentLineText = getCurrentLineText(delta, index);
   const transformedIndex = index + currentLineText.length + 1;
   const transformedIndex = index + currentLineText.length + 1;
+
   return transformedIndex > text.length ? text.length : transformedIndex;
   return transformedIndex > text.length ? text.length : transformedIndex;
 }
 }
 
 
@@ -61,12 +69,14 @@ export function getIndexRelativeEnter(delta: Delta, index: number) {
   const text = getDeltaText(delta.slice(0, index));
   const text = getDeltaText(delta.slice(0, index));
   const beforeLines = text.split('\n');
   const beforeLines = text.split('\n');
   const beforeLineText = beforeLines[beforeLines.length - 1];
   const beforeLineText = beforeLines[beforeLines.length - 1];
+
   return beforeLineText.length;
   return beforeLineText.length;
 }
 }
 
 
 export function getLastLineIndex(delta: Delta) {
 export function getLastLineIndex(delta: Delta) {
   const text = getDeltaText(delta);
   const text = getDeltaText(delta);
   const lastIndex = text.lastIndexOf('\n');
   const lastIndex = text.lastIndexOf('\n');
+
   return lastIndex === -1 ? 0 : lastIndex + 1;
   return lastIndex === -1 ? 0 : lastIndex + 1;
 }
 }
 
 
@@ -79,6 +89,7 @@ export function getDeltaByRange(
 ) {
 ) {
   const start = range.index;
   const start = range.index;
   const end = range.index + range.length;
   const end = range.index + range.length;
+
   return new Delta(delta.slice(start, end));
   return new Delta(delta.slice(start, end));
 }
 }
 
 
@@ -90,6 +101,7 @@ export function getBeofreExtentDeltaByRange(
   }
   }
 ) {
 ) {
   const start = range.index;
   const start = range.index;
+
   return new Delta(delta.slice(0, start));
   return new Delta(delta.slice(0, start));
 }
 }
 
 
@@ -101,5 +113,46 @@ export function getAfterExtentDeltaByRange(
   }
   }
 ) {
 ) {
   const start = range.index + range.length;
   const start = range.index + range.length;
+
   return new Delta(delta.slice(start));
   return new Delta(delta.slice(start));
 }
 }
+
+export function getPreviousWordIndex(delta: Delta, index: number) {
+  if (index === 0) return 0;
+  const text = getDeltaText(delta.slice(0, index));
+  const prevChar = text.charAt(index - 1);
+
+  if (!prevChar) return index;
+
+  if (isEmojiTail(prevChar)) {
+    // the char is emoji tail
+    // get all emojis from 0 to index
+    const emojis = getEmojis(text.substring(0, index));
+
+    if (emojis && emojis.length > 0) {
+      // get the last emoji
+      const lastEmoji = emojis[emojis.length - 1];
+      // move the index to the last emoji head
+      const distance = lastEmoji.length;
+
+      return index - distance;
+    }
+  }
+
+  // default return the index - 1
+  return index - 1;
+}
+
+const regex = emojiRegex();
+
+function getEmojis(text: string) {
+  const emojis = text.match(regex);
+
+  return emojis;
+}
+
+function isEmojiTail(character: string) {
+  const codepoint = character.charCodeAt(0);
+
+  return 0xdc00 <= codepoint && codepoint <= 0xdfff;
+}

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

@@ -4,11 +4,13 @@ function isTextNode(node: Node): boolean {
 
 
 export function exclude(node: Element) {
 export function exclude(node: Element) {
   let isPlaceholder = false;
   let isPlaceholder = false;
+
   try {
   try {
     isPlaceholder = !!node.getAttribute('data-slate-placeholder');
     isPlaceholder = !!node.getAttribute('data-slate-placeholder');
   } catch (e) {
   } catch (e) {
     // ignore
     // ignore
   }
   }
+
   return isPlaceholder;
   return isPlaceholder;
 }
 }
 
 
@@ -16,13 +18,16 @@ export function findFirstTextNode(node: Node): Node | null {
   if (isTextNode(node)) {
   if (isTextNode(node)) {
     return node;
     return node;
   }
   }
+
   if (exclude && exclude(node as Element)) {
   if (exclude && exclude(node as Element)) {
     return null;
     return null;
   }
   }
 
 
   const children = node.childNodes;
   const children = node.childNodes;
+
   for (let i = 0; i < children.length; i++) {
   for (let i = 0; i < children.length; i++) {
     const textNode = findFirstTextNode(children[i]);
     const textNode = findFirstTextNode(children[i]);
+
     if (textNode) {
     if (textNode) {
       return textNode;
       return textNode;
     }
     }
@@ -41,6 +46,7 @@ export function setCursorAtStartOfNode(node: Node): void {
   }
   }
 
 
   const selection = window.getSelection();
   const selection = window.getSelection();
+
   selection?.removeAllRanges();
   selection?.removeAllRanges();
   selection?.addRange(range);
   selection?.addRange(range);
 }
 }
@@ -55,8 +61,10 @@ export function findLastTextNode(node: Node): Node | null {
   }
   }
 
 
   const children = node.childNodes;
   const children = node.childNodes;
+
   for (let i = children.length - 1; i >= 0; i--) {
   for (let i = children.length - 1; i >= 0; i--) {
     const textNode = findLastTextNode(children[i]);
     const textNode = findLastTextNode(children[i]);
+
     if (textNode) {
     if (textNode) {
       return textNode;
       return textNode;
     }
     }
@@ -71,11 +79,13 @@ export function setCursorAtEndOfNode(node: Node): void {
 
 
   if (textNode) {
   if (textNode) {
     const textLength = textNode.textContent?.length || 0;
     const textLength = textNode.textContent?.length || 0;
+
     range.setStart(textNode, textLength);
     range.setStart(textNode, textLength);
     range.setEnd(textNode, textLength);
     range.setEnd(textNode, textLength);
   }
   }
 
 
   const selection = window.getSelection();
   const selection = window.getSelection();
+
   selection?.removeAllRanges();
   selection?.removeAllRanges();
   selection?.addRange(range);
   selection?.addRange(range);
 }
 }
@@ -84,47 +94,60 @@ export function setFullRangeAtNode(node: Node): void {
   const range = document.createRange();
   const range = document.createRange();
   const firstTextNode = findFirstTextNode(node);
   const firstTextNode = findFirstTextNode(node);
   const lastTextNode = findLastTextNode(node);
   const lastTextNode = findLastTextNode(node);
+
   if (!firstTextNode || !lastTextNode) return;
   if (!firstTextNode || !lastTextNode) return;
   range.setStart(firstTextNode, 0);
   range.setStart(firstTextNode, 0);
   range.setEnd(lastTextNode, lastTextNode.textContent?.length || 0);
   range.setEnd(lastTextNode, lastTextNode.textContent?.length || 0);
   const selection = window.getSelection();
   const selection = window.getSelection();
+
   selection?.removeAllRanges();
   selection?.removeAllRanges();
   selection?.addRange(range);
   selection?.addRange(range);
 }
 }
 
 
 export function getBlockIdByPoint(target: HTMLElement | null) {
 export function getBlockIdByPoint(target: HTMLElement | null) {
   let node = target;
   let node = target;
+
   while (node) {
   while (node) {
     const id = node.getAttribute('data-block-id');
     const id = node.getAttribute('data-block-id');
+
     if (id) {
     if (id) {
       return id;
       return id;
     }
     }
+
     node = node.parentElement;
     node = node.parentElement;
   }
   }
+
   return null;
   return null;
 }
 }
 
 
 export function findTextBoxParent(target: HTMLElement | null) {
 export function findTextBoxParent(target: HTMLElement | null) {
   let node = target;
   let node = target;
+
   while (node) {
   while (node) {
     if (node.getAttribute('role') === 'textbox') {
     if (node.getAttribute('role') === 'textbox') {
       return node;
       return node;
     }
     }
+
     node = node.parentElement;
     node = node.parentElement;
   }
   }
+
   return null;
   return null;
 }
 }
 
 
 export function isFocused(blockId: string) {
 export function isFocused(blockId: string) {
   const selection = window.getSelection();
   const selection = window.getSelection();
+
   if (!selection) return false;
   if (!selection) return false;
   const { anchorNode, focusNode } = selection;
   const { anchorNode, focusNode } = selection;
+
   if (!anchorNode || !focusNode) return false;
   if (!anchorNode || !focusNode) return false;
   const anchorElement = anchorNode.parentElement;
   const anchorElement = anchorNode.parentElement;
   const focusElement = focusNode.parentElement;
   const focusElement = focusNode.parentElement;
+
   if (!anchorElement || !focusElement) return false;
   if (!anchorElement || !focusElement) return false;
   const anchorBlockId = getBlockIdByPoint(anchorElement);
   const anchorBlockId = getBlockIdByPoint(anchorElement);
   const focusBlockId = getBlockIdByPoint(focusElement);
   const focusBlockId = getBlockIdByPoint(focusElement);
+
   return anchorBlockId === blockId || focusBlockId === blockId;
   return anchorBlockId === blockId || focusBlockId === blockId;
 }
 }
 
 
@@ -134,12 +157,15 @@ export function getNode(id: string) {
 
 
 export function isPointInBlock(target: HTMLElement | null) {
 export function isPointInBlock(target: HTMLElement | null) {
   let node = target;
   let node = target;
+
   while (node) {
   while (node) {
     if (node.getAttribute('data-block-id')) {
     if (node.getAttribute('data-block-id')) {
       return true;
       return true;
     }
     }
+
     node = node.parentElement;
     node = node.parentElement;
   }
   }
+
   return false;
   return false;
 }
 }
 
 
@@ -153,21 +179,27 @@ export function findTextNode(
 } {
 } {
   if (isTextNode(node)) {
   if (isTextNode(node)) {
     const textLength = node.textContent?.length || 0;
     const textLength = node.textContent?.length || 0;
+
     if (index <= textLength) {
     if (index <= textLength) {
       return { node, offset: index };
       return { node, offset: index };
     }
     }
+
     return { remainingIndex: index - textLength };
     return { remainingIndex: index - textLength };
   }
   }
 
 
   if (exclude && exclude(node)) {
   if (exclude && exclude(node)) {
     return { remainingIndex: index };
     return { remainingIndex: index };
   }
   }
+
   let remainingIndex = index;
   let remainingIndex = index;
+
   for (const childNode of node.childNodes) {
   for (const childNode of node.childNodes) {
     const result = findTextNode(childNode as Element, remainingIndex);
     const result = findTextNode(childNode as Element, remainingIndex);
+
     if (result.node) {
     if (result.node) {
       return result;
       return result;
     }
     }
+
     remainingIndex = result.remainingIndex || index;
     remainingIndex = result.remainingIndex || index;
   }
   }
 
 
@@ -176,6 +208,7 @@ export function findTextNode(
 
 
 export function getRangeByIndex(node: Element, index: number, length: number) {
 export function getRangeByIndex(node: Element, index: number, length: number) {
   const textBoxNode = node.querySelector(`[role="textbox"]`);
   const textBoxNode = node.querySelector(`[role="textbox"]`);
+
   if (!textBoxNode) return;
   if (!textBoxNode) return;
   const anchorNode = findTextNode(textBoxNode, index);
   const anchorNode = findTextNode(textBoxNode, index);
   const focusNode = findTextNode(textBoxNode, index + length);
   const focusNode = findTextNode(textBoxNode, index + length);
@@ -183,6 +216,7 @@ export function getRangeByIndex(node: Element, index: number, length: number) {
   if (!anchorNode?.node || !focusNode?.node) return;
   if (!anchorNode?.node || !focusNode?.node) return;
 
 
   const range = document.createRange();
   const range = document.createRange();
+
   range.setStart(anchorNode.node, anchorNode.offset || 0);
   range.setStart(anchorNode.node, anchorNode.offset || 0);
   range.setEnd(focusNode.node, focusNode.offset || 0);
   range.setEnd(focusNode.node, focusNode.offset || 0);
   return range;
   return range;
@@ -190,19 +224,25 @@ export function getRangeByIndex(node: Element, index: number, length: number) {
 
 
 export function focusNodeByIndex(node: Element, index: number, length: number) {
 export function focusNodeByIndex(node: Element, index: number, length: number) {
   const range = getRangeByIndex(node, index, length);
   const range = getRangeByIndex(node, index, length);
+
   if (!range) return false;
   if (!range) return false;
   const selection = window.getSelection();
   const selection = window.getSelection();
+
   selection?.removeAllRanges();
   selection?.removeAllRanges();
+
   selection?.addRange(range);
   selection?.addRange(range);
   const focusNode = selection?.focusNode;
   const focusNode = selection?.focusNode;
+
   if (!focusNode) return false;
   if (!focusNode) return false;
 
 
   const parent = findParent(focusNode as Element, node);
   const parent = findParent(focusNode as Element, node);
+
   return Boolean(parent);
   return Boolean(parent);
 }
 }
 
 
 export function getNodeTextBoxByBlockId(blockId: string) {
 export function getNodeTextBoxByBlockId(blockId: string) {
   const node = getNode(blockId);
   const node = getNode(blockId);
+
   return node?.querySelector(`[role="textbox"]`);
   return node?.querySelector(`[role="textbox"]`);
 }
 }
 
 
@@ -210,13 +250,17 @@ export function getNodeText(node: Element) {
   if (isTextNode(node)) {
   if (isTextNode(node)) {
     return node.textContent || '';
     return node.textContent || '';
   }
   }
+
   if (exclude && exclude(node)) {
   if (exclude && exclude(node)) {
     return '';
     return '';
   }
   }
+
   let text = '';
   let text = '';
+
   for (const childNode of node.childNodes) {
   for (const childNode of node.childNodes) {
     text += getNodeText(childNode as Element);
     text += getNodeText(childNode as Element);
   }
   }
+
   return replaceZeroWidthSpace(text);
   return replaceZeroWidthSpace(text);
 }
 }
 
 
@@ -231,14 +275,18 @@ export function replaceZeroWidthSpace(text: string) {
 
 
 export function findParent(node: Element, parentSelector: string | Element) {
 export function findParent(node: Element, parentSelector: string | Element) {
   let parentNode: Element | null = node;
   let parentNode: Element | null = node;
+
   while (parentNode) {
   while (parentNode) {
     if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) {
     if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) {
       return parentNode;
       return parentNode;
     }
     }
+
     if (parentNode === parentSelector) {
     if (parentNode === parentSelector) {
       return parentNode;
       return parentNode;
     }
     }
+
     parentNode = parentNode.parentElement;
     parentNode = parentNode.parentElement;
   }
   }
+
   return null;
   return null;
 }
 }

+ 46 - 11
frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts

@@ -1,8 +1,28 @@
-import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from "slate";
-import Delta from "quill-delta";
-import { getLineByIndex } from "$app/utils/document/delta";
+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[]){
+export function converToSlatePoint(editor: Editor, index: number) {
+  const children = editor.children;
+  const texts = (children[0] as BaseElement).children.map((child) => (child as Text).text);
+  let path = [0, 0];
+  let offset = 0;
+  let charCount = 0;
+
+  texts.forEach((text, i) => {
+    const endOffset = charCount + text.length;
+
+    if (index >= charCount && index <= endOffset) {
+      path = [0, i];
+      offset = index - charCount;
+    }
+
+    charCount += text.length;
+  });
+  return { path, offset };
+}
+
+export function convertToSlateSelection(index: number, length: number, slateValue: Descendant[]) {
   if (!slateValue || slateValue.length === 0) return null;
   if (!slateValue || slateValue.length === 0) return null;
   const texts = (slateValue[0] as BaseElement).children.map((child) => (child as Text).text);
   const texts = (slateValue[0] as BaseElement).children.map((child) => (child as Text).text);
   const anchorIndex = index;
   const anchorIndex = index;
@@ -12,16 +32,20 @@ export function convertToSlateSelection(index: number, length: number, slateValu
   let anchorOffset = 0;
   let anchorOffset = 0;
   let focusOffset = 0;
   let focusOffset = 0;
   let charCount = 0;
   let charCount = 0;
+
   texts.forEach((text, i) => {
   texts.forEach((text, i) => {
     const endOffset = charCount + text.length;
     const endOffset = charCount + text.length;
+
     if (anchorIndex >= charCount && anchorIndex <= endOffset) {
     if (anchorIndex >= charCount && anchorIndex <= endOffset) {
       anchorPath = [0, i];
       anchorPath = [0, i];
       anchorOffset = anchorIndex - charCount;
       anchorOffset = anchorIndex - charCount;
     }
     }
+
     if (focusIndex >= charCount && focusIndex <= endOffset) {
     if (focusIndex >= charCount && focusIndex <= endOffset) {
       focusPath = [0, i];
       focusPath = [0, i];
       focusOffset = focusIndex - charCount;
       focusOffset = focusIndex - charCount;
     }
     }
+
     charCount += text.length;
     charCount += text.length;
   });
   });
   return {
   return {
@@ -50,6 +74,7 @@ export function converToIndexLength(editor: Editor, range: Selection) {
     focus: after,
     focus: after,
   }).length;
   }).length;
   const length = focusIndex - index;
   const length = focusIndex - index;
+
   return { index, length };
   return { index, length };
 }
 }
 
 
@@ -82,53 +107,63 @@ export function convertToSlateValue(delta: Delta): Descendant[] {
 export function convertToDelta(slateValue: Descendant[]) {
 export function convertToDelta(slateValue: Descendant[]) {
   const ops = (slateValue[0] as Element).children.map((child) => {
   const ops = (slateValue[0] as Element).children.map((child) => {
     const { text, ...attributes } = child as Text;
     const { text, ...attributes } = child as Text;
+
     return {
     return {
       insert: text,
       insert: text,
       attributes,
       attributes,
     };
     };
   });
   });
+
   return new Delta(ops);
   return new Delta(ops);
 }
 }
 
 
 function getBreakLineBeginPoint(editor: Editor, at: Selection): BasePoint | undefined {
 function getBreakLineBeginPoint(editor: Editor, at: Selection): BasePoint | undefined {
   const delta = convertToDelta(editor.children);
   const delta = convertToDelta(editor.children);
   const currentSelection = converToIndexLength(editor, at);
   const currentSelection = converToIndexLength(editor, at);
+
   if (!currentSelection) return;
   if (!currentSelection) return;
   const { index } = getLineByIndex(delta, currentSelection.index);
   const { index } = getLineByIndex(delta, currentSelection.index);
   const selection = convertToSlateSelection(index, 0, editor.children);
   const selection = convertToSlateSelection(index, 0, editor.children);
+
   return selection?.anchor;
   return selection?.anchor;
 }
 }
 
 
 export function indent(editor: Editor, distance: number) {
 export function indent(editor: Editor, distance: number) {
   const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
   const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
+
   if (!beginPoint) return;
   if (!beginPoint) return;
-  const emptyStr = "".padStart(distance);
+  const emptyStr = ''.padStart(distance);
 
 
   editor.insertText(emptyStr, {
   editor.insertText(emptyStr, {
-    at: beginPoint
+    at: beginPoint,
   });
   });
 }
 }
 
 
 export function outdent(editor: Editor, distance: number) {
 export function outdent(editor: Editor, distance: number) {
   const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
   const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
+
   if (!beginPoint) return;
   if (!beginPoint) return;
   const afterBeginPoint = Editor.after(editor, beginPoint, {
   const afterBeginPoint = Editor.after(editor, beginPoint, {
-    distance
+    distance,
   });
   });
+
   if (!afterBeginPoint) return;
   if (!afterBeginPoint) return;
   const deleteChar = Editor.string(editor, {
   const deleteChar = Editor.string(editor, {
     anchor: beginPoint,
     anchor: beginPoint,
-    focus: afterBeginPoint
+    focus: afterBeginPoint,
   });
   });
-  const emptyStr = "".padStart(distance);
+  const emptyStr = ''.padStart(distance);
+
   if (deleteChar !== emptyStr) {
   if (deleteChar !== emptyStr) {
     if (distance > 1) {
     if (distance > 1) {
       outdent(editor, distance - 1);
       outdent(editor, distance - 1);
     }
     }
+
     return;
     return;
   }
   }
+
   editor.delete({
   editor.delete({
     at: beginPoint,
     at: beginPoint,
-    distance
+    distance,
   });
   });
-}
+}

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

@@ -1,8 +1,8 @@
 import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
 import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
 import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
 import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
 import { Log } from '../log';
 import { Log } from '../log';
-import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
 import { isEqual } from '$app/utils/tool';
 import { isEqual } from '$app/utils/tool';
+import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
 
 
 // This is a list of all the possible changes that can happen to document data
 // This is a list of all the possible changes that can happen to document data
 const matchCases = [
 const matchCases = [

+ 14 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/temporary.ts

@@ -0,0 +1,14 @@
+export function isOverlappingPrefix(first: string, second: string): boolean {
+  if (first.length === 0 || second.length === 0) return false;
+  let i = 0;
+
+  while (i < first.length) {
+    const chars = first.substring(i);
+
+    if (chars.length > second.length) return false;
+    if (second.startsWith(chars)) return true;
+    i++;
+  }
+
+  return false;
+}

+ 3 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/env.ts

@@ -0,0 +1,3 @@
+export function isApple() {
+  return typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent);
+}