Browse Source

feat: block list virtualized scroll (#2023)

* feat: block list virtualized scroll

* feat: block selection

* refactor: block editor

* fix: block selection scroll

* fix: ts error
qinluhe 2 years ago
parent
commit
8471bc299d
54 changed files with 2741 additions and 758 deletions
  1. 3 0
      frontend/appflowy_tauri/package.json
  2. 202 113
      frontend/appflowy_tauri/pnpm-lock.yaml
  3. 0 28
      frontend/appflowy_tauri/src/appflowy_app/block_editor/block.ts
  4. 71 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/blocks/text_block/index.ts
  5. 35 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/blocks/text_block/text_selection.ts
  6. 107 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/core/block.ts
  7. 225 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/core/block_chain.ts
  8. 16 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/core/op_adapter.ts
  9. 153 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/core/operation.ts
  10. 48 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/core/sync.ts
  11. 40 28
      frontend/appflowy_tauri/src/appflowy_app/block_editor/index.ts
  12. 0 66
      frontend/appflowy_tauri/src/appflowy_app/block_editor/rect.ts
  13. 0 140
      frontend/appflowy_tauri/src/appflowy_app/block_editor/tree.ts
  14. 73 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/view/block_position.ts
  15. 81 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/view/region_grid.ts
  16. 165 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/view/tree.ts
  17. 59 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/view/tree_node.ts
  18. 5 24
      frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/FormatButton.tsx
  19. 20 0
      frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/FormatIcon.tsx
  20. 0 5
      frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/components.tsx
  21. 36 0
      frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/index.hooks.ts
  22. 5 24
      frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/index.tsx
  23. 36 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockComponent/BlockComponet.hooks.ts
  24. 91 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockComponent/index.tsx
  25. 0 39
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockComponent.tsx
  26. 92 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockList.hooks.tsx
  27. 18 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockListTitle.tsx
  28. 31 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/ListFallbackComponent.tsx
  29. 52 37
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/index.tsx
  30. 137 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockSelection/BlockSelection.hooks.tsx
  31. 18 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockSelection/index.tsx
  32. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/block/CodeBlock/index.tsx
  33. 7 8
      frontend/appflowy_tauri/src/appflowy_app/components/block/ColumnBlock/index.tsx
  34. 5 9
      frontend/appflowy_tauri/src/appflowy_app/components/block/HeadingBlock/index.tsx
  35. 4 4
      frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/BulletedListBlock.tsx
  36. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/ColumnListBlock.tsx
  37. 14 9
      frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/NumberedListBlock.tsx
  38. 5 9
      frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/index.tsx
  39. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/block/PageBlock/index.tsx
  40. 98 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.hooks.ts
  41. 26 66
      frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.tsx
  42. 14 0
      frontend/appflowy_tauri/src/appflowy_app/constants/toolbar.ts
  43. 58 10
      frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts
  44. 25 0
      frontend/appflowy_tauri/src/appflowy_app/utils/block.ts
  45. 0 8
      frontend/appflowy_tauri/src/appflowy_app/utils/block_context.ts
  46. 36 0
      frontend/appflowy_tauri/src/appflowy_app/utils/block_selection.ts
  47. 0 25
      frontend/appflowy_tauri/src/appflowy_app/utils/editor/format.ts
  48. 0 22
      frontend/appflowy_tauri/src/appflowy_app/utils/editor/hotkey.ts
  49. 0 28
      frontend/appflowy_tauri/src/appflowy_app/utils/editor/toolbar.ts
  50. 6 0
      frontend/appflowy_tauri/src/appflowy_app/utils/slate/context.ts
  51. 3 13
      frontend/appflowy_tauri/src/appflowy_app/utils/slate/toolbar.ts
  52. 26 0
      frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts
  53. 575 22
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts
  54. 11 12
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx

+ 3 - 0
frontend/appflowy_tauri/package.json

@@ -20,7 +20,9 @@
     "@mui/icons-material": "^5.11.11",
     "@mui/material": "^5.11.12",
     "@reduxjs/toolkit": "^1.9.2",
+    "@tanstack/react-virtual": "3.0.0-beta.54",
     "@tauri-apps/api": "^1.2.0",
+    "events": "^3.3.0",
     "google-protobuf": "^3.21.2",
     "i18next": "^22.4.10",
     "i18next-browser-languagedetector": "^7.0.1",
@@ -40,6 +42,7 @@
     "slate": "^0.91.4",
     "slate-react": "^0.91.9",
     "ts-results": "^3.3.0",
+    "ulid": "^2.3.0",
     "utf8": "^3.0.0"
   },
   "devDependencies": {

File diff suppressed because it is too large
+ 202 - 113
frontend/appflowy_tauri/pnpm-lock.yaml


+ 0 - 28
frontend/appflowy_tauri/src/appflowy_app/block_editor/block.ts

@@ -1,28 +0,0 @@
-import { BlockInterface, BlockType } from '$app/interfaces/index';
-
-
-export class BlockDataManager {
-  private head: BlockInterface<BlockType.PageBlock> | null = null;
-  constructor(id: string, private map: Record<string, BlockInterface<BlockType>> | null) {
-    if (!map) return;
-    this.head = map[id];
-  }
-
-  setBlocksMap = (id: string, map: Record<string, BlockInterface<BlockType>>) => {
-    this.map = map;
-    this.head = map[id];
-  }
-
-  /**
-   * get block data
-   * @param blockId string
-   * @returns Block
-   */
-  getBlock = (blockId: string) => {
-    return this.map?.[blockId] || null;
-  }
-
-  destroy() {
-    this.map = null;
-  }
-}

+ 71 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/blocks/text_block/index.ts

@@ -0,0 +1,71 @@
+import { BaseEditor, BaseSelection, Descendant } from "slate";
+import { TreeNode } from '$app/block_editor/view/tree_node';
+import { Operation } from "$app/block_editor/core/operation";
+import { TextBlockSelectionManager } from './text_selection';
+
+export class TextBlockManager {
+  public selectionManager: TextBlockSelectionManager;
+  constructor(private operation: Operation) {
+    this.selectionManager = new TextBlockSelectionManager();
+  }
+
+  setSelection(node: TreeNode, selection: BaseSelection) {
+    // console.log(node.id, selection);
+    this.selectionManager.setSelection(node.id, selection)
+  }
+
+  update(node: TreeNode, path: string[], data: Descendant[]) {
+    this.operation.updateNode(node.id, path, data);
+  }
+
+  splitNode(node: TreeNode, editor: BaseEditor) {
+    const focus = editor.selection?.focus;
+    const path = focus?.path || [0, editor.children.length - 1];
+    const offset = focus?.offset || 0;
+    const parentIndex = path[0];
+    const index = path[1];
+    const editorNode = editor.children[parentIndex];
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    const children: { [key: string]: boolean | string; text: string }[] = editorNode.children;
+    const retainItems = children.filter((_: any, i: number) => i < index);
+    const splitItem: { [key: string]: boolean | string } = children[index];
+    const text = splitItem.text.toString();
+    const prevText = text.substring(0, offset);
+    const afterText = text.substring(offset);
+    retainItems.push({
+      ...splitItem,
+      text: prevText
+    });
+
+    const removeItems = children.filter((_: any, i: number) => i > index);
+
+    const data = {
+      type: node.type,
+      data: {
+        ...node.data,
+        content: [
+          {
+            ...splitItem,
+            text: afterText
+          },
+          ...removeItems
+        ]
+      }
+    };
+
+    const newBlock = this.operation.splitNode(node.id, {
+      path: ['data', 'content'],
+      value: retainItems,
+    }, data);
+    newBlock && this.selectionManager.focusStart(newBlock.id);
+  }
+
+  destroy() {
+    this.selectionManager.destroy();
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    this.operation = null;
+  }
+
+}

+ 35 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/blocks/text_block/text_selection.ts

@@ -0,0 +1,35 @@
+export class TextBlockSelectionManager {
+  private focusId = '';
+  private selection?: any;
+
+  getFocusSelection() {
+    return {
+      focusId: this.focusId,
+      selection: this.selection
+    }
+  }
+
+  focusStart(blockId: string) {
+    this.focusId = blockId;
+    this.setSelection(blockId, {
+      focus: {
+        path: [0, 0],
+        offset: 0,
+      },
+      anchor: {
+        path: [0, 0],
+        offset: 0,
+      },
+    })
+  }
+
+  setSelection(blockId: string, selection: any) {
+    this.focusId = blockId;
+    this.selection = selection;
+  }
+
+  destroy() {
+    this.focusId = '';
+    this.selection = undefined;
+  }
+}

+ 107 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/core/block.ts

@@ -0,0 +1,107 @@
+import { BlockType, BlockData } from '$app/interfaces/index';
+import { generateBlockId } from '$app/utils/block';
+
+/**
+ * Represents a single block of content in a document.
+ */
+export class Block<T extends BlockType = BlockType> {
+  id: string;
+  type: T;
+  data: BlockData<T>;
+  parent: Block<BlockType> | null = null; // Pointer to the parent block
+  prev: Block<BlockType> | null = null; // Pointer to the previous sibling block
+  next: Block<BlockType> | null = null; // Pointer to the next sibling block
+  firstChild: Block<BlockType> | null = null; // Pointer to the first child block
+
+  constructor(id: string, type: T, data: BlockData<T>) {
+    this.id = id;
+    this.type = type;
+    this.data = data;
+  }
+
+  /**
+   * Adds a new child block to the beginning of the current block's children list.
+   *
+   * @param {Object} content - The content of the new block, including its type and data.
+   * @param {string} content.type - The type of the new block.
+   * @param {Object} content.data - The data associated with the new block.
+   * @returns {Block} The newly created child block.
+   */
+  prependChild(content: { type: T, data: BlockData<T> }): Block | null {
+    const id = generateBlockId();
+    const newBlock = new Block(id, content.type, content.data);
+    newBlock.reposition(this, null);
+    return newBlock;
+  }
+
+  /**
+   * Add a new sibling block after this block.
+   * 
+   * @param content The type and data for the new sibling block.
+   * @returns The newly created sibling block.
+   */
+  addSibling(content: { type: T, data: BlockData<T> }): Block | null {
+    const id = generateBlockId();
+    const newBlock = new Block(id, content.type, content.data);
+    newBlock.reposition(this.parent, this);
+    return newBlock;
+  }
+
+  /**
+   * Remove this block and its descendants from the tree.
+   * 
+   */
+  remove() {
+    this.detach();
+    let child = this.firstChild;
+    while (child) {
+      const next = child.next;
+      child.remove();
+      child = next;
+    }
+  }
+
+  reposition(newParent: Block<BlockType> | null, newPrev: Block<BlockType> | null) {
+    // Update the block's parent and siblings
+    this.parent = newParent;
+    this.prev = newPrev;
+    this.next = null;
+
+    if (newParent) {
+      const prev = newPrev;
+      if (!prev) {
+        const next = newParent.firstChild;
+        newParent.firstChild = this;
+        if (next) {
+          this.next = next;
+          next.prev = this;
+        }
+        
+      } else {
+        // Update the next and prev pointers of the newPrev and next blocks
+        if (prev.next !== this) {
+          const next = prev.next;
+          if (next) {
+            next.prev = this
+            this.next = next;
+          }
+          prev.next = this;
+        }
+      }
+      
+    }
+  }
+
+  // detach the block from its current position in the tree
+  detach() {
+    if (this.prev) {
+      this.prev.next = this.next;
+    } else if (this.parent) {
+      this.parent.firstChild = this.next;
+    }
+    if (this.next) {
+      this.next.prev = this.prev;
+    }
+  }
+
+}

+ 225 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/core/block_chain.ts

@@ -0,0 +1,225 @@
+import { BlockData, BlockInterface, BlockType } from '$app/interfaces/index';
+import { set } from '../../utils/tool';
+import { Block } from './block';
+export interface BlockChangeProps {
+  block?: Block,
+  startBlock?: Block,
+  endBlock?: Block,
+  oldParentId?: string,
+  oldPrevId?: string
+}
+export class BlockChain {
+  private map: Map<string, Block<BlockType>> = new Map();
+  public head: Block<BlockType> | null = null;
+
+  constructor(private onBlockChange: (command: string, data: BlockChangeProps) => void) {
+
+  }
+  /**
+   * generate blocks from doc data
+   * @param id doc id
+   * @param map doc data
+   */
+  rebuild = (id: string, map: Record<string, BlockInterface<BlockType>>) => {
+    this.map.clear();
+    this.head = this.createBlock(id, map[id].type, map[id].data);
+
+    const callback = (block: Block) => {
+      const firstChildId = map[block.id].firstChild;
+      const nextId = map[block.id].next;
+      if (!block.firstChild && firstChildId) {
+        block.firstChild = this.createBlock(firstChildId, map[firstChildId].type, map[firstChildId].data);
+        block.firstChild.parent = block;
+        block.firstChild.prev = null;
+      }
+      if (!block.next && nextId) {
+        block.next = this.createBlock(nextId, map[nextId].type, map[nextId].data);
+        block.next.parent = block.parent;
+        block.next.prev = block;
+      }
+    }
+    this.traverse(callback);
+  }
+
+  /**
+   * Traversing the block list from front to back
+   * @param callback It will be call when the block visited
+   * @param block block item, it will be equal head node when the block item is undefined
+   */
+  traverse(callback: (_block: Block<BlockType>) => void, block?: Block<BlockType>) {
+    let currentBlock: Block | null = block || this.head;
+    while (currentBlock) {
+      callback(currentBlock);
+      if (currentBlock.firstChild) {
+        this.traverse(callback, currentBlock.firstChild);
+      }
+      currentBlock = currentBlock.next;
+    }
+  }
+
+  /**
+   * get block data
+   * @param blockId string
+   * @returns Block
+   */
+  getBlock = (blockId: string) => {
+    return this.map.get(blockId) || null;
+  }
+
+  destroy() {
+    this.map.clear();
+    this.head = null;
+    this.onBlockChange = () => null;
+  }
+
+  /**
+   * Adds a new child block to the beginning of the current block's children list.
+   *
+   * @param {string} parentId
+   * @param {Object} content - The content of the new block, including its type and data.
+   * @param {string} content.type - The type of the new block.
+   * @param {Object} content.data - The data associated with the new block.
+   * @returns {Block} The newly created child block.
+   */
+  prependChild(blockId: string, content: { type: BlockType, data: BlockData<BlockType> }): Block | null {
+    const parent = this.getBlock(blockId);
+    if (!parent) return null;
+    const newBlock = parent.prependChild(content);
+
+    if (newBlock) {
+      this.map.set(newBlock?.id, newBlock);
+      this.onBlockChange('insert', { block: newBlock });
+    }
+
+    return newBlock;
+  }
+
+  /**
+   * Add a new sibling block after this block.
+   * @param {string} blockId
+   * @param content The type and data for the new sibling block.
+   * @returns The newly created sibling block.
+   */
+  addSibling(blockId: string, content: { type: BlockType, data: BlockData<BlockType> }): Block | null {
+    const block = this.getBlock(blockId);
+    if (!block) return null;
+    const newBlock = block.addSibling(content);
+    if (newBlock) {
+      this.map.set(newBlock?.id, newBlock);
+      this.onBlockChange('insert', { block: newBlock });
+    }
+    return newBlock;
+  }
+
+  /**
+   * Remove this block and its descendants from the tree.
+   * @param {string} blockId
+   */
+  remove(blockId: string) {
+    const block = this.getBlock(blockId);
+    if (!block) return;
+    block.remove();
+    this.map.delete(block.id);
+    this.onBlockChange('delete', { block });
+    return block;
+  }
+
+  /**
+   * Move this block to a new position in the tree.
+   * @param {string} blockId
+   * @param newParentId The new parent block of this block. If null, the block becomes a top-level block.
+   * @param newPrevId The new previous sibling block of this block. If null, the block becomes the first child of the new parent.
+   * @returns This block after it has been moved.
+   */
+  move(blockId: string, newParentId: string, newPrevId: string): Block | null {
+    const block = this.getBlock(blockId);
+    if (!block) return null;
+    const oldParentId = block.parent?.id;
+    const oldPrevId = block.prev?.id;
+    block.detach();
+    const newParent = this.getBlock(newParentId);
+    const newPrev = this.getBlock(newPrevId);
+    block.reposition(newParent, newPrev);
+    this.onBlockChange('move', {
+      block,
+      oldParentId,
+      oldPrevId
+    });
+    return block;
+  }
+
+  updateBlock(id: string, data: { path: string[], value: any }) {
+    const block = this.getBlock(id);
+    if (!block) return null;
+    
+    set(block, data.path, data.value);
+    this.onBlockChange('update', {
+      block
+    });
+    return block;
+  }
+
+
+  moveBulk(startBlockId: string, endBlockId: string, newParentId: string, newPrevId: string): [Block, Block] | null {
+    const startBlock = this.getBlock(startBlockId);
+    const endBlock = this.getBlock(endBlockId);
+    if (!startBlock || !endBlock) return null;
+
+    if (startBlockId === endBlockId) {
+      const block = this.move(startBlockId, newParentId, '');
+      if (!block) return null;
+      return [block, block];
+    }
+
+    const oldParent = startBlock.parent;
+    const prev = startBlock.prev;
+    const newParent = this.getBlock(newParentId);
+    if (!oldParent || !newParent) return null;
+
+    if (oldParent.firstChild === startBlock) {
+      oldParent.firstChild = endBlock.next;
+    } else if (prev) {
+      prev.next = endBlock.next;
+    }
+    startBlock.prev = null;
+    endBlock.next = null;
+
+    startBlock.parent = newParent;
+    endBlock.parent = newParent;
+    const newPrev = this.getBlock(newPrevId);
+    if (!newPrev) {
+      const firstChild = newParent.firstChild;
+      newParent.firstChild = startBlock;
+      if (firstChild) {
+        endBlock.next = firstChild;
+        firstChild.prev = endBlock;
+      }
+    } else {
+      const next = newPrev.next;
+      newPrev.next = startBlock;
+      endBlock.next = next;
+      if (next) {
+        next.prev = endBlock;
+      }
+    }
+
+    this.onBlockChange('move', {
+      startBlock,
+      endBlock,
+      oldParentId: oldParent.id,
+      oldPrevId: prev?.id
+    });
+    
+    return [
+      startBlock,
+      endBlock
+    ];
+  }
+
+
+  private createBlock(id: string, type: BlockType, data: BlockData<BlockType>) {
+    const block = new Block(id, type, data);
+    this.map.set(id, block);
+    return block;
+  }
+}

+ 16 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/core/op_adapter.ts

@@ -0,0 +1,16 @@
+import { BackendOp, LocalOp } from "$app/interfaces";
+
+export class OpAdapter {
+
+  toBackendOp(localOp: LocalOp): BackendOp {
+    const backendOp: BackendOp = { ...localOp };
+    // switch localOp type and generate backendOp
+    return backendOp;
+  }
+
+  toLocalOp(backendOp: BackendOp): LocalOp {
+    const localOp: LocalOp = { ...backendOp };
+    // switch backendOp type and generate localOp
+    return localOp;
+  }
+}

+ 153 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/core/operation.ts

@@ -0,0 +1,153 @@
+import { BlockChain } from './block_chain';
+import { BlockInterface, BlockType, InsertOpData, LocalOp, UpdateOpData, moveOpData, moveRangeOpData, removeOpData, BlockData } from '$app/interfaces';
+import { BlockEditorSync } from './sync';
+import { Block } from './block';
+
+export class Operation {
+  private sync: BlockEditorSync;
+  constructor(private blockChain: BlockChain) {
+    this.sync = new BlockEditorSync();
+  }
+
+
+  splitNode(
+    retainId: string,
+    retainData: { path: string[], value: any },
+    newBlockData: {
+      type: BlockType;
+      data: BlockData
+    }) {
+    const ops: {
+      type: LocalOp['type'];
+      data: LocalOp['data'];
+    }[] = [];
+    const newBlock = this.blockChain.addSibling(retainId, newBlockData);
+    const parentId = newBlock?.parent?.id;
+    const retainBlock = this.blockChain.getBlock(retainId);
+    if (!newBlock || !parentId || !retainBlock) return null;
+
+    const insertOp = this.getInsertNodeOp({
+      id: newBlock.id,
+      next: newBlock.next?.id || null,
+      firstChild: newBlock.firstChild?.id || null,
+      data: newBlock.data,
+      type: newBlock.type,
+    }, parentId, retainId);
+
+    const updateOp = this.getUpdateNodeOp(retainId, retainData.path, retainData.value);
+    this.blockChain.updateBlock(retainId, retainData);
+
+    ops.push(insertOp, updateOp);
+    const startBlock = retainBlock.firstChild;
+    if (startBlock) {
+      const startBlockId = startBlock.id;
+      let next: Block | null = startBlock.next;
+      let endBlockId = startBlockId;
+      while (next) {
+        endBlockId = next.id;
+        next = next.next;
+      }
+      
+      const moveOp = this.getMoveRangeOp([startBlockId, endBlockId], newBlock.id);
+      this.blockChain.moveBulk(startBlockId, endBlockId, newBlock.id, '');
+      ops.push(moveOp);
+    }
+
+    this.sync.sendOps(ops);
+
+    return newBlock;
+  }
+
+  updateNode<T>(blockId: string, path: string[], value: T) {
+    const op = this.getUpdateNodeOp(blockId, path, value);
+    this.blockChain.updateBlock(blockId, {
+      path,
+      value
+    });
+    this.sync.sendOps([op]);
+  }
+  private getUpdateNodeOp<T>(blockId: string, path: string[], value: T): {
+    type: 'update',
+    data: UpdateOpData
+  } {
+    return {
+      type: 'update',
+      data: {
+        blockId,
+        path: path,
+        value
+      }
+    };
+  }
+
+  private getInsertNodeOp<T extends BlockInterface>(block: T, parentId: string, prevId?: string): {
+    type: 'insert';
+    data: InsertOpData
+  } {
+    return {
+      type: 'insert',
+      data: {
+        block,
+        parentId,
+        prevId
+      }
+    }
+  }
+
+  private getMoveRangeOp(range: [string, string], newParentId: string, newPrevId?: string): {
+    type: 'move_range',
+    data: moveRangeOpData
+  } {
+    return {
+      type: 'move_range',
+      data: {
+        range,
+        newParentId,
+        newPrevId,
+      }
+    }
+  }
+
+  private getMoveOp(blockId: string, newParentId: string, newPrevId?: string): {
+    type: 'move',
+    data: moveOpData
+  } {
+    return {
+      type: 'move',
+      data: {
+        blockId,
+        newParentId,
+        newPrevId
+      }
+    }
+  }
+
+  private getRemoveOp(blockId: string): {
+    type: 'remove'
+    data: removeOpData
+  } {
+    return {
+      type: 'remove',
+      data: {
+        blockId
+      }
+    }
+  }
+
+  applyOperation(op: LocalOp) {
+    switch (op.type) {
+      case 'insert':
+
+        break;
+
+      default:
+        break;
+    }
+  }
+
+  destroy() {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    this.blockChain = null;
+  }
+}

+ 48 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/core/sync.ts

@@ -0,0 +1,48 @@
+import { BackendOp, LocalOp } from '$app/interfaces';
+import { OpAdapter } from './op_adapter';
+
+/**
+ * BlockEditorSync is a class that synchronizes changes made to a block chain with a server.
+ * It allows for adding, removing, and moving blocks in the chain, and sends pending operations to the server.
+ */
+export class BlockEditorSync {
+  private version = 0;
+  private opAdapter: OpAdapter;
+  private pendingOps: BackendOp[] = [];
+  private appliedOps: LocalOp[] = [];
+  
+  constructor() {
+    this.opAdapter = new OpAdapter();
+  }
+
+  private applyOp(op: BackendOp): void {
+    const localOp = this.opAdapter.toLocalOp(op);
+    this.appliedOps.push(localOp);
+  }
+
+  private receiveOps(ops: BackendOp[]): void {
+    // Apply the incoming operations to the local document
+    ops.sort((a, b) => a.version - b.version);
+    for (const op of ops) {
+      this.applyOp(op);
+    }
+  }
+
+  private resolveConflict(): void {
+    // Implement conflict resolution logic here
+  }
+
+  public sendOps(ops: {
+    type: LocalOp["type"];
+    data: LocalOp["data"]
+  }[]) {
+    const backendOps = ops.map(op => this.opAdapter.toBackendOp({
+      ...op,
+      version: this.version
+    }));
+    this.pendingOps.push(...backendOps);
+    // Send the pending operations to the server
+    console.log('==== sync pending ops ====', [...this.pendingOps]);
+  }
+
+}

+ 40 - 28
frontend/appflowy_tauri/src/appflowy_app/block_editor/index.ts

@@ -1,48 +1,60 @@
+// Import dependencies
 import { BlockInterface } from '../interfaces';
-import { BlockDataManager } from './block';
-import { TreeManager } from './tree';
+import { BlockChain, BlockChangeProps } from './core/block_chain';
+import { RenderTree } from './view/tree';
+import { Operation } from './core/operation';
 
 /**
- * BlockEditor is a document data manager that operates on and renders data through managing blockData and RenderTreeManager.
- * The render tree will be re-render and update react component when block makes changes to the data.
- * RectManager updates the cache of node rect when the react component update is completed.
+ * The BlockEditor class manages a block chain and a render tree for a document editor.
+ * The block chain stores the content blocks of the document in sequence, while the
+ * render tree displays the document as a hierarchical tree structure.
  */
 export class BlockEditor {
-  // blockData manages document block data, including operations such as add, delete, update, and move.
-  public blockData: BlockDataManager;
-  // RenderTreeManager manages data rendering, including the construction and updating of the render tree.
-  public renderTree: TreeManager;
-
-  constructor(private id: string, data: Record<string, BlockInterface>) {
-    this.blockData = new BlockDataManager(id, data);
-    this.renderTree = new TreeManager(this.blockData.getBlock);
+  // Public properties
+  public blockChain: BlockChain; // (local data) the block chain used to store the document
+  public renderTree: RenderTree; // the render tree used to display the document
+  public operation: Operation;
+  /**
+   * Constructs a new BlockEditor object.
+   * @param id - the ID of the document
+   * @param data - the initial data for the document
+   */
+  constructor(private id: string, data: Record<string, BlockInterface>) {    
+    // Create the block chain and render tree
+    this.blockChain = new BlockChain(this.blockChange);
+    this.operation = new Operation(this.blockChain);
+    this.changeDoc(id, data);
+
+    this.renderTree = new RenderTree(this.blockChain);
   }
 
   /**
-   * update id and map when the doc is change
-   * @param id 
-   * @param data 
+   * Updates the document ID and block chain when the document changes.
+   * @param id - the new ID of the document
+   * @param data - the updated data for the document
    */
   changeDoc = (id: string, data: Record<string, BlockInterface>) => {
-    console.log('==== change document ====', id, data)
+    console.log('==== change document ====', id, data);
+    
+    // Update the document ID and rebuild the block chain
     this.id = id;
-    this.blockData.setBlocksMap(id, data);
+    this.blockChain.rebuild(id, data);
   }
 
+
+  /**
+   * Destroys the block chain and render tree.
+   */
   destroy = () => {
+    // Destroy the block chain and render tree
+    this.blockChain.destroy();
     this.renderTree.destroy();
-    this.blockData.destroy();
+    this.operation.destroy();
   }
-  
-}
 
-let blockEditorInstance: BlockEditor | null;
+  private blockChange = (command: string, data: BlockChangeProps) => {
+    this.renderTree.onBlockChange(command, data);
+  }
 
-export function getBlockEditor() {
-  return blockEditorInstance;
 }
 
-export function createBlockEditor(id: string, data: Record<string, BlockInterface>) {
-  blockEditorInstance = new BlockEditor(id, data);
-  return blockEditorInstance;
-}

+ 0 - 66
frontend/appflowy_tauri/src/appflowy_app/block_editor/rect.ts

@@ -1,66 +0,0 @@
-import { TreeNodeInterface } from "../interfaces";
-
-
-export function calculateBlockRect(blockId: string) {
-  const el = document.querySelectorAll(`[data-block-id=${blockId}]`)[0];
-  return el?.getBoundingClientRect();
-}
-
-export class RectManager {
-  map: Map<string, DOMRect>;
-
-  orderList: Set<string>;
-
-  private updatedQueue: Set<string>;
-
-  constructor(private getTreeNode: (nodeId: string) => TreeNodeInterface | null) {
-    this.map = new Map();
-    this.orderList = new Set();
-    this.updatedQueue = new Set();
-  }
-
-  build() {
-    console.log('====update all blocks position====')
-    this.orderList.forEach(id => this.updateNodeRect(id));
-  }
-
-  getNodeRect = (nodeId: string) => {
-    return this.map.get(nodeId) || null;
-  }
-
-  update() {
-    // In order to avoid excessive calculation frequency
-    // calculate and update the block position information in the queue every frame
-    requestAnimationFrame(() => {
-      // there is nothing to do if the updated queue is empty
-      if (this.updatedQueue.size === 0) return;
-      console.log(`==== update ${this.updatedQueue.size} blocks rect cache ====`)
-      this.updatedQueue.forEach((id: string) => {
-        const rect = calculateBlockRect(id);
-        this.map.set(id, rect);
-        this.updatedQueue.delete(id);
-      });
-    });
-  }
-
-  updateNodeRect = (nodeId: string) => {
-    if (this.updatedQueue.has(nodeId)) return;
-    let node: TreeNodeInterface | null = this.getTreeNode(nodeId);
-
-    // When one of the blocks is updated
-    // the positions of all its parent and child blocks need to be updated
-    while(node) {
-      node.parent?.children.forEach(child => this.updatedQueue.add(child.id));
-      node = node.parent;
-    }
-
-    this.update();
-  }
-
-  destroy() {
-    this.map.clear();
-    this.orderList.clear();
-    this.updatedQueue.clear();
-  }
-  
-}

+ 0 - 140
frontend/appflowy_tauri/src/appflowy_app/block_editor/tree.ts

@@ -1,140 +0,0 @@
-import { RectManager } from "./rect";
-import { BlockInterface, BlockData, BlockType, TreeNodeInterface } from '../interfaces/index';
-
-export class TreeManager {
-
-  // RenderTreeManager holds RectManager, which manages the position information of each node in the render tree.
-  private rect: RectManager;
-
-  root: TreeNode | null = null;
-
-  map: Map<string, TreeNode> = new Map();
-
-  constructor(private getBlock: (blockId: string) => BlockInterface | null) {
-    this.rect = new RectManager(this.getTreeNode);
-  }
-
-  /**
-   * Get render node data by nodeId
-   * @param nodeId string
-   * @returns TreeNode
-   */
-  getTreeNode = (nodeId: string): TreeNodeInterface | null => {
-    return this.map.get(nodeId) || null;
-  }
-
-  /**
-   * build tree node for rendering
-   * @param rootId 
-   * @returns 
-   */
-  build(rootId: string): TreeNode | null {
-    const head = this.getBlock(rootId);
-
-    if (!head) return null;
-
-    this.root = new TreeNode(head);
-
-    let node = this.root;
-
-    // loop line
-    while (node) {
-      this.map.set(node.id, node);
-      this.rect.orderList.add(node.id);
-
-      const block = this.getBlock(node.id)!;
-      const next = block.next ? this.getBlock(block.next) : null;
-      const firstChild = block.firstChild ? this.getBlock(block.firstChild) : null;
-
-      // find next line
-      if (firstChild) {
-        // the next line is node's first child
-        const child = new TreeNode(firstChild);
-        node.addChild(child);
-        node = child;
-      } else if (next) {
-        // the next line is node's sibling
-        const sibling = new TreeNode(next);
-        node.parent?.addChild(sibling);
-        node = sibling;
-      } else {
-        // the next line is parent's sibling
-        let isFind = false;
-        while(node.parent) {
-          const parentId = node.parent.id;
-          const parent = this.getBlock(parentId)!;
-          const parentNext = parent.next ? this.getBlock(parent.next) : null;
-          if (parentNext) {
-            const parentSibling = new TreeNode(parentNext);
-            node.parent?.parent?.addChild(parentSibling);
-            node = parentSibling;
-            isFind = true;
-            break;
-          } else {
-            node = node.parent;
-          }
-        }
-
-        if (!isFind) {
-          // Exit if next line not found
-          break;
-        }
-        
-      }
-    }
-
-    return this.root;
-  }
-
-  /**
-  * update dom rects cache
-  */
-  updateRects = () => {
-    this.rect.build();
-  }
-
-  /**
-   * get block rect cache
-   * @param id string
-   * @returns DOMRect
-   */
-  getNodeRect = (nodeId: string) => {
-    return this.rect.getNodeRect(nodeId);
-  }
-
-  /**
-   * update block rect cache
-   * @param id string
-   */
-  updateNodeRect = (nodeId: string) => {
-    this.rect.updateNodeRect(nodeId);
-  }
-  
-  destroy() {
-    this.rect?.destroy();
-  }
-}
-
-
-class TreeNode implements TreeNodeInterface {
-  id: string;
-  type: BlockType;
-  parent: TreeNode | null = null;
-  children: TreeNode[] = [];
-  data: BlockData<BlockType>;
-
-  constructor({
-    id,
-    type,
-    data
-  }: BlockInterface) {
-    this.id = id;
-    this.data = data;
-    this.type = type;
-  }
-
-  addChild(node: TreeNode) {
-    node.parent = this;
-    this.children.push(node);
-  }
-}

+ 73 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/view/block_position.ts

@@ -0,0 +1,73 @@
+import { RegionGrid, BlockPosition } from './region_grid';
+export class BlockPositionManager {
+  private regionGrid: RegionGrid;
+  private viewportBlocks: Set<string> = new Set();
+  private blockPositions: Map<string, BlockPosition> = new Map();
+  private observer: IntersectionObserver;
+  private container: HTMLDivElement | null = null;
+
+  constructor(container: HTMLDivElement) {
+    this.container = container;
+    this.regionGrid = new RegionGrid(container.offsetHeight);
+    this.observer = new IntersectionObserver((entries) => {
+      for (const entry of entries) {
+        const blockId = entry.target.getAttribute('data-block-id');
+        if (!blockId) return;
+        if (entry.isIntersecting) {
+          this.updateBlockPosition(blockId);
+          this.viewportBlocks.add(blockId);
+        } else {
+          this.viewportBlocks.delete(blockId);
+        }
+      }
+    }, { root: container });
+  }
+
+  observeBlock(node: HTMLDivElement) {
+    this.observer.observe(node);
+    return {
+      unobserve: () => this.observer.unobserve(node),
+    }
+  }
+
+  getBlockPosition(blockId: string) {
+    if (!this.blockPositions.has(blockId)) {
+      this.updateBlockPosition(blockId);
+    }
+    return this.blockPositions.get(blockId);
+  }
+
+  updateBlockPosition(blockId: string) {
+    if (!this.container) return;
+    const node = document.querySelector(`[data-block-id=${blockId}]`) as HTMLDivElement;
+    if (!node) return;
+    const rect = node.getBoundingClientRect();
+    const position = {
+      id: blockId,
+      x: rect.x,
+      y: rect.y + this.container.scrollTop,
+      height: rect.height,
+      width: rect.width
+    };
+    const prevPosition =  this.blockPositions.get(blockId);
+    if (prevPosition && prevPosition.x === position.x &&
+      prevPosition.y === position.y &&
+      prevPosition.height === position.height &&
+      prevPosition.width === position.width) {
+      return;
+    }
+    this.blockPositions.set(blockId, position);
+    this.regionGrid.removeBlock(blockId);
+    this.regionGrid.addBlock(position);
+  }
+
+  getIntersectBlocks(startX: number, startY: number, endX: number, endY: number): BlockPosition[] {
+    return this.regionGrid.getIntersectBlocks(startX, startY, endX, endY);
+  }
+
+  destroy() {
+    this.container = null;
+    this.observer.disconnect();
+  }
+
+}

+ 81 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/view/region_grid.ts

@@ -0,0 +1,81 @@
+export interface BlockPosition {
+  id: string;
+  x: number;
+  y: number;
+  height: number;
+  width: number;
+}
+interface BlockRegion {
+  regionX: number;
+  regionY: number;
+  blocks: BlockPosition[];
+}
+
+export class RegionGrid {
+  private regions: BlockRegion[][];
+  private regionSize: number;
+
+  constructor(regionSize: number) {
+    this.regionSize = regionSize;
+    this.regions = [];
+  }
+
+  addBlock(blockPosition: BlockPosition) {
+    const regionX = Math.floor(blockPosition.x / this.regionSize);
+    const regionY = Math.floor(blockPosition.y / this.regionSize);
+
+    let region = this.regions[regionY]?.[regionX];
+    if (!region) {
+      region = {
+        regionX,
+        regionY,
+        blocks: []
+      };
+      if (!this.regions[regionY]) {
+        this.regions[regionY] = [];
+      }
+      this.regions[regionY][regionX] = region;
+    }
+
+    region.blocks.push(blockPosition);
+  }
+  
+  removeBlock(blockId: string) {
+    for (const rows of this.regions) {
+      for (const region of rows) {
+        if (!region) return;
+        const blockIndex = region.blocks.findIndex(b => b.id === blockId);
+        if (blockIndex !== -1) {
+          region.blocks.splice(blockIndex, 1);
+          return;
+        }
+      }
+    }
+  }
+  
+
+  getIntersectBlocks(startX: number, startY: number, endX: number, endY: number): BlockPosition[] {
+    const selectedBlocks: BlockPosition[] = [];
+
+    const startRegionX = Math.floor(startX / this.regionSize);
+    const startRegionY = Math.floor(startY / this.regionSize);
+    const endRegionX = Math.floor(endX / this.regionSize);
+    const endRegionY = Math.floor(endY / this.regionSize);
+
+    for (let y = startRegionY; y <= endRegionY; y++) {
+      for (let x = startRegionX; x <= endRegionX; x++) {
+        const region = this.regions[y]?.[x];
+        if (region) {
+          for (const block of region.blocks) {
+            if (block.x + block.width - 1 >= startX && block.x <= endX &&
+              block.y + block.height - 1 >= startY && block.y <= endY) {
+              selectedBlocks.push(block);
+            }
+          }
+        }
+      }
+    }
+
+    return selectedBlocks;
+  }
+}

+ 165 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/view/tree.ts

@@ -0,0 +1,165 @@
+import { BlockChain, BlockChangeProps } from '../core/block_chain';
+import { Block } from '../core/block';
+import { TreeNode } from "./tree_node";
+import { BlockPositionManager } from './block_position';
+import { filterSelections } from '@/appflowy_app/utils/block_selection';
+
+export class RenderTree {
+  public blockPositionManager?: BlockPositionManager;
+
+  private map: Map<string, TreeNode> = new Map();
+  private root: TreeNode | null = null;
+  private selections: Set<string> = new Set();
+  constructor(private blockChain: BlockChain) {
+  }
+
+
+  createPositionManager(container: HTMLDivElement) {
+    this.blockPositionManager = new BlockPositionManager(container);
+  }
+
+  observeBlock(node: HTMLDivElement) {
+    return this.blockPositionManager?.observeBlock(node);
+  }
+
+  getBlockPosition(nodeId: string) {
+    return this.blockPositionManager?.getBlockPosition(nodeId) || null;
+  }
+  /**
+   * Get the TreeNode data by nodeId
+   * @param nodeId string
+   * @returns TreeNode|null
+   */
+  getTreeNode = (nodeId: string): TreeNode | null => {
+    // Return the TreeNode instance from the map or null if it does not exist
+    return this.map.get(nodeId) || null;
+  }
+
+  private createNode(block: Block): TreeNode {
+    if (this.map.has(block.id)) {
+      return this.map.get(block.id)!;
+    }
+    const node = new TreeNode(block);
+    this.map.set(block.id, node);
+    return node;
+  }
+
+
+  buildDeep(rootId: string): TreeNode | null {
+    this.map.clear();
+    // Define a callback function for the blockChain.traverse() method
+    const callback = (block: Block) => {
+      // Check if the TreeNode instance already exists in the map
+      const node = this.createNode(block);
+
+      // Add the TreeNode instance to the map
+      this.map.set(block.id, node);
+
+      // Add the first child of the block as a child of the current TreeNode instance
+      const firstChild = block.firstChild;
+      if (firstChild) {
+        const child = this.createNode(firstChild);
+        node.addChild(child);
+        this.map.set(child.id, child);
+      }
+
+      // Add the next block as a sibling of the current TreeNode instance
+      const next = block.next;
+      if (next) {
+        const nextNode = this.createNode(next);
+        node.parent?.addChild(nextNode);
+        this.map.set(next.id, nextNode);
+      }
+    }
+
+    // Traverse the blockChain using the callback function
+    this.blockChain.traverse(callback);
+
+    // Get the root node from the map and return it
+    const root = this.map.get(rootId)!;
+    this.root = root;
+    return root || null;
+  }
+
+
+  forceUpdate(nodeId: string, shouldUpdateChildren = false) {
+    const block = this.blockChain.getBlock(nodeId);
+    if (!block) return null;
+    const node = this.createNode(block);
+    if (!node) return null;
+
+    if (shouldUpdateChildren) {
+      const children: TreeNode[] = [];
+      let childBlock = block.firstChild;
+
+      while(childBlock) {
+        const child = this.createNode(childBlock);
+        child.update(childBlock, child.children);
+        children.push(child);
+        childBlock = childBlock.next;
+      }
+
+      node.update(block, children);
+      node?.reRender();
+      node?.children.forEach(child => {
+        child.reRender();
+      })
+    } else {
+      node.update(block, node.children);
+      node?.reRender();
+    }
+  }
+
+  onBlockChange(command: string, data: BlockChangeProps) {
+    const { block, startBlock, endBlock, oldParentId = '', oldPrevId = '' } = data;
+    switch (command) {
+      case 'insert':
+        if (block?.parent) this.forceUpdate(block.parent.id, true);
+        break;
+      case 'update':
+        this.forceUpdate(block!.id);
+        break;
+      case 'move':
+        if (oldParentId) this.forceUpdate(oldParentId, true);
+        if (block?.parent) this.forceUpdate(block.parent.id, true);
+        if (startBlock?.parent) this.forceUpdate(startBlock.parent.id, true);
+        break;
+      default:
+        break;
+    }
+    
+  }
+
+  updateSelections(selections: string[]) {
+    const newSelections = filterSelections<TreeNode>(selections, this.map);
+
+    let isDiff = false;
+    if (newSelections.length !== this.selections.size) {
+      isDiff = true;
+    }
+
+    const selectedBlocksSet = new Set(newSelections);
+    if (Array.from(this.selections).some((id) => !selectedBlocksSet.has(id))) {
+      isDiff = true;
+    }
+
+    if (isDiff) {
+      const shouldUpdateIds = new Set([...this.selections, ...newSelections]);
+      this.selections = selectedBlocksSet;
+      shouldUpdateIds.forEach((id) => this.forceUpdate(id));
+    }
+  }
+
+  isSelected(nodeId: string) {
+    return this.selections.has(nodeId);
+  }
+
+  /**
+   * Destroy the RenderTreeRectManager instance
+   */
+  destroy() {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    this.blockChain = null;
+  }
+}

+ 59 - 0
frontend/appflowy_tauri/src/appflowy_app/block_editor/view/tree_node.ts

@@ -0,0 +1,59 @@
+import { BlockData, BlockType } from '$app/interfaces/index';
+import { Block } from '../core/block';
+
+/**
+ * Represents a node in a tree structure of blocks.
+ */
+export class TreeNode {
+  id: string;
+  type: BlockType;
+  parent: TreeNode | null = null;
+  children: TreeNode[] = [];
+  data: BlockData<BlockType>;
+
+  private forceUpdate?: () => void;
+
+  /**
+   * Create a new TreeNode instance.
+   * @param block - The block data used to create the node.
+   */
+  constructor(private _block: Block) {
+    this.id = _block.id;
+    this.data = _block.data;
+    this.type = _block.type;
+  }
+
+  registerUpdate(forceUpdate: () => void) {
+    this.forceUpdate = forceUpdate;
+  }
+
+  unregisterUpdate() {
+    this.forceUpdate = undefined;
+  }
+
+  reRender() {
+    this.forceUpdate?.();
+  }
+
+  update(block: Block, children: TreeNode[]) {
+    this.data = block.data;
+    this.children = [];
+    children.forEach(child => {
+      this.addChild(child);
+    })
+  }
+
+  /**
+   * Add a child node to the current node.
+   * @param node - The child node to add.
+   */
+  addChild(node: TreeNode) {
+    node.parent = this;
+    this.children.push(node);
+  }
+
+  get block() {
+    return this._block;
+  }
+ 
+}

+ 5 - 24
frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/FormatButton.tsx

@@ -1,31 +1,12 @@
-import { useSlate } from 'slate-react';
 import { toggleFormat, isFormatActive } from '@/appflowy_app/utils/slate/format';
 import IconButton from '@mui/material/IconButton';
 import Tooltip from '@mui/material/Tooltip';
-import { useMemo } from 'react';
-import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
-import { command, iconSize } from '$app/constants/toolbar';
 
-const FormatButton = ({ format, icon }: { format: string; icon: string }) => {
-  const editor = useSlate();
-
-  const renderComponent = useMemo(() => {
-    switch (icon) {
-      case 'bold':
-        return <FormatBold sx={iconSize} />;
-      case 'underlined':
-        return <FormatUnderlined sx={iconSize} />;
-      case 'italic':
-        return <FormatItalic sx={iconSize} />;
-      case 'code':
-        return <CodeOutlined sx={iconSize} />;
-      case 'strikethrough':
-        return <StrikethroughSOutlined sx={iconSize} />;
-      default:
-        break;
-    }
-  }, [icon]);
+import { command } from '$app/constants/toolbar';
+import FormatIcon from './FormatIcon';
+import { BaseEditor } from 'slate';
 
+const FormatButton = ({ editor, format, icon }: { editor: BaseEditor; format: string; icon: string }) => {
   return (
     <Tooltip
       slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
@@ -42,7 +23,7 @@ const FormatButton = ({ format, icon }: { format: string; icon: string }) => {
         sx={{ color: isFormatActive(editor, format) ? '#00BCF0' : 'white' }}
         onClick={() => toggleFormat(editor, format)}
       >
-        {renderComponent}
+        <FormatIcon icon={icon} />
       </IconButton>
     </Tooltip>
   );

+ 20 - 0
frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/FormatIcon.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
+import { iconSize } from '$app/constants/toolbar';
+
+export default function FormatIcon({ icon }: { icon: string }) {
+  switch (icon) {
+    case 'bold':
+      return <FormatBold sx={iconSize} />;
+    case 'underlined':
+      return <FormatUnderlined sx={iconSize} />;
+    case 'italic':
+      return <FormatItalic sx={iconSize} />;
+    case 'code':
+      return <CodeOutlined sx={iconSize} />;
+    case 'strikethrough':
+      return <StrikethroughSOutlined sx={iconSize} />;
+    default:
+      return null;
+  }
+}

+ 0 - 5
frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/components.tsx

@@ -1,5 +0,0 @@
-import ReactDOM from 'react-dom';
-export const Portal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => {
-  const root = document.querySelectorAll(`[data-block-id=${blockId}]`)[0];
-  return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null;
-};

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/index.hooks.ts

@@ -0,0 +1,36 @@
+import { useEffect, useRef } from 'react';
+import { useFocused, useSlate } from 'slate-react';
+import { calcToolbarPosition } from '@/appflowy_app/utils/slate/toolbar';
+import { TreeNode } from '$app/block_editor/view/tree_node';
+
+export function useHoveringToolbar({node}: {
+  node: TreeNode
+}) {
+  const editor = useSlate();
+  const inFocus = useFocused();
+  const ref = useRef<HTMLDivElement | null>(null);
+
+  useEffect(() => {
+    const el = ref.current;
+    if (!el) return;
+    const nodeRect = document.querySelector(`[data-block-id=${node.id}]`)?.getBoundingClientRect();
+
+    if (!nodeRect) return;
+    const position = calcToolbarPosition(editor, el, nodeRect);
+
+    if (!position) {
+      el.style.opacity = '0';
+      el.style.zIndex = '-1';
+    } else {
+      el.style.opacity = '1';
+      el.style.zIndex = '1';
+      el.style.top = position.top;
+      el.style.left = position.left;
+    }
+  });
+  return {
+    ref,
+    inFocus,
+    editor
+  }
+}

+ 5 - 24
frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/index.tsx

@@ -1,29 +1,10 @@
-import { useEffect, useRef } from 'react';
-import { useFocused, useSlate } from 'slate-react';
 import FormatButton from './FormatButton';
 import Portal from './Portal';
-import { calcToolbarPosition } from '@/appflowy_app/utils/slate/toolbar';
-
-const HoveringToolbar = ({ blockId }: { blockId: string }) => {
-  const editor = useSlate();
-  const inFocus = useFocused();
-  const ref = useRef<HTMLDivElement | null>(null);
-
-  useEffect(() => {
-    const el = ref.current;
-    if (!el) return;
-
-    const position = calcToolbarPosition(editor, el, blockId);
-
-    if (!position) {
-      el.style.opacity = '0';
-    } else {
-      el.style.opacity = '1';
-      el.style.top = position.top;
-      el.style.left = position.left;
-    }
-  });
+import { TreeNode } from '$app/block_editor/view/tree_node';
+import { useHoveringToolbar } from './index.hooks';
 
+const HoveringToolbar = ({ blockId, node }: { blockId: string; node: TreeNode }) => {
+  const { inFocus, ref, editor } = useHoveringToolbar({ node });
   if (!inFocus) return null;
 
   return (
@@ -40,7 +21,7 @@ const HoveringToolbar = ({ blockId }: { blockId: string }) => {
         }}
       >
         {['bold', 'italic', 'underlined', 'strikethrough', 'code'].map((format) => (
-          <FormatButton key={format} format={format} icon={format} />
+          <FormatButton key={format} editor={editor} format={format} icon={format} />
         ))}
       </div>
     </Portal>

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockComponent/BlockComponet.hooks.ts

@@ -0,0 +1,36 @@
+import { useEffect, useState, useRef, useContext } from 'react';
+
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+import { BlockContext } from '$app/utils/block';
+
+export function useBlockComponent({
+  node
+}: {
+  node: TreeNode
+}) {
+  const { blockEditor } = useContext(BlockContext);
+
+  const [version, forceUpdate] = useState<number>(0);
+  const myRef = useRef<HTMLDivElement | null>(null);
+
+  const isSelected = blockEditor?.renderTree.isSelected(node.id);
+
+  useEffect(() => {
+    if (!myRef.current) {
+      return;
+    }
+    const observe = blockEditor?.renderTree.observeBlock(myRef.current);
+    node.registerUpdate(() => forceUpdate((prev) => prev + 1));
+
+    return () => {
+      node.unregisterUpdate();
+      observe?.unobserve();
+    };
+  }, []);
+  return {
+    version,
+    myRef,
+    isSelected,
+    className: `relative my-[1px] px-1`
+  }
+}

+ 91 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockComponent/index.tsx

@@ -0,0 +1,91 @@
+import React, { forwardRef } from 'react';
+import { BlockCommonProps, BlockType } from '$app/interfaces';
+import PageBlock from '../PageBlock';
+import TextBlock from '../TextBlock';
+import HeadingBlock from '../HeadingBlock';
+import ListBlock from '../ListBlock';
+import CodeBlock from '../CodeBlock';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+import { withErrorBoundary } from 'react-error-boundary';
+import { ErrorBoundaryFallbackComponent } from '../BlockList/BlockList.hooks';
+import { useBlockComponent } from './BlockComponet.hooks';
+
+const BlockComponent = forwardRef(
+  (
+    {
+      node,
+      renderChild,
+      ...props
+    }: { node: TreeNode; renderChild?: (_node: TreeNode) => React.ReactNode } & React.DetailedHTMLProps<
+      React.HTMLAttributes<HTMLDivElement>,
+      HTMLDivElement
+    >,
+    ref: React.ForwardedRef<HTMLDivElement>
+  ) => {
+    const { myRef, className, version, isSelected } = useBlockComponent({
+      node,
+    });
+
+    const renderComponent = () => {
+      let BlockComponentClass: (_: BlockCommonProps<TreeNode>) => JSX.Element | null;
+      switch (node.type) {
+        case BlockType.PageBlock:
+          BlockComponentClass = PageBlock;
+          break;
+        case BlockType.TextBlock:
+          BlockComponentClass = TextBlock;
+          break;
+        case BlockType.HeadingBlock:
+          BlockComponentClass = HeadingBlock;
+          break;
+        case BlockType.ListBlock:
+          BlockComponentClass = ListBlock;
+          break;
+        case BlockType.CodeBlock:
+          BlockComponentClass = CodeBlock;
+          break;
+        default:
+          break;
+      }
+
+      const blockProps: BlockCommonProps<TreeNode> = {
+        version,
+        node,
+      };
+
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      if (BlockComponentClass) {
+        return <BlockComponentClass {...blockProps} />;
+      }
+      return null;
+    };
+
+    return (
+      <div
+        ref={(el: HTMLDivElement | null) => {
+          myRef.current = el;
+          if (typeof ref === 'function') {
+            ref(el);
+          } else if (ref) {
+            ref.current = el;
+          }
+        }}
+        {...props}
+        data-block-id={node.id}
+        data-block-selected={isSelected}
+        className={props.className ? `${props.className} ${className}` : className}
+      >
+        {renderComponent()}
+        {renderChild ? node.children.map(renderChild) : null}
+        <div className='block-overlay'></div>
+        {isSelected ? <div className='pointer-events-none absolute inset-0 z-[-1] rounded-[4px] bg-[#E0F8FF]' /> : null}
+      </div>
+    );
+  }
+);
+
+const ComponentWithErrorBoundary = withErrorBoundary(BlockComponent, {
+  FallbackComponent: ErrorBoundaryFallbackComponent,
+});
+export default React.memo(ComponentWithErrorBoundary);

+ 0 - 39
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockComponent.tsx

@@ -1,39 +0,0 @@
-import React from 'react';
-import { BlockType, TreeNodeInterface } from '$app/interfaces';
-import PageBlock from '../PageBlock';
-import TextBlock from '../TextBlock';
-import HeadingBlock from '../HeadingBlock';
-import ListBlock from '../ListBlock';
-import CodeBlock from '../CodeBlock';
-
-function BlockComponent({
-  node,
-  ...props
-}: { node: TreeNodeInterface } & React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
-  const renderComponent = () => {
-    switch (node.type) {
-      case BlockType.PageBlock:
-        return <PageBlock node={node} />;
-      case BlockType.TextBlock:
-        return <TextBlock node={node} />;
-      case BlockType.HeadingBlock:
-        return <HeadingBlock node={node} />;
-      case BlockType.ListBlock:
-        return <ListBlock node={node} />;
-      case BlockType.CodeBlock:
-        return <CodeBlock node={node} />;
-      default:
-        return null;
-    }
-  };
-
-  return (
-    <div className='relative' data-block-id={node.id} {...props}>
-      {renderComponent()}
-      {props.children}
-      <div className='block-overlay'></div>
-    </div>
-  );
-}
-
-export default React.memo(BlockComponent);

+ 92 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockList.hooks.tsx

@@ -0,0 +1,92 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { BlockEditor } from '@/appflowy_app/block_editor';
+import { TreeNode } from '$app/block_editor/view/tree_node';
+import { Alert } from '@mui/material';
+import { FallbackProps } from 'react-error-boundary';
+import { TextBlockManager } from '@/appflowy_app/block_editor/blocks/text_block';
+import { TextBlockContext } from '@/appflowy_app/utils/slate/context';
+import { useVirtualizer } from '@tanstack/react-virtual';
+export interface BlockListProps {
+  blockId: string;
+  blockEditor: BlockEditor;
+}
+
+const defaultSize = 45;
+
+export function useBlockList({ blockId, blockEditor }: BlockListProps) {
+  const [root, setRoot] = useState<TreeNode | null>(null);
+
+  const parentRef = useRef<HTMLDivElement>(null);
+
+  const rowVirtualizer = useVirtualizer({
+    count: root?.children.length || 0,
+    getScrollElement: () => parentRef.current,
+    overscan: 5,
+    estimateSize: () => {
+      return defaultSize;
+    },
+  });
+
+  const [version, forceUpdate] = useState<number>(0);
+
+  const buildDeepTree = useCallback(() => {
+    const treeNode = blockEditor.renderTree.buildDeep(blockId);
+    setRoot(treeNode);
+  }, [blockEditor]);
+
+  useEffect(() => {
+    if (!parentRef.current) return;
+    blockEditor.renderTree.createPositionManager(parentRef.current);
+    buildDeepTree();
+
+    return () => {
+      blockEditor.destroy();
+    };
+  }, [blockId, blockEditor]);
+
+  useEffect(() => {
+    root?.registerUpdate(() => forceUpdate((prev) => prev + 1));
+    return () => {
+      root?.unregisterUpdate();
+    };
+  }, [root]);
+
+  return {
+    root,
+    rowVirtualizer,
+    parentRef,
+    blockEditor,
+  };
+}
+
+export function ErrorBoundaryFallbackComponent({ error, resetErrorBoundary }: FallbackProps) {
+  return (
+    <Alert severity='error' className='mb-2'>
+      <p>Something went wrong:</p>
+      <pre>{error.message}</pre>
+      <button onClick={resetErrorBoundary}>Try again</button>
+    </Alert>
+  );
+}
+
+export function withTextBlockManager(Component: (props: BlockListProps) => React.ReactElement) {
+  return (props: BlockListProps) => {
+    const textBlockManager = new TextBlockManager(props.blockEditor.operation);
+
+    useEffect(() => {
+      return () => {
+        textBlockManager.destroy();
+      };
+    }, []);
+
+    return (
+      <TextBlockContext.Provider
+        value={{
+          textBlockManager,
+        }}
+      >
+        <Component {...props} />
+      </TextBlockContext.Provider>
+    );
+  };
+}

+ 18 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockListTitle.tsx

@@ -0,0 +1,18 @@
+import TextBlock from '../TextBlock';
+import { TreeNode } from '$app/block_editor/view/tree_node';
+
+export default function BlockListTitle({ node }: { node: TreeNode | null }) {
+  if (!node) return null;
+  return (
+    <div data-block-id={node.id} className='doc-title flex pt-[50px] text-4xl font-bold'>
+      <TextBlock
+        version={0}
+        toolbarProps={{
+          showGroups: [],
+        }}
+        node={node}
+        needRenderChildren={false}
+      />
+    </div>
+  );
+}

+ 31 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/ListFallbackComponent.tsx

@@ -0,0 +1,31 @@
+import * as React from 'react';
+import Typography, { TypographyProps } from '@mui/material/Typography';
+import Skeleton from '@mui/material/Skeleton';
+import Grid from '@mui/material/Grid';
+
+const variants = ['h1', 'h3', 'body1', 'caption'] as readonly TypographyProps['variant'][];
+
+export default function ListFallbackComponent() {
+  return (
+    <div id='appflowy-block-doc' className='doc-scroller-container flex h-[100%] flex-col items-center overflow-auto'>
+      <div className='doc-content min-x-[0%] p-lg w-[900px] max-w-[100%]'>
+        <div className='doc-title my-[50px] flex w-[100%] px-14 text-4xl font-bold'>
+          <Typography className='w-[100%]' component='div' key={'h1'} variant={'h1'}>
+            <Skeleton />
+          </Typography>
+        </div>
+        <div className='doc-body px-14' style={{ height: '100vh' }}>
+          <Grid container spacing={8}>
+            <Grid item xs>
+              {variants.map((variant) => (
+                <Typography component='div' key={variant} variant={variant}>
+                  <Skeleton />
+                </Typography>
+              ))}
+            </Grid>
+          </Grid>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 52 - 37
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/index.tsx

@@ -1,43 +1,58 @@
-import BlockComponent from './BlockComponent';
-import React, { useEffect } from 'react';
-import { debounce } from '@/appflowy_app/utils/tool';
-import { getBlockEditor } from '../../../block_editor';
-
-const RESIZE_DELAY = 200;
-
-function BlockList({ blockId }: { blockId: string }) {
-  const blockEditor = getBlockEditor();
-  if (!blockEditor) return null;
-
-  const root = blockEditor.renderTree.build(blockId);
-  console.log('==== build tree ====', root);
-
-  useEffect(() => {
-    // update rect cache when did mount
-    blockEditor.renderTree.updateRects();
-
-    const resize = debounce(() => {
-      // update rect cache when window resized
-      blockEditor.renderTree.updateRects();
-    }, RESIZE_DELAY);
-
-    window.addEventListener('resize', resize);
-
-    return () => {
-      window.removeEventListener('resize', resize);
-    };
-  }, []);
-
+import React from 'react';
+import { BlockListProps, useBlockList, withTextBlockManager } from './BlockList.hooks';
+import { withErrorBoundary } from 'react-error-boundary';
+import ListFallbackComponent from './ListFallbackComponent';
+import BlockListTitle from './BlockListTitle';
+import BlockComponent from '../BlockComponent';
+import BlockSelection from '../BlockSelection';
+
+function BlockList(props: BlockListProps) {
+  const { root, rowVirtualizer, parentRef, blockEditor } = useBlockList(props);
+
+  const virtualItems = rowVirtualizer.getVirtualItems();
   return (
-    <div className='min-x-[0%] p-lg w-[900px] max-w-[100%]'>
-      <div className='my-[50px] flex px-14 text-4xl font-bold'>{root?.data.title}</div>
-      <div className='px-14'>
-        {root && root.children.length > 0
-          ? root.children.map((node) => <BlockComponent key={node.id} node={node} />)
-          : null}
+    <div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
+      <div
+        ref={parentRef}
+        className={`doc-scroller-container flex h-[100%] flex-wrap items-center justify-center overflow-auto px-20`}
+      >
+        <div
+          className='doc-body max-w-screen w-[900px] min-w-0'
+          style={{
+            height: rowVirtualizer.getTotalSize(),
+            position: 'relative',
+          }}
+        >
+          {root && virtualItems.length ? (
+            <div
+              style={{
+                position: 'absolute',
+                top: 0,
+                left: 0,
+                width: '100%',
+                transform: `translateY(${virtualItems[0].start || 0}px)`,
+              }}
+            >
+              {virtualItems.map((virtualRow) => {
+                const id = root.children[virtualRow.index].id;
+                return (
+                  <div className='p-[1px]' key={id} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
+                    {virtualRow.index === 0 ? <BlockListTitle node={root} /> : null}
+                    <BlockComponent node={root.children[virtualRow.index]} />
+                  </div>
+                );
+              })}
+            </div>
+          ) : null}
+        </div>
       </div>
+      {parentRef.current ? <BlockSelection blockEditor={blockEditor} container={parentRef.current} /> : null}
     </div>
   );
 }
 
-export default React.memo(BlockList);
+const ListWithErrorBoundary = withErrorBoundary(withTextBlockManager(BlockList), {
+  FallbackComponent: ListFallbackComponent,
+});
+
+export default React.memo(ListWithErrorBoundary);

+ 137 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockSelection/BlockSelection.hooks.tsx

@@ -0,0 +1,137 @@
+import { BlockEditor } from '@/appflowy_app/block_editor';
+import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
+
+export function useBlockSelection({ container, blockEditor }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
+  const blockPositionManager = blockEditor.renderTree.blockPositionManager;
+
+  const [isDragging, setDragging] = useState(false);
+  const pointRef = useRef<number[]>([]);
+  const startScrollTopRef = useRef<number>(0);
+
+  const [rect, setRect] = useState<{
+    startX: number;
+    startY: number;
+    endX: number;
+    endY: number;
+  } | null>(null);
+
+  const style = useMemo(() => {
+    if (!rect) return;
+    const { startX, endX, startY, endY } = rect;
+    const x = Math.min(startX, endX);
+    const y = Math.min(startY, endY);
+    const width = Math.abs(endX - startX);
+    const height = Math.abs(endY - startY);
+    return {
+      left: x - container.scrollLeft + 'px',
+      top: y - container.scrollTop + 'px',
+      width: width + 'px',
+      height: height + 'px',
+    };
+  }, [rect]);
+
+  const isPointInBlock = useCallback((target: HTMLElement | null) => {
+    let node = target;
+    while (node) {
+      if (node.getAttribute('data-block-id')) {
+        return true;
+      }
+      node = node.parentElement;
+    }
+    return false;
+  }, []);
+
+  const handleDragStart = useCallback((e: MouseEvent) => {
+    if (isPointInBlock(e.target as HTMLElement)) {
+      return;
+    }
+    e.preventDefault();
+    setDragging(true);
+
+    const startX = e.clientX + container.scrollLeft;
+    const startY = e.clientY + container.scrollTop;
+    pointRef.current = [startX, startY];
+    startScrollTopRef.current = container.scrollTop;
+    setRect({
+      startX,
+      startY,
+      endX: startX,
+      endY: startY,
+    });
+  }, []);
+
+  const calcIntersectBlocks = useCallback(
+    (clientX: number, clientY: number) => {
+      if (!isDragging || !blockPositionManager) return;
+      const [startX, startY] = pointRef.current;
+      const endX = clientX + container.scrollLeft;
+      const endY = clientY + container.scrollTop;
+
+      setRect({
+        startX,
+        startY,
+        endX,
+        endY,
+      });
+      const selectedBlocks = blockPositionManager.getIntersectBlocks(
+        Math.min(startX, endX),
+        Math.min(startY, endY),
+        Math.max(startX, endX),
+        Math.max(startY, endY)
+      );
+      const ids = selectedBlocks.map((item) => item.id);
+      blockEditor.renderTree.updateSelections(ids);
+    },
+    [isDragging]
+  );
+
+  const handleDraging = useCallback(
+    (e: MouseEvent) => {
+      if (!isDragging || !blockPositionManager) return;
+      e.preventDefault();
+      calcIntersectBlocks(e.clientX, e.clientY);
+
+      const { top, bottom } = container.getBoundingClientRect();
+      if (e.clientY >= bottom) {
+        const delta = e.clientY - bottom;
+        container.scrollBy(0, delta);
+      } else if (e.clientY <= top) {
+        const delta = e.clientY - top;
+        container.scrollBy(0, delta);
+      }
+    },
+    [isDragging]
+  );
+
+  const handleDragEnd = useCallback(
+    (e: MouseEvent) => {
+      if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
+        blockEditor.renderTree.updateSelections([]);
+        return;
+      }
+      if (!isDragging) return;
+      e.preventDefault();
+      calcIntersectBlocks(e.clientX, e.clientY);
+      setDragging(false);
+      setRect(null);
+    },
+    [isDragging]
+  );
+
+  useEffect(() => {
+    window.addEventListener('mousedown', handleDragStart);
+    window.addEventListener('mousemove', handleDraging);
+    window.addEventListener('mouseup', handleDragEnd);
+
+    return () => {
+      window.removeEventListener('mousedown', handleDragStart);
+      window.removeEventListener('mousemove', handleDraging);
+      window.removeEventListener('mouseup', handleDragEnd);
+    };
+  }, [handleDragStart, handleDragEnd, handleDraging]);
+
+  return {
+    isDragging,
+    style,
+  };
+}

+ 18 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockSelection/index.tsx

@@ -0,0 +1,18 @@
+import { useBlockSelection } from './BlockSelection.hooks';
+import { BlockEditor } from '$app/block_editor';
+import React from 'react';
+
+function BlockSelection({ container, blockEditor }: { container: HTMLDivElement; blockEditor: BlockEditor }) {
+  const { isDragging, style } = useBlockSelection({
+    container,
+    blockEditor,
+  });
+
+  return (
+    <div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
+      {isDragging ? <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} /> : null}
+    </div>
+  );
+}
+
+export default React.memo(BlockSelection);

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/block/CodeBlock/index.tsx

@@ -1,6 +1,6 @@
-import React from 'react';
-import { TreeNodeInterface } from '$app/interfaces';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+import { BlockCommonProps } from '@/appflowy_app/interfaces';
 
-export default function CodeBlock({ node }: { node: TreeNodeInterface }) {
+export default function CodeBlock({ node }: BlockCommonProps<TreeNode>) {
   return <div>{node.data.text}</div>;
 }

+ 7 - 8
frontend/appflowy_tauri/src/appflowy_app/components/block/ColumnBlock/index.tsx

@@ -1,13 +1,14 @@
 import React from 'react';
-import { TreeNodeInterface } from '$app/interfaces/index';
-import BlockComponent from '../BlockList/BlockComponent';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+
+import BlockComponent from '../BlockComponent';
 
 export default function ColumnBlock({
   node,
   resizerWidth,
   index,
 }: {
-  node: TreeNodeInterface;
+  node: TreeNode;
   resizerWidth: number;
   index: number;
 }) {
@@ -16,6 +17,7 @@ export default function ColumnBlock({
       <div className={`relative w-[46px] flex-shrink-0 flex-grow-0 transition-opacity`} style={{ opacity: 0 }}></div>
     );
   };
+
   return (
     <>
       {index === 0 ? (
@@ -41,11 +43,8 @@ export default function ColumnBlock({
           width: `calc((100% - ${resizerWidth}px) * ${node.data.ratio})`,
         }}
         node={node}
-      >
-        {node.children?.map((item) => (
-          <BlockComponent key={item.id} node={item} />
-        ))}
-      </BlockComponent>
+        renderChild={(item) => <BlockComponent key={item.id} node={item} />}
+      />
     </>
   );
 }

+ 5 - 9
frontend/appflowy_tauri/src/appflowy_app/components/block/HeadingBlock/index.tsx

@@ -1,21 +1,17 @@
-import React from 'react';
 import TextBlock from '../TextBlock';
-import { TreeNodeInterface } from '$app/interfaces/index';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+import { BlockCommonProps } from '@/appflowy_app/interfaces';
 
 const fontSize: Record<string, string> = {
   1: 'mt-8 text-3xl',
   2: 'mt-6 text-2xl',
   3: 'mt-4 text-xl',
 };
-export default function HeadingBlock({ node }: { node: TreeNodeInterface }) {
+
+export default function HeadingBlock({ node, version }: BlockCommonProps<TreeNode>) {
   return (
     <div className={`${fontSize[node.data.level]} font-semibold	`}>
-      <TextBlock
-        node={{
-          ...node,
-          children: [],
-        }}
-      />
+      <TextBlock version={version} node={node} needRenderChildren={false} />
     </div>
   );
 }

+ 4 - 4
frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/BulletedListBlock.tsx

@@ -1,13 +1,13 @@
 import { Circle } from '@mui/icons-material';
 
-import BlockComponent from '../BlockList/BlockComponent';
-import { TreeNodeInterface } from '$app/interfaces/index';
+import BlockComponent from '../BlockComponent';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
 
-export default function BulletedListBlock({ title, node }: { title: JSX.Element; node: TreeNodeInterface }) {
+export default function BulletedListBlock({ title, node }: { title: JSX.Element; node: TreeNode }) {
   return (
     <div className='bulleted-list-block relative'>
       <div className='relative flex'>
-        <div className={`relative mb-2 min-w-[24px] leading-5`}>
+        <div className={`relative flex h-[calc(1.5em_+_3px_+_3px)] min-w-[24px] select-none items-center`}>
           <Circle sx={{ width: 8, height: 8 }} />
         </div>
         {title}

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/ColumnListBlock.tsx

@@ -1,8 +1,8 @@
-import { TreeNodeInterface } from '@/appflowy_app/interfaces';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
 import React, { useMemo } from 'react';
-import ColumnBlock from '../ColumnBlock/index';
+import ColumnBlock from '../ColumnBlock';
 
-export default function ColumnListBlock({ node }: { node: TreeNodeInterface }) {
+export default function ColumnListBlock({ node }: { node: TreeNode }) {
   const resizerWidth = useMemo(() => {
     return 46 * (node.children?.length || 0);
   }, [node.children?.length]);

+ 14 - 9
frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/NumberedListBlock.tsx

@@ -1,16 +1,21 @@
-import { TreeNodeInterface } from '@/appflowy_app/interfaces';
-import React, { useMemo } from 'react';
-import BlockComponent from '../BlockList/BlockComponent';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+import BlockComponent from '../BlockComponent';
+import { BlockType } from '@/appflowy_app/interfaces';
+import { Block } from '@/appflowy_app/block_editor/core/block';
 
-export default function NumberedListBlock({ title, node }: { title: JSX.Element; node: TreeNodeInterface }) {
-  const index = useMemo(() => {
-    const i = node.parent?.children?.findIndex((item) => item.id === node.id) || 0;
-    return i + 1;
-  }, [node]);
+export default function NumberedListBlock({ title, node }: { title: JSX.Element; node: TreeNode }) {
+  let prev = node.block.prev;
+  let index = 1;
+  while (prev && prev.type === BlockType.ListBlock && (prev as Block<BlockType.ListBlock>).data.type === 'numbered') {
+    index++;
+    prev = prev.prev;
+  }
   return (
     <div className='numbered-list-block'>
       <div className='relative flex'>
-        <div className={`relative mb-2 min-w-[24px] max-w-[24px]`}>{`${index} .`}</div>
+        <div
+          className={`relative flex h-[calc(1.5em_+_3px_+_3px)] min-w-[24px] max-w-[24px] select-none items-center`}
+        >{`${index} .`}</div>
         {title}
       </div>
 

+ 5 - 9
frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/index.tsx

@@ -3,22 +3,18 @@ import TextBlock from '../TextBlock';
 import NumberedListBlock from './NumberedListBlock';
 import BulletedListBlock from './BulletedListBlock';
 import ColumnListBlock from './ColumnListBlock';
-import { TreeNodeInterface } from '$app/interfaces/index';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+import { BlockCommonProps } from '@/appflowy_app/interfaces';
 
-export default function ListBlock({ node }: { node: TreeNodeInterface }) {
+export default function ListBlock({ node, version }: BlockCommonProps<TreeNode>) {
   const title = useMemo(() => {
     if (node.data.type === 'column') return <></>;
     return (
       <div className='flex-1'>
-        <TextBlock
-          node={{
-            ...node,
-            children: [],
-          }}
-        />
+        <TextBlock version={version} node={node} needRenderChildren={false} />
       </div>
     );
-  }, [node]);
+  }, [node, version]);
 
   if (node.data.type === 'numbered') {
     return <NumberedListBlock title={title} node={node} />;

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/block/PageBlock/index.tsx

@@ -1,6 +1,6 @@
-import React from 'react';
-import { TreeNodeInterface } from '$app/interfaces';
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+import { BlockCommonProps } from '@/appflowy_app/interfaces';
 
-export default function PageBlock({ node }: { node: TreeNodeInterface }) {
+export default function PageBlock({ node }: BlockCommonProps<TreeNode>) {
   return <div className='cursor-pointer underline'>{node.data.title}</div>;
 }

+ 98 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.hooks.ts

@@ -0,0 +1,98 @@
+import { TreeNode } from "@/appflowy_app/block_editor/view/tree_node";
+import { triggerHotkey } from "@/appflowy_app/utils/slate/hotkey";
+import { useCallback, useContext, useLayoutEffect, useState } from "react";
+import { Transforms, createEditor, Descendant } from 'slate';
+import { ReactEditor, withReact } from 'slate-react';
+import { TextBlockContext } from '$app/utils/slate/context';
+
+export function useTextBlock({
+  node,
+}: {
+  node: TreeNode;
+}) {
+  const [editor] = useState(() => withReact(createEditor()));
+
+  const { textBlockManager } = useContext(TextBlockContext);
+
+  const value = [
+    {
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      type: 'paragraph',
+      children: node.data.content,
+    },
+  ];
+
+
+  const onChange = useCallback(
+    (e: Descendant[]) => {
+      if (!editor.operations || editor.operations.length === 0) return;
+      if (editor.operations[0].type !== 'set_selection') {
+        console.log('====text block ==== ', editor.operations)
+        const children = 'children' in e[0] ? e[0].children : [];
+        textBlockManager?.update(node, ['data', 'content'], children);
+      } else {
+        const newProperties = editor.operations[0].newProperties;
+        textBlockManager?.setSelection(node, editor.selection);
+      }
+    },
+    [node.id, editor],
+  );
+  
+
+  const onKeyDownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
+    switch (event.key) {
+      case 'Enter': {
+        event.stopPropagation();
+        event.preventDefault();
+        textBlockManager?.splitNode(node, editor);
+
+        return;
+      }
+    }
+
+    triggerHotkey(event, editor);
+  }
+
+  
+
+  const { focusId, selection } = textBlockManager!.selectionManager.getFocusSelection();
+  
+  editor.children = value;
+  Transforms.collapse(editor);
+
+  useLayoutEffect(() => {
+    let timer: NodeJS.Timeout;
+    if (focusId === node.id && selection) {
+      ReactEditor.focus(editor);
+      Transforms.select(editor, selection);
+      // Use setTimeout to delay setting the selection
+      // until Slate has fully loaded and rendered all components and contents,
+      // to ensure that the operation succeeds.
+      timer = setTimeout(() => {
+        Transforms.select(editor, selection);
+      }, 100);
+    }
+    
+    return () => timer && clearTimeout(timer)
+  }, [editor]);
+
+  const onDOMBeforeInput = useCallback((e: InputEvent) => {
+    // COMPAT: in Apple, `compositionend` is dispatched after the
+    // `beforeinput` for "insertFromComposition". It will cause repeated characters when inputting Chinese.
+    // Here, prevent the beforeInput event and wait for the compositionend event to take effect
+    if (e.inputType === 'insertFromComposition') {
+      e.preventDefault();
+    }
+    
+  }, []);
+  
+  
+  return {
+    editor,
+    value,
+    onChange,
+    onKeyDownCapture,
+    onDOMBeforeInput,
+  }
+}

+ 26 - 66
frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.tsx

@@ -1,77 +1,37 @@
-import React, { useContext, useMemo, useState } from 'react';
-import { TreeNodeInterface } from '$app/interfaces';
-import BlockComponent from '../BlockList/BlockComponent';
-
-import { createEditor } from 'slate';
-import { Slate, Editable, withReact } from 'slate-react';
+import BlockComponent from '../BlockComponent';
+import { Slate, Editable } from 'slate-react';
 import Leaf from './Leaf';
 import HoveringToolbar from '$app/components/HoveringToolbar';
-import { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey';
-import { BlockContext } from '$app/utils/block_context';
-import { debounce } from '@/appflowy_app/utils/tool';
-import { getBlockEditor } from '@/appflowy_app/block_editor/index';
-
-const INPUT_CHANGE_CACHE_DELAY = 300;
-
-export default function TextBlock({ node }: { node: TreeNodeInterface }) {
-  const blockEditor = getBlockEditor();
-  if (!blockEditor) return null;
-
-  const [editor] = useState(() => withReact(createEditor()));
-
-  const { id } = useContext(BlockContext);
-
-  const debounceUpdateBlockCache = useMemo(
-    () => debounce(blockEditor.renderTree.updateNodeRect, INPUT_CHANGE_CACHE_DELAY),
-    [id, node.id]
-  );
+import { TreeNode } from '@/appflowy_app/block_editor/view/tree_node';
+import { useTextBlock } from './index.hooks';
+import { BlockCommonProps, TextBlockToolbarProps } from '@/appflowy_app/interfaces';
+import { toolbarDefaultProps } from '@/appflowy_app/constants/toolbar';
+
+export default function TextBlock({
+  node,
+  needRenderChildren = true,
+  toolbarProps,
+  ...props
+}: {
+  needRenderChildren?: boolean;
+  toolbarProps?: TextBlockToolbarProps;
+} & BlockCommonProps<TreeNode> &
+  React.HTMLAttributes<HTMLDivElement>) {
+  const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock({ node });
+  const { showGroups } = toolbarProps || toolbarDefaultProps;
 
   return (
-    <div className='mb-2'>
-      <Slate
-        editor={editor}
-        onChange={(e) => {
-          if (editor.operations[0].type !== 'set_selection') {
-            console.log('=== text op ===', e, editor.operations);
-            // Temporary code, in the future, it is necessary to monitor the OP changes of the document to determine whether the location cache of the block needs to be updated
-            debounceUpdateBlockCache(node.id);
-          }
-        }}
-        value={[
-          {
-            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-            // @ts-ignore
-            type: 'paragraph',
-            children: node.data.content,
-          },
-        ]}
-      >
-        <HoveringToolbar blockId={node.id} />
+    <div {...props} className={`${props.className} py-1`}>
+      <Slate editor={editor} onChange={onChange} value={value}>
+        {showGroups.length > 0 && <HoveringToolbar node={node} blockId={node.id} />}
         <Editable
-          onKeyDownCapture={(event) => {
-            switch (event.key) {
-              case 'Enter': {
-                event.stopPropagation();
-                event.preventDefault();
-                return;
-              }
-            }
-
-            triggerHotkey(event, editor);
-          }}
-          onDOMBeforeInput={(e) => {
-            // COMPAT: in Apple, `compositionend` is dispatched after the
-            // `beforeinput` for "insertFromComposition". It will cause repeated characters when inputting Chinese.
-            // Here, prevent the beforeInput event and wait for the compositionend event to take effect
-            if (e.inputType === 'insertFromComposition') {
-              e.preventDefault();
-            }
-          }}
-          renderLeaf={(props) => <Leaf {...props} />}
+          onKeyDownCapture={onKeyDownCapture}
+          onDOMBeforeInput={onDOMBeforeInput}
+          renderLeaf={(leafProps) => <Leaf {...leafProps} />}
           placeholder='Enter some text...'
         />
       </Slate>
-      {node.children && node.children.length > 0 ? (
+      {needRenderChildren && node.children.length > 0 ? (
         <div className='pl-[1.5em]'>
           {node.children.map((item) => (
             <BlockComponent key={item.id} node={item} />

+ 14 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/toolbar.ts

@@ -1,3 +1,4 @@
+import { TextBlockToolbarGroup } from "../interfaces";
 
 export const iconSize = { width: 18, height: 18 };
 
@@ -22,4 +23,17 @@ export const command: Record<string, { title: string; key: string }> = {
     title: 'Strike through',
     key: '⌘ + Shift + S or ⌘ + Shift + X',
   },
+};
+
+export const toolbarDefaultProps = {
+  showGroups: [
+    TextBlockToolbarGroup.ASK_AI,
+    TextBlockToolbarGroup.BLOCK_SELECT,
+    TextBlockToolbarGroup.ADD_LINK,
+    TextBlockToolbarGroup.COMMENT,
+    TextBlockToolbarGroup.TEXT_FORMAT,
+    TextBlockToolbarGroup.TEXT_COLOR,
+    TextBlockToolbarGroup.MENTION,
+    TextBlockToolbarGroup.MORE,
+  ],
 };

+ 58 - 10
frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts

@@ -16,9 +16,7 @@ export enum BlockType {
 
 }
 
-
-
-export type BlockData<T> = T extends BlockType.TextBlock ? TextBlockData :
+export type BlockData<T = BlockType> = T extends BlockType.TextBlock ? TextBlockData :
 T extends BlockType.PageBlock ? PageBlockData :
 T extends BlockType.HeadingBlock ? HeadingBlockData : 
 T extends BlockType.ListBlock ? ListBlockData :
@@ -34,7 +32,7 @@ export interface BlockInterface<T = BlockType> {
 }
 
 
-interface TextBlockData {
+export interface TextBlockData {
   content: Descendant[];
 }
 
@@ -54,11 +52,61 @@ interface ColumnBlockData {
   ratio: string;
 }
 
+// eslint-disable-next-line no-shadow
+export enum TextBlockToolbarGroup {
+  ASK_AI,
+  BLOCK_SELECT,
+  ADD_LINK,
+  COMMENT,
+  TEXT_FORMAT,
+  TEXT_COLOR,
+  MENTION,
+  MORE
+}
+export interface TextBlockToolbarProps {
+  showGroups: TextBlockToolbarGroup[]
+}
+
 
-export interface TreeNodeInterface {
-  id: string;
-  type: BlockType;
-  parent: TreeNodeInterface | null;
-  children: TreeNodeInterface[];
-  data: BlockData<BlockType>;
+export interface BlockCommonProps<T> {
+  version: number;
+  node: T;
+}
+
+export interface BackendOp {
+  type: 'update' | 'insert' | 'remove' | 'move' | 'move_range';
+  version: number;
+  data: UpdateOpData | InsertOpData | moveRangeOpData | moveOpData | removeOpData;
+}
+export interface LocalOp {
+  type: 'update' | 'insert' | 'remove' | 'move' | 'move_range';
+  version: number;
+  data: UpdateOpData | InsertOpData | moveRangeOpData | moveOpData | removeOpData;
+}
+
+export interface UpdateOpData {
+  blockId: string;
+  value: BlockData;
+  path: string[];
+}
+export interface InsertOpData {
+  block: BlockInterface;
+  parentId: string;
+  prevId?: string
+}
+
+export interface moveRangeOpData {
+  range: [string, string];
+  newParentId: string;
+  newPrevId?: string
+}
+
+export interface moveOpData {
+  blockId: string;
+  newParentId: string;
+  newPrevId?: string
+}
+
+export interface removeOpData {
+  blockId: string
 }

+ 25 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/block.ts

@@ -0,0 +1,25 @@
+
+import { createContext } from 'react';
+import { ulid } from "ulid";
+import { BlockEditor } from '../block_editor/index';
+
+export const BlockContext = createContext<{
+  id?: string;
+  blockEditor?: BlockEditor;
+}>({});
+
+
+export function generateBlockId() {
+  const blockId = ulid()
+  return `block-id-${blockId}`;
+}
+
+const AVERAGE_BLOCK_HEIGHT = 30;
+export function calculateViewportBlockMaxCount() {
+  const viewportHeight = window.innerHeight;
+  const viewportBlockCount = Math.ceil(viewportHeight / AVERAGE_BLOCK_HEIGHT);
+
+  return viewportBlockCount;
+}
+
+

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

@@ -1,8 +0,0 @@
-
-import { createContext } from 'react';
-
-export const BlockContext = createContext<{
-  id?: string;
-}>({});
-
-

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/block_selection.ts

@@ -0,0 +1,36 @@
+import { BlockData, BlockType } from "../interfaces";
+
+
+export function filterSelections<TreeNode extends {
+  id: string;
+  children: TreeNode[];
+  parent: TreeNode | null;
+  type: BlockType;
+  data: BlockData;
+}>(ids: string[], nodeMap: Map<string, TreeNode>): string[] {
+  const selected = new Set(ids);
+  const newSelected = new Set<string>();
+  ids.forEach(selectedId => {
+    const node = nodeMap.get(selectedId);
+    if (!node) return;
+    if (node.type === BlockType.ListBlock && node.data.type === 'column') {
+      return;
+    }
+    if (node.children.length === 0) {
+      newSelected.add(selectedId);
+      return;
+    }
+    const hasChildSelected = node.children.some(i => selected.has(i.id));
+    if (!hasChildSelected) {
+      newSelected.add(selectedId);
+      return;
+    }
+    const hasSiblingSelected = node.parent?.children.filter(i => i.id !== selectedId).some(i => selected.has(i.id));
+    if (hasChildSelected && hasSiblingSelected) {
+      newSelected.add(selectedId);
+      return;
+    }
+  });
+
+  return Array.from(newSelected);
+}

+ 0 - 25
frontend/appflowy_tauri/src/appflowy_app/utils/editor/format.ts

@@ -1,25 +0,0 @@
-import {
-  Editor,
-  Transforms,
-  Text,
-  Node
-} from 'slate';
-
-export function toggleFormat(editor: Editor, format: string) {
-  const isActive = isFormatActive(editor, format)
-  Transforms.setNodes(
-    editor,
-    { [format]: isActive ? null : true },
-    { match: Text.isText, split: true }
-  )
-}
-
-export const isFormatActive = (editor: Editor, format: string) => {
-  const [match] = Editor.nodes(editor, {
-    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-    // @ts-ignore
-    match: (n: Node) => n[format] === true,
-    mode: 'all',
-  })
-  return !!match
-}

+ 0 - 22
frontend/appflowy_tauri/src/appflowy_app/utils/editor/hotkey.ts

@@ -1,22 +0,0 @@
-import isHotkey from 'is-hotkey';
-import { toggleFormat } from './format';
-import { Editor } from 'slate';
-
-const HOTKEYS: Record<string, string> = {
-  'mod+b': 'bold',
-  'mod+i': 'italic',
-  'mod+u': 'underline',
-  'mod+e': 'code',
-  'mod+shift+X': 'strikethrough',
-  'mod+shift+S': 'strikethrough',
-};
-
-export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  for (const hotkey in HOTKEYS) {
-    if (isHotkey(hotkey, event)) {
-      event.preventDefault()
-      const format = HOTKEYS[hotkey]
-      toggleFormat(editor, format)
-    }
-  }
-}

+ 0 - 28
frontend/appflowy_tauri/src/appflowy_app/utils/editor/toolbar.ts

@@ -1,28 +0,0 @@
-import { Editor, Range } from 'slate';
-export function calcToolbarPosition(editor: Editor, el: HTMLDivElement, blockRect: DOMRect) {
-  const { selection } = editor;
-
-  if (!selection || Range.isCollapsed(selection) || Editor.string(editor, selection) === '') {
-    return;
-  }
-
-  const domSelection = window.getSelection();
-  let domRange;
-  if (domSelection?.rangeCount === 0) {
-    domRange = document.createRange();
-    domRange.setStart(el, domSelection?.anchorOffset);
-    domRange.setEnd(el, domSelection?.anchorOffset);
-  } else {
-    domRange = domSelection?.getRangeAt(0);
-  }
-
-  const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
-  
-  const top = `${-el.offsetHeight - 5}px`;
-  const left = `${rect.left - blockRect.left - el.offsetWidth / 2 + rect.width / 2}px`;
-  return {
-    top,
-    left,
-  }
-  
-}

+ 6 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/slate/context.ts

@@ -0,0 +1,6 @@
+import { createContext } from "react";
+import { TextBlockManager } from '../../block_editor/blocks/text_block';
+
+export const TextBlockContext = createContext<{
+  textBlockManager?: TextBlockManager
+}>({});

+ 3 - 13
frontend/appflowy_tauri/src/appflowy_app/utils/slate/toolbar.ts

@@ -1,21 +1,11 @@
-import { getBlockEditor } from '@/appflowy_app/block_editor';
 import { Editor, Range } from 'slate';
-export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement, blockId: string) {
+export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement, blockRect: DOMRect) {
   const { selection } = editor;
 
-  const scrollContainer = document.querySelector('.doc-scroller-container');
-  if (!scrollContainer) return;
-
   if (!selection || Range.isCollapsed(selection) || Editor.string(editor, selection) === '') {
     return;
   }
 
-  const blockEditor = getBlockEditor();
-  const blockRect = blockEditor?.renderTree.getNodeRect(blockId);
-  const blockDom = document.querySelector(`[data-block-id=${blockId}]`);
-
-  if (!blockDom || !blockRect) return;
-
   const domSelection = window.getSelection();
   let domRange;
   if (domSelection?.rangeCount === 0) {
@@ -26,8 +16,8 @@ export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement,
 
   const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
   
-  const top = `${-toolbarDom.offsetHeight - 5 + (rect.top + scrollContainer.scrollTop - blockRect.top)}px`;
-  const left = `${rect.left - blockRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2}px`;
+  const top = `${-toolbarDom.offsetHeight - 5 + (rect.top - blockRect.y)}px`;
+  const left = `${rect.left - blockRect.x - toolbarDom.offsetWidth / 2 + rect.width / 2}px`;
   
   return {
     top,

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

@@ -8,3 +8,29 @@ export function debounce(fn: (...args: any[]) => void, delay: number) {
     }, delay)
   }
 }
+
+export function get(obj: any, path: string[], defaultValue?: any) {
+  let value = obj;
+  for (const prop of path) {
+    value = value[prop];
+    if (value === undefined) {
+      return defaultValue !== undefined ? defaultValue : undefined;
+    }
+  }
+  return value;
+}
+
+export function set(obj: any, path: string[], value: any): void {
+  let current = obj;
+  for (let i = 0; i < path.length; i++) {
+    const prop = path[i];
+    if (i === path.length - 1) {
+      current[prop] = value;
+    } else {
+      if (!current[prop]) {
+        current[prop] = {};
+      }
+      current = current[prop];
+    }
+  }
+}

+ 575 - 22
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import {
   DocumentEventGetDocument,
   DocumentVersionPB,
@@ -6,14 +6,14 @@ import {
 } from '../../services/backend/events/flowy-document';
 import { BlockInterface, BlockType } from '../interfaces';
 import { useParams } from 'react-router-dom';
-import { getBlockEditor, createBlockEditor } from '../block_editor';
+import { BlockEditor } from '../block_editor';
 
 const loadBlockData = async (id: string): Promise<Record<string, BlockInterface>> => {
   return {
     [id]: {
       id: id,
       type: BlockType.PageBlock,
-      data: { title: 'Document Title' },
+      data: { content: [{ text: 'Document Title' }] },
       next: null,
       firstChild: "L1-1",
     },
@@ -202,26 +202,580 @@ const loadBlockData = async (id: string): Promise<Record<string, BlockInterface>
       next: null,
       firstChild: null,
     },
+    "L1-8": {
+      id: "L1-8",
+      type: BlockType.HeadingBlock,
+      data: { level: 1, content: [{ text: 'Heading 1' }] },
+      next: "L1-9",
+      firstChild: null,
+    },
+    "L1-9": {
+      id: "L1-9",
+      type: BlockType.HeadingBlock,
+      data: { level: 2, content: [{ text: 'Heading 2' }] },
+      next: "L1-10",
+      firstChild: null,
+    },
+    "L1-10": {
+      id: "L1-10",
+      type: BlockType.HeadingBlock,
+      data: { level: 3, content: [{ text: 'Heading 3' }] },
+      next: "L1-11",
+      firstChild: null,
+    },
+    "L1-11": {
+      id: "L1-11",
+      type: BlockType.TextBlock,
+      data: { content: [
+        {
+          text:
+            'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
+        },
+        { text: 'bold', bold: true },
+        { text: ', ' },
+        { text: 'italic', italic: true },
+        { text: ', or anything else you might want to do!' },
+      ] },
+      next: "L1-12",
+      firstChild: null,
+    },
+    "L1-12": {
+      id: "L1-12",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+        { text: 'select any piece of text and the menu will appear', bold: true },
+        { text: '.' },
+      ] },
+      next: "L2-1",
+      firstChild: "L1-12-1",
+    },
+    "L1-12-1": {
+      id: "L1-12-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L1-12-2",
+      firstChild: null,
+    },
+    "L1-12-2": {
+      id: "L1-12-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L2-1": {
+      id: "L2-1",
+      type: BlockType.HeadingBlock,
+      data: { level: 1, content: [{ text: 'Heading 1' }] },
+      next: "L2-2",
+      firstChild: null,
+    },
+    "L2-2": {
+      id: "L2-2",
+      type: BlockType.HeadingBlock,
+      data: { level: 2, content: [{ text: 'Heading 2' }] },
+      next: "L2-3",
+      firstChild: null,
+    },
+    "L2-3": {
+      id: "L2-3",
+      type: BlockType.HeadingBlock,
+      data: { level: 3, content: [{ text: 'Heading 3' }] },
+      next: "L2-4",
+      firstChild: null,
+    },
+    "L2-4": {
+      id: "L2-4",
+      type: BlockType.TextBlock,
+      data: { content: [
+        {
+          text:
+            'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
+        },
+        { text: 'bold', bold: true },
+        { text: ', ' },
+        { text: 'italic', italic: true },
+        { text: ', or anything else you might want to do!' },
+      ] },
+      next: "L2-5",
+      firstChild: null,
+    },
+    "L2-5": {
+      id: "L2-5",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+        { text: 'select any piece of text and the menu will appear', bold: true },
+        { text: '.' },
+      ] },
+      next: "L2-6",
+      firstChild: "L2-5-1",
+    },
+    "L2-5-1": {
+      id: "L2-5-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L2-5-2",
+      firstChild: null,
+    },
+    "L2-5-2": {
+      id: "L2-5-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L2-6": {
+      id: "L2-6",
+      type: BlockType.ListBlock,
+      data: { type: 'bulleted', content: [
+        {
+          text:
+            "Since it's rich text, you can do things like turn a selection of text ",
+        },
+        { text: 'bold', bold: true },
+        {
+          text:
+            ', or add a semantically rendered block quote in the middle of the page, like this:',
+        },
+      ] },
+      next: "L2-7",
+      firstChild: "L2-6-1",
+    },
+    "L2-6-1": {
+      id: "L2-6-1",
+      type: BlockType.ListBlock,
+      data: { type: 'numbered', content: [
+        {
+          text:
+            "Since it's rich text, you can do things like turn a selection of text ",
+        },
+        
+      ] },
+      
+      next: "L2-6-2",
+      firstChild: null,
+    },
+    "L2-6-2": {
+      id: "L2-6-2",
+      type: BlockType.ListBlock,
+      data: { type: 'numbered', content: [
+        {
+          text:
+            "Since it's rich text, you can do things like turn a selection of text ",
+        },
+        
+      ] },
+      
+      next: "L2-6-3",
+      firstChild: null,
+    },
+
+    "L2-6-3": {
+      id: "L2-6-3",
+      type: BlockType.TextBlock,
+      data: { content: [{ text: 'A wise quote.' }] },
+      next: null,
+      firstChild: null,
+    },
+    
+    "L2-7": {
+      id: "L2-7",
+      type: BlockType.ListBlock,
+      data: { type: 'column' },
+      
+      next: "L2-8",
+      firstChild: "L2-7-1",
+    },
+    "L2-7-1": {
+      id: "L2-7-1",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: "L2-7-2",
+      firstChild: "L2-7-1-1",
+    },
+    "L2-7-1-1": {
+      id: "L2-7-1-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L2-7-2": {
+      id: "L2-7-2",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: "L2-7-3",
+      firstChild: "L2-7-2-1",
+    },
+    "L2-7-2-1": {
+      id: "L2-7-2-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L2-7-2-2",
+      firstChild: null,
+    },
+    "L2-7-2-2": {
+      id: "L2-7-2-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L2-7-3": {
+      id: "L2-7-3",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: null,
+      firstChild: "L2-7-3-1",
+    },
+    "L2-7-3-1": {
+      id: "L2-7-3-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L2-8": {
+      id: "L2-8",
+      type: BlockType.HeadingBlock,
+      data: { level: 1, content: [{ text: 'Heading 1' }] },
+      next: "L2-9",
+      firstChild: null,
+    },
+    "L2-9": {
+      id: "L2-9",
+      type: BlockType.HeadingBlock,
+      data: { level: 2, content: [{ text: 'Heading 2' }] },
+      next: "L2-10",
+      firstChild: null,
+    },
+    "L2-10": {
+      id: "L2-10",
+      type: BlockType.HeadingBlock,
+      data: { level: 3, content: [{ text: 'Heading 3' }] },
+      next: "L2-11",
+      firstChild: null,
+    },
+    "L2-11": {
+      id: "L2-11",
+      type: BlockType.TextBlock,
+      data: { content: [
+        {
+          text:
+            'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
+        },
+        { text: 'bold', bold: true },
+        { text: ', ' },
+        { text: 'italic', italic: true },
+        { text: ', or anything else you might want to do!' },
+      ] },
+      next: "L2-12",
+      firstChild: null,
+    },
+    "L2-12": {
+      id: "L2-12",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+        { text: 'select any piece of text and the menu will appear', bold: true },
+        { text: '.' },
+      ] },
+      next: "L3-1",
+      firstChild: "L2-12-1",
+    },
+    "L2-12-1": {
+      id: "L2-12-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L2-12-2",
+      firstChild: null,
+    },
+    "L2-12-2": {
+      id: "L2-12-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },"L3-1": {
+      id: "L3-1",
+      type: BlockType.HeadingBlock,
+      data: { level: 1, content: [{ text: 'Heading 1' }] },
+      next: "L3-2",
+      firstChild: null,
+    },
+    "L3-2": {
+      id: "L3-2",
+      type: BlockType.HeadingBlock,
+      data: { level: 2, content: [{ text: 'Heading 2' }] },
+      next: "L3-3",
+      firstChild: null,
+    },
+    "L3-3": {
+      id: "L3-3",
+      type: BlockType.HeadingBlock,
+      data: { level: 3, content: [{ text: 'Heading 3' }] },
+      next: "L3-4",
+      firstChild: null,
+    },
+    "L3-4": {
+      id: "L3-4",
+      type: BlockType.TextBlock,
+      data: { content: [
+        {
+          text:
+            'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
+        },
+        { text: 'bold', bold: true },
+        { text: ', ' },
+        { text: 'italic', italic: true },
+        { text: ', or anything else you might want to do!' },
+      ] },
+      next: "L3-5",
+      firstChild: null,
+    },
+    "L3-5": {
+      id: "L3-5",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+        { text: 'select any piece of text and the menu will appear', bold: true },
+        { text: '.' },
+      ] },
+      next: "L3-6",
+      firstChild: "L3-5-1",
+    },
+    "L3-5-1": {
+      id: "L3-5-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L3-5-2",
+      firstChild: null,
+    },
+    "L3-5-2": {
+      id: "L3-5-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L3-6": {
+      id: "L3-6",
+      type: BlockType.ListBlock,
+      data: { type: 'bulleted', content: [
+        {
+          text:
+            "Since it's rich text, you can do things like turn a selection of text ",
+        },
+        { text: 'bold', bold: true },
+        {
+          text:
+            ', or add a semantically rendered block quote in the middle of the page, like this:',
+        },
+      ] },
+      next: "L3-7",
+      firstChild: "L3-6-1",
+    },
+    "L3-6-1": {
+      id: "L3-6-1",
+      type: BlockType.ListBlock,
+      data: { type: 'numbered', content: [
+        {
+          text:
+            "Since it's rich text, you can do things like turn a selection of text ",
+        },
+        
+      ] },
+      
+      next: "L3-6-2",
+      firstChild: null,
+    },
+    "L3-6-2": {
+      id: "L3-6-2",
+      type: BlockType.ListBlock,
+      data: { type: 'numbered', content: [
+        {
+          text:
+            "Since it's rich text, you can do things like turn a selection of text ",
+        },
+        
+      ] },
+      
+      next: "L3-6-3",
+      firstChild: null,
+    },
+    
+    "L3-6-3": {
+      id: "L3-6-3",
+      type: BlockType.TextBlock,
+      data: { content: [{ text: 'A wise quote.' }] },
+      next: null,
+      firstChild: null,
+    },
+    
+    "L3-7": {
+      id: "L3-7",
+      type: BlockType.ListBlock,
+      data: { type: 'column' },
+      
+      next: "L3-8",
+      firstChild: "L3-7-1",
+    },
+    "L3-7-1": {
+      id: "L3-7-1",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: "L3-7-2",
+      firstChild: "L3-7-1-1",
+    },
+    "L3-7-1-1": {
+      id: "L3-7-1-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L3-7-2": {
+      id: "L3-7-2",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: "L3-7-3",
+      firstChild: "L3-7-2-1",
+    },
+    "L3-7-2-1": {
+      id: "L3-7-2-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L3-7-2-2",
+      firstChild: null,
+    },
+    "L3-7-2-2": {
+      id: "L3-7-2-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L3-7-3": {
+      id: "L3-7-3",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: null,
+      firstChild: "L3-7-3-1",
+    },
+    "L3-7-3-1": {
+      id: "L3-7-3-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L3-8": {
+      id: "L3-8",
+      type: BlockType.HeadingBlock,
+      data: { level: 1, content: [{ text: 'Heading 1' }] },
+      next: "L3-9",
+      firstChild: null,
+    },
+    "L3-9": {
+      id: "L3-9",
+      type: BlockType.HeadingBlock,
+      data: { level: 2, content: [{ text: 'Heading 2' }] },
+      next: "L3-10",
+      firstChild: null,
+    },
+    "L3-10": {
+      id: "L3-10",
+      type: BlockType.HeadingBlock,
+      data: { level: 3, content: [{ text: 'Heading 3' }] },
+      next: "L3-11",
+      firstChild: null,
+    },
+    "L3-11": {
+      id: "L3-11",
+      type: BlockType.TextBlock,
+      data: { content: [
+        {
+          text:
+            'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
+        },
+        { text: 'bold', bold: true },
+        { text: ', ' },
+        { text: 'italic', italic: true },
+        { text: ', or anything else you might want to do!' },
+      ] },
+      next: "L3-12",
+      firstChild: null,
+    },
+    "L3-12": {
+      id: "L3-12",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+        { text: 'select any piece of text and the menu will appear', bold: true },
+        { text: '.' },
+      ] },
+      next: null,
+      firstChild: "L3-12-1",
+    },
+    "L3-12-1": {
+      id: "L3-12-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L3-12-2",
+      firstChild: null,
+    },
+    "L3-12-2": {
+      id: "L3-12-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
   }
 }
 export const useDocument = () => {
   const params = useParams();
   const [blockId, setBlockId] = useState<string>();
-  const loadDocument = async (id: string): Promise<any> => {
-    const getDocumentResult = await DocumentEventGetDocument(
-      OpenDocumentPayloadPB.fromObject({
-        document_id: id,
-        version: DocumentVersionPB.V1,
-      })
-    );
+  const blockEditorRef = useRef<BlockEditor | null>(null)
 
-    if (getDocumentResult.ok) {
-      const pb = getDocumentResult.val;
-      return JSON.parse(pb.content);
-    } else {
-      throw new Error('get document error');
-    }
-  };
 
   useEffect(() => {
     void (async () => {
@@ -229,11 +783,10 @@ export const useDocument = () => {
       const data = await loadBlockData(params.id);
       console.log('==== enter ====', params?.id, data);
   
-      const blockEditor = getBlockEditor();
-      if (blockEditor) {
-        blockEditor.changeDoc(params?.id, data);
+      if (!blockEditorRef.current) {
+        blockEditorRef.current = new BlockEditor(params?.id, data);
       } else {
-        createBlockEditor(params?.id, data);
+        blockEditorRef.current.changeDoc(params?.id, data);
       }
 
       setBlockId(params.id)
@@ -242,5 +795,5 @@ export const useDocument = () => {
       console.log('==== leave ====', params?.id)
     }
   }, [params.id]);
-  return { blockId };
+  return { blockId, blockEditor: blockEditorRef.current };
 };

+ 11 - 12
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx

@@ -1,6 +1,6 @@
 import { useDocument } from './DocumentPage.hooks';
 import BlockList from '../components/block/BlockList';
-import { BlockContext } from '../utils/block_context';
+import { BlockContext } from '../utils/block';
 import { createTheme, ThemeProvider } from '@mui/material';
 
 const theme = createTheme({
@@ -9,20 +9,19 @@ const theme = createTheme({
   },
 });
 export const DocumentPage = () => {
-  const { blockId } = useDocument();
+  const { blockId, blockEditor } = useDocument();
 
-  if (!blockId) return <div className='error-page'></div>;
+  if (!blockId || !blockEditor) return <div className='error-page'></div>;
   return (
     <ThemeProvider theme={theme}>
-      <div id='appflowy-block-doc' className='doc-scroller-container flex h-[100%] flex-col items-center overflow-auto'>
-        <BlockContext.Provider
-          value={{
-            id: blockId,
-          }}
-        >
-          <BlockList blockId={blockId} />
-        </BlockContext.Provider>
-      </div>
+      <BlockContext.Provider
+        value={{
+          id: blockId,
+          blockEditor,
+        }}
+      >
+        <BlockList blockEditor={blockEditor} blockId={blockId} />
+      </BlockContext.Provider>
     </ThemeProvider>
   );
 };

Some files were not shown because too many files changed in this diff