mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			1027 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			1027 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import {
 | 
						|
  CaptureUpdateAction,
 | 
						|
  getSceneVersion,
 | 
						|
  restoreElements,
 | 
						|
  zoomToFitBounds,
 | 
						|
  reconcileElements,
 | 
						|
} from "@excalidraw/excalidraw";
 | 
						|
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
 | 
						|
import { APP_NAME, EVENT } from "@excalidraw/common";
 | 
						|
import {
 | 
						|
  IDLE_THRESHOLD,
 | 
						|
  ACTIVE_THRESHOLD,
 | 
						|
  UserIdleState,
 | 
						|
  assertNever,
 | 
						|
  isDevEnv,
 | 
						|
  isTestEnv,
 | 
						|
  preventUnload,
 | 
						|
  resolvablePromise,
 | 
						|
  throttleRAF,
 | 
						|
} from "@excalidraw/common";
 | 
						|
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
 | 
						|
import { getVisibleSceneBounds } from "@excalidraw/element";
 | 
						|
import { newElementWith } from "@excalidraw/element";
 | 
						|
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
 | 
						|
import { AbortError } from "@excalidraw/excalidraw/errors";
 | 
						|
import { t } from "@excalidraw/excalidraw/i18n";
 | 
						|
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
 | 
						|
 | 
						|
import throttle from "lodash.throttle";
 | 
						|
import { PureComponent } from "react";
 | 
						|
 | 
						|
import type {
 | 
						|
  ReconciledExcalidrawElement,
 | 
						|
  RemoteExcalidrawElement,
 | 
						|
} from "@excalidraw/excalidraw/data/reconcile";
 | 
						|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
 | 
						|
import type {
 | 
						|
  ExcalidrawElement,
 | 
						|
  FileId,
 | 
						|
  InitializedExcalidrawImageElement,
 | 
						|
  OrderedExcalidrawElement,
 | 
						|
} from "@excalidraw/element/types";
 | 
						|
import type {
 | 
						|
  BinaryFileData,
 | 
						|
  ExcalidrawImperativeAPI,
 | 
						|
  SocketId,
 | 
						|
  Collaborator,
 | 
						|
  Gesture,
 | 
						|
} from "@excalidraw/excalidraw/types";
 | 
						|
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
 | 
						|
 | 
						|
import { appJotaiStore, atom } from "../app-jotai";
 | 
						|
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,
 | 
						|
} from "../data";
 | 
						|
import {
 | 
						|
  encodeFilesForUpload,
 | 
						|
  FileManager,
 | 
						|
  updateStaleImageStatuses,
 | 
						|
} from "../data/FileManager";
 | 
						|
import { LocalData } from "../data/LocalData";
 | 
						|
import {
 | 
						|
  isSavedToFirebase,
 | 
						|
  loadFilesFromFirebase,
 | 
						|
  loadFromFirebase,
 | 
						|
  saveFilesToFirebase,
 | 
						|
  saveToFirebase,
 | 
						|
} from "../data/firebase";
 | 
						|
import {
 | 
						|
  importUsernameFromLocalStorage,
 | 
						|
  saveUsernameToLocalStorage,
 | 
						|
} from "../data/localStorage";
 | 
						|
import { resetBrowserStateVersions } from "../data/tabSync";
 | 
						|
 | 
						|
import { collabErrorIndicatorAtom } from "./CollabError";
 | 
						|
import Portal from "./Portal";
 | 
						|
 | 
						|
import type {
 | 
						|
  SocketUpdateDataSource,
 | 
						|
  SyncableExcalidrawElement,
 | 
						|
} from "../data";
 | 
						|
 | 
						|
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();
 | 
						|
        }
 | 
						|
 | 
						|
        const { savedFiles, erroredFiles } = await saveFilesToFirebase({
 | 
						|
          prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
 | 
						|
          files: await encodeFilesForUpload({
 | 
						|
            files: addedFiles,
 | 
						|
            encryptionKey: roomKey,
 | 
						|
            maxBytes: FILE_UPLOAD_MAX_BYTES,
 | 
						|
          }),
 | 
						|
        });
 | 
						|
 | 
						|
        return {
 | 
						|
          savedFiles: savedFiles.reduce(
 | 
						|
            (acc: Map<FileId, BinaryFileData>, id) => {
 | 
						|
              const fileData = addedFiles.get(id);
 | 
						|
              if (fileData) {
 | 
						|
                acc.set(id, fileData);
 | 
						|
              }
 | 
						|
              return acc;
 | 
						|
            },
 | 
						|
            new Map(),
 | 
						|
          ),
 | 
						|
          erroredFiles: erroredFiles.reduce(
 | 
						|
            (acc: Map<FileId, BinaryFileData>, id) => {
 | 
						|
              const fileData = addedFiles.get(id);
 | 
						|
              if (fileData) {
 | 
						|
                acc.set(id, fileData);
 | 
						|
              }
 | 
						|
              return acc;
 | 
						|
            },
 | 
						|
            new Map(),
 | 
						|
          ),
 | 
						|
        };
 | 
						|
      },
 | 
						|
    });
 | 
						|
    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 (isTestEnv() || isDevEnv()) {
 | 
						|
      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);
 | 
						|
 | 
						|
      if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
 | 
						|
        preventUnload(event);
 | 
						|
      } else {
 | 
						|
        console.warn(
 | 
						|
          "preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
 | 
						|
        );
 | 
						|
      }
 | 
						|
    }
 | 
						|
  });
 | 
						|
 | 
						|
  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,
 | 
						|
        captureUpdate: CaptureUpdateAction.NEVER,
 | 
						|
      });
 | 
						|
    }
 | 
						|
  };
 | 
						|
 | 
						|
  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.isFileTracked(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 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.updateScene({
 | 
						|
        elements,
 | 
						|
        captureUpdate: CaptureUpdateAction.NEVER,
 | 
						|
      });
 | 
						|
 | 
						|
      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);
 | 
						|
              // 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[],
 | 
						|
  ) => {
 | 
						|
    this.excalidrawAPI.updateScene({
 | 
						|
      elements,
 | 
						|
      captureUpdate: CaptureUpdateAction.NEVER,
 | 
						|
    });
 | 
						|
 | 
						|
    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 (isTestEnv() || isDevEnv()) {
 | 
						|
  window.collab = window.collab || ({} as Window["collab"]);
 | 
						|
}
 | 
						|
 | 
						|
export default Collab;
 | 
						|
 | 
						|
export type TCollabClass = Collab;
 |