fix: make it unidirectional data flow by listening to document updates (#2347)

This commit is contained in:
qinluhe 2023-04-25 19:10:08 +08:00 committed by GitHub
parent 1ad2f6cef5
commit eb78f9d36a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 135 additions and 221 deletions

View File

@ -1,18 +1,21 @@
import { DocumentData, BlockType, DeltaItem } from '@/appflowy_app/interfaces/document'; import { DocumentData, BlockType } from '@/appflowy_app/interfaces/document';
import { createContext, Dispatch } from 'react'; import { createContext } from 'react';
import { DocumentBackendService } from './document_bd_svc'; 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 { DocumentObserver } from './document_observer';
import { documentActions, Node } from '@/appflowy_app/stores/reducers/document/slice'; import { Node } from '@/appflowy_app/stores/reducers/document/slice';
import { Log } from '@/appflowy_app/utils/log';
import * as Y from 'yjs'; import * as Y from 'yjs';
export const DocumentControllerContext = createContext<DocumentController | null>(null); export const DocumentControllerContext = createContext<DocumentController | null>(null);
export class DocumentController { export class DocumentController {
private readonly backendService: DocumentBackendService; private readonly backendService: DocumentBackendService;
private readonly observer: DocumentObserver; private readonly observer: DocumentObserver;
constructor(public readonly viewId: string, private dispatch?: Dispatch<any>) { constructor(
public readonly viewId: string,
private onDocChange?: (props: { isRemote: boolean; data: BlockEventPayloadPB }) => void
) {
this.backendService = new DocumentBackendService(viewId); this.backendService = new DocumentBackendService(viewId);
this.observer = new DocumentObserver(viewId); this.observer = new DocumentObserver(viewId);
} }
@ -107,6 +110,7 @@ export class DocumentController {
}; };
dispose = async () => { dispose = async () => {
this.onDocChange = undefined;
await this.backendService.close(); await this.backendService.close();
}; };
@ -143,65 +147,16 @@ export class DocumentController {
}; };
private updated = (payload: Uint8Array) => { private updated = (payload: Uint8Array) => {
const dispatch = this.dispatch; if (!this.onDocChange) return;
if (!dispatch) return;
const { events, is_remote } = DocEventPB.deserializeBinary(payload); const { events, is_remote } = DocEventPB.deserializeBinary(payload);
if (!is_remote) return;
events.forEach((event) => { events.forEach((event) => {
event.event.forEach((_payload) => { event.event.forEach((_payload) => {
const { path, id, value, command } = _payload; this.onDocChange?.({
let valueJson; isRemote: is_remote,
try { data: _payload,
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));
}
}
}); });
}); });
}; };
} }
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;
}

View File

@ -42,13 +42,8 @@ const composeNodeThunk = createAsyncThunk(
// move must be before delete // move must be before delete
await controller.applyActions([...moveActions, deleteAction, updateAction]); await controller.applyActions([...moveActions, deleteAction, updateAction]);
// update local node data
children.reverse().forEach((childId) => { dispatch(documentActions.updateNodeData({ id: newNode.id, data: { delta: newNode.data.delta } }));
dispatch(documentActions.moveNode({ id: childId, newParentId: newNode.id, newPrevId: '' }));
});
dispatch(documentActions.setBlockMap(newNode));
dispatch(documentActions.removeBlockMapKey(node.id));
dispatch(documentActions.removeChildrenMapKey(node.children));
} }
); );

View File

@ -1,6 +1,6 @@
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions, DocumentState } from '../slice'; import { DocumentState } from '../slice';
export const deleteNodeThunk = createAsyncThunk( export const deleteNodeThunk = createAsyncThunk(
'document/deleteNode', 'document/deleteNode',
@ -11,22 +11,5 @@ export const deleteNodeThunk = createAsyncThunk(
const node = state.document.nodes[id]; const node = state.document.nodes[id];
if (!node) return; if (!node) return;
await controller.applyActions([controller.getDeleteAction(node)]); 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 }));
} }
); );

View File

