123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874 |
- import throttle from "lodash.throttle";
- import { PureComponent } from "react";
- import { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
- import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
- import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
- import { ImportedDataState } from "../../packages/excalidraw/data/types";
- import {
- ExcalidrawElement,
- InitializedExcalidrawImageElement,
- } from "../../packages/excalidraw/element/types";
- import {
- getSceneVersion,
- restoreElements,
- } from "../../packages/excalidraw/index";
- import { Collaborator, Gesture } from "../../packages/excalidraw/types";
- import {
- preventUnload,
- resolvablePromise,
- withBatchedUpdates,
- } from "../../packages/excalidraw/utils";
- import {
- CURSOR_SYNC_TIMEOUT,
- FILE_UPLOAD_MAX_BYTES,
- FIREBASE_STORAGE_PREFIXES,
- INITIAL_SCENE_UPDATE_TIMEOUT,
- LOAD_IMAGES_TIMEOUT,
- WS_SCENE_EVENT_TYPES,
- SYNC_FULL_SCENE_INTERVAL_MS,
- } from "../app_constants";
- import {
- generateCollaborationLinkData,
- getCollaborationLink,
- getCollabServer,
- getSyncableElements,
- SocketUpdateDataSource,
- SyncableExcalidrawElement,
- } from "../data";
- import {
- isSavedToFirebase,
- loadFilesFromFirebase,
- loadFromFirebase,
- saveFilesToFirebase,
- saveToFirebase,
- } from "../data/firebase";
- import {
- importUsernameFromLocalStorage,
- saveUsernameToLocalStorage,
- } from "../data/localStorage";
- import Portal from "./Portal";
- import RoomDialog from "./RoomDialog";
- import { t } from "../../packages/excalidraw/i18n";
- import { UserIdleState } from "../../packages/excalidraw/types";
- import {
- IDLE_THRESHOLD,
- ACTIVE_THRESHOLD,
- } from "../../packages/excalidraw/constants";
- import {
- encodeFilesForUpload,
- FileManager,
- updateStaleImageStatuses,
- } from "../data/FileManager";
- import { AbortError } from "../../packages/excalidraw/errors";
- import {
- isImageElement,
- isInitializedImageElement,
- } from "../../packages/excalidraw/element/typeChecks";
- import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
- import {
- ReconciledElements,
- reconcileElements as _reconcileElements,
- } from "./reconciliation";
- import { decryptData } from "../../packages/excalidraw/data/encryption";
- import { resetBrowserStateVersions } from "../data/tabSync";
- import { LocalData } from "../data/LocalData";
- import { atom, useAtom } from "jotai";
- import { appJotaiStore } from "../app-jotai";
- export const collabAPIAtom = atom<CollabAPI | null>(null);
- export const collabDialogShownAtom = atom(false);
- export const isCollaboratingAtom = atom(false);
- export const isOfflineAtom = atom(false);
- interface CollabState {
- errorMessage: string;
- username: string;
- activeRoomLink: string;
- }
- 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: (username: string) => void;
- }
- interface PublicProps {
- excalidrawAPI: ExcalidrawImperativeAPI;
- }
- type Props = PublicProps & { modalIsShown: boolean };
- class Collab extends PureComponent<Props, CollabState> {
- portal: Portal;
- fileManager: FileManager;
- excalidrawAPI: Props["excalidrawAPI"];
- activeIntervalId: number | null;
- idleTimeoutId: number | null;
- private socketInitializationTimer?: number;
- private lastBroadcastedOrReceivedSceneVersion: number = -1;
- private collaborators = new Map<string, Collaborator>();
- constructor(props: Props) {
- super(props);
- this.state = {
- errorMessage: "",
- username: importUsernameFromLocalStorage() || "",
- activeRoomLink: "",
- };
- this.portal = new Portal(this);
- this.fileManager = new FileManager({
- getFiles: async (fileIds) => {
- const { roomId, roomKey } = this.portal;
- if (!roomId || !roomKey) {
- throw new AbortError();
- }
- return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
- },
- saveFiles: async ({ addedFiles }) => {
- const { roomId, roomKey } = this.portal;
- if (!roomId || !roomKey) {
- throw new AbortError();
- }
- return saveFilesToFirebase({
- prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
- files: await encodeFilesForUpload({
- files: addedFiles,
- encryptionKey: roomKey,
- maxBytes: FILE_UPLOAD_MAX_BYTES,
- }),
- });
- },
- });
- this.excalidrawAPI = props.excalidrawAPI;
- this.activeIntervalId = null;
- this.idleTimeoutId = null;
- }
- componentDidMount() {
- window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
- window.addEventListener("online", this.onOfflineStatusToggle);
- window.addEventListener("offline", this.onOfflineStatusToggle);
- window.addEventListener(EVENT.UNLOAD, this.onUnload);
- 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,
- };
- 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;
- }
- }
- 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 savedData = await saveToFirebase(
- this.portal,
- syncableElements,
- this.excalidrawAPI.getAppState(),
- );
- if (this.isCollaborating() && savedData && savedData.reconciledElements) {
- this.handleRemoteSceneUpdate(
- this.reconcileElements(savedData.reconciledElements),
- );
- }
- } catch (error: any) {
- this.setState({
- // firestore doesn't return a specific error code when size exceeded
- errorMessage: /is longer than.*?bytes/.test(error.message)
- ? t("errors.collabSaveFailed_sizeExceeded")
- : t("errors.collabSaveFailed"),
- });
- console.error(error);
- }
- };
- stopCollaboration = (keepRemoteState = true) => {
- this.queueBroadcastAllElements.cancel();
- this.queueSaveToFirebase.cancel();
- this.loadImageFiles.cancel();
- this.saveCollabRoomToFirebase(
- getSyncableElements(
- this.excalidrawAPI.getSceneElementsIncludingDeleted(),
- ),
- );
- if (this.portal.socket && this.fallbackInitializationHandler) {
- this.portal.socket.off(
- "connect_error",
- this.fallbackInitializationHandler,
- );
- }
- if (!keepRemoteState) {
- LocalData.fileStorage.reset();
- this.destroySocketClient();
- } else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
- // hack to ensure that we prefer we disregard any new browser state
- // that could have been saved in other tabs while we were collaborating
- resetBrowserStateVersions();
- window.history.pushState({}, APP_NAME, window.location.origin);
- this.destroySocketClient();
- LocalData.fileStorage.reset();
- const elements = this.excalidrawAPI
- .getSceneElementsIncludingDeleted()
- .map((element) => {
- if (isImageElement(element) && element.status === "saved") {
- return newElementWith(element, { status: "pending" });
- }
- return element;
- });
- this.excalidrawAPI.updateScene({
- elements,
- commitToHistory: false,
- });
- }
- };
- private destroySocketClient = (opts?: { isUnload: boolean }) => {
- this.lastBroadcastedOrReceivedSceneVersion = -1;
- this.portal.close();
- this.fileManager.reset();
- if (!opts?.isUnload) {
- this.setIsCollaborating(false);
- this.setState({
- activeRoomLink: "",
- });
- this.collaborators = new Map();
- this.excalidrawAPI.updateScene({
- collaborators: this.collaborators,
- });
- LocalData.resumeSave("collaboration");
- }
- };
- private fetchImageFilesFromFirebase = async (opts: {
- elements: readonly ExcalidrawElement[];
- /**
- * Indicates whether to fetch files that are errored or pending and older
- * than 10 seconds.
- *
- * Use this as a mechanism to fetch files which may be ok but for some
- * reason their status was not updated correctly.
- */
- forceFetchFiles?: boolean;
- }) => {
- const unfetchedImages = opts.elements
- .filter((element) => {
- return (
- isInitializedImageElement(element) &&
- !this.fileManager.isFileHandled(element.fileId) &&
- !element.isDeleted &&
- (opts.forceFetchFiles
- ? element.status !== "pending" ||
- Date.now() - element.updated > 10000
- : element.status === "saved")
- );
- })
- .map((element) => (element as InitializedExcalidrawImageElement).fileId);
- return await this.fileManager.getFiles(unfetchedImages);
- };
- private decryptPayload = async (
- iv: Uint8Array,
- encryptedData: ArrayBuffer,
- decryptionKey: string,
- ) => {
- 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: "INVALID_RESPONSE",
- };
- }
- };
- private fallbackInitializationHandler: null | (() => any) = null;
- startCollaboration = async (
- existingRoomLinkData: null | { roomId: string; roomKey: string },
- ): Promise<ImportedDataState | null> => {
- if (!this.state.username) {
- import("@excalidraw/random-username").then(({ getRandomUsername }) => {
- const username = getRandomUsername();
- this.onUsernameChange(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 }),
- );
- }
- const scenePromise = resolvablePromise<ImportedDataState | 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 {
- const socketServerData = await getCollabServer();
- this.portal.socket = this.portal.open(
- socketIOClient(socketServerData.url, {
- transports: socketServerData.polling
- ? ["websocket", "polling"]
- : ["websocket"],
- }),
- roomId,
- roomKey,
- );
- this.portal.socket.once("connect_error", fallbackInitializationHandler);
- } catch (error: any) {
- console.error(error);
- this.setState({ errorMessage: error.message });
- return null;
- }
- if (!existingRoomLinkData) {
- const elements = this.excalidrawAPI.getSceneElements().map((element) => {
- if (isImageElement(element) && element.status === "saved") {
- return newElementWith(element, { status: "pending" });
- }
- return element;
- });
- // remove deleted elements from elements array & history to ensure we don't
- // expose potentially sensitive user data in case user manually deletes
- // existing elements (or clears scene), which would otherwise be persisted
- // to database even if deleted before creating the room.
- this.excalidrawAPI.history.clear();
- this.excalidrawAPI.updateScene({
- elements,
- commitToHistory: true,
- });
- this.saveCollabRoomToFirebase(getSyncableElements(elements));
- }
- // fallback in case you're not alone in the room but still don't receive
- // initial SCENE_INIT message
- this.socketInitializationTimer = window.setTimeout(
- fallbackInitializationHandler,
- INITIAL_SCENE_UPDATE_TIMEOUT,
- );
- // All socket listeners are moving to Portal
- this.portal.socket.on(
- "client-broadcast",
- async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
- if (!this.portal.roomKey) {
- return;
- }
- const decryptedData = await this.decryptPayload(
- iv,
- encryptedData,
- this.portal.roomKey,
- );
- switch (decryptedData.type) {
- case "INVALID_RESPONSE":
- return;
- case WS_SCENE_EVENT_TYPES.INIT: {
- if (!this.portal.socketInitialized) {
- this.initializeRoom({ fetchScene: false });
- const remoteElements = decryptedData.payload.elements;
- const reconciledElements = this.reconcileElements(remoteElements);
- this.handleRemoteSceneUpdate(reconciledElements, {
- init: true,
- });
- // noop if already resolved via init from firebase
- scenePromise.resolve({
- elements: reconciledElements,
- scrollToContent: true,
- });
- }
- break;
- }
- case WS_SCENE_EVENT_TYPES.UPDATE:
- this.handleRemoteSceneUpdate(
- this.reconcileElements(decryptedData.payload.elements),
- );
- break;
- case "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;
- const collaborators = new Map(this.collaborators);
- const user = collaborators.get(socketId) || {}!;
- user.pointer = pointer;
- user.button = button;
- user.selectedElementIds = selectedElementIds;
- user.username = username;
- collaborators.set(socketId, user);
- this.excalidrawAPI.updateScene({
- collaborators,
- });
- break;
- }
- case "IDLE_STATUS": {
- const { userState, socketId, username } = decryptedData.payload;
- const collaborators = new Map(this.collaborators);
- const user = collaborators.get(socketId) || {}!;
- user.userState = userState;
- user.username = username;
- this.excalidrawAPI.updateScene({
- collaborators,
- });
- break;
- }
- }
- },
- );
- 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.initializeIdleDetector();
- this.setState({
- activeRoomLink: 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[],
- ): ReconciledElements => {
- const localElements = this.getSceneElementsIncludingDeleted();
- const appState = this.excalidrawAPI.getAppState();
- remoteElements = restoreElements(remoteElements, null);
- const reconciledElements = _reconcileElements(
- localElements,
- remoteElements,
- 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: ReconciledElements,
- { init = false }: { init?: boolean } = {},
- ) => {
- this.excalidrawAPI.updateScene({
- elements,
- commitToHistory: !!init,
- });
- // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
- // when we receive any messages from another peer. This UX can be pretty rough -- if you
- // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
- // right now we think this is the right tradeoff.
- this.excalidrawAPI.history.clear();
- this.loadImageFiles();
- };
- private onPointerMove = () => {
- if (this.idleTimeoutId) {
- window.clearTimeout(this.idleTimeoutId);
- this.idleTimeoutId = null;
- }
- this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
- if (!this.activeIntervalId) {
- this.activeIntervalId = window.setInterval(
- this.reportActive,
- ACTIVE_THRESHOLD,
- );
- }
- };
- private onVisibilityChange = () => {
- if (document.hidden) {
- if (this.idleTimeoutId) {
- window.clearTimeout(this.idleTimeoutId);
- this.idleTimeoutId = null;
- }
- if (this.activeIntervalId) {
- window.clearInterval(this.activeIntervalId);
- this.activeIntervalId = null;
- }
- this.onIdleStateChange(UserIdleState.AWAY);
- } else {
- this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
- this.activeIntervalId = window.setInterval(
- this.reportActive,
- ACTIVE_THRESHOLD,
- );
- this.onIdleStateChange(UserIdleState.ACTIVE);
- }
- };
- private reportIdle = () => {
- this.onIdleStateChange(UserIdleState.IDLE);
- if (this.activeIntervalId) {
- window.clearInterval(this.activeIntervalId);
- this.activeIntervalId = null;
- }
- };
- private reportActive = () => {
- this.onIdleStateChange(UserIdleState.ACTIVE);
- };
- private initializeIdleDetector = () => {
- document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
- document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
- };
- setCollaborators(sockets: string[]) {
- const collaborators: InstanceType<typeof Collab>["collaborators"] =
- new Map();
- for (const socketId of sockets) {
- if (this.collaborators.has(socketId)) {
- collaborators.set(socketId, this.collaborators.get(socketId)!);
- } else {
- collaborators.set(socketId, {});
- }
- }
- 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,
- );
- onIdleStateChange = (userState: UserIdleState) => {
- this.portal.broadcastIdleChange(userState);
- };
- broadcastElements = (elements: readonly ExcalidrawElement[]) => {
- if (
- getSceneVersion(elements) >
- this.getLastBroadcastedOrReceivedSceneVersion()
- ) {
- this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
- this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
- this.queueBroadcastAllElements();
- }
- };
- syncElements = (elements: readonly ExcalidrawElement[]) => {
- this.broadcastElements(elements);
- this.queueSaveToFirebase();
- };
- queueBroadcastAllElements = throttle(() => {
- this.portal.broadcastScene(
- WS_SCENE_EVENT_TYPES.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 },
- );
- handleClose = () => {
- appJotaiStore.set(collabDialogShownAtom, false);
- };
- setUsername = (username: string) => {
- this.setState({ username });
- };
- onUsernameChange = (username: string) => {
- this.setUsername(username);
- saveUsernameToLocalStorage(username);
- };
- render() {
- const { username, errorMessage, activeRoomLink } = this.state;
- const { modalIsShown } = this.props;
- return (
- <>
- {modalIsShown && (
- <RoomDialog
- handleClose={this.handleClose}
- activeRoomLink={activeRoomLink}
- username={username}
- onUsernameChange={this.onUsernameChange}
- onRoomCreate={() => this.startCollaboration(null)}
- onRoomDestroy={this.stopCollaboration}
- setErrorMessage={(errorMessage) => {
- this.setState({ errorMessage });
- }}
- />
- )}
- {errorMessage && (
- <ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
- {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"]);
- }
- const _Collab: React.FC<PublicProps> = (props) => {
- const [collabDialogShown] = useAtom(collabDialogShownAtom);
- return <Collab {...props} modalIsShown={collabDialogShown} />;
- };
- export default _Collab;
- export type TCollabClass = Collab;
|