Selaa lähdekoodia

use deletedIds map to sync deletions (#936)

* use deletedIds map for sync deletions

* refactor how we create data for syncing

* fix comments

* streamline broadcast API

* split broadcast methods
David Luzar 5 vuotta sitten
vanhempi
commit
b9c75b5bc4
6 muutettua tiedostoa jossa 128 lisäystä ja 68 poistoa
  1. 1 0
      src/appState.ts
  2. 91 50
      src/components/App.tsx
  3. 20 16
      src/data/index.ts
  4. 1 1
      src/data/restore.ts
  5. 14 1
      src/scene/selection.ts
  6. 1 0
      src/types.ts

+ 1 - 0
src/appState.ts

@@ -34,6 +34,7 @@ export function getDefaultAppState(): AppState {
     openMenu: null,
     lastPointerDownWith: "mouse",
     selectedElementIds: {},
+    deletedIds: {},
     collaborators: new Map(),
   };
 }

+ 91 - 50
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<any, AppState> {
             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<any, AppState> {
                   },
                   {},
                 );
+
+                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<any, AppState> {
                       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<any, AppState> {
         });
       });
       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<any, AppState> {
         encrypted.iv,
       );
     }
-  };
+  }
 
   private unmounted = false;
   public async componentDidMount() {
@@ -2128,14 +2184,7 @@ export class App extends React.Component<any, AppState> {
       // 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<any, AppState> {
     }
     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();
     }

+ 20 - 16
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<AppState, "viewBackgroundColor" | "name" | "deletedIds">;
+    };
+  };
+  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<SocketUpdateData> {
+): Promise<SocketUpdateDataIncoming> {
   try {
     const importedKey = await getImportedKey(key, "decrypt");
     const decrypted = await window.crypto.subtle.decrypt(

+ 1 - 1
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,

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

+ 1 - 0
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<string, { pointer?: { x: number; y: number } }>;
 };