mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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
This commit is contained in:
parent
79a43de2d5
commit
ed2c5c17d8
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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
frontend/appflowy_tauri/src/appflowy_app/block_editor/tree.ts
Normal file
140
frontend/appflowy_tauri/src/appflowy_app/block_editor/tree.ts
Normal file
@ -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,5 +1,5 @@
|
|||||||
import { useSlate } from 'slate-react';
|
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 IconButton from '@mui/material/IconButton';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
@ -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;
|
@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useFocused, useSlate } from 'slate-react';
|
import { useFocused, useSlate } from 'slate-react';
|
||||||
import FormatButton from './FormatButton';
|
import FormatButton from './FormatButton';
|
||||||
import { Portal } from './components';
|
import Portal from './Portal';
|
||||||
import { calcToolbarPosition } from '$app/utils/editor/toolbar';
|
import { calcToolbarPosition } from '@/appflowy_app/utils/slate/toolbar';
|
||||||
|
|
||||||
const HoveringToolbar = ({ blockId }: { blockId: string }) => {
|
const HoveringToolbar = ({ blockId }: { blockId: string }) => {
|
||||||
const editor = useSlate();
|
const editor = useSlate();
|
||||||
@ -13,10 +13,7 @@ const HoveringToolbar = ({ blockId }: { blockId: string }) => {
|
|||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const blockDom = document.querySelectorAll(`[data-block-id=${blockId}]`)[0];
|
const position = calcToolbarPosition(editor, el, blockId);
|
||||||
const blockRect = blockDom?.getBoundingClientRect();
|
|
||||||
|
|
||||||
const position = calcToolbarPosition(editor, el, blockRect);
|
|
||||||
|
|
||||||
if (!position) {
|
if (!position) {
|
||||||
el.style.opacity = '0';
|
el.style.opacity = '0';
|
||||||
@ -33,6 +30,9 @@ const HoveringToolbar = ({ blockId }: { blockId: string }) => {
|
|||||||
<Portal blockId={blockId}>
|
<Portal blockId={blockId}>
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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'
|
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) => {
|
onMouseDown={(e) => {
|
||||||
// prevent toolbar from taking focus away from editor
|
// prevent toolbar from taking focus away from editor
|
||||||
|
@ -1,33 +1,39 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Block, BlockType } from '$app/interfaces';
|
import { BlockType, TreeNodeInterface } from '$app/interfaces';
|
||||||
import PageBlock from '../PageBlock';
|
import PageBlock from '../PageBlock';
|
||||||
import TextBlock from '../TextBlock';
|
import TextBlock from '../TextBlock';
|
||||||
import HeadingBlock from '../HeadingBlock';
|
import HeadingBlock from '../HeadingBlock';
|
||||||
import ListBlock from '../ListBlock';
|
import ListBlock from '../ListBlock';
|
||||||
import CodeBlock from '../CodeBlock';
|
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 = () => {
|
const renderComponent = () => {
|
||||||
switch (block.type) {
|
switch (node.type) {
|
||||||
case BlockType.PageBlock:
|
case BlockType.PageBlock:
|
||||||
return <PageBlock block={block} />;
|
return <PageBlock node={node} />;
|
||||||
case BlockType.TextBlock:
|
case BlockType.TextBlock:
|
||||||
return <TextBlock block={block} />;
|
return <TextBlock node={node} />;
|
||||||
case BlockType.HeadingBlock:
|
case BlockType.HeadingBlock:
|
||||||
return <HeadingBlock block={block} />;
|
return <HeadingBlock node={node} />;
|
||||||
case BlockType.ListBlock:
|
case BlockType.ListBlock:
|
||||||
return <ListBlock block={block} />;
|
return <ListBlock node={node} />;
|
||||||
case BlockType.CodeBlock:
|
case BlockType.CodeBlock:
|
||||||
return <CodeBlock block={block} />;
|
return <CodeBlock node={node} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='relative' data-block-id={block.id}>
|
<div className='relative' data-block-id={node.id} {...props}>
|
||||||
{renderComponent()}
|
{renderComponent()}
|
||||||
|
{props.children}
|
||||||
|
<div className='block-overlay'></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(BlockComponent);
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +1,43 @@
|
|||||||
import { useBlockList } from './BlockList.hooks';
|
|
||||||
import BlockComponent from './BlockComponent';
|
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 RESIZE_DELAY = 200;
|
||||||
const { blockList, title } = useBlockList();
|
|
||||||
|
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 (
|
return (
|
||||||
<div className='min-x-[0%] p-lg w-[900px] max-w-[100%]'>
|
<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'>
|
<div className='px-14'>
|
||||||
{blockList?.map((block) => (
|
{root && root.children.length > 0
|
||||||
<BlockComponent key={block.id} block={block} />
|
? root.children.map((node) => <BlockComponent key={node.id} node={node} />)
|
||||||
))}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(BlockList);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Block } from '$app/interfaces';
|
import { TreeNodeInterface } from '$app/interfaces';
|
||||||
|
|
||||||
export default function CodeBlock({ block }: { block: Block }) {
|
export default function CodeBlock({ node }: { node: TreeNodeInterface }) {
|
||||||
return <div>{block.data.text}</div>;
|
return <div>{node.data.text}</div>;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,18 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Block } from '$app/interfaces';
|
|
||||||
import TextBlock from '../TextBlock';
|
import TextBlock from '../TextBlock';
|
||||||
|
import { TreeNodeInterface } from '$app/interfaces/index';
|
||||||
|
|
||||||
const fontSize: Record<string, string> = {
|
const fontSize: Record<string, string> = {
|
||||||
1: 'mt-8 text-3xl',
|
1: 'mt-8 text-3xl',
|
||||||
2: 'mt-6 text-2xl',
|
2: 'mt-6 text-2xl',
|
||||||
3: 'mt-4 text-xl',
|
3: 'mt-4 text-xl',
|
||||||
};
|
};
|
||||||
export default function HeadingBlock({ block }: { block: Block }) {
|
export default function HeadingBlock({ node }: { node: TreeNodeInterface }) {
|
||||||
return (
|
return (
|
||||||
<div className={`${fontSize[block.data.level]} font-semibold `}>
|
<div className={`${fontSize[node.data.level]} font-semibold `}>
|
||||||
<TextBlock
|
<TextBlock
|
||||||
block={{
|
node={{
|
||||||
...block,
|
...node,
|
||||||
children: [],
|
children: [],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,29 +1,36 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Block } from '$app/interfaces';
|
|
||||||
import BlockComponent from '../BlockList/BlockComponent';
|
|
||||||
import TextBlock from '../TextBlock';
|
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 }) {
|
export default function ListBlock({ node }: { node: TreeNodeInterface }) {
|
||||||
const renderChildren = () => {
|
const title = useMemo(() => {
|
||||||
return block.children?.map((item) => (
|
if (node.data.type === 'column') return <></>;
|
||||||
<li key={item.id}>
|
return (
|
||||||
<BlockComponent block={item} />
|
<div className='flex-1'>
|
||||||
</li>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${block.data.type === 'ul' ? 'bulleted_list' : 'number_list'} flex`}>
|
|
||||||
<li className='w-[24px]' />
|
|
||||||
<div>
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
block={{
|
node={{
|
||||||
...block,
|
...node,
|
||||||
children: [],
|
children: [],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{renderChildren()}
|
|
||||||
</div>
|
</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;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Block } from '$app/interfaces';
|
import { TreeNodeInterface } from '$app/interfaces';
|
||||||
|
|
||||||
export default function PageBlock({ block }: { block: Block }) {
|
export default function PageBlock({ node }: { node: TreeNodeInterface }) {
|
||||||
return <div className='cursor-pointer underline'>{block.data.title}</div>;
|
return <div className='cursor-pointer underline'>{node.data.title}</div>;
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,38 @@
|
|||||||
|
import { BaseText } from 'slate';
|
||||||
import { RenderLeafProps } from 'slate-react';
|
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;
|
let newChildren = children;
|
||||||
if ('bold' in leaf && leaf.bold) {
|
if (leaf.bold) {
|
||||||
newChildren = <strong>{children}</strong>;
|
newChildren = <strong>{children}</strong>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('code' in leaf && leaf.code) {
|
if (leaf.code) {
|
||||||
newChildren = <code className='rounded-sm bg-[#F2FCFF] p-1'>{newChildren}</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>;
|
newChildren = <em>{newChildren}</em>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('underlined' in leaf && leaf.underlined) {
|
if (leaf.underlined) {
|
||||||
newChildren = <u>{newChildren}</u>;
|
newChildren = <u>{newChildren}</u>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span {...attributes} className={'strikethrough' in leaf && leaf.strikethrough ? `line-through` : ''}>
|
<span {...attributes} className={leaf.strikethrough ? `line-through` : ''}>
|
||||||
{newChildren}
|
{newChildren}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
@ -1,31 +1,52 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useContext, useMemo, useState } from 'react';
|
||||||
import { Block } from '$app/interfaces';
|
import { TreeNodeInterface } from '$app/interfaces';
|
||||||
import BlockComponent from '../BlockList/BlockComponent';
|
import BlockComponent from '../BlockList/BlockComponent';
|
||||||
|
|
||||||
import { createEditor } from 'slate';
|
import { createEditor } from 'slate';
|
||||||
import { Slate, Editable, withReact } from 'slate-react';
|
import { Slate, Editable, withReact } from 'slate-react';
|
||||||
import Leaf from './Leaf';
|
import Leaf from './Leaf';
|
||||||
import HoveringToolbar from '$app/components/HoveringToolbar';
|
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 [editor] = useState(() => withReact(createEditor()));
|
||||||
|
|
||||||
|
const { id } = useContext(BlockContext);
|
||||||
|
|
||||||
|
const debounceUpdateBlockCache = useMemo(
|
||||||
|
() => debounce(blockEditor.renderTree.updateNodeRect, INPUT_CHANGE_CACHE_DELAY),
|
||||||
|
[id, node.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mb-2'>
|
<div className='mb-2'>
|
||||||
<Slate
|
<Slate
|
||||||
editor={editor}
|
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={[
|
value={[
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
children: [{ text: block.data.text }],
|
children: node.data.content,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<HoveringToolbar blockId={block.id} />
|
<HoveringToolbar blockId={node.id} />
|
||||||
<Editable
|
<Editable
|
||||||
onKeyDownCapture={(event) => {
|
onKeyDownCapture={(event) => {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
@ -38,15 +59,25 @@ export default function TextBlock({ block }: { block: Block }) {
|
|||||||
|
|
||||||
triggerHotkey(event, editor);
|
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} />}
|
renderLeaf={(props) => <Leaf {...props} />}
|
||||||
placeholder='Enter some text...'
|
placeholder='Enter some text...'
|
||||||
/>
|
/>
|
||||||
</Slate>
|
</Slate>
|
||||||
<div className='pl-[1.5em]'>
|
{node.children && node.children.length > 0 ? (
|
||||||
{block.children?.map((item: Block) => (
|
<div className='pl-[1.5em]'>
|
||||||
<BlockComponent key={item.id} block={item} />
|
{node.children.map((item) => (
|
||||||
))}
|
<BlockComponent key={item.id} node={item} />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,51 +1,64 @@
|
|||||||
|
import { Descendant } from "slate";
|
||||||
|
|
||||||
// eslint-disable-next-line no-shadow
|
// eslint-disable-next-line no-shadow
|
||||||
export enum BlockType {
|
export enum BlockType {
|
||||||
PageBlock = 0,
|
PageBlock = 'page',
|
||||||
HeadingBlock = 1,
|
HeadingBlock = 'heading',
|
||||||
ListBlock = 2,
|
ListBlock = 'list',
|
||||||
TextBlock = 3,
|
TextBlock = 'text',
|
||||||
CodeBlock = 4,
|
CodeBlock = 'code',
|
||||||
EmbedBlock = 5,
|
EmbedBlock = 'embed',
|
||||||
QuoteBlock = 6,
|
QuoteBlock = 'quote',
|
||||||
DividerBlock = 7,
|
DividerBlock = 'divider',
|
||||||
MediaBlock = 8,
|
MediaBlock = 'media',
|
||||||
TableBlock = 9,
|
TableBlock = 'table',
|
||||||
|
ColumnBlock = 'column'
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type BlockData<T> = T extends BlockType.TextBlock ? TextBlockData :
|
export type BlockData<T> = T extends BlockType.TextBlock ? TextBlockData :
|
||||||
T extends BlockType.PageBlock ? PageBlockData :
|
T extends BlockType.PageBlock ? PageBlockData :
|
||||||
T extends BlockType.HeadingBlock ? HeadingBlockData:
|
T extends BlockType.HeadingBlock ? HeadingBlockData :
|
||||||
T extends BlockType.ListBlock ? ListBlockData : any;
|
T extends BlockType.ListBlock ? ListBlockData :
|
||||||
|
T extends BlockType.ColumnBlock ? ColumnBlockData : any;
|
||||||
|
|
||||||
export interface Block {
|
|
||||||
|
export interface BlockInterface<T = BlockType> {
|
||||||
id: string;
|
id: string;
|
||||||
type: BlockType;
|
type: BlockType;
|
||||||
data: BlockData<BlockType>;
|
data: BlockData<T>;
|
||||||
parent: string | null;
|
|
||||||
prev: string | null;
|
|
||||||
next: string | null;
|
next: string | null;
|
||||||
firstChild: string | null;
|
firstChild: string | null;
|
||||||
lastChild: string | null;
|
|
||||||
children?: Block[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TextBlockData {
|
interface TextBlockData {
|
||||||
text: string;
|
content: Descendant[];
|
||||||
attr: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageBlockData {
|
interface PageBlockData {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListBlockData {
|
interface ListBlockData extends TextBlockData {
|
||||||
type: 'ul' | 'ol';
|
type: 'numbered' | 'bulleted' | 'column';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HeadingBlockData {
|
interface HeadingBlockData extends TextBlockData {
|
||||||
level: number;
|
level: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ColumnBlockData {
|
||||||
|
ratio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TreeNodeInterface {
|
||||||
|
id: string;
|
||||||
|
type: BlockType;
|
||||||
|
parent: TreeNodeInterface | null;
|
||||||
|
children: TreeNodeInterface[];
|
||||||
|
data: BlockData<BlockType>;
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
import { Block, BlockType } from '../interfaces';
|
|
||||||
|
|
||||||
export const BlockContext = createContext<{
|
export const BlockContext = createContext<{
|
||||||
id?: string;
|
id?: string;
|
||||||
blocksMap?: Record<string, Block>;
|
}>({});
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts
Normal file
10
frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
@ -4,12 +4,209 @@ import {
|
|||||||
DocumentVersionPB,
|
DocumentVersionPB,
|
||||||
OpenDocumentPayloadPB,
|
OpenDocumentPayloadPB,
|
||||||
} from '../../services/backend/events/flowy-document';
|
} from '../../services/backend/events/flowy-document';
|
||||||
import { Block, BlockType } from '../interfaces';
|
import { BlockInterface, BlockType } from '../interfaces';
|
||||||
import { useParams } from 'react-router-dom';
|
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 = () => {
|
export const useDocument = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [blocksMap, setBlocksMap] = useState<Record<string, Block>>();
|
const [blockId, setBlockId] = useState<string>();
|
||||||
const loadDocument = async (id: string): Promise<any> => {
|
const loadDocument = async (id: string): Promise<any> => {
|
||||||
const getDocumentResult = await DocumentEventGetDocument(
|
const getDocumentResult = await DocumentEventGetDocument(
|
||||||
OpenDocumentPayloadPB.fromObject({
|
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(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (!params?.id) return;
|
if (!params?.id) return;
|
||||||
const data = await loadBlockData(params.id);
|
const data = await loadBlockData(params.id);
|
||||||
console.log(data);
|
console.log('==== enter ====', params?.id, data);
|
||||||
setBlocksMap(data);
|
|
||||||
|
const blockEditor = getBlockEditor();
|
||||||
|
if (blockEditor) {
|
||||||
|
blockEditor.changeDoc(params?.id, data);
|
||||||
|
} else {
|
||||||
|
createBlockEditor(params?.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBlockId(params.id)
|
||||||
})();
|
})();
|
||||||
}, [params]);
|
return () => {
|
||||||
return { blocksMap, blockId: params.id };
|
console.log('==== leave ====', params?.id)
|
||||||
|
}
|
||||||
|
}, [params.id]);
|
||||||
|
return { blockId };
|
||||||
};
|
};
|
||||||
|
@ -9,18 +9,18 @@ const theme = createTheme({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const DocumentPage = () => {
|
export const DocumentPage = () => {
|
||||||
const { blocksMap, blockId } = useDocument();
|
const { blockId } = useDocument();
|
||||||
|
|
||||||
|
if (!blockId) return <div className='error-page'></div>;
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<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
|
<BlockContext.Provider
|
||||||
value={{
|
value={{
|
||||||
id: blockId,
|
id: blockId,
|
||||||
blocksMap,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BlockList />
|
<BlockList blockId={blockId} />
|
||||||
</BlockContext.Provider>
|
</BlockContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
Loading…
Reference in New Issue
Block a user