diff --git a/frontend/appflowy_tauri/src/appflowy_app/block_editor/block.ts b/frontend/appflowy_tauri/src/appflowy_app/block_editor/block.ts new file mode 100644 index 0000000000..8fb8610b77 --- /dev/null +++ b/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 | null = null; + constructor(id: string, private map: Record> | null) { + if (!map) return; + this.head = map[id]; + } + + setBlocksMap = (id: string, map: Record>) => { + 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; + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/block_editor/index.ts b/frontend/appflowy_tauri/src/appflowy_app/block_editor/index.ts new file mode 100644 index 0000000000..01f49f656b --- /dev/null +++ b/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) { + 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) => { + 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) { + blockEditorInstance = new BlockEditor(id, data); + return blockEditorInstance; +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/block_editor/rect.ts b/frontend/appflowy_tauri/src/appflowy_app/block_editor/rect.ts new file mode 100644 index 0000000000..5398ab4a6f --- /dev/null +++ b/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; + + orderList: Set; + + private updatedQueue: Set; + + 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(); + } + +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/block_editor/tree.ts b/frontend/appflowy_tauri/src/appflowy_app/block_editor/tree.ts new file mode 100644 index 0000000000..bc545139fc --- /dev/null +++ b/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 = 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; + + constructor({ + id, + type, + data + }: BlockInterface) { + this.id = id; + this.data = data; + this.type = type; + } + + addChild(node: TreeNode) { + node.parent = this; + this.children.push(node); + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/FormatButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/FormatButton.tsx index 1b17c0bd1b..cbe27de694 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/FormatButton.tsx +++ b/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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/Portal.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/Portal.tsx new file mode 100644 index 0000000000..0176c8f429 --- /dev/null +++ b/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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/index.tsx index 5ad524c3b7..7b8454800b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/HoveringToolbar/index.tsx +++ b/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 }) => {
{ // prevent toolbar from taking focus away from editor diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockComponent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockComponent.tsx index 730e7ee742..051081ebaf 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockComponent.tsx +++ b/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, HTMLDivElement>) { const renderComponent = () => { - switch (block.type) { + switch (node.type) { case BlockType.PageBlock: - return ; + return ; case BlockType.TextBlock: - return ; + return ; case BlockType.HeadingBlock: - return ; + return ; case BlockType.ListBlock: - return ; + return ; case BlockType.CodeBlock: - return ; - + return ; default: return null; } }; return ( -
+
{renderComponent()} + {props.children} +
); } + +export default React.memo(BlockComponent); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockList.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockList.hooks.ts deleted file mode 100644 index 8c9114f99d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/BlockList.hooks.ts +++ /dev/null @@ -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([]); - - const [title, setTitle] = useState(''); - - 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 - } -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/index.tsx index 8364b6c75a..7badc069b1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/BlockList/index.tsx +++ b/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 (
-
{title}
+
{root?.data.title}
- {blockList?.map((block) => ( - - ))} + {root && root.children.length > 0 + ? root.children.map((node) => ) + : null}
); } + +export default React.memo(BlockList); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/CodeBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/CodeBlock/index.tsx index 57e74cf783..ab04d15820 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/CodeBlock/index.tsx +++ b/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
{block.data.text}
; +export default function CodeBlock({ node }: { node: TreeNodeInterface }) { + return
{node.data.text}
; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/ColumnBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/ColumnBlock/index.tsx new file mode 100644 index 0000000000..66c2076eed --- /dev/null +++ b/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 ( +
+ ); + }; + return ( + <> + {index === 0 ? ( +
+
+ {renderResizer()} +
+
+ ) : ( + renderResizer() + )} + + + {node.children?.map((item) => ( + + ))} + + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/HeadingBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/HeadingBlock/index.tsx index a7a39c57af..a3f47386fb 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/HeadingBlock/index.tsx +++ b/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 = { 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 ( -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/BulletedListBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/BulletedListBlock.tsx new file mode 100644 index 0000000000..8a69d1e3aa --- /dev/null +++ b/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 ( +
+
+
+ +
+ {title} +
+ +
+ {node.children?.map((item) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/ColumnListBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/ColumnListBlock.tsx new file mode 100644 index 0000000000..1c2b745229 --- /dev/null +++ b/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 ( + <> +
+ {node.children?.map((item, index) => ( + + ))} +
+ + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/NumberedListBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/NumberedListBlock.tsx new file mode 100644 index 0000000000..96857b663d --- /dev/null +++ b/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 ( +
+
+
{`${index} .`}
+ {title} +
+ +
+ {node.children?.map((item) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/index.tsx index 703944bb23..b235828ff4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/ListBlock/index.tsx +++ b/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) => ( -
  • - -
  • - )); - }; - - return ( -
    -
  • -
    +export default function ListBlock({ node }: { node: TreeNodeInterface }) { + const title = useMemo(() => { + if (node.data.type === 'column') return <>; + return ( +
    - {renderChildren()}
    -
    - ); + ); + }, [node]); + + if (node.data.type === 'numbered') { + return ; + } + + if (node.data.type === 'bulleted') { + return ; + } + + if (node.data.type === 'column') { + return ; + } + + return null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/PageBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/PageBlock/index.tsx index 349f6f7d9e..f4a5326916 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/PageBlock/index.tsx +++ b/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
    {block.data.title}
    ; +export default function PageBlock({ node }: { node: TreeNodeInterface }) { + return
    {node.data.title}
    ; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/Leaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/Leaf.tsx index b1c73f1116..aa5dcd1efa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/Leaf.tsx +++ b/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 = {children}; } - if ('code' in leaf && leaf.code) { + if (leaf.code) { newChildren = {newChildren}; } - if ('italic' in leaf && leaf.italic) { + if (leaf.italic) { newChildren = {newChildren}; } - if ('underlined' in leaf && leaf.underlined) { + if (leaf.underlined) { newChildren = {newChildren}; } return ( - + {newChildren} ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.tsx index 723573de27..dd3dbce5de 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/block/TextBlock/index.tsx +++ b/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 (
    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, }, ]} > - + { 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) => } placeholder='Enter some text...' /> -
    - {block.children?.map((item: Block) => ( - - ))} -
    + {node.children && node.children.length > 0 ? ( +
    + {node.children.map((item) => ( + + ))} +
    + ) : null}
    ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts index 7aedced472..4d91df74a6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts +++ b/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 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 { id: string; type: BlockType; - data: BlockData; - parent: string | null; - prev: string | null; + data: BlockData; 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; } \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/block_context.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/block_context.ts index 7d5248902f..71b99fd8a2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/block_context.ts +++ b/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; -} | null>(null); +}>({}); + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/format.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/slate/format.ts new file mode 100644 index 0000000000..fd36928b76 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts new file mode 100644 index 0000000000..fad418086d --- /dev/null +++ b/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 = { + '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, editor: Editor) { + for (const hotkey in HOTKEYS) { + if (isHotkey(hotkey, event)) { + event.preventDefault() + const format = HOTKEYS[hotkey] + toggleFormat(editor, format) + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/toolbar.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/slate/toolbar.ts new file mode 100644 index 0000000000..ff0572d278 --- /dev/null +++ b/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, + } + +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts new file mode 100644 index 0000000000..a893f2eb0f --- /dev/null +++ b/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) + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/tree.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/tree.ts deleted file mode 100644 index 6a3902f929..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/tree.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Block } from "../interfaces"; - -export function buildTree(id: string, blocksMap: Record) { - 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; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts index 0a1abca58a..6c4bda8e97 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts +++ b/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> => { + 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>(); + const [blockId, setBlockId] = useState(); const loadDocument = async (id: string): Promise => { const getDocumentResult = await DocumentEventGetDocument( OpenDocumentPayloadPB.fromObject({ @@ -26,148 +223,24 @@ export const useDocument = () => { } }; - const loadBlockData = async (blockId: string): Promise> => { - 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 }; }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx index 70be86656d..5bf71870da 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx +++ b/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
    ; return ( -
    +
    - +