feat: Initialize appflowy block data and render block list (#1940)

This commit is contained in:
qinluhe 2023-03-09 13:52:48 +08:00 committed by GitHub
parent 21199c04ac
commit 1d28bed281
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 5463 additions and 14 deletions

View File

@ -16,28 +16,38 @@
"test": "jest"
},
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.12",
"@reduxjs/toolkit": "^1.9.2",
"@tauri-apps/api": "^1.2.0",
"google-protobuf": "^3.21.2",
"i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1",
"is-hotkey": "^0.2.0",
"jest": "^29.4.3",
"nanoid": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^12.2.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.0",
"react18-input-otp": "^1.1.2",
"redux": "^4.2.1",
"rxjs": "^7.8.0",
"slate": "^0.91.4",
"slate-react": "^0.91.9",
"ts-results": "^3.3.0",
"utf8": "^3.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.2",
"@types/google-protobuf": "^3.15.6",
"@types/is-hotkey": "^0.1.7",
"@types/jest": "^29.4.0",
"@types/mocha": "^10.0.1",
"@types/node": "^18.7.10",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
import { useSlate } from 'slate-react';
import { toggleFormat, isFormatActive } from '$app/utils/editor/format';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { useMemo } from 'react';
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
import { command, iconSize } from '$app/constants/toolbar';
const FormatButton = ({ format, icon }: { format: string; icon: string }) => {
const editor = useSlate();
const renderComponent = useMemo(() => {
switch (icon) {
case 'bold':
return <FormatBold sx={iconSize} />;
case 'underlined':
return <FormatUnderlined sx={iconSize} />;
case 'italic':
return <FormatItalic sx={iconSize} />;
case 'code':
return <CodeOutlined sx={iconSize} />;
case 'strikethrough':
return <StrikethroughSOutlined sx={iconSize} />;
default:
break;
}
}, [icon]);
return (
<Tooltip
slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
title={
<div className='flex flex-col'>
<span className='text-base font-medium text-black'>{command[format].title}</span>
<span className='text-sm text-slate-400'>{command[format].key}</span>
</div>
}
placement='top-start'
>
<IconButton
size='small'
sx={{ color: isFormatActive(editor, format) ? '#00BCF0' : 'white' }}
onClick={() => toggleFormat(editor, format)}
>
{renderComponent}
</IconButton>
</Tooltip>
);
};
export default FormatButton;

View File

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

View File

@ -0,0 +1,50 @@
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';
const HoveringToolbar = ({ blockId }: { blockId: string }) => {
const editor = useSlate();
const inFocus = useFocused();
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const blockDom = document.querySelectorAll(`[data-block-id=${blockId}]`)[0];
const blockRect = blockDom?.getBoundingClientRect();
const position = calcToolbarPosition(editor, el, blockRect);
if (!position) {
el.style.opacity = '0';
} else {
el.style.opacity = '1';
el.style.top = position.top;
el.style.left = position.left;
}
});
if (!inFocus) return null;
return (
<Portal blockId={blockId}>
<div
ref={ref}
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
e.preventDefault();
}}
>
{['bold', 'italic', 'underlined', 'strikethrough', 'code'].map((format) => (
<FormatButton key={format} format={format} icon={format} />
))}
</div>
</Portal>
);
};
export default HoveringToolbar;

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Block, BlockType } 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 }) {
const renderComponent = () => {
switch (block.type) {
case BlockType.PageBlock:
return <PageBlock block={block} />;
case BlockType.TextBlock:
return <TextBlock block={block} />;
case BlockType.HeadingBlock:
return <HeadingBlock block={block} />;
case BlockType.ListBlock:
return <ListBlock block={block} />;
case BlockType.CodeBlock:
return <CodeBlock block={block} />;
default:
return null;
}
};
return (
<div className='relative' data-block-id={block.id}>
{renderComponent()}
</div>
);
}

View File

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

@ -0,0 +1,17 @@
import { useBlockList } from './BlockList.hooks';
import BlockComponent from './BlockComponent';
export default function BlockList() {
const { blockList, title } = useBlockList();
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='px-14'>
{blockList?.map((block) => (
<BlockComponent key={block.id} block={block} />
))}
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,21 @@
import React from 'react';
import { Block } from '$app/interfaces';
import TextBlock from '../TextBlock';
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 }) {
return (
<div className={`${fontSize[block.data.level]} font-semibold `}>
<TextBlock
block={{
...block,
children: [],
}}
/>
</div>
);
}

View File

