Explorar el Código

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

qinluhe hace 2 años
padre
commit
eb78f9d36a

+ 15 - 60
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<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;
-}

+ 2 - 7
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 } }));
   }
 );
 

+ 1 - 18
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 }));
   }
 );

+ 2 - 9
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,
-      })
-    );
   }
 );

+ 2 - 18
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 }));
   }
 );

+ 2 - 9
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,
-      })
-    );
   }
 );

+ 0 - 1
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';
 

+ 3 - 17
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 }));
   }

+ 10 - 12
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,

+ 63 - 69
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<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;
       }
-    },
-
-    // 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 (!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);
+          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);
-    },
   },
 });
 

+ 29 - 1
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;
+}

+ 7 - 1
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<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();