mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
refactor: document controller
This commit is contained in:
parent
35c21c0d84
commit
886766c887
@ -72,7 +72,7 @@ export function useBlockSelection({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const calcIntersectBlocks = useCallback(
|
||||
const updateSelctionsByPoint = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!isDragging) return;
|
||||
const [startX, startY] = pointRef.current;
|
||||
@ -86,7 +86,7 @@ export function useBlockSelection({
|
||||
endY,
|
||||
});
|
||||
disaptch(
|
||||
documentActions.changeSelectionByIntersectRect({
|
||||
documentActions.setSelectionByRect({
|
||||
startX: Math.min(startX, endX),
|
||||
startY: Math.min(startY, endY),
|
||||
endX: Math.max(startX, endX),
|
||||
@ -102,7 +102,7 @@ export function useBlockSelection({
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
calcIntersectBlocks(e.clientX, e.clientY);
|
||||
updateSelctionsByPoint(e.clientX, e.clientY);
|
||||
|
||||
const { top, bottom } = container.getBoundingClientRect();
|
||||
if (e.clientY >= bottom) {
|
||||
@ -124,7 +124,7 @@ export function useBlockSelection({
|
||||
}
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
calcIntersectBlocks(e.clientX, e.clientY);
|
||||
updateSelctionsByPoint(e.clientX, e.clientY);
|
||||
setDragging(false);
|
||||
setRect(null);
|
||||
},
|
||||
|
@ -2,7 +2,7 @@ import { BlockType } from '@/appflowy_app/interfaces/document';
|
||||
import { useAppSelector } from '@/appflowy_app/stores/store';
|
||||
import { debounce } from '@/appflowy_app/utils/tool';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { YDocControllerContext } from '../../../stores/effects/document/document_controller';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@ -74,7 +74,7 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement })
|
||||
}
|
||||
|
||||
function useController() {
|
||||
const controller = useContext(YDocControllerContext);
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
|
||||
const insertAfter = useCallback((node: Node) => {
|
||||
const parentId = node.parent;
|
||||
|
@ -5,14 +5,14 @@ import { documentActions } from '$app/stores/reducers/document/slice';
|
||||
|
||||
export function useParseTree(documentData: DocumentData) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { blocks, ytexts, yarrays } = documentData;
|
||||
const { blocks, meta } = documentData;
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
documentActions.createTree({
|
||||
documentActions.create({
|
||||
nodes: blocks,
|
||||
delta: ytexts,
|
||||
children: yarrays,
|
||||
delta: meta.text_map,
|
||||
children: meta.children_map,
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { useRoot } from './Root.hooks';
|
||||
import Node from '../Node';
|
||||
import { withErrorBoundary } from 'react-error-boundary';
|
||||
import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent';
|
||||
import VirtualizerList from '../VirtualizerList';
|
||||
import VirtualizedList from '../VirtualizerList';
|
||||
import { Skeleton } from '@mui/material';
|
||||
|
||||
function Root({ documentData }: { documentData: DocumentData }) {
|
||||
@ -20,7 +20,7 @@ function Root({ documentData }: { documentData: DocumentData }) {
|
||||
|
||||
return (
|
||||
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
|
||||
<VirtualizerList node={node} childIds={childIds} renderNode={renderNode} />
|
||||
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,61 +0,0 @@
|
||||
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { createEditor } from "slate";
|
||||
import { withReact } from "slate-react";
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
|
||||
import { Delta } from '@slate-yjs/core/dist/model/types';
|
||||
import { TextDelta } from '@/appflowy_app/interfaces/document';
|
||||
|
||||
const initialValue = [{
|
||||
type: 'paragraph',
|
||||
children: [{ text: '' }],
|
||||
}];
|
||||
|
||||
export function useBindYjs(delta: TextDelta[], update: (_delta: Delta) => void) {
|
||||
const yTextRef = useRef<Y.XmlText>();
|
||||
// Create a yjs document and get the shared type
|
||||
const sharedType = useMemo(() => {
|
||||
const ydoc = new Y.Doc()
|
||||
const _sharedType = ydoc.get('content', Y.XmlText) as Y.XmlText;
|
||||
|
||||
const insertDelta = slateNodesToInsertDelta(initialValue);
|
||||
// Load the initial value into the yjs document
|
||||
_sharedType.applyDelta(insertDelta);
|
||||
|
||||
const yText = insertDelta[0].insert as Y.XmlText;
|
||||
yTextRef.current = yText;
|
||||
|
||||
return _sharedType;
|
||||
}, []);
|
||||
|
||||
const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
|
||||
|
||||
useEffect(() => {
|
||||
YjsEditor.connect(editor);
|
||||
return () => {
|
||||
yTextRef.current = undefined;
|
||||
YjsEditor.disconnect(editor);
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
const yText = yTextRef.current;
|
||||
if (!yText) return;
|
||||
|
||||
const textEventHandler = (event: Y.YTextEvent) => {
|
||||
update(event.changes.delta as Delta);
|
||||
}
|
||||
yText.applyDelta(delta);
|
||||
yText.observe(textEventHandler);
|
||||
|
||||
return () => {
|
||||
yText.unobserve(textEventHandler);
|
||||
}
|
||||
}, [delta])
|
||||
|
||||
|
||||
return { editor }
|
||||
}
|
@ -1,72 +1,18 @@
|
||||
import { triggerHotkey } from "@/appflowy_app/utils/slate/hotkey";
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { Descendant, Range } from "slate";
|
||||
import { useBindYjs } from "./BindYjs.hooks";
|
||||
import { YDocControllerContext } from '../../../stores/effects/document/document_controller';
|
||||
import { Delta } from "@slate-yjs/core/dist/model/types";
|
||||
import { TextDelta } from '../../../interfaces/document';
|
||||
import { debounce } from "@/appflowy_app/utils/tool";
|
||||
|
||||
function useController(textId: string) {
|
||||
const docController = useContext(YDocControllerContext);
|
||||
|
||||
const update = useCallback(
|
||||
(delta: Delta) => {
|
||||
docController?.yTextApply(textId, delta)
|
||||
},
|
||||
[textId],
|
||||
);
|
||||
const transact = useCallback(
|
||||
(actions: (() => void)[]) => {
|
||||
docController?.transact(actions)
|
||||
},
|
||||
[textId],
|
||||
)
|
||||
|
||||
return {
|
||||
update,
|
||||
transact
|
||||
}
|
||||
}
|
||||
|
||||
function useTransact(textId: string) {
|
||||
const pendingActions = useRef<(() => void)[]>([]);
|
||||
const { update, transact } = useController(textId);
|
||||
|
||||
const sendTransact = useCallback(
|
||||
() => {
|
||||
const actions = pendingActions.current;
|
||||
transact(actions);
|
||||
},
|
||||
[transact],
|
||||
)
|
||||
|
||||
const debounceSendTransact = useMemo(() => debounce(sendTransact, 300), [transact]);
|
||||
|
||||
const sendDelta = useCallback(
|
||||
(delta: Delta) => {
|
||||
const action = () => update(delta);
|
||||
pendingActions.current.push(action);
|
||||
debounceSendTransact()
|
||||
},
|
||||
[update, debounceSendTransact],
|
||||
);
|
||||
return {
|
||||
sendDelta
|
||||
}
|
||||
}
|
||||
import { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Descendant, Range } from 'slate';
|
||||
import { TextDelta } from '$app/interfaces/document';
|
||||
import { useTextInput } from '../_shared/TextInput.hooks';
|
||||
|
||||
export function useTextBlock(text: string, delta: TextDelta[]) {
|
||||
const { sendDelta } = useTransact(text);
|
||||
|
||||
const { editor } = useBindYjs(delta, sendDelta);
|
||||
const { editor } = useTextInput(text, delta);
|
||||
const [value, setValue] = useState<Descendant[]>([]);
|
||||
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: Descendant[]) => {
|
||||
setValue(e);
|
||||
},
|
||||
[editor],
|
||||
[editor]
|
||||
);
|
||||
|
||||
const onKeyDownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
@ -74,14 +20,13 @@ export function useTextBlock(text: string, delta: TextDelta[]) {
|
||||
case 'Enter': {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
case 'Backspace': {
|
||||
if (!editor.selection) return;
|
||||
const { anchor } = editor.selection;
|
||||
const isCollapase = Range.isCollapsed(editor.selection);
|
||||
if (isCollapase && anchor.offset === 0 && anchor.path.toString() === '0,0') {
|
||||
const isCollapsed = Range.isCollapsed(editor.selection);
|
||||
if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
@ -89,16 +34,15 @@ export function useTextBlock(text: string, delta: TextDelta[]) {
|
||||
}
|
||||
}
|
||||
triggerHotkey(event, editor);
|
||||
}
|
||||
};
|
||||
|
||||
const onDOMBeforeInput = useCallback((e: InputEvent) => {
|
||||
// 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
|
||||
// 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();
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@ -106,6 +50,6 @@ export function useTextBlock(text: string, delta: TextDelta[]) {
|
||||
onKeyDownCapture,
|
||||
onDOMBeforeInput,
|
||||
editor,
|
||||
value
|
||||
}
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import Leaf from './Leaf';
|
||||
import { useTextBlock } from './TextBlock.hooks';
|
||||
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||
import NodeComponent from '../Node';
|
||||
import HoveringToolbar from '../HoveringToolbar';
|
||||
import HoveringToolbar from '../_shared/HoveringToolbar';
|
||||
import { TextDelta } from '@/appflowy_app/interfaces/document';
|
||||
import React from 'react';
|
||||
|
||||
|
@ -3,10 +3,10 @@ import { useRef } from 'react';
|
||||
|
||||
const defaultSize = 60;
|
||||
|
||||
export function useVirtualizerList(count: number) {
|
||||
export function useVirtualizedList(count: number) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
const Virtualize = useVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => {
|
||||
@ -15,7 +15,7 @@ export function useVirtualizerList(count: number) {
|
||||
});
|
||||
|
||||
return {
|
||||
rowVirtualizer,
|
||||
Virtualize: Virtualize,
|
||||
parentRef,
|
||||
};
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useVirtualizerList } from './VirtualizerList.hooks';
|
||||
import { useVirtualizedList } from './VirtualizedList.hooks';
|
||||
import { Node } from '@/appflowy_app/stores/reducers/document/slice';
|
||||
import DocumentTitle from '../DocumentTitle';
|
||||
import Overlay from '../Overlay';
|
||||
|
||||
export default function VirtualizerList({
|
||||
export default function VirtualizedList({
|
||||
childIds,
|
||||
node,
|
||||
renderNode,
|
||||
@ -13,9 +13,8 @@ export default function VirtualizerList({
|
||||
node: Node;
|
||||
renderNode: (nodeId: string) => JSX.Element;
|
||||
}) {
|
||||
const { rowVirtualizer, parentRef } = useVirtualizerList(childIds.length);
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const { Virtualize, parentRef } = useVirtualizedList(childIds.length);
|
||||
const virtualItems = Virtualize.getVirtualItems();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -26,7 +25,7 @@ export default function VirtualizerList({
|
||||
<div
|
||||
className='doc-body max-w-screen w-[900px] min-w-0'
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
height: Virtualize.getTotalSize(),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
@ -43,7 +42,7 @@ export default function VirtualizerList({
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const id = childIds[virtualRow.index];
|
||||
return (
|
||||
<div className='p-[1px]' key={id} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
|
||||
<div className='p-[1px]' key={id} data-index={virtualRow.index} ref={Virtualize.measureElement}>
|
||||
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
|
||||
{renderNode(id)}
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { toggleFormat, isFormatActive } from '@/appflowy_app/utils/slate/format';
|
||||
import { toggleFormat, isFormatActive } from '$app/utils/slate/format';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useFocused, useSlate } from 'slate-react';
|
||||
import { calcToolbarPosition } from '@/appflowy_app/utils/slate/toolbar';
|
||||
|
||||
import { calcToolbarPosition } from '$app/utils/slate/toolbar';
|
||||
|
||||
export function useHoveringToolbar(id: string) {
|
||||
const editor = useSlate();
|
||||
@ -29,6 +28,6 @@ export function useHoveringToolbar(id: string) {
|
||||
return {
|
||||
ref,
|
||||
inFocus,
|
||||
editor
|
||||
}
|
||||
}
|
||||
editor,
|
||||
};
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import FormatButton from './FormatButton';
|
||||
import Portal from '../BlockPortal';
|
||||
import Portal from '../../BlockPortal';
|
||||
import { useHoveringToolbar } from './index.hooks';
|
||||
|
||||
const HoveringToolbar = ({ id }: { id: string }) => {
|
@ -3,22 +3,36 @@ import { useAppSelector } from '@/appflowy_app/stores/store';
|
||||
import { useMemo } from 'react';
|
||||
import { TextDelta } from '@/appflowy_app/interfaces/document';
|
||||
|
||||
/**
|
||||
* Subscribe to a node and its children
|
||||
* It will be change when the node or its children is changed
|
||||
* And it will not be change when other node is changed
|
||||
* @param id
|
||||
*/
|
||||
export function useSubscribeNode(id: string) {
|
||||
const node = useAppSelector<Node>(state => state.document.nodes[id]);
|
||||
|
||||
const childIds = useAppSelector<string[] | undefined>(state => {
|
||||
const childrenId = state.document.nodes[id]?.children;
|
||||
if (!childrenId) return;
|
||||
return state.document.children[childrenId];
|
||||
});
|
||||
|
||||
const delta = useAppSelector<TextDelta[] | undefined>(state => {
|
||||
const deltaId = state.document.nodes[id]?.data?.text;
|
||||
const externalType = state.document.nodes[id]?.externalType;
|
||||
if (externalType !== 'text') return;
|
||||
const deltaId = state.document.nodes[id]?.externalId;
|
||||
if (!deltaId) return;
|
||||
return state.document.delta[deltaId];
|
||||
});
|
||||
|
||||
const isSelected = useAppSelector<boolean>(state => {
|
||||
return state.document.selections?.includes(id) || false;
|
||||
});
|
||||
|
||||
// Memoize the node and its children
|
||||
// So that the component will not be re-rendered when other node is changed
|
||||
// It very important for performance
|
||||
const memoizedNode = useMemo(() => node, [node?.id, node?.data, node?.parent, node?.type, node?.children]);
|
||||
const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]);
|
||||
const memoizedDelta = useMemo(() => delta, [JSON.stringify(delta)]);
|
||||
|
@ -0,0 +1,116 @@
|
||||
import { useCallback, useContext, useMemo, useRef, useEffect } from 'react';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { TextDelta } from '$app/interfaces/document';
|
||||
import { debounce } from '@/appflowy_app/utils/tool';
|
||||
import { createEditor } from 'slate';
|
||||
import { withReact } from 'slate-react';
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
|
||||
|
||||
export function useTextInput(text: string, delta: TextDelta[]) {
|
||||
const { sendDelta } = useTransact(text);
|
||||
const { editor } = useBindYjs(delta, sendDelta);
|
||||
|
||||
return {
|
||||
editor,
|
||||
};
|
||||
}
|
||||
|
||||
function useController(textId: string) {
|
||||
const docController = useContext(DocumentControllerContext);
|
||||
|
||||
const update = useCallback(
|
||||
(delta: TextDelta[]) => {
|
||||
docController?.yTextApply(textId, delta);
|
||||
},
|
||||
[textId]
|
||||
);
|
||||
const transact = useCallback(
|
||||
(actions: (() => void)[]) => {
|
||||
docController?.transact(actions);
|
||||
},
|
||||
[textId]
|
||||
);
|
||||
|
||||
return {
|
||||
update,
|
||||
transact,
|
||||
};
|
||||
}
|
||||
|
||||
function useTransact(textId: string) {
|
||||
const pendingActions = useRef<(() => void)[]>([]);
|
||||
const { update, transact } = useController(textId);
|
||||
|
||||
const sendTransact = useCallback(() => {
|
||||
const actions = pendingActions.current;
|
||||
transact(actions);
|
||||
}, [transact]);
|
||||
|
||||
const debounceSendTransact = useMemo(() => debounce(sendTransact, 300), [transact]);
|
||||
|
||||
const sendDelta = useCallback(
|
||||
(delta: TextDelta[]) => {
|
||||
const action = () => update(delta);
|
||||
pendingActions.current.push(action);
|
||||
debounceSendTransact();
|
||||
},
|
||||
[update, debounceSendTransact]
|
||||
);
|
||||
return {
|
||||
sendDelta,
|
||||
};
|
||||
}
|
||||
|
||||
const initialValue = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [{ text: '' }],
|
||||
},
|
||||
];
|
||||
|
||||
export function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) {
|
||||
const yTextRef = useRef<Y.XmlText>();
|
||||
// Create a yjs document and get the shared type
|
||||
const sharedType = useMemo(() => {
|
||||
const doc = new Y.Doc();
|
||||
const _sharedType = doc.get('content', Y.XmlText) as Y.XmlText;
|
||||
|
||||
const insertDelta = slateNodesToInsertDelta(initialValue);
|
||||
// Load the initial value into the yjs document
|
||||
_sharedType.applyDelta(insertDelta);
|
||||
|
||||
const yText = insertDelta[0].insert as Y.XmlText;
|
||||
yTextRef.current = yText;
|
||||
|
||||
return _sharedType;
|
||||
}, []);
|
||||
|
||||
const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
|
||||
|
||||
useEffect(() => {
|
||||
YjsEditor.connect(editor);
|
||||
return () => {
|
||||
yTextRef.current = undefined;
|
||||
YjsEditor.disconnect(editor);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
const yText = yTextRef.current;
|
||||
if (!yText) return;
|
||||
|
||||
const textEventHandler = (event: Y.YTextEvent) => {
|
||||
update(event.changes.delta as TextDelta[]);
|
||||
};
|
||||
yText.applyDelta(delta);
|
||||
yText.observe(textEventHandler);
|
||||
|
||||
return () => {
|
||||
yText.unobserve(textEventHandler);
|
||||
};
|
||||
}, [delta]);
|
||||
|
||||
return { editor };
|
||||
}
|
@ -9,7 +9,6 @@ import { useError } from '../../error/Error.hooks';
|
||||
import { AppObserver } from '../../../stores/effects/folder/app/app_observer';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { INITIAL_FOLDER_HEIGHT, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
|
||||
import { YDocController } from '$app/stores/effects/document/document_controller';
|
||||
|
||||
export const useFolderEvents = (folder: IFolder, pages: IPage[]) => {
|
||||
const appDispatch = useAppDispatch();
|
||||
@ -133,10 +132,6 @@ export const useFolderEvents = (folder: IFolder, pages: IPage[]) => {
|
||||
layoutType: ViewLayoutTypePB.Document,
|
||||
});
|
||||
|
||||
// temp: let me try it by yjs
|
||||
const ydocController = new YDocController(newView.id);
|
||||
await ydocController.createDocument();
|
||||
|
||||
appDispatch(
|
||||
pagesActions.addPage({
|
||||
folderId: folder.id,
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { TextBlockToolbarGroup } from "../interfaces";
|
||||
|
||||
export const iconSize = { width: 18, height: 18 };
|
||||
|
||||
@ -24,16 +23,3 @@ export const command: Record<string, { title: string; key: string }> = {
|
||||
key: '⌘ + Shift + S or ⌘ + Shift + X',
|
||||
},
|
||||
};
|
||||
|
||||
export const toolbarDefaultProps = {
|
||||
showGroups: [
|
||||
TextBlockToolbarGroup.ASK_AI,
|
||||
TextBlockToolbarGroup.BLOCK_SELECT,
|
||||
TextBlockToolbarGroup.ADD_LINK,
|
||||
TextBlockToolbarGroup.COMMENT,
|
||||
TextBlockToolbarGroup.TEXT_FORMAT,
|
||||
TextBlockToolbarGroup.TEXT_COLOR,
|
||||
TextBlockToolbarGroup.MENTION,
|
||||
TextBlockToolbarGroup.MORE,
|
||||
],
|
||||
};
|
@ -16,6 +16,8 @@ export interface NestedBlock {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
data: Record<string, any>;
|
||||
externalId: string;
|
||||
externalType: 'text' | 'array' | 'map';
|
||||
parent: string | null;
|
||||
children: string;
|
||||
}
|
||||
@ -26,6 +28,8 @@ export interface TextDelta {
|
||||
export interface DocumentData {
|
||||
rootId: string;
|
||||
blocks: Record<string, NestedBlock>;
|
||||
ytexts: Record<string, TextDelta[]>;
|
||||
yarrays: Record<string, string[]>;
|
||||
meta: {
|
||||
text_map: Record<string, TextDelta[]>;
|
||||
children_map: Record<string, string[]>;
|
||||
}
|
||||
}
|
||||
|
@ -1,112 +1 @@
|
||||
import { Descendant } from "slate";
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
export enum BlockType {
|
||||
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 = BlockType> = T extends BlockType.TextBlock ? TextBlockData :
|
||||
T extends BlockType.PageBlock ? PageBlockData :
|
||||
T extends BlockType.HeadingBlock ? HeadingBlockData :
|
||||
T extends BlockType.ListBlock ? ListBlockData :
|
||||
T extends BlockType.ColumnBlock ? ColumnBlockData : any;
|
||||
|
||||
|
||||
export interface BlockInterface<T = BlockType> {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
data: BlockData<T>;
|
||||
next: string | null;
|
||||
firstChild: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface TextBlockData {
|
||||
content: Descendant[];
|
||||
}
|
||||
|
||||
interface PageBlockData {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ListBlockData extends TextBlockData {
|
||||
type: 'numbered' | 'bulleted' | 'column';
|
||||
}
|
||||
|
||||
interface HeadingBlockData extends TextBlockData {
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface ColumnBlockData {
|
||||
ratio: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
export enum TextBlockToolbarGroup {
|
||||
ASK_AI,
|
||||
BLOCK_SELECT,
|
||||
ADD_LINK,
|
||||
COMMENT,
|
||||
TEXT_FORMAT,
|
||||
TEXT_COLOR,
|
||||
MENTION,
|
||||
MORE
|
||||
}
|
||||
export interface TextBlockToolbarProps {
|
||||
showGroups: TextBlockToolbarGroup[]
|
||||
}
|
||||
|
||||
|
||||
export interface BlockCommonProps<T> {
|
||||
version: number;
|
||||
node: T;
|
||||
}
|
||||
|
||||
export interface BackendOp {
|
||||
type: 'update' | 'insert' | 'remove' | 'move' | 'move_range';
|
||||
version: number;
|
||||
data: UpdateOpData | InsertOpData | moveRangeOpData | moveOpData | removeOpData;
|
||||
}
|
||||
export interface LocalOp {
|
||||
type: 'update' | 'insert' | 'remove' | 'move' | 'move_range';
|
||||
version: number;
|
||||
data: UpdateOpData | InsertOpData | moveRangeOpData | moveOpData | removeOpData;
|
||||
}
|
||||
|
||||
export interface UpdateOpData {
|
||||
blockId: string;
|
||||
value: BlockData;
|
||||
path: string[];
|
||||
}
|
||||
export interface InsertOpData {
|
||||
block: BlockInterface;
|
||||
parentId: string;
|
||||
prevId?: string
|
||||
}
|
||||
|
||||
export interface moveRangeOpData {
|
||||
range: [string, string];
|
||||
newParentId: string;
|
||||
newPrevId?: string
|
||||
}
|
||||
|
||||
export interface moveOpData {
|
||||
blockId: string;
|
||||
newParentId: string;
|
||||
newPrevId?: string
|
||||
}
|
||||
|
||||
export interface removeOpData {
|
||||
blockId: string
|
||||
}
|
||||
export interface Document {}
|
@ -1,194 +1,48 @@
|
||||
import * as Y from 'yjs';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import { v4 } from 'uuid';
|
||||
import { DocumentData, NestedBlock } from '@/appflowy_app/interfaces/document';
|
||||
import { DocumentData, BlockType, TextDelta } from '@/appflowy_app/interfaces/document';
|
||||
import { createContext } from 'react';
|
||||
import { BlockType } from '@/appflowy_app/interfaces';
|
||||
import { DocumentBackendService } from './document_bd_svc';
|
||||
|
||||
export type DeltaAttributes = {
|
||||
retain: number;
|
||||
attributes: Record<string, unknown>;
|
||||
};
|
||||
export const DocumentControllerContext = createContext<DocumentController | null>(null);
|
||||
|
||||
export type DeltaRetain = { retain: number };
|
||||
export type DeltaDelete = { delete: number };
|
||||
export type DeltaInsert = {
|
||||
insert: string | Y.XmlText;
|
||||
attributes?: Record<string, unknown>;
|
||||
};
|
||||
export class DocumentController {
|
||||
private readonly backendService: DocumentBackendService;
|
||||
|
||||
export type InsertDelta = Array<DeltaInsert>;
|
||||
export type Delta = Array<
|
||||
DeltaRetain | DeltaDelete | DeltaInsert | DeltaAttributes
|
||||
>;
|
||||
|
||||
|
||||
export const YDocControllerContext = createContext<YDocController | null>(null);
|
||||
|
||||
export class YDocController {
|
||||
private _ydoc: Y.Doc;
|
||||
private readonly provider: IndexeddbPersistence;
|
||||
|
||||
constructor(private id: string) {
|
||||
this._ydoc = new Y.Doc();
|
||||
this.provider = new IndexeddbPersistence(`document-${this.id}`, this._ydoc);
|
||||
this._ydoc.on('update', this.handleUpdate);
|
||||
constructor(public readonly viewId: string) {
|
||||
this.backendService = new DocumentBackendService(viewId);
|
||||
}
|
||||
|
||||
handleUpdate = (update: Uint8Array, origin: any) => {
|
||||
const isLocal = origin === null;
|
||||
Y.logUpdate(update);
|
||||
}
|
||||
|
||||
|
||||
createDocument = async () => {
|
||||
await this.provider.whenSynced;
|
||||
const ydoc = this._ydoc;
|
||||
const blocks = ydoc.getMap('blocks');
|
||||
const rootNode = ydoc.getArray("root");
|
||||
|
||||
// create page block for root node
|
||||
const rootId = v4();
|
||||
rootNode.push([rootId])
|
||||
const rootChildrenId = v4();
|
||||
const rootChildren = ydoc.getArray(rootChildrenId);
|
||||
const rootTitleId = v4();
|
||||
const yTitle = ydoc.getText(rootTitleId);
|
||||
yTitle.insert(0, "");
|
||||
const root = {
|
||||
id: rootId,
|
||||
type: 'page',
|
||||
data: {
|
||||
text: rootTitleId
|
||||
},
|
||||
parent: null,
|
||||
children: rootChildrenId
|
||||
};
|
||||
blocks.set(root.id, root);
|
||||
|
||||
// create text block for first line
|
||||
const textId = v4();
|
||||
const yTextId = v4();
|
||||
const ytext = ydoc.getText(yTextId);
|
||||
ytext.insert(0, "");
|
||||
const textChildrenId = v4();
|
||||
ydoc.getArray(textChildrenId);
|
||||
const text = {
|
||||
id: textId,
|
||||
type: 'text',
|
||||
data: {
|
||||
text: yTextId,
|
||||
},
|
||||
parent: rootId,
|
||||
children: textChildrenId,
|
||||
open = async (): Promise<DocumentData | null> => {
|
||||
const openDocumentResult = await this.backendService.open();
|
||||
if (openDocumentResult.ok) {
|
||||
return {
|
||||
rootId: '',
|
||||
blocks: {},
|
||||
ytexts: {},
|
||||
yarrays: {}
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// add text block to root children
|
||||
rootChildren.push([textId]);
|
||||
blocks.set(text.id, text);
|
||||
}
|
||||
};
|
||||
|
||||
open = async (): Promise<DocumentData> => {
|
||||
await this.provider.whenSynced;
|
||||
const ydoc = this._ydoc;
|
||||
|
||||
const blocks = ydoc.getMap('blocks');
|
||||
const obj: DocumentData = {
|
||||
rootId: ydoc.getArray<string>('root').toArray()[0] || '',
|
||||
blocks: blocks.toJSON(),
|
||||
ytexts: {},
|
||||
yarrays: {}
|
||||
};
|
||||
|
||||
Object.keys(obj.blocks).forEach(key => {
|
||||
const value = obj.blocks[key];
|
||||
if (value.children) {
|
||||
const yarray = ydoc.getArray<string>(value.children);
|
||||
Object.assign(obj.yarrays, {
|
||||
[value.children]: yarray.toArray()
|
||||
});
|
||||
}
|
||||
if (value.data.text) {
|
||||
const ytext = ydoc.getText(value.data.text);
|
||||
Object.assign(obj.ytexts, {
|
||||
[value.data.text]: ytext.toDelta()
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
blocks.observe(this.handleBlocksEvent);
|
||||
return obj;
|
||||
}
|
||||
|
||||
insert(node: {
|
||||
id: string,
|
||||
type: BlockType,
|
||||
delta?: Delta
|
||||
delta?: TextDelta[]
|
||||
}, parentId: string, prevId: string) {
|
||||
const blocks = this._ydoc.getMap<NestedBlock>('blocks');
|
||||
const parent = blocks.get(parentId);
|
||||
if (!parent) return;
|
||||
const insertNode = {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
data: {
|
||||
text: ''
|
||||
},
|
||||
children: '',
|
||||
parent: ''
|
||||
}
|
||||
// create ytext
|
||||
if (node.delta) {
|
||||
const ytextId = v4();
|
||||
const ytext = this._ydoc.getText(ytextId);
|
||||
ytext.applyDelta(node.delta);
|
||||
insertNode.data.text = ytextId;
|
||||
}
|
||||
// create children
|
||||
const yArrayId = v4();
|
||||
this._ydoc.getArray(yArrayId);
|
||||
insertNode.children = yArrayId;
|
||||
// insert in parent's children
|
||||
const children = this._ydoc.getArray(parent.children);
|
||||
const index = children.toArray().indexOf(prevId) + 1;
|
||||
children.insert(index, [node.id]);
|
||||
insertNode.parent = parentId;
|
||||
// set in blocks
|
||||
this._ydoc.getMap('blocks').set(node.id, insertNode);
|
||||
//
|
||||
}
|
||||
|
||||
transact(actions: (() => void)[]) {
|
||||
const ydoc = this._ydoc;
|
||||
console.log('====transact')
|
||||
ydoc.transact(() => {
|
||||
actions.forEach(action => {
|
||||
action();
|
||||
});
|
||||
});
|
||||
//
|
||||
}
|
||||
|
||||
yTextApply = (yTextId: string, delta: Delta) => {
|
||||
const ydoc = this._ydoc;
|
||||
const ytext = ydoc.getText(yTextId);
|
||||
ytext.applyDelta(delta);
|
||||
console.log("====", yTextId, delta);
|
||||
}
|
||||
|
||||
close = () => {
|
||||
const blocks = this._ydoc.getMap('blocks');
|
||||
blocks.unobserve(this.handleBlocksEvent);
|
||||
}
|
||||
|
||||
private handleBlocksEvent = (mapEvent: Y.YMapEvent<unknown>) => {
|
||||
console.log(mapEvent.changes);
|
||||
}
|
||||
|
||||
private handleTextEvent = (textEvent: Y.YTextEvent) => {
|
||||
console.log(textEvent.changes);
|
||||
}
|
||||
|
||||
private handleArrayEvent = (arrayEvent: Y.YArrayEvent<string>) => {
|
||||
console.log(arrayEvent.changes);
|
||||
yTextApply = (yTextId: string, delta: TextDelta[]) => {
|
||||
//
|
||||
}
|
||||
|
||||
dispose = async () => {
|
||||
await this.backendService.close();
|
||||
};
|
||||
}
|
||||
|
@ -1,17 +1,8 @@
|
||||
import { BlockType, TextDelta } from "@/appflowy_app/interfaces/document";
|
||||
import { BlockType, NestedBlock, TextDelta } from "@/appflowy_app/interfaces/document";
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
import { RegionGrid } from "./region_grid";
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
data: {
|
||||
text?: string;
|
||||
style?: Record<string, any>
|
||||
};
|
||||
parent: string | null;
|
||||
children: string;
|
||||
}
|
||||
export type Node = NestedBlock;
|
||||
|
||||
export interface NodeState {
|
||||
nodes: Record<string, Node>;
|
||||
@ -33,11 +24,11 @@ export const documentSlice = createSlice({
|
||||
name: 'document',
|
||||
initialState: initialState,
|
||||
reducers: {
|
||||
clear: (state, action: PayloadAction) => {
|
||||
clear: () => {
|
||||
return initialState;
|
||||
},
|
||||
|
||||
createTree: (state, action: PayloadAction<{
|
||||
create: (state, action: PayloadAction<{
|
||||
nodes: Record<string, Node>;
|
||||
children: Record<string, string[]>;
|
||||
delta: Record<string, TextDelta[]>;
|
||||
@ -52,7 +43,7 @@ export const documentSlice = createSlice({
|
||||
state.selections = action.payload;
|
||||
},
|
||||
|
||||
changeSelectionByIntersectRect: (state, action: PayloadAction<{
|
||||
setSelectionByRect: (state, action: PayloadAction<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
@ -77,26 +68,57 @@ export const documentSlice = createSlice({
|
||||
regionGrid.updateBlock(id, position);
|
||||
},
|
||||
|
||||
addNode: (state, action: PayloadAction<Node>) => {
|
||||
state.nodes[action.payload.id] = action.payload;
|
||||
},
|
||||
|
||||
addChild: (state, action: PayloadAction<{ parentId: string, childId: string, prevId: string }>) => {
|
||||
const { parentId, childId, prevId } = action.payload;
|
||||
const parentChildrenId = state.nodes[parentId].children;
|
||||
const children = state.children[parentChildrenId];
|
||||
const prevIndex = children.indexOf(prevId);
|
||||
if (prevIndex === -1) {
|
||||
children.push(childId)
|
||||
} else {
|
||||
children.splice(prevIndex + 1, 0, childId);
|
||||
}
|
||||
},
|
||||
|
||||
updateChildren: (state, action: PayloadAction<{ id: string; childIds: string[] }>) => {
|
||||
const { id, childIds } = action.payload;
|
||||
state.children[id] = childIds;
|
||||
},
|
||||
|
||||
updateDelta: (state, action: PayloadAction<{ id: string; delta: TextDelta[] }>) => {
|
||||
const { id, delta } = action.payload;
|
||||
state.delta[id] = delta;
|
||||
},
|
||||
|
||||
updateNode: (state, action: PayloadAction<{id: string; type?: BlockType; data?: any }>) => {
|
||||
state.nodes[action.payload.id] = {
|
||||
...state.nodes[action.payload.id],
|
||||
...action.payload
|
||||
}
|
||||
},
|
||||
|
||||
removeNode: (state, action: PayloadAction<string>) => {
|
||||
const { children, data, parent } = state.nodes[action.payload];
|
||||
// remove from parent
|
||||
if (parent) {
|
||||
const index = state.children[state.nodes[parent].children].indexOf(action.payload);
|
||||
if (index > -1) {
|
||||
state.children[state.nodes[parent].children].splice(index, 1);
|
||||
}
|
||||
}
|
||||
// remove children
|
||||
if (children) {
|
||||
delete state.children[children];
|
||||
}
|
||||
// remove delta
|
||||
if (data && data.text) {
|
||||
delete state.delta[data.text];
|
||||
}
|
||||
// remove node
|
||||
delete state.nodes[action.payload];
|
||||
},
|
||||
},
|
||||
|
@ -1,36 +0,0 @@
|
||||
import { BlockData, BlockType } from "../interfaces";
|
||||
|
||||
|
||||
export function filterSelections<TreeNode extends {
|
||||
id: string;
|
||||
children: TreeNode[];
|
||||
parent: TreeNode | null;
|
||||
type: BlockType;
|
||||
data: BlockData;
|
||||
}>(ids: string[], nodeMap: Map<string, TreeNode>): string[] {
|
||||
const selected = new Set(ids);
|
||||
const newSelected = new Set<string>();
|
||||
ids.forEach(selectedId => {
|
||||
const node = nodeMap.get(selectedId);
|
||||
if (!node) return;
|
||||
if (node.type === BlockType.ListBlock && node.data.type === 'column') {
|
||||
return;
|
||||
}
|
||||
if (node.children.length === 0) {
|
||||
newSelected.add(selectedId);
|
||||
return;
|
||||
}
|
||||
const hasChildSelected = node.children.some(i => selected.has(i.id));
|
||||
if (!hasChildSelected) {
|
||||
newSelected.add(selectedId);
|
||||
return;
|
||||
}
|
||||
const hasSiblingSelected = node.parent?.children.filter(i => i.id !== selectedId).some(i => selected.has(i.id));
|
||||
if (hasChildSelected && hasSiblingSelected) {
|
||||
newSelected.add(selectedId);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(newSelected);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
import { TextBlockManager } from '../../block_editor/blocks/text_block';
|
||||
|
||||
export const TextBlockContext = createContext<{
|
||||
textBlockManager?: TextBlockManager
|
||||
}>({});
|
@ -6,24 +6,26 @@ import {
|
||||
} from '../../services/backend/events/flowy-document';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DocumentData } from '../interfaces/document';
|
||||
import { YDocController } from '$app/stores/effects/document/document_controller';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
|
||||
|
||||
export const useDocument = () => {
|
||||
const params = useParams();
|
||||
const [ documentId, setDocumentId ] = useState<string>();
|
||||
const [ documentData, setDocumentData ] = useState<DocumentData>();
|
||||
const [ controller, setController ] = useState<YDocController | null>(null);
|
||||
const [ controller, setController ] = useState<DocumentController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
if (!params?.id) return;
|
||||
const c = new YDocController(params.id);
|
||||
const c = new DocumentController(params.id);
|
||||
setController(c);
|
||||
const res = await c.open();
|
||||
console.log(res)
|
||||
if (!res) return;
|
||||
setDocumentData(res)
|
||||
setDocumentId(params.id)
|
||||
|
||||
})();
|
||||
return () => {
|
||||
console.log('==== leave ====', params?.id)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useDocument } from './DocumentPage.hooks';
|
||||
import { createTheme, ThemeProvider } from '@mui/material';
|
||||
import Root from '../components/document/Root';
|
||||
import { YDocControllerContext } from '../stores/effects/document/document_controller';
|
||||
import { DocumentControllerContext } from '../stores/effects/document/document_controller';
|
||||
|
||||
const theme = createTheme({
|
||||
typography: {
|
||||
@ -15,9 +15,9 @@ export const DocumentPage = () => {
|
||||
if (!documentId || !documentData || !controller) return null;
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<YDocControllerContext.Provider value={controller}>
|
||||
<DocumentControllerContext.Provider value={controller}>
|
||||
<Root documentData={documentData} />
|
||||
</YDocControllerContext.Provider>
|
||||
</DocumentControllerContext.Provider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user