Sfoglia il codice sorgente

Support range selection and refactor data update mechanism and optimize outdent/indent operations (#2514)

* refactor: simplify data update logic and optimize outdent/indent operations

* feat: support range selection

* fix: review suggestions
Kilu.He 2 anni fa
parent
commit
99c48f7100
30 ha cambiato i file con 639 aggiunte e 378 eliminazioni
  1. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts
  2. 19 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts
  3. 46 50
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx
  4. 2 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  5. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts
  6. 3 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx
  7. 7 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  8. 17 8
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx
  9. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
  10. 3 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts
  11. 8 13
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  12. 59 11
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  13. 11 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts
  14. 68 133
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts
  15. 113 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts
  16. 3 1
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  17. 21 11
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts
  18. 1 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts
  19. 45 7
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts
  20. 4 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts
  21. 13 30
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts
  22. 3 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts
  23. 12 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts
  24. 86 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts
  25. 26 42
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  26. 30 5
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts
  27. 13 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts
  28. 10 22
      frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts
  29. 6 2
      frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts
  30. 5 0
      frontend/appflowy_tauri/src/styles/template.css

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts

@@ -24,6 +24,7 @@ export function useHoveringToolbar(id: string) {
       el.style.left = position.left;
     }
   });
+
   return {
     ref,
     inFocus,

+ 19 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts

@@ -1,4 +1,4 @@
-import { useCallback, useContext, useEffect, useMemo } from 'react';
+import { useCallback, useContext, useEffect, useMemo, useState } from "react";
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { useAppSelector } from '$app/stores/store';
 import { RegionGrid } from '$app/utils/region_grid';
@@ -6,9 +6,7 @@ import { RegionGrid } from '$app/utils/region_grid';
 export function useNodesRect(container: HTMLDivElement) {
   const controller = useContext(DocumentControllerContext);
 
-  const data = useAppSelector((state) => {
-    return state.document;
-  });
+  const version = useVersionUpdate();
 
   const regionGrid = useMemo(() => {
     if (!controller) return null;
@@ -40,7 +38,7 @@ export function useNodesRect(container: HTMLDivElement) {
   // update nodes rect when data changed
   useEffect(() => {
     updateViewPortNodesRect();
-  }, [data, updateViewPortNodesRect]);
+  }, [version, updateViewPortNodesRect]);
 
   // update nodes rect when scroll
   useEffect(() => {
@@ -74,3 +72,19 @@ export function useNodesRect(container: HTMLDivElement) {
     getIntersectedBlockIds,
   };
 }
+
+function useVersionUpdate() {
+  const [version, setVersion] = useState(0);
+  const data = useAppSelector((state) => {
+    return state.document;
+  });
+
+  useEffect(() => {
+    setVersion((v) => {
+      if (v < Number.MAX_VALUE) return v + 1;
+      return 0;
+    });
+  }, [data]);
+
+  return version;
+}

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

@@ -1,57 +1,50 @@
-import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document';
-import { useAppSelector } from '@/appflowy_app/stores/store';
-import { debounce } from '@/appflowy_app/utils/tool';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { BlockType, HeadingBlockData, NestedBlock } from "@/appflowy_app/interfaces/document";
+import { useAppDispatch } from "@/appflowy_app/stores/store";
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { getBlockByIdThunk } from "$app_reducers/document/async-actions";
 
+const headingBlockTopOffset: Record<number, number> = {
+  1: 7,
+  2: 6,
+  3: 3,
+};
 export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
-  const [nodeId, setHoverNodeId] = useState<string>('');
+  const [nodeId, setHoverNodeId] = useState<string | null>(null);
   const [menuOpen, setMenuOpen] = useState(false);
   const ref = useRef<HTMLDivElement | null>(null);
-  const nodes = useAppSelector((state) => state.document.nodes);
-  const nodesRef = useRef(nodes);
-
-  const handleMouseMove = useCallback((e: MouseEvent) => {
-    const { clientX, clientY } = e;
-    const x = clientX;
-    const y = clientY;
-    const id = getNodeIdByPoint(x, y);
-    if (!id) {
-      setHoverNodeId('');
-    } else {
-      if ([BlockType.ColumnBlock].includes(nodesRef.current[id].type)) {
-        setHoverNodeId('');
-        return;
-      }
-      setHoverNodeId(id);
-    }
-  }, []);
-
-  const debounceMove = useMemo(() => debounce(handleMouseMove, 30), [handleMouseMove]);
+  const dispatch = useAppDispatch();
+  const [style, setStyle] = useState<React.CSSProperties>({});
 
   useEffect(() => {
     const el = ref.current;
     if (!el || !nodeId) return;
+    void(async () => {
+      const{ payload: node } = await dispatch(getBlockByIdThunk(nodeId)) as {
+        payload: NestedBlock;
+      };
+      if (!node) {
+        setStyle({
+          opacity: '0',
+          pointerEvents: 'none',
+        });
+        return;
+      } else {
+        let top = 1;
 
-    const node = nodesRef.current[nodeId];
-    if (!node) {
-      el.style.opacity = '0';
-      el.style.pointerEvents = 'none';
-    } else {
-      el.style.opacity = '1';
-      el.style.pointerEvents = 'auto';
-      el.style.top = '1px';
-      if (node?.type === BlockType.HeadingBlock) {
-        const nodeData = node.data as HeadingBlockData;
-        if (nodeData.level === 1) {
-          el.style.top = '8px';
-        } else if (nodeData.level === 2) {
-          el.style.top = '6px';
-        } else {
-          el.style.top = '5px';
+        if (node.type === BlockType.HeadingBlock) {
+          const nodeData = node.data as HeadingBlockData;
+          top = headingBlockTopOffset[nodeData.level];
         }
+
+        setStyle({
+          opacity: '1',
+          pointerEvents: 'auto',
+          top: `${top}px`,
+        });
       }
-    }
-  }, [nodeId]);
+    })();
+
+  }, [dispatch, nodeId]);
 
   const handleToggleMenu = useCallback((isOpen: boolean) => {
     setMenuOpen(isOpen);
@@ -60,22 +53,25 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
     }
   }, []);
 
+  const handleMouseMove = useCallback((e: MouseEvent) => {
+    const { clientX, clientY } = e;
+    const id = getNodeIdByPoint(clientX, clientY);
+    setHoverNodeId(id);
+  }, []);
+
   useEffect(() => {
-    container.addEventListener('mousemove', debounceMove);
+    container.addEventListener('mousemove', handleMouseMove);
     return () => {
-      container.removeEventListener('mousemove', debounceMove);
+      container.removeEventListener('mousemove', handleMouseMove);
     };
-  }, [debounceMove]);
-
-  useEffect(() => {
-    nodesRef.current = nodes;
-  }, [nodes]);
+  }, [container, handleMouseMove]);
 
   return {
     nodeId,
     ref,
     handleToggleMenu,
     menuOpen,
+    style
   };
 }
 

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

