mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-25 00:44:38 +02:00 
			
		
		
		
	 32df5502ae
			
		
	
	32df5502ae
	
	
	
		
			
			* Introducing fractional indices as part of `element.index` * Ensuring invalid fractional indices are always synchronized with the array order * Simplifying reconciliation based on the fractional indices * Moving reconciliation inside the `@excalidraw/excalidraw` package --------- Co-authored-by: Marcel Mraz <marcel@excalidraw.com> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
		
			
				
	
	
		
			1001 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			1001 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import throttle from "lodash.throttle";
 | |
| import { PureComponent } from "react";
 | |
| import {
 | |
|   ExcalidrawImperativeAPI,
 | |
|   SocketId,
 | |
| } from "../../packages/excalidraw/types";
 | |
| import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
 | |
| import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
 | |
| import { ImportedDataState } from "../../packages/excalidraw/data/types";
 | |
| import {
 | |
|   ExcalidrawElement,
 | |
|   InitializedExcalidrawImageElement,
 | |
|   OrderedExcalidrawElement,
 | |
| } from "../../packages/excalidraw/element/types";
 | |
| import {
 | |
|   getSceneVersion,
 | |
|   restoreElements,
 | |
|   zoomToFitBounds,
 | |
| } from "../../packages/excalidraw/index";
 | |
| import { Collaborator, Gesture } from "../../packages/excalidraw/types";
 | |
| import {
 | |
|   assertNever,
 | |
|   preventUnload,
 | |
|   resolvablePromise,
 | |
|   throttleRAF,
 | |
| } from "../../packages/excalidraw/utils";
 | |
| import {
 | |
|   CURSOR_SYNC_TIMEOUT,
 | |
|   FILE_UPLOAD_MAX_BYTES,
 | |
|   FIREBASE_STORAGE_PREFIXES,
 | |
|   INITIAL_SCENE_UPDATE_TIMEOUT,
 | |
|   LOAD_IMAGES_TIMEOUT,
 | |
|   WS_SUBTYPES,
 | |
|   SYNC_FULL_SCENE_INTERVAL_MS,
 | |
|   WS_EVENTS,
 | |
| } from "../app_constants";
 | |
| import {
 | |
|   generateCollaborationLinkData,
 | |
|   getCollaborationLink,
 | |
|   getSyncableElements,
 | |
|   SocketUpdateDataSource,
 | |
|   SyncableExcalidrawElement,
 | |
| } from "../data";
 | |
| import {
 | |
|   isSavedToFirebase,
 | |
|   loadFilesFromFirebase,
 | |
|   loadFromFirebase,
 | |
|   saveFilesToFirebase,
 | |
|   saveToFirebase,
 | |
| } from "../data/firebase";
 | |
| import {
 | |
|   importUsernameFromLocalStorage,
 | |
|   saveUsernameToLocalStorage,
 | |
| } from "../data/localStorage";
 | |
| import Portal from "./Portal";
 | |
| import { t } from "../../packages/excalidraw/i18n";
 | |
| import { UserIdleState } from "../../packages/excalidraw/types";
 | |
| import {
 | |
|   IDLE_THRESHOLD,
 | |
|   ACTIVE_THRESHOLD,
 | |
| } from "../../packages/excalidraw/constants";
 | |
| import {
 | |
|   encodeFilesForUpload,
 | |
|   FileManager,
 | |
|   updateStaleImageStatuses,
 | |
| } from "../data/FileManager";
 | |
| import { AbortError } from "../../packages/excalidraw/errors";
 | |
| import {
 | |
|   isImageElement,
 | |
|   isInitializedImageElement,
 | |
| } from "../../packages/excalidraw/element/typeChecks";
 | |
| import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
 | |
| import { decryptData } from "../../packages/excalidraw/data/encryption";
 | |
| import { resetBrowserStateVersions } from "../data/tabSync";
 | |
| import { LocalData } from "../data/LocalData";
 | |
| import { atom } from "jotai";
 | |
| import { appJotaiStore } from "../app-jotai";
 | |
| import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
 | |
| import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
 | |
| import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
 | |
| import { collabErrorIndicatorAtom } from "./CollabError";
 | |
| import {
 | |
|   ReconciledExcalidrawElement,
 | |
|   RemoteExcalidrawElement,
 | |
|   reconcileElements,
 | |
| } from "../../packages/excalidraw/data/reconcile";
 | |
| 
 | |
| export const collabAPIAtom = atom<CollabAPI | null>(null);
 | |
| export const isCollaboratingAtom = atom(false);
 | |
| export const isOfflineAtom = atom(false);
 | |
| 
 | |
| interface CollabState {
 | |
|   errorMessage: string | null;
 | |
|   /** errors related to saving */
 | |
|   dialogNotifiedErrors: Record<string, boolean>;
 | |
|   username: string;
 | |
|   activeRoomLink: string | null;
 | |
| }
 | |
