Pārlūkot izejas kodu

fix: rectangular selection (#2480)

Kilu.He 2 gadi atpakaļ
vecāks
revīzija
37345578c1
27 mainītis faili ar 547 papildinājumiem un 406 dzēšanām
  1. 6 0
      frontend/appflowy_tauri/.eslintrc.cjs
  2. 0 1
      frontend/appflowy_tauri/package.json
  3. 0 20
      frontend/appflowy_tauri/pnpm-lock.yaml
  4. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts
  5. 46 46
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx
  6. 76 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts
  7. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  8. 2 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts
  9. 2 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx
  10. 1 23
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/Node.hooks.ts
  11. 3 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
  12. 7 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  13. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx
  14. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  15. 139 114
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts
  16. 12 4
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  17. 3 3
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
  18. 4 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts
  19. 4 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts
  20. 9 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts
  21. 27 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts
  22. 56 81
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  23. 2 2
      frontend/appflowy_tauri/src/appflowy_app/stores/store.ts
  24. 43 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts
  25. 6 12
      frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts
  26. 76 68
      frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts
  27. 16 0
      frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts

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

@@ -51,6 +51,12 @@ module.exports = {
     'no-void': 'off',
     'prefer-const': 'warn',
     'prefer-spread': 'off',
+    '@typescript-eslint/no-unused-vars': [
+      'warn',
+      {
+        argsIgnorePattern: '^_',
+      }
+    ],
   },
   ignorePatterns: ['src/**/*.test.ts'],
 };

+ 0 - 1
frontend/appflowy_tauri/package.json

@@ -22,7 +22,6 @@
     "@mui/icons-material": "^5.11.11",
     "@mui/material": "^5.11.12",
     "@reduxjs/toolkit": "^1.9.2",
-    "@slate-yjs/core": "^0.3.1",
     "@tanstack/react-virtual": "3.0.0-beta.54",
     "@tauri-apps/api": "^1.2.0",
     "dayjs": "^1.11.7",

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

@@ -22,9 +22,6 @@ dependencies:
   '@reduxjs/toolkit':
     specifier: ^1.9.2
     version: 1.9.3([email protected])([email protected])
-  '@slate-yjs/core':
-    specifier: ^0.3.1
-    version: 0.3.1([email protected])([email protected])
   '@tanstack/react-virtual':
     specifier: 3.0.0-beta.54
     version: 3.0.0-beta.54([email protected])
@@ -1412,17 +1409,6 @@ packages:
       '@sinonjs/commons': 2.0.0
     dev: false
 
-  /@slate-yjs/[email protected]([email protected])([email protected]):
-    resolution: {integrity: sha512-8nvS9m5FhMNONgydAfzwDCUhuoWbgzx5Bvw1/foSe+JO331UOT1xAKbUX5FzGCOunUcbRjMPXSdNyiPc0dodJg==}
-    peerDependencies:
-      slate: '>=0.70.0'
-      yjs: ^13.5.29
-    dependencies:
-      slate: 0.91.4
-      y-protocols: 1.0.5
-      yjs: 13.5.51
-    dev: false
-
   /@tanstack/[email protected]([email protected]):
     resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
     peerDependencies:
@@ -5133,12 +5119,6 @@ packages:
       yjs: 13.5.51
     dev: false
 
-  /[email protected]:
-    resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==}
-    dependencies:
-      lib0: 0.2.73
-    dev: false
-
   /[email protected]:
     resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
     engines: {node: '>=10'}

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

@@ -1,4 +1,4 @@
-import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
+import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice";
 import { useAppDispatch } from '@/appflowy_app/stores/store';
 import { useRef, useState, useEffect } from 'react';
 
@@ -12,7 +12,7 @@ export function useBlockMenu(nodeId: string, open: boolean) {
       return;
     }
     // set selection when open
-    dispatch(documentActions.setSelectionById(nodeId));
+    dispatch(rectSelectionActions.setSelectionById(nodeId));
     // get node rect
     const rect = document.querySelector(`[data-block-id="${nodeId}"]`)?.getBoundingClientRect();
     if (!rect) return;
@@ -21,7 +21,7 @@ export function useBlockMenu(nodeId: string, open: boolean) {
       top: rect.top + 'px',
       left: rect.left + 'px',
     });