@@ -9,7 +9,7 @@ import BlockMenu from '../BlockMenu';
 const sx = { height: 24, width: 24 };
 
 export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
-  const { nodeId, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
+  const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
 
   if (!nodeId) return null;
   return (
@@ -19,6 +19,7 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
           ref={ref}
           style={{
             opacity: 0,
+            ...style,
           }}
           className='absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-500'
           onMouseDown={(e) => {

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

@@ -7,7 +7,7 @@ import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
 import { useAppDispatch } from '$app/stores/store';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { splitNodeThunk } from '$app_reducers/document/async-actions';
-import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents';
+import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
 import { indent, outdent } from '$app/utils/document/blocks/code';
 
 export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {

+ 3 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx

@@ -1,16 +1,13 @@
 import React from 'react';
 import { useDocumentTitle } from './DocumentTitle.hooks';
 import TextBlock from '../TextBlock';
-import { NodeContext } from '../_shared/SubscribeNode.hooks';
 
 export default function DocumentTitle({ id }: { id: string }) {
   const { node } = useDocumentTitle(id);
   if (!node) return null;
   return (
-    <NodeContext.Provider value={node}>
-      <div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
-        <TextBlock placeholder='Untitled' childIds={[]} node={node} />
-      </div>
-    </NodeContext.Provider>
+    <div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
+      <TextBlock placeholder='Untitled' childIds={[]} node={node} />
+    </div>
   );
 }

+ 7 - 10
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx

@@ -3,7 +3,6 @@ import { useNode } from './Node.hooks';
 import { withErrorBoundary } from 'react-error-boundary';
 import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent';
 import TextBlock from '../TextBlock';
-import { NodeContext } from '../_shared/SubscribeNode.hooks';
 import { BlockType } from '$app/interfaces/document';
 import { Alert } from '@mui/material';
 
@@ -59,15 +58,13 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
   if (!node) return null;
 
   return (
-    <NodeContext.Provider value={node}>
-      <div {...props} ref={ref} data-block-id={node.id} className={`relative ${props.className}`}>
-        {renderBlock()}
-        <div className='block-overlay' />
-        {isSelected ? (
-          <div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
-        ) : null}
-      </div>
-    </NodeContext.Provider>
+    <div {...props} ref={ref} data-block-id={node.id} className={`relative ${props.className}`}>
+      {renderBlock()}
+      <div className='block-overlay' />
+      {isSelected ? (
+        <div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
+      ) : null}
+    </div>
   );
 }
 

+ 17 - 8
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx

@@ -1,19 +1,20 @@
 import { BaseText } from 'slate';
 import { RenderLeafProps } from 'slate-react';
-
-const Leaf = ({
-  attributes,
-  children,
-  leaf,
-}: RenderLeafProps & {
+interface LeafProps extends RenderLeafProps {
   leaf: BaseText & {
     bold?: boolean;
     code?: boolean;
     italic?: boolean;
     underlined?: boolean;
     strikethrough?: boolean;
+    selectionHighlighted?: boolean;
   };
-}) => {
+}
+const Leaf = ({
+  attributes,
+  children,
+  leaf,
+}: LeafProps) => {
   let newChildren = children;
   if (leaf.bold) {
     newChildren = <strong>{children}</strong>;
@@ -31,8 +32,16 @@ const Leaf = ({
     newChildren = <u>{newChildren}</u>;
   }
 
+  let className = "";
+  if (leaf.strikethrough) {
+    className += "line-through";
+  }
+  if (leaf.selectionHighlighted) {
+    className += " bg-main-secondary";
+  }
+
   return (
-    <span {...attributes} className={leaf.strikethrough ? `line-through` : ''}>
+    <span {...attributes} className={className}>
       {newChildren}
     </span>
   );

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

@@ -2,13 +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, ...rest } =
-    useTextInput(id);
+  const { editor, ...props } = useTextInput(id);
+
   const { onKeyDown } = useTextBlockKeyEvent(id, editor);
 
   return {
     onKeyDown,
     editor,
-    ...rest
+    ...props,
   };
 }

+ 3 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts

@@ -8,9 +8,10 @@ import isHotkey from 'is-hotkey';
 import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { useAppDispatch } from '$app/stores/store';
-import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents';
+import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
+import { ReactEditor } from 'slate-react';
 
-export function useTextBlockKeyEvent(id: string, editor: Editor) {
+export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
   const controller = useContext(DocumentControllerContext);
   const dispatch = useAppDispatch();
 

+ 8 - 13
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx

@@ -2,33 +2,28 @@ import { Slate, Editable } from 'slate-react';
 import Leaf from './Leaf';
 import { useTextBlock } from './TextBlock.hooks';
 import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
-import React from 'react';
-import { BlockType, NestedBlock } from '$app/interfaces/document';
+import React, { useEffect } from 'react';
+import { NestedBlock } from '$app/interfaces/document';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
 
 function TextBlock({
   node,
   childIds,
   placeholder,
-  ...props
+  className = '',
 }: {
   node: NestedBlock;
   childIds?: string[];
   placeholder?: string;
-} & React.HTMLAttributes<HTMLDivElement>) {
-  const {
-    editor,
-    value,
-    onChange,
-    ...rest
-  } = useTextBlock(node.id);
-  const className = props.className !== undefined ? ` ${props.className}` : '';
+  className?: string;
+}) {
+  const { editor, value, onChange, ...rest } = useTextBlock(node.id);
 
   return (
     <>
-      <div {...props} className={`px-1 py-[2px]${className}`}>
+      <div className={`px-1 py-[2px] ${className}`}>
         <Slate editor={editor} onChange={onChange} value={value}>
-          <BlockHorizontalToolbar id={node.id} />
+          {/*<BlockHorizontalToolbar id={node.id} />*/}
           <Editable
             {...rest}
             renderLeaf={(leafProps) => <Leaf {...leafProps} />}

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

@@ -1,12 +1,11 @@
 import { useAppSelector } from '@/appflowy_app/stores/store';
-import { useMemo, createContext } from 'react';
-import { Node } from '$app/interfaces/document';
-export const NodeContext = createContext<Node | null>(null);
+import { useMemo, useRef } from 'react';
+import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
+import { nodeInRange } from '$app/utils/document/blocks/common';
+import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta';
 
 /**
- * Subscribe to a node and its children
- * It will be change when the node or its children is changed
- * And it will not be change when other node is changed
+ * Subscribe node information
  * @param id
  */
 export function useSubscribeNode(id: string) {
@@ -19,16 +18,13 @@ export function useSubscribeNode(id: string) {
   });
 
   const isSelected = useAppSelector<boolean>((state) => {
-    return state.rectSelection.selections?.includes(id) || false;
+    return state.documentRectSelection.includes(id) || false;
   });
 
   // Memoize the node and its children
   // So that the component will not be re-rendered when other node is changed
   // It very important for performance
-  const memoizedNode = useMemo(
-    () => node,
-    [JSON.stringify(node)]
-  );
+  const memoizedNode = useMemo(() => node, [JSON.stringify(node)]);
   const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
 
   return {
@@ -37,3 +33,55 @@ export function useSubscribeNode(id: string) {
     isSelected,
   };
 }
+
+/**
+ * Subscribe selection information
+ * @param id
+ */
+export function useSubscribeRangeSelection(id: string) {
+  const rangeRef = useRef<RangeSelectionState>();
+
+  const currentSelection = useAppSelector((state) => {
+    const range = state.documentRangeSelection;
+    rangeRef.current = range;
+    if (range.anchor?.id === id) {
+      return range.anchor.selection;
+    }
+    if (range.focus?.id === id) {
+      return range.focus.selection;
+    }
+    return getAmendInRangeNodeSelection(id, range, state.document);
+  });
+
+  return {
+    rangeRef,
+    currentSelection,
+  };
+}
+
+function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) {
+  if (!range.anchor || !range.focus || range.anchor.id === range.focus.id) {
+    return null;
+  }
+  const isForward = selectionIsForward(range.anchor.selection);
+  const isNodeInRange = nodeInRange(
+    id,
+    {
+      startId: range.anchor.id,
+      endId: range.focus.id,
+    },
+    isForward,
+    document
+  );
+
+  if (isNodeInRange) {
+    const delta = document.nodes[id].data.delta;
+    return {
+      anchor: {
+        path: [0, 0],
+        offset: 0,
+      },
+      focus: getNodeEndSelection(delta).anchor,
+    };
+  }
+}

+ 11 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/useTextEvents.ts → frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts

@@ -1,7 +1,6 @@
 import { useAppDispatch } from '$app/stores/store';
 import { useCallback, useContext } from 'react';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { Editor } from 'slate';
 import { backspaceNodeThunk, setCursorNextLineThunk, setCursorPreLineThunk } from '$app_reducers/document/async-actions';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
 import {
@@ -12,20 +11,21 @@ import {
   canHandleUpKey,
 } from '$app/utils/document/blocks/text/hotkey';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
+import { ReactEditor } from "slate-react";
 
 export function useDefaultTextInputEvents(id: string) {
   const dispatch = useAppDispatch();
   const controller = useContext(DocumentControllerContext);
 
   const focusPreLineAction = useCallback(
-    async (params: { editor: Editor; focusEnd?: boolean }) => {
+    async (params: { editor: ReactEditor; focusEnd?: boolean }) => {
       await dispatch(setCursorPreLineThunk({ id, ...params }));
     },
     [dispatch, id]
   );
 
   const focusNextLineAction = useCallback(
-    async (params: { editor: Editor; focusStart?: boolean }) => {
+    async (params: { editor: ReactEditor; focusStart?: boolean }) => {
       await dispatch(setCursorNextLineThunk({ id, ...params }));
     },
     [dispatch, id]
@@ -35,6 +35,8 @@ export function useDefaultTextInputEvents(id: string) {
       triggerEventKey: keyBoardEventKeyMap.Up,
       canHandle: canHandleUpKey,
       handler: (...args: TextBlockKeyEventHandlerParams) => {
+        const [e, _] = args;
+        e.preventDefault();
         void focusPreLineAction({
           editor: args[1],
         });
@@ -44,6 +46,8 @@ export function useDefaultTextInputEvents(id: string) {
       triggerEventKey: keyBoardEventKeyMap.Down,
       canHandle: canHandleDownKey,
       handler: (...args: TextBlockKeyEventHandlerParams) => {
+        const [e, _] = args;
+        e.preventDefault();
         void focusNextLineAction({
           editor: args[1],
         });
@@ -53,6 +57,8 @@ export function useDefaultTextInputEvents(id: string) {
       triggerEventKey: keyBoardEventKeyMap.Left,
       canHandle: canHandleLeftKey,
       handler: (...args: TextBlockKeyEventHandlerParams) => {
+        const [e, _] = args;
+        e.preventDefault();
         void focusPreLineAction({
           editor: args[1],
           focusEnd: true,
@@ -63,6 +69,8 @@ export function useDefaultTextInputEvents(id: string) {
       triggerEventKey: keyBoardEventKeyMap.Right,
       canHandle: canHandleRightKey,
       handler: (...args: TextBlockKeyEventHandlerParams) => {
+        const [e, _] = args;
+        e.preventDefault();
         void focusNextLineAction({
           editor: args[1],
           focusStart: true,

+ 68 - 133
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts

@@ -1,22 +1,22 @@
-import { createEditor, Descendant, Transforms, Element, Text, Editor } from 'slate';
-import { ReactEditor, withReact } from 'slate-react';
+import { createEditor, Descendant, Editor } from 'slate';
+import { withReact } from 'slate-react';
 import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { TextDelta, TextSelection } from '$app/interfaces/document';
-import { NodeContext } from '../SubscribeNode.hooks';
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { TextDelta } from '$app/interfaces/document';
+import { useAppDispatch } from '$app/stores/store';
 import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
-import { deltaToSlateValue, 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';
+import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
+import { isSameDelta } from '$app/utils/document/blocks/text/delta';
+import { debounce } from '$app/utils/tool';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
 
 export function useTextInput(id: string) {
+  const { node } = useSubscribeNode(id);
   const [editor] = useState(() => withReact(createEditor()));
-  const node = useContext(NodeContext);
-  const { sendDelta } = useController(id);
-  const { storeSelection } = useSelection(id, editor);
   const isComposition = useRef(false);
+  const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
 
   const delta = useMemo(() => {
     if (!node || !('delta' in node.data)) {
@@ -24,38 +24,30 @@ export function useTextInput(id: string) {
     }
     return node.data.delta;
   }, [node]);
+
+  const { sync, receive } = useUpdateDelta(id, editor);
+
   const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
 
   // Update the editor's value when the node's delta changes.
   useEffect(() => {
     // If composition is in progress, do nothing.
     if (isComposition.current) return;
-
-    // If the delta is the same as the editor's value, do nothing.
-    const localDelta = slateValueToDelta(editor.children);
-    const isSame = isSameDelta(delta, localDelta);
-    if (isSame) return;
-
-    const slateValue = deltaToSlateValue(delta);
-    editor.children = slateValue;
-    setValue(slateValue);
-  }, [delta, editor]);
+    receive(delta);
+  }, [delta, receive]);
 
   // Update the node's delta when the editor's value changes.
   const onChange = useCallback(
     (e: Descendant[]) => {
       // Update the editor's value and selection.
       setValue(e);
-      storeSelection();
-
+      // If the selection is not null, update the last active selection.
+      if (editor.selection !== null) setLastActiveSelection(editor.selection);
       // If composition is in progress, do nothing.
       if (isComposition.current) return;
-
-      // Update the node's delta
-      const textDelta = slateValueToDelta(e);
-      void sendDelta(textDelta);
+      sync();
     },
-    [sendDelta, storeSelection]
+    [editor.selection, setLastActiveSelection, sync]
   );
 
   const onDOMBeforeInput = useCallback((e: InputEvent) => {
@@ -83,6 +75,7 @@ export function useTextInput(id: string) {
     editor,
     onChange,
     value,
+    ...selectionProps,
     onDOMBeforeInput,
     onCompositionStart,
     onCompositionUpdate,
@@ -90,118 +83,60 @@ export function useTextInput(id: string) {
   };
 }
 
-function useController(id: string) {
-  const docController = useContext(DocumentControllerContext);
+function useUpdateDelta(id: string, editor: Editor) {
+  const controller = useContext(DocumentControllerContext);
   const dispatch = useAppDispatch();
-
-  const sendDelta = useCallback(
-    async (delta: TextDelta[]) => {
-      if (!docController) return;
-      await dispatch(
-        updateNodeDeltaThunk({
-          id,
-          delta,
-          controller: docController,
-        })
-      );
-    },
-    [dispatch, docController, id]
-  );
-
-  return {
-    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;
+  const penddingRef = useRef(false);
+
+  // when user input, update the node's delta after 200ms
+  const debounceUpdate = useMemo(() => {
+    return debounce(() => {
+      if (!controller) return;
+      const delta = slateValueToDelta(editor.children);
+      void (async () => {
+        await dispatch(
+          updateNodeDeltaThunk({
+            id,
+            delta,
+            controller,
+          })
+        );
+        // reset pendding flag
+        penddingRef.current = false;
+      })();
+    }, 200);
+  }, [controller, dispatch, editor, id]);
+
+  const sync = useCallback(() => {
+    // set pendding flag
+    penddingRef.current = true;
+    debounceUpdate();
+  }, [debounceUpdate]);
+
+  const receive = useCallback(
+    (delta: TextDelta[]) => {
+      // if pendding, do nothing
+      if (penddingRef.current) return;
+
+      // If the delta is the same as the editor's value, do nothing.
+      const localDelta = slateValueToDelta(editor.children);
+      const isSame = isSameDelta(delta, localDelta);
+      if (isSame) return;
+
+      const slateValue = deltaToSlateValue(delta);
+      editor.children = slateValue;
     },
     [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 () => {
+      debounceUpdate.cancel();
+    };
+  });
 
   return {
-    storeSelection,
+    sync,
+    receive,
   };
 }

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

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

+ 3 - 1
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -1,5 +1,6 @@
 import { Editor } from 'slate';
 import { RegionGrid } from '$app/utils/region_grid';
+import { ReactEditor } from "slate-react";
 
 export enum BlockType {
   PageBlock = 'page',
@@ -131,6 +132,7 @@ export interface DocumentState {
 }
 
 export interface RangeSelectionState {
+  isDragging?: boolean,
   anchor?: PointState,
   focus?: PointState,
 }
@@ -158,4 +160,4 @@ export interface BlockPBValue {
   data: string;
 }
 
-export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, Editor];
+export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor];

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

@@ -2,7 +2,15 @@ import { DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { blockConfig } from '$app/constants/document/config';
+import { getPrevNodeId } from "$app/utils/document/blocks/common";
 
+/**
+ * indent node
+ * 1. if node parent is root, do nothing
+ * 2. if node parent is not root
+ * 2.1. get prev node, if prev node is not allowed to have children, do nothing
+ * 2.2. if prev node is allowed to have children, move node to prev node's last child, and move node's children after node
+ */
 export const indentNodeThunk = createAsyncThunk(
   'document/indentNode',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
@@ -11,21 +19,23 @@ export const indentNodeThunk = createAsyncThunk(
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
     if (!node.parent) return;
-    // get parent
-    const parent = state.nodes[node.parent];
+
     // get prev node
-    const children = state.children[parent.children];
-    const index = children.indexOf(id);
-    if (index === 0) return;
-    const newParentId = children[index - 1];
-    const prevNode = state.nodes[newParentId];
+    const prevNodeId = getPrevNodeId(state, id);
+    if (!prevNodeId) return;
+    const newParentNode = state.nodes[prevNodeId];
     // check if prev node is allowed to have children
-    const config = blockConfig[prevNode.type];
+    const config = blockConfig[newParentNode.type];
     if (!config.canAddChild) return;
+
     // check if prev node has children and get last child for new prev node
-    const prevNodeChildren = state.children[prevNode.children];
-    const newPrevId = prevNodeChildren[prevNodeChildren.length - 1];
+    const newParentChildren = state.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 moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
 
-    await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]);
+    await controller.applyActions([moveAction, ...moveChildrenActions]);
   }
 );

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

@@ -2,7 +2,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentState } from '$app/interfaces/document';
 import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common";
-import { documentActions, rangeSelectionActions } from "$app_reducers/document/slice";
+import { rangeSelectionActions } from "$app_reducers/document/slice";
 import { blockConfig } from '$app/constants/document/config';
 import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
 
@@ -40,8 +40,6 @@ export const mergeToPrevLineThunk = createAsyncThunk(
 
     const mergeDelta = [...prevLineDelta, ...node.data.delta];
 
-    dispatch(documentActions.updateNodeData({ id: prevLine.id, data: { delta: mergeDelta } }));
-
     const updateAction = controller.getUpdateAction({
       ...prevLine,
       data: {
@@ -66,7 +64,6 @@ export const mergeToPrevLineThunk = createAsyncThunk(
       actions.push(deleteAction);
     } else {
       // clear current block delta
-      dispatch(documentActions.updateNodeData({ id: node.id, data: { delta: [] } }));
       const updateAction = controller.getUpdateAction({
         ...node,
         data: {

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

@@ -1,8 +1,17 @@
 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';
 
+/**
+ * outdent node
+ * 1. if node parent is root, do nothing
+ * 2. if node parent is not root, move node to after parent and record next sibling ids
+ * 2.1. if next sibling ids is empty, do nothing
+ * 2.2. if next sibling ids is not empty
+ * 2.2.1. if node can add child, move next sibling ids to node's children
+ * 2.2.2. if node can not add child, move next sibling ids to after node
+ */
 export const outdentNodeThunk = createAsyncThunk(
   'document/outdentNode',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
@@ -10,11 +19,40 @@ export const outdentNodeThunk = createAsyncThunk(
     const { getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
-    const newPrevId = node.parent;
-    if (!newPrevId) return;
-    const parent = state.nodes[newPrevId];
-    const newParentId = parent.parent;
-    if (!newParentId) return;
-    await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]);
+    const parentId = node.parent;
+    if (!parentId) return;
+    const ancestorId = state.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 actions = [];
+    const moveAction = controller.getMoveAction(node, ancestorId, parentId);
+    actions.push(moveAction);
+
+    const config = blockConfig[node.type];
+    if (nextSiblingIds.length > 0) {
+      if (config.canAddChild) {
+        const children = state.children[node.children];
+        let lastChildId: string | null = null;
+        const lastIndex = children.length - 1;
+        if (lastIndex >= 0) {
+          lastChildId = children[lastIndex];
+        }
+        const moveChildrenActions = nextSiblingIds
+          .reverse()
+          .map((id) => controller.getMoveAction(state.nodes[id], node.id, lastChildId));
+        actions.push(...moveChildrenActions);
+      } else {
+        const moveChildrenActions = nextSiblingIds
+          .reverse()
+          .map((id) => controller.getMoveAction(state.nodes[id], ancestorId, node.id));
+        actions.push(...moveChildrenActions);
+      }
+    }
+
+    await controller.applyActions(actions);
   }
 );

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

@@ -1,16 +1,15 @@
-import { DocumentState, TextDelta } from '$app/interfaces/document';
+import { DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { documentActions } from '$app_reducers/document/slice';
 import { setCursorBeforeThunk } from '../../cursor';
 import { newBlock } from '$app/utils/document/blocks/common';
 import { blockConfig, SplitRelationship } from '$app/constants/document/config';
-import { Editor } from 'slate';
 import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
+import { ReactEditor } from "slate-react";
 
 export const splitNodeThunk = createAsyncThunk(
   'document/splitNode',
-  async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
+  async (payload: { id: string; editor: ReactEditor; controller: DocumentController }, thunkAPI) => {
     const { id, controller, editor } = payload;
     // get the split content
     const { retain, insert } = getSplitDelta(editor);
@@ -68,8 +67,7 @@ export const splitNodeThunk = createAsyncThunk(
 
     await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]);
 
-    // update local node data
-    dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } }));
+    ReactEditor.deselect(editor);
     // set cursor
     await dispatch(setCursorBeforeThunk({ id: newNode.id }));
   }

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

@@ -1,44 +1,29 @@
-import { TextDelta, NestedBlock, DocumentState, BlockData } from '$app/interfaces/document';
+import { TextDelta, DocumentState, BlockData } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 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) => {
     const { id, delta, controller } = payload;
-    const { dispatch, getState } = thunkAPI;
+    const { getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
-    const isSame = isSameDelta(delta, node.data.delta);
+    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 } }));
+    const newData = { ...node.data, delta };
 
-    // the transaction is delayed to avoid too many updates
-    debounceApplyUpdate(controller, {
-      ...node,
-      data: {
-        ...node.data,
-        delta,
-      },
-    });
+    await controller.applyActions([
+      controller.getUpdateAction({
+        ...node,
+        data: newData,
+      }),
+    ]);
   }
 );
 
-const debounceApplyUpdate = debounce((controller: DocumentController, updateNode: NestedBlock) => {
-  void controller.applyActions([
-    controller.getUpdateAction({
-      ...updateNode,
-      data: {
-        ...updateNode.data,
-      },
-    }),
-  ]);
-}, 500);
-
 export const updateNodeDataThunk = createAsyncThunk<
   void,
   {
@@ -48,14 +33,12 @@ export const updateNodeDataThunk = createAsyncThunk<
   }
 >('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => {
   const { id, data, controller } = payload;
-  const { dispatch, getState } = thunkAPI;
+  const { getState } = thunkAPI;
   const state = (getState() as { document: DocumentState }).document;
   const node = state.nodes[id];
 
   const newData = { ...node.data, ...data };
 
-  dispatch(documentActions.updateNodeData({ id, data: newData }));
-
   await controller.applyActions([
     controller.getUpdateAction({
       ...node,

+ 3 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts

@@ -11,6 +11,7 @@ import {
   getStartLineSelectionByOffset,
 } from '$app/utils/document/blocks/text/delta';
 import { getCollapsedRange, getNextLineId, getPrevLineId } from "$app/utils/document/blocks/common";
+import { ReactEditor } from "slate-react";
 
 export const setCursorBeforeThunk = createAsyncThunk(
   'document/setCursorBefore',
@@ -39,7 +40,7 @@ export const setCursorAfterThunk = createAsyncThunk(
 
 export const setCursorPreLineThunk = createAsyncThunk(
   'document/setCursorPreLine',
-  async (payload: { id: string; editor: Editor; focusEnd?: boolean }, thunkAPI) => {
+  async (payload: { id: string; editor: ReactEditor; focusEnd?: boolean }, thunkAPI) => {
     const { id, editor, focusEnd } = payload;
     const selection = editor.selection as TextSelection;
     const { dispatch, getState } = thunkAPI;
@@ -73,7 +74,7 @@ export const setCursorPreLineThunk = createAsyncThunk(
 
 export const setCursorNextLineThunk = createAsyncThunk(
   'document/setCursorNextLine',
-  async (payload: { id: string; editor: Editor; focusStart?: boolean }, thunkAPI) => {
+  async (payload: { id: string; editor: ReactEditor; focusStart?: boolean }, thunkAPI) => {
     const { id, editor, focusStart } = payload;
     const selection = editor.selection as TextSelection;
     const { dispatch, getState } = thunkAPI;

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

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

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

@@ -0,0 +1,86 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { DocumentState, RangeSelectionState, TextSelection } from '$app/interfaces/document';
+import { rangeSelectionActions } from '$app_reducers/document/slice';
+import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta';
+import { isEqual } from '$app/utils/tool';
+
+const amendAnchorNodeThunk = createAsyncThunk(
+  'document/amendAnchorNode',
+  async (
+    payload: {
+      id: string;
+    },
+    thunkAPI
+  ) => {
+    const { id } = payload;
+    const { getState, dispatch } = thunkAPI;
+    const nodes = (getState() as { document: DocumentState }).document.nodes;
+    const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
+    const { anchor: anchorNode, isDragging, focus: focusNode } = range;
+
+    if (!isDragging || !anchorNode || anchorNode.id !== id) return;
+    const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
+    if (isCollapsed) return;
+
+    const selection = anchorNode.selection;
+    const isForward = selectionIsForward(selection);
+    const node = nodes[id];
+    const focus = isForward
+      ? getNodeEndSelection(node.data.delta).anchor
+      : {
+          path: [0, 0],
+          offset: 0,
+        };
+    if (isEqual(focus, selection.focus)) return;
+    const newSelection = {
+      anchor: selection.anchor,
+      focus,
+    };
+
+    dispatch(
+      rangeSelectionActions.setRange({
+        anchor: {
+          id,
+          selection: newSelection as TextSelection,
+        },
+      })
+    );
+  }
+);
+
+export const syncRangeSelectionThunk = createAsyncThunk(
+  'document/syncRangeSelection',
+  async (
+    payload: {
+      id: string;
+      selection: TextSelection;
+    },
+    thunkAPI
+  ) => {
+    const { getState, dispatch } = thunkAPI;
+    const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
+
+    const { id, selection } = payload;
+    const updateRange = {
+      focus: {
+        id,
+        selection,
+      },
+    };
+    const isAnchor = range.anchor?.id === id;
+    if (isAnchor) {
+      Object.assign(updateRange, {
+        anchor: {
+          id,
+          selection,
+        },
+      });
+    }
+    dispatch(rangeSelectionActions.setRange(updateRange));
+
+    const anchorId = range.anchor?.id;
+    if (!isAnchor && anchorId) {
+      dispatch(amendAnchorNodeThunk({ id: anchorId }));
+    }
+  }
+);

+ 26 - 42
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -1,26 +1,22 @@
 import { DocumentState, Node, RangeSelectionState } from '@/appflowy_app/interfaces/document';
 import { BlockEventPayloadPB } from '@/services/backend';
-import { combineReducers, createSlice, PayloadAction } from "@reduxjs/toolkit";
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
-import blockSelection from "$app/components/document/BlockSelection";
-import { databaseSlice } from "$app_reducers/database/slice";
 
 const initialState: DocumentState = {
   nodes: {},
   children: {},
 };
 
-const rectSelectionInitialState: {
-  selections: string[];
-} = {
-  selections: [],
-};
+const rectSelectionInitialState: string[] = [];
 
 const rangeSelectionInitialState: RangeSelectionState = {};
 
 export const documentSlice = createSlice({
   name: 'document',
   initialState: initialState,
+  // Here we can't offer actions to update the document state.
+  // Because the document state is updated by the `onDataChange`
   reducers: {
     // initialize the document
     clear: () => {
@@ -40,22 +36,13 @@ export const documentSlice = createSlice({
       state.children = children;
     },
 
-    // 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> }>) => {
-      const { id, data } = action.payload;
-      const node = state.nodes[id];
-      if (!node) return;
-      node.data = {
-        ...node.data,
-        ...data,
-      };
-    },
-
-    // when we use `onDataChange` to handle the change, we don't need care about the change is from which client,
-    // because the data is always from db state, and then to UI.
-    // Except the `updateNodeData` action, we will use it before `onDataChange` to update the local state,
-    // so we should skip update block's `data` field when the change is from local
+    /**
+     This function listens for changes in the data layer triggered by the data API,
+     and updates the UI state accordingly.
+     It enables a unidirectional data flow,
+     where changes in the data layer update the UI layer,
+     but not the other way around.
+     */
     onDataChange: (
       state,
       action: PayloadAction<{
@@ -64,52 +51,49 @@ export const documentSlice = createSlice({
       }>
     ) => {
       const { path, id, value, command } = action.payload.data;
-      const isRemote = action.payload.isRemote;
 
       const valueJson = parseValue(value);
       if (!valueJson) return;
 
       // match change
-      matchChange(state, { path, id, value: valueJson, command }, isRemote);
+      matchChange(state, { path, id, value: valueJson, command });
     },
   },
 });
 
 export const rectSelectionSlice = createSlice({
-  name: 'rectSelection',
+  name: 'documentRectSelection',
   initialState: rectSelectionInitialState,
   reducers: {
     // update block selections
     updateSelections: (state, action: PayloadAction<string[]>) => {
-      state.selections = action.payload;
+      return action.payload;
     },
 
     // set block selected
     setSelectionById: (state, action: PayloadAction<string>) => {
       const id = action.payload;
-      state.selections = [id];
+      if (state.includes(id)) return;
+      state.push(id);
     },
-  }
+  },
 });
 
-
 export const rangeSelectionSlice = createSlice({
-  name: 'rangeSelection',
+  name: 'documentRangeSelection',
   initialState: rangeSelectionInitialState,
   reducers: {
-    setRange: (
-      state,
-      action: PayloadAction<RangeSelectionState>
-    ) => {
-      state.anchor = action.payload.anchor;
-      state.focus = action.payload.focus;
+    setRange: (state, action: PayloadAction<RangeSelectionState>) => {
+      return {
+        ...state,
+        ...action.payload,
+      };
     },
 
     clearRange: (state, _: PayloadAction) => {
-      state.anchor = undefined;
-      state.focus = undefined;
+      return rangeSelectionInitialState;
     },
-  }
+  },
 });
 
 export const documentReducers = {
@@ -120,4 +104,4 @@ export const documentReducers = {
 
 export const documentActions = documentSlice.actions;
 export const rectSelectionActions = rectSelectionSlice.actions;
-export const rangeSelectionActions = rangeSelectionSlice.actions;
+export const rangeSelectionActions = rangeSelectionSlice.actions;

+ 30 - 5
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts

@@ -5,13 +5,13 @@ import {
   NestedBlock,
   RangeSelectionState,
   TextDelta,
-  TextSelection
-} from "$app/interfaces/document";
+  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";
+import { clone } from '$app/utils/tool';
 
 export function slateValueToDelta(slateNodes: Descendant[]) {
   const element = slateNodes[0] as Element;
@@ -145,10 +145,35 @@ export function newBlock<Type>(type: BlockType, parentId: string, data: BlockDat
 export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState {
   const point = {
     id,
-    selection
+    selection,
   };
   return {
     anchor: clone(point),
     focus: clone(point),
+    isDragging: false,
+  };
+}
+
+export function nodeInRange(
+  id: string,
+  range: {
+    startId: string;
+    endId: string;
+  },
+  isForward: boolean,
+  document: DocumentState
+) {
+  const { startId, endId } = range;
+  let currentId = startId;
+  while (currentId && currentId !== id && currentId !== endId) {
+    if (isForward) {
+      currentId = getNextLineId(document, currentId) || '';
+    } else {
+      currentId = getPrevLineId(document, currentId) || '';
+    }
   }
-}
+  if (currentId === id) {
+    return true;
+  }
+  return false;
+}

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

@@ -80,6 +80,13 @@ export function getNodeBeginSelection(): TextSelection {
   return selection;
 }
 
+export function getEditorEndPoint(editor: Editor): SelectionPoint {
+  const fragment = (editor.children[0] as Element).children;
+  const lastIndex = fragment.length - 1;
+  const lastNode = fragment[lastIndex] as Text;
+  return { path: [0, lastIndex], offset: lastNode.text.length };
+}
+
 /**
  * get the selection of the end of the node
  * @param delta
@@ -282,3 +289,9 @@ export function getPointOfCurrentLineBeginning(editor: Editor) {
   const beginPoint = getPointByTextOffset(delta, lineBeginOffset);
   return beginPoint;
 }
+
+export function selectionIsForward(selection: TextSelection) {
+  const { anchor, focus } = selection;
+  if (!anchor || !focus) return false;
+  return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset);
+}

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

@@ -2,6 +2,7 @@ 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,12 +27,11 @@ export function matchChange(
     id: string;
     value: BlockPBValue & string[];
   },
-  isRemote?: boolean
 ) {
   const matchCase = matchCases.find((item) => item.match(command, path));
 
   if (matchCase) {
-    matchCase.onMatch(state, id, value, isRemote);
+    matchCase.onMatch(state, id, value);
   }
 }
 
@@ -99,46 +99,34 @@ function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) {
   );
 }
 
-function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue, _isRemote?: boolean) {
+function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
   state.nodes[blockId] = blockChangeValue2Node(blockValue);
 }
 
-function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue, isRemote?: boolean) {
+function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) {
   const block = blockChangeValue2Node(blockValue);
   const node = state.nodes[blockId];
   if (!node) return;
-  // if the change is from remote, we should update all fields
-  if (isRemote) {
-    state.nodes[blockId] = block;
-    return;
-  }
-  // if the change is from local, we should update all fields except `data`,
-  // because we will update `data` field in `updateNodeData` action
-  const shouldUpdate = node.parent !== block.parent || node.type !== block.type || node.children !== block.children;
-  if (shouldUpdate) {
-    state.nodes[blockId] = {
-      ...block,
-      data: node.data,
-    };
-  }
+  if (isEqual(node, block)) return;
+  state.nodes[blockId] = block;
   return;
 }
 
-function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue, _isRemote?: boolean) {
+function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue) {
   delete state.nodes[blockId];
 }
 
-function onMatchChildrenInsert(state: DocumentState, id: string, children: string[], _isRemote?: boolean) {
+function onMatchChildrenInsert(state: DocumentState, id: string, children: string[]) {
   state.children[id] = children;
 }
 
-function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[], _isRemote?: boolean) {
+function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) {
   const children = state.children[id];
   if (!children) return;
   state.children[id] = newChildren;
 }
 
-function onMatchChildrenDelete(state: DocumentState, id: string, _children: string[], _isRemote?: boolean) {
+function onMatchChildrenDelete(state: DocumentState, id: string, _children: string[]) {
   delete state.children[id];
 }
 

+ 6 - 2
frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts

@@ -1,11 +1,15 @@
 export function debounce(fn: (...args: any[]) => void, delay: number) {
   let timeout: NodeJS.Timeout;
-  return (...args: any[]) => {
+  const debounceFn = (...args: any[]) => {
     clearTimeout(timeout);
     timeout = setTimeout(() => {
       fn.apply(undefined, args);
     }, delay);
   };
+  debounceFn.cancel = () => {
+    clearTimeout(timeout);
+  };
+  return debounceFn;
 }
 
 export function throttle(fn: (...args: any[]) => void, delay: number, immediate = true) {
@@ -97,4 +101,4 @@ export function clone<T>(value: T): T {
     result[key] = clone(value[key]);
   }
   return result;
-}
+}

+ 5 - 0
frontend/appflowy_tauri/src/styles/template.css

@@ -20,6 +20,11 @@ body {
   @apply bg-[#E0F8FF]
 }
 
+#appflowy-block-doc ::selection {
+  @apply bg-[transparent]
+}
+
+
 .btn {
   @apply rounded-xl border border-gray-500 px-4 py-3;
 }