Przeglądaj źródła

Feat appflowy list block (#1949)

* feat: Initialize appflowy block data and render block list

* feat: Implement column layout rendering

* feat: Implement list redering

* feat: Cache block rect info

* fix: The input chars will repeated when inputting Chinese

* fix: Remove unnecessary fields in the block and encapsulate the block manager

* fix: fix ts error
qinluhe 2 lat temu
rodzic
commit
ed2c5c17d8
29 zmienionych plików z 919 dodań i 309 usunięć
  1. 28 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/block.ts
  2. 48 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/index.ts
  3. 66 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/rect.ts
  4. 140 0
      frontend/appflowy_tauri/src/appflowy_app/block_editor/tree.ts
  5. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/FormatButton.tsx
  6. 9 0
      frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/Portal.tsx
  7. 6 6
      frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/index.tsx
  8. 16 10
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockComponent.tsx
  9. 0 28
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockList.hooks.ts
  10. 33 7
      frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/index.tsx
  11. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/block/CodeBlock/index.tsx
  12. 51 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/ColumnBlock/index.tsx
  13. 5 5
      frontend/appflowy_tauri/src/appflowy_app/components/block/HeadingBlock/index.tsx
  14. 25 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/BulletedListBlock.tsx
  15. 18 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/ColumnListBlock.tsx
  16. 26 0
      frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/NumberedListBlock.tsx
  17. 28 21
      frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/index.tsx
  18. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/block/PageBlock/index.tsx
  19. 19 6
      frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/Leaf.tsx
  20. 43 12
      frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.tsx
  21. 36 23
      frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts
  22. 3 3
      frontend/appflowy_tauri/src/appflowy_app/utils/block_context.ts
  23. 25 0
      frontend/appflowy_tauri/src/appflowy_app/utils/slate/format.ts
  24. 22 0
      frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts
  25. 37 0
      frontend/appflowy_tauri/src/appflowy_app/utils/slate/toolbar.ts
  26. 10 0
      frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts
  27. 0 36
      frontend/appflowy_tauri/src/appflowy_app/utils/tree.ts
  28. 214 141
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts
  29. 4 4
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx

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

@@ -0,0 +1,28 @@
+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;
+  }
+}

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

@@ -0,0 +1,48 @@
+import { BlockInterface } from '../interfaces';
+import { BlockDataManager } from './block';
+import { TreeManager } from './tree';
+
+/**
+ * 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.
+ */
+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);
+  }
+
+  /**
+   * update id and map when the doc is change
+   * @param id 
+   * @param data 
+   */
+  changeDoc = (id: string, data: Record<string, BlockInterface>) => {
+    console.log('==== change document ====', id, data)
+    this.id = id;
+    this.blockData.setBlocksMap(id, data);
+  }
+
+  destroy = () => {
+    this.renderTree.destroy();
+    this.blockData.destroy();
+  }
+  
+}
+
+let blockEditorInstance: BlockEditor | null;
+
+export function getBlockEditor() {
+  return blockEditorInstance;
+}
+
+export function createBlockEditor(id: string, data: Record<string, BlockInterface>) {
+  blockEditorInstance = new BlockEditor(id, data);
+  return blockEditorInstance;
+}

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

@@ -0,0 +1,66 @@
+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();
+  }
+  
+}

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

@@ -0,0 +1,140 @@
+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);
+  }
+}

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

@@ -1,5 +1,5 @@
 import { useSlate } from 'slate-react';
-import { toggleFormat, isFormatActive } from '$app/utils/editor/format';
+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';

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

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

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

@@ -1,8 +1,8 @@
 import { useEffect, useRef } from 'react';
 import { useFocused, useSlate } from 'slate-react';
 import FormatButton from './FormatButton';
