summaryrefslogtreecommitdiffstats
path: root/excalidraw-app/collab/Collab.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'excalidraw-app/collab/Collab.tsx')
-rw-r--r--excalidraw-app/collab/Collab.tsx1018
1 files changed, 1018 insertions, 0 deletions
diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx
new file mode 100644
index 0000000..9a16e05
--- /dev/null
+++ b/excalidraw-app/collab/Collab.tsx
@@ -0,0 +1,1018 @@
+import throttle from "lodash.throttle";
+import { PureComponent } from "react";
+import type {
+ BinaryFileData,
+ ExcalidrawImperativeAPI,
+ SocketId,
+ Collaborator,
+ Gesture,
+} from "@excalidraw/excalidraw/types";
+import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
+import { APP_NAME, ENV, EVENT } from "@excalidraw/excalidraw/constants";
+import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
+import type {
+ ExcalidrawElement,
+ FileId,
+ InitializedExcalidrawImageElement,
+ OrderedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import {
+ CaptureUpdateAction,
+ getSceneVersion,
+ restoreElements,
+ zoomToFitBounds,
+ reconcileElements,
+} from "@excalidraw/excalidraw";
+import {
+ assertNever,
+ preventUnload,
+ resolvablePromise,
+ throttleRAF,
+} from "@excalidraw/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 type {
+ SocketUpdateDataSource,
+ SyncableExcalidrawElement,
+} from "../data";
+import {
+ generateCollaborationLinkData,
+ getCollaborationLink,
+ getSyncableElements,
+} from "../data";
+import {
+ isSavedToFirebase,
+ loadFilesFromFirebase,
+ loadFromFirebase,
+ saveFilesToFirebase,
+ saveToFirebase,
+} from "../data/firebase";
+import {
+ importUsernameFromLocalStorage,
+ saveUsernameToLocalStorage,
+} from "../data/localStorage";
+import Portal from "./Portal";
+import { t } from "@excalidraw/excalidraw/i18n";
+import {
+ IDLE_THRESHOLD,
+ ACTIVE_THRESHOLD,
+ UserIdleState,
+} from "@excalidraw/excalidraw/constants";
+import {
+ encodeFilesForUpload,
+ FileManager,
+ updateStaleImageStatuses,
+} from "../data/FileManager";
+import { AbortError } from "@excalidraw/excalidraw/errors";
+import {
+ isImageElement,
+ isInitializedImageElement,
+} from "@excalidraw/excalidraw/element/typeChecks";
+import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
+import { decryptData } from "@excalidraw/excalidraw/data/encryption";
+import { resetBrowserStateVersions } from "../data/tabSync";
+import { LocalData } from "../data/LocalData";
+import { appJotaiStore, atom } from "../app-jotai";
+import type { Mutable, ValueOf } from "@excalidraw/excalidraw/utility-types";
+import { getVisibleSceneBounds } from "@excalidraw/excalidraw/element/bounds";
+import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
+import { collabErrorIndicatorAtom } from "./CollabError";
+import type {
+ ReconciledExcalidrawElement,
+ RemoteExcalidrawElement,
+} from "@excalidraw/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();
+ }
+
+ 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 (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,
+ 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 (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
+ window.collab = window.collab || ({} as Window["collab"]);
+}
+
+export default Collab;
+
+export type TCollabClass = Collab;