feat: collab component state handling rewrite & fixes (#5046)
This commit is contained in:
parent
a1a62468a6
commit
dac8dda4d4
@ -18,13 +18,15 @@
|
|||||||
left: -5px;
|
left: -5px;
|
||||||
}
|
}
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
|
min-height: 1em;
|
||||||
|
line-height: 1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: $oc-green-6;
|
background-color: $oc-green-6;
|
||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
font-size: 0.7em;
|
font-size: 0.6em;
|
||||||
font-family: var(--ui-font);
|
font-family: "Cascadia";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ const CollabButton = ({
|
|||||||
aria-label={t("labels.liveCollaboration")}
|
aria-label={t("labels.liveCollaboration")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useDevice().isMobile}
|
||||||
>
|
>
|
||||||
{collaboratorCount > 0 && (
|
{isCollaborating && (
|
||||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||||
)}
|
)}
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
export const createInverseContext = <T extends unknown = null>(
|
|
||||||
initialValue: T,
|
|
||||||
) => {
|
|
||||||
const Context = React.createContext(initialValue) as React.Context<T> & {
|
|
||||||
_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 (
|
|
||||||
<Context.Provider value={this.state.value}>
|
|
||||||
{this.props.children}
|
|
||||||
</Context.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class InverseProvider extends React.Component<{ value: T }> {
|
|
||||||
componentDidMount() {
|
|
||||||
Context._updateProviderValue?.(this.props.value);
|
|
||||||
}
|
|
||||||
componentDidUpdate() {
|
|
||||||
Context._updateProviderValue?.(this.props.value);
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
Context,
|
|
||||||
Consumer: InverseConsumer,
|
|
||||||
Provider: InverseProvider,
|
|
||||||
};
|
|
||||||
};
|
|
@ -8,10 +8,12 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { getSceneVersion } from "../../packages/excalidraw/index";
|
import {
|
||||||
|
getSceneVersion,
|
||||||
|
restoreElements,
|
||||||
|
} from "../../packages/excalidraw/index";
|
||||||
import { Collaborator, Gesture } from "../../types";
|
import { Collaborator, Gesture } from "../../types";
|
||||||
import {
|
import {
|
||||||
getFrame,
|
|
||||||
preventUnload,
|
preventUnload,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
withBatchedUpdates,
|
withBatchedUpdates,
|
||||||
@ -47,11 +49,9 @@ import {
|
|||||||
} from "../data/localStorage";
|
} from "../data/localStorage";
|
||||||
import Portal from "./Portal";
|
import Portal from "./Portal";
|
||||||
import RoomDialog from "./RoomDialog";
|
import RoomDialog from "./RoomDialog";
|
||||||
import { createInverseContext } from "../../createInverseContext";
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { UserIdleState } from "../../types";
|
import { UserIdleState } from "../../types";
|
||||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||||
import { trackEvent } from "../../analytics";
|
|
||||||
import {
|
import {
|
||||||
encodeFilesForUpload,
|
encodeFilesForUpload,
|
||||||
FileManager,
|
FileManager,
|
||||||
@ -70,52 +70,45 @@ import {
|
|||||||
import { decryptData } from "../../data/encryption";
|
import { decryptData } from "../../data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { jotaiStore } from "../../jotai";
|
||||||
|
|
||||||
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
|
export const collabDialogShownAtom = atom(false);
|
||||||
|
export const isCollaboratingAtom = atom(false);
|
||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
modalIsShown: boolean;
|
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
username: string;
|
username: string;
|
||||||
userState: UserIdleState;
|
|
||||||
activeRoomLink: string;
|
activeRoomLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
type CollabInstance = InstanceType<typeof Collab>;
|
||||||
|
|
||||||
export interface CollabAPI {
|
export interface CollabAPI {
|
||||||
/** function so that we can access the latest value from stale callbacks */
|
/** function so that we can access the latest value from stale callbacks */
|
||||||
isCollaborating: () => boolean;
|
isCollaborating: () => boolean;
|
||||||
username: CollabState["username"];
|
|
||||||
userState: CollabState["userState"];
|
|
||||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
startCollaboration: CollabInstance["startCollaboration"];
|
||||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||||
syncElements: CollabInstance["syncElements"];
|
syncElements: CollabInstance["syncElements"];
|
||||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||||
setUsername: (username: string) => void;
|
setUsername: (username: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface PublicProps {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
onRoomClose?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
type Props = PublicProps & { modalIsShown: boolean };
|
||||||
Context: CollabContext,
|
|
||||||
Consumer: CollabContextConsumer,
|
|
||||||
Provider: CollabContextProvider,
|
|
||||||
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
|
|
||||||
|
|
||||||
export { CollabContext, CollabContextConsumer };
|
class Collab extends PureComponent<Props, CollabState> {
|
||||||
|
|
||||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
|
||||||
portal: Portal;
|
portal: Portal;
|
||||||
fileManager: FileManager;
|
fileManager: FileManager;
|
||||||
excalidrawAPI: Props["excalidrawAPI"];
|
excalidrawAPI: Props["excalidrawAPI"];
|
||||||
activeIntervalId: number | null;
|
activeIntervalId: number | null;
|
||||||
idleTimeoutId: 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 socketInitializationTimer?: number;
|
||||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||||
private collaborators = new Map<string, Collaborator>();
|
private collaborators = new Map<string, Collaborator>();
|
||||||
@ -123,10 +116,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
modalIsShown: false,
|
|
||||||
errorMessage: "",
|
errorMessage: "",
|
||||||
username: importUsernameFromLocalStorage() || "",
|
username: importUsernameFromLocalStorage() || "",
|
||||||
userState: UserIdleState.ACTIVE,
|
|
||||||
activeRoomLink: "",
|
activeRoomLink: "",
|
||||||
};
|
};
|
||||||
this.portal = new Portal(this);
|
this.portal = new Portal(this);
|
||||||
@ -164,6 +155,18 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
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 (
|
if (
|
||||||
process.env.NODE_ENV === ENV.TEST ||
|
process.env.NODE_ENV === ENV.TEST ||
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||||
@ -196,7 +199,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isCollaborating = () => this._isCollaborating;
|
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
|
||||||
|
|
||||||
|
private setIsCollaborating = (isCollaborating: boolean) => {
|
||||||
|
jotaiStore.set(isCollaboratingAtom, isCollaborating);
|
||||||
|
};
|
||||||
|
|
||||||
private onUnload = () => {
|
private onUnload = () => {
|
||||||
this.destroySocketClient({ isUnload: true });
|
this.destroySocketClient({ isUnload: true });
|
||||||
@ -208,7 +215,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this._isCollaborating &&
|
this.isCollaborating() &&
|
||||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||||
!isSavedToFirebase(this.portal, syncableElements))
|
!isSavedToFirebase(this.portal, syncableElements))
|
||||||
) {
|
) {
|
||||||
@ -252,12 +259,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
openPortal = async () => {
|
stopCollaboration = (keepRemoteState = true) => {
|
||||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
|
||||||
return this.initializeSocketClient(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
closePortal = () => {
|
|
||||||
this.queueBroadcastAllElements.cancel();
|
this.queueBroadcastAllElements.cancel();
|
||||||
this.queueSaveToFirebase.cancel();
|
this.queueSaveToFirebase.cancel();
|
||||||
this.loadImageFiles.cancel();
|
this.loadImageFiles.cancel();
|
||||||
@ -267,16 +269,26 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
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
|
// hack to ensure that we prefer we disregard any new browser state
|
||||||
// that could have been saved in other tabs while we were collaborating
|
// that could have been saved in other tabs while we were collaborating
|
||||||
resetBrowserStateVersions();
|
resetBrowserStateVersions();
|
||||||
|
|
||||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||||
this.destroySocketClient();
|
this.destroySocketClient();
|
||||||
trackEvent("share", "room closed");
|
|
||||||
|
|
||||||
this.props.onRoomClose?.();
|
LocalData.fileStorage.reset();
|
||||||
|
|
||||||
const elements = this.excalidrawAPI
|
const elements = this.excalidrawAPI
|
||||||
.getSceneElementsIncludingDeleted()
|
.getSceneElementsIncludingDeleted()
|
||||||
@ -295,20 +307,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||||
|
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||||
|
this.portal.close();
|
||||||
|
this.fileManager.reset();
|
||||||
if (!opts?.isUnload) {
|
if (!opts?.isUnload) {
|
||||||
|
this.setIsCollaborating(false);
|
||||||
|
this.setState({
|
||||||
|
activeRoomLink: "",
|
||||||
|
});
|
||||||
this.collaborators = new Map();
|
this.collaborators = new Map();
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
collaborators: this.collaborators,
|
collaborators: this.collaborators,
|
||||||
});
|
});
|
||||||
this.setState({
|
|
||||||
activeRoomLink: "",
|
|
||||||
});
|
|
||||||
this._isCollaborating = false;
|
|
||||||
LocalData.resumeSave("collaboration");
|
LocalData.resumeSave("collaboration");
|
||||||
}
|
}
|
||||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
|
||||||
this.portal.close();
|
|
||||||
this.fileManager.reset();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private fetchImageFilesFromFirebase = async (scene: {
|
private fetchImageFilesFromFirebase = async (scene: {
|
||||||
@ -349,7 +361,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private initializeSocketClient = async (
|
private fallbackInitializationHandler: null | (() => any) = null;
|
||||||
|
|
||||||
|
startCollaboration = async (
|
||||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||||
): Promise<ImportedDataState | null> => {
|
): Promise<ImportedDataState | null> => {
|
||||||
if (this.portal.socket) {
|
if (this.portal.socket) {
|
||||||
@ -372,13 +386,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||||
|
|
||||||
this._isCollaborating = true;
|
this.setIsCollaborating(true);
|
||||||
LocalData.pauseSave("collaboration");
|
LocalData.pauseSave("collaboration");
|
||||||
|
|
||||||
const { default: socketIOClient } = await import(
|
const { default: socketIOClient } = await import(
|
||||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fallbackInitializationHandler = () => {
|
||||||
|
this.initializeRoom({
|
||||||
|
roomLinkData: existingRoomLinkData,
|
||||||
|
fetchScene: true,
|
||||||
|
}).then((scene) => {
|
||||||
|
scenePromise.resolve(scene);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const socketServerData = await getCollabServer();
|
const socketServerData = await getCollabServer();
|
||||||
|
|
||||||
@ -391,6 +415,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
roomId,
|
roomId,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.setState({ errorMessage: error.message });
|
this.setState({ errorMessage: error.message });
|
||||||
@ -419,13 +445,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
// fallback in case you're not alone in the room but still don't receive
|
// fallback in case you're not alone in the room but still don't receive
|
||||||
// initial SCENE_INIT message
|
// initial SCENE_INIT message
|
||||||
this.socketInitializationTimer = window.setTimeout(() => {
|
this.socketInitializationTimer = window.setTimeout(
|
||||||
this.initializeRoom({
|
fallbackInitializationHandler,
|
||||||
roomLinkData: existingRoomLinkData,
|
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||||
fetchScene: true,
|
);
|
||||||
});
|
|
||||||
scenePromise.resolve(null);
|
|
||||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
|
||||||
|
|
||||||
// All socket listeners are moving to Portal
|
// All socket listeners are moving to Portal
|
||||||
this.portal.socket.on(
|
this.portal.socket.on(
|
||||||
@ -530,6 +553,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
| { fetchScene: false; roomLinkData?: null }) => {
|
| { fetchScene: false; roomLinkData?: null }) => {
|
||||||
clearTimeout(this.socketInitializationTimer!);
|
clearTimeout(this.socketInitializationTimer!);
|
||||||
|
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||||
|
this.portal.socket.off(
|
||||||
|
"connect_error",
|
||||||
|
this.fallbackInitializationHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (fetchScene && roomLinkData && this.portal.socket) {
|
if (fetchScene && roomLinkData && this.portal.socket) {
|
||||||
this.excalidrawAPI.resetScene();
|
this.excalidrawAPI.resetScene();
|
||||||
|
|
||||||
@ -567,6 +596,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
const localElements = this.getSceneElementsIncludingDeleted();
|
const localElements = this.getSceneElementsIncludingDeleted();
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
|
|
||||||
|
remoteElements = restoreElements(remoteElements, null);
|
||||||
|
|
||||||
const reconciledElements = _reconcileElements(
|
const reconciledElements = _reconcileElements(
|
||||||
localElements,
|
localElements,
|
||||||
remoteElements,
|
remoteElements,
|
||||||
@ -672,8 +703,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setCollaborators(sockets: string[]) {
|
setCollaborators(sockets: string[]) {
|
||||||
this.setState((state) => {
|
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||||
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
|
|
||||||
new Map();
|
new Map();
|
||||||
for (const socketId of sockets) {
|
for (const socketId of sockets) {
|
||||||
if (this.collaborators.has(socketId)) {
|
if (this.collaborators.has(socketId)) {
|
||||||
@ -684,7 +714,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
this.collaborators = collaborators;
|
this.collaborators = collaborators;
|
||||||
this.excalidrawAPI.updateScene({ collaborators });
|
this.excalidrawAPI.updateScene({ collaborators });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||||
@ -713,7 +742,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
onIdleStateChange = (userState: UserIdleState) => {
|
onIdleStateChange = (userState: UserIdleState) => {
|
||||||
this.setState({ userState });
|
|
||||||
this.portal.broadcastIdleChange(userState);
|
this.portal.broadcastIdleChange(userState);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -747,7 +775,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||||
|
|
||||||
queueSaveToFirebase = throttle(() => {
|
queueSaveToFirebase = throttle(
|
||||||
|
() => {
|
||||||
if (this.portal.socketInitialized) {
|
if (this.portal.socketInitialized) {
|
||||||
this.saveCollabRoomToFirebase(
|
this.saveCollabRoomToFirebase(
|
||||||
getSyncableElements(
|
getSyncableElements(
|
||||||
@ -755,10 +784,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
},
|
||||||
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||||
|
{ leading: false },
|
||||||
|
);
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = () => {
|
||||||
this.setState({ modalIsShown: false });
|
jotaiStore.set(collabDialogShownAtom, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
setUsername = (username: string) => {
|
setUsername = (username: string) => {
|
||||||
@ -770,35 +802,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
saveUsernameToLocalStorage(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.syncElements = this.syncElements;
|
|
||||||
this.contextValue.fetchImageFilesFromFirebase =
|
|
||||||
this.fetchImageFilesFromFirebase;
|
|
||||||
this.contextValue.setUsername = this.setUsername;
|
|
||||||
return this.contextValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
const { username, errorMessage, activeRoomLink } = this.state;
|
||||||
|
|
||||||
|
const { modalIsShown } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -808,8 +815,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
activeRoomLink={activeRoomLink}
|
activeRoomLink={activeRoomLink}
|
||||||
username={username}
|
username={username}
|
||||||
onUsernameChange={this.onUsernameChange}
|
onUsernameChange={this.onUsernameChange}
|
||||||
onRoomCreate={this.openPortal}
|
onRoomCreate={() => this.startCollaboration(null)}
|
||||||
onRoomDestroy={this.closePortal}
|
onRoomDestroy={this.stopCollaboration}
|
||||||
setErrorMessage={(errorMessage) => {
|
setErrorMessage={(errorMessage) => {
|
||||||
this.setState({ errorMessage });
|
this.setState({ errorMessage });
|
||||||
}}
|
}}
|
||||||
@ -822,11 +829,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
onClose={() => this.setState({ errorMessage: "" })}
|
onClose={() => this.setState({ errorMessage: "" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CollabContextProvider
|
|
||||||
value={{
|
|
||||||
api: this.getContextValue(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -834,7 +836,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
collab: InstanceType<typeof CollabWrapper>;
|
collab: InstanceType<typeof Collab>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -845,4 +847,11 @@ if (
|
|||||||
window.collab = window.collab || ({} as Window["collab"]);
|
window.collab = window.collab || ({} as Window["collab"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CollabWrapper;
|
const _Collab: React.FC<PublicProps> = (props) => {
|
||||||
|
const [collabDialogShown] = useAtom(collabDialogShownAtom);
|
||||||
|
return <Collab {...props} modalIsShown={collabDialogShown} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default _Collab;
|
||||||
|
|
||||||
|
export type TCollabClass = Collab;
|
@ -4,7 +4,7 @@ import {
|
|||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
|
|
||||||
import CollabWrapper from "./CollabWrapper";
|
import { TCollabClass } from "./Collab";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import {
|
import {
|
||||||
@ -20,14 +20,14 @@ import { BroadcastedExcalidrawElement } from "./reconciliation";
|
|||||||
import { encryptData } from "../../data/encryption";
|
import { encryptData } from "../../data/encryption";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: CollabWrapper;
|
collab: TCollabClass;
|
||||||
socket: SocketIOClient.Socket | null = null;
|
socket: SocketIOClient.Socket | null = null;
|
||||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||||
roomId: string | null = null;
|
roomId: string | null = null;
|
||||||
roomKey: string | null = null;
|
roomKey: string | null = null;
|
||||||
broadcastedElementVersions: Map<string, number> = new Map();
|
broadcastedElementVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
constructor(collab: CollabWrapper) {
|
constructor(collab: TCollabClass) {
|
||||||
this.collab = collab;
|
this.collab = collab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,6 +14,8 @@ import { t } from "../../i18n";
|
|||||||
import "./RoomDialog.scss";
|
import "./RoomDialog.scss";
|
||||||
import Stack from "../../components/Stack";
|
import Stack from "../../components/Stack";
|
||||||
import { AppState } from "../../types";
|
import { AppState } from "../../types";
|
||||||
|
import { trackEvent } from "../../analytics";
|
||||||
|
import { getFrame } from "../../utils";
|
||||||
|
|
||||||
const getShareIcon = () => {
|
const getShareIcon = () => {
|
||||||
const navigator = window.navigator as any;
|
const navigator = window.navigator as any;
|
||||||
@ -95,7 +97,10 @@ const RoomDialog = ({
|
|||||||
title={t("roomDialog.button_startSession")}
|
title={t("roomDialog.button_startSession")}
|
||||||
aria-label={t("roomDialog.button_startSession")}
|
aria-label={t("roomDialog.button_startSession")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={onRoomCreate}
|
onClick={() => {
|
||||||
|
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||||
|
onRoomCreate();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -160,7 +165,10 @@ const RoomDialog = ({
|
|||||||
title={t("roomDialog.button_stopSession")}
|
title={t("roomDialog.button_stopSession")}
|
||||||
aria-label={t("roomDialog.button_stopSession")}
|
aria-label={t("roomDialog.button_stopSession")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={onRoomDestroy}
|
onClick={() => {
|
||||||
|
trackEvent("share", "room closed");
|
||||||
|
onRoomDestroy();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -134,9 +134,16 @@ export type SocketUpdateData =
|
|||||||
_brand: "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) => {
|
export const getCollaborationLinkData = (link: string) => {
|
||||||
const hash = new URL(link).hash;
|
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) {
|
if (match && match[2].length !== 22) {
|
||||||
window.alert(t("alerts.invalidEncryptionKey"));
|
window.alert(t("alerts.invalidEncryptionKey"));
|
||||||
return null;
|
return null;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
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 { trackEvent } from "../analytics";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { ErrorDialog } from "../components/ErrorDialog";
|
import { ErrorDialog } from "../components/ErrorDialog";
|
||||||
@ -45,20 +45,26 @@ import {
|
|||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
SYNC_BROWSER_TABS_TIMEOUT,
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
} from "./app_constants";
|
} from "./app_constants";
|
||||||
import CollabWrapper, {
|
import Collab, {
|
||||||
CollabAPI,
|
CollabAPI,
|
||||||
CollabContext,
|
collabAPIAtom,
|
||||||
CollabContextConsumer,
|
collabDialogShownAtom,
|
||||||
} from "./collab/CollabWrapper";
|
isCollaboratingAtom,
|
||||||
|
} from "./collab/Collab";
|
||||||
import { LanguageList } from "./components/LanguageList";
|
import { LanguageList } from "./components/LanguageList";
|
||||||
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
import {
|
||||||
|
exportToBackend,
|
||||||
|
getCollaborationLinkData,
|
||||||
|
isCollaborationLink,
|
||||||
|
loadScene,
|
||||||
|
} from "./data";
|
||||||
import {
|
import {
|
||||||
getLibraryItemsFromStorage,
|
getLibraryItemsFromStorage,
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
} from "./data/localStorage";
|
} from "./data/localStorage";
|
||||||
import CustomStats from "./CustomStats";
|
import CustomStats from "./CustomStats";
|
||||||
import { restoreAppState, RestoredDataState } from "../data/restore";
|
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { shield } from "../components/icons";
|
import { shield } from "../components/icons";
|
||||||
|
|
||||||
@ -72,6 +78,9 @@ import { loadFilesFromFirebase } from "./data/firebase";
|
|||||||
import { LocalData } from "./data/LocalData";
|
import { LocalData } from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import clsx from "clsx";
|
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";
|
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||||
|
|
||||||
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||||
@ -170,7 +179,7 @@ const initializeScene = async (opts: {
|
|||||||
|
|
||||||
if (roomLinkData) {
|
if (roomLinkData) {
|
||||||
return {
|
return {
|
||||||
scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
|
scene: await opts.collabAPI.startCollaboration(roomLinkData),
|
||||||
isExternalScene: true,
|
isExternalScene: true,
|
||||||
id: roomLinkData.roomId,
|
id: roomLinkData.roomId,
|
||||||
key: roomLinkData.roomKey,
|
key: roomLinkData.roomKey,
|
||||||
@ -242,7 +251,11 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [excalidrawAPI, excalidrawRefCallback] =
|
const [excalidrawAPI, excalidrawRefCallback] =
|
||||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||||
|
|
||||||
const collabAPI = useContext(CollabContext)?.api;
|
const [collabAPI] = useAtom(collabAPIAtom);
|
||||||
|
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||||
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||||
|
return isCollaborationLink(window.location.href);
|
||||||
|
});
|
||||||
|
|
||||||
useHandleLibrary({
|
useHandleLibrary({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
@ -320,21 +333,44 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeScene({ collabAPI }).then((data) => {
|
initializeScene({ collabAPI }).then(async (data) => {
|
||||||
loadImages(data, /* isInitialLoad */ true);
|
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) => {
|
const onHashChange = async (event: HashChangeEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||||
if (!libraryUrlTokens) {
|
if (!libraryUrlTokens) {
|
||||||
|
if (
|
||||||
|
collabAPI.isCollaborating() &&
|
||||||
|
!isCollaborationLink(window.location.href)
|
||||||
|
) {
|
||||||
|
collabAPI.stopCollaboration(false);
|
||||||
|
}
|
||||||
|
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
||||||
|
|
||||||
initializeScene({ collabAPI }).then((data) => {
|
initializeScene({ collabAPI }).then((data) => {
|
||||||
loadImages(data);
|
loadImages(data);
|
||||||
if (data.scene) {
|
if (data.scene) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...data.scene,
|
...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);
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRoomClose = useCallback(() => {
|
|
||||||
LocalData.fileStorage.reset();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
className={clsx("excalidraw-app", {
|
className={clsx("excalidraw-app", {
|
||||||
"is-collaborating": collabAPI?.isCollaborating(),
|
"is-collaborating": isCollaborating,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={excalidrawRefCallback}
|
ref={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
onCollabButtonClick={() => setCollabDialogShown(true)}
|
||||||
isCollaborating={collabAPI?.isCollaborating()}
|
isCollaborating={isCollaborating}
|
||||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
UIOptions={{
|
UIOptions={{
|
||||||
canvasActions: {
|
canvasActions: {
|
||||||
@ -686,12 +718,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
onLibraryChange={onLibraryChange}
|
onLibraryChange={onLibraryChange}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
/>
|
/>
|
||||||
{excalidrawAPI && (
|
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||||
<CollabWrapper
|
|
||||||
excalidrawAPI={excalidrawAPI}
|
|
||||||
onRoomClose={onRoomClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
@ -705,9 +732,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
const ExcalidrawApp = () => {
|
const ExcalidrawApp = () => {
|
||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<CollabContextConsumer>
|
<Provider unstable_createStore={() => jotaiStore}>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
</CollabContextConsumer>
|
</Provider>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
25
src/jotai.ts
25
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 jotaiScope = Symbol();
|
||||||
export const jotaiStore = unstable_createStore();
|
export const jotaiStore = unstable_createStore();
|
||||||
|
|
||||||
|
export const useAtomWithInitialValue = <
|
||||||
|
T extends unknown,
|
||||||
|
A extends WritableAtom<T, T>,
|
||||||
|
>(
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
@ -50,6 +50,7 @@ jest.mock("socket.io-client", () => {
|
|||||||
return {
|
return {
|
||||||
close: () => {},
|
close: () => {},
|
||||||
on: () => {},
|
on: () => {},
|
||||||
|
once: () => {},
|
||||||
off: () => {},
|
off: () => {},
|
||||||
emit: () => {},
|
emit: () => {},
|
||||||
};
|
};
|
||||||
@ -77,7 +78,7 @@ describe("collaboration", () => {
|
|||||||
]);
|
]);
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
expect(API.getStateHistory().length).toBe(1);
|
||||||
});
|
});
|
||||||
window.collab.openPortal();
|
window.collab.startCollaboration(null);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
expect(API.getStateHistory().length).toBe(1);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user