-import { Portal } from './components';
-import { calcToolbarPosition } from '$app/utils/editor/toolbar';
+import Portal from './Portal';
+import { calcToolbarPosition } from '@/appflowy_app/utils/slate/toolbar';
 
 const HoveringToolbar = ({ blockId }: { blockId: string }) => {
   const editor = useSlate();
@@ -13,10 +13,7 @@ const HoveringToolbar = ({ blockId }: { blockId: string }) => {
     const el = ref.current;
     if (!el) return;
 
-    const blockDom = document.querySelectorAll(`[data-block-id=${blockId}]`)[0];
-    const blockRect = blockDom?.getBoundingClientRect();
-
-    const position = calcToolbarPosition(editor, el, blockRect);
+    const position = calcToolbarPosition(editor, el, blockId);
 
     if (!position) {
       el.style.opacity = '0';
@@ -33,6 +30,9 @@ const HoveringToolbar = ({ blockId }: { blockId: string }) => {
     <Portal blockId={blockId}>
       <div
         ref={ref}
+        style={{
+          opacity: 0,
+        }}
         className='z-1 absolute mt-[-6px] inline-flex h-[32px] items-stretch overflow-hidden rounded-[8px] bg-[#333] p-2 leading-tight shadow-lg transition-opacity duration-700'
         onMouseDown={(e) => {
           // prevent toolbar from taking focus away from editor

+ 16 - 10
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockComponent.tsx

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

+ 0 - 28
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockList.hooks.ts

@@ -1,28 +0,0 @@
-import { useContext, useEffect, useState } from "react";
-import { BlockContext } from "$app/utils/block_context";
-import { buildTree } from "$app/utils/tree";
-import { Block } from "$app/interfaces";
-
-export function useBlockList() {
-  const blockContext = useContext(BlockContext);
-  
-  const [blockList, setBlockList] = useState<Block[]>([]);
-
-  const [title, setTitle] = useState<string>('');
-
-  useEffect(() => {
-    if (!blockContext) return;
-    const { blocksMap, id } = blockContext;
-    if (!id || !blocksMap) return;
-    const root = buildTree(id, blocksMap);
-    if (!root) return;
-    console.log(root);
-    setTitle(root.data.title);
-    setBlockList(root.children || []);
-  }, [blockContext]);
-
-  return {
-    title,
-    blockList
-  }
-}

+ 33 - 7
frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/index.tsx

@@ -1,17 +1,43 @@
-import { useBlockList } from './BlockList.hooks';
 import BlockComponent from './BlockComponent';
+import React, { useEffect } from 'react';
+import { debounce } from '@/appflowy_app/utils/tool';
+import { getBlockEditor } from '../../../block_editor';
 
-export default function BlockList() {
-  const { blockList, title } = useBlockList();
+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);
+    };
+  }, []);
 
   return (
     <div className='min-x-[0%] p-lg w-[900px] max-w-[100%]'>
-      <div className='my-[50px] flex px-14 text-4xl font-bold'>{title}</div>
+      <div className='my-[50px] flex px-14 text-4xl font-bold'>{root?.data.title}</div>
       <div className='px-14'>
-        {blockList?.map((block) => (
-          <BlockComponent key={block.id} block={block} />
-        ))}
+        {root && root.children.length > 0
+          ? root.children.map((node) => <BlockComponent key={node.id} node={node} />)
+          : null}
       </div>
     </div>
   );
 }
+
+export default React.memo(BlockList);

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

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

+ 51 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/ColumnBlock/index.tsx

@@ -0,0 +1,51 @@
+import React from 'react';
+import { TreeNodeInterface } from '$app/interfaces/index';
+import BlockComponent from '../BlockList/BlockComponent';
+
+export default function ColumnBlock({
+  node,
+  resizerWidth,
+  index,
+}: {
+  node: TreeNodeInterface;
+  resizerWidth: number;
+  index: number;
+}) {
+  const renderResizer = () => {
+    return (
+      <div className={`relative w-[46px] flex-shrink-0 flex-grow-0 transition-opacity`} style={{ opacity: 0 }}></div>
+    );
+  };
+  return (
+    <>
+      {index === 0 ? (
+        <div className='contents'>
+          <div
+            className='absolute flex'
+            style={{
+              inset: '0px 100% 0px auto',
+            }}
+          >
+            {renderResizer()}
+          </div>
+        </div>
+      ) : (
+        renderResizer()
+      )}
+
+      <BlockComponent
+        className={`column-block py-3`}
+        style={{
+          flexGrow: 0,
+          flexShrink: 0,
+          width: `calc((100% - ${resizerWidth}px) * ${node.data.ratio})`,
+        }}
+        node={node}
+      >
+        {node.children?.map((item) => (
+          <BlockComponent key={item.id} node={item} />
+        ))}
+      </BlockComponent>
+    </>
+  );
+}

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

@@ -1,18 +1,18 @@
 import React from 'react';
-import { Block } from '$app/interfaces';
 import TextBlock from '../TextBlock';
+import { TreeNodeInterface } from '$app/interfaces/index';
 
 const fontSize: Record<string, string> = {
   1: 'mt-8 text-3xl',
   2: 'mt-6 text-2xl',
   3: 'mt-4 text-xl',
 };
-export default function HeadingBlock({ block }: { block: Block }) {
+export default function HeadingBlock({ node }: { node: TreeNodeInterface }) {
   return (
-    <div className={`${fontSize[block.data.level]} font-semibold	`}>
+    <div className={`${fontSize[node.data.level]} font-semibold	`}>
       <TextBlock
-        block={{
-          ...block,
+        node={{
+          ...node,
           children: [],
         }}
       />

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

@@ -0,0 +1,25 @@
+import { Circle } from '@mui/icons-material';
+
+import BlockComponent from '../BlockList/BlockComponent';
+import { TreeNodeInterface } from '$app/interfaces/index';
+
+export default function BulletedListBlock({ title, node }: { title: JSX.Element; node: TreeNodeInterface }) {
+  return (
+    <div className='bulleted-list-block relative'>
+      <div className='relative flex'>
+        <div className={`relative mb-2 min-w-[24px] leading-5`}>
+          <Circle sx={{ width: 8, height: 8 }} />
+        </div>
+        {title}
+      </div>
+
+      <div className='pl-[24px]'>
+        {node.children?.map((item) => (
+          <div key={item.id}>
+            <BlockComponent node={item} />
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,18 @@
+import { TreeNodeInterface } from '@/appflowy_app/interfaces';
+import React, { useMemo } from 'react';
+import ColumnBlock from '../ColumnBlock/index';
+
+export default function ColumnListBlock({ node }: { node: TreeNodeInterface }) {
+  const resizerWidth = useMemo(() => {
+    return 46 * (node.children?.length || 0);
+  }, [node.children?.length]);
+  return (
+    <>
+      <div className='column-list-block flex-grow-1 flex flex-row'>
+        {node.children?.map((item, index) => (
+          <ColumnBlock key={item.id} index={index} resizerWidth={resizerWidth} node={item} />
+        ))}
+      </div>
+    </>
+  );
+}

+ 26 - 0
frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/NumberedListBlock.tsx

@@ -0,0 +1,26 @@
+import { TreeNodeInterface } from '@/appflowy_app/interfaces';
+import React, { useMemo } from 'react';
+import BlockComponent from '../BlockList/BlockComponent';
+
+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]);
+  return (
+    <div className='numbered-list-block'>
+      <div className='relative flex'>
+        <div className={`relative mb-2 min-w-[24px] max-w-[24px]`}>{`${index} .`}</div>
+        {title}
+      </div>
+
+      <div className='pl-[24px]'>
+        {node.children?.map((item) => (
+          <div key={item.id}>
+            <BlockComponent node={item} />
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 28 - 21
frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/index.tsx

@@ -1,29 +1,36 @@
-import React from 'react';
-import { Block } from '$app/interfaces';
-import BlockComponent from '../BlockList/BlockComponent';
+import React, { useMemo } from 'react';
 import TextBlock from '../TextBlock';
+import NumberedListBlock from './NumberedListBlock';
+import BulletedListBlock from './BulletedListBlock';
+import ColumnListBlock from './ColumnListBlock';
+import { TreeNodeInterface } from '$app/interfaces/index';
 
-export default function ListBlock({ block }: { block: Block }) {
-  const renderChildren = () => {
-    return block.children?.map((item) => (
-      <li key={item.id}>
-        <BlockComponent block={item} />
-      </li>
-    ));
-  };
-
-  return (
-    <div className={`${block.data.type === 'ul' ? 'bulleted_list' : 'number_list'} flex`}>
-      <li className='w-[24px]' />
-      <div>
+export default function ListBlock({ node }: { node: TreeNodeInterface }) {
+  const title = useMemo(() => {
+    if (node.data.type === 'column') return <></>;
+    return (
+      <div className='flex-1'>
         <TextBlock
-          block={{
-            ...block,
+          node={{
+            ...node,
             children: [],
           }}
         />
-        {renderChildren()}
       </div>
-    </div>
-  );
+    );
+  }, [node]);
+
+  if (node.data.type === 'numbered') {
+    return <NumberedListBlock title={title} node={node} />;
+  }
+
+  if (node.data.type === 'bulleted') {
+    return <BulletedListBlock title={title} node={node} />;
+  }
+
+  if (node.data.type === 'column') {
+    return <ColumnListBlock node={node} />;
+  }
+
+  return null;
 }

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

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

+ 19 - 6
frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/Leaf.tsx

@@ -1,25 +1,38 @@
+import { BaseText } from 'slate';
 import { RenderLeafProps } from 'slate-react';
 
-const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
+const Leaf = ({
+  attributes,
+  children,
+  leaf,
+}: RenderLeafProps & {
+  leaf: BaseText & {
+    bold?: boolean;
+    code?: boolean;
+    italic?: boolean;
+    underlined?: boolean;
+    strikethrough?: boolean;
+  };
+}) => {
   let newChildren = children;
-  if ('bold' in leaf && leaf.bold) {
+  if (leaf.bold) {
     newChildren = <strong>{children}</strong>;
   }
 
-  if ('code' in leaf && leaf.code) {
+  if (leaf.code) {
     newChildren = <code className='rounded-sm	 bg-[#F2FCFF] p-1'>{newChildren}</code>;
   }
 
-  if ('italic' in leaf && leaf.italic) {
+  if (leaf.italic) {
     newChildren = <em>{newChildren}</em>;
   }
 
-  if ('underlined' in leaf && leaf.underlined) {
+  if (leaf.underlined) {
     newChildren = <u>{newChildren}</u>;
   }
 
   return (
-    <span {...attributes} className={'strikethrough' in leaf && leaf.strikethrough ? `line-through` : ''}>
+    <span {...attributes} className={leaf.strikethrough ? `line-through` : ''}>
       {newChildren}
     </span>
   );

+ 43 - 12
frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.tsx

@@ -1,31 +1,52 @@
-import React, { useState } from 'react';
-import { Block } from '$app/interfaces';
+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 Leaf from './Leaf';
 import HoveringToolbar from '$app/components/HoveringToolbar';
-import { triggerHotkey } from '$app/utils/editor/hotkey';
+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;
 
-export default function TextBlock({ block }: { block: Block }) {
   const [editor] = useState(() => withReact(createEditor()));
 
+  const { id } = useContext(BlockContext);
+
+  const debounceUpdateBlockCache = useMemo(
+    () => debounce(blockEditor.renderTree.updateNodeRect, INPUT_CHANGE_CACHE_DELAY),
+    [id, node.id]
+  );
+
   return (
     <div className='mb-2'>
       <Slate
         editor={editor}
-        onChange={(e) => console.log('===', e, editor.operations)}
+        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: [{ text: block.data.text }],
+            children: node.data.content,
           },
         ]}
       >
-        <HoveringToolbar blockId={block.id} />
+        <HoveringToolbar blockId={node.id} />
         <Editable
           onKeyDownCapture={(event) => {
             switch (event.key) {
@@ -38,15 +59,25 @@ export default function TextBlock({ block }: { block: Block }) {
 
             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} />}
           placeholder='Enter some text...'
         />
       </Slate>
-      <div className='pl-[1.5em]'>
-        {block.children?.map((item: Block) => (
-          <BlockComponent key={item.id} block={item} />
-        ))}
-      </div>
+      {node.children && node.children.length > 0 ? (
+        <div className='pl-[1.5em]'>
+          {node.children.map((item) => (
+            <BlockComponent key={item.id} node={item} />
+          ))}
+        </div>
+      ) : null}
     </div>
   );
 }

+ 36 - 23
frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts

@@ -1,51 +1,64 @@
+import { Descendant } from "slate";
 
 // eslint-disable-next-line no-shadow
 export enum BlockType {
-  PageBlock = 0,
-  HeadingBlock = 1,
-  ListBlock = 2,
-  TextBlock = 3,
-  CodeBlock = 4,
-  EmbedBlock = 5,
-  QuoteBlock = 6,
-  DividerBlock = 7,
-  MediaBlock = 8,
-  TableBlock = 9,
+  PageBlock = 'page',
+  HeadingBlock = 'heading',
+  ListBlock = 'list',
+  TextBlock = 'text',
+  CodeBlock = 'code',
+  EmbedBlock = 'embed',
+  QuoteBlock = 'quote',
+  DividerBlock = 'divider',
+  MediaBlock = 'media',
+  TableBlock = 'table',
+  ColumnBlock = 'column'
+
 }
 
 
 
 export type BlockData<T> = T extends BlockType.TextBlock ? TextBlockData :
 T extends BlockType.PageBlock ? PageBlockData :
-T extends BlockType.HeadingBlock ? HeadingBlockData: 
-T extends BlockType.ListBlock ? ListBlockData : any;
+T extends BlockType.HeadingBlock ? HeadingBlockData : 
+T extends BlockType.ListBlock ? ListBlockData :
+T extends BlockType.ColumnBlock ? ColumnBlockData :  any;
+
 
-export interface Block {
+export interface BlockInterface<T = BlockType> {
   id: string;
   type: BlockType;
-  data: BlockData<BlockType>;
-  parent: string | null;
-  prev: string | null;
+  data: BlockData<T>;
   next: string | null;
   firstChild: string | null;
-  lastChild: string | null;
-  children?: Block[];
 }
 
 
 interface TextBlockData {
-  text: string;
-  attr: string;
+  content: Descendant[];
 }
 
 interface PageBlockData {
   title: string;
 }
 
-interface ListBlockData {
-  type: 'ul' | 'ol';
+interface ListBlockData extends TextBlockData {
+  type: 'numbered' | 'bulleted' | 'column';
 }
 
-interface HeadingBlockData {
+interface HeadingBlockData extends TextBlockData {
   level: number;
+}
+
+interface ColumnBlockData {
+  ratio: string;
+}
+
+
+export interface TreeNodeInterface {
+  id: string;
+  type: BlockType;
+  parent: TreeNodeInterface | null;
+  children: TreeNodeInterface[];
+  data: BlockData<BlockType>;
 }

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/utils/block_context.ts

@@ -1,8 +1,8 @@
 
 import { createContext } from 'react';
-import { Block, BlockType } from '../interfaces';
 
 export const BlockContext = createContext<{
   id?: string;
-  blocksMap?: Record<string, Block>;
-} | null>(null);
+}>({});
+
+

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

@@ -0,0 +1,25 @@
+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
+}

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

@@ -0,0 +1,22 @@
+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)
+    }
+  }
+}

+ 37 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/slate/toolbar.ts

@@ -0,0 +1,37 @@
+import { getBlockEditor } from '@/appflowy_app/block_editor';
+import { Editor, Range } from 'slate';
+export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement, blockId: string) {
+  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) {
+    return;
+  } else {
+    domRange = domSelection?.getRangeAt(0);
+  }
+
+  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`;
+  
+  return {
+    top,
+    left,
+  }
+  
+}

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

@@ -0,0 +1,10 @@
+export function debounce(fn: (...args: any[]) => void, delay: number) {
+  let timeout: NodeJS.Timeout;
+  return (...args: any[]) => {
+    clearTimeout(timeout)
+    timeout = setTimeout(()=>{
+      // eslint-disable-next-line prefer-spread
+      fn.apply(undefined, args)
+    }, delay)
+  }
+}

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

@@ -1,36 +0,0 @@
-import { Block } from "../interfaces";
-
-export function buildTree(id: string, blocksMap: Record<string, Block>) {
-  const head = blocksMap[id];
-  let node: Block | null = head;
-  while(node) {
-    
-    if (node.parent) {
-      const parent = blocksMap[node.parent];
-      !parent.children && (parent.children = []);
-      parent.children.push(node);
-    }
-    
-    if (node.firstChild) {
-      node = blocksMap[node.firstChild];
-    } else  if (node.next) {
-      node = blocksMap[node.next];
-    } else {
-      while(node && node?.parent) {
-        const parent: Block | null = blocksMap[node.parent];
-        if (parent?.next) {
-          node = blocksMap[parent.next];
-          break;
-        } else {
-          node = parent;
-        }
-      }
-      if (node.id === head.id) {
-        node = null;
-        break;
-      }
-    } 
-
-  }
-  return head;
-}

+ 214 - 141
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts

@@ -4,12 +4,209 @@ import {
   DocumentVersionPB,
   OpenDocumentPayloadPB,
 } from '../../services/backend/events/flowy-document';
-import { Block, BlockType } from '../interfaces';
+import { BlockInterface, BlockType } from '../interfaces';
 import { useParams } from 'react-router-dom';
+import { getBlockEditor, createBlockEditor } from '../block_editor';
 
+const loadBlockData = async (id: string): Promise<Record<string, BlockInterface>> => {
+  return {
+    [id]: {
+      id: id,
+      type: BlockType.PageBlock,
+      data: { title: 'Document Title' },
+      next: null,
+      firstChild: "L1-1",
+    },
+    "L1-1": {
+      id: "L1-1",
+      type: BlockType.HeadingBlock,
+      data: { level: 1, content: [{ text: 'Heading 1' }] },
+      next: "L1-2",
+      firstChild: null,
+    },
+    "L1-2": {
+      id: "L1-2",
+      type: BlockType.HeadingBlock,
+      data: { level: 2, content: [{ text: 'Heading 2' }] },
+      next: "L1-3",
+      firstChild: null,
+    },
+    "L1-3": {
+      id: "L1-3",
+      type: BlockType.HeadingBlock,
+      data: { level: 3, content: [{ text: 'Heading 3' }] },
+      next: "L1-4",
+      firstChild: null,
+    },
+    "L1-4": {
+      id: "L1-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: "L1-5",
+      firstChild: null,
+    },
+    "L1-5": {
+      id: "L1-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: "L1-6",
+      firstChild: "L1-5-1",
+    },
+    "L1-5-1": {
+      id: "L1-5-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L1-5-2",
+      firstChild: null,
+    },
+    "L1-5-2": {
+      id: "L1-5-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L1-6": {
+      id: "L1-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: "L1-7",
+      firstChild: "L1-6-1",
+    },
+    "L1-6-1": {
+      id: "L1-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: "L1-6-2",
+      firstChild: null,
+    },
+    "L1-6-2": {
+      id: "L1-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: "L1-6-3",
+      firstChild: null,
+    },
+
+    "L1-6-3": {
+      id: "L1-6-3",
+      type: BlockType.TextBlock,
+      data: { content: [{ text: 'A wise quote.' }] },
+      next: null,
+      firstChild: null,
+    },
+    
+    "L1-7": {
+      id: "L1-7",
+      type: BlockType.ListBlock,
+      data: { type: 'column' },
+      
+      next: "L1-8",
+      firstChild: "L1-7-1",
+    },
+    "L1-7-1": {
+      id: "L1-7-1",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: "L1-7-2",
+      firstChild: "L1-7-1-1",
+    },
+    "L1-7-1-1": {
+      id: "L1-7-1-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L1-7-2": {
+      id: "L1-7-2",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: "L1-7-3",
+      firstChild: "L1-7-2-1",
+    },
+    "L1-7-2-1": {
+      id: "L1-7-2-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: "L1-7-2-2",
+      firstChild: null,
+    },
+    "L1-7-2-2": {
+      id: "L1-7-2-2",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+    "L1-7-3": {
+      id: "L1-7-3",
+      type: BlockType.ColumnBlock,
+      data: { ratio: '0.33' },
+      next: null,
+      firstChild: "L1-7-3-1",
+    },
+    "L1-7-3-1": {
+      id: "L1-7-3-1",
+      type: BlockType.TextBlock,
+      data: { content: [
+        { text: 'Try it out yourself! Just ' },
+      ] },
+      next: null,
+      firstChild: null,
+    },
+  }
+}
 export const useDocument = () => {
   const params = useParams();
-  const [blocksMap, setBlocksMap] = useState<Record<string, Block>>();
+  const [blockId, setBlockId] = useState<string>();
   const loadDocument = async (id: string): Promise<any> => {
     const getDocumentResult = await DocumentEventGetDocument(
       OpenDocumentPayloadPB.fromObject({
@@ -26,148 +223,24 @@ export const useDocument = () => {
     }
   };
 
-  const loadBlockData = async (blockId: string): Promise<Record<string, Block>> => {
-    return {
-      [blockId]: {
-        id: blockId,
-        type: BlockType.PageBlock,
-        data: { title: 'Document Title' },
-        parent: null,
-        next: null,
-        prev: null,
-        firstChild: "A",
-        lastChild: "E"
-      },
-      "A": {
-        id: "A",
-        type: BlockType.HeadingBlock,
-        data: { level: 1, text: 'A Heading-1' },
-        parent: blockId,
-        prev: null,
-        next: "B",
-        firstChild: null,
-        lastChild: null,
-      },
-      "B": {
-        id: "B",
-        type: BlockType.TextBlock,
-        data: { text: 'Hello', attr: '' },
-        parent: blockId,
-        prev: "A",
-        next: "C",
-        firstChild: null,
-        lastChild: null,
-      },
-      "C": {
-        id: "C",
-        type: BlockType.TextBlock,
-        data: { text: 'block c' },
-        prev: null,
-        parent: blockId,
-        next: "D",
-        firstChild: "F",
-        lastChild: null,
-      },
-      "D": {
-        id: "D",
-        type: BlockType.ListBlock,
-        data: { type: 'number_list', text: 'D List' },
-        prev: "C",
-        parent: blockId,
-        next: null,
-        firstChild: "G",
-        lastChild: "H",
-      },
-      "E": {
-        id: "E",
-        type: BlockType.TextBlock,
-        data: { text: 'World', attr: '' },
-        prev: "D",
-        parent: blockId,
-        next: null,
-        firstChild: null,
-        lastChild: null,
-      },
-      "F": {
-        id: "F",
-        type: BlockType.TextBlock,
-        data: { text: 'Heading', attr: '' },
-        prev: null,
-        parent: "C",
-        next: null,
-        firstChild: null,
-        lastChild: null,
-      },
-      "G": {
-        id: "G",
-        type: BlockType.TextBlock,
-        data: { text: 'Item 1', attr: '' },
-        prev: null,
-        parent: "D",
-        next: "H",
-        firstChild: null,
-        lastChild: null,
-      },
-      "H": {
-        id: "H",
-        type: BlockType.TextBlock,
-        data: { text: 'Item 2', attr: '' },
-        prev: "G",
-        parent: "D",
-        next: "I",
-        firstChild: null,
-        lastChild: null,
-      },
-      "I": {
-        id: "I",
-        type: BlockType.HeadingBlock,
-        data: { level: 2, text: 'B Heading-1' },
-        parent: blockId,
-        prev: "H",
-        next: 'L',
-        firstChild: null,
-        lastChild: null,
-      },
-      "L": {
-        id: "L",
-        type: BlockType.TextBlock,
-        data: { text: '456' },
-        parent: blockId,
-        prev: "I",
-        next: 'J',
-        firstChild: null,
-        lastChild: null,
-      },
-      "J": {
-        id: "J",
-        type: BlockType.HeadingBlock,
-        data: { level: 3, text: 'C Heading-1' },
-        parent: blockId,
-        prev: "L",
-        next: "K",
-        firstChild: null,
-        lastChild: null,
-      },
-      "K": {
-        id: "K",
-        type: BlockType.TextBlock,
-        data: { text: '123' },
-        parent: blockId,
-        prev: "J",
-        next: null,
-        firstChild: null,
-        lastChild: null,
-      },
-    }
-  }
-
   useEffect(() => {
     void (async () => {
       if (!params?.id) return;
       const data = await loadBlockData(params.id);
-      console.log(data);
-      setBlocksMap(data);
+      console.log('==== enter ====', params?.id, data);
+  
+      const blockEditor = getBlockEditor();
+      if (blockEditor) {
+        blockEditor.changeDoc(params?.id, data);
+      } else {
+        createBlockEditor(params?.id, data);
+      }
+
+      setBlockId(params.id)
     })();
-  }, [params]);
-  return { blocksMap, blockId: params.id };
+    return () => {
+      console.log('==== leave ====', params?.id)
+    }
+  }, [params.id]);
+  return { blockId };
 };

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

@@ -9,18 +9,18 @@ const theme = createTheme({
   },
 });
 export const DocumentPage = () => {
-  const { blocksMap, blockId } = useDocument();
+  const { blockId } = useDocument();
 
+  if (!blockId) return <div className='error-page'></div>;
   return (
     <ThemeProvider theme={theme}>
-      <div id='appflowy-block-doc' className='flex flex-col items-center'>
+      <div id='appflowy-block-doc' className='doc-scroller-container flex h-[100%] flex-col items-center overflow-auto'>
         <BlockContext.Provider
           value={{
             id: blockId,
-            blocksMap,
           }}
         >
-          <BlockList />
+          <BlockList blockId={blockId} />
         </BlockContext.Provider>
       </div>
     </ThemeProvider>