@ -1,13 +1,13 @@
import { BlockType } from '@/appflowy_app/interfaces/document'; import { BlockType } from '@/appflowy_app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions, DocumentState } from '../slice'; import { DocumentState } from '../slice';
export const indentNodeThunk = createAsyncThunk( export const indentNodeThunk = createAsyncThunk(
'document/indentNode', 'document/indentNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const { dispatch, getState } = thunkAPI; const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id]; const node = state.nodes[id];
if (!node.parent) return; if (!node.parent) return;
@ -26,12 +26,5 @@ export const indentNodeThunk = createAsyncThunk(
const newPrevId = prevNodeChildren[prevNodeChildren.length - 1]; const newPrevId = prevNodeChildren[prevNodeChildren.length - 1];
await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]); await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]);
dispatch(
documentActions.moveNode({
id,
newParentId,
newPrevId,
})
);
} }
); );

View File

@ -1,9 +1,9 @@
import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document'; import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions, DocumentState } from '../slice'; import { DocumentState } from '../slice';
import { generateId } from '@/appflowy_app/utils/block'; import { generateId } from '@/appflowy_app/utils/block';
import { setCursorAfterThunk } from './set_cursor';
export const insertAfterNodeThunk = createAsyncThunk( export const insertAfterNodeThunk = createAsyncThunk(
'document/insertAfterNode', 'document/insertAfterNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
@ -25,21 +25,5 @@ export const insertAfterNodeThunk = createAsyncThunk(
children: generateId(), children: generateId(),
}; };
await controller.applyActions([controller.getInsertAction(newNode, node.id)]); 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 }));
} }
); );

View File

@ -1,12 +1,12 @@
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions, DocumentState } from '../slice'; import { DocumentState } from '../slice';
export const outdentNodeThunk = createAsyncThunk( export const outdentNodeThunk = createAsyncThunk(
'document/outdentNode', 'document/outdentNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload; const { id, controller } = payload;
const { dispatch, getState } = thunkAPI; const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id]; const node = state.nodes[id];
const newPrevId = node.parent; const newPrevId = node.parent;
@ -15,12 +15,5 @@ export const outdentNodeThunk = createAsyncThunk(
const newParentId = parent.parent; const newParentId = parent.parent;
if (!newParentId) return; if (!newParentId) return;
await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]); await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]);
dispatch(
documentActions.moveNode({
id: node.id,
newParentId,
newPrevId,
})
);
} }
); );

View File

@ -1,4 +1,3 @@
import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions, DocumentState, SelectionPoint, TextSelection } from '../slice'; import { documentActions, DocumentState, SelectionPoint, TextSelection } from '../slice';

View File

@ -2,7 +2,7 @@ import { BlockType, TextDelta } from '@/appflowy_app/interfaces/document';
import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller'; import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { generateId } from '@/appflowy_app/utils/block'; import { generateId } from '@/appflowy_app/utils/block';
import { documentActions, DocumentState, TextSelection } from '../slice'; import { documentActions, DocumentState } from '../slice';
import { setCursorBeforeThunk } from './set_cursor'; import { setCursorBeforeThunk } from './set_cursor';
export const splitNodeThunk = createAsyncThunk( export const splitNodeThunk = createAsyncThunk(
@ -36,22 +36,8 @@ export const splitNodeThunk = createAsyncThunk(
}, },
}; };
await controller.applyActions([controller.getInsertAction(newNode, prevId), controller.getUpdateAction(retainNode)]); await controller.applyActions([controller.getInsertAction(newNode, prevId), controller.getUpdateAction(retainNode)]);
dispatch(documentActions.setBlockMap(newNode)); // update local node data
dispatch(documentActions.setBlockMap(retainNode)); dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } }));
dispatch(
documentActions.setChildrenMap({
id: newNode.children,
childIds: [],
})
);
dispatch(
documentActions.insertChild({
id: parent.id,
childId: newNode.id,
prevId,
})
);
// set cursor // set cursor
await dispatch(setCursorBeforeThunk({ id: newNode.id })); await dispatch(setCursorBeforeThunk({ id: newNode.id }));
} }

View File

