mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	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
This commit is contained in:
		@@ -34,6 +34,7 @@ export function getDefaultAppState(): AppState {
 | 
			
		||||
    openMenu: null,
 | 
			
		||||
    lastPointerDownWith: "mouse",
 | 
			
		||||
    selectedElementIds: {},
 | 
			
		||||
    deletedIds: {},
 | 
			
		||||
    collaborators: new Map(),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 } }>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user