mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
fix: make it unidirectional data flow by listening to document updates (#2347)
This commit is contained in:
parent
1ad2f6cef5
commit
eb78f9d36a
@ -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;
|
||||
}
|
||||
|
@ -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,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 }));
|
||||
}
|
||||
);
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -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 }));
|
||||
}
|
||||
);
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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 }));
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user