diff --git a/src/components/App.tsx b/src/components/App.tsx index 7172d1eb..3d19938c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -15,7 +15,6 @@ import { getCursorForResizingElement, getPerfectElementSize, getNormalizedDimensions, - getElementMap, getSceneVersion, getSyncableElements, newLinearElement, @@ -1285,8 +1284,15 @@ class App extends React.Component { if (init || initFromSnapshot) { this.setScrollToCenter(elements); } + const newElements = this.portal.reconcileElements(elements); - this.updateScene({ elements }); + // Avoid broadcasting to the rest of the collaborators the scene + // we just received! + // Note: this needs to be set before updating the scene as it + // syncronously calls render. + this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements)); + + this.updateScene({ elements: newElements }); if (!this.portal.socketInitialized && !initFromSnapshot) { this.initializeSocket(); @@ -1301,94 +1307,27 @@ class App extends React.Component { this.portal.close(); }; - public updateScene = ( - sceneData: { + public updateScene = withBatchedUpdates( + (sceneData: { elements: readonly ExcalidrawElement[]; appState?: AppState; - }, - { replaceAll = false }: { replaceAll?: boolean } = {}, - ) => { - // currently we only support syncing background color - if (sceneData.appState?.viewBackgroundColor) { - this.setState({ - viewBackgroundColor: sceneData.appState.viewBackgroundColor, - }); - } - // Perform reconciliation - in collaboration, if we encounter - // elements with more staler versions than ours, ignore them - // and keep ours. - const currentElements = this.scene.getElementsIncludingDeleted(); - if (replaceAll || !currentElements.length) { + }) => { + // currently we only support syncing background color + if (sceneData.appState?.viewBackgroundColor) { + this.setState({ + viewBackgroundColor: sceneData.appState.viewBackgroundColor, + }); + } + this.scene.replaceAllElements(sceneData.elements); - } else { - // create a map of ids so we don't have to iterate - // over the array more than once. - const localElementMap = getElementMap(currentElements); - // Reconcile - const newElements = sceneData.elements - .reduce((elements, element) => { - // if the remote element references one that's currently - // edited on local, skip it (it'll be added in the next - // step) - if ( - element.id === this.state.editingElement?.id || - element.id === this.state.resizingElement?.id || - element.id === this.state.draggingElement?.id - ) { - return elements; - } - - if ( - localElementMap.hasOwnProperty(element.id) && - localElementMap[element.id].version > element.version - ) { - elements.push(localElementMap[element.id]); - delete localElementMap[element.id]; - } else if ( - localElementMap.hasOwnProperty(element.id) && - localElementMap[element.id].version === element.version && - localElementMap[element.id].versionNonce !== element.versionNonce - ) { - // resolve conflicting edits deterministically by taking the one with the lowest versionNonce - if ( - localElementMap[element.id].versionNonce < element.versionNonce - ) { - elements.push(localElementMap[element.id]); - } else { - // it should be highly unlikely that the two versionNonces are the same. if we are - // really worried about this, we can replace the versionNonce with the socket id. - elements.push(element); - } - delete localElementMap[element.id]; - } else { - elements.push(element); - delete localElementMap[element.id]; - } - - return elements; - }, [] as Mutable) - // add local elements that weren't deleted or on remote - .concat(...Object.values(localElementMap)); - - // Avoid broadcasting to the rest of the collaborators the scene - // we just received! - // Note: this needs to be set before replaceAllElements as it - // syncronously calls render. - - this.setLastBroadcastedOrReceivedSceneVersion( - getSceneVersion(newElements), - ); - - this.scene.replaceAllElements(newElements); - } - - // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack - // when we receive any messages from another peer. This UX can be pretty rough -- if you - // undo, a user makes a change, and then try to redo, your element(s) will be lost. However, - // right now we think this is the right tradeoff. - history.clear(); - }; + // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack + // when we receive any messages from another peer. This UX can be pretty rough -- if you + // undo, a user makes a change, and then try to redo, your element(s) will be lost. However, + // right now we think this is the right tradeoff. + history.clear(); + }, + ); private initializeSocket = () => { this.portal.socketInitialized = true; diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx index d6de2fe6..dbb3188a 100644 --- a/src/components/Portal.tsx +++ b/src/components/Portal.tsx @@ -3,7 +3,12 @@ import { encryptAESGEM, SocketUpdateDataSource } from "../data"; import { SocketUpdateData } from "../types"; import { BROADCAST, SCENE } from "../constants"; import App from "./App"; -import { getSceneVersion, getSyncableElements } from "../element"; +import { + getElementMap, + getSceneVersion, + getSyncableElements, +} from "../element"; +import { ExcalidrawElement } from "../element/types"; class Portal { app: App; @@ -151,6 +156,58 @@ class Portal { ); } }; + + reconcileElements = (sceneElements: readonly ExcalidrawElement[]) => { + const currentElements = this.app.getSceneElementsIncludingDeleted(); + // create a map of ids so we don't have to iterate + // over the array more than once. + const localElementMap = getElementMap(currentElements); + + // Reconcile + const newElements = sceneElements + .reduce((elements, element) => { + // if the remote element references one that's currently + // edited on local, skip it (it'll be added in the next + // step) + if ( + element.id === this.app.state.editingElement?.id || + element.id === this.app.state.resizingElement?.id || + element.id === this.app.state.draggingElement?.id + ) { + return elements; + } + + if ( + localElementMap.hasOwnProperty(element.id) && + localElementMap[element.id].version > element.version + ) { + elements.push(localElementMap[element.id]); + delete localElementMap[element.id]; + } else if ( + localElementMap.hasOwnProperty(element.id) && + localElementMap[element.id].version === element.version && + localElementMap[element.id].versionNonce !== element.versionNonce + ) { + // resolve conflicting edits deterministically by taking the one with the lowest versionNonce + if (localElementMap[element.id].versionNonce < element.versionNonce) { + elements.push(localElementMap[element.id]); + } else { + // it should be highly unlikely that the two versionNonces are the same. if we are + // really worried about this, we can replace the versionNonce with the socket id. + elements.push(element); + } + delete localElementMap[element.id]; + } else { + elements.push(element); + delete localElementMap[element.id]; + } + + return elements; + }, [] as Mutable) + // add local elements that weren't deleted or on remote + .concat(...Object.values(localElementMap)); + return newElements; + }; } export default Portal;