-  }, [open, nodeId]);
+  }, [open, nodeId, dispatch]);
 
   return {
     ref,

+ 46 - 46
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx

@@ -1,6 +1,8 @@
-import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useAppDispatch } from '$app/stores/store';
-import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
+import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice";
+import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks';
+import { setRectSelectionThunk } from "$app_reducers/document/async-actions/rect_selection";
 
 export function useBlockSelection({
   container,
@@ -13,12 +15,13 @@ export function useBlockSelection({
   const disaptch = useAppDispatch();
 
   const [isDragging, setDragging] = useState(false);
-  const pointRef = useRef<number[]>([]);
-  const startScrollTopRef = useRef<number>(0);
+  const startPointRef = useRef<number[]>([]);
+
+  const { getIntersectedBlockIds } = useNodesRect(container);
 
   useEffect(() => {
     onDragging?.(isDragging);
-  }, [isDragging]);
+  }, [isDragging, onDragging]);
 
   const [rect, setRect] = useState<{
     startX: number;
@@ -40,7 +43,7 @@ export function useBlockSelection({
       width: width + 'px',
       height: height + 'px',
     };
-  }, [rect]);
+  }, [container.scrollLeft, container.scrollTop, rect]);
 
   const isPointInBlock = useCallback((target: HTMLElement | null) => {
     let node = target;
@@ -53,48 +56,45 @@ export function useBlockSelection({
     return false;
   }, []);
 
-  const handleDragStart = useCallback((e: MouseEvent) => {
-    if (isPointInBlock(e.target as HTMLElement)) {
-      return;
-    }
-    e.preventDefault();
-    setDragging(true);
-
-    const startX = e.clientX + container.scrollLeft;
-    const startY = e.clientY + container.scrollTop;
-    pointRef.current = [startX, startY];
-    startScrollTopRef.current = container.scrollTop;
-    setRect({
-      startX,
-      startY,
-      endX: startX,
-      endY: startY,
-    });
-  }, []);
+  const handleDragStart = useCallback(
+    (e: MouseEvent) => {
+      if (isPointInBlock(e.target as HTMLElement)) {
+        return;
+      }
+      e.preventDefault();
+      setDragging(true);
+
+      const startX = e.clientX + container.scrollLeft;
+      const startY = e.clientY + container.scrollTop;
+      startPointRef.current = [startX, startY];
+      setRect({
+        startX,
+        startY,
+        endX: startX,
+        endY: startY,
+      });
+    },
+    [container.scrollLeft, container.scrollTop, isPointInBlock]
+  );
 
   const updateSelctionsByPoint = useCallback(
     (clientX: number, clientY: number) => {
       if (!isDragging) return;
-      const [startX, startY] = pointRef.current;
+      const [startX, startY] = startPointRef.current;
       const endX = clientX + container.scrollLeft;
       const endY = clientY + container.scrollTop;
 
-      setRect({
+      const newRect = {
         startX,
         startY,
         endX,
         endY,
-      });
-      disaptch(
-        documentActions.setSelectionByRect({
-          startX: Math.min(startX, endX),
-          startY: Math.min(startY, endY),
-          endX: Math.max(startX, endX),
-          endY: Math.max(startY, endY),
-        })
-      );
+      };
+      const blockIds = getIntersectedBlockIds(newRect);
+      setRect(newRect);
+      disaptch(setRectSelectionThunk(blockIds));
     },
-    [isDragging]
+    [container.scrollLeft, container.scrollTop, disaptch, getIntersectedBlockIds, isDragging]
   );
 
   const handleDraging = useCallback(
@@ -113,13 +113,13 @@ export function useBlockSelection({
         container.scrollBy(0, delta);
       }
     },
-    [isDragging]
+    [container, isDragging, updateSelctionsByPoint]
   );
 
   const handleDragEnd = useCallback(
     (e: MouseEvent) => {
       if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
-        disaptch(documentActions.updateSelections([]));
+        disaptch(rectSelectionActions.updateSelections([]));
         return;
       }
       if (!isDragging) return;
@@ -128,21 +128,21 @@ export function useBlockSelection({
       setDragging(false);
       setRect(null);
     },
-    [isDragging]
+    [disaptch, isDragging, isPointInBlock, updateSelctionsByPoint]
   );
 
   useEffect(() => {
     if (!ref.current) return;
-    container.addEventListener('mousedown', handleDragStart);
-    container.addEventListener('mousemove', handleDraging);
-    container.addEventListener('mouseup', handleDragEnd);
+    document.addEventListener('mousedown', handleDragStart);
+    document.addEventListener('mousemove', handleDraging);
+    document.addEventListener('mouseup', handleDragEnd);
 
     return () => {
-      container.removeEventListener('mousedown', handleDragStart);
-      container.removeEventListener('mousemove', handleDraging);
-      container.removeEventListener('mouseup', handleDragEnd);
+      document.removeEventListener('mousedown', handleDragStart);
+      document.removeEventListener('mousemove', handleDraging);
+      document.removeEventListener('mouseup', handleDragEnd);
     };
-  }, [handleDragStart, handleDragEnd, handleDraging, container]);
+  }, [handleDragStart, handleDragEnd, handleDraging]);
 
   return {
     isDragging,

+ 76 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts

@@ -0,0 +1,76 @@
+import { useCallback, useContext, useEffect, useMemo } from 'react';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useAppSelector } from '$app/stores/store';
+import { RegionGrid } from '$app/utils/region_grid';
+
+export function useNodesRect(container: HTMLDivElement) {
+  const controller = useContext(DocumentControllerContext);
+
+  const data = useAppSelector((state) => {
+    return state.document;
+  });
+
+  const regionGrid = useMemo(() => {
+    if (!controller) return null;
+    return new RegionGrid(300);
+  }, [controller]);
+
+  const updateNodeRect = useCallback(
+    (node: Element) => {
+      const { x, y, width, height } = node.getBoundingClientRect();
+      const id = node.getAttribute('data-block-id');
+      if (!id) return;
+      const rect = {
+        id,
+        x: x + container.scrollLeft,
+        y: y + container.scrollTop,
+        width,
+        height,
+      };
+      regionGrid?.updateBlock(rect);
+    },
+    [container.scrollLeft, container.scrollTop, regionGrid]
+  );
+
+  const updateViewPortNodesRect = useCallback(() => {
+    const nodes = container.querySelectorAll('[data-block-id]');
+    nodes.forEach(updateNodeRect);
+  }, [container, updateNodeRect]);
+
+  // update nodes rect when data changed
+  useEffect(() => {
+    updateViewPortNodesRect();
+  }, [data, updateViewPortNodesRect]);
+
+  // update nodes rect when scroll
+  useEffect(() => {
+    container.addEventListener('scroll', updateViewPortNodesRect);
+    return () => {
+      container.removeEventListener('scroll', updateViewPortNodesRect);
+    };
+  }, [container, updateViewPortNodesRect]);
+
+  const getIntersectedBlockIds = useCallback(
+    (rect: { startX: number; startY: number; endX: number; endY: number }) => {
+      if (!regionGrid) return [];
+      const { startX, startY, endX, endY } = rect;
+      const x = Math.min(startX, endX);
+      const y = Math.min(startY, endY);
+      const width = Math.abs(endX - startX);
+      const height = Math.abs(endY - startY);
+      return regionGrid
+        .getIntersectingBlocks({
+          x,
+          y,
+          width,
+          height,
+        })
+        .map((block) => block.id);
+    },
+    [regionGrid]
+  );
+
+  return {
+    getIntersectedBlockIds,
+  };
+}

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

@@ -24,6 +24,7 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
           onMouseDown={(e) => {
             // prevent toolbar from taking focus away from editor
             e.preventDefault();
+            e.stopPropagation();
           }}
         >
           <IconButton onClick={() => handleToggleMenu(true)} sx={sx}>

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

@@ -14,7 +14,7 @@ export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {
   const id = node.id;
   const dispatch = useAppDispatch();
   const controller = useContext(DocumentControllerContext);
-  const { editor, onChange, value, onDOMBeforeInput } = useTextInput(id);
+  const { editor, ...rest } = useTextInput(id);
   const defaultTextInputEvents = useDefaultTextInputEvents(id);
 
   const customEvents = useMemo(() => {
@@ -81,8 +81,6 @@ export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {
   return {
     editor,
     onKeyDown,
-    onChange,
-    value,
-    onDOMBeforeInput,
+    ...rest
   };
 }

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

@@ -12,7 +12,7 @@ export default function CodeBlock({
   placeholder,
   ...props
 }: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) {
-  const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useCodeBlock(node);
+  const { editor, value, onChange, ...rest } = useCodeBlock(node);
 
   const className = props.className ? ` ${props.className}` : '';
   const id = node.id;
@@ -24,11 +24,9 @@ export default function CodeBlock({
       </div>
       <Slate editor={editor} onChange={onChange} value={value}>
         <BlockHorizontalToolbar id={id} />
-
         <Editable
-          onKeyDown={onKeyDown}
+          {...rest}
           decorate={(entry) => decorateCodeFunc(entry, language)}
-          onDOMBeforeInput={onDOMBeforeInput}
           renderLeaf={CodeLeaf}
           renderElement={CodeBlockElement}
           placeholder={placeholder || 'Please enter some text...'}

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

@@ -1,32 +1,10 @@
-import { useEffect, useRef } from 'react';
+import { useRef } from 'react';
 import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
-import { useAppDispatch } from '$app/stores/store';
-import { documentActions } from '$app/stores/reducers/document/slice';
 
 export function useNode(id: string) {
   const { node, childIds, isSelected } = useSubscribeNode(id);
   const ref = useRef<HTMLDivElement>(null);
 
-  const dispatch = useAppDispatch();
-
-  useEffect(() => {
-    if (!ref.current) return;
-    const rect = ref.current.getBoundingClientRect();
-
-    const scrollContainer = document.querySelector('.doc-scroller-container') as HTMLDivElement;
-    dispatch(
-      documentActions.updateNodePosition({
-        id,
-        rect: {
-          x: rect.x,
-          y: rect.y + scrollContainer.scrollTop,
-          height: rect.height,
-          width: rect.width,
-        },
-      })
-    );
-  }, []);
-
   return {
     ref,
     node,

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

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

+ 7 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx

@@ -16,7 +16,12 @@ function TextBlock({
   childIds?: string[];
   placeholder?: string;
 } & React.HTMLAttributes<HTMLDivElement>) {
-  const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useTextBlock(node.id);
+  const {
+    editor,
+    value,
+    onChange,
+    ...rest
+  } = useTextBlock(node.id);
   const className = props.className !== undefined ? ` ${props.className}` : '';
 
   return (
@@ -25,8 +30,7 @@ function TextBlock({
         <Slate editor={editor} onChange={onChange} value={value}>
           <BlockHorizontalToolbar id={node.id} />
           <Editable
-            onKeyDown={onKeyDown}
-            onDOMBeforeInput={onDOMBeforeInput}
+            {...rest}
             renderLeaf={(leafProps) => <Leaf {...leafProps} />}
             placeholder={placeholder || 'Please enter some text...'}
           />

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

@@ -31,6 +31,7 @@ export default function VirtualizedList({
         >
           {node && childIds && virtualItems.length ? (
             <div
+              className={'doc-body-inner'}
               style={{
                 position: 'absolute',
                 top: 0,

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

@@ -19,7 +19,7 @@ export function useSubscribeNode(id: string) {
   });
 
   const isSelected = useAppSelector<boolean>((state) => {
-    return state.document.selections?.includes(id) || false;
+    return state.rectSelection.selections?.includes(id) || false;
   });
 
   // Memoize the node and its children
@@ -27,7 +27,7 @@ export function useSubscribeNode(id: string) {
   // It very important for performance
   const memoizedNode = useMemo(
     () => node,
-    [node?.id, JSON.stringify(node?.data), node?.parent, node?.type, node?.children]
+    [JSON.stringify(node)]
   );
   const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
 

+ 139 - 114
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts

@@ -1,7 +1,5 @@
-import { createEditor, Descendant, Transforms } from 'slate';
+import { createEditor, Descendant, Transforms, Element, Text, Editor } from 'slate';
 import { ReactEditor, withReact } from 'slate-react';
-import * as Y from 'yjs';
-import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
 import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
@@ -9,15 +7,16 @@ import { TextDelta, TextSelection } from '$app/interfaces/document';
 import { NodeContext } from '../SubscribeNode.hooks';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
-import { deltaToSlateValue } from '$app/utils/document/blocks/common';
-import { documentActions } from '$app_reducers/document/slice';
-
-import { isSameDelta } from '$app/utils/document/blocks/text/delta';
+import { deltaToSlateValue, getCollapsedRange, slateValueToDelta } from "$app/utils/document/blocks/common";
+import { rangeSelectionActions } from "$app_reducers/document/slice";
+import { getNodeEndSelection, isSameDelta } from '$app/utils/document/blocks/text/delta';
 
 export function useTextInput(id: string) {
-  const dispatch = useAppDispatch();
+  const [editor] = useState(() => withReact(createEditor()));
   const node = useContext(NodeContext);
-  const selectionRef = useRef<TextSelection | null>(null);
+  const { sendDelta } = useController(id);
+  const { storeSelection } = useSelection(id, editor);
+  const isComposition = useRef(false);
 
   const delta = useMemo(() => {
     if (!node || !('delta' in node.data)) {
@@ -25,61 +24,39 @@ export function useTextInput(id: string) {
     }
     return node.data.delta;
   }, [node]);
+  const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
 
-  const { editor, yText } = useBindYjs(id, delta);
-
-  const [value, setValue] = useState<Descendant[]>([]);
-
-  const storeSelection = useCallback(() => {
-    if (!ReactEditor.isFocused(editor)) {
-      selectionRef.current = null;
-      return;
-    }
-
-    const selection = editor.selection as TextSelection;
-    if (selectionRef.current && JSON.stringify(selection) !== JSON.stringify(selectionRef.current)) {
-      Transforms.select(editor, selectionRef.current);
-      selectionRef.current = null;
-    }
+  // Update the editor's value when the node's delta changes.
+  useEffect(() => {
+    // If composition is in progress, do nothing.
+    if (isComposition.current) return;
 
-    dispatch(documentActions.setTextSelection({ blockId: id, selection }));
-  }, [dispatch, editor, id]);
+    // If the delta is the same as the editor's value, do nothing.
+    const localDelta = slateValueToDelta(editor.children);
+    const isSame = isSameDelta(delta, localDelta);
+    if (isSame) return;
 
-  const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
-  const restoreSelection = useCallback(() => {
-    if (!currentSelection) return;
-    if (ReactEditor.isFocused(editor)) {
-      Transforms.select(editor, currentSelection);
-    } else {
-      selectionRef.current = currentSelection;
-      Transforms.select(editor, currentSelection);
-      ReactEditor.focus(editor);
-    }
-  }, [currentSelection, editor]);
+    const slateValue = deltaToSlateValue(delta);
+    editor.children = slateValue;
+    setValue(slateValue);
+  }, [delta, editor]);
 
+  // Update the node's delta when the editor's value changes.
   const onChange = useCallback(
     (e: Descendant[]) => {
+      // Update the editor's value and selection.
       setValue(e);
       storeSelection();
-    },
-    [storeSelection]
-  );
 
-  useEffect(() => {
-    restoreSelection();
-    return () => {
-      dispatch(documentActions.removeTextSelection(id));
-    };
-  }, [dispatch, id, restoreSelection]);
+      // If composition is in progress, do nothing.
+      if (isComposition.current) return;
 
-  if (editor.selection && ReactEditor.isFocused(editor)) {
-    const domSelection = window.getSelection();
-    // this is a hack to fix the issue where the selection is not in the dom
-    if (domSelection?.rangeCount === 0) {
-      const range = ReactEditor.toDOMRange(editor, editor.selection);
-      domSelection.addRange(range);
-    }
-  }
+      // Update the node's delta
+      const textDelta = slateValueToDelta(e);
+      void sendDelta(textDelta);
+    },
+    [sendDelta, storeSelection]
+  );
 
   const onDOMBeforeInput = useCallback((e: InputEvent) => {
     // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
@@ -90,73 +67,28 @@ export function useTextInput(id: string) {
     }
   }, []);
 
+  const onCompositionStart = useCallback(() => {
+    isComposition.current = true;
+  }, []);
+
+  const onCompositionUpdate = useCallback(() => {
+    isComposition.current = true;
+  }, []);
+
+  const onCompositionEnd = useCallback(() => {
+    isComposition.current = false;
+  }, []);
+
   return {
     editor,
-    yText,
     onChange,
     value,
     onDOMBeforeInput,
+    onCompositionStart,
+    onCompositionUpdate,
+    onCompositionEnd,
   };
 }
-function useBindYjs(id: string, delta: TextDelta[]) {
-  const { sendDelta } = useController(id);
-  const yTextRef = useRef<Y.XmlText>();
-
-  // Create a yjs document and get the shared type
-  const sharedType = useMemo(() => {
-    const doc = new Y.Doc();
-    const _sharedType = doc.get('content', Y.XmlText) as Y.XmlText;
-
-    const insertDelta = slateNodesToInsertDelta(deltaToSlateValue(delta));
-    // Load the initial value into the yjs document
-    _sharedType.applyDelta(insertDelta);
-
-    const yText = insertDelta[0].insert as Y.XmlText;
-    yTextRef.current = yText;
-
-    return _sharedType;
-    // Here we only want to create the sharedType once
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []);
-
-  const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
-
-  useEffect(() => {
-    YjsEditor.connect(editor);
-    return () => {
-      yTextRef.current = undefined;
-      YjsEditor.disconnect(editor);
-    };
-  }, [editor]);
-
-  useEffect(() => {
-    const yText = yTextRef.current;
-    if (!yText) return;
-    const textEventHandler = (event: Y.YTextEvent) => {
-      const textDelta = event.target.toDelta();
-      void sendDelta(textDelta);
-    };
-
-    yText.observe(textEventHandler);
-    return () => {
-      yText.unobserve(textEventHandler);
-    };
-  }, [sendDelta]);
-
-  useEffect(() => {
-    const yText = yTextRef.current;
-    if (!yText) return;
-
-    // If the delta is not equal to the current yText, then we need to update the yText
-    const isSame = isSameDelta(delta, yText.toDelta());
-    if (isSame) return;
-
-    yText.delete(0, yText.length);
-    yText.applyDelta(delta);
-  }, [delta, editor]);
-
-  return { editor, yText: yTextRef.current };
-}
 
 function useController(id: string) {
   const docController = useContext(DocumentControllerContext);
@@ -180,3 +112,96 @@ function useController(id: string) {
     sendDelta,
   };
 }
+
+function useSelection(id: string, editor: ReactEditor) {
+  const dispatch = useAppDispatch();
+  const selectionRef = useRef<TextSelection | null>(null);
+  const currentSelection = useAppSelector((state) => {
+    const range = state.rangeSelection;
+    if (!range.anchor || !range.focus) return null;
+    if (range.anchor.id === id) {
+      return range.anchor.selection;
+    }
+    if (range.focus.id === id) {
+      return range.focus.selection;
+    }
+    return null;
+  });
+
+  // whether the selection is out of range.
+  const outOfRange = useCallback(
+    (selection: TextSelection) => {
+      const point = Editor.end(editor, selection);
+      const { path, offset } = point;
+      // path length is 2, because the editor is a single text node.
+      const [i, j] = path;
+      const children = editor.children[i] as Element;
+      if (!children) return true;
+      const child = children.children[j] as Text;
+      return child.text.length < offset;
+    },
+    [editor]
+  );
+
+  // store the selection
+  const storeSelection = useCallback(() => {
+    // do nothing if the node is not focused.
+    if (!ReactEditor.isFocused(editor)) {
+      selectionRef.current = null;
+      return;
+    }
+    // set selection to the end of the node if the selection is out of range.
+    if (outOfRange(editor.selection as TextSelection)) {
+      editor.selection = getNodeEndSelection(slateValueToDelta(editor.children));
+      selectionRef.current = null;
+    }
+
+    let selection = editor.selection as TextSelection;
+    // the selection will sometimes be cleared after the editor is focused.
+    // so we need to restore the selection when selection ref is not null.
+    if (selectionRef.current && JSON.stringify(editor.selection) !== JSON.stringify(selectionRef.current)) {
+      Transforms.select(editor, selectionRef.current);
+      selection = selectionRef.current;
+    }
+    selectionRef.current = null;
+    const range = getCollapsedRange(id, selection);
+    dispatch(rangeSelectionActions.setRange(range));
+  }, [dispatch, editor, id, outOfRange]);
+
+
+  // restore the selection
+  const restoreSelection = useCallback((selection: TextSelection | null) => {
+    if (!selection) return;
+    // do nothing if the selection is out of range
+    if (outOfRange(selection)) return;
+
+    if (ReactEditor.isFocused(editor)) {
+      // if the editor is focused, set the selection directly.
+      if (JSON.stringify(selection) === JSON.stringify(editor.selection)) return;
+      Transforms.select(editor, selection);
+    } else {
+      // Here we store the selection in the ref,
+      // because the selection will sometimes be cleared after the editor is focused.
+      selectionRef.current = selection;
+      Transforms.select(editor, selection);
+      ReactEditor.focus(editor);
+    }
+  }, [editor, outOfRange]);
+
+  useEffect(() => {
+    restoreSelection(currentSelection);
+  }, [restoreSelection, currentSelection]);
+
+  if (editor.selection && ReactEditor.isFocused(editor)) {
+    const domSelection = window.getSelection();
+    // this is a hack to fix the issue where the selection is not in the dom
+    if (domSelection?.rangeCount === 0) {
+      const range = ReactEditor.toDOMRange(editor, editor.selection);
+      domSelection.addRange(range);
+    }
+  }
+
+  return {
+    storeSelection,
+  };
+}

+ 12 - 4
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -1,4 +1,5 @@
 import { Editor } from 'slate';
+import { RegionGrid } from '$app/utils/region_grid';
 
 export enum BlockType {
   PageBlock = 'page',
@@ -127,10 +128,17 @@ export interface DocumentState {
   nodes: Record<string, Node>;
   // map of block id to children block ids
   children: Record<string, string[]>;
-  // selected block ids
-  selections: string[];
-  // map of block id to text selection
-  textSelections: Record<string, TextSelection>;
+}
+
+export interface RangeSelectionState {
+  anchor?: PointState,
+  focus?: PointState,
+}
+
+
+export interface PointState {
+  id: string,
+  selection: TextSelection
 }
 
 export enum ChangeType {

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

@@ -24,11 +24,11 @@ export class DocumentController {
   private readonly observer: DocumentObserver;
 
   constructor(
-    public readonly viewId: string,
+    public readonly documentId: string,
     private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void
   ) {
-    this.backendService = new DocumentBackendService(viewId);
-    this.observer = new DocumentObserver(viewId);
+    this.backendService = new DocumentBackendService(documentId);
+    this.observer = new DocumentObserver(documentId);
   }
 
   create = async (): Promise<FlowyError | void> => {

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

@@ -1,9 +1,8 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentState } from '$app/interfaces/document';
-import { getPrevLineId } from '$app/utils/document/blocks/common';
-import { setCursorAfterThunk } from '$app_reducers/document/async-actions';
-import { documentActions } from '$app_reducers/document/slice';
+import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common";
+import { documentActions, rangeSelectionActions } from "$app_reducers/document/slice";
 import { blockConfig } from '$app/constants/document/config';
 import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
 
@@ -80,6 +79,7 @@ export const mergeToPrevLineThunk = createAsyncThunk(
     await controller.applyActions(actions);
 
     // set cursor after the prev line
-    dispatch(documentActions.setTextSelection({ blockId: prevLine.id, selection }));
+    const range = getCollapsedRange(prevLine.id, selection);
+    dispatch(rangeSelectionActions.setRange(range));
   }
 );

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

@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions } from '$app_reducers/document/slice';
 import { debounce } from '$app/utils/tool';
+import { isSameDelta } from '$app/utils/document/blocks/text/delta';
 export const updateNodeDeltaThunk = createAsyncThunk(
   'document/updateNodeDelta',
   async (payload: { id: string; delta: TextDelta[]; controller: DocumentController }, thunkAPI) => {
@@ -10,6 +11,8 @@ export const updateNodeDeltaThunk = createAsyncThunk(
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
+    const isSame = isSameDelta(delta, node.data.delta);
+    if (isSame) return;
     // The block map should be updated immediately
     // or the component will use the old data to update the editor
     dispatch(documentActions.updateNodeData({ id, data: { delta } }));
@@ -34,7 +37,7 @@ const debounceApplyUpdate = debounce((controller: DocumentController, updateNode
       },
     }),
   ]);
-}, 200);
+}, 500);
 
 export const updateNodeDataThunk = createAsyncThunk<
   void,

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

@@ -1,5 +1,5 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { documentActions } from '../slice';
+import { rangeSelectionActions } from "../slice";
 import { DocumentState, TextSelection } from '$app/interfaces/document';
 import { Editor } from 'slate';
 import {
@@ -10,7 +10,7 @@ import {
   getNodeEndSelection,
   getStartLineSelectionByOffset,
 } from '$app/utils/document/blocks/text/delta';
-import { getNextLineId, getPrevLineId } from '$app/utils/document/blocks/common';
+import { getCollapsedRange, getNextLineId, getPrevLineId } from "$app/utils/document/blocks/common";
 
 export const setCursorBeforeThunk = createAsyncThunk(
   'document/setCursorBefore',
@@ -18,7 +18,9 @@ export const setCursorBeforeThunk = createAsyncThunk(
     const { id } = payload;
     const { dispatch } = thunkAPI;
     const selection = getNodeBeginSelection();
-    dispatch(documentActions.setTextSelection({ blockId: id, selection }));
+
+    const range = getCollapsedRange(id, selection);
+    dispatch(rangeSelectionActions.setRange(range));
   }
 );
 
@@ -30,7 +32,8 @@ export const setCursorAfterThunk = createAsyncThunk(
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
     const selection = getNodeEndSelection(node.data.delta);
-    dispatch(documentActions.setTextSelection({ blockId: node.id, selection }));
+    const range = getCollapsedRange(id, selection);
+    dispatch(rangeSelectionActions.setRange(range));
   }
 );
 
@@ -64,7 +67,7 @@ export const setCursorPreLineThunk = createAsyncThunk(
 
     // set the cursor to prev line with the relative offset
     const newSelection = getEndLineSelectionByOffset(prevLineNode.data.delta, textOffset);
-    dispatch(documentActions.setTextSelection({ blockId: prevLineNode.id, selection: newSelection }));
+    dispatch(rangeSelectionActions.setRange(getCollapsedRange(prevLineNode.id, newSelection)));
   }
 );
 
@@ -100,6 +103,6 @@ export const setCursorNextLineThunk = createAsyncThunk(
     // set the cursor to next line with the relative offset
     const newSelection = getStartLineSelectionByOffset(delta, textOffset);
 
-    dispatch(documentActions.setTextSelection({ blockId: nextLineNode.id, selection: newSelection }));
+    dispatch(rangeSelectionActions.setRange(getCollapsedRange(nextLineNode.id, newSelection)));
   }
 );

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

@@ -0,0 +1,27 @@
+import { createAsyncThunk } from "@reduxjs/toolkit";
+import { getNextNodeId, getPrevNodeId } from "$app/utils/document/blocks/common";
+import { DocumentState } from "$app/interfaces/document";
+import { rectSelectionActions } from "$app_reducers/document/slice";
+
+export const setRectSelectionThunk = createAsyncThunk(
+  'document/setRectSelection',
+  async (payload: string[], thunkAPI) => {
+    const { getState, dispatch } = thunkAPI;
+    const documentState = (getState() as { document: DocumentState }).document;
+    const selected: Record<string, boolean> = {};
+    payload.forEach((id) => {
+      const node = documentState.nodes[id];
+      if (!node.parent) {
+        return;
+      }
+      selected[id] = selected[id] === undefined ? true : selected[id];
+      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))) {
+        selected[node.parent] = true;
+      }
+    });
+    dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id])))
+  }
+);

+ 56 - 81
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -1,18 +1,23 @@
-import { DocumentState, Node, TextSelection } from '@/appflowy_app/interfaces/document';
+import { DocumentState, Node, RangeSelectionState } from '@/appflowy_app/interfaces/document';
 import { BlockEventPayloadPB } from '@/services/backend';
-import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-import { RegionGrid } from '@/appflowy_app/utils/region_grid';
+import { combineReducers, createSlice, PayloadAction } from "@reduxjs/toolkit";
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
-
-const regionGrid = new RegionGrid(50);
+import blockSelection from "$app/components/document/BlockSelection";
+import { databaseSlice } from "$app_reducers/database/slice";
 
 const initialState: DocumentState = {
   nodes: {},
   children: {},
+};
+
+const rectSelectionInitialState: {
+  selections: string[];
+} = {
   selections: [],
-  textSelections: {},
 };
 
+const rangeSelectionInitialState: RangeSelectionState = {};
+
 export const documentSlice = createSlice({
   name: 'document',
   initialState: initialState,
@@ -35,81 +40,6 @@ export const documentSlice = createSlice({
       state.children = children;
     },
 
-    // update block selections
-    updateSelections: (state, action: PayloadAction<string[]>) => {
-      state.selections = action.payload;
-    },
-
-    // set block selected
-    setSelectionById: (state, action: PayloadAction<string>) => {
-      const id = action.payload;
-      state.selections = [id];
-    },
-
-    // set block selected by selection rect
-    setSelectionByRect: (
-      state,
-      action: PayloadAction<{
-        startX: number;
-        startY: number;
-        endX: number;
-        endY: number;
-      }>
-    ) => {
-      const { startX, startY, endX, endY } = action.payload;
-      const blocks = regionGrid.getIntersectBlocks(startX, startY, endX, endY);
-      state.selections = blocks.map((block) => block.id);
-    },
-
-    // update block position
-    updateNodePosition: (
-      state,
-      action: PayloadAction<{
-        id: string;
-        rect: {
-          x: number;
-          y: number;
-          width: number;
-          height: number;
-        };
-      }>
-    ) => {
-      const { id, rect } = action.payload;
-      const position = {
-        id,
-        ...rect,
-      };
-      regionGrid.updateBlock(id, position);
-    },
-
-    // update text selections
-    setTextSelection: (
-      state,
-      action: PayloadAction<{
-        blockId: string;
-        selection?: TextSelection;
-      }>
-    ) => {
-      const { blockId, selection } = action.payload;
-      const node = state.nodes[blockId];
-      const oldSelection = state.textSelections[blockId];
-      if (JSON.stringify(oldSelection) === JSON.stringify(selection)) return;
-      if (!node || !selection) {
-        delete state.textSelections[blockId];
-      } else {
-        state.textSelections = {
-          [blockId]: selection,
-        };
-      }
-    },
-
-    // remove text selections
-    removeTextSelection: (state, action: PayloadAction<string>) => {
-      const id = action.payload;
-      if (!state.textSelections[id]) return;
-      state.textSelections;
-    },
-
     // We need this action to update the local state before `onDataChange` to make the UI more smooth,
     // because we often use `debounce` to send the change to db, so the db data will be updated later.
     updateNodeData: (state, action: PayloadAction<{ id: string; data: Record<string, any> }>) => {
@@ -145,4 +75,49 @@ export const documentSlice = createSlice({
   },
 });
 
+export const rectSelectionSlice = createSlice({
+  name: 'rectSelection',
+  initialState: rectSelectionInitialState,
+  reducers: {
+    // update block selections
+    updateSelections: (state, action: PayloadAction<string[]>) => {
+      state.selections = action.payload;
+    },
+
+    // set block selected
+    setSelectionById: (state, action: PayloadAction<string>) => {
+      const id = action.payload;
+      state.selections = [id];
+    },
+  }
+});
+
+
+export const rangeSelectionSlice = createSlice({
+  name: 'rangeSelection',
+  initialState: rangeSelectionInitialState,
+  reducers: {
+    setRange: (
+      state,
+      action: PayloadAction<RangeSelectionState>
+    ) => {
+      state.anchor = action.payload.anchor;
+      state.focus = action.payload.focus;
+    },
+
+    clearRange: (state, _: PayloadAction) => {
+      state.anchor = undefined;
+      state.focus = undefined;
+    },
+  }
+});
+
+export const documentReducers = {
+  [documentSlice.name]: documentSlice.reducer,
+  [rectSelectionSlice.name]: rectSelectionSlice.reducer,
+  [rangeSelectionSlice.name]: rangeSelectionSlice.reducer,
+};
+
 export const documentActions = documentSlice.actions;
+export const rectSelectionActions = rectSelectionSlice.actions;
+export const rangeSelectionActions = rangeSelectionSlice.actions;

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/store.ts

@@ -14,7 +14,7 @@ import { currentUserSlice } from './reducers/current-user/slice';
 import { gridSlice } from './reducers/grid/slice';
 import { workspaceSlice } from './reducers/workspace/slice';
 import { databaseSlice } from './reducers/database/slice';
-import { documentSlice } from './reducers/document/slice';
+import { documentReducers } from './reducers/document/slice';
 import { boardSlice } from './reducers/board/slice';
 import { errorSlice } from './reducers/error/slice';
 import { activePageIdSlice } from '$app_reducers/active-page-id/slice';
@@ -33,9 +33,9 @@ const store = configureStore({
     [gridSlice.name]: gridSlice.reducer,
     [databaseSlice.name]: databaseSlice.reducer,
     [boardSlice.name]: boardSlice.reducer,
-    [documentSlice.name]: documentSlice.reducer,
     [workspaceSlice.name]: workspaceSlice.reducer,
     [errorSlice.name]: errorSlice.reducer,
+    ...documentReducers,
   },
   middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware),
 });

+ 43 - 1
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts

@@ -1,8 +1,29 @@
-import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document';
+import {
+  BlockData,
+  BlockType,
+  DocumentState,
+  NestedBlock,
+  RangeSelectionState,
+  TextDelta,
+  TextSelection
+} from "$app/interfaces/document";
 import { Descendant, Element, Text } from 'slate';
 import { BlockPB } from '@/services/backend';
 import { Log } from '$app/utils/log';
 import { nanoid } from 'nanoid';
+import { clone } from "$app/utils/tool";
+
+export function slateValueToDelta(slateNodes: Descendant[]) {
+  const element = slateNodes[0] as Element;
+  const children = element.children as Text[];
+  return children.map((child) => {
+    const { text, ...attributes } = child;
+    return {
+      insert: text,
+      attributes,
+    };
+  });
+}
 
 export function deltaToSlateValue(delta: TextDelta[]) {
   const slateNode = {
@@ -101,6 +122,16 @@ export function getNextNodeId(state: DocumentState, id: string) {
   return nextNodeId;
 }
 
+export function getPrevNodeId(state: DocumentState, id: string) {
+  const node = state.nodes[id];
+  if (!node.parent) return;
+  const parent = state.nodes[node.parent];
+  const children = state.children[parent.children];
+  const index = children.indexOf(id);
+  const prevNodeId = children[index - 1];
+  return prevNodeId;
+}
+
 export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
   return {
     id: generateId(),
@@ -110,3 +141,14 @@ export function newBlock<Type>(type: BlockType, parentId: string, data: BlockDat
     data,
   };
 }
+
+export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState {
+  const point = {
+    id,
+    selection
+  };
+  return {
+    anchor: clone(point),
+    focus: clone(point),
+  }
+}

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

@@ -1,7 +1,7 @@
-import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
-import { BlockType, NestedBlock, DocumentState, ChangeType, BlockPBValue } from '../../interfaces/document';
-import { Log } from '../log';
-import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '../../constants/document/block';
+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";
 
 // This is a list of all the possible changes that can happen to document data
 const matchCases = [
@@ -100,8 +100,7 @@ function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) {
 }
 
 function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) {
-  const block = blockChangeValue2Node(blockValue);
-  state.nodes[blockId] = block;
+  state.nodes[blockId] = blockChangeValue2Node(blockValue);
 }
 
 function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue, isRemote?: boolean) {
@@ -125,12 +124,7 @@ function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: B
   return;
 }
 
-function onMatchBlockDelete(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) {
-  const index = state.selections.indexOf(blockId);
-  if (index > -1) {
-    state.selections.splice(index, 1);
-  }
-  delete state.textSelections[blockId];
+function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue, _isRemote?: boolean) {
   delete state.nodes[blockId];
 }
 

+ 76 - 68
frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts

@@ -5,92 +5,100 @@ export interface BlockPosition {
   height: number;
   width: number;
 }
-interface BlockRegion {
-  regionX: number;
-  regionY: number;
-  blocks: BlockPosition[];
+
+interface Rectangle {
+  x: number;
+  y: number;
+  height: number;
+  width: number;
 }
 
 export class RegionGrid {
-  private regions: BlockRegion[][];
-  private regionSize: number;
-  private blocks = new Map();
+  private readonly gridSize: number;
+  private readonly grid: Map<string, BlockPosition[]>;
+  private readonly blockKeysMap: Map<string, string[]>;
 
-  constructor(regionSize: number) {
-    this.regionSize = regionSize;
-    this.regions = [];
+  constructor(gridSize: number) {
+    this.gridSize = gridSize;
+    this.grid = new Map();
+    this.blockKeysMap = new Map();
   }
 
-  addBlock(blockPosition: BlockPosition) {
-    const regionX = Math.floor(blockPosition.x / this.regionSize);
-    const regionY = Math.floor(blockPosition.y / this.regionSize);
-
-    let region = this.regions[regionY]?.[regionX];
-    if (!region) {
-      region = {
-        regionX,
-        regionY,
-        blocks: []
-      };
-      if (!this.regions[regionY]) {
-        this.regions[regionY] = [];
+  private getKeys(x: number, y: number, width: number, height: number): string[] {
+    const keys: string[] = [];
+
+    for (let i = Math.floor(x / this.gridSize); i <= Math.floor((x + width) / this.gridSize); i++) {
+      for (let j = Math.floor(y / this.gridSize); j <= Math.floor((y + height) / this.gridSize); j++) {
+        keys.push(`${i},${j}`);
       }
-      this.regions[regionY][regionX] = region;
     }
-    this.blocks.set(blockPosition.id, blockPosition);
-    region.blocks.push(blockPosition);
+
+    return keys;
   }
 
-  updateBlock(blockId: string, position: BlockPosition) {
-    const prevPosition = this.blocks.get(blockId);
-    if (prevPosition && prevPosition.x === position.x &&
-      prevPosition.y === position.y &&
-      prevPosition.height === position.height &&
-      prevPosition.width === position.width) {
-      return;
+  addBlock(block: BlockPosition): void {
+    const keys = this.getKeys(block.x, block.y, block.width, block.height);
+
+    this.blockKeysMap.set(block.id, keys);
+
+    for (const key of keys) {
+      if (!this.grid.has(key)) {
+        this.grid.set(key, []);
+      }
+
+      this.grid.get(key)!.push(block);
     }
-    this.blocks.set(blockId, position);
-    this.removeBlock(blockId);
-    this.addBlock(position);
   }
-  
-  removeBlock(blockId: string) {
-    for (const rows of this.regions.filter(r => r)) {
-      for (const region of rows) {
-        if (!region) return;
-        const blockIndex = region.blocks.findIndex(b => b.id === blockId);
-        if (blockIndex !== -1) {
-          region.blocks.splice(blockIndex, 1);
-          return;
-        }
-      }
+
+  hasBlock(id: string) {
+    return this.blockKeysMap.has(id);
+  }
+
+  updateBlock(block: BlockPosition): void {
+    if (this.hasBlock(block.id)) {
+      this.removeBlock(block);
     }
-    this.blocks.delete(blockId);
+    this.addBlock(block);
   }
-  
-
-  getIntersectBlocks(startX: number, startY: number, endX: number, endY: number): BlockPosition[] {
-    const selectedBlocks: BlockPosition[] = [];
-
-    const startRegionX = Math.floor(startX / this.regionSize);
-    const startRegionY = Math.floor(startY / this.regionSize);
-    const endRegionX = Math.floor(endX / this.regionSize);
-    const endRegionY = Math.floor(endY / this.regionSize);
-
-    for (let y = startRegionY; y <= endRegionY; y++) {
-      for (let x = startRegionX; x <= endRegionX; x++) {
-        const region = this.regions[y]?.[x];
-        if (region) {
-          for (const block of region.blocks) {
-            if (block.x + block.width - 1 >= startX && block.x <= endX &&
-              block.y + block.height - 1 >= startY && block.y <= endY) {
-              selectedBlocks.push(block);
-            }
+
+  removeBlock(block: BlockPosition): void {
+    const keys = this.blockKeysMap.get(block.id) || [];
+
+    for (const key of keys) {
+      const blocks = this.grid.get(key);
+
+      if (blocks) {
+        const index = blocks.findIndex((b) => b.id === block.id);
+
+        if (index !== -1) {
+          blocks.splice(index, 1);
+
+          if (blocks.length === 0) {
+            this.grid.delete(key);
           }
         }
       }
     }
+  }
+
+  getIntersectingBlocks(rect: Rectangle): BlockPosition[] {
+    const blocks = new Set<BlockPosition>();
+    const keys = this.getKeys(rect.x, rect.y, rect.width, rect.height);
+
+    for (const key of keys) {
+      if (this.grid.has(key)) {
+        this.grid.get(key)?.forEach((block) => {
+          if (
+            rect.x < block.x + block.width &&
+            rect.x + rect.width > block.x &&
+            rect.y < block.y + block.height &&
+            rect.y + rect.height > block.y
+          )
+            blocks.add(block);
+        });
+      }
+    }
 
-    return selectedBlocks;
+    return Array.from(blocks);
   }
 }

+ 16 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts

@@ -82,3 +82,19 @@ export function isEqual<T>(value1: T, value2: T): boolean {
   }
   return true;
 }
+
+export function clone<T>(value: T): T {
+  if (typeof value !== 'object' || value === null) {
+    return value;
+  }
+
+  if (Array.isArray(value)) {
+    return value.map((item) => clone(item)) as any;
+  }
+
+  const result: any = {};
+  for (const key in value) {
+    result[key] = clone(value[key]);
+  }
+  return result;
+}