From 1ad2f6cef5ceaa5e7c21aee2ff4da9da0df05c33 Mon Sep 17 00:00:00 2001
From: qinluhe <108015703+qinluhe@users.noreply.github.com>
Date: Tue, 25 Apr 2023 15:52:57 +0800
Subject: [PATCH] fix: set cursor after operation (#2343)

---
 .../document/HeadingBlock/index.tsx           |  12 +-
 .../document/ListBlock/ColumnListBlock.tsx    |  12 +-
 .../components/document/ListBlock/index.tsx   |  34 ++-
 .../document/TextBlock/TextBlock.hooks.ts     | 111 ++++-----
 .../components/document/TextBlock/index.tsx   |   6 +-
 .../document/_shared/TextInput.hooks.ts       | 211 ++++++++++--------
 .../src/appflowy_app/interfaces/document.ts   |   7 +-
 .../effects/document/document_controller.ts   |  22 +-
 .../document/async_actions/backspace.ts       |  83 ++++---
 .../reducers/document/async_actions/insert.ts |   6 +-
 .../document/async_actions/set_cursor.ts      |  47 ++++
 .../reducers/document/async_actions/split.ts  |   6 +-
 .../reducers/document/async_actions/update.ts |  39 ++++
 .../stores/reducers/document/slice.ts         |  22 +-
 .../src/appflowy_app/utils/block.ts           |  33 ++-
 .../flowy-document2/src/event_handler.rs      |   4 +-
 .../rust-lib/flowy-document2/src/manager.rs   |   2 +-
 17 files changed, 440 insertions(+), 217 deletions(-)
 create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts
 create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts

diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx
index ad2dfcf69d..58526cae7c 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx
@@ -1,6 +1,6 @@
 import TextBlock from '../TextBlock';
 import { Node } from '@/appflowy_app/stores/reducers/document/slice';
-import { TextDelta } from '@/appflowy_app/interfaces/document';
+import { HeadingBlockData } from '@/appflowy_app/interfaces/document';
 
 const fontSize: Record<string, string> = {
   1: 'mt-8 text-3xl',
@@ -8,9 +8,15 @@ const fontSize: Record<string, string> = {
   3: 'mt-4 text-xl',
 };
 
-export default function HeadingBlock({ node, delta }: { node: Node; delta: TextDelta[] }) {
+export default function HeadingBlock({
+  node,
+}: {
+  node: Node & {
+    data: HeadingBlockData;
+  };
+}) {
   return (
-    <div className={`${fontSize[node.data.style?.level]} font-semibold	`}>
+    <div className={`${fontSize[node.data.level]} font-semibold	`}>
       {/*<TextBlock node={node} childIds={[]} delta={delta} />*/}
     </div>
   );
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/ColumnListBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/ColumnListBlock.tsx
index 82fd423e9d..2a24900eb2 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/ColumnListBlock.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/ColumnListBlock.tsx
@@ -2,7 +2,15 @@ import React, { useMemo } from 'react';
 import ColumnBlock from '../ColumnBlock';
 import { Node } from '@/appflowy_app/stores/reducers/document/slice';
 
-export default function ColumnListBlock({ node, childIds }: { node: Node; childIds?: string[] }) {
+export default function ColumnListBlock({
+  node,
+  childIds,
+}: {
+  node: Node & {
+    data: Record<string, any>;
+  };
+  childIds?: string[];
+}) {
   const resizerWidth = useMemo(() => {
     return 46 * (node.children?.length || 0);
   }, [node.children?.length]);
@@ -13,7 +21,7 @@ export default function ColumnListBlock({ node, childIds }: { node: Node; childI
           <ColumnBlock
             key={item}
             index={index}
-            width={`calc((100% - ${resizerWidth}px) * ${node.data.style?.ratio})`}
+            width={`calc((100% - ${resizerWidth}px) * ${node.data.ratio})`}
             id={item}
           />
         ))}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/index.tsx
index 5d8671ac44..b156bfa55e 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/index.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ListBlock/index.tsx
@@ -6,27 +6,23 @@ import ColumnListBlock from './ColumnListBlock';
 import { Node } from '@/appflowy_app/stores/reducers/document/slice';
 import { TextDelta } from '@/appflowy_app/interfaces/document';
 
-export default function ListBlock({ node, delta }: { node: Node; delta: TextDelta[] }) {
+export default function ListBlock({ node }: { node: Node }) {
   const title = useMemo(() => {
-    if (node.data.style?.type === 'column') return <></>;
-    return (
-      <div className='flex-1'>
-        {/*<TextBlock delta={delta} node={node} childIds={[]} />*/}
-      </div>
-    );
-  }, [node, delta]);
+    // if (node.data.style?.type === 'column') return <></>;
+    return <div className='flex-1'>{/*<TextBlock delta={delta} node={node} childIds={[]} />*/}</div>;
+  }, [node]);
 
-  if (node.data.style?.type === 'numbered') {
-    return <NumberedListBlock title={title} node={node} />;
-  }
-
-  if (node.data.style?.type === 'bulleted') {
-    return <BulletedListBlock title={title} node={node} />;
-  }
-
-  if (node.data.style?.type === 'column') {
-    return <ColumnListBlock node={node} />;
-  }
+  // if (node.data.type === 'numbered') {
+  //   return <NumberedListBlock title={title} node={node} />;
+  // }
+  //
+  // if (node.data.type === 'bulleted') {
+  //   return <BulletedListBlock title={title} node={node} />;
+  // }
+  //
+  // if (node.data.type === 'column') {
+  //   return <ColumnListBlock node={node} />;
+  // }
 
   return null;
 }
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
index f586500186..ad4f7a2d56 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
@@ -1,6 +1,6 @@
 import { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey';
-import { useCallback, useContext, useState } from 'react';
-import { Descendant, Range, Editor, Element, Text, Location } from 'slate';
+import { useCallback, useContext } from 'react';
+import { Range, Editor, Element, Text, Location } from 'slate';
 import { TextDelta } from '$app/interfaces/document';
 import { useTextInput } from '../_shared/TextInput.hooks';
 import { useAppDispatch } from '@/appflowy_app/stores/store';
@@ -10,65 +10,76 @@ import {
   indentNodeThunk,
   splitNodeThunk,
 } from '@/appflowy_app/stores/reducers/document/async_actions';
-import { TextSelection } from '@/appflowy_app/stores/reducers/document/slice';
+import { documentActions, TextSelection } from '@/appflowy_app/stores/reducers/document/slice';
 
-export function useTextBlock(id: string, delta: TextDelta[]) {
-  const { editor, onSelectionChange } = useTextInput(id, delta);
-  const [value, setValue] = useState<Descendant[]>([]);
+export function useTextBlock(id: string) {
+  const { editor, onChange, value } = useTextInput(id);
   const { onTab, onBackSpace, onEnter } = useActions(id);
-  const onChange = useCallback(
-    (e: Descendant[]) => {
-      setValue(e);
-      editor.operations.forEach((op) => {
-        if (op.type === 'set_selection') {
-          onSelectionChange(op.newProperties as TextSelection);
-        }
-      });
-    },
-    [editor]
-  );
+  const dispatch = useAppDispatch();
 
-  const onKeyDownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
-    switch (event.key) {
-      case 'Enter': {
-        if (!editor.selection) return;
-        event.stopPropagation();
-        event.preventDefault();
-        const retainRange = getRetainRangeBy(editor);
-        const retain = getDelta(editor, retainRange);
-        const insertRange = getInsertRangeBy(editor);
-        const insert = getDelta(editor, insertRange);
-        void (async () => {
-          await onEnter(retain, insert);
-        })();
-        return;
-      }
-      case 'Backspace': {
-        if (!editor.selection) return;
+  const keepSelection = useCallback(() => {
+    // This is a hack to make sure the selection is updated after next render
+    // It will save the selection to the store, and the selection will be restored
+    if (!editor.selection || !editor.selection.anchor || !editor.selection.focus) return;
+    const { anchor, focus } = editor.selection;
+    const selection = { anchor, focus } as TextSelection;
+    dispatch(documentActions.setTextSelection({ blockId: id, selection }));
+  }, [editor]);
 
-        const { anchor } = editor.selection;
-        const isCollapsed = Range.isCollapsed(editor.selection);
-        if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') {
+  const onKeyDownCapture = useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>) => {
+      switch (event.key) {
+        // It should be handled when `Enter` is pressed
+        case 'Enter': {
+          if (!editor.selection) return;
           event.stopPropagation();
           event.preventDefault();
+          // get the retain content
+          const retainRange = getRetainRangeBy(editor);
+          const retain = getDelta(editor, retainRange);
+          // get the insert content
+          const insertRange = getInsertRangeBy(editor);
+          const insert = getDelta(editor, insertRange);
           void (async () => {
-            await onBackSpace();
+            // retain this node and insert a new node
+            await onEnter(retain, insert);
           })();
+          return;
         }
-        return;
-      }
-      case 'Tab': {
-        event.stopPropagation();
-        event.preventDefault();
-        void (async () => {
-          await onTab();
-        })();
+        // It should be handled when `Backspace` is pressed
+        case 'Backspace': {
+          if (!editor.selection) {
+            return;
+          }
+          // It should be handled if the selection is collapsed and the cursor is at the beginning of the block
+          const { anchor } = editor.selection;
+          const isCollapsed = Range.isCollapsed(editor.selection);
+          if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') {
+            event.stopPropagation();
+            event.preventDefault();
+            keepSelection();
+            void (async () => {
+              await onBackSpace();
+            })();
+          }
+          return;
+        }
+        // It should be handled when `Tab` is pressed
+        case 'Tab': {
+          event.stopPropagation();
+          event.preventDefault();
+          keepSelection();
+          void (async () => {
+            await onTab();
+          })();
 
-        return;
+          return;
+        }
       }
-    }
-    triggerHotkey(event, editor);
-  };
+      triggerHotkey(event, editor);
+    },
+    [editor, keepSelection, onEnter, onBackSpace, onTab]
+  );
 
   const onDOMBeforeInput = useCallback((e: InputEvent) => {
     // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
index c838d2d9bf..4538391bd0 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
@@ -4,7 +4,7 @@ import { useTextBlock } from './TextBlock.hooks';
 import { Node } from '@/appflowy_app/stores/reducers/document/slice';
 import NodeComponent from '../Node';
 import HoveringToolbar from '../_shared/HoveringToolbar';
-import React, { useMemo } from 'react';
+import React, { useEffect } from 'react';
 
 function TextBlock({
   node,
@@ -16,9 +16,7 @@ function TextBlock({
   childIds?: string[];
   placeholder?: string;
 } & React.HTMLAttributes<HTMLDivElement>) {
-  const delta = useMemo(() => node.data.delta || [], [node.data.delta]);
-  const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id, delta);
-
+  const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id);
   return (
     <>
       <div {...props} className={`py-[2px] ${props.className}`}>
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts
index 8f8aa46abd..588ac08015 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts
@@ -1,122 +1,75 @@
-import { useCallback, useContext, useMemo, useRef, useEffect } from 'react';
+import { useCallback, useContext, useMemo, useRef, useEffect, useState } from 'react';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { TextDelta } from '$app/interfaces/document';
-import { debounce } from '@/appflowy_app/utils/tool';
 import { NodeContext } from './SubscribeNode.hooks';
-import { BlockActionTypePB } from '@/services/backend/models/flowy-document2';
 import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
-import { documentActions, TextSelection } from '@/appflowy_app/stores/reducers/document/slice';
 
-import { createEditor, Transforms } from 'slate';
+import { createEditor, Descendant, Transforms } from 'slate';
 import { withReact, ReactEditor } from 'slate-react';
 
 import * as Y from 'yjs';
 import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
+import { updateNodeDeltaThunk } from '@/appflowy_app/stores/reducers/document/async_actions/update';
+import { documentActions, TextSelection } from '@/appflowy_app/stores/reducers/document/slice';
+import { deltaToSlateValue, getDeltaFromSlateNodes } from '@/appflowy_app/utils/block';
 
-export function useTextInput(id: string, delta: TextDelta[]) {
-  const { sendDelta } = useTransact();
-  const { editor, yText } = useBindYjs(delta, sendDelta);
+export function useTextInput(id: string) {
   const dispatch = useAppDispatch();
+  const node = useContext(NodeContext);
+
+  const delta = useMemo(() => {
+    if (!node || !('delta' in node.data)) {
+      return [];
+    }
+    return node.data.delta;
+  }, [node?.data]);
+
+  const { editor, yText } = useBindYjs(id, delta);
+
+  useEffect(() => {
+    return () => {
+      dispatch(documentActions.removeTextSelection(id));
+    };
+  }, [id]);
+
+  const [value, setValue] = useState<Descendant[]>([]);
+
+  const onChange = useCallback((e: Descendant[]) => {
+    setValue(e);
+  }, []);
+
   const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
 
   useEffect(() => {
-    if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) return;
-    ReactEditor.focus(editor);
-    Transforms.select(editor, currentSelection);
-  }, [currentSelection, editor]);
+    setSelection(editor, currentSelection);
+  }, [editor, currentSelection]);
 
-  const onSelectionChange = useCallback(
-    (selection?: TextSelection) => {
-      dispatch(
-        documentActions.setTextSelection({
-          blockId: id,
-          selection,
-        })
-      );
-    },
-    [id]
-  );
+  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 {
     editor,
     yText,
-    onSelectionChange,
+    onChange,
+    value,
   };
 }
-
-function useController() {
-  const docController = useContext(DocumentControllerContext);
-  const node = useContext(NodeContext);
-  const dispatch = useAppDispatch();
-
-  const update = useCallback(
-    async (delta: TextDelta[]) => {
-      if (!docController || !node) return;
-      await docController.applyActions([
-        {
-          action: BlockActionTypePB.Update,
-          payload: {
-            block: {
-              id: node.id,
-              ty: node.type,
-              parent_id: node.parent || '',
-              children_id: node.children,
-              data: JSON.stringify({
-                ...node.data,
-                delta,
-              }),
-            },
-          },
-        },
-      ]);
-      dispatch(
-        documentActions.setBlockMap({
-          ...node,
-          data: {
-            delta,
-          },
-        })
-      );
-    },
-    [docController, node]
-  );
-
-  return {
-    update,
-  };
-}
-
-function useTransact() {
-  const { update } = useController();
-
-  const sendDelta = useCallback(
-    (delta: TextDelta[]) => {
-      void update(delta);
-    },
-    [update]
-  );
-  const debounceSendDelta = useMemo(() => debounce(sendDelta, 300), [sendDelta]);
-
-  return {
-    sendDelta: debounceSendDelta,
-  };
-}
-
-const initialValue = [
-  {
-    type: 'paragraph',
-    children: [{ text: '' }],
-  },
-];
-
-function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) {
+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(initialValue);
+    const insertDelta = slateNodesToInsertDelta(deltaToSlateValue(delta));
     // Load the initial value into the yjs document
     _sharedType.applyDelta(insertDelta);
 
@@ -141,18 +94,80 @@ function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) {
     if (!yText) return;
     const textEventHandler = (event: Y.YTextEvent) => {
       const textDelta = event.target.toDelta();
-      update(textDelta);
+      void sendDelta(textDelta);
     };
-    if (JSON.stringify(yText.toDelta()) !== JSON.stringify(delta)) {
-      yText.delete(0, yText.length);
-      yText.applyDelta(delta);
-    }
+
     yText.observe(textEventHandler);
 
     return () => {
       yText.unobserve(textEventHandler);
     };
-  }, [delta]);
+  }, [sendDelta]);
+
+  const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
+
+  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
+    if (JSON.stringify(yText.toDelta()) !== JSON.stringify(delta)) {
+      yText.delete(0, yText.length);
+      yText.applyDelta(delta);
+      // It should be noted that the selection will be lost after the yText is updated
+      setSelection(editor, currentSelection);
+    }
+  }, [delta, currentSelection, editor]);
 
   return { editor, yText: yTextRef.current };
 }
