refactor: document controller

This commit is contained in:
qinluhe 2023-03-27 15:48:50 +08:00
parent 35c21c0d84
commit 886766c887
25 changed files with 253 additions and 532 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +0,0 @@
import { createContext } from "react";
import { TextBlockManager } from '../../block_editor/blocks/text_block';
export const TextBlockContext = createContext<{
textBlockManager?: TextBlockManager
}>({});

View File

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

View File

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