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:
qinluhe 2023-03-13 12:04:31 +08:00 committed by GitHub
parent 79a43de2d5
commit ed2c5c17d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 919 additions and 309 deletions

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View 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);
}
}

View File

@ -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';

View File

@ -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;

View File

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

View File

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

View File

@ -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
}
}

View File

@ -1,17 +1,43 @@
import { useBlockList } from './BlockList.hooks';
import BlockComponent from './BlockComponent';
import React, { useEffect } from 'react';
import { debounce } from '@/appflowy_app/utils/tool';
import { getBlockEditor } from '../../../block_editor';
export default function BlockList() {
const { blockList, title } = useBlockList();
const RESIZE_DELAY = 200;
function BlockList({ blockId }: { blockId: string }) {
const blockEditor = getBlockEditor();
if (!blockEditor) return null;
const root = blockEditor.renderTree.build(blockId);
console.log('==== build tree ====', root);
useEffect(() => {
// update rect cache when did mount
blockEditor.renderTree.updateRects();
const resize = debounce(() => {
// update rect cache when window resized
blockEditor.renderTree.updateRects();
}, RESIZE_DELAY);
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
};
}, []);
return (
<div className='min-x-[0%] p-lg w-[900px] max-w-[100%]'>
<div className='my-[50px] flex px-14 text-4xl font-bold'>{title}</div>
<div className='my-[50px] flex px-14 text-4xl font-bold'>{root?.data.title}</div>
<div className='px-14'>
{blockList?.map((block) => (
<BlockComponent key={block.id} block={block} />
))}
{root && root.children.length > 0
? root.children.map((node) => <BlockComponent key={node.id} node={node} />)
: null}
</div>
</div>
);
}
export default React.memo(BlockList);

View File

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

View File

@ -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>
</>
);
}

View File

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

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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,
}
}

View 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)
}
}

View File

@ -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;
}

View File

