diff --git a/src/components/App.tsx b/src/components/App.tsx index 35687829..22b10b3e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -502,7 +502,7 @@ export class App extends React.Component { getDrawingVersion(globalSceneState.getAllElements()) > this.lastBroadcastedOrReceivedSceneVersion ) { - this.broadcastSceneUpdate(); + this.broadcastScene("SCENE_UPDATE"); } history.record(this.state, globalSceneState.getAllElements()); @@ -708,6 +708,92 @@ export class App extends React.Component { // initial SCENE_UPDATE message const initializationTimer = setTimeout(initialize, 5000); + const updateScene = ( + decryptedData: SocketUpdateDataSource["SCENE_INIT" | "SCENE_UPDATE"], + ) => { + const { elements: remoteElements } = decryptedData.payload; + 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. + if ( + globalSceneState.getAllElements() == null || + globalSceneState.getAllElements().length === 0 + ) { + globalSceneState.replaceAllElements(restoredState.elements); + } else { + // create a map of ids so we don't have to iterate + // over the array more than once. + const localElementMap = getElementMap( + globalSceneState.getAllElements(), + ); + + // Reconcile + globalSceneState.replaceAllElements( + restoredState.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)), + ); + } + this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion( + globalSceneState.getAllElements(), + ); + + // 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(); + if (this.socketInitialized === false) { + initialize(); + } + }; + this.socket = socketIOClient(SOCKET_SERVER); this.roomID = roomMatch[1]; this.roomKey = roomMatch[2]; @@ -729,90 +815,15 @@ export class App extends React.Component { switch (decryptedData.type) { case "INVALID_RESPONSE": return; - case "SCENE_UPDATE": { - const { elements: remoteElements } = decryptedData.payload; - 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. - if ( - globalSceneState.getAllElements() == null || - globalSceneState.getAllElements().length === 0 - ) { - globalSceneState.replaceAllElements(restoredState.elements); - } else { - // create a map of ids so we don't have to iterate - // over the array more than once. - const localElementMap = getElementMap( - globalSceneState.getAllElements(), - ); - - // Reconcile - globalSceneState.replaceAllElements( - restoredState.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)), - ); - } - this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion( - globalSceneState.getAllElements(), - ); - // 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(); - if (this.socketInitialized === false) { - initialize(); + case "SCENE_INIT": { + if (!this.socketInitialized) { + updateScene(decryptedData); } break; } + case "SCENE_UPDATE": + updateScene(decryptedData); + break; case "MOUSE_LOCATION": { const { socketID, pointerCoords } = decryptedData.payload; this.setState((state) => { @@ -852,7 +863,7 @@ export class App extends React.Component { }); }); this.socket.on("new-user", async (socketID: string) => { - this.broadcastSceneUpdate(); + this.broadcastScene("SCENE_INIT"); }); this.setState({ @@ -879,9 +890,9 @@ export class App extends React.Component { } }; - private broadcastSceneUpdate = () => { - const data: SocketUpdateDataSource["SCENE_UPDATE"] = { - type: "SCENE_UPDATE", + private broadcastScene = (sceneType: "SCENE_INIT" | "SCENE_UPDATE") => { + const data: SocketUpdateDataSource[typeof sceneType] = { + type: sceneType, payload: { elements: getSyncableElements(globalSceneState.getAllElements()), }, diff --git a/src/data/index.ts b/src/data/index.ts index 3284dcd1..21679a12 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -32,6 +32,12 @@ export type EncryptedData = { }; export type SocketUpdateDataSource = { + SCENE_INIT: { + type: "SCENE_INIT"; + payload: { + elements: readonly ExcalidrawElement[]; + }; + }; SCENE_UPDATE: { type: "SCENE_UPDATE"; payload: {