| 
 | |
| export const activeRoomLinkAtom = atom<string | null>(null);
 | |
| 
 | |
| type CollabInstance = InstanceType<typeof Collab>;
 | |
| 
 | |
| export interface CollabAPI {
 | |
|   /** function so that we can access the latest value from stale callbacks */
 | |
|   isCollaborating: () => boolean;
 | |
|   onPointerUpdate: CollabInstance["onPointerUpdate"];
 | |
|   startCollaboration: CollabInstance["startCollaboration"];
 | |
|   stopCollaboration: CollabInstance["stopCollaboration"];
 | |
|   syncElements: CollabInstance["syncElements"];
 | |
|   fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
 | |
|   setUsername: CollabInstance["setUsername"];
 | |
|   getUsername: CollabInstance["getUsername"];
 | |
|   getActiveRoomLink: CollabInstance["getActiveRoomLink"];
 | |
|   setCollabError: CollabInstance["setErrorDialog"];
 | |
| }
 | |
| 
 | |
| interface CollabProps {
 | |
|   excalidrawAPI: ExcalidrawImperativeAPI;
 | |
| }
 | |
| 
 | |
| class Collab extends PureComponent<CollabProps, CollabState> {
 | |
|   portal: Portal;
 | |
|   fileManager: FileManager;
 | |
|   excalidrawAPI: CollabProps["excalidrawAPI"];
 | |
|   activeIntervalId: number | null;
 | |
|   idleTimeoutId: number | null;
 | |
| 
 | |
|   private socketInitializationTimer?: number;
 | |
|   private lastBroadcastedOrReceivedSceneVersion: number = -1;
 | |
|   private collaborators = new Map<SocketId, Collaborator>();
 | |
| 
 | |
|   constructor(props: CollabProps) {
 | |
|     super(props);
 | |
|     this.state = {
 | |
|       errorMessage: null,
 | |
|       dialogNotifiedErrors: {},
 | |
|       username: importUsernameFromLocalStorage() || "",
 | |
|       activeRoomLink: null,
 | |
|     };
 | |
|     this.portal = new Portal(this);
 | |
|     this.fileManager = new FileManager({
 | |
|       getFiles: async (fileIds) => {
 | |
|         const { roomId, roomKey } = this.portal;
 | |
|         if (!roomId || !roomKey) {
 | |
|           throw new AbortError();
 | |
|         }
 | |
| 
 | |
|         return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
 | |
|       },
 | |
|       saveFiles: async ({ addedFiles }) => {
 | |
|         const { roomId, roomKey } = this.portal;
 | |
|         if (!roomId || !roomKey) {
 | |
|           throw new AbortError();
 | |
|         }
 | |
| 
 | |
|         return saveFilesToFirebase({
 | |
|           prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
 | |
|           files: await encodeFilesForUpload({
 | |
|             files: addedFiles,
 | |
|             encryptionKey: roomKey,
 | |
|             maxBytes: FILE_UPLOAD_MAX_BYTES,
 | |
|           }),
 | |
|         });
 | |
|       },
 | |
|     });
 | |
|     this.excalidrawAPI = props.excalidrawAPI;
 | |
|     this.activeIntervalId = null;
 | |
|     this.idleTimeoutId = null;
 | |
|   }
 | |
| 
 | |
|   private onUmmount: (() => void) | null = null;
 | |
| 
 | |
|   componentDidMount() {
 | |
|     window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
 | |
|     window.addEventListener("online", this.onOfflineStatusToggle);
 | |
|     window.addEventListener("offline", this.onOfflineStatusToggle);
 | |
|     window.addEventListener(EVENT.UNLOAD, this.onUnload);
 | |
| 
 | |
|     const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
 | |
|       this.portal.socket && this.portal.broadcastUserFollowed(payload);
 | |
|     });
 | |
|     const throttledRelayUserViewportBounds = throttleRAF(
 | |
|       this.relayVisibleSceneBounds,
 | |
|     );
 | |
|     const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() =>
 | |
|       throttledRelayUserViewportBounds(),
 | |
|     );
 | |
|     this.onUmmount = () => {
 | |
|       unsubOnUserFollow();
 | |
|       unsubOnScrollChange();
 | |
|     };
 | |
| 
 | |
|     this.onOfflineStatusToggle();
 | |
| 
 | |
|     const collabAPI: CollabAPI = {
 | |
|       isCollaborating: this.isCollaborating,
 | |
|       onPointerUpdate: this.onPointerUpdate,
 | |
|       startCollaboration: this.startCollaboration,
 | |
|       syncElements: this.syncElements,
 | |
|       fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
 | |
|       stopCollaboration: this.stopCollaboration,
 | |
|       setUsername: this.setUsername,
 | |
|       getUsername: this.getUsername,
 | |
|       getActiveRoomLink: this.getActiveRoomLink,
 | |
|       setCollabError: this.setErrorDialog,
 | |
|     };
 | |
