
Introducing independent change detection for appState and elements Generalizing object change, cleanup, refactoring, comments, solving typing issues Shaping increment, change, delta hierarchy Structural clone of elements Introducing store and incremental API Disabling buttons for canvas actions, smaller store and changes improvements Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands Solving concurrency issues, solving (partly) linear element issues, introducing commitToStore breaking change Fixing existing tests, updating snapshots Trying to be smarter on the appstate change detection Extending collab test, refactoring action / updateScene params, bugfixes Resetting snapshots Resetting snapshots UI / API tests for history - WIP Changing actions related to the observed appstate to at least update the store snapshot - WIP Adding skipping of snapshot update flag for most no-breaking changes compatible solution Ignoring uncomitted elements from local async actions, updating store directly in updateScene Bound element issues - WIP
857 lines
25 KiB
TypeScript
857 lines
25 KiB
TypeScript
import throttle from "lodash.throttle";
|
|
import { PureComponent } from "react";
|
|
import { ExcalidrawImperativeAPI } from "../../src/types";
|
|
import { ErrorDialog } from "../../src/components/ErrorDialog";
|
|
import { APP_NAME, ENV, EVENT } from "../../src/constants";
|
|
import { ImportedDataState } from "../../src/data/types";
|
|
import {
|
|
ExcalidrawElement,
|
|
InitializedExcalidrawImageElement,
|
|
} from "../../src/element/types";
|
|
import {
|
|
getSceneVersion,
|
|
restoreElements,
|
|
} from "../../src/packages/excalidraw/index";
|
|
import { Collaborator, Gesture } from "../../src/types";
|
|
import {
|
|
preventUnload,
|
|
resolvablePromise,
|
|
withBatchedUpdates,
|
|
} from "../../src/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 "../../src/i18n";
|
|
import { UserIdleState } from "../../src/types";
|
|
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../src/constants";
|
|
import {
|
|
encodeFilesForUpload,
|
|
FileManager,
|
|
updateStaleImageStatuses,
|
|
} from "../data/FileManager";
|
|
import { AbortError } from "../../src/errors";
|
|
import {
|
|
isImageElement,
|
|
isInitializedImageElement,
|
|
} from "../../src/element/typeChecks";
|
|
import { newElementWith } from "../../src/element/mutateElement";
|
|
import {
|
|
ReconciledElements,
|
|
reconcileElements as _reconcileElements,
|
|
} from "./reconciliation";
|
|
import { decryptData } from "../../src/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,
|
|
});
|
|
}
|
|
};
|
|
|
|
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 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,
|
|
});
|
|
|
|
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);
|
|
// 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) => {
|
|
this.excalidrawAPI.updateScene({
|
|
elements,
|
|
});
|
|
|
|
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;
|