diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts index 7bca163c64..c1da46ee34 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts @@ -1,18 +1,21 @@ -import { DocumentData, BlockType, DeltaItem } from '@/appflowy_app/interfaces/document'; -import { createContext, Dispatch } from 'react'; +import { DocumentData, BlockType } from '@/appflowy_app/interfaces/document'; +import { createContext } from 'react'; import { DocumentBackendService } from './document_bd_svc'; -import { FlowyError, BlockActionPB, DocEventPB, DeltaTypePB, BlockActionTypePB } from '@/services/backend'; +import { FlowyError, BlockActionPB, DocEventPB, BlockActionTypePB, BlockEventPayloadPB } from '@/services/backend'; import { DocumentObserver } from './document_observer'; -import { documentActions, Node } from '@/appflowy_app/stores/reducers/document/slice'; -import { Log } from '@/appflowy_app/utils/log'; +import { Node } from '@/appflowy_app/stores/reducers/document/slice'; import * as Y from 'yjs'; + export const DocumentControllerContext = createContext(null); export class DocumentController { private readonly backendService: DocumentBackendService; private readonly observer: DocumentObserver; - constructor(public readonly viewId: string, private dispatch?: Dispatch) { + constructor( + public readonly viewId: string, + private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void + ) { this.backendService = new DocumentBackendService(viewId); this.observer = new DocumentObserver(viewId); } @@ -107,6 +110,7 @@ export class DocumentController { }; dispose = async () => { + this.onDocChange = undefined; await this.backendService.close(); }; @@ -143,65 +147,16 @@ export class DocumentController { }; private updated = (payload: Uint8Array) => { - const dispatch = this.dispatch; - if (!dispatch) return; + if (!this.onDocChange) return; const { events, is_remote } = DocEventPB.deserializeBinary(payload); - if (!is_remote) return; events.forEach((event) => { event.event.forEach((_payload) => { - const { path, id, value, command } = _payload; - let valueJson; - try { - valueJson = JSON.parse(value); - } catch { - console.error('json parse error', value); - return; - } - if (!valueJson) return; - - if (command === DeltaTypePB.Inserted || command === DeltaTypePB.Updated) { - // set map key and value ( block map or children map) - if (path[0] === 'blocks') { - const block = blockChangeValue2Node(valueJson); - dispatch(documentActions.setBlockMap(block)); - } else { - dispatch( - documentActions.setChildrenMap({ - id, - childIds: valueJson, - }) - ); - } - } else { - // remove map key ( block map or children map) - if (path[0] === 'blocks') { - dispatch(documentActions.removeBlockMapKey(id)); - } else { - dispatch(documentActions.removeChildrenMapKey(id)); - } - } + this.onDocChange?.({ + isRemote: is_remote, + data: _payload, + }); }); }); }; } - -function blockChangeValue2Node(value: { id: string; ty: string; parent: string; children: string; data: string }): Node { - const block = { - id: value.id, - type: value.ty as BlockType, - parent: value.parent, - children: value.children, - data: {}, - }; - if ('data' in value && typeof value.data === 'string') { - try { - Object.assign(block, { - data: JSON.parse(value.data), - }); - } catch { - Log.error('valueJson data parse error', block.data); - } - } - return block; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts index e7952348fa..2dfea617b7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts @@ -42,13 +42,8 @@ const composeNodeThunk = createAsyncThunk( // move must be before delete await controller.applyActions([...moveActions, deleteAction, updateAction]); - - children.reverse().forEach((childId) => { - dispatch(documentActions.moveNode({ id: childId, newParentId: newNode.id, newPrevId: '' })); - }); - dispatch(documentActions.setBlockMap(newNode)); - dispatch(documentActions.removeBlockMapKey(node.id)); - dispatch(documentActions.removeChildrenMapKey(node.children)); + // update local node data + dispatch(documentActions.updateNodeData({ id: newNode.id, data: { delta: newNode.data.delta } })); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts index d56f0deaa9..5110827f37 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts @@ -1,6 +1,6 @@ import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { documentActions, DocumentState } from '../slice'; +import { DocumentState } from '../slice'; export const deleteNodeThunk = createAsyncThunk( 'document/deleteNode', @@ -11,22 +11,5 @@ export const deleteNodeThunk = createAsyncThunk( const node = state.document.nodes[id]; if (!node) return; await controller.applyActions([controller.getDeleteAction(node)]); - - const deleteNode = (deleteId: string) => { - const deleteItem = state.document.nodes[deleteId]; - const children = state.document.children[deleteItem.children]; - // delete children - if (children.length > 0) { - children.forEach((childId) => { - deleteNode(childId); - }); - } - dispatch(documentActions.removeBlockMapKey(deleteItem.id)); - dispatch(documentActions.removeChildrenMapKey(deleteItem.children)); - }; - deleteNode(node.id); - - if (!node.parent) return; - dispatch(documentActions.deleteChild({ id: node.parent, childId: node.id })); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts index 04c927974e..a9236b88ab 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts @@ -1,13 +1,13 @@ import { BlockType } from '@/appflowy_app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { documentActions, DocumentState } from '../slice'; +import { DocumentState } from '../slice'; export const indentNodeThunk = createAsyncThunk( 'document/indentNode', async (payload: { id: string; controller: DocumentController }, thunkAPI) => { const { id, controller } = payload; - const { dispatch, getState } = thunkAPI; + const { getState } = thunkAPI; const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; if (!node.parent) return; @@ -26,12 +26,5 @@ export const indentNodeThunk = createAsyncThunk( const newPrevId = prevNodeChildren[prevNodeChildren.length - 1]; await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]); - dispatch( - documentActions.moveNode({ - id, - newParentId, - newPrevId, - }) - ); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts index 1ab0f69d10..9bf3c69704 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts @@ -1,9 +1,9 @@ import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { documentActions, DocumentState } from '../slice'; +import { DocumentState } from '../slice'; import { generateId } from '@/appflowy_app/utils/block'; -import { setCursorAfterThunk } from './set_cursor'; + export const insertAfterNodeThunk = createAsyncThunk( 'document/insertAfterNode', async (payload: { id: string; controller: DocumentController }, thunkAPI) => { @@ -25,21 +25,5 @@ export const insertAfterNodeThunk = createAsyncThunk( children: generateId(), }; await controller.applyActions([controller.getInsertAction(newNode, node.id)]); - dispatch(documentActions.setBlockMap(newNode)); - dispatch( - documentActions.setChildrenMap({ - id: newNode.children, - childIds: [], - }) - ); - // insert new node to parent - dispatch( - documentActions.insertChild({ - id: parentId, - childId: newNode.id, - prevId: node.id, - }) - ); - await dispatch(setCursorAfterThunk({ id: newNode.id })); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts index 90ab37611b..5b2fa32cfe 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts @@ -1,12 +1,12 @@ import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { documentActions, DocumentState } from '../slice'; +import { DocumentState } from '../slice'; export const outdentNodeThunk = createAsyncThunk( 'document/outdentNode', async (payload: { id: string; controller: DocumentController }, thunkAPI) => { const { id, controller } = payload; - const { dispatch, getState } = thunkAPI; + const { getState } = thunkAPI; const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; const newPrevId = node.parent; @@ -15,12 +15,5 @@ export const outdentNodeThunk = createAsyncThunk( const newParentId = parent.parent; if (!newParentId) return; await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]); - dispatch( - documentActions.moveNode({ - id: node.id, - newParentId, - newPrevId, - }) - ); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts index 8c903cb856..38917f7420 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts @@ -1,4 +1,3 @@ -import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { documentActions, DocumentState, SelectionPoint, TextSelection } from '../slice'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts index a06d13365d..30d0149dca 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts @@ -2,7 +2,7 @@ import { BlockType, TextDelta } from '@/appflowy_app/interfaces/document'; import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { generateId } from '@/appflowy_app/utils/block'; -import { documentActions, DocumentState, TextSelection } from '../slice'; +import { documentActions, DocumentState } from '../slice'; import { setCursorBeforeThunk } from './set_cursor'; export const splitNodeThunk = createAsyncThunk( @@ -36,22 +36,8 @@ export const splitNodeThunk = createAsyncThunk( }, }; await controller.applyActions([controller.getInsertAction(newNode, prevId), controller.getUpdateAction(retainNode)]); - dispatch(documentActions.setBlockMap(newNode)); - dispatch(documentActions.setBlockMap(retainNode)); - dispatch( - documentActions.setChildrenMap({ - id: newNode.children, - childIds: [], - }) - ); - dispatch( - documentActions.insertChild({ - id: parent.id, - childId: newNode.id, - prevId, - }) - ); - + // update local node data + dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } })); // set cursor await dispatch(setCursorBeforeThunk({ id: newNode.id })); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts index 4a31a98676..21419b8096 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/update.ts @@ -1,7 +1,7 @@ -import { TextDelta } from '@/appflowy_app/interfaces/document'; +import { TextDelta, NestedBlock } from '@/appflowy_app/interfaces/document'; import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { documentActions, DocumentState, Node } from '../slice'; +import { documentActions, DocumentState } from '../slice'; import { debounce } from '$app/utils/tool'; export const updateNodeDeltaThunk = createAsyncThunk( 'document/updateNodeDelta', @@ -9,25 +9,23 @@ export const updateNodeDeltaThunk = createAsyncThunk( const { id, delta, controller } = payload; const { dispatch, getState } = thunkAPI; const state = (getState() as { document: DocumentState }).document; + // The block map should be updated immediately + // or the component will use the old data to update the editor + dispatch(documentActions.updateNodeData({ id, data: { delta } })); + const node = state.nodes[id]; - const updateNode = { + // the transaction is delayed to avoid too many updates + debounceApplyUpdate(controller, { ...node, - id, data: { ...node.data, delta, }, - }; - // The block map should be updated immediately - // or the component will use the old data to update the editor - dispatch(documentActions.setBlockMap(updateNode)); - - // the transaction is delayed to avoid too many updates - debounceApplyUpdate(controller, updateNode); + }); } ); -const debounceApplyUpdate = debounce((controller: DocumentController, updateNode: Node) => { +const debounceApplyUpdate = debounce((controller: DocumentController, updateNode: NestedBlock) => { void controller.applyActions([ controller.getUpdateAction({ ...updateNode, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts index d83b194573..0c942b2077 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts @@ -1,7 +1,8 @@ -import { BlockType, NestedBlock, TextDelta } from '@/appflowy_app/interfaces/document'; +import { NestedBlock } from '@/appflowy_app/interfaces/document'; +import { blockChangeValue2Node } from '@/appflowy_app/utils/block'; +import { Log } from '@/appflowy_app/utils/log'; +import { BlockEventPayloadPB, DeltaTypePB } from '@/services/backend'; import { PayloadAction, createSlice } from '@reduxjs/toolkit'; -import { nanoid } from 'nanoid'; -import { DocumentController } from '../../effects/document/document_controller'; import { RegionGrid } from './region_grid'; export type Node = NestedBlock; @@ -131,78 +132,71 @@ export const documentSlice = createSlice({ state.textSelections; }, - // insert block - setBlockMap: (state, action: PayloadAction) => { - state.nodes[action.payload.id] = action.payload; + // We need this action to update the local state before `onDataChange` to make the UI more smooth, + // because we often use `debounce` to send the change to db, so the db data will be updated later. + updateNodeData: (state, action: PayloadAction<{ id: string; data: Record }>) => { + const { id, data } = action.payload; + const node = state.nodes[id]; + if (!node) return; + node.data = { + ...node.data, + ...data, + }; }, - // update block when `type`, `parent` or `children` changed - updateBlock: (state, action: PayloadAction<{ id: string; block: NestedBlock }>) => { - const { id, block } = action.payload; - const node = state.nodes[id]; - if (!node || node.parent !== block.parent || node.type !== block.type || node.children !== block.children) { - state.nodes[action.payload.id] = block; + // when we use `onDataChange` to handle the change, we don't need care about the change is from which client, + // because the data is always from db state, and then to UI. + // Except the `updateNodeData` action, we will use it before `onDataChange` to update the local state, + // so we should skip update block's `data` field when the change is from local + onDataChange: ( + state, + action: PayloadAction<{ + data: BlockEventPayloadPB; + isRemote: boolean; + }> + ) => { + const { path, id, value, command } = action.payload.data; + const isRemote = action.payload.isRemote; + + let valueJson; + try { + valueJson = JSON.parse(value); + } catch { + Log.error('[onDataChange] json parse error', value); return; } - }, + if (!valueJson) return; - // remove block - removeBlockMapKey(state, action: PayloadAction) { - if (!state.nodes[action.payload]) return; - const { id } = state.nodes[action.payload]; - regionGrid.removeBlock(id); - delete state.nodes[id]; - }, - - // set block's relationship with its children - setChildrenMap: (state, action: PayloadAction<{ id: string; childIds: string[] }>) => { - const { id, childIds } = action.payload; - state.children[id] = childIds; - }, - - // remove block's relationship with its children - removeChildrenMapKey(state, action: PayloadAction) { - if (state.children[action.payload]) { - delete state.children[action.payload]; + if (command === DeltaTypePB.Inserted || command === DeltaTypePB.Updated) { + // set map key and value ( block map or children map) + if (path[0] === 'blocks') { + const block = blockChangeValue2Node(valueJson); + if (command === DeltaTypePB.Updated && !isRemote) { + // the `data` from local is already updated in local, so we just need to update other fields + const node = state.nodes[block.id]; + if (!node || node.parent !== block.parent || node.type !== block.type || node.children !== block.children) { + state.nodes[block.id] = block; + } + } else { + state.nodes[block.id] = block; + } + } else { + state.children[id] = valueJson; + } + } else { + // remove map key ( block map or children map) + if (path[0] === 'blocks') { + if (state.selections.indexOf(id)) { + state.selections.splice(state.selections.indexOf(id), 1); + } + regionGrid.removeBlock(id); + delete state.textSelections[id]; + delete state.nodes[id]; + } else { + delete state.children[id]; + } } }, - - // set block's relationship with its parent - insertChild: (state, action: PayloadAction<{ id: string; childId: string; prevId: string | null }>) => { - const { id, childId, prevId } = action.payload; - const parent = state.nodes[id]; - const children = state.children[parent.children]; - const index = prevId ? children.indexOf(prevId) + 1 : 0; - children.splice(index, 0, childId); - }, - - // remove block's relationship with its parent - deleteChild: (state, action: PayloadAction<{ id: string; childId: string }>) => { - const { id, childId } = action.payload; - const parent = state.nodes[id]; - const children = state.children[parent.children]; - const index = children.indexOf(childId); - children.splice(index, 1); - }, - - // move block to another parent - moveNode: (state, action: PayloadAction<{ id: string; newParentId: string; newPrevId: string | null }>) => { - const { id, newParentId, newPrevId } = action.payload; - const newParent = state.nodes[newParentId]; - const oldParentId = state.nodes[id].parent; - if (!oldParentId) return; - const oldParent = state.nodes[oldParentId]; - - state.nodes[id] = { - ...state.nodes[id], - parent: newParentId, - }; - const index = state.children[oldParent.children].indexOf(id); - state.children[oldParent.children].splice(index, 1); - - const newIndex = newPrevId ? state.children[newParent.children].indexOf(newPrevId) + 1 : 0; - state.children[newParent.children].splice(newIndex, 0, id); - }, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts index dbb519e052..16a182f6d1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts @@ -1,6 +1,7 @@ import { nanoid } from 'nanoid'; import { Descendant, Element, Text } from 'slate'; -import { TextDelta } from '../interfaces/document'; +import { TextDelta, BlockType, NestedBlock } from '../interfaces/document'; +import { Log } from './log'; export function generateId() { return nanoid(10); @@ -34,3 +35,30 @@ export function getDeltaFromSlateNodes(slateNodes: Descendant[]) { }; }); } + +export function blockChangeValue2Node(value: { + id: string; + ty: string; + parent: string; + children: string; + data: string; +}): NestedBlock { + const block = { + id: value.id, + type: value.ty as BlockType, + parent: value.parent, + children: value.children, + data: {}, + }; + if ('data' in value && typeof value.data === 'string') { + try { + Object.assign(block, { + data: JSON.parse(value.data), + }); + } catch { + Log.error('valueJson data parse error', block.data); + } + } + + return block; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts index d1aeab23e2..4b18191292 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts @@ -4,6 +4,8 @@ import { DocumentData } from '../interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { useAppDispatch } from '../stores/store'; import { Log } from '../utils/log'; +import { documentActions } from '../stores/reducers/document/slice'; +import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2'; export const useDocument = () => { const params = useParams(); @@ -12,12 +14,16 @@ export const useDocument = () => { const [controller, setController] = useState(null); const dispatch = useAppDispatch(); + const onDocumentChange = (props: { isRemote: boolean; data: BlockEventPayloadPB }) => { + dispatch(documentActions.onDataChange(props)); + }; + useEffect(() => { let documentController: DocumentController | null = null; void (async () => { if (!params?.id) return; Log.debug('open document', params.id); - documentController = new DocumentController(params.id, dispatch); + documentController = new DocumentController(params.id, onDocumentChange); setController(documentController); try { const res = await documentController.open();