123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569 |
- import throttle from "lodash.throttle";
- import React, { PureComponent } from "react";
- import { ExcalidrawImperativeAPI } from "../../components/App";
- import { ErrorDialog } from "../../components/ErrorDialog";
- import { APP_NAME, ENV, EVENT } from "../../constants";
- import { ImportedDataState } from "../../data/types";
- import { ExcalidrawElement } from "../../element/types";
- import {
- getElementMap,
- getSceneVersion,
- getSyncableElements,
- } from "../../packages/excalidraw/index";
- import { Collaborator, Gesture } from "../../types";
- import { resolvablePromise, withBatchedUpdates } from "../../utils";
- import {
- INITIAL_SCENE_UPDATE_TIMEOUT,
- SCENE,
- SYNC_FULL_SCENE_INTERVAL_MS,
- } from "../app_constants";
- import {
- decryptAESGEM,
- generateCollaborationLinkData,
- getCollaborationLink,
- SocketUpdateDataSource,
- SOCKET_SERVER,
- } from "../data";
- import {
- isSavedToFirebase,
- loadFromFirebase,
- saveToFirebase,
- } from "../data/firebase";
- import {
- importUsernameFromLocalStorage,
- saveUsernameToLocalStorage,
- STORAGE_KEYS,
- } from "../data/localStorage";
- import Portal from "./Portal";
- import RoomDialog from "./RoomDialog";
- import { createInverseContext } from "../../createInverseContext";
- import { t } from "../../i18n";
- interface CollabState {
- modalIsShown: boolean;
- errorMessage: string;
- username: string;
- activeRoomLink: string;
- }
- type CollabInstance = InstanceType<typeof CollabWrapper>;
- export interface CollabAPI {
- /** function so that we can access the latest value from stale callbacks */
- isCollaborating: () => boolean;
- username: CollabState["username"];
- onPointerUpdate: CollabInstance["onPointerUpdate"];
- initializeSocketClient: CollabInstance["initializeSocketClient"];
- onCollabButtonClick: CollabInstance["onCollabButtonClick"];
- broadcastElements: CollabInstance["broadcastElements"];
- }
- type ReconciledElements = readonly ExcalidrawElement[] & {
- _brand: "reconciledElements";
- };
- interface Props {
- excalidrawAPI: ExcalidrawImperativeAPI;
- }
- const {
- Context: CollabContext,
- Consumer: CollabContextConsumer,
- Provider: CollabContextProvider,
- } = createInverseContext<{ api: CollabAPI | null }>({ api: null });
- export { CollabContext, CollabContextConsumer };
- class CollabWrapper extends PureComponent<Props, CollabState> {
- portal: Portal;
- excalidrawAPI: Props["excalidrawAPI"];
- isCollaborating: boolean = false;
- private socketInitializationTimer?: NodeJS.Timeout;
- private lastBroadcastedOrReceivedSceneVersion: number = -1;
- private collaborators = new Map<string, Collaborator>();
- constructor(props: Props) {
- super(props);
- this.state = {
- modalIsShown: false,
- errorMessage: "",
- username: importUsernameFromLocalStorage() || "",
- activeRoomLink: "",
- };
- this.portal = new Portal(this);
- this.excalidrawAPI = props.excalidrawAPI;
- }
- componentDidMount() {
- window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
- window.addEventListener(EVENT.UNLOAD, this.onUnload);
- if (
- process.env.NODE_ENV === ENV.TEST ||
- process.env.NODE_ENV === ENV.DEVELOPMENT
- ) {
- window.h = window.h || ({} as Window["h"]);
- Object.defineProperties(window.h, {
- collab: {
- configurable: true,
- value: this,
- },
- });
- }
- }
- componentWillUnmount() {
- window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
- window.removeEventListener(EVENT.UNLOAD, this.onUnload);
- }
- private onUnload = () => {
- this.destroySocketClient({ isUnload: true });
- };
- private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
- const syncableElements = getSyncableElements(
- this.getSceneElementsIncludingDeleted(),
- );
- if (
- this.isCollaborating &&
- !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);
- event.preventDefault();
- // NOTE: modern browsers no longer allow showing a custom message here
- event.returnValue = "";
- }
- if (this.isCollaborating || this.portal.roomId) {
- try {
- localStorage?.setItem(
- STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
- JSON.stringify({
- timestamp: Date.now(),
- room: this.portal.roomId,
- }),
- );
- } catch {}
- }
- });
- saveCollabRoomToFirebase = async (
- syncableElements: ExcalidrawElement[] = getSyncableElements(
- this.excalidrawAPI.getSceneElementsIncludingDeleted(),
- ),
- ) => {
- try {
- await saveToFirebase(this.portal, syncableElements);
- } catch (error) {
- console.error(error);
- }
- };
- openPortal = async () => {
- return this.initializeSocketClient(null);
- };
- closePortal = () => {
- this.saveCollabRoomToFirebase();
- if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
- window.history.pushState({}, APP_NAME, window.location.origin);
- this.destroySocketClient();
- }
- };
- private destroySocketClient = (opts?: { isUnload: boolean }) => {
- if (!opts?.isUnload) {
- this.collaborators = new Map();
- this.excalidrawAPI.updateScene({
- collaborators: this.collaborators,
- });
- this.setState({
- activeRoomLink: "",
- });
- this.isCollaborating = false;
- }
- this.portal.close();
- };
- private initializeSocketClient = async (
- existingRoomLinkData: null | { roomId: string; roomKey: string },
- ): Promise<ImportedDataState | null> => {
- 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.isCollaborating = true;
- const { default: socketIOClient }: any = await import(
- /* webpackChunkName: "socketIoClient" */ "socket.io-client"
- );
- this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
- if (existingRoomLinkData) {
- this.excalidrawAPI.resetScene();
- try {
- const elements = await loadFromFirebase(
- roomId,
- roomKey,
- this.portal.socket,
- );
- if (elements) {
- scenePromise.resolve({
- elements,
- });
- }
- } catch (error) {
- // log the error and move on. other peers will sync us the scene.
- console.error(error);
- }
- } else {
- const elements = this.excalidrawAPI.getSceneElements();
- // 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,
- });
- }
- // fallback in case you're not alone in the room but still don't receive
- // initial SCENE_UPDATE message
- this.socketInitializationTimer = setTimeout(() => {
- this.initializeSocket();
- scenePromise.resolve(null);
- }, 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 decryptAESGEM(
- encryptedData,
- this.portal.roomKey,
- iv,
- );
- switch (decryptedData.type) {
- case "INVALID_RESPONSE":
- return;
- case SCENE.INIT: {
- if (!this.portal.socketInitialized) {
- this.initializeSocket();
- 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 });
- }
- break;
- }
- case SCENE.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;
- }
- }
- },
- );
- this.portal.socket!.on("first-in-room", () => {
- if (this.portal.socket) {
- this.portal.socket.off("first-in-room");
- }
- this.initializeSocket();
- scenePromise.resolve(null);
- });
- this.setState({
- activeRoomLink: window.location.href,
- });
- return scenePromise;
- };
- private initializeSocket = () => {
- this.portal.socketInitialized = true;
- clearTimeout(this.socketInitializationTimer!);
- };
- private reconcileElements = (
- elements: readonly ExcalidrawElement[],
- ): ReconciledElements => {
- const currentElements = this.getSceneElementsIncludingDeleted();
- // create a map of ids so we don't have to iterate
- // over the array more than once.
- const localElementMap = getElementMap(currentElements);
- const appState = this.excalidrawAPI.getAppState();
- // Reconcile
- const newElements: readonly ExcalidrawElement[] = elements
- .reduce((elements, element) => {
- // if the remote element references one that's currently
- // edited on local, skip it (it'll be added in the next step)
- if (
- element.id === appState.editingElement?.id ||
- element.id === appState.resizingElement?.id ||
- element.id === appState.draggingElement?.id
- ) {
- return elements;
- }
- if (
- localElementMap.hasOwnProperty(element.id) &&
- localElementMap[element.id].version > element.version
- ) {
- elements.push(localElementMap[element.id]);
- delete localElementMap[element.id];
- } else if (
- localElementMap.hasOwnProperty(element.id) &&
- localElementMap[element.id].version === element.version &&
- localElementMap[element.id].versionNonce !== element.versionNonce
- ) {
- // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
- if (localElementMap[element.id].versionNonce < element.versionNonce) {
- elements.push(localElementMap[element.id]);
- } else {
- // it should be highly unlikely that the two versionNonces are the same. if we are
- // really worried about this, we can replace the versionNonce with the socket id.
- elements.push(element);
- }
- delete localElementMap[element.id];
- } else {
- elements.push(element);
- delete localElementMap[element.id];
- }
- return elements;
- }, [] as Mutable<typeof elements>)
- // add local elements that weren't deleted or on remote
- .concat(...Object.values(localElementMap));
- // 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
- // syncronously calls render.
- this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
- return newElements as ReconciledElements;
- };
- private handleRemoteSceneUpdate = (
- elements: ReconciledElements,
- {
- init = false,
- initFromSnapshot = false,
- }: { init?: boolean; initFromSnapshot?: boolean } = {},
- ) => {
- if (init || initFromSnapshot) {
- this.excalidrawAPI.setScrollToCenter(elements);
- }
- 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();
- };
- setCollaborators(sockets: string[]) {
- this.setState((state) => {
- const collaborators: InstanceType<
- typeof CollabWrapper
- >["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 = (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);
- };
- broadcastElements = (elements: readonly ExcalidrawElement[]) => {
- if (
- getSceneVersion(elements) >
- this.getLastBroadcastedOrReceivedSceneVersion()
- ) {
- this.portal.broadcastScene(
- SCENE.UPDATE,
- getSyncableElements(elements),
- false,
- );
- this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
- this.queueBroadcastAllElements();
- }
- };
- queueBroadcastAllElements = throttle(() => {
- this.portal.broadcastScene(
- SCENE.UPDATE,
- getSyncableElements(
- this.excalidrawAPI.getSceneElementsIncludingDeleted(),
- ),
- true,
- );
- const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
- const newVersion = Math.max(
- currentVersion,
- getSceneVersion(this.getSceneElementsIncludingDeleted()),
- );
- this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
- }, SYNC_FULL_SCENE_INTERVAL_MS);
- handleClose = () => {
- this.setState({ modalIsShown: false });
- const collabIcon = document.querySelector(".CollabButton") as HTMLElement;
- collabIcon.focus();
- };
- onUsernameChange = (username: string) => {
- this.setState({ username });
- saveUsernameToLocalStorage(username);
- };
- onCollabButtonClick = () => {
- this.setState({
- modalIsShown: true,
- });
- };
- /** PRIVATE. Use `this.getContextValue()` instead. */
- private contextValue: CollabAPI | null = null;
- /** Getter of context value. Returned object is stable. */
- getContextValue = (): CollabAPI => {
- if (!this.contextValue) {
- this.contextValue = {} as CollabAPI;
- }
- this.contextValue.isCollaborating = () => this.isCollaborating;
- this.contextValue.username = this.state.username;
- this.contextValue.onPointerUpdate = this.onPointerUpdate;
- this.contextValue.initializeSocketClient = this.initializeSocketClient;
- this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
- this.contextValue.broadcastElements = this.broadcastElements;
- return this.contextValue;
- };
- render() {
- const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
- return (
- <>
- {modalIsShown && (
- <RoomDialog
- handleClose={this.handleClose}
- activeRoomLink={activeRoomLink}
- username={username}
- onUsernameChange={this.onUsernameChange}
- onRoomCreate={this.openPortal}
- onRoomDestroy={this.closePortal}
- setErrorMessage={(errorMessage) => {
- this.setState({ errorMessage });
- }}
- />
- )}
- {errorMessage && (
- <ErrorDialog
- message={errorMessage}
- onClose={() => this.setState({ errorMessage: "" })}
- />
- )}
- <CollabContextProvider
- value={{
- api: this.getContextValue(),
- }}
- />
- </>
- );
- }
- }
- export default CollabWrapper;
|