@ -0,0 +1,29 @@
import React from 'react';
import { Block } from '$app/interfaces';
import BlockComponent from '../BlockList/BlockComponent';
import TextBlock from '../TextBlock';
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>
<TextBlock
block={{
...block,
children: [],
}}
/>
{renderChildren()}
</div>
</div>
);
}

View File

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

View File

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

View File

@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Block } 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';
export default function TextBlock({ block }: { block: Block }) {
const [editor] = useState(() => withReact(createEditor()));
return (
<div className='mb-2'>
<Slate
editor={editor}
onChange={(e) => console.log('===', e, editor.operations)}
value={[
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type: 'paragraph',
children: [{ text: block.data.text }],
},
]}
>
<HoveringToolbar blockId={block.id} />
<Editable
onKeyDownCapture={(event) => {
switch (event.key) {
case 'Enter': {
event.stopPropagation();
event.preventDefault();
return;
}
}
triggerHotkey(event, editor);
}}
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>
</div>
);
}

View File

@ -0,0 +1,25 @@
export const iconSize = { width: 18, height: 18 };
export const command: Record<string, { title: string; key: string }> = {
bold: {
title: 'Bold',
key: '⌘ + B',
},
underlined: {
title: 'Underlined',
key: '⌘ + U',
},
italic: {
title: 'Italic',
key: '⌘ + I',
},
code: {
title: 'Mark as code',
key: '⌘ + E',
},
strikethrough: {
title: 'Strike through',
key: '⌘ + Shift + S or ⌘ + Shift + X',
},
};

View File

@ -0,0 +1,51 @@
// 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,
}
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;
export interface Block {
id: string;
type: BlockType;
data: BlockData<BlockType>;
parent: string | null;
prev: string | null;
next: string | null;
firstChild: string | null;
lastChild: string | null;
children?: Block[];
}
interface TextBlockData {
text: string;
attr: string;
}
interface PageBlockData {
title: string;
}
interface ListBlockData {
type: 'ul' | 'ol';
}
interface HeadingBlockData {
level: number;
}

View File

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

View File

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

@ -1,10 +1,15 @@
import { useEffect, useState } from 'react';
import {
DocumentEventGetDocument,
DocumentVersionPB,
OpenDocumentPayloadPB,
} from '../../services/backend/events/flowy-document';
import { Block, BlockType } from '../interfaces';
import { useParams } from 'react-router-dom';
export const useDocument = () => {
const params = useParams();
const [blocksMap, setBlocksMap] = useState<Record<string, Block>>();
const loadDocument = async (id: string): Promise<any> => {
const getDocumentResult = await DocumentEventGetDocument(
OpenDocumentPayloadPB.fromObject({
@ -20,5 +25,149 @@ export const useDocument = () => {
throw new Error('get document error');
}
};
return { loadDocument };
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);
})();
}, [params]);
return { blocksMap, blockId: params.id };
};

View File

@ -1,17 +1,28 @@
import { useParams } from 'react-router-dom';
import { useEffect } from 'react';
import { useDocument } from './DocumentPage.hooks';
import BlockList from '../components/block/BlockList';
import { BlockContext } from '../utils/block_context';
import { createTheme, ThemeProvider } from '@mui/material';
const theme = createTheme({
typography: {
fontFamily: ['Poppins'].join(','),
},
});
export const DocumentPage = () => {
const params = useParams();
const { loadDocument } = useDocument();
useEffect(() => {
void (async () => {
if (!params?.id) return;
const content: any = await loadDocument(params.id);
console.log(content);
})();
}, [params]);
const { blocksMap, blockId } = useDocument();
return <div className={'p-8'}>Document Page ID: {params.id}</div>;
return (
<ThemeProvider theme={theme}>
<div id='appflowy-block-doc' className='flex flex-col items-center'>
<BlockContext.Provider
value={{
id: blockId,
blocksMap,
}}
>
<BlockList />
</BlockContext.Provider>
</div>
</ThemeProvider>
);
};

View File

@ -1 +1,2 @@
/// <reference types="vite/client" />

View File

@ -16,6 +16,10 @@ body {
font-family: Poppins;
}
::selection {
@apply bg-[#E0F8FF]
}
.btn {
@apply rounded-xl border border-gray-500 px-4 py-3;
}

View File

@ -14,8 +14,15 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"types": ["node", "jest"],
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"$app/*": ["src/appflowy_app/*"]
},
},
"include": ["src", "vite.config.ts", "../app_flowy/assets/translations"],
"exclude": ["node_modules"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -24,4 +24,10 @@ export default defineConfig({
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
resolve: {
alias: [
{ find: '@/', replacement: `${__dirname}/src/` },
{ find: '$app/', replacement: `${__dirname}/src/appflowy_app/` }
],
},
});