Преглед изворни кода

Support document multiple instance and change doc_id from String to &str (#2808)

* fix: support multiple document

* fix: change the doc_id params to ref

* fix: function to converge subscription state

* fix: mousedown behavior update

* fix: turn to textblock when the enter pressed in empty block

* fix: support cut

* fix: emoji caret

* fix: support slash arrow key

* fix: eslint padding-line-between-statements

* fix: add comment

* fix: block side menu bugs

* fix: support key event to select block menu option

* fix: support side menu arrow key

* fix: optimizate selectOptionByUpDown

* fix: format
Kilu.He пре 1 година
родитељ
комит
177f7c4fa3
79 измењених фајлова са 1792 додато и 750 уклоњено
  1. 10 0
      frontend/appflowy_tauri/.eslintrc.cjs
  2. 37 19
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
  3. 23 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts
  4. 4 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts
  5. 6 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts
  6. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts
  7. 134 44
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx
  8. 12 13
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx
  9. 11 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx
  10. 0 36
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/MenuItem.tsx
  11. 13 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  12. 197 39
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx
  13. 24 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts
  14. 12 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx
  15. 3 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts
  16. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx
  17. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts
  18. 6 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts
  19. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  20. 0 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/Root/Root.hooks.tsx
  21. 0 16
      frontend/appflowy_tauri/src/appflowy_app/components/document/Root/Tree.hooks.tsx
  22. 6 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts
  23. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
  24. 16 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  25. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts
  26. 2 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
  27. 11 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts
  28. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts
  29. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts
  30. 26 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts
  31. 3 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts
  32. 15 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts
  33. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts
  34. 13 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts
  35. 76 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx
  36. 14 36
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
  37. 20 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts
  38. 12 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts
  39. 15 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  40. 10 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts
  41. 32 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts
  42. 12 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts
  43. 23 14
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkEditPopover.tsx
  44. 14 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/index.tsx
  45. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts
  46. 108 26
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx
  47. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts
  48. 30 0
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  49. 16 5
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
  50. 5 3
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts
  51. 12 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts
  52. 9 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts
  53. 6 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts
  54. 7 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts
  55. 12 10
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts
  56. 9 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
  57. 33 9
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts
  58. 34 23
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
  59. 1 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts
  60. 86 43
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
  61. 26 35
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts
  62. 25 8
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
  63. 53 22
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
  64. 18 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts
  65. 12 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts
  66. 258 77
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  67. 51 6
      frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
  68. 0 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts
  69. 8 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/menu.ts
  70. 10 31
      frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
  71. 12 6
      frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts
  72. 58 10
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts
  73. 1 1
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx
  74. 5 5
      frontend/rust-lib/flowy-core/src/deps_resolve/folder2_deps.rs
  75. 7 7
      frontend/rust-lib/flowy-document2/src/event_handler.rs
  76. 25 14
      frontend/rust-lib/flowy-document2/src/manager.rs
  77. 2 2
      frontend/rust-lib/flowy-document2/tests/document/document_redo_undo_test.rs
  78. 13 13
      frontend/rust-lib/flowy-document2/tests/document/document_test.rs
  79. 2 2
      frontend/rust-lib/flowy-document2/tests/document/util.rs

+ 10 - 0
frontend/appflowy_tauri/.eslintrc.cjs

@@ -57,6 +57,16 @@ module.exports = {
         argsIgnorePattern: '^_',
       }
     ],
+    'padding-line-between-statements': [
+      "warn",
+      { blankLine: "always", prev: ["const", "let", "var"], next: "*"},
+      { blankLine: "any",    prev: ["const", "let", "var"], next: ["const", "let", "var"]},
+      { blankLine: "always", prev: "import", next: "*" },
+      { blankLine: "any", prev: "import", next: "import" },
+      { blankLine: "always", prev: "block-like", next: "*" },
+      { blankLine: "always", prev: "block", next: "*" },
+
+    ]
   },
   ignorePatterns: ['src/**/*.test.ts'],
 };

+ 37 - 19
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts

@@ -1,6 +1,6 @@
 import { useCallback, useEffect, useRef, useState } from 'react';
 import { rangeActions } from '$app_reducers/document/slice';
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { useAppDispatch } from '$app/stores/store';
 import {
   getBlockIdByPoint,
   getNodeTextBoxByBlockId,
@@ -9,12 +9,16 @@ import {
   setCursorAtStartOfNode,
 } from '$app/utils/document/node';
 import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
 
 export function useBlockRangeSelection(container: HTMLDivElement) {
   const dispatch = useAppDispatch();
   const onKeyDown = useRangeKeyDown();
-  const range = useAppSelector((state) => state.documentRange);
-  const isDragging = range.isDragging;
+  const { docId } = useSubscribeDocument();
+
+  const range = useSubscribeRanges();
+  const isDragging = range?.isDragging;
 
   const anchorRef = useRef<{
     id: string;
@@ -28,13 +32,9 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
 
   const [isForward, setForward] = useState(true);
 
-  const reset = useCallback(() => {
-    dispatch(rangeActions.clearRange());
-    setForward(true);
-  }, [dispatch]);
-
   // display caret color
   useEffect(() => {
+    if (!range) return;
     const { anchor, focus } = range;
     if (!anchor || !focus) {
       container.classList.remove('caret-transparent');
@@ -54,7 +54,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
     const selection = window.getSelection();
     if (!selection) return;
     // update focus point
-    dispatch(rangeActions.setFocusPoint(focus));
+    dispatch(
+      rangeActions.setFocusPoint({
+        ...focus,
+        docId,
+      })
+    );
 
     const focused = isFocused(focus.id);
     // if the focus block is not focused, we need to set the cursor position
@@ -82,17 +87,18 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
         setCursorAtEndOfNode(node);
       }
     }
-  }, [container, dispatch, focus, isForward]);
+  }, [container, dispatch, docId, focus, isForward]);
 
   const handleDragStart = useCallback(
     (e: MouseEvent) => {
-      reset();
+      setForward(true);
       // skip if the target is not a block
       const blockId = getBlockIdByPoint(e.target as HTMLElement);
       if (!blockId) {
+        dispatch(rangeActions.initialState(docId));
         return;
       }
-
+      dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
       const startX = e.clientX + container.scrollLeft;
       const startY = e.clientY + container.scrollTop;
 
@@ -108,11 +114,17 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
         ...anchor,
       };
       // set the anchor point and focus point
-      dispatch(rangeActions.setAnchorPoint({ ...anchor }));
-      dispatch(rangeActions.setFocusPoint({ ...anchor }));
-      dispatch(rangeActions.setDragging(true));
+      dispatch(rangeActions.setAnchorPoint({ ...anchor, docId }));
+      dispatch(rangeActions.setFocusPoint({ ...anchor, docId }));
+      dispatch(
+        rangeActions.setDragging({
+          isDragging: true,
+          docId,
+        })
+      );
+      return;
     },
-    [container.scrollLeft, container.scrollTop, dispatch, reset]
+    [container.scrollLeft, container.scrollTop, dispatch, docId]
   );
 
   const handleDraging = useCallback(
@@ -152,8 +164,13 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
     if (!isDragging) return;
     setFocus(null);
     anchorRef.current = null;
-    dispatch(rangeActions.setDragging(false));
-  }, [dispatch, isDragging]);
+    dispatch(
+      rangeActions.setDragging({
+        isDragging: false,
+        docId,
+      })
+    );
+  }, [docId, dispatch, isDragging]);
 
   useEffect(() => {
     document.addEventListener('mousedown', handleDragStart);
@@ -164,9 +181,10 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
       document.removeEventListener('mousedown', handleDragStart);
       document.removeEventListener('mousemove', handleDraging);
       document.removeEventListener('mouseup', handleDragEnd);
+
       container.removeEventListener('keydown', onKeyDown, true);
     };
-  }, [reset, handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
+  }, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
 
   return null;
 }

+ 23 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts

@@ -4,6 +4,7 @@ import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/sl
 import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
 
 import { isPointInBlock } from '$app/utils/document/node';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export interface BlockRectSelectionProps {
   container: HTMLDivElement;
@@ -12,13 +13,19 @@ export interface BlockRectSelectionProps {
 
 export function useBlockRectSelection({ container, getIntersectedBlockIds }: BlockRectSelectionProps) {
   const dispatch = useAppDispatch();
+  const { docId } = useSubscribeDocument();
 
   const [isDragging, setDragging] = useState(false);
   const startPointRef = useRef<number[]>([]);
 
   useEffect(() => {
-    dispatch(rectSelectionActions.setDragging(isDragging));
-  }, [dispatch, isDragging]);
+    dispatch(
+      rectSelectionActions.setDragging({
+        docId,
+        isDragging,
+      })
+    );
+  }, [docId, dispatch, isDragging]);
 
   const [rect, setRect] = useState<{
     startX: number;
@@ -78,9 +85,14 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
       };
       const blockIds = getIntersectedBlockIds(newRect);
       setRect(newRect);
-      dispatch(setRectSelectionThunk(blockIds));
+      dispatch(
+        setRectSelectionThunk({
+          selection: blockIds,
+          docId,
+        })
+      );
     },