| 
 | |
|     appJotaiStore.set(collabAPIAtom, collabAPI);
 | |
| 
 | |
|     if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
 | |
|       window.collab = window.collab || ({} as Window["collab"]);
 | |
|       Object.defineProperties(window, {
 | |
|         collab: {
 | |
|           configurable: true,
 | |
|           value: this,
 | |
|         },
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   onOfflineStatusToggle = () => {
 | |
|     appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
 | |
|   };
 | |
| 
 | |
|   componentWillUnmount() {
 | |
|     window.removeEventListener("online", this.onOfflineStatusToggle);
 | |
|     window.removeEventListener("offline", this.onOfflineStatusToggle);
 | |
|     window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
 | |
|     window.removeEventListener(EVENT.UNLOAD, this.onUnload);
 | |
|     window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
 | |
|     window.removeEventListener(
 | |
|       EVENT.VISIBILITY_CHANGE,
 | |
|       this.onVisibilityChange,
 | |
|     );
 | |
|     if (this.activeIntervalId) {
 | |
|       window.clearInterval(this.activeIntervalId);
 | |
|       this.activeIntervalId = null;
 | |
|     }
 | |
|     if (this.idleTimeoutId) {
 | |
|       window.clearTimeout(this.idleTimeoutId);
 | |
|       this.idleTimeoutId = null;
 | |
|     }
 | |
|     this.onUmmount?.();
 | |
|   }
 | |
| 
 | |
|   isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
 | |
| 
 | |
|   private setIsCollaborating = (isCollaborating: boolean) => {
 | |
|     appJotaiStore.set(isCollaboratingAtom, isCollaborating);
 | |
|   };
 | |
| 
 | |
|   private onUnload = () => {
 | |
|     this.destroySocketClient({ isUnload: true });
 | |
|   };
 | |
| 
 | |
|   private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
 | |
|     const syncableElements = getSyncableElements(
 | |
|       this.getSceneElementsIncludingDeleted(),
 | |
|     );
 | |
| 
 | |
|     if (
 | |
|       this.isCollaborating() &&
 | |
|       (this.fileManager.shouldPreventUnload(syncableElements) ||
 | |
|         !isSavedToFirebase(this.portal, syncableElements))
 | |
|     ) {
 | |
|       // this won't run in time if user decides to leave the site, but
 | |
|       //  the purpose is to run in immediately after user decides to stay
 | |
|       this.saveCollabRoomToFirebase(syncableElements);
 | |
| 
 | |
|       preventUnload(event);
 | |
|     }
 | |
|   });
 | |
| 
 | |
