1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-10 11:35:52 +01:00
excalidraw/excalidraw-app/collab/Collab.tsx
Aakansha Doshi 1ed53b153c
build: enable consistent type imports eslint rule (#7992)
* build: enable consistent type imports eslint rule

* change to warn

* fix the warning in example and excalidraw-app

* fix packages

* enable type annotations and throw error for the rule
2024-05-08 14:21:50 +05:30

994 lines
29 KiB
TypeScript

import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
ExcalidrawImperativeAPI,
SocketId,
} from "../../packages/excalidraw/types";
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
ExcalidrawElement,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
StoreAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
reconcileElements,
} from "../../packages/excalidraw";
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
import {
assertNever,
preventUnload,
resolvablePromise,
throttleRAF,
} from "../../packages/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 "../../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 { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "../../packages/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();
}
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;
}
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,
storeAction: StoreAction.UPDATE,
});
}
};
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.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,
): 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,
storeAction: StoreAction.UPDATE,
});
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,
storeAction: StoreAction.UPDATE,
});
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;