@ -4,12 +4,209 @@ import {
DocumentVersionPB,
OpenDocumentPayloadPB,
} from '../../services/backend/events/flowy-document';
import { Block, BlockType } from '../interfaces';
import { BlockInterface, BlockType } from '../interfaces';
import { useParams } from 'react-router-dom';
import { getBlockEditor, createBlockEditor } from '../block_editor';
const loadBlockData = async (id: string): Promise<Record<string, BlockInterface>> => {
return {
[id]: {
id: id,
type: BlockType.PageBlock,
data: { title: 'Document Title' },
next: null,
firstChild: "L1-1",
},
"L1-1": {
id: "L1-1",
type: BlockType.HeadingBlock,
data: { level: 1, content: [{ text: 'Heading 1' }] },
next: "L1-2",
firstChild: null,
},
"L1-2": {
id: "L1-2",
type: BlockType.HeadingBlock,
data: { level: 2, content: [{ text: 'Heading 2' }] },
next: "L1-3",
firstChild: null,
},
"L1-3": {
id: "L1-3",
type: BlockType.HeadingBlock,
data: { level: 3, content: [{ text: 'Heading 3' }] },
next: "L1-4",
firstChild: null,
},
"L1-4": {
id: "L1-4",
type: BlockType.TextBlock,
data: { content: [
{
text:
'This example shows how you can make a hovering menu appear above your content, which you can use to make text ',
},
{ text: 'bold', bold: true },
{ text: ', ' },
{ text: 'italic', italic: true },
{ text: ', or anything else you might want to do!' },
] },
next: "L1-5",
firstChild: null,
},
"L1-5": {
id: "L1-5",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
{ text: 'select any piece of text and the menu will appear', bold: true },
{ text: '.' },
] },
next: "L1-6",
firstChild: "L1-5-1",
},
"L1-5-1": {
id: "L1-5-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L1-5-2",
firstChild: null,
},
"L1-5-2": {
id: "L1-5-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L1-6": {
id: "L1-6",
type: BlockType.ListBlock,
data: { type: 'bulleted', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
{ text: 'bold', bold: true },
{
text:
', or add a semantically rendered block quote in the middle of the page, like this:',
},
] },
next: "L1-7",
firstChild: "L1-6-1",
},
"L1-6-1": {
id: "L1-6-1",
type: BlockType.ListBlock,
data: { type: 'numbered', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
] },
next: "L1-6-2",
firstChild: null,
},
"L1-6-2": {
id: "L1-6-2",
type: BlockType.ListBlock,
data: { type: 'numbered', content: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
] },
next: "L1-6-3",
firstChild: null,
},
"L1-6-3": {
id: "L1-6-3",
type: BlockType.TextBlock,
data: { content: [{ text: 'A wise quote.' }] },
next: null,
firstChild: null,
},
"L1-7": {
id: "L1-7",
type: BlockType.ListBlock,
data: { type: 'column' },
next: "L1-8",
firstChild: "L1-7-1",
},
"L1-7-1": {
id: "L1-7-1",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: "L1-7-2",
firstChild: "L1-7-1-1",
},
"L1-7-1-1": {
id: "L1-7-1-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L1-7-2": {
id: "L1-7-2",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: "L1-7-3",
firstChild: "L1-7-2-1",
},
"L1-7-2-1": {
id: "L1-7-2-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: "L1-7-2-2",
firstChild: null,
},
"L1-7-2-2": {
id: "L1-7-2-2",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
"L1-7-3": {
id: "L1-7-3",
type: BlockType.ColumnBlock,
data: { ratio: '0.33' },
next: null,
firstChild: "L1-7-3-1",
},
"L1-7-3-1": {
id: "L1-7-3-1",
type: BlockType.TextBlock,
data: { content: [
{ text: 'Try it out yourself! Just ' },
] },
next: null,
firstChild: null,
},
}
}
export const useDocument = () => {
const params = useParams();
const [blocksMap, setBlocksMap] = useState<Record<string, Block>>();
const [blockId, setBlockId] = useState<string>();
const loadDocument = async (id: string): Promise<any> => {
const getDocumentResult = await DocumentEventGetDocument(
OpenDocumentPayloadPB.fromObject({
@ -26,148 +223,24 @@ export const useDocument = () => {
}
};
const loadBlockData = async (blockId: string): Promise<Record<string, Block>> => {
return {
[blockId]: {
id: blockId,
type: BlockType.PageBlock,
data: { title: 'Document Title' },
parent: null,
next: null,
prev: null,
firstChild: "A",
lastChild: "E"
},
"A": {
id: "A",
type: BlockType.HeadingBlock,
data: { level: 1, text: 'A Heading-1' },
parent: blockId,
prev: null,
next: "B",
firstChild: null,
lastChild: null,
},
"B": {
id: "B",
type: BlockType.TextBlock,
data: { text: 'Hello', attr: '' },
parent: blockId,
prev: "A",
next: "C",
firstChild: null,
lastChild: null,
},
"C": {
id: "C",
type: BlockType.TextBlock,
data: { text: 'block c' },
prev: null,
parent: blockId,
next: "D",
firstChild: "F",
lastChild: null,
},
"D": {
id: "D",
type: BlockType.ListBlock,
data: { type: 'number_list', text: 'D List' },
prev: "C",
parent: blockId,
next: null,
firstChild: "G",
lastChild: "H",
},
"E": {
id: "E",
type: BlockType.TextBlock,
data: { text: 'World', attr: '' },
prev: "D",
parent: blockId,
next: null,
firstChild: null,
lastChild: null,
},
"F": {
id: "F",
type: BlockType.TextBlock,
data: { text: 'Heading', attr: '' },
prev: null,
parent: "C",
next: null,
firstChild: null,
lastChild: null,
},
"G": {
id: "G",
type: BlockType.TextBlock,
data: { text: 'Item 1', attr: '' },
prev: null,
parent: "D",
next: "H",
firstChild: null,
lastChild: null,
},
"H": {
id: "H",
type: BlockType.TextBlock,
data: { text: 'Item 2', attr: '' },
prev: "G",
parent: "D",
next: "I",
firstChild: null,
lastChild: null,
},
"I": {
id: "I",
type: BlockType.HeadingBlock,
data: { level: 2, text: 'B Heading-1' },
parent: blockId,
prev: "H",
next: 'L',
firstChild: null,
lastChild: null,
},
"L": {
id: "L",
type: BlockType.TextBlock,
data: { text: '456' },
parent: blockId,
prev: "I",
next: 'J',
firstChild: null,
lastChild: null,
},
"J": {
id: "J",
type: BlockType.HeadingBlock,
data: { level: 3, text: 'C Heading-1' },
parent: blockId,
prev: "L",
next: "K",
firstChild: null,
lastChild: null,
},
"K": {
id: "K",
type: BlockType.TextBlock,
data: { text: '123' },
parent: blockId,
prev: "J",
next: null,
firstChild: null,
lastChild: null,
},
}
}
useEffect(() => {
void (async () => {
if (!params?.id) return;
const data = await loadBlockData(params.id);
console.log(data);
setBlocksMap(data);
console.log('==== enter ====', params?.id, data);
const blockEditor = getBlockEditor();
if (blockEditor) {
blockEditor.changeDoc(params?.id, data);
} else {
createBlockEditor(params?.id, data);
}
setBlockId(params.id)
})();
}, [params]);
return { blocksMap, blockId: params.id };
return () => {
console.log('==== leave ====', params?.id)
}
}, [params.id]);
return { blockId };
};

View File

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