|   saveCollabRoomToFirebase = async (
 | |
|     syncableElements: readonly SyncableExcalidrawElement[],
 | |
|   ) => {
 | |
|     try {
 | |
|       const storedElements = await saveToFirebase(
 | |
|         this.portal,
 | |
|         syncableElements,
 | |
|         this.excalidrawAPI.getAppState(),
 | |
|       );
 | |
| 
 | |
|       this.resetErrorIndicator();
 | |
| 
 | |
|       if (this.isCollaborating() && storedElements) {
 | |
|         this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
 | |
|       }
 | |
|     } catch (error: any) {
 | |
|       const errorMessage = /is longer than.*?bytes/.test(error.message)
 | |
|         ? t("errors.collabSaveFailed_sizeExceeded")
 | |
|         : t("errors.collabSaveFailed");
 | |
| 
 | |
|       if (
 | |
|         !this.state.dialogNotifiedErrors[errorMessage] ||
 | |
|         !this.isCollaborating()
 | |
|       ) {
 | |
|         this.setErrorDialog(errorMessage);
 | |
|         this.setState({
 | |
|           dialogNotifiedErrors: {
 | |
|             ...this.state.dialogNotifiedErrors,
 | |
|             [errorMessage]: true,
 | |
|           },
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       if (this.isCollaborating()) {
 | |
|         this.setErrorIndicator(errorMessage);
 | |
|       }
 | |
| 
 | |
|       console.error(error);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   stopCollaboration = (keepRemoteState = true) => {
 | |
|     this.queueBroadcastAllElements.cancel();
 | |
|     this.queueSaveToFirebase.cancel();
 | |
|     this.loadImageFiles.cancel();
 | |
|     this.resetErrorIndicator(true);
 | |
| 
 | |
|     this.saveCollabRoomToFirebase(
 | |
|       getSyncableElements(
 | |
|         this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|       ),
 | |
|     );
 | |
| 
 | |
|     if (this.portal.socket && this.fallbackInitializationHandler) {
 | |
|       this.portal.socket.off(
 | |
|         "connect_error",
 | |
|         this.fallbackInitializationHandler,
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     if (!keepRemoteState) {
 | |
|       LocalData.fileStorage.reset();
 | |
|       this.destroySocketClient();
 | |
|     } else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
 | |
|       // hack to ensure that we prefer we disregard any new browser state
 | |
|       // that could have been saved in other tabs while we were collaborating
 | |
|       resetBrowserStateVersions();
 | |
| 
 | |
|       window.history.pushState({}, APP_NAME, window.location.origin);
 | |
|       this.destroySocketClient();
 | |
| 
 | |
|       LocalData.fileStorage.reset();
 | |
| 
 | |
|       const elements = this.excalidrawAPI
 | |
|         .getSceneElementsIncludingDeleted()
 | |
|         .map((element) => {
 | |
|           if (isImageElement(element) && element.status === "saved") {
 | |
|             return newElementWith(element, { status: "pending" });
 | |
|           }
 | |
|           return element;
 | |
|         });
 | |
| 
 | |
|       this.excalidrawAPI.updateScene({
 | |
|         elements,
 | |
|         commitToHistory: false,
 | |
|       });
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   private destroySocketClient = (opts?: { isUnload: boolean }) => {
 | |
|     this.lastBroadcastedOrReceivedSceneVersion = -1;
 | |
|     this.portal.close();
 | |
|     this.fileManager.reset();
 | |
|     if (!opts?.isUnload) {
 | |
|       this.setIsCollaborating(false);
 | |
|       this.setActiveRoomLink(null);
 | |
|       this.collaborators = new Map();
 | |
|       this.excalidrawAPI.updateScene({
 | |
|         collaborators: this.collaborators,
 | |
|       });
 | |
|       LocalData.resumeSave("collaboration");
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   private fetchImageFilesFromFirebase = async (opts: {
 | |
|     elements: readonly ExcalidrawElement[];
 | |
|     /**
 | |
|      * Indicates whether to fetch files that are errored or pending and older
 | |
|      * than 10 seconds.
 | |
|      *
 | |
|      * Use this as a mechanism to fetch files which may be ok but for some
 | |
|      * reason their status was not updated correctly.
 | |
|      */
 | |
|     forceFetchFiles?: boolean;
 | |
|   }) => {
 | |
|     const unfetchedImages = opts.elements
 | |
|       .filter((element) => {
 | |
|         return (
 | |
|           isInitializedImageElement(element) &&
 | |
|           !this.fileManager.isFileHandled(element.fileId) &&
 | |
|           !element.isDeleted &&
 | |
|           (opts.forceFetchFiles
 | |
|             ? element.status !== "pending" ||
 | |
|               Date.now() - element.updated > 10000
 | |
|             : element.status === "saved")
 | |
|         );
 | |
|       })
 | |
|       .map((element) => (element as InitializedExcalidrawImageElement).fileId);
 | |
| 
 | |
|     return await this.fileManager.getFiles(unfetchedImages);
 | |
|   };
 | |
| 
 | |
|   private decryptPayload = async (
 | |
|     iv: Uint8Array,
 | |
|     encryptedData: ArrayBuffer,
 | |
|     decryptionKey: string,
 | |
|   ): Promise<ValueOf<SocketUpdateDataSource>> => {
 | |
|     try {
 | |
|       const decrypted = await decryptData(iv, encryptedData, decryptionKey);
 | |
| 
 | |
|       const decodedData = new TextDecoder("utf-8").decode(
 | |
|         new Uint8Array(decrypted),
 | |
|       );
 | |
|       return JSON.parse(decodedData);
 | |
|     } catch (error) {
 | |
|       window.alert(t("alerts.decryptFailed"));
 | |
|       console.error(error);
 | |
|       return {
 | |
|         type: WS_SUBTYPES.INVALID_RESPONSE,
 | |
|       };
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   private fallbackInitializationHandler: null | (() => any) = null;
 | |
| 
 | |
|   startCollaboration = async (
 | |
|     existingRoomLinkData: null | { roomId: string; roomKey: string },
 | |
|   ) => {
 | |
|     if (!this.state.username) {
 | |
|       import("@excalidraw/random-username").then(({ getRandomUsername }) => {
 | |
|         const username = getRandomUsername();
 | |
|         this.setUsername(username);
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     if (this.portal.socket) {
 | |
|       return null;
 | |
|     }
 | |
| 
 | |
|     let roomId;
 | |
|     let roomKey;
 | |
| 
 | |
|     if (existingRoomLinkData) {
 | |
|       ({ roomId, roomKey } = existingRoomLinkData);
 | |
|     } else {
 | |
|       ({ roomId, roomKey } = await generateCollaborationLinkData());
 | |
|       window.history.pushState(
 | |
|         {},
 | |
|         APP_NAME,
 | |
|         getCollaborationLink({ roomId, roomKey }),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     // TODO: `ImportedDataState` type here seems abused
 | |
|     const scenePromise = resolvablePromise<
 | |
|       | (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
 | |
|       | null
 | |
|     >();
 | |
| 
 | |
|     this.setIsCollaborating(true);
 | |
|     LocalData.pauseSave("collaboration");
 | |
| 
 | |
|     const { default: socketIOClient } = await import(
 | |
|       /* webpackChunkName: "socketIoClient" */ "socket.io-client"
 | |
|     );
 | |
| 
 | |
|     const fallbackInitializationHandler = () => {
 | |
|       this.initializeRoom({
 | |
|         roomLinkData: existingRoomLinkData,
 | |
|         fetchScene: true,
 | |
|       }).then((scene) => {
 | |
|         scenePromise.resolve(scene);
 | |
|       });
 | |
|     };
 | |
|     this.fallbackInitializationHandler = fallbackInitializationHandler;
 | |
| 
 | |
|     try {
 | |
|       this.portal.socket = this.portal.open(
 | |
|         socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, {
 | |
|           transports: ["websocket", "polling"],
 | |
|         }),
 | |
|         roomId,
 | |
|         roomKey,
 | |
|       );
 | |
| 
 | |
|       this.portal.socket.once("connect_error", fallbackInitializationHandler);
 | |
|     } catch (error: any) {
 | |
|       console.error(error);
 | |
|       this.setErrorDialog(error.message);
 | |
|       return null;
 | |
|     }
 | |
| 
 | |
|     if (!existingRoomLinkData) {
 | |
|       const elements = this.excalidrawAPI.getSceneElements().map((element) => {
 | |
|         if (isImageElement(element) && element.status === "saved") {
 | |
|           return newElementWith(element, { status: "pending" });
 | |
|         }
 | |
|         return element;
 | |
|       });
 | |
|       // remove deleted elements from elements array & history to ensure we don't
 | |
|       // expose potentially sensitive user data in case user manually deletes
 | |
|       // existing elements (or clears scene), which would otherwise be persisted
 | |
|       // to database even if deleted before creating the room.
 | |
|       this.excalidrawAPI.history.clear();
 | |
|       this.excalidrawAPI.updateScene({
 | |
|         elements,
 | |
|         commitToHistory: true,
 | |
|       });
 | |
| 
 | |
|       this.saveCollabRoomToFirebase(getSyncableElements(elements));
 | |
|     }
 | |
| 
 | |
|     // fallback in case you're not alone in the room but still don't receive
 | |
|     // initial SCENE_INIT message
 | |
|     this.socketInitializationTimer = window.setTimeout(
 | |
|       fallbackInitializationHandler,
 | |
|       INITIAL_SCENE_UPDATE_TIMEOUT,
 | |
|     );
 | |
| 
 | |
|     // All socket listeners are moving to Portal
 | |
|     this.portal.socket.on(
 | |
|       "client-broadcast",
 | |
|       async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
 | |
|         if (!this.portal.roomKey) {
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         const decryptedData = await this.decryptPayload(
 | |
|           iv,
 | |
|           encryptedData,
 | |
|           this.portal.roomKey,
 | |
|         );
 | |
| 
 | |
|         switch (decryptedData.type) {
 | |
|           case WS_SUBTYPES.INVALID_RESPONSE:
 | |
|             return;
 | |
|           case WS_SUBTYPES.INIT: {
 | |
|             if (!this.portal.socketInitialized) {
 | |
|               this.initializeRoom({ fetchScene: false });
 | |
|               const remoteElements = decryptedData.payload.elements;
 | |
|               const reconciledElements =
 | |
|                 this._reconcileElements(remoteElements);
 | |
|               this.handleRemoteSceneUpdate(reconciledElements, {
 | |
|                 init: true,
 | |
|               });
 | |
|               // noop if already resolved via init from firebase
 | |
|               scenePromise.resolve({
 | |
|                 elements: reconciledElements,
 | |
|                 scrollToContent: true,
 | |
|               });
 | |
|             }
 | |
|             break;
 | |
|           }
 | |
|           case WS_SUBTYPES.UPDATE:
 | |
|             this.handleRemoteSceneUpdate(
 | |
|               this._reconcileElements(decryptedData.payload.elements),
 | |
|             );
 | |
|             break;
 | |
|           case WS_SUBTYPES.MOUSE_LOCATION: {
 | |
|             const { pointer, button, username, selectedElementIds } =
 | |
|               decryptedData.payload;
 | |
| 
 | |
|             const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
 | |
|               decryptedData.payload.socketId ||
 | |
|               // @ts-ignore legacy, see #2094 (#2097)
 | |
|               decryptedData.payload.socketID;
 | |
| 
 | |
|             this.updateCollaborator(socketId, {
 | |
|               pointer,
 | |
|               button,
 | |
|               selectedElementIds,
 | |
|               username,
 | |
|             });
 | |
| 
 | |
|             break;
 | |
|           }
 | |
| 
 | |
|           case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
 | |
|             const { sceneBounds, socketId } = decryptedData.payload;
 | |
| 
 | |
|             const appState = this.excalidrawAPI.getAppState();
 | |
| 
 | |
|             // we're not following the user
 | |
|             // (shouldn't happen, but could be late message or bug upstream)
 | |
|             if (appState.userToFollow?.socketId !== socketId) {
 | |
|               console.warn(
 | |
|                 `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
 | |
|               );
 | |
|               return;
 | |
|             }
 | |
| 
 | |
|             // cross-follow case, ignore updates in this case
 | |
|             if (
 | |
|               appState.userToFollow &&
 | |
|               appState.followedBy.has(appState.userToFollow.socketId)
 | |
|             ) {
 | |
|               return;
 | |
|             }
 | |
| 
 | |
|             this.excalidrawAPI.updateScene({
 | |
|               appState: zoomToFitBounds({
 | |
|                 appState,
 | |
|                 bounds: sceneBounds,
 | |
|                 fitToViewport: true,
 | |
|                 viewportZoomFactor: 1,
 | |
|               }).appState,
 | |
|             });
 | |
| 
 | |
|             break;
 | |
|           }
 | |
| 
 | |
|           case WS_SUBTYPES.IDLE_STATUS: {
 | |
|             const { userState, socketId, username } = decryptedData.payload;
 | |
|             this.updateCollaborator(socketId, {
 | |
|               userState,
 | |
|               username,
 | |
|             });
 | |
|             break;
 | |
|           }
 | |
| 
 | |
|           default: {
 | |
|             assertNever(decryptedData, null);
 | |
|           }
 | |
|         }
 | |
|       },
 | |
|     );
 | |
| 
 | |
|     this.portal.socket.on("first-in-room", async () => {
 | |
|       if (this.portal.socket) {
 | |
|         this.portal.socket.off("first-in-room");
 | |
|       }
 | |
|       const sceneData = await this.initializeRoom({
 | |
|         fetchScene: true,
 | |
|         roomLinkData: existingRoomLinkData,
 | |
|       });
 | |
|       scenePromise.resolve(sceneData);
 | |
|     });
 | |
| 
 | |
|     this.portal.socket.on(
 | |
|       WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
 | |
|       (followedBy: SocketId[]) => {
 | |
|         this.excalidrawAPI.updateScene({
 | |
|           appState: { followedBy: new Set(followedBy) },
 | |
|         });
 | |
| 
 | |
|         this.relayVisibleSceneBounds({ force: true });
 | |
|       },
 | |
|     );
 | |
| 
 | |
|     this.initializeIdleDetector();
 | |
| 
 | |
|     this.setActiveRoomLink(window.location.href);
 | |
| 
 | |
|     return scenePromise;
 | |
|   };
 | |
| 
 | |
|   private initializeRoom = async ({
 | |
|     fetchScene,
 | |
|     roomLinkData,
 | |
|   }:
 | |
|     | {
 | |
|         fetchScene: true;
 | |
|         roomLinkData: { roomId: string; roomKey: string } | null;
 | |
|       }
 | |
|     | { fetchScene: false; roomLinkData?: null }) => {
 | |
|     clearTimeout(this.socketInitializationTimer!);
 | |
|     if (this.portal.socket && this.fallbackInitializationHandler) {
 | |
|       this.portal.socket.off(
 | |
|         "connect_error",
 | |
|         this.fallbackInitializationHandler,
 | |
|       );
 | |
|     }
 | |
|     if (fetchScene && roomLinkData && this.portal.socket) {
 | |
|       this.excalidrawAPI.resetScene();
 | |
| 
 | |
|       try {
 | |
|         const elements = await loadFromFirebase(
 | |
|           roomLinkData.roomId,
 | |
|           roomLinkData.roomKey,
 | |
|           this.portal.socket,
 | |
|         );
 | |
|         if (elements) {
 | |
|           this.setLastBroadcastedOrReceivedSceneVersion(
 | |
|             getSceneVersion(elements),
 | |
|           );
 | |
| 
 | |
|           return {
 | |
|             elements,
 | |
|             scrollToContent: true,
 | |
|           };
 | |
|         }
 | |
|       } catch (error: any) {
 | |
|         // log the error and move on. other peers will sync us the scene.
 | |
|         console.error(error);
 | |
|       } finally {
 | |
|         this.portal.socketInitialized = true;
 | |
|       }
 | |
|     } else {
 | |
|       this.portal.socketInitialized = true;
 | |
|     }
 | |
|     return null;
 | |
|   };
 | |
| 
 | |
|   private _reconcileElements = (
 | |
|     remoteElements: readonly ExcalidrawElement[],
 | |
|   ): ReconciledExcalidrawElement[] => {
 | |
|     const localElements = this.getSceneElementsIncludingDeleted();
 | |
|     const appState = this.excalidrawAPI.getAppState();
 | |
|     const restoredRemoteElements = restoreElements(remoteElements, null);
 | |
|     const reconciledElements = reconcileElements(
 | |
|       localElements,
 | |
|       restoredRemoteElements as RemoteExcalidrawElement[],
 | |
|       appState,
 | |
|     );
 | |
| 
 | |
|     // 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
 | |
|     // synchronously calls render.
 | |
|     this.setLastBroadcastedOrReceivedSceneVersion(
 | |
|       getSceneVersion(reconciledElements),
 | |
|     );
 | |
| 
 | |
|     return reconciledElements;
 | |
|   };
 | |
| 
 | |
|   private loadImageFiles = throttle(async () => {
 | |
|     const { loadedFiles, erroredFiles } =
 | |
|       await this.fetchImageFilesFromFirebase({
 | |
|         elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|       });
 | |
| 
 | |
|     this.excalidrawAPI.addFiles(loadedFiles);
 | |
| 
 | |
|     updateStaleImageStatuses({
 | |
|       excalidrawAPI: this.excalidrawAPI,
 | |
|       erroredFiles,
 | |
|       elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|     });
 | |
|   }, LOAD_IMAGES_TIMEOUT);
 | |
| 
 | |
|   private handleRemoteSceneUpdate = (
 | |
|     elements: ReconciledExcalidrawElement[],
 | |
|     { init = false }: { init?: boolean } = {},
 | |
|   ) => {
 | |
|     this.excalidrawAPI.updateScene({
 | |
|       elements,
 | |
|       commitToHistory: !!init,
 | |
|     });
 | |
| 
 | |
|     // 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.
 | |
|     this.excalidrawAPI.history.clear();
 | |
| 
 | |
|     this.loadImageFiles();
 | |
|   };
 | |
| 
 | |
|   private onPointerMove = () => {
 | |
|     if (this.idleTimeoutId) {
 | |
|       window.clearTimeout(this.idleTimeoutId);
 | |
|       this.idleTimeoutId = null;
 | |
|     }
 | |
| 
 | |
|     this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
 | |
| 
 | |
|     if (!this.activeIntervalId) {
 | |
|       this.activeIntervalId = window.setInterval(
 | |
|         this.reportActive,
 | |
|         ACTIVE_THRESHOLD,
 | |
|       );
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   private onVisibilityChange = () => {
 | |
|     if (document.hidden) {
 | |
|       if (this.idleTimeoutId) {
 | |
|         window.clearTimeout(this.idleTimeoutId);
 | |
|         this.idleTimeoutId = null;
 | |
|       }
 | |
|       if (this.activeIntervalId) {
 | |
|         window.clearInterval(this.activeIntervalId);
 | |
|         this.activeIntervalId = null;
 | |
|       }
 | |
|       this.onIdleStateChange(UserIdleState.AWAY);
 | |
|     } else {
 | |
|       this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
 | |
|       this.activeIntervalId = window.setInterval(
 | |
|         this.reportActive,
 | |
|         ACTIVE_THRESHOLD,
 | |
|       );
 | |
|       this.onIdleStateChange(UserIdleState.ACTIVE);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   private reportIdle = () => {
 | |
|     this.onIdleStateChange(UserIdleState.IDLE);
 | |
|     if (this.activeIntervalId) {
 | |
|       window.clearInterval(this.activeIntervalId);
 | |
|       this.activeIntervalId = null;
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   private reportActive = () => {
 | |
|     this.onIdleStateChange(UserIdleState.ACTIVE);
 | |
|   };
 | |
| 
 | |
|   private initializeIdleDetector = () => {
 | |
|     document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
 | |
|     document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
 | |
|   };
 | |
| 
 | |
|   setCollaborators(sockets: SocketId[]) {
 | |
|     const collaborators: InstanceType<typeof Collab>["collaborators"] =
 | |
|       new Map();
 | |
|     for (const socketId of sockets) {
 | |
|       collaborators.set(
 | |
|         socketId,
 | |
|         Object.assign({}, this.collaborators.get(socketId), {
 | |
|           isCurrentUser: socketId === this.portal.socket?.id,
 | |
|         }),
 | |
|       );
 | |
|     }
 | |
|     this.collaborators = collaborators;
 | |
|     this.excalidrawAPI.updateScene({ collaborators });
 | |
|   }
 | |
| 
 | |
|   updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
 | |
|     const collaborators = new Map(this.collaborators);
 | |
|     const user: Mutable<Collaborator> = Object.assign(
 | |
|       {},
 | |
|       collaborators.get(socketId),
 | |
|       updates,
 | |
|       {
 | |
|         isCurrentUser: socketId === this.portal.socket?.id,
 | |
|       },
 | |
|     );
 | |
|     collaborators.set(socketId, user);
 | |
|     this.collaborators = collaborators;
 | |
| 
 | |
|     this.excalidrawAPI.updateScene({
 | |
|       collaborators,
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
 | |
|     this.lastBroadcastedOrReceivedSceneVersion = version;
 | |
|   };
 | |
| 
 | |
|   public getLastBroadcastedOrReceivedSceneVersion = () => {
 | |
|     return this.lastBroadcastedOrReceivedSceneVersion;
 | |
|   };
 | |
| 
 | |
|   public getSceneElementsIncludingDeleted = () => {
 | |
|     return this.excalidrawAPI.getSceneElementsIncludingDeleted();
 | |
|   };
 | |
| 
 | |
|   onPointerUpdate = throttle(
 | |
|     (payload: {
 | |
|       pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
 | |
|       button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
 | |
|       pointersMap: Gesture["pointers"];
 | |
|     }) => {
 | |
|       payload.pointersMap.size < 2 &&
 | |
|         this.portal.socket &&
 | |
|         this.portal.broadcastMouseLocation(payload);
 | |
|     },
 | |
|     CURSOR_SYNC_TIMEOUT,
 | |
|   );
 | |
| 
 | |
|   relayVisibleSceneBounds = (props?: { force: boolean }) => {
 | |
|     const appState = this.excalidrawAPI.getAppState();
 | |
| 
 | |
|     if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
 | |
|       this.portal.broadcastVisibleSceneBounds(
 | |
|         {
 | |
|           sceneBounds: getVisibleSceneBounds(appState),
 | |
|         },
 | |
|         `follow@${this.portal.socket.id}`,
 | |
|       );
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   onIdleStateChange = (userState: UserIdleState) => {
 | |
|     this.portal.broadcastIdleChange(userState);
 | |
|   };
 | |
| 
 | |
|   broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
 | |
|     if (
 | |
|       getSceneVersion(elements) >
 | |
|       this.getLastBroadcastedOrReceivedSceneVersion()
 | |
|     ) {
 | |
|       this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false);
 | |
|       this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
 | |
|       this.queueBroadcastAllElements();
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
 | |
|     this.broadcastElements(elements);
 | |
|     this.queueSaveToFirebase();
 | |
|   };
 | |
| 
 | |
|   queueBroadcastAllElements = throttle(() => {
 | |
|     this.portal.broadcastScene(
 | |
|       WS_SUBTYPES.UPDATE,
 | |
|       this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|       true,
 | |
|     );
 | |
|     const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
 | |
|     const newVersion = Math.max(
 | |
|       currentVersion,
 | |
|       getSceneVersion(this.getSceneElementsIncludingDeleted()),
 | |
|     );
 | |
|     this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
 | |
|   }, SYNC_FULL_SCENE_INTERVAL_MS);
 | |
| 
 | |
|   queueSaveToFirebase = throttle(
 | |
|     () => {
 | |
|       if (this.portal.socketInitialized) {
 | |
|         this.saveCollabRoomToFirebase(
 | |
|           getSyncableElements(
 | |
|             this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|           ),
 | |
|         );
 | |
|       }
 | |
|     },
 | |
|     SYNC_FULL_SCENE_INTERVAL_MS,
 | |
|     { leading: false },
 | |
|   );
 | |
| 
 | |
|   setUsername = (username: string) => {
 | |
|     this.setState({ username });
 | |
|     saveUsernameToLocalStorage(username);
 | |
|   };
 | |
| 
 | |
|   getUsername = () => this.state.username;
 | |
| 
 | |
|   setActiveRoomLink = (activeRoomLink: string | null) => {
 | |
|     this.setState({ activeRoomLink });
 | |
|     appJotaiStore.set(activeRoomLinkAtom, activeRoomLink);
 | |
|   };
 | |
| 
 | |
|   getActiveRoomLink = () => this.state.activeRoomLink;
 | |
| 
 | |
|   setErrorIndicator = (errorMessage: string | null) => {
 | |
|     appJotaiStore.set(collabErrorIndicatorAtom, {
 | |
|       message: errorMessage,
 | |
|       nonce: Date.now(),
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
 | |
|     appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
 | |
|     if (resetDialogNotifiedErrors) {
 | |
|       this.setState({
 | |
|         dialogNotifiedErrors: {},
 | |
|       });
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   setErrorDialog = (errorMessage: string | null) => {
 | |
|     this.setState({
 | |
|       errorMessage,
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   render() {
 | |
|     const { errorMessage } = this.state;
 | |
| 
 | |
|     return (
 | |
|       <>
 | |
|         {errorMessage != null && (
 | |
|           <ErrorDialog onClose={() => this.setErrorDialog(null)}>
 | |
|             {errorMessage}
 | |
|           </ErrorDialog>
 | |
|         )}
 | |
|       </>
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| declare global {
 | |
|   interface Window {
 | |
|     collab: InstanceType<typeof Collab>;
 | |
|   }
 | |
| }
 | |
| 
 | |
| if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
 | |
|   window.collab = window.collab || ({} as Window["collab"]);
 | |
| }
 | |
| 
 | |
| export default Collab;
 | |
| 
 | |
| export type TCollabClass = Collab;
 |