diff --git a/src/components/CollabButton.scss b/src/components/CollabButton.scss
index 49362343..93abb07c 100644
--- a/src/components/CollabButton.scss
+++ b/src/components/CollabButton.scss
@@ -18,13 +18,15 @@
left: -5px;
}
min-width: 1em;
+ min-height: 1em;
+ line-height: 1;
position: absolute;
bottom: -5px;
padding: 3px;
border-radius: 50%;
background-color: $oc-green-6;
color: $oc-white;
- font-size: 0.7em;
- font-family: var(--ui-font);
+ font-size: 0.6em;
+ font-family: "Cascadia";
}
}
diff --git a/src/components/CollabButton.tsx b/src/components/CollabButton.tsx
index d6544e95..c2f64274 100644
--- a/src/components/CollabButton.tsx
+++ b/src/components/CollabButton.tsx
@@ -28,7 +28,7 @@ const CollabButton = ({
aria-label={t("labels.liveCollaboration")}
showAriaLabel={useDevice().isMobile}
>
- {collaboratorCount > 0 && (
+ {isCollaborating && (
{collaboratorCount}
)}
diff --git a/src/createInverseContext.tsx b/src/createInverseContext.tsx
deleted file mode 100644
index ac6cc223..00000000
--- a/src/createInverseContext.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from "react";
-
-export const createInverseContext = (
- initialValue: T,
-) => {
- const Context = React.createContext(initialValue) as React.Context & {
- _updateProviderValue?: (value: T) => void;
- };
-
- class InverseConsumer extends React.Component {
- state = { value: initialValue };
- constructor(props: any) {
- super(props);
- Context._updateProviderValue = (value: T) => this.setState({ value });
- }
- render() {
- return (
-
- {this.props.children}
-
- );
- }
- }
-
- class InverseProvider extends React.Component<{ value: T }> {
- componentDidMount() {
- Context._updateProviderValue?.(this.props.value);
- }
- componentDidUpdate() {
- Context._updateProviderValue?.(this.props.value);
- }
- render() {
- return {() => this.props.children};
- }
- }
-
- return {
- Context,
- Consumer: InverseConsumer,
- Provider: InverseProvider,
- };
-};
diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/Collab.tsx
similarity index 85%
rename from src/excalidraw-app/collab/CollabWrapper.tsx
rename to src/excalidraw-app/collab/Collab.tsx
index c3786a44..7112671c 100644
--- a/src/excalidraw-app/collab/CollabWrapper.tsx
+++ b/src/excalidraw-app/collab/Collab.tsx
@@ -8,10 +8,12 @@ import {
ExcalidrawElement,
InitializedExcalidrawImageElement,
} from "../../element/types";
-import { getSceneVersion } from "../../packages/excalidraw/index";
+import {
+ getSceneVersion,
+ restoreElements,
+} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types";
import {
- getFrame,
preventUnload,
resolvablePromise,
withBatchedUpdates,
@@ -47,11 +49,9 @@ import {
} from "../data/localStorage";
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
-import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n";
import { UserIdleState } from "../../types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
-import { trackEvent } from "../../analytics";
import {
encodeFilesForUpload,
FileManager,
@@ -70,52 +70,45 @@ import {
import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
+import { atom, useAtom } from "jotai";
+import { jotaiStore } from "../../jotai";
+
+export const collabAPIAtom = atom(null);
+export const collabDialogShownAtom = atom(false);
+export const isCollaboratingAtom = atom(false);
interface CollabState {
- modalIsShown: boolean;
errorMessage: string;
username: string;
- userState: UserIdleState;
activeRoomLink: string;
}
-type CollabInstance = InstanceType;
+type CollabInstance = InstanceType;
export interface CollabAPI {
/** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean;
- username: CollabState["username"];
- userState: CollabState["userState"];
onPointerUpdate: CollabInstance["onPointerUpdate"];
- initializeSocketClient: CollabInstance["initializeSocketClient"];
- onCollabButtonClick: CollabInstance["onCollabButtonClick"];
+ startCollaboration: CollabInstance["startCollaboration"];
+ stopCollaboration: CollabInstance["stopCollaboration"];
syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void;
}
-interface Props {
+interface PublicProps {
excalidrawAPI: ExcalidrawImperativeAPI;
- onRoomClose?: () => void;
}
-const {
- Context: CollabContext,
- Consumer: CollabContextConsumer,
- Provider: CollabContextProvider,
-} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
+type Props = PublicProps & { modalIsShown: boolean };
-export { CollabContext, CollabContextConsumer };
-
-class CollabWrapper extends PureComponent {
+class Collab extends PureComponent {
portal: Portal;
fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"];
activeIntervalId: number | null;
idleTimeoutId: number | null;
- // marked as private to ensure we don't change it outside this class
- private _isCollaborating: boolean = false;
private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map();
@@ -123,10 +116,8 @@ class CollabWrapper extends PureComponent {
constructor(props: Props) {
super(props);
this.state = {
- modalIsShown: false,
errorMessage: "",
username: importUsernameFromLocalStorage() || "",
- userState: UserIdleState.ACTIVE,
activeRoomLink: "",
};
this.portal = new Portal(this);
@@ -164,6 +155,18 @@ class CollabWrapper extends PureComponent {
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.addEventListener(EVENT.UNLOAD, this.onUnload);
+ const collabAPI: CollabAPI = {
+ isCollaborating: this.isCollaborating,
+ onPointerUpdate: this.onPointerUpdate,
+ startCollaboration: this.startCollaboration,
+ syncElements: this.syncElements,
+ fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
+ stopCollaboration: this.stopCollaboration,
+ setUsername: this.setUsername,
+ };
+
+ jotaiStore.set(collabAPIAtom, collabAPI);
+
if (
process.env.NODE_ENV === ENV.TEST ||
process.env.NODE_ENV === ENV.DEVELOPMENT
@@ -196,7 +199,11 @@ class CollabWrapper extends PureComponent {
}
}
- isCollaborating = () => this._isCollaborating;
+ isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
+
+ private setIsCollaborating = (isCollaborating: boolean) => {
+ jotaiStore.set(isCollaboratingAtom, isCollaborating);
+ };
private onUnload = () => {
this.destroySocketClient({ isUnload: true });
@@ -208,7 +215,7 @@ class CollabWrapper extends PureComponent {
);
if (
- this._isCollaborating &&
+ this.isCollaborating() &&
(this.fileManager.shouldPreventUnload(syncableElements) ||
!isSavedToFirebase(this.portal, syncableElements))
) {
@@ -252,12 +259,7 @@ class CollabWrapper extends PureComponent {
}
};
- openPortal = async () => {
- trackEvent("share", "room creation", `ui (${getFrame()})`);
- return this.initializeSocketClient(null);
- };
-
- closePortal = () => {
+ stopCollaboration = (keepRemoteState = true) => {
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
@@ -267,16 +269,26 @@ class CollabWrapper extends PureComponent {
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
- if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
+
+ 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();
- trackEvent("share", "room closed");
- this.props.onRoomClose?.();
+ LocalData.fileStorage.reset();
const elements = this.excalidrawAPI
.getSceneElementsIncludingDeleted()
@@ -295,20 +307,20 @@ class CollabWrapper extends PureComponent {
};
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,
});
- this.setState({
- activeRoomLink: "",
- });
- this._isCollaborating = false;
LocalData.resumeSave("collaboration");
}
- this.lastBroadcastedOrReceivedSceneVersion = -1;
- this.portal.close();
- this.fileManager.reset();
};
private fetchImageFilesFromFirebase = async (scene: {
@@ -349,7 +361,9 @@ class CollabWrapper extends PureComponent {
}
};
- private initializeSocketClient = async (
+ private fallbackInitializationHandler: null | (() => any) = null;
+
+ startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise => {
if (this.portal.socket) {
@@ -372,13 +386,23 @@ class CollabWrapper extends PureComponent {
const scenePromise = resolvablePromise();
- this._isCollaborating = true;
+ 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();
@@ -391,6 +415,8 @@ class CollabWrapper extends PureComponent {
roomId,
roomKey,
);
+
+ this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) {
console.error(error);
this.setState({ errorMessage: error.message });
@@ -419,13 +445,10 @@ class CollabWrapper extends PureComponent {
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_INIT message
- this.socketInitializationTimer = window.setTimeout(() => {
- this.initializeRoom({
- roomLinkData: existingRoomLinkData,
- fetchScene: true,
- });
- scenePromise.resolve(null);
- }, INITIAL_SCENE_UPDATE_TIMEOUT);
+ this.socketInitializationTimer = window.setTimeout(
+ fallbackInitializationHandler,
+ INITIAL_SCENE_UPDATE_TIMEOUT,
+ );
// All socket listeners are moving to Portal
this.portal.socket.on(
@@ -530,6 +553,12 @@ class CollabWrapper extends PureComponent {
}
| { 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();
@@ -567,6 +596,8 @@ class CollabWrapper extends PureComponent {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
+ remoteElements = restoreElements(remoteElements, null);
+
const reconciledElements = _reconcileElements(
localElements,
remoteElements,
@@ -672,19 +703,17 @@ class CollabWrapper extends PureComponent {
};
setCollaborators(sockets: string[]) {
- this.setState((state) => {
- const collaborators: InstanceType["collaborators"] =
- new Map();
- for (const socketId of sockets) {
- if (this.collaborators.has(socketId)) {
- collaborators.set(socketId, this.collaborators.get(socketId)!);
- } else {
- collaborators.set(socketId, {});
- }
+ const collaborators: InstanceType["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 });
- });
+ }
+ this.collaborators = collaborators;
+ this.excalidrawAPI.updateScene({ collaborators });
}
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
@@ -713,7 +742,6 @@ class CollabWrapper extends PureComponent {
);
onIdleStateChange = (userState: UserIdleState) => {
- this.setState({ userState });
this.portal.broadcastIdleChange(userState);
};
@@ -747,18 +775,22 @@ class CollabWrapper extends PureComponent {
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);
+ queueSaveToFirebase = throttle(
+ () => {
+ if (this.portal.socketInitialized) {
+ this.saveCollabRoomToFirebase(
+ getSyncableElements(
+ this.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ ),
+ );
+ }
+ },
+ SYNC_FULL_SCENE_INTERVAL_MS,
+ { leading: false },
+ );
handleClose = () => {
- this.setState({ modalIsShown: false });
+ jotaiStore.set(collabDialogShownAtom, false);
};
setUsername = (username: string) => {
@@ -770,35 +802,10 @@ class CollabWrapper extends PureComponent {
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.syncElements = this.syncElements;
- this.contextValue.fetchImageFilesFromFirebase =
- this.fetchImageFilesFromFirebase;
- this.contextValue.setUsername = this.setUsername;
- return this.contextValue;
- };
-
render() {
- const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
+ const { username, errorMessage, activeRoomLink } = this.state;
+
+ const { modalIsShown } = this.props;
return (
<>
@@ -808,8 +815,8 @@ class CollabWrapper extends PureComponent {
activeRoomLink={activeRoomLink}
username={username}
onUsernameChange={this.onUsernameChange}
- onRoomCreate={this.openPortal}
- onRoomDestroy={this.closePortal}
+ onRoomCreate={() => this.startCollaboration(null)}
+ onRoomDestroy={this.stopCollaboration}
setErrorMessage={(errorMessage) => {
this.setState({ errorMessage });
}}
@@ -822,11 +829,6 @@ class CollabWrapper extends PureComponent {
onClose={() => this.setState({ errorMessage: "" })}
/>
)}
-
>
);
}
@@ -834,7 +836,7 @@ class CollabWrapper extends PureComponent {
declare global {
interface Window {
- collab: InstanceType;
+ collab: InstanceType;
}
}
@@ -845,4 +847,11 @@ if (
window.collab = window.collab || ({} as Window["collab"]);
}
-export default CollabWrapper;
+const _Collab: React.FC = (props) => {
+ const [collabDialogShown] = useAtom(collabDialogShownAtom);
+ return ;
+};
+
+export default _Collab;
+
+export type TCollabClass = Collab;
diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx
index 3a60e1df..95e0e7aa 100644
--- a/src/excalidraw-app/collab/Portal.tsx
+++ b/src/excalidraw-app/collab/Portal.tsx
@@ -4,7 +4,7 @@ import {
SocketUpdateDataSource,
} from "../data";
-import CollabWrapper from "./CollabWrapper";
+import { TCollabClass } from "./Collab";
import { ExcalidrawElement } from "../../element/types";
import {
@@ -20,14 +20,14 @@ import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../data/encryption";
class Portal {
- collab: CollabWrapper;
+ collab: TCollabClass;
socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map = new Map();
- constructor(collab: CollabWrapper) {
+ constructor(collab: TCollabClass) {
this.collab = collab;
}
diff --git a/src/excalidraw-app/collab/RoomDialog.tsx b/src/excalidraw-app/collab/RoomDialog.tsx
index ac3fc27c..724856b3 100644
--- a/src/excalidraw-app/collab/RoomDialog.tsx
+++ b/src/excalidraw-app/collab/RoomDialog.tsx
@@ -14,6 +14,8 @@ import { t } from "../../i18n";
import "./RoomDialog.scss";
import Stack from "../../components/Stack";
import { AppState } from "../../types";
+import { trackEvent } from "../../analytics";
+import { getFrame } from "../../utils";
const getShareIcon = () => {
const navigator = window.navigator as any;
@@ -95,7 +97,10 @@ const RoomDialog = ({
title={t("roomDialog.button_startSession")}
aria-label={t("roomDialog.button_startSession")}
showAriaLabel={true}
- onClick={onRoomCreate}
+ onClick={() => {
+ trackEvent("share", "room creation", `ui (${getFrame()})`);
+ onRoomCreate();
+ }}
/>
>
@@ -160,7 +165,10 @@ const RoomDialog = ({
title={t("roomDialog.button_stopSession")}
aria-label={t("roomDialog.button_stopSession")}
showAriaLabel={true}
- onClick={onRoomDestroy}
+ onClick={() => {
+ trackEvent("share", "room closed");
+ onRoomDestroy();
+ }}
/>
>
diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts
index 705347fc..a74c439f 100644
--- a/src/excalidraw-app/data/index.ts
+++ b/src/excalidraw-app/data/index.ts
@@ -134,9 +134,16 @@ export type SocketUpdateData =
_brand: "socketUpdateData";
};
+const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
+
+export const isCollaborationLink = (link: string) => {
+ const hash = new URL(link).hash;
+ return RE_COLLAB_LINK.test(hash);
+};
+
export const getCollaborationLinkData = (link: string) => {
const hash = new URL(link).hash;
- const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
+ const match = hash.match(RE_COLLAB_LINK);
if (match && match[2].length !== 22) {
window.alert(t("alerts.invalidEncryptionKey"));
return null;
diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx
index c67e48bc..f8ff9541 100644
--- a/src/excalidraw-app/index.tsx
+++ b/src/excalidraw-app/index.tsx
@@ -1,5 +1,5 @@
import LanguageDetector from "i18next-browser-languagedetector";
-import { useCallback, useContext, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { ErrorDialog } from "../components/ErrorDialog";
@@ -45,20 +45,26 @@ import {
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
-import CollabWrapper, {
+import Collab, {
CollabAPI,
- CollabContext,
- CollabContextConsumer,
-} from "./collab/CollabWrapper";
+ collabAPIAtom,
+ collabDialogShownAtom,
+ isCollaboratingAtom,
+} from "./collab/Collab";
import { LanguageList } from "./components/LanguageList";
-import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
+import {
+ exportToBackend,
+ getCollaborationLinkData,
+ isCollaborationLink,
+ loadScene,
+} from "./data";
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
-import { restoreAppState, RestoredDataState } from "../data/restore";
+import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import { Tooltip } from "../components/Tooltip";
import { shield } from "../components/icons";
@@ -72,6 +78,9 @@ import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
+import { Provider, useAtom } from "jotai";
+import { jotaiStore, useAtomWithInitialValue } from "../jotai";
+import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
const isExcalidrawPlusSignedUser = document.cookie.includes(
@@ -170,7 +179,7 @@ const initializeScene = async (opts: {
if (roomLinkData) {
return {
- scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
+ scene: await opts.collabAPI.startCollaboration(roomLinkData),
isExternalScene: true,
id: roomLinkData.roomId,
key: roomLinkData.roomKey,
@@ -242,7 +251,11 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState();
- const collabAPI = useContext(CollabContext)?.api;
+ const [collabAPI] = useAtom(collabAPIAtom);
+ const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
+ const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
+ return isCollaborationLink(window.location.href);
+ });
useHandleLibrary({
excalidrawAPI,
@@ -320,21 +333,44 @@ const ExcalidrawWrapper = () => {
}
};
- initializeScene({ collabAPI }).then((data) => {
+ initializeScene({ collabAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true);
- initialStatePromiseRef.current.promise.resolve(data.scene);
+
+ initialStatePromiseRef.current.promise.resolve({
+ ...data.scene,
+ // at this point the state may have already been updated (e.g. when
+ // collaborating, we may have received updates from other clients)
+ appState: restoreAppState(
+ data.scene?.appState,
+ excalidrawAPI.getAppState(),
+ ),
+ elements: reconcileElements(
+ data.scene?.elements || [],
+ excalidrawAPI.getSceneElementsIncludingDeleted(),
+ excalidrawAPI.getAppState(),
+ ),
+ });
});
const onHashChange = async (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (!libraryUrlTokens) {
+ if (
+ collabAPI.isCollaborating() &&
+ !isCollaborationLink(window.location.href)
+ ) {
+ collabAPI.stopCollaboration(false);
+ }
+ excalidrawAPI.updateScene({ appState: { isLoading: true } });
+
initializeScene({ collabAPI }).then((data) => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
- appState: restoreAppState(data.scene.appState, null),
+ ...restore(data.scene, null, null),
+ commitToHistory: true,
});
}
});
@@ -636,23 +672,19 @@ const ExcalidrawWrapper = () => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
};
- const onRoomClose = useCallback(() => {
- LocalData.fileStorage.reset();
- }, []);
-
return (
setCollabDialogShown(true)}
+ isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
UIOptions={{
canvasActions: {
@@ -686,12 +718,7 @@ const ExcalidrawWrapper = () => {
onLibraryChange={onLibraryChange}
autoFocus={true}
/>
- {excalidrawAPI && (
-
- )}
+ {excalidrawAPI && }
{errorMessage && (
{
const ExcalidrawApp = () => {
return (
-
+ jotaiStore}>
-
+
);
};
diff --git a/src/jotai.ts b/src/jotai.ts
index e26bab1d..c730cbbc 100644
--- a/src/jotai.ts
+++ b/src/jotai.ts
@@ -1,4 +1,27 @@
-import { unstable_createStore } from "jotai";
+import { unstable_createStore, useAtom, WritableAtom } from "jotai";
+import { useLayoutEffect } from "react";
export const jotaiScope = Symbol();
export const jotaiStore = unstable_createStore();
+
+export const useAtomWithInitialValue = <
+ T extends unknown,
+ A extends WritableAtom,
+>(
+ atom: A,
+ initialValue: T | (() => T),
+) => {
+ const [value, setValue] = useAtom(atom);
+
+ useLayoutEffect(() => {
+ if (typeof initialValue === "function") {
+ // @ts-ignore
+ setValue(initialValue());
+ } else {
+ setValue(initialValue);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return [value, setValue] as const;
+};
diff --git a/src/tests/collab.test.tsx b/src/tests/collab.test.tsx
index 84fd9e3a..42ad571a 100644
--- a/src/tests/collab.test.tsx
+++ b/src/tests/collab.test.tsx
@@ -50,6 +50,7 @@ jest.mock("socket.io-client", () => {
return {
close: () => {},
on: () => {},
+ once: () => {},
off: () => {},
emit: () => {},
};
@@ -77,7 +78,7 @@ describe("collaboration", () => {
]);
expect(API.getStateHistory().length).toBe(1);
});
- window.collab.openPortal();
+ window.collab.startCollaboration(null);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);