123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023 |
- 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/bounds";
- import { newElementWith } from "@excalidraw/element/mutateElement";
- import {
- isImageElement,
- isInitializedImageElement,
- } from "@excalidraw/element/typeChecks";
- 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);
- 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 (isTestEnv() || isDevEnv()) {
- window.collab = window.collab || ({} as Window["collab"]);
- }
- export default Collab;
- export type TCollabClass = Collab;
|