summaryrefslogtreecommitdiffstats
path: root/excalidraw-app/collab
diff options
context:
space:
mode:
Diffstat (limited to 'excalidraw-app/collab')
-rw-r--r--excalidraw-app/collab/Collab.tsx1018
-rw-r--r--excalidraw-app/collab/CollabError.scss35
-rw-r--r--excalidraw-app/collab/CollabError.tsx54
-rw-r--r--excalidraw-app/collab/Portal.tsx256
4 files changed, 1363 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;
diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss
new file mode 100644
index 0000000..bb774d0
--- /dev/null
+++ b/excalidraw-app/collab/CollabError.scss
@@ -0,0 +1,35 @@
+@import "../../packages/excalidraw/css/variables.module.scss";
+
+.excalidraw {
+ .collab-errors-button {
+ width: 26px;
+ height: 26px;
+ margin-inline-end: 1rem;
+
+ color: var(--color-danger);
+
+ flex-shrink: 0;
+ }
+
+ .collab-errors-button-shake {
+ animation: strong-shake 0.15s 6;
+ }
+
+ @keyframes strong-shake {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 25% {
+ transform: rotate(10deg);
+ }
+ 50% {
+ transform: rotate(0deg);
+ }
+ 75% {
+ transform: rotate(-10deg);
+ }
+ 100% {
+ transform: rotate(0deg);
+ }
+ }
+}
diff --git a/excalidraw-app/collab/CollabError.tsx b/excalidraw-app/collab/CollabError.tsx
new file mode 100644
index 0000000..76828cf
--- /dev/null
+++ b/excalidraw-app/collab/CollabError.tsx
@@ -0,0 +1,54 @@
+import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
+import { warning } from "@excalidraw/excalidraw/components/icons";
+import clsx from "clsx";
+import { useEffect, useRef, useState } from "react";
+import { atom } from "../app-jotai";
+
+import "./CollabError.scss";
+
+type ErrorIndicator = {
+ message: string | null;
+ /** used to rerun the useEffect responsible for animation */
+ nonce: number;
+};
+
+export const collabErrorIndicatorAtom = atom<ErrorIndicator>({
+ message: null,
+ nonce: 0,
+});
+
+const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
+ const [isAnimating, setIsAnimating] = useState(false);
+ const clearAnimationRef = useRef<string | number>(0);
+
+ useEffect(() => {
+ setIsAnimating(true);
+ clearAnimationRef.current = window.setTimeout(() => {
+ setIsAnimating(false);
+ }, 1000);
+
+ return () => {
+ window.clearTimeout(clearAnimationRef.current);
+ };
+ }, [collabError.message, collabError.nonce]);
+
+ if (!collabError.message) {
+ return null;
+ }
+
+ return (
+ <Tooltip label={collabError.message} long={true}>
+ <div
+ className={clsx("collab-errors-button", {
+ "collab-errors-button-shake": isAnimating,
+ })}
+ >
+ {warning}
+ </div>
+ </Tooltip>
+ );
+};
+
+CollabError.displayName = "CollabError";
+
+export default CollabError;
diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx
new file mode 100644
index 0000000..1788d39
--- /dev/null
+++ b/excalidraw-app/collab/Portal.tsx
@@ -0,0 +1,256 @@
+import type {
+ SocketUpdateData,
+ SocketUpdateDataSource,
+ SyncableExcalidrawElement,
+} from "../data";
+import { isSyncableElement } from "../data";
+
+import type { TCollabClass } from "./Collab";
+
+import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
+import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
+import type {
+ OnUserFollowedPayload,
+ SocketId,
+} from "@excalidraw/excalidraw/types";
+import type { UserIdleState } from "@excalidraw/excalidraw/constants";
+import { trackEvent } from "@excalidraw/excalidraw/analytics";
+import throttle from "lodash.throttle";
+import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
+import { encryptData } from "@excalidraw/excalidraw/data/encryption";
+import type { Socket } from "socket.io-client";
+import { CaptureUpdateAction } from "@excalidraw/excalidraw";
+
+class Portal {
+ collab: TCollabClass;
+ socket: Socket | null = null;
+ socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
+ roomId: string | null = null;
+ roomKey: string | null = null;
+ broadcastedElementVersions: Map<string, number> = new Map();
+
+ constructor(collab: TCollabClass) {
+ this.collab = collab;
+ }
+
+ open(socket: Socket, id: string, key: string) {
+ this.socket = socket;
+ this.roomId = id;
+ this.roomKey = key;
+
+ // Initialize socket listeners
+ this.socket.on("init-room", () => {
+ if (this.socket) {
+ this.socket.emit("join-room", this.roomId);
+ trackEvent("share", "room joined");
+ }
+ });
+ this.socket.on("new-user", async (_socketId: string) => {
+ this.broadcastScene(
+ WS_SUBTYPES.INIT,
+ this.collab.getSceneElementsIncludingDeleted(),
+ /* syncAll */ true,
+ );
+ });
+ this.socket.on("room-user-change", (clients: SocketId[]) => {
+ this.collab.setCollaborators(clients);
+ });
+
+ return socket;
+ }
+
+ close() {
+ if (!this.socket) {
+ return;
+ }
+ this.queueFileUpload.flush();
+ this.socket.close();
+ this.socket = null;
+ this.roomId = null;
+ this.roomKey = null;
+ this.socketInitialized = false;
+ this.broadcastedElementVersions = new Map();
+ }
+
+ isOpen() {
+ return !!(
+ this.socketInitialized &&
+ this.socket &&
+ this.roomId &&
+ this.roomKey
+ );
+ }
+
+ async _broadcastSocketData(
+ data: SocketUpdateData,
+ volatile: boolean = false,
+ roomId?: string,
+ ) {
+ if (this.isOpen()) {
+ const json = JSON.stringify(data);
+ const encoded = new TextEncoder().encode(json);
+ const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
+
+ this.socket?.emit(
+ volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
+ roomId ?? this.roomId,
+ encryptedBuffer,
+ iv,
+ );
+ }
+ }
+
+ queueFileUpload = throttle(async () => {
+ try {
+ await this.collab.fileManager.saveFiles({
+ elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ files: this.collab.excalidrawAPI.getFiles(),
+ });
+ } catch (error: any) {
+ if (error.name !== "AbortError") {
+ this.collab.excalidrawAPI.updateScene({
+ appState: {
+ errorMessage: error.message,
+ },
+ });
+ }
+ }
+
+ let isChanged = false;
+ const newElements = this.collab.excalidrawAPI
+ .getSceneElementsIncludingDeleted()
+ .map((element) => {
+ if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
+ isChanged = true;
+ // this will signal collaborators to pull image data from server
+ // (using mutation instead of newElementWith otherwise it'd break
+ // in-progress dragging)
+ return newElementWith(element, { status: "saved" });
+ }
+ return element;
+ });
+
+ if (isChanged) {
+ this.collab.excalidrawAPI.updateScene({
+ elements: newElements,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+ }
+ }, FILE_UPLOAD_TIMEOUT);
+
+ broadcastScene = async (
+ updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
+ elements: readonly OrderedExcalidrawElement[],
+ syncAll: boolean,
+ ) => {
+ if (updateType === WS_SUBTYPES.INIT && !syncAll) {
+ throw new Error("syncAll must be true when sending SCENE.INIT");
+ }
+
+ // sync out only the elements we think we need to to save bandwidth.
+ // periodically we'll resync the whole thing to make sure no one diverges
+ // due to a dropped message (server goes down etc).
+ const syncableElements = elements.reduce((acc, element) => {
+ if (
+ (syncAll ||
+ !this.broadcastedElementVersions.has(element.id) ||
+ element.version > this.broadcastedElementVersions.get(element.id)!) &&
+ isSyncableElement(element)
+ ) {
+ acc.push(element);
+ }
+ return acc;
+ }, [] as SyncableExcalidrawElement[]);
+
+ const data: SocketUpdateDataSource[typeof updateType] = {
+ type: updateType,
+ payload: {
+ elements: syncableElements,
+ },
+ };
+
+ for (const syncableElement of syncableElements) {
+ this.broadcastedElementVersions.set(
+ syncableElement.id,
+ syncableElement.version,
+ );
+ }
+
+ this.queueFileUpload();
+
+ await this._broadcastSocketData(data as SocketUpdateData);
+ };
+
+ broadcastIdleChange = (userState: UserIdleState) => {
+ if (this.socket?.id) {
+ const data: SocketUpdateDataSource["IDLE_STATUS"] = {
+ type: WS_SUBTYPES.IDLE_STATUS,
+ payload: {
+ socketId: this.socket.id as SocketId,
+ userState,
+ username: this.collab.state.username,
+ },
+ };
+ return this._broadcastSocketData(
+ data as SocketUpdateData,
+ true, // volatile
+ );
+ }
+ };
+
+ broadcastMouseLocation = (payload: {
+ pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
+ button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
+ }) => {
+ if (this.socket?.id) {
+ const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
+ type: WS_SUBTYPES.MOUSE_LOCATION,
+ payload: {
+ socketId: this.socket.id as SocketId,
+ pointer: payload.pointer,
+ button: payload.button || "up",
+ selectedElementIds:
+ this.collab.excalidrawAPI.getAppState().selectedElementIds,
+ username: this.collab.state.username,
+ },
+ };
+
+ return this._broadcastSocketData(
+ data as SocketUpdateData,
+ true, // volatile
+ );
+ }
+ };
+
+ broadcastVisibleSceneBounds = (
+ payload: {
+ sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
+ },
+ roomId: string,
+ ) => {
+ if (this.socket?.id) {
+ const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
+ type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
+ payload: {
+ socketId: this.socket.id as SocketId,
+ username: this.collab.state.username,
+ sceneBounds: payload.sceneBounds,
+ },
+ };
+
+ return this._broadcastSocketData(
+ data as SocketUpdateData,
+ true, // volatile
+ roomId,
+ );
+ }
+ };
+
+ broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
+ if (this.socket?.id) {
+ this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
+ }
+ };
+}
+
+export default Portal;