diff --git a/src/appState.ts b/src/appState.ts index 6e15bc3bc6..207a5a47af 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -34,6 +34,7 @@ export function getDefaultAppState(): AppState { openMenu: null, lastPointerDownWith: "mouse", selectedElementIds: {}, + deletedIds: {}, collaborators: new Map(), }; } diff --git a/src/components/App.tsx b/src/components/App.tsx index 913fd3bb6a..a8eaae7625 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -37,7 +37,7 @@ import { loadScene, loadFromBlob, SOCKET_SERVER, - SocketUpdateData, + SocketUpdateDataSource, } from "../data"; import { restore } from "../data/restore"; @@ -270,19 +270,18 @@ export class App extends React.Component { iv, ); + let deletedIds = this.state.deletedIds; switch (decryptedData.type) { case "INVALID_RESPONSE": return; case "SCENE_UPDATE": const { - elements: sceneElements, - appState: sceneAppState, + elements: remoteElements, + appState: remoteAppState, } = decryptedData.payload; - const restoredState = restore( - sceneElements || [], - sceneAppState || getDefaultAppState(), - { scrollToContent: true }, - ); + const restoredState = restore(remoteElements || [], null, { + scrollToContent: true, + }); // Perform reconciliation - in collaboration, if we encounter // elements with more staler versions than ours, ignore them // and keep ours. @@ -301,6 +300,23 @@ export class App extends React.Component { }, {}, ); + + deletedIds = { ...deletedIds }; + + for (const [id, remoteDeletedEl] of Object.entries( + remoteAppState.deletedIds, + )) { + if ( + !localElementMap[id] || + // don't remove local element if it's newer than the one + // deleted on remote + remoteDeletedEl.version >= localElementMap[id].version + ) { + deletedIds[id] = remoteDeletedEl; + delete localElementMap[id]; + } + } + // Reconcile elements = restoredState.elements .reduce((elements, element) => { @@ -320,26 +336,28 @@ export class App extends React.Component { localElementMap[element.id].version > element.version ) { elements.push(localElementMap[element.id]); + delete localElementMap[element.id]; } else { - elements.push(element); + if (deletedIds.hasOwnProperty(element.id)) { + if (element.version > deletedIds[element.id].version) { + elements.push(element); + delete deletedIds[element.id]; + delete localElementMap[element.id]; + } + } else { + elements.push(element); + delete localElementMap[element.id]; + } } return elements; }, [] as any) - // add local elements that are currently being edited - // (can't be done in the step above because the elements may - // not exist on remote at all) - .concat( - elements.filter(element => { - return ( - element.id === this.state.editingElement?.id || - element.id === this.state.resizingElement?.id || - element.id === this.state.draggingElement?.id - ); - }), - ); + // add local elements that weren't deleted or on remote + .concat(...Object.values(localElementMap)); } - this.setState({}); + this.setState({ + deletedIds, + }); if (this.socketInitialized === false) { this.socketInitialized = true; } @@ -382,20 +400,58 @@ export class App extends React.Component { }); }); this.socket.on("new-user", async (socketID: string) => { - this.broadcastSocketData({ - type: "SCENE_UPDATE", - payload: { - elements: elements.filter(element => { - return element.id !== this.state.editingElement?.id; - }), - appState: this.state, - }, - }); + this.broadcastSceneUpdate(); }); } }; - private broadcastSocketData = async (data: SocketUpdateData) => { + private broadcastMouseLocation = (payload: { + pointerCoords: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointerCoords"]; + }) => { + if (this.socket?.id) { + const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { + type: "MOUSE_LOCATION", + payload: { + socketID: this.socket.id, + pointerCoords: payload.pointerCoords, + }, + }; + return this._broadcastSocketData( + data as typeof data & { _brand: "socketUpdateData" }, + ); + } + }; + + private broadcastSceneUpdate = () => { + const deletedIds = { ...this.state.deletedIds }; + const _elements = elements.filter(element => { + if (element.id in deletedIds) { + delete deletedIds[element.id]; + } + return element.id !== this.state.editingElement?.id; + }); + const data: SocketUpdateDataSource["SCENE_UPDATE"] = { + type: "SCENE_UPDATE", + payload: { + elements: _elements, + appState: { + viewBackgroundColor: this.state.viewBackgroundColor, + name: this.state.name, + deletedIds, + }, + }, + }; + return this._broadcastSocketData( + data as typeof data & { _brand: "socketUpdateData" }, + ); + }; + + // Low-level. Use type-specific broadcast* method. + private async _broadcastSocketData( + data: SocketUpdateDataSource[keyof SocketUpdateDataSource] & { + _brand: "socketUpdateData"; + }, + ) { if (this.socketInitialized && this.socket && this.roomID && this.roomKey) { const json = JSON.stringify(data); const encoded = new TextEncoder().encode(json); @@ -407,7 +463,7 @@ export class App extends React.Component { encrypted.iv, ); } - }; + } private unmounted = false; public async componentDidMount() { @@ -2128,14 +2184,7 @@ export class App extends React.Component { // sometimes the pointer goes off screen return; } - this.socket && - this.broadcastSocketData({ - type: "MOUSE_LOCATION", - payload: { - socketID: this.socket.id, - pointerCoords, - }, - }); + this.socket && this.broadcastMouseLocation({ pointerCoords }); }; private saveDebounced = debounce(() => { @@ -2188,15 +2237,7 @@ export class App extends React.Component { } this.saveDebounced(); if (history.isRecording()) { - this.broadcastSocketData({ - type: "SCENE_UPDATE", - payload: { - elements: elements.filter(element => { - return element.id !== this.state.editingElement?.id; - }), - appState: this.state, - }, - }); + this.broadcastSceneUpdate(); history.pushEntry(this.state, elements); history.skipRecording(); } diff --git a/src/data/index.ts b/src/data/index.ts index 9ec26e0f97..c6011414bb 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -30,21 +30,25 @@ export type EncryptedData = { iv: Uint8Array; }; -export type SocketUpdateData = - | { - type: "SCENE_UPDATE"; - payload: { - elements: readonly ExcalidrawElement[]; - appState: AppState | null; - }; - } - | { - type: "MOUSE_LOCATION"; - payload: { - socketID: string; - pointerCoords: { x: number; y: number }; - }; - } +export type SocketUpdateDataSource = { + SCENE_UPDATE: { + type: "SCENE_UPDATE"; + payload: { + elements: readonly ExcalidrawElement[]; + appState: Pick; + }; + }; + MOUSE_LOCATION: { + type: "MOUSE_LOCATION"; + payload: { + socketID: string; + pointerCoords: { x: number; y: number }; + }; + }; +}; + +export type SocketUpdateDataIncoming = + | SocketUpdateDataSource[keyof SocketUpdateDataSource] | { type: "INVALID_RESPONSE"; }; @@ -137,7 +141,7 @@ export async function decryptAESGEM( data: ArrayBuffer, key: string, iv: Uint8Array, -): Promise { +): Promise { try { const importedKey = await getImportedKey(key, "decrypt"); const decrypted = await window.crypto.subtle.decrypt( diff --git a/src/data/restore.ts b/src/data/restore.ts index 6d648aab80..499366c1d9 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -52,7 +52,7 @@ export function restore( return { ...element, - version: element.id ? element.version + 1 : element.version || 0, + version: element.version || 0, id: element.id || nanoid(), fillStyle: element.fillStyle || "hachure", strokeWidth: element.strokeWidth || 1, diff --git a/src/scene/selection.ts b/src/scene/selection.ts index b3b277c5d8..2570686451 100644 --- a/src/scene/selection.ts +++ b/src/scene/selection.ts @@ -34,11 +34,24 @@ export function deleteSelectedElements( elements: readonly ExcalidrawElement[], appState: AppState, ) { + const deletedIds: AppState["deletedIds"] = {}; return { - elements: elements.filter(el => !appState.selectedElementIds[el.id]), + elements: elements.filter(el => { + if (appState.selectedElementIds[el.id]) { + deletedIds[el.id] = { + version: el.version, + }; + return false; + } + return true; + }), appState: { ...appState, selectedElementIds: {}, + deletedIds: { + ...appState.deletedIds, + ...deletedIds, + }, }, }; } diff --git a/src/types.ts b/src/types.ts index 6e96b8334a..3eeebbdeb7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,7 @@ export type AppState = { openMenu: "canvas" | "shape" | null; lastPointerDownWith: PointerType; selectedElementIds: { [id: string]: boolean }; + deletedIds: { [id: string]: { version: ExcalidrawElement["version"] } }; collaborators: Map; };