+
+function useController(id: string) {
+  const docController = useContext(DocumentControllerContext);
+  const dispatch = useAppDispatch();
+
+  const sendDelta = useCallback(
+    async (delta: TextDelta[]) => {
+      if (!docController) return;
+      await dispatch(
+        updateNodeDeltaThunk({
+          id,
+          delta,
+          controller: docController,
+        })
+      );
+    },
+    [docController, id]
+  );
+
+  return {
+    sendDelta,
+  };
+}
+
+function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
+  // If the current selection is empty, blur the editor and deselect the selection
+  if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) {
+    ReactEditor.blur(editor);
+    ReactEditor.deselect(editor);
+    return;
+  }
+
+  // If the editor is focused and the current selection is the same as the editor's selection, no need to set the selection
+  if (ReactEditor.isFocused(editor) && JSON.stringify(currentSelection) === JSON.stringify(editor.selection)) {
+    return;
+  }
+
+  const { path, offset } = currentSelection.focus;
+  // It is possible that the current selection is out of range
+  const children = getDeltaFromSlateNodes(editor.children);
+  if (children[path[1]].insert.length < offset) {
+    return;
+  }
+
+  // the order of the following two lines is important
+  // if we reverse the order, the selection will be lost or always at the start
+  Transforms.select(editor, currentSelection);
+  editor.selection = currentSelection;
+  ReactEditor.focus(editor);
+}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
index 1834968d68..d5672ffde3 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
@@ -15,18 +15,21 @@ export enum BlockType {
 
 export interface HeadingBlockData {
   level: number;
+  delta: TextDelta[];
 }
 
 export interface TextBlockData {
   delta: TextDelta[];
 }
 
-export interface PageBlockData extends TextBlockData {}
+export type PageBlockData = TextBlockData;
+
+export type BlockData = TextBlockData | HeadingBlockData | PageBlockData;
 
 export interface NestedBlock {
   id: string;
   type: BlockType;
-  data: Record<string, any>;
+  data: BlockData | Record<string, any>;
   parent: string | null;
   children: string;
 }
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
index 9487be8c9b..7bca163c64 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
@@ -5,7 +5,7 @@ import { FlowyError, BlockActionPB, DocEventPB, DeltaTypePB, BlockActionTypePB }
 import { DocumentObserver } from './document_observer';
 import { documentActions, Node } from '@/appflowy_app/stores/reducers/document/slice';
 import { Log } from '@/appflowy_app/utils/log';
-
+import * as Y from 'yjs';
 export const DocumentControllerContext = createContext<DocumentController | null>(null);
 
 export class DocumentController {
@@ -69,6 +69,8 @@ export class DocumentController {
   };
 
   getInsertAction = (node: Node, prevId: string | null) => {
+    // Here to make sure the delta is correct
+    this.composeDelta(node);
     return {
       action: BlockActionTypePB.Insert,
       payload: this.getActionPayloadByNode(node, prevId),
@@ -76,6 +78,8 @@ export class DocumentController {
   };
 
   getUpdateAction = (node: Node) => {
+    // Here to make sure the delta is correct
+    this.composeDelta(node);
     return {
       action: BlockActionTypePB.Update,
       payload: this.getActionPayloadByNode(node, ''),
@@ -124,11 +128,25 @@ export class DocumentController {
     };
   };
 
+  private composeDelta = (node: Node) => {
+    const delta = node.data.delta;
+    if (!delta) {
+      return;
+    }
+    // we use yjs to compose delta, it can make sure the delta is correct
+    // for example, if we insert a text at the end of the line, the delta will be [{ insert: 'hello' }, { insert: " world" }]
+    // but if we use yjs to compose the delta, the delta will be [{ insert: 'hello world' }]
+    const ydoc = new Y.Doc();
+    const ytext = ydoc.getText(node.id);
+    ytext.applyDelta(delta);
+    Object.assign(node.data, { delta: ytext.toDelta() });
+  };
+
   private updated = (payload: Uint8Array) => {
     const dispatch = this.dispatch;
     if (!dispatch) return;
     const { events, is_remote } = DocEventPB.deserializeBinary(payload);
-    console.log('updated', events, is_remote);
+
     if (!is_remote) return;
     events.forEach((event) => {
       event.event.forEach((_payload) => {
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts
index eedf951c2e..e7952348fa 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts
@@ -3,6 +3,54 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions, DocumentState } from '../slice';
 import { outdentNodeThunk } from './outdent';
+import { setCursorAfterThunk } from './set_cursor';
+
+const composeNodeThunk = createAsyncThunk(
+  'document/composeNode',
+  async (payload: { id: string; composeId: string; controller: DocumentController }, thunkAPI) => {
+    const { id, composeId, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+
+    const composeNode = state.nodes[composeId];
+    // set cursor in compose node end
+    // It must be stored before update, for the cursor can be restored after update
+    await dispatch(setCursorAfterThunk({ id: composeId }));
+
+    // merge delta and update
+    const nodeDelta = node.data?.delta || [];
+    const composeDelta = composeNode.data?.delta || [];
+    const newNode = {
+      ...composeNode,
+      data: {
+        ...composeNode.data,
+        delta: [...composeDelta, ...nodeDelta],
+      },
+    };
+    const updateAction = controller.getUpdateAction(newNode);
+
+    // move children
+    const children = state.children[node.children];
+    // the reverse can ensure that every child will be inserted in first place and don't need to update prevId
+    const moveActions = children.reverse().map((childId) => {
+      return controller.getMoveAction(state.nodes[childId], newNode.id, '');
+    });
+
+    // delete node
+    const deleteAction = controller.getDeleteAction(node);
+
+    // move must be before delete
+    await controller.applyActions([...moveActions, deleteAction, updateAction]);
+
+    children.reverse().forEach((childId) => {
+      dispatch(documentActions.moveNode({ id: childId, newParentId: newNode.id, newPrevId: '' }));
+    });
+    dispatch(documentActions.setBlockMap(newNode));
+    dispatch(documentActions.removeBlockMapKey(node.id));
+    dispatch(documentActions.removeChildrenMapKey(node.children));
+  }
+);
 
 const composeParentThunk = createAsyncThunk(
   'document/composeParent',
@@ -12,30 +60,18 @@ const composeParentThunk = createAsyncThunk(
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
     if (!node.parent) return;
-    const parent = state.nodes[node.parent];
-    // merge delta
-    const newParent = {
-      ...parent,
-      data: {
-        ...parent.data,
-        delta: [...parent.data.delta, ...node.data.delta],
-      },
-    };
-    await controller.applyActions([controller.getDeleteAction(node), controller.getUpdateAction(newParent)]);
-
-    dispatch(documentActions.setBlockMap(newParent));
-    dispatch(documentActions.removeBlockMapKey(node.id));
-    dispatch(documentActions.removeChildrenMapKey(node.children));
+    await dispatch(composeNodeThunk({ id: id, composeId: node.parent, controller }));
   }
 );
+
 const composePrevNodeThunk = createAsyncThunk(
   'document/composePrevNode',
   async (payload: { prevNodeId: string; id: string; controller: DocumentController }, thunkAPI) => {
     const { id, prevNodeId, controller } = payload;
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
     const prevNode = state.nodes[prevNodeId];
+    if (!prevNode) return;
     // find prev line
     let prevLineId = prevNode.id;
     while (prevLineId) {
@@ -43,20 +79,7 @@ const composePrevNodeThunk = createAsyncThunk(
       if (prevLineChildren.length === 0) break;
       prevLineId = prevLineChildren[prevLineChildren.length - 1];
     }
-    const prevLine = state.nodes[prevLineId];
-    // merge delta
-    const newPrevLine = {
-      ...prevLine,
-      data: {
-        ...prevLine.data,
-        delta: [...prevLine.data.delta, ...node.data.delta],
-      },
-    };
-    await controller.applyActions([controller.getDeleteAction(node), controller.getUpdateAction(newPrevLine)]);
-
-    dispatch(documentActions.setBlockMap(newPrevLine));
-    dispatch(documentActions.removeBlockMapKey(node.id));
-    dispatch(documentActions.removeChildrenMapKey(node.children));
+    await dispatch(composeNodeThunk({ id: id, composeId: prevLineId, controller }));
   }
 );
 
@@ -80,6 +103,8 @@ export const backspaceNodeThunk = createAsyncThunk(
     }
     // compose to previous line when it has next sibling or no ancestor
     if (nextNodeId || !ancestorId) {
+      // do nothing when it is the first line
+      if (!prevNodeId && !ancestorId) return;
       // compose to parent when it has no previous sibling
       if (!prevNodeId) {
         await dispatch(composeParentThunk({ id, controller }));
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts
index 3724c3fb83..1ab0f69d10 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts
@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions, DocumentState } from '../slice';
 import { generateId } from '@/appflowy_app/utils/block';
+import { setCursorAfterThunk } from './set_cursor';
 export const insertAfterNodeThunk = createAsyncThunk(
   'document/insertAfterNode',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
@@ -18,7 +19,9 @@ export const insertAfterNodeThunk = createAsyncThunk(
       id: generateId(),
       parent: parentId,
       type: BlockType.TextBlock,
-      data: {},
+      data: {
+        delta: [],
+      },
       children: generateId(),
     };
     await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
@@ -37,5 +40,6 @@ export const insertAfterNodeThunk = createAsyncThunk(
         prevId: node.id,
       })
     );
+    await dispatch(setCursorAfterThunk({ id: newNode.id }));
   }
 );
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts
new file mode 100644
index 0000000000..8c903cb856
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts
@@ -0,0 +1,47 @@
+import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { documentActions, DocumentState, SelectionPoint, TextSelection } from '../slice';
+
+export const setCursorBeforeThunk = createAsyncThunk(
+  'document/setCursorBefore',
+  async (payload: { id: string }, thunkAPI) => {
+    const { id } = payload;
+    const { dispatch } = thunkAPI;
+    const selection: TextSelection = {
+      anchor: {
+        path: [0, 0],
+        offset: 0,
+      },
+      focus: {
+        path: [0, 0],
+        offset: 0,
+      },
+    };
+    dispatch(documentActions.setTextSelection({ blockId: id, selection }));
+  }
+);
+
+export const setCursorAfterThunk = createAsyncThunk(
+  'document/setCursorAfter',
+  async (payload: { id: string }, thunkAPI) => {
+    const { id } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    const len = node.data.delta?.length || 0;
+    const offset = len > 0 ? node.data.delta[len - 1].insert.length : 0;
+    const cursorPoint: SelectionPoint = {
+      path: [0, len > 0 ? len - 1 : 0],
+      offset,
+    };
+    const selection: TextSelection = {
+      anchor: {
+        ...cursorPoint,
+      },
+      focus: {
+        ...cursorPoint,
+      },
+    };
+    dispatch(documentActions.setTextSelection({ blockId: node.id, selection }));
+  }
+);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts
index ac2be1350b..a06d13365d 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts
@@ -2,7 +2,8 @@ import { BlockType, TextDelta } from '@/appflowy_app/interfaces/document';
 import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { generateId } from '@/appflowy_app/utils/block';
-import { documentActions, DocumentState } from '../slice';
+import { documentActions, DocumentState, TextSelection } from '../slice';
+import { setCursorBeforeThunk } from './set_cursor';
 
 export const splitNodeThunk = createAsyncThunk(
   'document/splitNode',
@@ -50,5 +51,8 @@ export const splitNodeThunk = createAsyncThunk(
         prevId,
       })
     );
+
+    // set cursor
+    await dispatch(setCursorBeforeThunk({ id: newNode.id }));
   }
 );
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts
new file mode 100644
index 0000000000..4a31a98676
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts
@@ -0,0 +1,39 @@
+import { TextDelta } from '@/appflowy_app/interfaces/document';
+import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { documentActions, DocumentState, Node } from '../slice';
+import { debounce } from '$app/utils/tool';
+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 state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    const updateNode = {
+      ...node,
+      id,
+      data: {
+        ...node.data,
+        delta,
+      },
+    };
+    // The block map should be updated immediately
+    // or the component will use the old data to update the editor
+    dispatch(documentActions.setBlockMap(updateNode));
+
+    // the transaction is delayed to avoid too many updates
+    debounceApplyUpdate(controller, updateNode);
+  }
+);
+
+const debounceApplyUpdate = debounce((controller: DocumentController, updateNode: Node) => {
+  void controller.applyActions([
+    controller.getUpdateAction({
+      ...updateNode,
+      data: {
+        ...updateNode.data,
+      },
+    }),
+  ]);
+}, 200);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
index 19235be4ce..d83b194573 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
@@ -114,7 +114,8 @@ export const documentSlice = createSlice({
       }>
     ) => {
       const { blockId, selection } = action.payload;
-      if (!selection) {
+      const node = state.nodes[blockId];
+      if (!node || !selection) {
         delete state.textSelections[blockId];
       } else {
         state.textSelections = {
@@ -123,11 +124,28 @@ export const documentSlice = createSlice({
       }
     },
 
-    // update block
+    // remove text selections
+    removeTextSelection: (state, action: PayloadAction<string>) => {
+      const id = action.payload;
+      if (!state.textSelections[id]) return;
+      state.textSelections;
+    },
+
+    // insert block
     setBlockMap: (state, action: PayloadAction<Node>) => {
       state.nodes[action.payload.id] = action.payload;
     },
 
+    // update block when `type`, `parent` or `children` changed
+    updateBlock: (state, action: PayloadAction<{ id: string; block: NestedBlock }>) => {
+      const { id, block } = action.payload;
+      const node = state.nodes[id];
+      if (!node || node.parent !== block.parent || node.type !== block.type || node.children !== block.children) {
+        state.nodes[action.payload.id] = block;
+        return;
+      }
+    },
+
     // remove block
     removeBlockMapKey(state, action: PayloadAction<string>) {
       if (!state.nodes[action.payload]) return;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts
index 6318f80616..dbb519e052 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts
@@ -1,5 +1,36 @@
 import { nanoid } from 'nanoid';
+import { Descendant, Element, Text } from 'slate';
+import { TextDelta } from '../interfaces/document';
 
 export function generateId() {
   return nanoid(10);
-}
\ No newline at end of file
+}
+
+export function deltaToSlateValue(delta: TextDelta[]) {
+  const slateNode = {
+    type: 'paragraph',
+    children: [{ text: '' }],
+  };
+  const slateNodes = [slateNode];
+  if (delta.length > 0) {
+    slateNode.children = delta.map((d) => {
+      return {
+        ...d.attributes,
+        text: d.insert,
+      };
+    });
+  }
+  return slateNodes;
+}
+
+export function getDeltaFromSlateNodes(slateNodes: Descendant[]) {
+  const element = slateNodes[0] as Element;
+  const children = element.children as Text[];
+  return children.map((child) => {
+    const { text, ...attributes } = child;
+    return {
+      insert: text,
+      attributes,
+    };
+  });
+}
diff --git a/frontend/rust-lib/flowy-document2/src/event_handler.rs b/frontend/rust-lib/flowy-document2/src/event_handler.rs
index ccb6d91b0e..07295b9dd2 100644
--- a/frontend/rust-lib/flowy-document2/src/event_handler.rs
+++ b/frontend/rust-lib/flowy-document2/src/event_handler.rs
@@ -138,8 +138,8 @@ impl From<DeltaType> for DeltaTypePB {
   }
 }
 
-impl DocEventPB {
-  pub(crate) fn get_from(events: &Vec<BlockEvent>, is_remote: bool) -> Self {
+impl From<(&Vec<BlockEvent>, bool)> for DocEventPB {
+  fn from((events, is_remote): (&Vec<BlockEvent>, bool)) -> Self {
     Self {
       events: events.iter().map(|e| e.to_owned().into()).collect(),
       is_remote,
diff --git a/frontend/rust-lib/flowy-document2/src/manager.rs b/frontend/rust-lib/flowy-document2/src/manager.rs
index 39428047a8..6e1ed88b3d 100644
--- a/frontend/rust-lib/flowy-document2/src/manager.rs
+++ b/frontend/rust-lib/flowy-document2/src/manager.rs
@@ -60,7 +60,7 @@ impl DocumentManager {
       .lock()
       .open(move |events, is_remote| {
         send_notification(&clone_doc_id, DocumentNotification::DidReceiveUpdate)
-          .payload(DocEventPB::get_from(events, is_remote))
+          .payload::<DocEventPB>((events, is_remote).into())
           .send();
       })
       .map_err(|err| FlowyError::internal().context(err))?;