@ -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 { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions, DocumentState, Node } from '../slice'; import { documentActions, DocumentState } from '../slice';
import { debounce } from '$app/utils/tool'; import { debounce } from '$app/utils/tool';
export const updateNodeDeltaThunk = createAsyncThunk( export const updateNodeDeltaThunk = createAsyncThunk(
'document/updateNodeDelta', 'document/updateNodeDelta',
@ -9,25 +9,23 @@ export const updateNodeDeltaThunk = createAsyncThunk(
const { id, delta, controller } = payload; const { id, delta, controller } = payload;
const { dispatch, getState } = thunkAPI; const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document; 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 node = state.nodes[id];
const updateNode = { // the transaction is delayed to avoid too many updates
debounceApplyUpdate(controller, {
...node, ...node,
id,
data: { data: {
...node.data, ...node.data,
delta, 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([ void controller.applyActions([
controller.getUpdateAction({ controller.getUpdateAction({
...updateNode, ...updateNode,

View File

@ -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 { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { nanoid } from 'nanoid';
import { DocumentController } from '../../effects/document/document_controller';
import { RegionGrid } from './region_grid'; import { RegionGrid } from './region_grid';
export type Node = NestedBlock; export type Node = NestedBlock;
@ -131,78 +132,71 @@ export const documentSlice = createSlice({
state.textSelections; state.textSelections;
}, },
// insert block // We need this action to update the local state before `onDataChange` to make the UI more smooth,
setBlockMap: (state, action: PayloadAction<Node>) => { // because we often use `debounce` to send the change to db, so the db data will be updated later.
state.nodes[action.payload.id] = action.payload; updateNodeData: (state, action: PayloadAction<{ id: string; data: Record<string, any> }>) => {
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 // when we use `onDataChange` to handle the change, we don't need care about the change is from which client,
updateBlock: (state, action: PayloadAction<{ id: string; block: NestedBlock }>) => { // because the data is always from db state, and then to UI.
const { id, block } = action.payload; // Except the `updateNodeData` action, we will use it before `onDataChange` to update the local state,
const node = state.nodes[id]; // so we should skip update block's `data` field when the change is from local
if (!node || node.parent !== block.parent || node.type !== block.type || node.children !== block.children) { onDataChange: (
state.nodes[action.payload.id] = block; 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; return;
} }
}, if (!valueJson) return;
// remove block if (command === DeltaTypePB.Inserted || command === DeltaTypePB.Updated) {
removeBlockMapKey(state, action: PayloadAction<string>) { // set map key and value ( block map or children map)
if (!state.nodes[action.payload]) return; if (path[0] === 'blocks') {
const { id } = state.nodes[action.payload]; const block = blockChangeValue2Node(valueJson);
regionGrid.removeBlock(id); if (command === DeltaTypePB.Updated && !isRemote) {
delete state.nodes[id]; // 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) {
// set block's relationship with its children state.nodes[block.id] = block;
setChildrenMap: (state, action: PayloadAction<{ id: string; childIds: string[] }>) => { }
const { id, childIds } = action.payload; } else {
state.children[id] = childIds; state.nodes[block.id] = block;
}, }
} else {
// remove block's relationship with its children state.children[id] = valueJson;
removeChildrenMapKey(state, action: PayloadAction<string>) { }
if (state.children[action.payload]) { } else {
delete state.children[action.payload]; // 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);
},
}, },
}); });

View File

@ -1,6 +1,7 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Descendant, Element, Text } from 'slate'; 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() { export function generateId() {
return nanoid(10); 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;
}

View File

@ -4,6 +4,8 @@ import { DocumentData } from '../interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '../stores/store'; import { useAppDispatch } from '../stores/store';
import { Log } from '../utils/log'; import { Log } from '../utils/log';
import { documentActions } from '../stores/reducers/document/slice';
import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2';
export const useDocument = () => { export const useDocument = () => {
const params = useParams(); const params = useParams();
@ -12,12 +14,16 @@ export const useDocument = () => {
const [controller, setController] = useState<DocumentController | null>(null); const [controller, setController] = useState<DocumentController | null>(null);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onDocumentChange = (props: { isRemote: boolean; data: BlockEventPayloadPB }) => {
dispatch(documentActions.onDataChange(props));
};
useEffect(() => { useEffect(() => {
let documentController: DocumentController | null = null; let documentController: DocumentController | null = null;
void (async () => { void (async () => {
if (!params?.id) return; if (!params?.id) return;
Log.debug('open document', params.id); Log.debug('open document', params.id);
documentController = new DocumentController(params.id, dispatch); documentController = new DocumentController(params.id, onDocumentChange);
setController(documentController); setController(documentController);
try { try {
const res = await documentController.open(); const res = await documentController.open();