-    [container.scrollLeft, container.scrollTop, dispatch, getIntersectedBlockIds, isDragging]
+    [container.scrollLeft, container.scrollTop, dispatch, docId, getIntersectedBlockIds, isDragging]
   );
 
   const handleDraging = useCallback(
@@ -105,7 +117,12 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
   const handleDragEnd = useCallback(
     (e: MouseEvent) => {
       if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
-        dispatch(rectSelectionActions.updateSelections([]));
+        dispatch(
+          rectSelectionActions.updateSelections({
+            docId,
+            selection: [],
+          })
+        );
         return;
       }
       if (!isDragging) return;
@@ -114,7 +131,7 @@ export function useBlockRectSelection({ container, getIntersectedBlockIds }: Blo
       setDragging(false);
       setRect(null);
     },
-    [dispatch, isDragging, updateSelctionsByPoint]
+    [dispatch, docId, isDragging, updateSelctionsByPoint]
   );
 
   useEffect(() => {

+ 4 - 7
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts

@@ -1,10 +1,9 @@
-import { useCallback, useContext, useEffect, useMemo, useState } from "react";
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { useAppSelector } from '$app/stores/store';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { RegionGrid } from '$app/utils/region_grid';
+import { useSubscribeDocument, useSubscribeDocumentData } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useNodesRect(container: HTMLDivElement) {
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
 
   const version = useVersionUpdate();
 
@@ -75,9 +74,7 @@ export function useNodesRect(container: HTMLDivElement) {
 
 function useVersionUpdate() {
   const [version, setVersion] = useState(0);
-  const data = useAppSelector((state) => {
-    return state.document;
-  });
+  const data = useSubscribeDocumentData();
 
   useEffect(() => {
     setVersion((v) => {

+ 6 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts

@@ -1,7 +1,6 @@
-import { useCallback, useContext, useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
 import { Keyboard } from '$app/constants/document/keyboard';
 import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions';
 import Delta from 'quill-delta';
 import isHotkey from 'is-hotkey';
@@ -10,12 +9,14 @@ import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection
 import { isPrintableKeyEvent } from '$app/utils/document/action';
 import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
 import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useRangeKeyDown() {
   const rangeRef = useRangeRef();
 
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { docId, controller } = useSubscribeDocument();
+
   const interceptEvents = useMemo(
     () => [
       {
@@ -92,6 +93,7 @@ export function useRangeKeyDown() {
           dispatch(
             arrowActionForRangeThunk({
               key: e.key,
+              docId,
             })
           );
         },
@@ -112,7 +114,7 @@ export function useRangeKeyDown() {
         },
       },
     ],
-    [controller, dispatch]
+    [controller, dispatch, docId]
   );
 
   const onKeyDownCapture = useCallback(

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts

@@ -1,12 +1,12 @@
 import { useAppDispatch } from '$app/stores/store';
-import { useCallback, useContext } from 'react';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useCallback } from 'react';
 import { duplicateBelowNodeThunk } from '$app_reducers/document/async-actions/blocks/duplicate';
 import { deleteNodeThunk } from '$app_reducers/document/async-actions';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useBlockMenu(id: string) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
 
   const handleDuplicate = useCallback(async () => {
     if (!controller) return;

+ 134 - 44
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx

@@ -1,14 +1,37 @@
-import React, { useCallback, useState } from 'react';
-import { List } from '@mui/material';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
 import { ContentCopy, Delete } from '@mui/icons-material';
-import MenuItem from './MenuItem';
+import MenuItem from '../_shared/MenuItem';
 import { useBlockMenu } from '$app/components/document/BlockSideToolbar/BlockMenu.hooks';
 import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMenuTurnInto';
+import TextField from '@mui/material/TextField';
+import { Keyboard } from '$app/constants/document/keyboard';
+import { selectOptionByUpDown } from '$app/utils/document/menu';
+
+enum BlockMenuOption {
+  Duplicate = 'Duplicate',
+  Delete = 'Delete',
+  TurnInto = 'TurnInto',
+}
+
+interface Option {
+  operate?: () => Promise<void>;
+  title?: string;
+  icon?: React.ReactNode;
+  key: BlockMenuOption;
+  openNextMenu?: boolean;
+}
 
 function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
   const { handleDelete, handleDuplicate } = useBlockMenu(id);
+  const [subMenuOpened, setSubMenuOpened] = useState(false);
+  const [hovered, setHovered] = useState<BlockMenuOption | null>(null);
+
+  useEffect(() => {
+    if (hovered !== BlockMenuOption.TurnInto) {
+      setSubMenuOpened(false);
+    }
+  }, [hovered]);
 
-  const [turnIntoOptionHovered, setTurnIntoOptionHorvered] = useState<boolean>(false);
   const handleClick = useCallback(
     async ({ operate }: { operate: () => Promise<void> }) => {
       await operate();
@@ -17,52 +40,119 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
     [onClose]
   );
 
+  const options: Option[] = useMemo(
+    () => [
+      {
+        operate: () => {
+          return handleClick({ operate: handleDelete });
+        },
+        title: 'Delete',
+        icon: <Delete />,
+        key: BlockMenuOption.Delete,
+      },
+      {
+        operate: () => {
+          return handleClick({ operate: handleDuplicate });
+        },
+        title: 'Duplicate',
+        icon: <ContentCopy />,
+        key: BlockMenuOption.Duplicate,
+      },
+      {
+        key: BlockMenuOption.TurnInto,
+      },
+    ],
+    [handleClick, handleDelete, handleDuplicate]
+  );
+
+  const onKeyDown = useCallback(
+    (e: React.KeyboardEvent) => {
+      const isUp = e.key === Keyboard.keys.UP;
+      const isDown = e.key === Keyboard.keys.DOWN;
+      const isLeft = e.key === Keyboard.keys.LEFT;
+      const isRight = e.key === Keyboard.keys.RIGHT;
+      const isEnter = e.key === Keyboard.keys.ENTER;
+
+      const isArrow = isUp || isDown || isLeft || isRight;
+
+      if (!isArrow && !isEnter) return;
+      e.stopPropagation();
+      if (isEnter) {
+        if (hovered) {
+          const option = options.find((option) => option.key === hovered);
+
+          if (option) {
+            option.operate?.();
+          }
+        } else {
+          onClose();
+        }
+
+        return;
+      }
+
+      const optionsKeys = options.map((option) => option.key);
+
+      if (isUp || isDown) {
+        const nextKey = selectOptionByUpDown(isUp, hovered, optionsKeys);
+        const nextOption = options.find((option) => option.key === nextKey);
+
+        setHovered(nextOption?.key ?? null);
+        return;
+      }
+
+      if (isLeft || isRight) {
+        if (hovered === BlockMenuOption.TurnInto) {
+          setSubMenuOpened(isRight);
+        }
+      }
+    },
+    [hovered, onClose, options]
+  );
+
   return (
-    <List
+    <div
+      tabIndex={1}
+      onKeyDown={onKeyDown}
       onMouseDown={(e) => {
-        // Prevent the block from being selected.
-        e.preventDefault();
         e.stopPropagation();
       }}
     >
-      {/** Delete option in the BlockMenu. */}
-      <MenuItem
-        title='Delete'
-        icon={<Delete />}
-        onClick={() =>
-          handleClick({
-            operate: handleDelete,
-          })
-        }
-        onHover={(isHovered) => {
-          if (isHovered) {
-            setTurnIntoOptionHorvered(false);
-          }
-        }}
-      />
-      {/** Duplicate option in the BlockMenu. */}
-      <MenuItem
-        title='Duplicate'
-        icon={<ContentCopy />}
-        onClick={() =>
-          handleClick({
-            operate: handleDuplicate,
-          })
+      <div className={'p-2'}>
+        <TextField autoFocus label='Search' placeholder='Search actions...' variant='standard' />
+      </div>
+      {options.map((option) => {
+        if (option.key === BlockMenuOption.TurnInto) {
+          return (
+            <BlockMenuTurnInto
+              key={option.key}
+              onHovered={() => {
+                setHovered(BlockMenuOption.TurnInto);
+                setSubMenuOpened(true);
+              }}
+              menuOpened={subMenuOpened}
+              isHovered={hovered === BlockMenuOption.TurnInto}
+              onClose={() => setSubMenuOpened(false)}
+              id={id}
+            />
+          );
         }
-        onHover={(isHovered) => {
-          if (isHovered) {
-            setTurnIntoOptionHorvered(false);
-          }
-        }}
-      />
-      {/** Turn Into option in the BlockMenu. */}
-      <BlockMenuTurnInto
-        onHovered={() => setTurnIntoOptionHorvered(true)}
-        isHovered={turnIntoOptionHovered}
-        onClose={onClose}
-        id={id}
-      />
-    </List>
+
+        return (
+          <MenuItem
+            key={option.key}
+            title={option.title}
+            icon={option.icon}
+            isHovered={hovered === option.key}
+            onClick={option.operate}
+            onHover={() => {
+              setHovered(option.key);
+              setSubMenuOpened(false);
+            }}
+          />
+        );
+      })}
+    </div>
   );
 }
 

+ 12 - 13
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx

@@ -1,6 +1,6 @@
-import React, { useState } from 'react';
+import React, { MouseEvent, useRef } from 'react';
 import { ArrowRight, Transform } from '@mui/icons-material';
-import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
+import MenuItem from '$app/components/document/_shared/MenuItem';
 import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
 
 function BlockMenuTurnInto({
@@ -8,28 +8,27 @@ function BlockMenuTurnInto({
   onClose,
   onHovered,
   isHovered,
+  menuOpened,
 }: {
   id: string;
   onClose: () => void;
-  onHovered: () => void;
+  onHovered: (e: MouseEvent) => void;
   isHovered: boolean;
+  menuOpened: boolean;
 }) {
-  const [anchorEl, setAnchorEl] = useState<null | HTMLDivElement>(null);
-
-  const open = isHovered && Boolean(anchorEl);
+  const ref = useRef<HTMLDivElement | null>(null);
+  const open = isHovered && menuOpened && Boolean(ref.current);
 
   return (
     <>
       <MenuItem
+        ref={ref}
         title='Turn into'
+        isHovered={isHovered}
         icon={<Transform />}
         extra={<ArrowRight />}
-        onHover={(hovered, event) => {
-          if (hovered) {
-            onHovered();
-            setAnchorEl(event.currentTarget);
-            return;
-          }
+        onHover={(e) => {
+          onHovered(e);
         }}
       />
       <TurnIntoPopover
@@ -46,7 +45,7 @@ function BlockMenuTurnInto({
           },
         }}
         onClose={onClose}
-        anchorEl={anchorEl}
+        anchorEl={ref.current}
         anchorOrigin={{
           vertical: 'center',
           horizontal: 'right',

+ 11 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx

@@ -3,23 +3,28 @@ import { useAppDispatch } from '@/appflowy_app/stores/store';
 import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { PopoverOrigin } from '@mui/material/Popover/Popover';
 import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 const headingBlockTopOffset: Record<number, number> = {
   1: 7,
   2: 5,
   3: 4,
 };
+
 export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
   const [nodeId, setHoverNodeId] = useState<string | null>(null);
   const ref = useRef<HTMLDivElement | null>(null);
   const dispatch = useAppDispatch();
   const [style, setStyle] = useState<React.CSSProperties>({});
+  const { docId } = useSubscribeDocument();
 
   useEffect(() => {
     const el = ref.current;
+
     if (!el || !nodeId) return;
     void (async () => {
-      const node = getBlock(nodeId);
+      const node = getBlock(docId, nodeId);
+
       if (!node) {
         setStyle({
           opacity: '0',
@@ -31,6 +36,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
 
         if (node.type === BlockType.HeadingBlock) {
           const nodeData = node.data as HeadingBlockData;
+
           top = headingBlockTopOffset[nodeData.level];
         }
 
@@ -41,11 +47,12 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
         });
       }
     })();
-  }, [dispatch, nodeId]);
+  }, [dispatch, docId, nodeId]);
 
   const handleMouseMove = useCallback((e: MouseEvent) => {
     const { clientX, clientY } = e;
     const id = getNodeIdByPoint(clientX, clientY);
+
     setHoverNodeId(id);
   }, []);
 
@@ -69,6 +76,7 @@ function getNodeIdByPoint(x: number, y: number) {
     el: Element;
     rect: DOMRect;
   } | null = null;
+
   viewportNodes.forEach((el) => {
     const rect = el.getBoundingClientRect();
 
@@ -104,6 +112,7 @@ const origin: {
     horizontal: 'left',
   },
 };
+
 export function usePopover() {
   const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
 
@@ -123,7 +132,6 @@ export function usePopover() {
     onClose,
     open,
     handleOpen,
-    disableAutoFocus: true,
     ...origin,
   };
 }

+ 0 - 36
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/MenuItem.tsx

@@ -1,36 +0,0 @@
-import React from 'react';
-import { ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
-
-function MenuItem({
-  icon,
-  title,
-  onClick,
-  extra,
-  onHover,
-}: {
-  title: string;
-  icon: React.ReactNode;
-  onClick?: () => void;
-  extra?: React.ReactNode;
-  onHover?: (isHovered: boolean, event: React.MouseEvent<HTMLDivElement>) => void;
-}) {
-  return (
-    <ListItem disablePadding>
-      <ListItemButton
-        onMouseEnter={(e) => onHover?.(true, e)}
-        onMouseLeave={(e) => onHover?.(false, e)}
-        onClick={(e) => {
-          e.preventDefault();
-          e.stopPropagation();
-          onClick?.();
-        }}
-      >
-        <ListItemIcon>{icon}</ListItemIcon>
-        <ListItemText primary={title} />
-        {extra}
-      </ListItemButton>
-    </ListItem>
-  );
-}
-
-export default MenuItem;

+ 13 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx

@@ -1,4 +1,4 @@
-import React, { useContext } from 'react';
+import React from 'react';
 import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
 import Portal from '../BlockPortal';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
@@ -9,13 +9,16 @@ import BlockMenu from './BlockMenu';
 import ToolbarButton from './ToolbarButton';
 import { rectSelectionActions } from '$app_reducers/document/slice';
 import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { docId, controller } = useSubscribeDocument();
+
   const { nodeId, style, ref } = useBlockSideToolbar({ container });
-  const isDragging = useAppSelector((state) => state.documentRange.isDragging || state.documentRectSelection.isDragging);
+  const isDragging = useAppSelector(
+    (state) => state.documentRange[docId]?.isDragging || state.documentRectSelection[docId]?.isDragging
+  );
   const { handleOpen, ...popoverProps } = usePopover();
 
   // prevent popover from showing when anchorEl is not in DOM
@@ -60,7 +63,12 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
             tooltip={'Click to open Menu'}
             onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
               if (!nodeId) return;
-              dispatch(rectSelectionActions.setSelectionById(nodeId));
+              dispatch(
+                rectSelectionActions.setSelectionById({
+                  docId,
+                  blockId: nodeId,
+                })
+              );
               handleOpen(e);
             }}
           >

+ 197 - 39
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx

@@ -1,5 +1,5 @@
-import React, { useCallback, useContext, useMemo } from 'react';
-import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import MenuItem from '$app/components/document/_shared/MenuItem';
 import {
   ArrowRight,
   Check,
@@ -12,15 +12,36 @@ import {
   Title,
   SafetyDivider,
 } from '@mui/icons-material';
-import { List } from '@mui/material';
-import { BlockData, BlockType } from '$app/interfaces/document';
+import {
+  BlockData,
+  BlockType,
+  SlashCommandGroup,
+  SlashCommandOption,
+  SlashCommandOptionKey,
+} from '$app/interfaces/document';
 import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { slashCommandActions } from '$app_reducers/document/slice';
+import { Keyboard } from '$app/constants/document/keyboard';
+import { selectOptionByUpDown } from '$app/utils/document/menu';
 
-function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: () => void; searchText?: string }) {
+function BlockSlashMenu({
+  id,
+  onClose,
+  searchText,
+  hoverOption,
+  container,
+}: {
+  id: string;
+  onClose?: () => void;
+  searchText?: string;
+  hoverOption?: SlashCommandOption;
+  container: HTMLDivElement;
+}) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const ref = useRef<HTMLDivElement | null>(null);
+  const { docId, controller } = useSubscribeDocument();
   const handleInsert = useCallback(
     async (type: BlockType, data?: BlockData<any>) => {
       if (!controller) return;
@@ -39,108 +60,245 @@ function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: ()
     [controller, dispatch, id, onClose]
   );
 
-  const optionColumns = useMemo(
-    () => [
+  const options: (SlashCommandOption & {
+    title: string;
+    icon: React.ReactNode;
+    group: SlashCommandGroup;
+  })[] = useMemo(
+    () =>
       [
         {
+          key: SlashCommandOptionKey.TEXT,
           type: BlockType.TextBlock,
           title: 'Text',
           icon: <TextFields />,
+          group: SlashCommandGroup.BASIC,
         },
         {
+          key: SlashCommandOptionKey.HEADING_1,
           type: BlockType.HeadingBlock,
           title: 'Heading 1',
           icon: <Title />,
-          props: {
+          data: {
             level: 1,
           },
+          group: SlashCommandGroup.BASIC,
         },
         {
+          key: SlashCommandOptionKey.HEADING_2,
           type: BlockType.HeadingBlock,
           title: 'Heading 2',
           icon: <Title />,
-          props: {
+          data: {
             level: 2,
           },
+          group: SlashCommandGroup.BASIC,
         },
         {
+          key: SlashCommandOptionKey.HEADING_3,
           type: BlockType.HeadingBlock,
           title: 'Heading 3',
           icon: <Title />,
-          props: {
+          data: {
             level: 3,
           },
+          group: SlashCommandGroup.BASIC,
         },
         {
+          key: SlashCommandOptionKey.TODO,
           type: BlockType.TodoListBlock,
           title: 'To-do list',
           icon: <Check />,
+          group: SlashCommandGroup.BASIC,
         },
         {
+          key: SlashCommandOptionKey.BULLET,
           type: BlockType.BulletedListBlock,
           title: 'Bulleted list',
           icon: <FormatListBulleted />,
+          group: SlashCommandGroup.BASIC,
         },
         {
+          key: SlashCommandOptionKey.NUMBER,
           type: BlockType.NumberedListBlock,
           title: 'Numbered list',
           icon: <FormatListNumbered />,
+          group: SlashCommandGroup.BASIC,
         },
-      ],
-      [
         {
+          key: SlashCommandOptionKey.TOGGLE,
           type: BlockType.ToggleListBlock,
           title: 'Toggle list',
           icon: <ArrowRight />,
+          group: SlashCommandGroup.BASIC,
         },
         {
-          type: BlockType.CodeBlock,
-          title: 'Code',
-          icon: <DataObject />,
-        },
-        {
+          key: SlashCommandOptionKey.QUOTE,
           type: BlockType.QuoteBlock,
           title: 'Quote',
           icon: <FormatQuote />,
+          group: SlashCommandGroup.BASIC,
         },
         {
+          key: SlashCommandOptionKey.CALLOUT,
           type: BlockType.CalloutBlock,
           title: 'Callout',
           icon: <Lightbulb />,
+          group: SlashCommandGroup.BASIC,
         },
         {
+          key: SlashCommandOptionKey.DIVIDER,
           type: BlockType.DividerBlock,
           title: 'Divider',
           icon: <SafetyDivider />,
+          group: SlashCommandGroup.BASIC,
+        },
+        {
+          key: SlashCommandOptionKey.CODE,
+          type: BlockType.CodeBlock,
+          title: 'Code',
+          icon: <DataObject />,
+          group: SlashCommandGroup.MEDIA,
         },
-      ],
-    ],
-    []
+      ].filter((option) => {
+        if (!searchText) return true;
+        const match = (text: string) => {
+          return text.toLowerCase().includes(searchText.toLowerCase());
+        };
+
+        return match(option.title) || match(option.type);
+      }),
+    [searchText]
   );
+
+  const optionsByGroup = useMemo(() => {
+    return options.reduce((acc, option) => {
+      if (!acc[option.group]) {
+        acc[option.group] = [];
+      }
+
+      acc[option.group].push(option);
+      return acc;
+    }, {} as Record<SlashCommandGroup, typeof options>);
+  }, [options]);
+
+  const scrollIntoOption = useCallback((option: SlashCommandOption) => {
+    if (!ref.current) return;
+    const containerRect = ref.current.getBoundingClientRect();
+    const optionElement = document.querySelector(`#slash-item-${option.key}`);
+
+    if (!optionElement) return;
+    const itemRect = optionElement?.getBoundingClientRect();
+
+    if (!itemRect) return;
+
+    if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
+      optionElement.scrollIntoView({ behavior: 'smooth' });
+    }
+  }, []);
+
+  const selectOptionByArrow = useCallback(
+    ({ isUp = false, isDown = false }: { isUp?: boolean; isDown?: boolean }) => {
+      if (!isUp && !isDown) return;
+      const optionsKeys = options.map((option) => String(option.key));
+      const nextKey = selectOptionByUpDown(isUp, String(hoverOption?.key), optionsKeys);
+      const nextOption = options.find((option) => String(option.key) === nextKey);
+
+      if (!nextOption) return;
+
+      scrollIntoOption(nextOption);
+      dispatch(
+        slashCommandActions.setHoverOption({
+          option: nextOption,
+          docId,
+        })
+      );
+    },
+    [dispatch, docId, hoverOption?.key, options, scrollIntoOption]
+  );
+
+  useEffect(() => {
+    const handleKeyDownCapture = (e: KeyboardEvent) => {
+      const isUp = e.key === Keyboard.keys.UP;
+      const isDown = e.key === Keyboard.keys.DOWN;
+      const isEnter = e.key === Keyboard.keys.ENTER;
+
+      // if any arrow key is pressed, prevent default behavior and stop propagation
+      if (isUp || isDown || isEnter) {
+        e.stopPropagation();
+        e.preventDefault();
+        if (isEnter) {
+          if (hoverOption) {
+            handleInsert(hoverOption.type, hoverOption.data);
+          }
+
+          return;
+        }
+
+        selectOptionByArrow({
+          isUp,
+          isDown,
+        });
+      }
+    };
+
+    // intercept keydown event in capture phase before it reaches the editor
+    container.addEventListener('keydown', handleKeyDownCapture, true);
+    return () => {
+      container.removeEventListener('keydown', handleKeyDownCapture, true);
+    };
+  }, [container, handleInsert, hoverOption, selectOptionByArrow]);
+
+  const onHoverOption = useCallback(
+    (option: SlashCommandOption) => {
+      dispatch(
+        slashCommandActions.setHoverOption({
+          option: {
+            key: option.key,
+            type: option.type,
+            data: option.data,
+          },
+          docId,
+        })
+      );
+    },
+    [dispatch, docId]
+  );
+
   return (
     <div
       onMouseDown={(e) => {
         e.preventDefault();
         e.stopPropagation();
       }}
-      className={'flex'}
+      className={'flex h-[100%] max-h-[40vh] w-[324px] min-w-[180px] max-w-[calc(100vw-32px)] flex-col p-1'}
     >
-      {optionColumns.map((column, index) => (
-        <List key={index} className={'flex-1'}>
-          {column.map((option) => {
-            return (
-              <MenuItem
-                key={option.title}
-                title={option.title}
-                icon={option.icon}
-                onClick={() => {
-                  handleInsert(option.type, option.props);
-                }}
-              />
-            );
-          })}
-        </List>
-      ))}
+      <div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}>
+        {Object.entries(optionsByGroup).map(([group, options]) => (
+          <div key={group}>
+            <div className={'px-2 py-2 text-sm text-shade-3'}>{group}</div>
+            <div>
+              {options.map((option) => {
+                return (
+                  <MenuItem
+                    id={`slash-item-${option.key}`}
+                    key={option.key}
+                    title={option.title}
+                    icon={option.icon}
+                    onHover={() => {
+                      onHoverOption(option);
+                    }}
+                    isHovered={hoverOption?.key === option.key}
+                    onClick={() => {
+                      handleInsert(option.type, option.data);
+                    }}
+                  />
+                );
+              })}
+            </div>
+          </div>
+        ))}
+      </div>
     </div>
   );
 }

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

@@ -1,16 +1,23 @@
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { useAppDispatch } from '$app/stores/store';
 import React, { useCallback, useEffect, useMemo } from 'react';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { Op } from 'quill-delta';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks';
 
 export function useBlockSlash() {
   const dispatch = useAppDispatch();
-  const { blockId, visible, slashText } = useSubscribeSlash();
+  const { docId } = useSubscribeDocument();
+
+  const { blockId, visible, slashText, hoverOption } = useSubscribeSlash();
   const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
+
   useEffect(() => {
     if (blockId && visible) {
-      const el = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement;
+      const blockEl = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement;
+      const el = blockEl.querySelector(`[role="textbox"]`) as HTMLElement;
+
       if (el) {
         setAnchorEl(el);
         return;
@@ -21,18 +28,20 @@ export function useBlockSlash() {
 
   useEffect(() => {
     if (!slashText) {
-      dispatch(slashCommandActions.closeSlashCommand());
+      dispatch(slashCommandActions.closeSlashCommand(docId));
     }
-  }, [dispatch, slashText]);
+  }, [dispatch, docId, slashText]);
 
   const searchText = useMemo(() => {
     if (!slashText) return '';
     if (slashText[0] !== '/') return slashText;
+
     return slashText.slice(1, slashText.length);
   }, [slashText]);
+
   const onClose = useCallback(() => {
-    dispatch(slashCommandActions.closeSlashCommand());
-  }, [dispatch]);
+    dispatch(slashCommandActions.closeSlashCommand(docId));
+  }, [dispatch, docId]);
 
   const open = Boolean(anchorEl);
 
@@ -42,17 +51,21 @@ export function useBlockSlash() {
     onClose,
     blockId,
     searchText,
+    hoverOption,
   };
 }
+
 export function useSubscribeSlash() {
-  const slashCommandState = useAppSelector((state) => state.documentSlashCommand);
+  const slashCommandState = useSubscribeSlashState();
+  const visible = slashCommandState.isSlashCommand;
+  const blockId = slashCommandState.blockId;
 
-  const visible = useMemo(() => slashCommandState.isSlashCommand, [slashCommandState.isSlashCommand]);
-  const blockId = useMemo(() => slashCommandState.blockId, [slashCommandState.blockId]);
   const { node } = useSubscribeNode(blockId || '');
+
   const slashText = useMemo(() => {
     if (!node) return '';
     const delta = node.data.delta || [];
+
     return delta
       .map((op: Op) => {
         if (typeof op.insert === 'string') {
@@ -68,5 +81,6 @@ export function useSubscribeSlash() {
     visible,
     blockId,
     slashText,
+    hoverOption: slashCommandState.hoverOption,
   };
 }

+ 12 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx

@@ -1,10 +1,12 @@
-import React from 'react';
+import React, { useEffect } from 'react';
 import Popover from '@mui/material/Popover';
 import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu';
 import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks';
+import { Keyboard } from '$app/constants/document/keyboard';
+
+function BlockSlash({ container }: { container: HTMLDivElement }) {
+  const { blockId, open, onClose, anchorEl, searchText, hoverOption } = useBlockSlash();
 
-function BlockSlash() {
-  const { blockId, open, onClose, anchorEl, searchText } = useBlockSlash();
   if (!blockId) return null;
 
   return (
@@ -22,7 +24,13 @@ function BlockSlash() {
       disableAutoFocus
       onClose={onClose}
     >
-      <BlockSlashMenu id={blockId} onClose={onClose} searchText={searchText} />
+      <BlockSlashMenu
+        container={container}
+        hoverOption={hoverOption}
+        id={blockId}
+        onClose={onClose}
+        searchText={searchText}
+      />
     </Popover>
   );
 }

+ 3 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts

@@ -1,15 +1,14 @@
-import { useCallback, useContext, useMemo, useState } from 'react';
-import emojiData, { EmojiMartData, Emoji } from '@emoji-mart/data';
+import { useCallback, useMemo, useState } from 'react';
 import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useCalloutBlock(nodeId: string) {
   const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
   const open = useMemo(() => Boolean(anchorEl), [anchorEl]);
   const id = useMemo(() => (open ? 'emoji-popover' : undefined), [open]);
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
 
   const closeEmojiSelect = useCallback(() => {
     setAnchorEl(null);

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

@@ -4,12 +4,12 @@ import FormControl from '@mui/material/FormControl';
 import Select, { SelectChangeEvent } from '@mui/material/Select';
 import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
 import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { supportLanguage } from '$app/constants/document/code';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 function SelectLanguage({ id, language }: { id: string; language: string }) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
 
   const onLanguageSelect = useCallback(
     (event: SelectChangeEvent) => {

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

@@ -1,14 +1,14 @@
 import isHotkey from 'is-hotkey';
 import { useCallback, useContext, useMemo } from 'react';
 import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { Keyboard } from '$app/constants/document/keyboard';
 import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents';
 import { enterActionForBlockThunk } from '$app_reducers/document/async-actions';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useKeyDown(id: string) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { docId, controller } = useSubscribeDocument();
 
   const commonKeyEvents = useCommonKeyEvents(id);
   const customEvents = useMemo(() => {

+ 6 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts

@@ -1,11 +1,15 @@
 import { useAppSelector } from '$app/stores/store';
 import { BlockType, NestedBlock } from '$app/interfaces/document';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useNumberedListBlock(node: NestedBlock<BlockType.NumberedListBlock>) {
+  const { docId } = useSubscribeDocument();
+
   // Find the last index of the previous blocks
   const prevNumberedIndex = useAppSelector((state) => {
-    const nodes = state['document'].nodes;
-    const children = state['document'].children;
+    const documentState = state['document'][docId];
+    const nodes = documentState.nodes;
+    const children = documentState.children;
     // The parent must be existed
     const parent = nodes[node.parent!];
     const siblings = children[parent.children];

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

@@ -17,7 +17,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
       <BlockSideToolbar container={container} />
       <TextActionMenu container={container} />
       <BlockSelection container={container} />
-      <BlockSlash />
+      <BlockSlash container={container} />
       <LinkEditPopover />
     </>
   );

+ 0 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/Root/Root.hooks.tsx

@@ -1,12 +1,9 @@
 import { DocumentData } from '$app/interfaces/document';
 import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
-import { useParseTree } from './Tree.hooks';
 
 export function useRoot({ documentData }: { documentData: DocumentData }) {
   const { rootId } = documentData;
 
-  useParseTree(documentData);
-
   const { node: rootNode, childIds: rootChildIds } = useSubscribeNode(rootId);
 
   return {

+ 0 - 16
frontend/appflowy_tauri/src/appflowy_app/components/document/Root/Tree.hooks.tsx

@@ -1,16 +0,0 @@
-import { useEffect } from 'react';
-import { DocumentData } from '$app/interfaces/document';
-import { useAppDispatch } from '@/appflowy_app/stores/store';
-import { documentActions } from '$app/stores/reducers/document/slice';
-
-export function useParseTree(documentData: DocumentData) {
-  const dispatch = useAppDispatch();
-
-  useEffect(() => {
-    dispatch(documentActions.create(documentData));
-
-    return () => {
-      dispatch(documentActions.clear());
-    };
-  }, [documentData]);
-}

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

@@ -1,12 +1,16 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 import { calcToolbarPosition } from '$app/utils/document/toolbar';
 import { useAppSelector } from '$app/stores/store';
 import { getNode } from '$app/utils/document/node';
 import { debounce } from '$app/utils/tool';
+import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
 
 export function useMenuStyle(container: HTMLDivElement) {
   const ref = useRef<HTMLDivElement | null>(null);
-  const id = useAppSelector((state) => state.documentRange.caret?.id);
+
+  const caret = useSubscribeCaret();
+  const id = caret?.id;
+
   const [isScrolling, setIsScrolling] = useState(false);
 
   const reCalculatePosition = useCallback(() => {

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

@@ -1,8 +1,8 @@
 import { useMenuStyle } from './index.hooks';
-import { useAppSelector } from '$app/stores/store';
 import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
 import BlockPortal from '$app/components/document/BlockPortal';
 import { useMemo } from 'react';
+import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
 
 const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
   const { ref, id } = useMenuStyle(container);
@@ -28,7 +28,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
   );
 };
 const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
-  const range = useAppSelector((state) => state.documentRange);
+  const range = useSubscribeRanges();
   const canShow = useMemo(() => {
     const { isDragging, focus, anchor, ranges, caret } = range;
     // don't show if dragging

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

@@ -8,12 +8,13 @@ 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 { newLinkThunk } from '$app_reducers/document/async-actions/link';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { docId, controller } = useSubscribeDocument();
 
-  const focusId = useAppSelector((state) => state.documentRange.focus?.id || '');
+  const focusId = useAppSelector((state) => state.documentRange[docId]?.focus?.id || '');
   const { node: focusNode } = useSubscribeNode(focusId);
 
   const [isActive, setIsActive] = React.useState(false);
@@ -33,9 +34,14 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
 
   const isFormatActive = useCallback(async () => {
     if (!focusNode) return false;
-    const { payload: isActive } = await dispatch(getFormatActiveThunk(format));
+    const { payload: isActive } = await dispatch(
+      getFormatActiveThunk({
+        format,
+        docId,
+      })
+    );
     return !!isActive;
-  }, [dispatch, format, focusNode]);
+  }, [docId, dispatch, format, focusNode]);
 
   const toggleFormat = useCallback(
     async (format: TextAction) => {
@@ -52,8 +58,12 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
   );
 
   const addLink = useCallback(() => {
-    dispatch(newLinkThunk());
-  }, [dispatch]);
+    dispatch(
+      newLinkThunk({
+        docId,
+      })
+    );
+  }, [dispatch, docId]);
 
   const formatClick = useCallback(
     (format: TextAction) => {

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

@@ -1,4 +1,3 @@
-import { useAppSelector } from '$app/stores/store';
 import { useMemo } from 'react';
 import {
   blockConfig,
@@ -9,9 +8,10 @@ import {
 } 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';
 
 export function useTextActionMenu() {
-  const range = useAppSelector((state) => state.documentRange);
+  const range = useSubscribeRanges();
   const isSingleLine = useMemo(() => {
     return range.focus?.id === range.anchor?.id;
   }, [range]);

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

@@ -10,9 +10,10 @@ import {
 } from '$app_reducers/document/async-actions';
 import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents';
 import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useKeyDown(id: string) {
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
   const dispatch = useAppDispatch();
   const turnIntoEvents = useTurnIntoBlockEvents(id);
   const commonKeyEvents = useCommonKeyEvents(id);

+ 11 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts

@@ -12,29 +12,30 @@ import isHotkey from 'is-hotkey';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { Keyboard } from '$app/constants/document/keyboard';
 import { getDeltaText } from '$app/utils/document/delta';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useTurnIntoBlockEvents(id: string) {
-  const controller = useContext(DocumentControllerContext);
+  const { docId, controller } = useSubscribeDocument();
+
   const dispatch = useAppDispatch();
   const rangeRef = useRangeRef();
 
   const getFlag = useCallback(() => {
     const range = rangeRef.current?.caret;
     if (!range || range.id !== id) return;
-    const node = getBlock(id);
+    const node = getBlock(docId, id);
     const delta = new Delta(node.data.delta || []);
-    const flag = getDeltaText(delta.slice(0, range.index));
-    return flag;
-  }, [id, rangeRef]);
+    return getDeltaText(delta.slice(0, range.index));
+  }, [docId, id, rangeRef]);
 
   const getDeltaContent = useCallback(() => {
     const range = rangeRef.current?.caret;
     if (!range || range.id !== id) return;
-    const node = getBlock(id);
+    const node = getBlock(docId, id);
     const delta = new Delta(node.data.delta || []);
     const content = delta.slice(range.index);
     return new Delta(content);
-  }, [id, rangeRef]);
+  }, [docId, id, rangeRef]);
 
   const canHandle = useCallback(
     (event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
@@ -171,17 +172,18 @@ export function useTurnIntoBlockEvents(id: string) {
           const flag = getFlag();
           return isHotkey('/', e) && flag === '';
         },
-        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+        handler: (_: React.KeyboardEvent<HTMLDivElement>) => {
           if (!controller) return;
           dispatch(
             slashCommandActions.openSlashCommand({
               blockId: id,
+              docId,
             })
           );
         },
       },
     ];
-  }, [canHandle, controller, dispatch, getDeltaContent, getFlag, id, spaceTriggerMap]);
+  }, [canHandle, controller, dispatch, docId, getDeltaContent, getFlag, id, spaceTriggerMap]);
 
   return turnIntoBlockEvents;
 }

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

@@ -1,13 +1,13 @@
 import { useAppDispatch } from '$app/stores/store';
-import { useCallback, useContext } from 'react';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useCallback } from 'react';
 import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
 import { BlockData, BlockType } from '$app/interfaces/document';
 import isHotkey from 'is-hotkey';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useTodoListBlock(id: string, data: BlockData<BlockType.TodoListBlock>) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
   const toggleCheckbox = useCallback(() => {
     if (!controller) return;
     void dispatch(

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

@@ -1,13 +1,13 @@
 import { useAppDispatch } from '$app/stores/store';
-import { useCallback, useContext } from 'react';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useCallback } from 'react';
 import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
 import { BlockData, BlockType } from '$app/interfaces/document';
 import isHotkey from 'is-hotkey';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useToggleListBlock(id: string, data: BlockData<BlockType.ToggleListBlock>) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
   const toggleCollapsed = useCallback(() => {
     if (!controller) return;
     void dispatch(

+ 26 - 7
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts

@@ -1,16 +1,16 @@
-import { useCallback, useContext, useEffect } from 'react';
-import { copyThunk } from '$app_reducers/document/async-actions/copyPaste';
+import { useCallback, useEffect } from 'react';
+import { copyThunk } from '$app_reducers/document/async-actions/copy_paste';
 import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { BlockCopyData } from '$app/interfaces/document';
 import { clipboardTypes } from '$app/constants/document/copy_paste';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useCopy(container: HTMLDivElement) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
 
-  const handleCopyCapture = useCallback(
-    (e: ClipboardEvent) => {
+  const onCopy = useCallback(
+    (e: ClipboardEvent, isCut: boolean) => {
       if (!controller) return;
       e.stopPropagation();
       e.preventDefault();
@@ -22,16 +22,35 @@ export function useCopy(container: HTMLDivElement) {
       dispatch(
         copyThunk({
           setClipboardData,
+          controller,
+          isCut,
         })
       );
     },
     [controller, dispatch]
   );
 
+  const handleCopyCapture = useCallback(
+    (e: ClipboardEvent) => {
+      onCopy(e, false);
+    },
+    [onCopy]
+  );
+
+  const handleCutCapture = useCallback(
+    (e: ClipboardEvent) => {
+      onCopy(e, true);
+    },
+    [onCopy]
+  );
+
   useEffect(() => {
     container.addEventListener('copy', handleCopyCapture, true);
+    container.addEventListener('cut', handleCutCapture, true);
+
     return () => {
       container.removeEventListener('copy', handleCopyCapture, true);
+      container.removeEventListener('cut', handleCutCapture, true);
     };
-  }, [container, handleCopyCapture]);
+  }, [container, handleCopyCapture, handleCutCapture]);
 }

+ 3 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts

@@ -1,13 +1,12 @@
 import { useCallback, useContext, useEffect } from 'react';
 import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { pasteThunk } from '$app_reducers/document/async-actions/copyPaste';
+import { pasteThunk } from '$app_reducers/document/async-actions/copy_paste';
 import { clipboardTypes } from '$app/constants/document/copy_paste';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function usePaste(container: HTMLDivElement) {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
-
+  const { controller } = useSubscribeDocument();
   const handlePasteCapture = useCallback(
     (e: ClipboardEvent) => {
       if (!controller) return;

+ 15 - 10
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts

@@ -6,16 +6,19 @@ import {
   rightActionForBlockThunk,
   upDownActionForBlockThunk,
 } from '$app_reducers/document/async-actions';
-import { useContext, useMemo } from 'react';
+import { useMemo } from 'react';
 import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { useAppDispatch } from '$app/stores/store';
 import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
 import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
+import Delta from 'quill-delta';
 
 export function useCommonKeyEvents(id: string) {
   const { focused, caretRef } = useFocused(id);
-  const controller = useContext(DocumentControllerContext);
+  const { docId, controller } = useSubscribeDocument();
+
   const dispatch = useAppDispatch();
   const commonKeyEvents = useMemo(() => {
     return [
@@ -42,7 +45,7 @@ export function useCommonKeyEvents(id: string) {
         },
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
-          dispatch(upDownActionForBlockThunk({ id }));
+          dispatch(upDownActionForBlockThunk({ docId, id }));
         },
       },
       {
@@ -52,27 +55,29 @@ export function useCommonKeyEvents(id: string) {
         },
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
-          dispatch(upDownActionForBlockThunk({ id, down: true }));
+          dispatch(upDownActionForBlockThunk({ docId, id, down: true }));
         },
       },
       {
         // handle left arrow key and no other key is pressed
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
-          return isHotkey(Keyboard.keys.LEFT, e);
+          return isHotkey(Keyboard.keys.LEFT, e) && caretRef.current?.index === 0 && caretRef.current?.length === 0;
         },
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
-          dispatch(leftActionForBlockThunk({ id }));
+          dispatch(leftActionForBlockThunk({ docId, id }));
         },
       },
       {
         // handle right arrow key and no other key is pressed
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
-          return isHotkey(Keyboard.keys.RIGHT, e);
+          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;
         },
         handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
           e.preventDefault();
-          dispatch(rightActionForBlockThunk({ id }));
+          dispatch(rightActionForBlockThunk({ docId, id }));
         },
       },
       {
@@ -91,6 +96,6 @@ export function useCommonKeyEvents(id: string) {
         },
       },
     ];
-  }, [caretRef, controller, dispatch, focused, id]);
+  }, [docId, caretRef, controller, dispatch, focused, id]);
   return commonKeyEvents;
 }

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

@@ -1,12 +1,12 @@
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { useAppDispatch } from '$app/stores/store';
 import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions';
 import Delta from 'quill-delta';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) {
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
   const dispatch = useAppDispatch();
   const penddingRef = useRef(false);
   const { node } = useSubscribeNode(id);

+ 13 - 7
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useContext, useEffect, useState } from 'react';
 import { RangeStatic } from 'quill';
 import { useAppDispatch } from '$app/stores/store';
 import { rangeActions } from '$app_reducers/document/slice';
@@ -8,6 +8,7 @@ import {
   useSubscribeDecorate,
 } from '$app/components/document/_shared/SubscribeSelection.hooks';
 import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useSelection(id: string) {
   const rangeRef = useRangeRef();
@@ -15,12 +16,13 @@ export function useSelection(id: string) {
   const decorateProps = useSubscribeDecorate(id);
   const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
   const dispatch = useAppDispatch();
+  const { docId } = useSubscribeDocument();
 
   const storeRange = useCallback(
     (range: RangeStatic) => {
-      dispatch(storeRangeThunk({ id, range }));
+      dispatch(storeRangeThunk({ id, range, docId }));
     },
-    [id, dispatch]
+    [docId, id, dispatch]
   );
 
   const onSelectionChange = useCallback(
@@ -28,14 +30,17 @@ export function useSelection(id: string) {
       if (!range) return;
       dispatch(
         rangeActions.setCaret({
-          id,
-          index: range.index,
-          length: range.length,
+          docId,
+          caret: {
+            id,
+            index: range.index,
+            length: range.length,
+          },
         })
       );
       storeRange(range);
     },
-    [id, dispatch, storeRange]
+    [docId, id, dispatch, storeRange]
   );
 
   useEffect(() => {
@@ -44,6 +49,7 @@ export function useSelection(id: string) {
       setSelection(undefined);
       return;
     }
+
     setSelection({
       index: focusCaret.index,
       length: focusCaret.length,

+ 76 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx

@@ -0,0 +1,76 @@
+import React, { forwardRef, MouseEvent, useMemo } from 'react';
+import { ListItemButton } from '@mui/material';
+
+const MenuItem = forwardRef(function (
+  {
+    id,
+    icon,
+    title,
+    onClick,
+    extra,
+    onHover,
+    isHovered,
+    className,
+    iconSize,
+    desc,
+  }: {
+    id?: string;
+    className?: string;
+    title?: string;
+    desc?: string;
+    icon: React.ReactNode;
+    onClick?: () => void;
+    extra?: React.ReactNode;
+    isHovered?: boolean;
+    onHover?: (e: MouseEvent) => void;
+    iconSize?: {
+      width: number;
+      height: number;
+    };
+  },
+  ref: React.ForwardedRef<HTMLDivElement>
+) {
+  const imgSize = useMemo(() => iconSize || { width: 50, height: 50 }, [iconSize]);
+
+  return (
+    <div className={className} ref={ref} id={id}>
+      <ListItemButton
+        sx={{
+          borderRadius: '4px',
+          padding: '4px 8px',
+          fontSize: 14,
+        }}
+        selected={isHovered}
+        onMouseEnter={(e) => onHover?.(e)}
+        onClick={(e) => {
+          e.preventDefault();
+          e.stopPropagation();
+          onClick?.();
+        }}
+      >
+        <div
+          className={`mr-2 flex h-[${imgSize.height}px] w-[${imgSize.width}px] items-center justify-center rounded border border-shade-5`}
+        >
+          {icon}
+        </div>
+        <div className={'flex flex-1 flex-col'}>
+          <div className={'text-sm'}>{title}</div>
+          {desc && (
+            <div
+              className={'font-normal text-shade-4'}
+              style={{
+                fontSize: '0.85em',
+                fontWeight: 300,
+              }}
+            >
+              {desc}
+            </div>
+          )}
+        </div>
+        <div>{extra}</div>
+      </ListItemButton>
+    </div>
+  );
+});
+
+export default MenuItem;

+ 14 - 36
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts

@@ -9,7 +9,7 @@ import {
   indent,
   outdent,
 } from '$app/utils/document/slate_editor';
-import { focusNodeByIndex, getWordIndices } from '$app/utils/document/node';
+import { focusNodeByIndex } from '$app/utils/document/node';
 import { Keyboard } from '$app/constants/document/keyboard';
 import Delta from 'quill-delta';
 import isHotkey from 'is-hotkey';
@@ -139,39 +139,6 @@ export function useEditor({
     [editor]
   );
 
-  // This is a hack to fix the bug that the editor decoration is updated cause selection is lost
-  const onMouseDownCapture = useCallback(
-    (event: React.MouseEvent) => {
-      editor.deselect();
-      requestAnimationFrame(() => {
-        const range = document.caretRangeFromPoint(event.clientX, event.clientY);
-        if (!range) return;
-        const selection = window.getSelection();
-        if (!selection) return;
-        selection.removeAllRanges();
-        selection.addRange(range);
-      });
-    },
-    [editor]
-  );
-
-  // double click to select a word
-  // This is a hack to fix the bug that mouse down event deselect the selection
-  const onDoubleClick = useCallback((event: React.MouseEvent) => {
-    const selection = window.getSelection();
-    if (!selection) return;
-    const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
-    if (!range) return;
-    const node = range.startContainer;
-    const offset = range.startOffset;
-    const wordIndices = getWordIndices(node, offset);
-    if (wordIndices.length === 0) return;
-    range.setStart(node, wordIndices[0].startIndex);
-    range.setEnd(node, wordIndices[0].endIndex);
-    selection.removeAllRanges();
-    selection.addRange(range);
-  }, []);
-
   useEffect(() => {
     if (!ref.current) return;
     const isFocused = ReactEditor.isFocused(editor);
@@ -179,12 +146,25 @@ export function useEditor({
       isFocused && editor.deselect();
       return;
     }
+
     const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
     if (!slateSelection) return;
+
     if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
+
+    // why we didn't use slate api to 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
     const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
     if (!isSuccess) {
       Transforms.select(editor, slateSelection);
+    } else {
+      // Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
+      requestAnimationFrame(() => {
+        if (window.getSelection()?.type === 'None' && !editor.selection) {
+          Transforms.select(editor, slateSelection);
+        }
+      });
     }
   }, [editor, selection]);
 
@@ -197,7 +177,5 @@ export function useEditor({
     ref,
     onKeyDown: onKeyDownRewrite,
     onBlur,
-    onMouseDownCapture,
-    onDoubleClick,
   };
 }

+ 20 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts

@@ -0,0 +1,20 @@
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useContext } from 'react';
+import { useAppSelector } from '$app/stores/store';
+
+export function useSubscribeDocument() {
+  const controller = useContext(DocumentControllerContext);
+  const docId = controller.documentId;
+  return {
+    docId,
+    controller,
+  };
+}
+
+export function useSubscribeDocumentData() {
+  const { docId } = useSubscribeDocument();
+  const data = useAppSelector((state) => {
+    return state.document[docId];
+  });
+  return data;
+}

+ 12 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts

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

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

@@ -1,22 +1,30 @@
 import { store, useAppSelector } from '@/appflowy_app/stores/store';
-import { createContext, useEffect, useMemo, useRef } from 'react';
+import { createContext, useMemo } from 'react';
 import { Node } from '$app/interfaces/document';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 /**
  * Subscribe node information
  * @param id
  */
 export function useSubscribeNode(id: string) {
-  const node = useAppSelector<Node>((state) => state.document.nodes[id]);
+  const { docId } = useSubscribeDocument();
+
+  const node = useAppSelector<Node>((state) => {
+    const documentState = state.document[docId];
+    return documentState?.nodes[id];
+  });
 
   const childIds = useAppSelector<string[] | undefined>((state) => {
-    const childrenId = state.document.nodes[id]?.children;
+    const documentState = state.document[docId];
+    if (!documentState) return;
+    const childrenId = documentState.nodes[id]?.children;
     if (!childrenId) return;
-    return state.document.children[childrenId];
+    return documentState.children[childrenId];
   });
 
   const isSelected = useAppSelector<boolean>((state) => {
-    return state.documentRectSelection.selection.includes(id) || false;
+    return state.documentRectSelection[docId]?.selection.includes(id) || false;
   });
 
   // Memoize the node and its children
@@ -32,8 +40,8 @@ export function useSubscribeNode(id: string) {
   };
 }
 
-export function getBlock(id: string) {
-  return store.getState().document.nodes[id];
+export function getBlock(docId: string, id: string) {
+  return store.getState().document[docId].nodes[id];
 }
 
 export const NodeIdContext = createContext<string>('');

+ 10 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts

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

+ 32 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts

@@ -1,15 +1,18 @@
 import { useAppSelector } from '$app/stores/store';
 import { RangeState, RangeStatic } from '$app/interfaces/document';
 import { useMemo, useRef } from 'react';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useSubscribeDecorate(id: string) {
+  const { docId } = useSubscribeDocument();
+
   const decorateSelection = useAppSelector((state) => {
-    return state.documentRange.ranges[id];
+    return state.documentRange[docId]?.ranges[id];
   });
 
   const linkDecorateSelection = useAppSelector((state) => {
-    const linkPopoverState = state.documentLinkPopover;
-    if (!linkPopoverState.open || linkPopoverState.id !== id) return;
+    const linkPopoverState = state.documentLinkPopover[docId];
+    if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
     return {
       selection: linkPopoverState.selection,
       placeholder: linkPopoverState.title,
@@ -22,9 +25,11 @@ export function useSubscribeDecorate(id: string) {
   };
 }
 export function useFocused(id: string) {
+  const { docId } = useSubscribeDocument();
+
   const caretRef = useRef<RangeStatic>();
   const focusCaret = useAppSelector((state) => {
-    const currentCaret = state.documentRange.caret;
+    const currentCaret = state.documentRange[docId]?.caret;
     caretRef.current = currentCaret;
     if (currentCaret?.id === id) {
       return currentCaret;
@@ -44,10 +49,32 @@ export function useFocused(id: string) {
 }
 
 export function useRangeRef() {
+  const { docId, controller } = useSubscribeDocument();
+
   const rangeRef = useRef<RangeState>();
   useAppSelector((state) => {
-    const currentRange = state.documentRange;
+    const currentRange = state.documentRange[docId];
     rangeRef.current = currentRange;
   });
   return rangeRef;
 }
+
+export function useSubscribeRanges() {
+  const { docId } = useSubscribeDocument();
+
+  const rangeState = useAppSelector((state) => {
+    return state.documentRange[docId];
+  });
+
+  return rangeState;
+}
+
+export function useSubscribeCaret() {
+  const { docId } = useSubscribeDocument();
+
+  const caret = useAppSelector((state) => {
+    return state.documentRange[docId]?.caret;
+  });
+
+  return caret;
+}

+ 12 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts

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

+ 23 - 14
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkEditPopover.tsx

@@ -5,20 +5,21 @@ import { DeleteOutline, Done } from '@mui/icons-material';
 import EditLink from '$app/components/document/_shared/TextLink/EditLink';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { updateLinkThunk } from '$app_reducers/document/async-actions';
 import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
 import LinkButton from '$app/components/document/_shared/TextLink/LinkButton';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useSubscribeLinkPopover } from '$app/components/document/_shared/SubscribeLinkPopover.hooks';
 
 function LinkEditPopover() {
   const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
-  const popoverState = useAppSelector((state) => state.documentLinkPopover);
+  const { docId, controller } = useSubscribeDocument();
+
+  const popoverState = useSubscribeLinkPopover();
   const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
 
   const onClose = useCallback(() => {
-    dispatch(linkPopoverActions.closeLinkPopover());
-  }, [dispatch]);
+    dispatch(linkPopoverActions.closeLinkPopover(docId));
+  }, [dispatch, docId]);
 
   const onExited = useCallback(() => {
     if (!id || !selection) return;
@@ -28,31 +29,39 @@ function LinkEditPopover() {
     };
     dispatch(
       rangeActions.setRange({
+        docId,
         id,
         rangeStatic: newSelection,
       })
     );
     dispatch(
       rangeActions.setCaret({
-        id,
-        ...newSelection,
+        docId,
+        caret: {
+          id,
+          ...newSelection,
+        },
       })
     );
-  }, [id, selection, title, dispatch]);
+  }, [docId, id, selection, title, dispatch]);
 
   const onChange = useCallback(
     (newVal: { href?: string; title: string }) => {
       if (!id) return;
       if (newVal.title === title && newVal.href === href) return;
+
       dispatch(
-        updateLinkThunk({
-          id,
-          href: newVal.href,
-          title: newVal.title,
+        linkPopoverActions.updateLinkPopover({
+          docId,
+          linkState: {
+            id,
+            href: newVal.href,
+            title: newVal.title,
+          },
         })
       );
     },
-    [dispatch, href, id, title]
+    [docId, dispatch, href, id, title]
   );
 
   const onDone = useCallback(async () => {

+ 14 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/index.tsx

@@ -4,6 +4,7 @@ import { useTextLink } from '$app/components/document/_shared/TextLink/TextLink.
 import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar';
 import { useAppDispatch } from '$app/stores/store';
 import { linkPopoverActions } from '$app_reducers/document/slice';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 function TextLink({
   getSelection,
@@ -22,6 +23,7 @@ function TextLink({
   const blockId = useContext(NodeIdContext);
   const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId);
   const dispatch = useAppDispatch();
+  const { docId } = useSubscribeDocument();
 
   const onEdit = useCallback(() => {
     if (!ref.current) return;
@@ -31,18 +33,21 @@ function TextLink({
     if (!rect) return;
     dispatch(
       linkPopoverActions.setLinkPopover({
-        anchorPosition: {
-          top: rect.top + rect.height,
-          left: rect.left + rect.width / 2,
+        docId,
+        linkState: {
+          anchorPosition: {
+            top: rect.top + rect.height,
+            left: rect.left + rect.width / 2,
+          },
+          id: blockId,
+          selection,
+          title,
+          href,
+          open: true,
         },
-        id: blockId,
-        selection,
-        title,
-        href,
-        open: true,
       })
     );
-  }, [blockId, dispatch, getSelection, href, ref, title]);
+  }, [blockId, dispatch, docId, getSelection, href, ref, title]);
   if (!blockId) return null;
 
   return (

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

@@ -1,14 +1,14 @@
 import { useAppDispatch } from '$app/stores/store';
-import { useCallback, useContext } from 'react';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useCallback } from 'react';
 import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
 import { turnToBlockThunk } from '$app_reducers/document/async-actions';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
   const dispatch = useAppDispatch();
 
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
 
   const turnIntoBlock = useCallback(
     async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {

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

@@ -1,5 +1,5 @@
-import React, { useMemo } from 'react';
-import { BlockType } from '$app/interfaces/document';
+import React, { useCallback, useEffect, useMemo } from 'react';
+import { BlockType, SlashCommandOptionKey } from '$app/interfaces/document';
 
 import {
   ArrowRight,
@@ -14,10 +14,20 @@ import {
   Functions,
 } from '@mui/icons-material';
 import Popover, { PopoverProps } from '@mui/material/Popover';
-import { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useTurnInto } from '$app/components/document/_shared/TurnInto/TurnInto.hooks';
+import { Keyboard } from '$app/constants/document/keyboard';
+import MenuItem from '$app/components/document/_shared/MenuItem';
+import { selectOptionByUpDown } from '$app/utils/document/menu';
 
+interface Option {
+  key: SlashCommandOptionKey;
+  type: BlockType;
+  title: string;
+  icon: React.ReactNode;
+  selected?: boolean;
+  onClick?: (type: BlockType, isSelected: boolean) => void;
+}
 const TurnIntoPopover = ({
   id,
   onClose,
@@ -28,21 +38,18 @@ const TurnIntoPopover = ({
 } & PopoverProps) => {
   const { node } = useSubscribeNode(id);
   const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
+  const [hovered, setHovered] = React.useState<SlashCommandOptionKey>();
 
-  const options: {
-    type: BlockType;
-    title: string;
-    icon: React.ReactNode;
-    selected?: boolean;
-    onClick?: (type: BlockType, isSelected: boolean) => void;
-  }[] = useMemo(
+  const options: Option[] = useMemo(
     () => [
       {
+        key: SlashCommandOptionKey.TEXT,
         type: BlockType.TextBlock,
         title: 'Text',
         icon: <TextFields />,
       },
       {
+        key: SlashCommandOptionKey.HEADING_1,
         type: BlockType.HeadingBlock,
         title: 'Heading 1',
         icon: <Title />,
@@ -52,6 +59,7 @@ const TurnIntoPopover = ({
         },
       },
       {
+        key: SlashCommandOptionKey.HEADING_2,
         type: BlockType.HeadingBlock,
         title: 'Heading 2',
         icon: <Title />,
@@ -61,6 +69,7 @@ const TurnIntoPopover = ({
         },
       },
       {
+        key: SlashCommandOptionKey.HEADING_3,
         type: BlockType.HeadingBlock,
         title: 'Heading 3',
         icon: <Title />,
@@ -70,36 +79,43 @@ const TurnIntoPopover = ({
         },
       },
       {
+        key: SlashCommandOptionKey.TODO,
         type: BlockType.TodoListBlock,
         title: 'To-do list',
         icon: <Check />,
       },
       {
+        key: SlashCommandOptionKey.BULLET,
         type: BlockType.BulletedListBlock,
         title: 'Bulleted list',
         icon: <FormatListBulleted />,
       },
       {
+        key: SlashCommandOptionKey.NUMBER,
         type: BlockType.NumberedListBlock,
         title: 'Numbered list',
         icon: <FormatListNumbered />,
       },
       {
+        key: SlashCommandOptionKey.TOGGLE,
         type: BlockType.ToggleListBlock,
         title: 'Toggle list',
         icon: <ArrowRight />,
       },
       {
+        key: SlashCommandOptionKey.CODE,
         type: BlockType.CodeBlock,
         title: 'Code',
         icon: <DataObject />,
       },
       {
+        key: SlashCommandOptionKey.QUOTE,
         type: BlockType.QuoteBlock,
         title: 'Quote',
         icon: <FormatQuote />,
       },
       {
+        key: SlashCommandOptionKey.CALLOUT,
         type: BlockType.CalloutBlock,
         title: 'Callout',
         icon: <Lightbulb />,
@@ -113,24 +129,90 @@ const TurnIntoPopover = ({
     [node?.data?.level, turnIntoHeading]
   );
 
+  const getSelected = useCallback(
+    (option: Option) => {
+      return option.type === node.type && option.selected !== false;
+    },
+    [node?.type]
+  );
+
+  const onClick = useCallback(
+    (option: Option) => {
+      const isSelected = getSelected(option);
+
+      option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected);
+    },
+    [getSelected, turnIntoBlock]
+  );
+
+  const onKeyDown = useCallback(
+    (e: KeyboardEvent) => {
+      e.stopPropagation();
+      e.preventDefault();
+      const isUp = e.key === Keyboard.keys.UP;
+      const isDown = e.key === Keyboard.keys.DOWN;
+      const isEnter = e.key === Keyboard.keys.ENTER;
+      const isLeft = e.key === Keyboard.keys.LEFT;
+
+      if (isLeft) {
+        onClose?.();
+        return;
+      }
+
+      if (!isUp && !isDown && !isEnter) return;
+      if (isEnter) {
+        const option = options.find((option) => option.key === hovered);
+
+        if (option) {
+          onClick(option);
+        }
+
+        return;
+      }
+
+      const nextKey = selectOptionByUpDown(
+        isUp,
+        String(hovered),
+        options.map((option) => String(option.key))
+      );
+      const nextOption = options.find((option) => String(option.key) === nextKey);
+
+      setHovered(nextOption?.key);
+    },
+    [hovered, onClick, onClose, options]
+  );
+
+  useEffect(() => {
+    if (props.open) {
+      document.addEventListener('keydown', onKeyDown, true);
+    }
+
+    return () => {
+      document.removeEventListener('keydown', onKeyDown, true);
+    };
+  }, [onKeyDown, props.open]);
+
   return (
     <Popover disableAutoFocus={true} onClose={onClose} {...props}>
-      {options.map((option) => {
-        const isSelected = option.type === node.type && option.selected !== false;
-        return (
-          <MenuItem
-            className={'w-[100%]'}
-            key={option.title}
-            onClick={() =>
-              option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected)
-            }
-          >
-            <ListItemIcon>{option.icon}</ListItemIcon>
-            <ListItemText>{option.title}</ListItemText>
-            <ListItemIcon>{isSelected ? <Check /> : null}</ListItemIcon>
-          </MenuItem>
-        );
-      })}
+      <div className={'min-w-[220px]'}>
+        {options.map((option) => {
+          return (
+            <MenuItem
+              iconSize={{
+                width: 20,
+                height: 20,
+              }}
+              icon={option.icon}
+              title={option.title}
+              isHovered={hovered === option.key}
+              extra={getSelected(option) ? <Check /> : null}
+              className={'w-[100%]'}
+              key={option.title}
+              onClick={() => onClick(option)}
+            ></MenuItem>
+          );
+        })}
+      </div>
     </Popover>
   );
 };

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts

@@ -1,10 +1,10 @@
-import { useCallback, useContext, useEffect } from 'react';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useCallback, useEffect } from 'react';
 import isHotkey from 'is-hotkey';
 import { Keyboard } from '@/appflowy_app/constants/document/keyboard';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 export function useUndoRedo(container: HTMLDivElement) {
-  const controller = useContext(DocumentControllerContext);
+  const { controller } = useSubscribeDocument();
 
   const onUndo = useCallback(() => {
     if (!controller) return;

+ 30 - 0
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -115,9 +115,39 @@ export interface DocumentState {
   // map of block id to children block ids
   children: Record<string, string[]>;
 }
+
 export interface SlashCommandState {
   isSlashCommand: boolean;
   blockId?: string;
+  hoverOption?: SlashCommandOption;
+}
+
+export enum SlashCommandOptionKey {
+  TEXT,
+  PAGE,
+  TODO,
+  BULLET,
+  NUMBER,
+  TOGGLE,
+  CODE,
+  EQUATION,
+  QUOTE,
+  CALLOUT,
+  DIVIDER,
+  HEADING_1,
+  HEADING_2,
+  HEADING_3,
+}
+
+export interface SlashCommandOption {
+  type: BlockType;
+  data?: BlockData<any>;
+  key: SlashCommandOptionKey;
+}
+
+export enum SlashCommandGroup {
+  BASIC = 'Basic',
+  MEDIA = 'Media',
 }
 
 export interface RectSelectionState {

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

@@ -1,8 +1,7 @@
-import { DocumentBlockJSON, DocumentData, Node } from '@/appflowy_app/interfaces/document';
+import { DocumentData, Node } from '@/appflowy_app/interfaces/document';
 import { createContext } from 'react';
 import { DocumentBackendService } from './document_bd_svc';
 import {
-  FlowyError,
   BlockActionPB,
   DocEventPB,
   BlockActionTypePB,
@@ -17,15 +16,13 @@ import { get } from '@/appflowy_app/utils/tool';
 import { blockPB2Node } from '$app/utils/document/block';
 import { Log } from '$app/utils/log';
 
-export const DocumentControllerContext = createContext<DocumentController | null>(null);
-
 export class DocumentController {
   private readonly backendService: DocumentBackendService;
   private readonly observer: DocumentObserver;
 
   constructor(
     public readonly documentId: string,
-    private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void
+    private onDocChange?: (props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => void
   ) {
     this.backendService = new DocumentBackendService(documentId);
     this.observer = new DocumentObserver(documentId);
@@ -37,14 +34,17 @@ export class DocumentController {
     });
 
     const document = await this.backendService.open();
+
     if (document.ok) {
       const nodes: DocumentData['nodes'] = {};
+
       get<Map<string, BlockPB>>(document.val, [BLOCK_MAP_NAME]).forEach((block) => {
         Object.assign(nodes, {
           [block.id]: blockPB2Node(block),
         });
       });
       const children: Record<string, string[]> = {};
+
       get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
         children[key] = child.children;
       });
@@ -60,6 +60,7 @@ export class DocumentController {
 
   applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => {
     Log.debug('applyActions', actions);
+    if (actions.length === 0) return;
     await this.backendService.applyActions(actions);
   };
 
@@ -109,21 +110,25 @@ export class DocumentController {
 
   canUndo = async () => {
     const result = await this.backendService.canUndoRedo();
+
     return result.ok && result.val.can_undo;
   };
 
   canRedo = async () => {
     const result = await this.backendService.canUndoRedo();
+
     return result.ok && result.val.can_redo;
   };
 
   undo = async () => {
     const result = await this.backendService.undo();
+
     return result.ok && result.val.is_success;
   };
 
   redo = async () => {
     const result = await this.backendService.redo();
+
     return result.ok && result.val.is_success;
   };
 
@@ -152,14 +157,17 @@ export class DocumentController {
 
   private composeDelta = (node: Node) => {
     const delta = node.data.delta;
+
     if (!delta) {
       return;
     }
+
     // we use yjs to compose delta, it can make sure the delta is correct
     // for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }]
     // but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }]
     const ydoc = new Y.Doc();
     const ytext = ydoc.getText(node.id);
+
     ytext.applyDelta(delta);
     Object.assign(node.data, { delta: ytext.toDelta() });
   };
@@ -172,6 +180,7 @@ export class DocumentController {
     events.forEach((blockEvent) => {
       blockEvent.event.forEach((_payload) => {
         this.onDocChange?.({
+          docId: this.documentId,
           isRemote: is_remote,
           data: _payload,
         });
@@ -179,3 +188,5 @@ export class DocumentController {
     });
   };
 }
+
+export const DocumentControllerContext = createContext<DocumentController>(new DocumentController(''));

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

@@ -1,15 +1,17 @@
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 
-import { DocumentState } from '$app/interfaces/document';
+import { RootState } from '$app/stores/store';
 
 export const deleteNodeThunk = createAsyncThunk(
   'document/deleteNode',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
     const { getState } = thunkAPI;
-    const state = getState() as { document: DocumentState };
-    const node = state.document.nodes[id];
+    const state = getState() as RootState;
+    const docId = controller.documentId;
+    const docState = state.document[docId];
+    const node = docState.nodes[id];
     if (!node) return;
     await controller.applyActions([controller.getDeleteAction(node)]);
   }

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

@@ -1,23 +1,29 @@
-import { DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { newBlock } from '$app/utils/document/block';
 import { rectSelectionActions } from '$app_reducers/document/slice';
 import { getDuplicateActions } from '$app/utils/document/action';
+import { RootState } from '$app/stores/store';
 
 export const duplicateBelowNodeThunk = createAsyncThunk(
   'document/duplicateBelowNode',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
     const { getState, dispatch } = thunkAPI;
-    const state = getState() as { document: DocumentState };
-    const node = state.document.nodes[id];
+    const state = getState() as RootState;
+    const docId = controller.documentId;
+    const docState = state.document[docId];
+    const node = docState.nodes[id];
     if (!node || !node.parent) return;
-    const duplicateActions = getDuplicateActions(id, node.parent, state.document, controller);
+    const duplicateActions = getDuplicateActions(id, node.parent, docState, controller);
 
     if (!duplicateActions) return;
     await controller.applyActions(duplicateActions.actions);
 
-    dispatch(rectSelectionActions.updateSelections([duplicateActions.newNodeId]));
+    dispatch(
+      rectSelectionActions.updateSelections({
+        docId,
+        selection: [duplicateActions.newNodeId],
+      })
+    );
   }
 );

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

@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { blockConfig } from '$app/constants/document/config';
 import { getPrevNodeId } from '$app/utils/document/block';
+import { RootState } from '$app/stores/store';
 
 /**
  * indent node
@@ -16,24 +17,26 @@ export const indentNodeThunk = createAsyncThunk(
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
     const { getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
+    const state = getState() as RootState;
+    const docId = controller.documentId;
+    const docState = state.document[docId];
+    const node = docState.nodes[id];
     if (!node.parent) return;
 
     // get prev node
-    const prevNodeId = getPrevNodeId(state, id);
+    const prevNodeId = getPrevNodeId(docState, id);
     if (!prevNodeId) return;
-    const newParentNode = state.nodes[prevNodeId];
+    const newParentNode = docState.nodes[prevNodeId];
     // check if prev node is allowed to have children
     const config = blockConfig[newParentNode.type];
     if (!config.canAddChild) return;
 
     // check if prev node has children and get last child for new prev node
-    const newParentChildren = state.children[newParentNode.children];
+    const newParentChildren = docState.children[newParentNode.children];
     const newPrevId = newParentChildren[newParentChildren.length - 1];
 
     const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId);
-    const childrenNodes = state.children[node.children].map((id) => state.nodes[id]);
+    const childrenNodes = docState.children[node.children].map((id) => docState.nodes[id]);
     const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
 
     await controller.applyActions([moveAction, ...moveChildrenActions]);

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

@@ -2,6 +2,7 @@ import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { newBlock } from '$app/utils/document/block';
+import { RootState } from '$app/stores/store';
 
 export const insertAfterNodeThunk = createAsyncThunk(
   'document/insertAfterNode',
@@ -12,10 +13,13 @@ export const insertAfterNodeThunk = createAsyncThunk(
       data = {
         delta: [],
       },
+      id,
     } = payload;
     const { getState } = thunkAPI;
-    const state = getState() as { document: DocumentState };
-    const node = state.document.nodes[payload.id];
+    const state = getState() as RootState;
+    const docId = controller.documentId;
+    const docState = state.document[docId];
+    const node = docState.nodes[id];
     if (!node) return;
     const parentId = node.parent;
     if (!parentId) return;

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

@@ -4,6 +4,7 @@ import { DocumentState } from '$app/interfaces/document';
 import Delta from 'quill-delta';
 import { blockConfig } from '$app/constants/document/config';
 import { getMoveChildrenActions } from '$app/utils/document/action';
+import { RootState } from '$app/stores/store';
 
 /**
  * Merge two blocks
@@ -16,9 +17,11 @@ export const mergeDeltaThunk = createAsyncThunk(
   async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => {
     const { sourceId, targetId, controller } = payload;
     const { getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const target = state.nodes[targetId];
-    const source = state.nodes[sourceId];
+    const state = getState() as RootState;
+    const docId = controller.documentId;
+    const docState = state.document[docId];
+    const target = docState.nodes[targetId];
+    const source = docState.nodes[sourceId];
     if (!target || !source) return;
     const targetDelta = new Delta(target.data.delta);
     const sourceDelta = new Delta(source.data.delta);
@@ -34,7 +37,7 @@ export const mergeDeltaThunk = createAsyncThunk(
 
     const actions = [updateAction];
     // move children
-    const children = state.children[source.children].map((id) => state.nodes[id]);
+    const children = docState.children[source.children].map((id) => docState.nodes[id]);
     const moveActions = getMoveChildrenActions({
       controller,
       children,

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

@@ -1,7 +1,7 @@
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentState } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
+import { RootState } from '$app/stores/store';
 
 /**
  * outdent node
@@ -17,16 +17,18 @@ export const outdentNodeThunk = createAsyncThunk(
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
     const { getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
+    const state = getState() as RootState;
+    const docId = controller.documentId;
+    const docState = state.document[docId];
+    const node = docState.nodes[id];
     const parentId = node.parent;
     if (!parentId) return;
-    const ancestorId = state.nodes[parentId].parent;
+    const ancestorId = docState.nodes[parentId].parent;
     if (!ancestorId) return;
 
-    const parent = state.nodes[parentId];
-    const index = state.children[parent.children].indexOf(id);
-    const nextSiblingIds = state.children[parent.children].slice(index + 1);
+    const parent = docState.nodes[parentId];
+    const index = docState.children[parent.children].indexOf(id);
+    const nextSiblingIds = docState.children[parent.children].slice(index + 1);
 
     const actions = [];
     const moveAction = controller.getMoveAction(node, ancestorId, parentId);
@@ -35,7 +37,7 @@ export const outdentNodeThunk = createAsyncThunk(
     const config = blockConfig[node.type];
     if (nextSiblingIds.length > 0) {
       if (config.canAddChild) {
-        const children = state.children[node.children];
+        const children = docState.children[node.children];
         let lastChildId: string | null = null;
         const lastIndex = children.length - 1;
         if (lastIndex >= 0) {
@@ -43,12 +45,12 @@ export const outdentNodeThunk = createAsyncThunk(
         }
         const moveChildrenActions = nextSiblingIds
           .reverse()
-          .map((id) => controller.getMoveAction(state.nodes[id], node.id, lastChildId));
+          .map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId));
         actions.push(...moveChildrenActions);
       } else {
         const moveChildrenActions = nextSiblingIds
           .reverse()
-          .map((id) => controller.getMoveAction(state.nodes[id], ancestorId, node.id));
+          .map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id));
         actions.push(...moveChildrenActions);
       }
     }

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

@@ -2,14 +2,17 @@ import { BlockData, DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import Delta, { Op } from 'quill-delta';
+import { RootState } from '$app/stores/store';
 
 export const updateNodeDeltaThunk = createAsyncThunk(
   'document/updateNodeDelta',
   async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
     const { id, delta, controller } = payload;
     const { getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
+    const state = getState() as RootState;
+    const docId = controller.documentId;
+    const docState = state.document[docId];
+    const node = docState.nodes[id];
     const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
     if (diffDelta.ops.length === 0) return;
 
@@ -34,8 +37,10 @@ export const updateNodeDataThunk = createAsyncThunk<
 >('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => {
   const { id, data, controller } = payload;
   const { getState } = thunkAPI;
-  const state = (getState() as { document: DocumentState }).document;
-  const node = state.nodes[id];
+  const state = getState() as RootState;
+  const docId = controller.documentId;
+  const docState = state.document[docId];
+  const node = docState.nodes[id];
 
   const newData = { ...node.data, ...data };
 

+ 33 - 9
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copyPaste.ts → frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts

@@ -17,13 +17,17 @@ import { rangeActions } from '$app_reducers/document/slice';
 export const copyThunk = createAsyncThunk<
   void,
   {
+    isCut?: boolean;
+    controller: DocumentController;
     setClipboardData: (data: BlockCopyData) => void;
   }
 >('document/copy', async (payload, thunkAPI) => {
-  const { getState } = thunkAPI;
-  const { setClipboardData } = payload;
+  const { getState, dispatch } = thunkAPI;
+  const { setClipboardData, isCut = false, controller } = payload;
+  const docId = controller.documentId;
   const state = getState() as RootState;
-  const { document, documentRange } = state;
+  const document = state.document[docId];
+  const documentRange = state.documentRange[docId];
   const startAndEndIds = getStartAndEndIdsByRange(documentRange);
   if (startAndEndIds.length === 0) return;
   const result: DocumentBlockJSON[] = [];
@@ -70,6 +74,10 @@ export const copyThunk = createAsyncThunk<
     text: '',
     html: '',
   });
+  if (isCut) {
+    // delete range blocks
+    await dispatch(deleteRangeAndInsertThunk({ controller }));
+  }
 });
 
 /**
@@ -94,6 +102,11 @@ export const pasteThunk = createAsyncThunk<
   // delete range blocks
   await dispatch(deleteRangeAndInsertThunk({ controller }));
 
+  const state = getState() as RootState;
+  const docId = controller.documentId;
+  const document = state.document[docId];
+  const documentRange = state.documentRange[docId];
+
   let pasteData;
   if (data.json) {
     pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
@@ -103,7 +116,6 @@ export const pasteThunk = createAsyncThunk<
     // TODO: implement html
   }
   if (!pasteData) return;
-  const { document, documentRange } = getState() as RootState;
   const { caret } = documentRange;
   if (!caret) return;
   const currentBlock = document.nodes[caret.id];
@@ -135,9 +147,12 @@ export const pasteThunk = createAsyncThunk<
     // set caret to the end of the last paste block
     dispatch(
       rangeActions.setCaret({
-        id: lastPasteBlock.id,
-        index: new Delta(lastPasteBlock.data.delta).length(),
-        length: 0,
+        docId,
+        caret: {
+          id: lastPasteBlock.id,
+          index: new Delta(lastPasteBlock.data.delta).length(),
+          length: 0,
+        },
       })
     );
     return;
@@ -150,7 +165,11 @@ export const pasteThunk = createAsyncThunk<
     length: currentBlockDelta.length() - caret.index,
   });
 
-  let newCaret;
+  let newCaret: {
+    id: string;
+    index: number;
+    length: number;
+  };
   const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
   const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
   let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
@@ -198,5 +217,10 @@ export const pasteThunk = createAsyncThunk<
   // set caret to the end of the last paste block
   if (!newCaret) return;
 
-  dispatch(rangeActions.setCaret(newCaret));
+  dispatch(
+    rangeActions.setCaret({
+      docId,
+      caret: newCaret,
+    })
+  );
 });

+ 34 - 23
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts

@@ -4,42 +4,53 @@ import { TextAction } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import Delta from 'quill-delta';
 
-export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
-  'document/getFormatActive',
-  async (format, thunkAPI) => {
-    const { getState } = thunkAPI;
-    const state = getState() as RootState;
-    const { document, documentRange } = state;
-    const { ranges } = documentRange;
-    const match = (delta: Delta, format: TextAction) => {
-      return delta.ops.every((op) => op.attributes?.[format]);
-    };
-    return Object.entries(ranges).every(([id, range]) => {
-      const node = document.nodes[id];
-      const delta = new Delta(node.data?.delta);
-      const index = range?.index || 0;
-      const length = range?.length || 0;
-      const rangeDelta = delta.slice(index, index + length);
-
-      return match(rangeDelta, format);
-    });
+export const getFormatActiveThunk = createAsyncThunk<
+  boolean,
+  {
+    format: TextAction;
+    docId: string;
   }
-);
+>('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
+  const { getState } = thunkAPI;
+  const state = getState() as RootState;
+  const document = state.document[docId];
+  const documentRange = state.documentRange[docId];
+  const { ranges } = documentRange;
+  const match = (delta: Delta, format: TextAction) => {
+    return delta.ops.every((op) => op.attributes?.[format]);
+  };
+  return Object.entries(ranges).every(([id, range]) => {
+    const node = document.nodes[id];
+    const delta = new Delta(node.data?.delta);
+    const index = range?.index || 0;
+    const length = range?.length || 0;
+    const rangeDelta = delta.slice(index, index + length);
+
+    return match(rangeDelta, format);
+  });
+});
 
 export const toggleFormatThunk = createAsyncThunk(
   'document/toggleFormat',
   async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
     const { getState, dispatch } = thunkAPI;
     const { format, controller } = payload;
+    const docId = controller.documentId;
     let isActive = payload.isActive;
     if (isActive === undefined) {
-      const { payload: active } = await dispatch(getFormatActiveThunk(format));
+      const { payload: active } = await dispatch(
+        getFormatActiveThunk({
+          format,
+          docId,
+        })
+      );
       isActive = !!active;
     }
     const formatValue = isActive ? undefined : true;
     const state = getState() as RootState;
-    const { document } = state;
-    const { ranges } = state.documentRange;
+    const document = state.document[docId];
+    const documentRange = state.documentRange[docId];
+    const { ranges } = documentRange;
 
     const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
       const newOps = delta.ops.map((op) => {

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

@@ -2,4 +2,4 @@ export * from './blocks';
 export * from './turn_to';
 export * from './keydown';
 export * from './range';
-export { updateLinkThunk } from '$app_reducers/document/async-actions/link';
+export * from './link';

+ 86 - 43
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts

@@ -1,6 +1,6 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockType, DocumentState, SplitRelationship } from '$app/interfaces/document';
+import { BlockType, RangeStatic, SplitRelationship } from '$app/interfaces/document';
 import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
 import {
   findNextHasDeltaNode,
@@ -29,8 +29,9 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
   'document/backspaceDeleteActionForBlock',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
+    const docId = controller.documentId;
     const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
+    const state = (getState() as RootState).document[docId];
     const node = state.nodes[id];
     if (!node.parent) return;
     const parent = state.nodes[node.parent];
@@ -60,8 +61,13 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
           controller,
         })
       );
-      dispatch(rangeActions.clearRange());
-      dispatch(rangeActions.setCaret(caret));
+      dispatch(rangeActions.initialState(docId));
+      dispatch(
+        rangeActions.setCaret({
+          docId,
+          caret,
+        })
+      );
       return;
     }
     // outdent
@@ -81,11 +87,19 @@ export const enterActionForBlockThunk = createAsyncThunk(
     const { id, controller } = payload;
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
-    const node = state.document.nodes[id];
-    const caret = state.documentRange.caret;
+    const docId = controller.documentId;
+    const documentState = state.document[docId];
+    const node = documentState.nodes[id];
+    const caret = state.documentRange[docId]?.caret;
     if (!node || !caret || caret.id !== id) return;
+    const delta = new Delta(node.data.delta);
+    if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
+      // If the node is not a text block, turn it to a text block
+      await dispatch(turnToTextBlockThunk({ id, controller }));
+      return;
+    }
+    const nodeDelta = delta.slice(0, caret.index);
 
-    const nodeDelta = new Delta(node.data.delta).slice(0, caret.index);
     const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
 
     const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
@@ -98,11 +112,11 @@ export const enterActionForBlockThunk = createAsyncThunk(
       },
     };
 
-    const children = state.document.children[node.children];
+    const children = documentState.children[node.children];
     const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
     const moveChildrenAction = needMoveChildren
       ? controller.getMoveChildrenAction(
-          children.map((id) => state.document.nodes[id]),
+          children.map((id) => documentState.nodes[id]),
           insertNodeAction.id,
           ''
         )
@@ -110,12 +124,15 @@ export const enterActionForBlockThunk = createAsyncThunk(
     const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
     await controller.applyActions(actions);
 
-    dispatch(rangeActions.clearRange());
+    dispatch(rangeActions.initialState(docId));
     dispatch(
       rangeActions.setCaret({
-        id: insertNodeAction.id,
-        index: 0,
-        length: 0,
+        docId,
+        caret: {
+          id: insertNodeAction.id,
+          index: 0,
+          length: 0,
+        },
       })
     );
   }
@@ -131,41 +148,48 @@ export const tabActionForBlockThunk = createAsyncThunk(
 
 export const upDownActionForBlockThunk = createAsyncThunk(
   'document/upActionForBlock',
-  async (payload: { id: string; down?: boolean }, thunkAPI) => {
-    const { id, down } = payload;
+  async (payload: { docId: string; id: string; down?: boolean }, thunkAPI) => {
+    const { docId, id, down } = payload;
     const { dispatch, getState } = thunkAPI;
     const state = getState() as RootState;
-    const rangeState = state.documentRange;
+    const documentState = state.document[docId];
+    const rangeState = state.documentRange[docId];
     const caret = rangeState.caret;
-    const node = state.document.nodes[id];
+    const node = documentState.nodes[id];
     if (!node || !caret || id !== caret.id) return;
 
     let newCaret;
 
     if (down) {
-      newCaret = transformToNextLineCaret(state.document, caret);
+      newCaret = transformToNextLineCaret(documentState, caret);
     } else {
-      newCaret = transformToPrevLineCaret(state.document, caret);
+      newCaret = transformToPrevLineCaret(documentState, caret);
     }
     if (!newCaret) {
       return;
     }
-    dispatch(rangeActions.clearRange());
-    dispatch(rangeActions.setCaret(newCaret));
+    dispatch(rangeActions.initialState(docId));
+    dispatch(
+      rangeActions.setCaret({
+        docId,
+        caret: newCaret,
+      })
+    );
   }
 );
 
 export const leftActionForBlockThunk = createAsyncThunk(
   'document/leftActionForBlock',
-  async (payload: { id: string }, thunkAPI) => {
-    const { id } = payload;
+  async (payload: { docId: string; id: string }, thunkAPI) => {
+    const { id, docId } = payload;
     const { dispatch, getState } = thunkAPI;
     const state = getState() as RootState;
-    const rangeState = state.documentRange;
+    const documentState = state.document[docId];
+    const rangeState = state.documentRange[docId];
     const caret = rangeState.caret;
-    const node = state.document.nodes[id];
+    const node = documentState.nodes[id];
     if (!node || !caret || id !== caret.id) return;
-    let newCaret;
+    let newCaret: RangeStatic;
     if (caret.length > 0) {
       newCaret = {
         id,
@@ -180,7 +204,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
           length: 0,
         };
       } else {
-        const prevNode = findPrevHasDeltaNode(state.document, id);
+        const prevNode = findPrevHasDeltaNode(documentState, id);
         if (!prevNode) return;
         const prevDelta = new Delta(prevNode.data.delta);
         newCaret = {
@@ -194,22 +218,28 @@ export const leftActionForBlockThunk = createAsyncThunk(
     if (!newCaret) {
       return;
     }
-    dispatch(rangeActions.clearRange());
-    dispatch(rangeActions.setCaret(newCaret));
+    dispatch(rangeActions.initialState(docId));
+    dispatch(
+      rangeActions.setCaret({
+        docId,
+        caret: newCaret,
+      })
+    );
   }
 );
 
 export const rightActionForBlockThunk = createAsyncThunk(
   'document/rightActionForBlock',
-  async (payload: { id: string }, thunkAPI) => {
-    const { id } = payload;
+  async (payload: { id: string; docId: string }, thunkAPI) => {
+    const { id, docId } = payload;
     const { dispatch, getState } = thunkAPI;
     const state = getState() as RootState;
-    const rangeState = state.documentRange;
+    const documentState = state.document[docId];
+    const rangeState = state.documentRange[docId];
     const caret = rangeState.caret;
-    const node = state.document.nodes[id];
+    const node = documentState.nodes[id];
     if (!node || !caret || id !== caret.id) return;
-    let newCaret;
+    let newCaret: RangeStatic;
     const delta = new Delta(node.data.delta);
     const deltaLength = delta.length();
     if (caret.length > 0) {
@@ -227,7 +257,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
           length: 0,
         };
       } else {
-        const nextNode = findNextHasDeltaNode(state.document, id);
+        const nextNode = findNextHasDeltaNode(documentState, id);
         if (!nextNode) return;
         newCaret = {
           id: nextNode.id,
@@ -240,9 +270,14 @@ export const rightActionForBlockThunk = createAsyncThunk(
     if (!newCaret) {
       return;
     }
-    dispatch(rangeActions.clearRange());
+    dispatch(rangeActions.initialState(docId));
 
-    dispatch(rangeActions.setCaret(newCaret));
+    dispatch(
+      rangeActions.setCaret({
+        caret: newCaret,
+        docId,
+      })
+    );
   }
 );
 
@@ -259,19 +294,22 @@ export const arrowActionForRangeThunk = createAsyncThunk(
   async (
     payload: {
       key: string;
+      docId: string;
     },
     thunkAPI
   ) => {
     const { dispatch, getState } = thunkAPI;
+    const { key, docId } = payload;
     const state = getState() as RootState;
-    const rangeState = state.documentRange;
+    const documentState = state.document[docId];
+    const rangeState = state.documentRange[docId];
     let caret;
     const leftCaret = getLeftCaretByRange(rangeState);
     const rightCaret = getRightCaretByRange(rangeState);
 
     if (!leftCaret || !rightCaret) return;
 
-    switch (payload.key) {
+    switch (key) {
       case Keyboard.keys.LEFT:
         caret = leftCaret;
         break;
@@ -279,14 +317,19 @@ export const arrowActionForRangeThunk = createAsyncThunk(
         caret = rightCaret;
         break;
       case Keyboard.keys.UP:
-        caret = transformToPrevLineCaret(state.document, leftCaret);
+        caret = transformToPrevLineCaret(documentState, leftCaret);
         break;
       case Keyboard.keys.DOWN:
-        caret = transformToNextLineCaret(state.document, rightCaret);
+        caret = transformToNextLineCaret(documentState, rightCaret);
         break;
     }
     if (!caret) return;
-    dispatch(rangeActions.clearRange());
-    dispatch(rangeActions.setCaret(caret));
+    dispatch(rangeActions.initialState(docId));
+    dispatch(
+      rangeActions.setCaret({
+        docId,
+        caret,
+      })
+    );
   }
 );

+ 26 - 35
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts

@@ -12,13 +12,14 @@ export const formatLinkThunk = createAsyncThunk<
 >('document/formatLink', async (payload, thunkAPI) => {
   const { controller } = payload;
   const { getState } = thunkAPI;
+  const docId = controller.documentId;
   const state = getState() as RootState;
-  const linkPopover = state.documentLinkPopover;
+  const documentState = state.document[docId];
+  const linkPopover = state.documentLinkPopover[docId];
   if (!linkPopover) return false;
   const { selection, id, href, title = '' } = linkPopover;
   if (!selection || !id) return false;
-  const document = state.document;
-  const node = document.nodes[id];
+  const node = documentState.nodes[id];
   const nodeDelta = new Delta(node.data?.delta);
   const index = selection.index || 0;
   const length = selection.length || 0;
@@ -44,35 +45,22 @@ export const formatLinkThunk = createAsyncThunk<
   return true;
 });
 
-export const updateLinkThunk = createAsyncThunk<
+export const newLinkThunk = createAsyncThunk<
   void,
   {
-    id: string;
-    href?: string;
-    title: string;
+    docId: string;
   }
->('document/updateLink', async (payload, thunkAPI) => {
-  const { id, href, title } = payload;
-  const { dispatch } = thunkAPI;
-
-  dispatch(
-    linkPopoverActions.updateLinkPopover({
-      id,
-      href,
-      title,
-    })
-  );
-});
-
-export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (payload, thunkAPI) => {
+>('document/newLink', async ({ docId }, thunkAPI) => {
   const { getState, dispatch } = thunkAPI;
-  const { documentRange, document } = getState() as RootState;
+  const state = getState() as RootState;
+  const documentState = state.document[docId];
+  const documentRange = state.documentRange[docId];
 
   const { caret } = documentRange;
   if (!caret) return;
   const { index, length, id } = caret;
 
-  const block = document.nodes[id];
+  const block = documentState.nodes[id];
   const delta = new Delta(block.data.delta).slice(index, index + length);
   const op = delta.ops.find((op) => op.attributes?.href);
   const href = op?.attributes?.href as string;
@@ -83,21 +71,24 @@ export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (pa
   if (!domRange) return;
   const title = domSelection.toString();
   const { top, left, height, width } = domRange.getBoundingClientRect();
-  dispatch(rangeActions.clearRange());
+  dispatch(rangeActions.initialState(docId));
   dispatch(
     linkPopoverActions.setLinkPopover({
-      anchorPosition: {
-        top: top + height,
-        left: left + width / 2,
-      },
-      id,
-      selection: {
-        index,
-        length,
+      docId,
+      linkState: {
+        anchorPosition: {
+          top: top + height,
+          left: left + width / 2,
+        },
+        id,
+        selection: {
+          index,
+          length,
+        },
+        title,
+        href,
+        open: true,
       },
-      title,
-      href,
-      open: true,
     })
   );
 });

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

@@ -1,5 +1,5 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
+import { BlockData, BlockType } from '$app/interfaces/document';
 import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { rangeActions, slashCommandActions } from '$app_reducers/document/slice';
@@ -18,8 +18,9 @@ export const addBlockBelowClickThunk = createAsyncThunk(
   'document/addBlockBelowClick',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
+    const docId = controller.documentId;
     const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
+    const state = (getState() as RootState).document[docId];
     const node = state.nodes[id];
     if (!node) return;
     const delta = (node.data.delta as Op[]) || [];
@@ -31,15 +32,25 @@ export const addBlockBelowClickThunk = createAsyncThunk(
         insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
       );
       if (newBlockId) {
-        dispatch(rangeActions.setCaret({ id: newBlockId as string, index: 0, length: 0 }));
-        dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string }));
+        dispatch(
+          rangeActions.setCaret({
+            docId,
+            caret: { id: newBlockId as string, index: 0, length: 0 },
+          })
+        );
+        dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string }));
       }
       return;
     }
     // if current block is empty, open slash command
-    dispatch(rangeActions.setCaret({ id, index: 0, length: 0 }));
+    dispatch(
+      rangeActions.setCaret({
+        docId,
+        caret: { id, index: 0, length: 0 },
+      })
+    );
 
-    dispatch(slashCommandActions.openSlashCommand({ blockId: id }));
+    dispatch(slashCommandActions.openSlashCommand({ docId, blockId: id }));
   }
 );
 
@@ -63,8 +74,9 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
   ) => {
     const { id, controller, props } = payload;
     const { dispatch, getState } = thunkAPI;
+    const docId = controller.documentId;
     const state = getState() as RootState;
-    const { document } = state;
+    const document = state.document[docId];
     const node = document.nodes[id];
     if (!node) return;
     const delta = new Delta(node.data.delta);
@@ -111,6 +123,11 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
     );
     const newBlockId = insertNodePayload.payload as string;
 
-    dispatch(rangeActions.setCaret({ id: newBlockId, index: 0, length: 0 }));
+    dispatch(
+      rangeActions.setCaret({
+        docId,
+        caret: { id: newBlockId, index: 0, length: 0 },
+      })
+    );
   }
 );

+ 53 - 22
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts

@@ -15,6 +15,7 @@ import { RangeState, SplitRelationship } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
 
 interface storeRangeThunkPayload {
+  docId: string;
   id: string;
   range: {
     index: number;
@@ -28,10 +29,11 @@ interface storeRangeThunkPayload {
  * 2. if isDragging is true, we need amend range between anchor and focus
  */
 export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: storeRangeThunkPayload, thunkAPI) => {
-  const { id, range } = payload;
+  const { docId, id, range } = payload;
   const { dispatch, getState } = thunkAPI;
   const state = getState() as RootState;
-  const rangeState = state.documentRange;
+  const rangeState = state.documentRange[docId];
+  const documentState = state.document[docId];
   // we need amend range between anchor and focus
   const { anchor, focus, isDragging } = rangeState;
   if (!isDragging || !anchor || !focus) return;
@@ -42,20 +44,30 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
   let anchorIndex = anchor.point.index;
   let anchorLength = anchor.point.length;
   if (anchorIndex === undefined || anchorLength === undefined) {
-    dispatch(rangeActions.setAnchorPointRange(range));
+    dispatch(
+      rangeActions.setAnchorPointRange({
+        ...range,
+        docId,
+      })
+    );
     anchorIndex = range.index;
     anchorLength = range.length;
   }
 
   // if anchor and focus are in the same node, we don't need to amend range
   if (anchor.id === id) {
-    dispatch(rangeActions.setRanges(ranges));
+    dispatch(
+      rangeActions.setRanges({
+        ranges,
+        docId,
+      })
+    );
     return;
   }
 
   // amend anchor range because slatejs will stop update selection when dragging quickly
   const isForward = anchor.point.y < focus.point.y;
-  const anchorDelta = new Delta(state.document.nodes[anchor.id].data.delta);
+  const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
   if (isForward) {
     const selectedDelta = anchorDelta.slice(anchorIndex);
     ranges[anchor.id] = {
@@ -74,9 +86,9 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
   const startId = isForward ? anchor.id : focus.id;
   const endId = isForward ? focus.id : anchor.id;
 
-  const middleIds = getMiddleIds(state.document, startId, endId);
+  const middleIds = getMiddleIds(documentState, startId, endId);
   middleIds.forEach((id) => {
-    const node = state.document.nodes[id];
+    const node = documentState.nodes[id];
 
     if (!node || !node.data.delta) return;
     const delta = new Delta(node.data.delta);
@@ -88,7 +100,12 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
     ranges[id] = rangeStatic;
   });
 
-  dispatch(rangeActions.setRanges(ranges));
+  dispatch(
+    rangeActions.setRanges({
+      ranges,
+      docId,
+    })
+  );
 });
 
 /**
@@ -101,9 +118,11 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
   'document/deleteRange',
   async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => {
     const { controller, insertDelta } = payload;
+    const docId = controller.documentId;
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
-    const rangeState = state.documentRange;
+    const rangeState = state.documentRange[docId];
+    const documentState = state.document[docId];
 
     const actions = [];
     // get merge actions
@@ -112,20 +131,25 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
       actions.push(...mergeActions);
     }
     // get middle nodes
-    const middleIds = getMiddleIdsByRange(rangeState, state.document);
+    const middleIds = getMiddleIdsByRange(rangeState, documentState);
     // delete middle nodes
-    const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || [];
+    const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
     actions.push(...deleteMiddleNodesActions);
 
     const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
 
+    if (actions.length === 0) return;
     // apply actions
     await controller.applyActions(actions);
-
     // clear range
-    dispatch(rangeActions.clearRange());
+    dispatch(rangeActions.initialState(docId));
     if (caret) {
-      dispatch(rangeActions.setCaret(caret));
+      dispatch(
+        rangeActions.setCaret({
+          docId,
+          caret,
+        })
+      );
     }
   }
 );
@@ -144,15 +168,17 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
   async (payload: { controller: DocumentController; shiftKey: boolean }, thunkAPI) => {
     const { controller, shiftKey } = payload;
     const { getState, dispatch } = thunkAPI;
+    const docId = controller.documentId;
     const state = getState() as RootState;
-    const rangeState = state.documentRange;
+    const rangeState = state.documentRange[docId];
+    const documentState = state.document[docId];
     const actions = [];
 
-    const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {};
+    const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
     if (!startDelta || !endDelta || !endNode || !startNode) return;
 
     // get middle nodes
-    const middleIds = getMiddleIds(state.document, startNode.id, endNode.id);
+    const middleIds = getMiddleIds(documentState, startNode.id, endNode.id);
 
     let newStartDelta = new Delta(startDelta);
     let caret = null;
@@ -174,10 +200,10 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
         blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
       if (needMoveChildren) {
         // filter children by delete middle ids
-        const children = state.document.children[startNode.children].filter((id) => middleIds?.includes(id));
+        const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
         const moveChildrenAction = needMoveChildren
           ? controller.getMoveChildrenAction(
-              children.map((id) => state.document.nodes[id]),
+              children.map((id) => documentState.nodes[id]),
               insertNodeAction.id,
               ''
             )
@@ -201,16 +227,21 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
     }
 
     // delete middle nodes
-    const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || [];
+    const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
     actions.push(...deleteMiddleNodesActions);
 
     // apply actions
     await controller.applyActions(actions);
 
     // clear range
-    dispatch(rangeActions.clearRange());
+    dispatch(rangeActions.initialState(docId));
     if (caret) {
-      dispatch(rangeActions.setCaret(caret));
+      dispatch(
+        rangeActions.setCaret({
+          docId,
+          caret,
+        })
+      );
     }
   }
 );

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

@@ -1,15 +1,22 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block';
-import { DocumentState } from '$app/interfaces/document';
 import { rectSelectionActions } from '$app_reducers/document/slice';
+import { RootState } from '$app/stores/store';
 
 export const setRectSelectionThunk = createAsyncThunk(
   'document/setRectSelection',
-  async (payload: string[], thunkAPI) => {
+  async (
+    payload: {
+      docId: string;
+      selection: string[];
+    },
+    thunkAPI
+  ) => {
     const { getState, dispatch } = thunkAPI;
-    const documentState = (getState() as { document: DocumentState }).document;
+    const { docId, selection } = payload;
+    const documentState = (getState() as RootState).document[docId];
     const selected: Record<string, boolean> = {};
-    payload.forEach((id) => {
+    selection.forEach((id) => {
       const node = documentState.nodes[id];
       if (!node.parent) {
         return;
@@ -18,10 +25,15 @@ export const setRectSelectionThunk = createAsyncThunk(
       selected[node.parent] = false;
       const nextNodeId = getNextNodeId(documentState, node.parent);
       const prevNodeId = getPrevNodeId(documentState, node.parent);
-      if ((nextNodeId && payload.includes(nextNodeId)) || (prevNodeId && payload.includes(prevNodeId))) {
+      if ((nextNodeId && selection.includes(nextNodeId)) || (prevNodeId && selection.includes(prevNodeId))) {
         selected[node.parent] = true;
       }
     });
-    dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id])));
+    dispatch(
+      rectSelectionActions.updateSelections({
+        docId,
+        selection: selection.filter((id) => selected[id]),
+      })
+    );
   }
 );

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

@@ -1,9 +1,10 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
+import { BlockData, BlockType } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
 import { newBlock } from '$app/utils/document/block';
 import { rangeActions } from '$app_reducers/document/slice';
+import { RootState } from '$app/stores/store';
 
 /**
  * transform to block
@@ -17,8 +18,9 @@ export const turnToBlockThunk = createAsyncThunk(
   'document/turnToBlock',
   async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => {
     const { id, controller, type, data } = payload;
+    const docId = controller.documentId;
     const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
+    const state = (getState() as RootState).document[docId];
 
     const node = state.nodes[id];
     if (!node.parent) return;
@@ -49,7 +51,12 @@ export const turnToBlockThunk = createAsyncThunk(
     // submit actions
     await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]);
     // set cursor in new block
-    dispatch(rangeActions.setCaret({ id: caretId, index: 0, length: 0 }));
+    dispatch(
+      rangeActions.setCaret({
+        docId,
+        caret: { id: caretId, index: 0, length: 0 },
+      })
+    );
   }
 );
 
@@ -64,7 +71,8 @@ export const turnToTextBlockThunk = createAsyncThunk(
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
     const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
+    const docId = controller.documentId;
+    const state = (getState() as RootState).document[docId];
     const node = state.nodes[id];
     const data = {
       delta: node.data.delta,

+ 258 - 77
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -6,31 +6,21 @@ import {
   RangeState,
   RangeStatic,
   LinkPopoverState,
+  SlashCommandOption,
 } from '@/appflowy_app/interfaces/document';
 import { BlockEventPayloadPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
 
-const initialState: DocumentState = {
-  nodes: {},
-  children: {},
-};
+const initialState: Record<string, DocumentState> = {};
 
-const rectSelectionInitialState: RectSelectionState = {
-  selection: [],
-  isDragging: false,
-};
+const rectSelectionInitialState: Record<string, RectSelectionState> = {};
 
-const rangeInitialState: RangeState = {
-  isDragging: false,
-  ranges: {},
-};
+const rangeInitialState: Record<string, RangeState> = {};
 
-const slashCommandInitialState: SlashCommandState = {
-  isSlashCommand: false,
-};
+const slashCommandInitialState: Record<string, SlashCommandState> = {};
 
-const linkPopoverState: LinkPopoverState = {};
+const linkPopoverState: Record<string, LinkPopoverState> = {};
 
 export const documentSlice = createSlice({
   name: 'document',
@@ -39,21 +29,35 @@ export const documentSlice = createSlice({
   // Because the document state is updated by the `onDataChange`
   reducers: {
     // initialize the document
-    clear: () => {
-      return initialState;
+    initialState: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      state[docId] = {
+        nodes: {},
+        children: {},
+      };
+    },
+    clear: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      delete state[docId];
     },
 
     // set document data
     create: (
       state,
       action: PayloadAction<{
+        docId: string;
         nodes: Record<string, Node>;
         children: Record<string, string[]>;
       }>
     ) => {
-      const { nodes, children } = action.payload;
-      state.nodes = nodes;
-      state.children = children;
+      const { docId, nodes, children } = action.payload;
+
+      state[docId] = {
+        nodes,
+        children,
+      };
     },
     /**
      This function listens for changes in the data layer triggered by the data API,
@@ -65,17 +69,23 @@ export const documentSlice = createSlice({
     onDataChange: (
       state,
       action: PayloadAction<{
+        docId: string;
         data: BlockEventPayloadPB;
         isRemote: boolean;
       }>
     ) => {
-      const { path, id, value, command } = action.payload.data;
+      const { docId, data } = action.payload;
+      const { path, id, value, command } = data;
+
+      const documentState = state[docId];
 
+      if (!documentState) return;
       const valueJson = parseValue(value);
+
       if (!valueJson) return;
 
       // match change
-      matchChange(state, { path, id, value: valueJson, command });
+      matchChange(documentState, { path, id, value: valueJson, command });
     },
   },
 });
@@ -84,20 +94,57 @@ export const rectSelectionSlice = createSlice({
   name: 'documentRectSelection',
   initialState: rectSelectionInitialState,
   reducers: {
+    initialState: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      state[docId] = {
+        selection: [],
+        isDragging: false,
+      };
+    },
+    clear: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      delete state[docId];
+    },
     // update block selections
-    updateSelections: (state, action: PayloadAction<string[]>) => {
-      state.selection = action.payload;
+    updateSelections: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        selection: string[];
+      }>
+    ) => {
+      const { docId, selection } = action.payload;
+
+      state[docId].selection = selection;
     },
 
     // set block selected
-    setSelectionById: (state, action: PayloadAction<string>) => {
-      const id = action.payload;
-      if (state.selection.includes(id)) return;
-      state.selection = [...state.selection, id];
+    setSelectionById: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        blockId: string;
+      }>
+    ) => {
+      const { docId, blockId } = action.payload;
+      const selection = state[docId].selection;
+
+      if (selection.includes(blockId)) return;
+      state[docId].selection = [...selection, blockId];
     },
 
-    setDragging: (state, action: PayloadAction<boolean>) => {
-      state.isDragging = action.payload;
+    setDragging: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        isDragging: boolean;
+      }>
+    ) => {
+      const { docId, isDragging } = action.payload;
+
+      state[docId].isDragging = isDragging;
     },
   },
 });
@@ -106,12 +153,34 @@ export const rangeSlice = createSlice({
   name: 'documentRange',
   initialState: rangeInitialState,
   reducers: {
-    setRanges: (state, action: PayloadAction<RangeState['ranges']>) => {
-      state.ranges = action.payload;
+    initialState: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      state[docId] = {
+        isDragging: false,
+        ranges: {},
+      };
+    },
+    clear: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      delete state[docId];
+    },
+    setRanges: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        ranges: RangeState['ranges'];
+      }>
+    ) => {
+      const { docId, ranges } = action.payload;
+
+      state[docId].ranges = ranges;
     },
     setRange: (
       state,
       action: PayloadAction<{
+        docId: string;
         id: string;
         rangeStatic: {
           index: number;
@@ -119,84 +188,178 @@ export const rangeSlice = createSlice({
         };
       }>
     ) => {
-      const { id, rangeStatic } = action.payload;
-      state.ranges[id] = rangeStatic;
+      const { docId, id, rangeStatic } = action.payload;
+
+      state[docId].ranges[id] = rangeStatic;
     },
-    removeRange: (state, action: PayloadAction<string>) => {
-      const id = action.payload;
-      delete state.ranges[id];
+    removeRange: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        id: string;
+      }>
+    ) => {
+      const { docId, id } = action.payload;
+      const ranges = state[docId].ranges;
+
+      delete ranges[id];
     },
     setAnchorPoint: (
       state,
       action: PayloadAction<{
+        docId: string;
         id: string;
         point: { x: number; y: number };
       }>
     ) => {
-      state.anchor = action.payload;
+      const { docId, id, point } = action.payload;
+
+      state[docId].anchor = {
+        id,
+        point,
+      };
     },
     setAnchorPointRange: (
       state,
       action: PayloadAction<{
+        docId: string;
         index: number;
         length: number;
       }>
     ) => {
-      const anchor = state.anchor;
+      const { docId, index, length } = action.payload;
+      const anchor = state[docId].anchor;
+
       if (!anchor) return;
       anchor.point = {
         ...anchor.point,
-        ...action.payload,
+        index,
+        length,
       };
     },
     setFocusPoint: (
       state,
       action: PayloadAction<{
+        docId: string;
         id: string;
         point: { x: number; y: number };
       }>
     ) => {
-      state.focus = action.payload;
+      const { docId, id, point } = action.payload;
+
+      state[docId].focus = {
+        id,
+        point,
+      };
     },
-    setDragging: (state, action: PayloadAction<boolean>) => {
-      state.isDragging = action.payload;
+    setDragging: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        isDragging: boolean;
+      }>
+    ) => {
+      const { docId, isDragging } = action.payload;
+
+      state[docId].isDragging = isDragging;
     },
-    setCaret: (state, action: PayloadAction<RangeStatic | null>) => {
-      if (!action.payload) {
-        state.caret = undefined;
+    setCaret: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        caret: RangeStatic | null;
+      }>
+    ) => {
+      const { docId, caret } = action.payload;
+      const rangeState = state[docId];
+
+      if (!caret) {
+        rangeState.caret = undefined;
         return;
       }
-      const id = action.payload.id;
-      state.ranges[id] = {
-        index: action.payload.index,
-        length: action.payload.length,
+
+      const { id, index, length } = caret;
+
+      rangeState.ranges[id] = {
+        index,
+        length,
       };
-      state.caret = action.payload;
+      rangeState.caret = caret;
     },
-    clearRange: (state, _: PayloadAction) => {
-      return rangeInitialState;
+    clearRanges: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        exclude?: string;
+      }>
+    ) => {
+      const { docId, exclude } = action.payload;
+      const ranges = state[docId].ranges;
+      const newRanges = Object.keys(ranges).reduce((acc, id) => {
+        if (id !== exclude) return { ...acc };
+        return {
+          ...acc,
+          [id]: ranges[id],
+        };
+      }, {});
+
+      state[docId].ranges = newRanges;
     },
   },
 });
+
 export const slashCommandSlice = createSlice({
   name: 'documentSlashCommand',
   initialState: slashCommandInitialState,
   reducers: {
+    initialState: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      state[docId] = {
+        isSlashCommand: false,
+      };
+    },
+    clear: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      delete state[docId];
+    },
     openSlashCommand: (
       state,
       action: PayloadAction<{
+        docId: string;
         blockId: string;
       }>
     ) => {
-      const { blockId } = action.payload;
-      return {
-        ...state,
+      const { blockId, docId } = action.payload;
+
+      state[docId] = {
+        ...state[docId],
         isSlashCommand: true,
         blockId,
       };
     },
-    closeSlashCommand: (state, _: PayloadAction) => {
-      return slashCommandInitialState;
+    closeSlashCommand: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      state[docId] = {
+        ...state[docId],
+        isSlashCommand: false,
+      };
+    },
+    setHoverOption: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        option: SlashCommandOption;
+      }>
+    ) => {
+      const { docId, option } = action.payload;
+
+      state[docId] = {
+        ...state[docId],
+        hoverOption: option,
+      };
     },
   },
 });
@@ -205,28 +368,46 @@ export const linkPopoverSlice = createSlice({
   name: 'documentLinkPopover',
   initialState: linkPopoverState,
   reducers: {
-    setLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
-      return {
-        ...state,
-        ...action.payload,
+    initialState: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      state[docId] = {
+        open: false,
       };
     },
-    updateLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
-      const { id } = action.payload;
-      if (!state.open || state.id !== id) return;
-      return {
-        ...state,
-        ...action.payload,
-      };
+    clear: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      delete state[docId];
     },
-    closeLinkPopover: (state, _: PayloadAction) => {
-      return {
-        ...state,
-        open: false,
-      };
+    setLinkPopover: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        linkState: LinkPopoverState;
+      }>
+    ) => {
+      const { docId, linkState } = action.payload;
+
+      state[docId] = linkState;
     },
-    resetLinkPopover: (state, _: PayloadAction) => {
-      return linkPopoverState;
+    updateLinkPopover: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        linkState: LinkPopoverState;
+      }>
+    ) => {
+      const { docId, linkState } = action.payload;
+      const { id } = linkState;
+
+      if (!state[docId].open || state[docId].id !== id) return;
+      state[docId] = linkState;
+    },
+    closeLinkPopover: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      state[docId].open = false;
     },
   },
 });

+ 51 - 6
frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts

@@ -27,40 +27,50 @@ import {
 export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
   const middleIds = [];
   let currentId: string | undefined = startId;
+
   while (currentId && currentId !== endId) {
     const nextId = getNextLineId(document, currentId);
+
     if (nextId && nextId !== endId) {
       middleIds.push(nextId);
     }
+
     currentId = nextId;
   }
+
   return middleIds;
 }
 
 export function getStartAndEndIdsByRange(rangeState: RangeState) {
   const { anchor, focus } = rangeState;
+
   if (!anchor || !focus) return [];
   if (anchor.id === focus.id) return [anchor.id];
   const isForward = anchor.point.y < focus.point.y;
   const startId = isForward ? anchor.id : focus.id;
   const endId = isForward ? focus.id : anchor.id;
+
   return [startId, endId];
 }
 
 export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
   const ids = getStartAndEndIdsByRange(rangeState);
+
   if (ids.length < 2) return;
   const [startId, endId] = ids;
+
   return getMiddleIds(document, startId, endId);
 }
 
 export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
   const { anchor, focus, ranges } = rangeState;
+
   if (!anchor || !focus) return;
 
   const isForward = anchor.point.y < focus.point.y;
   const startId = isForward ? anchor.id : focus.id;
   const startRange = ranges[startId];
+
   if (!startRange) return;
   const offset = insertDelta ? insertDelta.length() : 0;
 
@@ -71,9 +81,9 @@ export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?:
   };
 }
 
-export function getStartAndEndExtentDelta(state: RootState) {
-  const rangeState = state.documentRange;
+export function getStartAndEndExtentDelta(documentState: DocumentState, rangeState: RangeState) {
   const ids = getStartAndEndIdsByRange(rangeState);
+
   if (ids.length === 0) return;
   const startId = ids[0];
   const endId = ids[ids.length - 1];
@@ -81,12 +91,13 @@ export function getStartAndEndExtentDelta(state: RootState) {
   // get start and end delta
   const startRange = ranges[startId];
   const endRange = ranges[endId];
+
   if (!startRange || !endRange) return;
-  const startNode = state.document.nodes[startId];
+  const startNode = documentState.nodes[startId];
   const startNodeDelta = new Delta(startNode.data.delta);
   const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange);
 
-  const endNode = state.document.nodes[endId];
+  const endNode = documentState.nodes[endId];
   const endNodeDelta = new Delta(endNode.data.delta);
   const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange);
 
@@ -104,10 +115,15 @@ export function getMergeEndDeltaToStartActionsByRange(
   insertDelta?: Delta
 ) {
   const actions = [];
-  const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {};
+  const docId = controller.documentId;
+  const documentState = state.document[docId];
+  const rangeState = state.documentRange[docId];
+  const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
+
   if (!startDelta || !endDelta || !endNode || !startNode) return;
   // merge start and end nodes
   const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
+
   actions.push(
     controller.getUpdateAction({
       ...startNode,
@@ -117,13 +133,14 @@ export function getMergeEndDeltaToStartActionsByRange(
     })
   );
   if (endNode.id !== startNode.id) {
-    const children = state.document.children[endNode.children].map((id) => state.document.nodes[id]);
+    const children = documentState.children[endNode.children].map((id) => documentState.nodes[id]);
 
     const moveChildrenActions = getMoveChildrenActions({
       target: startNode,
       children,
       controller,
     });
+
     actions.push(...moveChildrenActions);
     // delete end node
     actions.push(controller.getDeleteAction(endNode));
@@ -146,9 +163,11 @@ export function getMoveChildrenActions({
   // move children
   const config = blockConfig[target.type];
   const targetParentId = config.canAddChild ? target.id : target.parent;
+
   if (!targetParentId) return [];
   const targetPrevId = targetParentId === target.id ? prevId : target.id;
   const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
+
   return moveActions;
 }
 
@@ -164,10 +183,12 @@ export function getInsertEnterNodeFields(sourceNode: NestedBlock) {
   const newNodeType = config.nextLineBlockType;
   const relationShip = config.nextLineRelationShip;
   const defaultData = blockConfig[newNodeType].defaultData;
+
   // if the defaultData property is not defined for the new block type, we throw an error.
   if (!defaultData) {
     throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`);
   }
+
   const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id;
   const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : '';
 
@@ -185,6 +206,7 @@ export function getInsertEnterNodeAction(
   controller: DocumentController
 ) {
   const insertNodeFields = getInsertEnterNodeFields(sourceNode);
+
   if (!insertNodeFields) return;
   const { type, data, parentId, prevId } = insertNodeFields;
   const insertNode = newBlock<any>(type, parentId, {
@@ -200,27 +222,35 @@ export function getInsertEnterNodeAction(
 
 export function findPrevHasDeltaNode(state: DocumentState, id: string) {
   const prevLineId = getPrevLineId(state, id);
+
   if (!prevLineId) return;
   let prevLine = state.nodes[prevLineId];
+
   // Find the prev line that has delta
   while (prevLine && !prevLine.data.delta) {
     const id = getPrevLineId(state, prevLine.id);
+
     if (!id) return;
     prevLine = state.nodes[id];
   }
+
   return prevLine;
 }
 
 export function findNextHasDeltaNode(state: DocumentState, id: string) {
   const nextLineId = getNextLineId(state, id);
+
   if (!nextLineId) return;
   let nextLine = state.nodes[nextLineId];
+
   // Find the next line that has delta
   while (nextLine && !nextLine.data.delta) {
     const id = getNextLineId(state, nextLine.id);
+
     if (!id) return;
     nextLine = state.nodes[id];
   }
+
   return nextLine;
 }
 
@@ -233,11 +263,13 @@ export function isPrintableKeyEvent(event: KeyboardEvent) {
 
 export function getLeftCaretByRange(rangeState: RangeState) {
   const { anchor, ranges, focus } = rangeState;
+
   if (!anchor || !focus) return;
   const isForward = anchor.point.y < focus.point.y;
   const startId = isForward ? anchor.id : focus.id;
 
   const range = ranges[startId];
+
   if (!range) return;
   return {
     id: startId,
@@ -248,11 +280,13 @@ export function getLeftCaretByRange(rangeState: RangeState) {
 
 export function getRightCaretByRange(rangeState: RangeState) {
   const { anchor, focus, ranges, caret } = rangeState;
+
   if (!anchor || !focus) return;
   const isForward = anchor.point.y < focus.point.y;
   const endId = isForward ? focus.id : anchor.id;
 
   const range = ranges[endId];
+
   if (!range) return;
 
   return {
@@ -268,13 +302,16 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
 
   if (!inTopEdge) {
     const index = transformIndexToPrevLine(delta, caret.index);
+
     return {
       id: caret.id,
       index,
       length: 0,
     };
   }
+
   const prevLine = findPrevHasDeltaNode(document, caret.id);
+
   if (!prevLine) return;
   const relativeIndex = getIndexRelativeEnter(delta, caret.index);
   const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta));
@@ -282,6 +319,7 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
   const newPrevLineIndex = prevLineIndex + relativeIndex;
   const prevLineLength = prevLineText.length;
   const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex;
+
   return {
     id: prevLine.id,
     index,
@@ -292,8 +330,10 @@ export function transformToPrevLineCaret(document: DocumentState, caret: RangeSt
 export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) {
   const delta = new Delta(document.nodes[caret.id].data.delta);
   const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index);
+
   if (!inBottomEdge) {
     const index = transformIndexToNextLine(delta, caret.index);
+
     return {
       id: caret.id,
       index,
@@ -303,6 +343,7 @@ export function transformToNextLineCaret(document: DocumentState, caret: RangeSt
   }
 
   const nextLine = findNextHasDeltaNode(document, caret.id);
+
   if (!nextLine) return;
   const nextLineText = getDeltaText(new Delta(nextLine.data.delta));
   const relativeIndex = getIndexRelativeEnter(delta, caret.index);
@@ -323,15 +364,19 @@ export function getDuplicateActions(
 ) {
   const actions: ControllerAction[] = [];
   const node = document.nodes[id];
+
   if (!node) return;
   // duplicate new node
   const newNode = newBlock<any>(node.type, parentId, {
     ...node.data,
   });
+
   actions.push(controller.getInsertAction(newNode, node.id));
   const children = document.children[node.children];
+
   children.forEach((child) => {
     const duplicateChildActions = getDuplicateActions(child, newNode.id, document, controller);
+
     if (!duplicateChildActions) return;
     actions.push(...duplicateChildActions.actions);
   });

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

@@ -3,7 +3,6 @@ import { getDeltaByRange } from '$app/utils/document/delta';
 import Delta from 'quill-delta';
 import { generateId } from '$app/utils/document/block';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { blockConfig } from '$app/constants/document/config';
 
 export function getCopyData(
   node: NestedBlock,

+ 8 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/menu.ts

@@ -0,0 +1,8 @@
+export function selectOptionByUpDown(isUp: boolean, selected: string | null, options: string[]) {
+  const index = options.findIndex((option) => option === selected);
+  const length = options.length;
+
+  const nextIndex = isUp ? (index - 1 + length) % length : (index + 1) % length;
+
+  return options[nextIndex];
+}

+ 10 - 31
frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts

@@ -194,7 +194,11 @@ export function focusNodeByIndex(node: Element, index: number, length: number) {
   const selection = window.getSelection();
   selection?.removeAllRanges();
   selection?.addRange(range);
-  return true;
+  const focusNode = selection?.focusNode;
+  if (!focusNode) return false;
+
+  const parent = findParent(focusNode as Element, node);
+  return Boolean(parent);
 }
 
 export function getNodeTextBoxByBlockId(blockId: string) {
@@ -225,41 +229,16 @@ export function replaceZeroWidthSpace(text: string) {
   return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
 }
 
-export function findParent(node: Element, parentSelector: string) {
+export function findParent(node: Element, parentSelector: string | Element) {
   let parentNode: Element | null = node;
   while (parentNode) {
-    if (parentNode.matches(parentSelector)) {
+    if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) {
+      return parentNode;
+    }
+    if (parentNode === parentSelector) {
       return parentNode;
     }
     parentNode = parentNode.parentElement;
   }
   return null;
 }
-
-export function getWordIndices(startContainer: Node, startOffset: number) {
-  const textNode = startContainer;
-  const textContent = textNode.textContent || '';
-
-  const wordRegex = /\b\w+\b/g;
-  let match;
-  const wordIndices = [];
-
-  while ((match = wordRegex.exec(textContent)) !== null) {
-    const word = match[0];
-    const wordIndex = match.index;
-    const wordEndIndex = wordIndex + word.length;
-
-    // If the startOffset is greater than the wordIndex and less than the wordEndIndex, then the startOffset is
-    if (startOffset > wordIndex && startOffset <= wordEndIndex) {
-      wordIndices.push({
-        word: word,
-        startIndex: wordIndex,
-        endIndex: wordEndIndex,
-      });
-      break;
-    }
-  }
-
-  // If there are no matches, then the startOffset is greater than the last wordEndIndex
-  return wordIndices;
-}

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

@@ -1,8 +1,8 @@
-import { DeltaTypePB } from "@/services/backend/models/flowy-document2";
-import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from "$app/interfaces/document";
-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 { DeltaTypePB } from '@/services/backend/models/flowy-document2';
+import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
+import { Log } from '../log';
+import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
+import { isEqual } from '$app/utils/tool';
 
 // This is a list of all the possible changes that can happen to document data
 const matchCases = [
@@ -26,7 +26,7 @@ export function matchChange(
     path: string[];
     id: string;
     value: BlockPBValue & string[];
-  },
+  }
 ) {
   const matchCase = matchCases.find((item) => item.match(command, path));
 
@@ -106,7 +106,9 @@ function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: B
 function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
   const block = blockChangeValue2Node(blockValue);
   const node = state.nodes[blockId];
+
   if (!node) return;
+
   if (isEqual(node, block)) return;
   state.nodes[blockId] = block;
   return;
@@ -122,6 +124,7 @@ function onMatchChildrenInsert(state: DocumentState, id: string, children: strin
 
 function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) {
   const children = state.children[id];
+
   if (!children) return;
   state.children[id] = newChildren;
 }
@@ -144,6 +147,7 @@ export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
       delta: [],
     },
   };
+
   if ('data' in value && typeof value.data === 'string') {
     try {
       Object.assign(block, {
@@ -159,11 +163,13 @@ export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
 
 export function parseValue(value: string) {
   let valueJson;
+
   try {
     valueJson = JSON.parse(value);
   } catch {
     Log.error('[onDataChange] json parse error', value);
     return;
   }
+
   return valueJson;
 }

+ 58 - 10
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts

@@ -4,7 +4,13 @@ import { DocumentData } from '../interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { useAppDispatch } from '../stores/store';
 import { Log } from '../utils/log';
-import { documentActions } from '../stores/reducers/document/slice';
+import {
+  documentActions,
+  linkPopoverActions,
+  rangeActions,
+  rectSelectionActions,
+  slashCommandActions,
+} from '$app/stores/reducers/document/slice';
 import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2';
 
 export const useDocument = () => {
@@ -14,20 +20,60 @@ export const useDocument = () => {
   const [controller, setController] = useState<DocumentController | null>(null);
   const dispatch = useAppDispatch();
 
-  const onDocumentChange = useCallback((props: { isRemote: boolean; data: BlockEventPayloadPB }) => {
-    dispatch(documentActions.onDataChange(props));
-  }, []);
+  const onDocumentChange = useCallback(
+    (props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => {
+      dispatch(documentActions.onDataChange(props));
+    },
+    [dispatch]
+  );
+
+  const initializeDocument = useCallback(
+    (docId: string) => {
+      Log.debug('initialize document', docId);
+      dispatch(documentActions.initialState(docId));
+      dispatch(rangeActions.initialState(docId));
+      dispatch(rectSelectionActions.initialState(docId));
+      dispatch(slashCommandActions.initialState(docId));
+      dispatch(linkPopoverActions.initialState(docId));
+    },
+    [dispatch]
+  );
+
+  const clearDocument = useCallback(
+    (docId: string) => {
+      Log.debug('clear document', docId);
+      dispatch(documentActions.clear(docId));
+      dispatch(rangeActions.clear(docId));
+      dispatch(rectSelectionActions.clear(docId));
+      dispatch(slashCommandActions.clear(docId));
+      dispatch(linkPopoverActions.clear(docId));
+    },
+    [dispatch]
+  );
 
   useEffect(() => {
     let documentController: DocumentController | null = null;
+
     void (async () => {
       if (!params?.id) return;
-      Log.debug('open document', params.id);
       documentController = new DocumentController(params.id, onDocumentChange);
+      const docId = documentController.documentId;
+
+      Log.debug('open document', params.id);
+
+      initializeDocument(documentController.documentId);
+
       setController(documentController);
       try {
         const res = await documentController.open();
+
         if (!res) return;
+        dispatch(
+          documentActions.create({
+            ...res,
+            docId,
+          })
+        );
         setDocumentData(res);
         setDocumentId(params.id);
       } catch (e) {
@@ -35,15 +81,17 @@ export const useDocument = () => {
       }
     })();
 
-    const closeDocument = () => {
+    return () => {
       if (documentController) {
-        void documentController.dispose();
+        void (async () => {
+          await documentController.dispose();
+          clearDocument(documentController.documentId);
+        })();
       }
+
       Log.debug('close document', params.id);
     };
-
-    return closeDocument;
-  }, [params.id]);
+  }, [clearDocument, dispatch, initializeDocument, onDocumentChange, params.id]);
 
   return { documentId, documentData, controller };
 };

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx

@@ -6,7 +6,7 @@ import { DocumentControllerContext } from '../stores/effects/document/document_c
 const muiTheme = createTheme({
   typography: {
     fontFamily: ['Poppins'].join(','),
-    fontSize: 14,
+    fontSize: 12,
   },
   palette: {
     primary: {

+ 5 - 5
frontend/rust-lib/flowy-core/src/deps_resolve/folder2_deps.rs

@@ -105,7 +105,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
               let json_str = include_str!("../../assets/read_me.json");
               let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap();
               manager
-                .create_document(view.parent_view.id.clone(), Some(document_pb.into()))
+                .create_document(&view.parent_view.id, Some(document_pb.into()))
                 .unwrap();
               view
             })
@@ -143,7 +143,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
     let manager = self.0.clone();
     let view_id = view_id.to_string();
     FutureResult::new(async move {
-      let document = manager.get_document_from_disk(view_id)?;
+      let document = manager.get_document_from_disk(&view_id)?;
       let data: DocumentDataPB = document.lock().get_document()?.into();
       let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?;
       Ok(data_bytes)
@@ -164,7 +164,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
     let manager = self.0.clone();
     FutureResult::new(async move {
       let data = DocumentDataPB::try_from(Bytes::from(data))?;
-      manager.create_document(view_id, Some(data.into()))?;
+      manager.create_document(&view_id, Some(data.into()))?;
       Ok(())
     })
   }
@@ -181,7 +181,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
     let view_id = view_id.to_string();
     let manager = self.0.clone();
     FutureResult::new(async move {
-      manager.create_document(view_id, None)?;
+      manager.create_document(&view_id, None)?;
       Ok(())
     })
   }
@@ -197,7 +197,7 @@ impl FolderOperationHandler for DocumentFolderOperation {
     let manager = self.0.clone();
     FutureResult::new(async move {
       let data = DocumentDataPB::try_from(Bytes::from(bytes))?;
-      manager.create_document(view_id, Some(data.into()))?;
+      manager.create_document(&view_id, Some(data.into()))?;
       Ok(())
     })
   }

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

@@ -35,7 +35,7 @@ pub(crate) async fn create_document_handler(
   manager: AFPluginState<Arc<DocumentManager>>,
 ) -> FlowyResult<()> {
   let params: CreateDocumentParams = data.into_inner().try_into()?;
-  manager.create_document(params.document_id, params.initial_data)?;
+  manager.create_document(&params.document_id, params.initial_data)?;
   Ok(())
 }
 
@@ -46,7 +46,7 @@ pub(crate) async fn open_document_handler(
 ) -> DataResult<DocumentDataPB, FlowyError> {
   let params: OpenDocumentParams = data.into_inner().try_into()?;
   let doc_id = params.document_id;
-  let document = manager.get_or_open_document(doc_id)?;
+  let document = manager.get_or_open_document(&doc_id)?;
   let document_data = document.lock().get_document()?;
   data_result_ok(DocumentDataPB::from(document_data))
 }
@@ -69,7 +69,7 @@ pub(crate) async fn get_document_data_handler(
 ) -> DataResult<DocumentDataPB, FlowyError> {
   let params: OpenDocumentParams = data.into_inner().try_into()?;
   let doc_id = params.document_id;
-  let document = manager.get_document_from_disk(doc_id)?;
+  let document = manager.get_document_from_disk(&doc_id)?;
   let document_data = document.lock().get_document()?;
   data_result_ok(DocumentDataPB::from(document_data))
 }
@@ -81,7 +81,7 @@ pub(crate) async fn apply_action_handler(
 ) -> FlowyResult<()> {
   let params: ApplyActionParams = data.into_inner().try_into()?;
   let doc_id = params.document_id;
-  let document = manager.get_or_open_document(doc_id)?;
+  let document = manager.get_or_open_document(&doc_id)?;
   let actions = params.actions;
   document.lock().apply_action(actions);
   Ok(())
@@ -117,7 +117,7 @@ pub(crate) async fn redo_handler(
 ) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
   let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
   let doc_id = params.document_id;
-  let document = manager.get_or_open_document(doc_id)?;
+  let document = manager.get_or_open_document(&doc_id)?;
   let document = document.lock();
   let redo = document.redo();
   let can_redo = document.can_redo();
@@ -135,7 +135,7 @@ pub(crate) async fn undo_handler(
 ) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
   let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
   let doc_id = params.document_id;
-  let document = manager.get_or_open_document(doc_id)?;
+  let document = manager.get_or_open_document(&doc_id)?;
   let document = document.lock();
   let undo = document.undo();
   let can_redo = document.can_redo();
@@ -153,7 +153,7 @@ pub(crate) async fn can_undo_redo_handler(
 ) -> DataResult<DocumentRedoUndoResponsePB, FlowyError> {
   let params: DocumentRedoUndoParams = data.into_inner().try_into()?;
   let doc_id = params.document_id;
-  let document = manager.get_or_open_document(doc_id)?;
+  let document = manager.get_or_open_document(&doc_id)?;
   let document = document.lock();
   let can_redo = document.can_redo();
   let can_undo = document.can_undo();

+ 25 - 14
frontend/rust-lib/flowy-document2/src/manager.rs

@@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Arc};
 use appflowy_integrate::collab_builder::AppFlowyCollabBuilder;
 use appflowy_integrate::RocksCollabDB;
 use collab_document::blocks::DocumentData;
+use collab_document::error::DocumentError;
 use collab_document::YrsDocAction;
 use parking_lot::RwLock;
 
@@ -42,13 +43,13 @@ impl DocumentManager {
   /// if the data is None, will create a document with default data.
   pub fn create_document(
     &self,
-    doc_id: String,
+    doc_id: &str,
     data: Option<DocumentData>,
   ) -> FlowyResult<Arc<Document>> {
-    tracing::debug!("create a document: {:?}", &doc_id);
+    tracing::debug!("create a document: {:?}", doc_id);
     let uid = self.user.user_id()?;
     let db = self.user.collab_db()?;
-    let collab = self.collab_builder.build(uid, &doc_id, "document", db);
+    let collab = self.collab_builder.build(uid, doc_id, "document", db);
     let data = data.unwrap_or_else(default_document_data);
     let document = Arc::new(Document::create_with_data(collab, data)?);
     Ok(document)
@@ -56,22 +57,34 @@ impl DocumentManager {
 
   /// get document
   /// read the existing document from the map if it exists, otherwise read it from the disk and write it to the map.
-  pub fn get_or_open_document(&self, doc_id: String) -> FlowyResult<Arc<Document>> {
-    if let Some(doc) = self.documents.read().get(&doc_id) {
+  pub fn get_or_open_document(&self, doc_id: &str) -> FlowyResult<Arc<Document>> {
+    if let Some(doc) = self.documents.read().get(doc_id) {
       return Ok(doc.clone());
     }
-    tracing::debug!("open_document: {:?}", &doc_id);
+    tracing::debug!("open_document: {:?}", doc_id);
     // read the existing document from the disk.
-    let document = self.get_document_from_disk(doc_id.clone())?;
+    let document = self.get_document_from_disk(&doc_id)?;
     // save the document to the memory and read it from the memory if we open the same document again.
     // and we don't want to subscribe to the document changes if we open the same document again.
     self
       .documents
       .write()
-      .insert(doc_id.clone(), document.clone());
+      .insert(doc_id.to_string(), document.clone());
 
     // subscribe to the document changes.
-    document.lock().open(move |events, is_remote| {
+    self.subscribe_document_changes(document.clone(), doc_id)?;
+
+    Ok(document)
+  }
+
+  pub fn subscribe_document_changes(
+    &self,
+    document: Arc<Document>,
+    doc_id: &str,
+  ) -> Result<DocumentData, DocumentError> {
+    let mut document = document.lock();
+    let doc_id = doc_id.to_string();
+    document.open(move |events, is_remote| {
       tracing::trace!(
         "document changed: {:?}, from remote: {}",
         &events,
@@ -81,17 +94,15 @@ impl DocumentManager {
       send_notification(&doc_id, DocumentNotification::DidReceiveUpdate)
         .payload::<DocEventPB>((events, is_remote).into())
         .send();
-    })?;
-
-    Ok(document)
+    })
   }
 
   /// get document
   /// read the existing document from the disk.
-  pub fn get_document_from_disk(&self, doc_id: String) -> FlowyResult<Arc<Document>> {
+  pub fn get_document_from_disk(&self, doc_id: &str) -> FlowyResult<Arc<Document>> {
     let uid = self.user.user_id()?;
     let db = self.user.collab_db()?;
-    let collab = self.collab_builder.build(uid, &doc_id, "document", db);
+    let collab = self.collab_builder.build(uid, doc_id, "document", db);
     // read the existing document from the disk.
     let document = Arc::new(Document::new(collab)?);
     Ok(document)

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

@@ -15,10 +15,10 @@ async fn undo_redo_test() {
   let data = default_document_data();
 
   // create a document
-  _ = manager.create_document(doc_id.clone(), Some(data.clone()));
+  _ = manager.create_document(&doc_id, Some(data.clone()));
 
   // open a document
-  let document = manager.get_or_open_document(doc_id.clone()).unwrap();
+  let document = manager.get_or_open_document(&doc_id).unwrap();
   let document = document.lock();
   let page_block = document.get_block(&data.page_id).unwrap();
   let page_id = page_block.id;

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

@@ -20,14 +20,14 @@ fn restore_document() {
   let doc_id: String = gen_document_id();
   let data = default_document_data();
   let document_a = manager
-    .create_document(doc_id.clone(), Some(data.clone()))
+    .create_document(&doc_id, Some(data.clone()))
     .unwrap();
   let data_a = document_a.lock().get_document().unwrap();
   assert_eq!(data_a, data);
 
   // open a document
   let data_b = manager
-    .get_or_open_document(doc_id.clone())
+    .get_or_open_document(&doc_id)
     .unwrap()
     .lock()
     .get_document()
@@ -37,10 +37,10 @@ fn restore_document() {
   assert_eq!(data_b, data);
 
   // restore
-  _ = manager.create_document(doc_id.clone(), Some(data.clone()));
+  _ = manager.create_document(&doc_id, Some(data.clone()));
   // open a document
   let data_b = manager
-    .get_or_open_document(doc_id.clone())
+    .get_or_open_document(&doc_id)
     .unwrap()
     .lock()
     .get_document()
@@ -60,10 +60,10 @@ fn document_apply_insert_action() {
   let data = default_document_data();
 
   // create a document
-  _ = manager.create_document(doc_id.clone(), Some(data.clone()));
+  _ = manager.create_document(&doc_id, Some(data.clone()));
 
   // open a document
-  let document = manager.get_or_open_document(doc_id.clone()).unwrap();
+  let document = manager.get_or_open_document(&doc_id).unwrap();
   let page_block = document.lock().get_block(&data.page_id).unwrap();
 
   // insert a text block
@@ -91,7 +91,7 @@ fn document_apply_insert_action() {
 
   // re-open the document
   let data_b = manager
-    .get_or_open_document(doc_id.clone())
+    .get_or_open_document(&doc_id)
     .unwrap()
     .lock()
     .get_document()
@@ -111,10 +111,10 @@ fn document_apply_update_page_action() {
   let data = default_document_data();
 
   // create a document
-  _ = manager.create_document(doc_id.clone(), Some(data.clone()));
+  _ = manager.create_document(&doc_id, Some(data.clone()));
 
   // open a document
-  let document = manager.get_or_open_document(doc_id.clone()).unwrap();
+  let document = manager.get_or_open_document(&doc_id).unwrap();
   let page_block = document.lock().get_block(&data.page_id).unwrap();
 
   let mut page_block_clone = page_block;
@@ -138,7 +138,7 @@ fn document_apply_update_page_action() {
   _ = manager.close_document(&doc_id);
 
   // re-open the document
-  let document = manager.get_or_open_document(doc_id).unwrap();
+  let document = manager.get_or_open_document(&doc_id).unwrap();
   let page_block_new = document.lock().get_block(&data.page_id).unwrap();
   assert_eq!(page_block_old, page_block_new);
   assert!(page_block_new.data.contains_key("delta"));
@@ -153,10 +153,10 @@ fn document_apply_update_action() {
   let data = default_document_data();
 
   // create a document
-  _ = manager.create_document(doc_id.clone(), Some(data.clone()));
+  _ = manager.create_document(&doc_id, Some(data.clone()));
 
   // open a document
-  let document = manager.get_or_open_document(doc_id.clone()).unwrap();
+  let document = manager.get_or_open_document(&doc_id).unwrap();
   let page_block = document.lock().get_block(&data.page_id).unwrap();
 
   // insert a text block
@@ -206,7 +206,7 @@ fn document_apply_update_action() {
   _ = manager.close_document(&doc_id);
 
   // re-open the document
-  let document = manager.get_or_open_document(doc_id.clone()).unwrap();
+  let document = manager.get_or_open_document(&doc_id).unwrap();
   let block = document.lock().get_block(&text_block_id).unwrap();
   assert_eq!(block.data, updated_text_block_data);
   // close a document

+ 2 - 2
frontend/rust-lib/flowy-document2/tests/document/util.rs

@@ -66,10 +66,10 @@ pub fn create_and_open_empty_document() -> (DocumentManager, Arc<Document>, Stri
 
   // create a document
   _ = manager
-    .create_document(doc_id.clone(), Some(data.clone()))
+    .create_document(&doc_id, Some(data.clone()))
     .unwrap();
 
-  let document = manager.get_or_open_document(doc_id).unwrap();
+  let document = manager.get_or_open_document(&doc_id).unwrap();
 
   (manager, document, data.page_id)
 }