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 { 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<DocumentController | null>(null);
export class DocumentController {
private readonly backendService: DocumentBackendService;
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.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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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 { 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<Node>) => {
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<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
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<string>) {
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<string>) {
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);
},
},
});

View File

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

View File

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