Factor out collaboration code (#2313)

Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2020-12-05 20:00:53 +05:30 committed by GitHub
parent d8a0dc3b4d
commit e617ccc252
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 2250 additions and 2018 deletions

View File

@ -64,7 +64,6 @@ export const actionClearCanvas = register({
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
username: appState.username,
},
commitToHistory: true,
};

View File

@ -34,7 +34,6 @@ export const actionGoToCollaborator = register({
},
PanelComponent: ({ appState, updateData, id }) => {
const clientId = id;
if (!clientId) {
return null;
}

View File

@ -47,9 +47,7 @@ export const getDefaultAppState = (): Omit<
cursorButton: "up",
scrolledOutside: false,
name: `${t("labels.untitled")}-${getDateTime()}`,
username: "",
isBindingEnabled: true,
isCollaborating: false,
isResizing: false,
isRotating: false,
selectionElement: null,
@ -61,7 +59,6 @@ export const getDefaultAppState = (): Omit<
lastPointerDownWith: "mouse",
selectedElementIds: {},
previousSelectedElementIds: {},
collaborators: new Map(),
shouldCacheIgnoreZoom: false,
showShortcutsDialog: false,
suggestedBindings: [],
@ -73,6 +70,7 @@ export const getDefaultAppState = (): Omit<
height: window.innerHeight,
isLibraryOpen: false,
fileHandle: null,
collaborators: new Map(),
};
};
@ -92,7 +90,6 @@ const APP_STATE_STORAGE_CONF = (<
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => config)({
appearance: { browser: true, export: false },
collaborators: { browser: false, export: false },
currentItemBackgroundColor: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false },
@ -121,7 +118,6 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isCollaborating: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false },
isLoading: { browser: false, export: false },
isResizing: { browser: false, export: false },
@ -142,7 +138,6 @@ const APP_STATE_STORAGE_CONF = (<
shouldCacheIgnoreZoom: { browser: true, export: false },
showShortcutsDialog: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
username: { browser: true, export: false },
viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
@ -150,6 +145,7 @@ const APP_STATE_STORAGE_CONF = (<
offsetTop: { browser: false, export: false },
offsetLeft: { browser: false, export: false },
fileHandle: { browser: false, export: false },
collaborators: { browser: false, export: false },
});
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(

View File

@ -15,8 +15,6 @@ import {
getCursorForResizingElement,
getPerfectElementSize,
getNormalizedDimensions,
getSceneVersion,
getSyncableElements,
newLinearElement,
transformElements,
getElementWithTransformHandleType,
@ -42,17 +40,16 @@ import {
isSomeElementSelected,
calculateScrollCenter,
} from "../scene";
import {
decryptAESGEM,
loadScene,
loadFromBlob,
SOCKET_SERVER,
exportCanvas,
} from "../data";
import Portal from "./Portal";
import { loadFromBlob, exportCanvas } from "../data";
import { renderScene } from "../renderer";
import { AppState, GestureEvent, Gesture, ExcalidrawProps } from "../types";
import {
AppState,
GestureEvent,
Gesture,
ExcalidrawProps,
SceneData,
} from "../types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
@ -75,6 +72,9 @@ import {
sceneCoordsToViewportCoords,
setCursorForShape,
tupleToCoors,
ResolvablePromise,
resolvablePromise,
withBatchedUpdates,
} from "../utils";
import {
KEYS,
@ -116,28 +116,20 @@ import {
DRAGGING_THRESHOLD,
TEXT_TO_CENTER_SNAP_THRESHOLD,
LINE_CONFIRM_THRESHOLD,
SCENE,
EVENT,
ENV,
CANVAS_ONLY_ACTIONS,
DEFAULT_VERTICAL_ALIGN,
GRID_SIZE,
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
MIME_TYPES,
} from "../constants";
import {
INITIAL_SCENE_UPDATE_TIMEOUT,
TAP_TWICE_TIMEOUT,
SYNC_FULL_SCENE_INTERVAL_MS,
TOUCH_CTX_MENU_TIMEOUT,
} from "../time_constants";
} from "../constants";
import LayerUI from "./LayerUI";
import { ScrollBars, SceneState } from "../scene/types";
import { generateCollaborationLink, getCollaborationLinkData } from "../data";
import { mutateElement } from "../element/mutateElement";
import { invalidateShapeForElement } from "../renderer/renderElement";
import { unstable_batchedUpdates } from "react-dom";
import {
isLinearElement,
isLinearElementType,
@ -146,7 +138,6 @@ import {
} from "../element/typeChecks";
import { actionFinalize, actionDeleteSelected } from "../actions";
import throttle from "lodash.throttle";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
getSelectedGroupIds,
@ -175,32 +166,15 @@ import {
import { MaybeTransformHandleType } from "../element/transformHandles";
import { renderSpreadsheet } from "../charts";
import { isValidLibrary } from "../data/json";
import {
loadFromFirebase,
saveToFirebase,
isSavedToFirebase,
} from "../data/firebase";
import { getNewZoom } from "../scene/zoom";
import { restore } from "../data/restore";
import {
EVENT_DIALOG,
EVENT_LIBRARY,
EVENT_SHAPE,
EVENT_SHARE,
trackEvent,
} from "../analytics";
/**
* @param func handler taking at most single parameter (event).
*/
const withBatchedUpdates = <
TFunction extends ((event: any) => void) | (() => void)
>(
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
) =>
((event) => {
unstable_batchedUpdates(func as TFunction, event);
}) as TFunction;
const { history } = createHistory();
let didTapTwice: boolean = false;
@ -275,58 +249,77 @@ export type PointerDownState = Readonly<{
};
}>;
export type ExcalidrawImperativeAPI =
| {
updateScene: InstanceType<typeof App>["updateScene"];
resetScene: InstanceType<typeof App>["resetScene"];
resetHistory: InstanceType<typeof App>["resetHistory"];
getSceneElementsIncludingDeleted: InstanceType<
typeof App
>["getSceneElementsIncludingDeleted"];
}
| undefined;
export type ExcalidrawImperativeAPI = {
updateScene: InstanceType<typeof App>["updateScene"];
resetScene: InstanceType<typeof App>["resetScene"];
getSceneElementsIncludingDeleted: InstanceType<
typeof App
>["getSceneElementsIncludingDeleted"];
history: {
clear: InstanceType<typeof App>["resetHistory"];
};
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
getSceneElements: InstanceType<typeof App>["getSceneElements"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
};
class App extends React.Component<ExcalidrawProps, AppState> {
canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | null = null;
portal: Portal;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
unmounted: boolean = false;
actionManager: ActionManager;
private excalidrawRef: any;
private socketInitializationTimer: any;
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
public static defaultProps: Partial<ExcalidrawProps> = {
width: window.innerWidth,
height: window.innerHeight,
};
private scene: Scene;
constructor(props: ExcalidrawProps) {
super(props);
const defaultAppState = getDefaultAppState();
const { width, height, offsetLeft, offsetTop, user, forwardedRef } = props;
const {
width = window.innerWidth,
height = window.innerHeight,
offsetLeft,
offsetTop,
excalidrawRef,
} = props;
this.state = {
...defaultAppState,
isLoading: true,
width,
height,
username: user?.name || "",
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
};
if (forwardedRef && "current" in forwardedRef) {
forwardedRef.current = {
if (excalidrawRef) {
const readyPromise =
typeof excalidrawRef === "function"
? resolvablePromise<ExcalidrawImperativeAPI>()
: excalidrawRef.current!.readyPromise;
const api: ExcalidrawImperativeAPI = {
ready: true,
readyPromise,
updateScene: this.updateScene,
resetScene: this.resetScene,
resetHistory: this.resetHistory,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
};
history: {
clear: this.resetHistory,
},
setScrollToCenter: this.setScrollToCenter,
getSceneElements: this.getSceneElements,
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
} else {
excalidrawRef.current = api;
}
readyPromise.resolve(api);
}
this.scene = new Scene();
this.portal = new Portal(this);
this.excalidrawRef = React.createRef();
this.actionManager = new ActionManager(
this.syncActionResult,
() => this.state,
@ -347,7 +340,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
offsetLeft,
} = this.state;
const { onUsernameChange } = this.props;
const { onCollabButtonClick } = this.props;
const canvasScale = window.devicePixelRatio;
const canvasWidth = canvasDOMWidth * canvasScale;
@ -356,7 +349,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return (
<div
className="excalidraw"
ref={this.excalidrawRef}
ref={this.excalidrawContainerRef}
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
@ -370,12 +363,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getElements()}
onRoomCreate={this.openPortal}
onRoomDestroy={this.closePortal}
onUsernameChange={(username) => {
onUsernameChange && onUsernameChange(username);
this.setState({ username });
}}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={this.toggleLock}
onInsertShape={(elements) =>
this.addElementsFromPasteOrLibrary(elements)
@ -383,6 +371,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
zenModeEnabled={zenModeEnabled}
toggleZenMode={this.toggleZenMode}
lng={getLanguage().lng}
isCollaborating={this.props.isCollaborating || false}
/>
<main>
<canvas
@ -410,18 +399,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
);
}
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
this.lastBroadcastedOrReceivedSceneVersion = version;
};
public getLastBroadcastedOrReceivedSceneVersion = () => {
return this.lastBroadcastedOrReceivedSceneVersion;
};
public getSceneElementsIncludingDeleted = () => {
return this.scene.getElementsIncludingDeleted();
};
public getSceneElements = () => {
return this.scene.getElements();
};
private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) {
@ -454,8 +439,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
...actionResult.appState,
editingElement:
editingElement || actionResult.appState?.editingElement || null,
isCollaborating: state.isCollaborating,
collaborators: state.collaborators,
width: state.width,
height: state.height,
offsetTop: state.offsetTop,
@ -482,7 +465,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
});
private onUnload = () => {
this.destroySocketClient();
this.onBlur();
};
@ -499,46 +481,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.onSceneUpdated();
};
private shouldForceLoadScene(
scene: ResolutionType<typeof loadScene>,
): boolean {
if (!scene.elements.length) {
return true;
}
const roomMatch = getCollaborationLinkData(window.location.href);
if (!roomMatch) {
return false;
}
const roomId = roomMatch[1];
let collabForceLoadFlag;
try {
collabForceLoadFlag = localStorage?.getItem(
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
);
} catch {}
if (collabForceLoadFlag) {
try {
const {
room: previousRoom,
timestamp,
}: { room: string; timestamp: number } = JSON.parse(
collabForceLoadFlag,
);
// if loading same room as the one previously unloaded within 15sec
// force reload without prompting
if (previousRoom === roomId && Date.now() - timestamp < 15000) {
return true;
}
} catch {}
}
return false;
}
private importLibraryFromUrl = async (url: string) => {
window.history.replaceState({}, "Excalidraw", window.location.origin);
try {
@ -569,17 +511,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
history.clear();
};
// Completely resets scene & history.
// Do not use for clear scene user action.
private resetScene = withBatchedUpdates(() => {
this.scene.replaceAllElements([]);
this.setState({
...getDefaultAppState(),
appearance: this.state.appearance,
username: this.state.username,
});
this.resetHistory();
});
/**
* Resets scene & history.
* ! Do not use to clear scene user action !
*/
private resetScene = withBatchedUpdates(
(opts?: { resetLoadingState: boolean }) => {
this.scene.replaceAllElements([]);
this.setState((state) => ({
...getDefaultAppState(),
isLoading: opts?.resetLoadingState ? false : state.isLoading,
appearance: this.state.appearance,
}));
this.resetHistory();
},
);
private initializeScene = async () => {
if ("launchQueue" in window && "LaunchParams" in window) {
@ -609,86 +555,42 @@ class App extends React.Component<ExcalidrawProps, AppState> {
);
}
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
const jsonMatch = window.location.hash.match(
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
);
if (!this.state.isLoading) {
this.setState({ isLoading: true });
}
let scene = await loadScene(null, null, this.props.initialData);
let isCollaborationScene = !!getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonMatch || isCollaborationScene);
if (isExternalScene) {
if (
this.shouldForceLoadScene(scene) ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
// Backwards compatibility with legacy url format
if (id) {
scene = await loadScene(id, null, this.props.initialData);
} else if (jsonMatch) {
scene = await loadScene(
jsonMatch[1],
jsonMatch[2],
this.props.initialData,
);
}
if (!isCollaborationScene) {
window.history.replaceState({}, "Excalidraw", window.location.origin);
}
} else {
// https://github.com/excalidraw/excalidraw/issues/1919
if (document.hidden) {
window.addEventListener("focus", () => this.initializeScene(), {
once: true,
});
return;
}
isCollaborationScene = false;
window.history.replaceState({}, "Excalidraw", window.location.origin);
}
let initialData = null;
try {
initialData = (await this.props.initialData) || null;
} catch (error) {
console.error(error);
}
if (this.state.isLoading) {
this.setState({ isLoading: false });
}
const scene = restore(initialData, null);
if (isCollaborationScene) {
// when joining a room we don't want user's local scene data to be merged
// into the remote scene
this.resetScene();
this.initializeSocketClient({ showLoadingState: true });
trackEvent(EVENT_SHARE, "session join");
} else if (scene) {
if (scene.appState) {
scene.appState = {
scene.appState = {
...scene.appState,
...calculateScrollCenter(
scene.elements,
{
...scene.appState,
...calculateScrollCenter(
scene.elements,
{
...scene.appState,
offsetTop: this.state.offsetTop,
offsetLeft: this.state.offsetLeft,
},
null,
),
};
}
this.resetHistory();
this.syncActionResult({
...scene,
commitToHistory: true,
});
}
offsetTop: this.state.offsetTop,
offsetLeft: this.state.offsetLeft,
},
null,
),
isLoading: false,
};
const addToLibraryUrl = searchParams.get("addLibrary");
this.resetHistory();
this.syncActionResult({
...scene,
commitToHistory: true,
});
const addToLibraryUrl = new URLSearchParams(window.location.search).get(
"addLibrary",
);
if (addToLibraryUrl) {
await this.importLibraryFromUrl(addToLibraryUrl);
@ -752,12 +654,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.setState({});
});
private onHashChange = (_: HashChangeEvent) => {
if (window.location.hash.length > 1) {
this.initializeScene();
}
};
private removeEventListeners() {
document.removeEventListener(EVENT.COPY, this.onCopy);
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
@ -775,7 +671,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
window.removeEventListener(EVENT.BLUR, this.onBlur, false);
window.removeEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
window.removeEventListener(EVENT.DROP, this.disableEvent, false);
window.removeEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
document.removeEventListener(
EVENT.GESTURE_START,
@ -792,7 +687,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.onGestureEnd as any,
false,
);
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
}
private addEventListeners() {
@ -811,7 +705,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
window.addEventListener(EVENT.BLUR, this.onBlur, false);
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
window.addEventListener(EVENT.DROP, this.disableEvent, false);
window.addEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
// rerender text elements on font load to fix #637 && #1553
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
@ -832,42 +725,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.onGestureEnd as any,
false,
);
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
}
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
if (this.state.isCollaborating && this.portal.roomId) {
try {
localStorage?.setItem(
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
JSON.stringify({
timestamp: Date.now(),
room: this.portal.roomId,
}),
);
} catch {}
}
const syncableElements = getSyncableElements(
this.scene.getElementsIncludingDeleted(),
);
if (
this.state.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 = "";
}
});
queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
}, SYNC_FULL_SCENE_INTERVAL_MS);
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
if (
prevProps.width !== this.props.width ||
@ -878,8 +737,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
prevProps.offsetTop !== this.props.offsetTop)
) {
this.setState({
width: this.props.width,
height: this.props.height,
width: this.props.width ?? window.innerWidth,
height: this.props.height ?? window.innerHeight,
...this.getCanvasOffsets(this.props),
});
}
@ -990,19 +849,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.setState({ scrolledOutside });
}
if (
getSceneVersion(this.scene.getElementsIncludingDeleted()) >
this.lastBroadcastedOrReceivedSceneVersion
) {
this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
this.queueBroadcastAllElements();
}
history.record(this.state, this.scene.getElementsIncludingDeleted());
if (this.props.onChange) {
this.props.onChange(this.scene.getElementsIncludingDeleted(), this.state);
}
this.props.onChange?.(this.scene.getElementsIncludingDeleted(), this.state);
}
// Copy/paste
@ -1254,31 +1103,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
gesture.pointers.delete(event.pointerId);
};
openPortal = async () => {
window.history.pushState(
{},
"Excalidraw",
await generateCollaborationLink(),
);
// 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.
history.clear();
history.resumeRecording();
this.scene.replaceAllElements(this.scene.getElements());
await this.initializeSocketClient({ showLoadingState: false });
trackEvent(EVENT_SHARE, "session start");
};
closePortal = () => {
this.saveCollabRoomToFirebase();
window.history.pushState({}, "Excalidraw", window.location.origin);
this.destroySocketClient();
trackEvent(EVENT_SHARE, "session end");
};
toggleLock = () => {
this.setState((prevState) => {
trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
@ -1313,202 +1137,26 @@ class App extends React.Component<ExcalidrawProps, AppState> {
});
};
private handleRemoteSceneUpdate = (
elements: readonly ExcalidrawElement[],
{
init = false,
initFromSnapshot = false,
}: { init?: boolean; initFromSnapshot?: boolean } = {},
) => {
if (init) {
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
if (sceneData.commitToHistory) {
history.resumeRecording();
}
if (init || initFromSnapshot) {
this.setScrollToCenter(elements);
}
const newElements = this.portal.reconcileElements(elements);
// 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));
this.updateScene({ elements: newElements });
// 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.resetHistory();
if (!this.portal.socketInitialized && !initFromSnapshot) {
this.initializeSocket();
}
};
private destroySocketClient = () => {
this.setState({
isCollaborating: false,
collaborators: new Map(),
});
this.portal.close();
};
public updateScene = withBatchedUpdates(
(sceneData: {
elements: readonly ExcalidrawElement[];
appState?: AppState;
}) => {
// currently we only support syncing background color
if (sceneData.appState?.viewBackgroundColor) {
this.setState({
viewBackgroundColor: sceneData.appState.viewBackgroundColor,
});
}
this.scene.replaceAllElements(sceneData.elements);
},
);
private initializeSocket = () => {
this.portal.socketInitialized = true;
clearTimeout(this.socketInitializationTimer);
if (this.state.isLoading && !this.unmounted) {
this.setState({ isLoading: false });
}
};
private initializeSocketClient = async (opts: {
showLoadingState: boolean;
}) => {
if (this.portal.socket) {
return;
}
const roomMatch = getCollaborationLinkData(window.location.href);
if (roomMatch) {
const roomId = roomMatch[1];
const roomKey = roomMatch[2];
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_UPDATE message
this.socketInitializationTimer = setTimeout(
this.initializeSocket,
INITIAL_SCENE_UPDATE_TIMEOUT,
);
const { default: socketIOClient }: any = await import("socket.io-client");
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
// 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) {
const remoteElements = decryptedData.payload.elements;
this.handleRemoteSceneUpdate(remoteElements, { init: true });
}
break;
}
case SCENE.UPDATE:
this.handleRemoteSceneUpdate(decryptedData.payload.elements);
break;
case "MOUSE_LOCATION": {
const {
socketId,
pointer,
button,
username,
selectedElementIds,
} = decryptedData.payload;
// NOTE purposefully mutating collaborators map in case of
// pointer updates so as not to trigger LayerUI rerender
this.setState((state) => {
if (!state.collaborators.has(socketId)) {
state.collaborators.set(socketId, {});
}
const user = state.collaborators.get(socketId)!;
user.pointer = pointer;
user.button = button;
user.selectedElementIds = selectedElementIds;
user.username = username;
state.collaborators.set(socketId, user);
return state;
});
break;
}
}
},
);
this.portal.socket!.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
}
this.initializeSocket();
});
// currently we only support syncing background color
if (sceneData.appState?.viewBackgroundColor) {
this.setState({
isCollaborating: true,
isLoading: opts.showLoadingState ? true : this.state.isLoading,
viewBackgroundColor: sceneData.appState.viewBackgroundColor,
});
try {
const elements = await loadFromFirebase(roomId, roomKey);
if (elements) {
this.handleRemoteSceneUpdate(elements, { initFromSnapshot: true });
}
} catch (error) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
}
}
};
// Portal-only
setCollaborators(sockets: string[]) {
this.setState((state) => {
const collaborators: typeof state.collaborators = new Map();
for (const socketId of sockets) {
if (state.collaborators.has(socketId)) {
collaborators.set(socketId, state.collaborators.get(socketId)!);
} else {
collaborators.set(socketId, {});
}
}
return {
...state,
collaborators,
};
});
}
saveCollabRoomToFirebase = async (
syncableElements: ExcalidrawElement[] = getSyncableElements(
this.scene.getElementsIncludingDeleted(),
),
) => {
try {
await saveToFirebase(this.portal, syncableElements);
} catch (error) {
console.error(error);
if (sceneData.elements) {
this.scene.replaceAllElements(sceneData.elements);
}
};
if (sceneData.collaborators) {
this.setState({ collaborators: sceneData.collaborators });
}
});
private onSceneUpdated = () => {
this.setState({});
@ -3989,15 +3637,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (isNaN(pointer.x) || isNaN(pointer.y)) {
// sometimes the pointer goes off screen
return;
}
this.portal.socket &&
// do not broadcast when more than 1 pointer since that shows flickering on the other side
gesture.pointers.size < 2 &&
this.portal.broadcastMouseLocation({
pointer,
button,
});
this.props.onPointerUpdate?.({
pointer,
button,
pointersMap: gesture.pointers,
});
};
private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
@ -4017,8 +3663,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
offsetTop: offsets.offsetTop,
};
}
if (this.excalidrawRef?.current) {
const parentElement = this.excalidrawRef.current.parentElement;
if (this.excalidrawContainerRef?.current?.parentElement) {
const parentElement = this.excalidrawContainerRef.current.parentElement;
const { left, top } = parentElement.getBoundingClientRect();
return {
offsetLeft:
@ -4048,6 +3694,9 @@ declare global {
history: SceneHistory;
app: InstanceType<typeof App>;
library: typeof Library;
collab: InstanceType<
typeof import("../excalidraw-app/collab/CollabWrapper").default
>;
};
}
}
@ -4056,10 +3705,11 @@ if (
process.env.NODE_ENV === ENV.TEST ||
process.env.NODE_ENV === ENV.DEVELOPMENT
) {
window.h = {} as Window["h"];
window.h = window.h || ({} as Window["h"]);
Object.defineProperties(window.h, {
elements: {
configurable: true,
get() {
return this.app.scene.getElementsIncludingDeleted();
},
@ -4068,9 +3718,11 @@ if (
},
},
history: {
configurable: true,
get: () => history,
},
library: {
configurable: true,
value: Library,
},
});

View File

@ -0,0 +1,29 @@
@import "../css/_variables";
.excalidraw {
.CollabButton.is-collaborating {
background-color: var(--button-special-active-background-color);
.ToolIcon__icon svg {
color: var(--icon-green-fill-color);
}
}
.CollabButton-collaborators {
:root[dir="ltr"] & {
right: -5px;
}
:root[dir="rtl"] & {
left: -5px;
}
min-width: 1em;
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);
}
}

View File

@ -0,0 +1,44 @@
import React from "react";
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { users } from "./icons";
import "./CollabButton.scss";
import { EVENT_DIALOG, trackEvent } from "../analytics";
const CollabButton = ({
isCollaborating,
collaboratorCount,
onClick,
}: {
isCollaborating: boolean;
collaboratorCount: number;
onClick: () => void;
}) => {
return (
<>
<ToolButton
className={clsx("CollabButton", {
"is-collaborating": isCollaborating,
})}
onClick={() => {
trackEvent(EVENT_DIALOG, "collaboration");
onClick();
}}
icon={users}
type="button"
title={t("buttons.roomDialog")}
aria-label={t("buttons.roomDialog")}
showAriaLabel={useIsMobile()}
>
{collaboratorCount > 0 && (
<div className="CollabButton-collaborators">{collaboratorCount}</div>
)}
</ToolButton>
</>
);
};
export default CollabButton;

View File

@ -28,7 +28,7 @@ import { ExportType } from "../scene/types";
import { MobileMenu } from "./MobileMenu";
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import { RoomDialog } from "./RoomDialog";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
import { ShortcutsDialog } from "./ShortcutsDialog";
import { LoadingMessage } from "./LoadingMessage";
@ -58,14 +58,13 @@ interface LayerUIProps {
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onRoomCreate: () => void;
onUsernameChange: (username: string) => void;
onRoomDestroy: () => void;
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onInsertShape: (elements: LibraryItem) => void;
zenModeEnabled: boolean;
toggleZenMode: () => void;
lng: string;
isCollaborating: boolean;
}
const useOnClickOutside = (
@ -299,13 +298,12 @@ const LayerUI = ({
setAppState,
canvas,
elements,
onRoomCreate,
onUsernameChange,
onRoomDestroy,
onCollabButtonClick,
onLockToggle,
onInsertShape,
zenModeEnabled,
toggleZenMode,
isCollaborating,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@ -400,17 +398,13 @@ const LayerUI = ({
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
{actionManager.renderAction("clearCanvas")}
<RoomDialog
isCollaborating={appState.isCollaborating}
collaboratorCount={appState.collaborators.size}
username={appState.username}
onUsernameChange={onUsernameChange}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
setErrorMessage={(message: string) =>
setAppState({ errorMessage: message })
}
/>
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
</Stack.Row>
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
@ -602,11 +596,10 @@ const LayerUI = ({
libraryMenu={libraryMenu}
exportButton={renderExportDialog()}
setAppState={setAppState}
onUsernameChange={onUsernameChange}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
canvas={canvas}
isCollaborating={isCollaborating}
/>
) : (
<div className="layer-ui__wrapper">

View File

@ -12,7 +12,7 @@ import { HintViewer } from "./HintViewer";
import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import { RoomDialog } from "./RoomDialog";
import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockIcon } from "./LockIcon";
import { LoadingMessage } from "./LoadingMessage";
@ -27,11 +27,10 @@ type MobileMenuProps = {
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
libraryMenu: JSX.Element | null;
onRoomCreate: () => void;
onUsernameChange: (username: string) => void;
onRoomDestroy: () => void;
onCollabButtonClick?: () => void;
onLockToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
};
export const MobileMenu = ({
@ -41,11 +40,10 @@ export const MobileMenu = ({
actionManager,
exportButton,
setAppState,
onRoomCreate,
onUsernameChange,
onRoomDestroy,
onCollabButtonClick,
onLockToggle,
canvas,
isCollaborating,
}: MobileMenuProps) => (
<>
{appState.isLoading && <LoadingMessage />}
@ -94,17 +92,13 @@ export const MobileMenu = ({
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
<RoomDialog
isCollaborating={appState.isCollaborating}
collaboratorCount={appState.collaborators.size}
username={appState.username}
onUsernameChange={onUsernameChange}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
setErrorMessage={(message: string) =>
setAppState({ errorMessage: message })
}
/>
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}

View File

@ -1,202 +0,0 @@
import clsx from "clsx";
import React, { useEffect, useRef, useState } from "react";
import { EVENT_DIALOG, EVENT_SHARE, trackEvent } from "../analytics";
import { copyTextToSystemClipboard } from "../clipboard";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import { AppState } from "../types";
import { Dialog } from "./Dialog";
import { clipboard, start, stop, users } from "./icons";
import "./RoomDialog.scss";
import { ToolButton } from "./ToolButton";
const RoomModal = ({
activeRoomLink,
username,
onUsernameChange,
onRoomCreate,
onRoomDestroy,
onPressingEnter,
setErrorMessage,
}: {
activeRoomLink: string;
username: string;
onUsernameChange: (username: string) => void;
onRoomCreate: () => void;
onRoomDestroy: () => void;
onPressingEnter: () => void;
setErrorMessage: (message: string) => void;
}) => {
const roomLinkInput = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
trackEvent(EVENT_SHARE, "copy link");
} catch (error) {
setErrorMessage(error.message);
}
if (roomLinkInput.current) {
roomLinkInput.current.select();
}
};
const selectInput = (event: React.MouseEvent<HTMLInputElement>) => {
if (event.target !== document.activeElement) {
event.preventDefault();
(event.target as HTMLInputElement).select();
}
};
return (
<div className="RoomDialog-modal">
{!activeRoomLink && (
<>
<p>{t("roomDialog.desc_intro")}</p>
<p>{`🔒 ${t("roomDialog.desc_privacy")}`}</p>
<div className="RoomDialog-sessionStartButtonContainer">
<ToolButton
className="RoomDialog-startSession"
type="button"
icon={start}
title={t("roomDialog.button_startSession")}
aria-label={t("roomDialog.button_startSession")}
showAriaLabel={true}
onClick={onRoomCreate}
/>
</div>
</>
)}
{activeRoomLink && (
<>
<p>{t("roomDialog.desc_inProgressIntro")}</p>
<p>{t("roomDialog.desc_shareLink")}</p>
<div className="RoomDialog-linkContainer">
<ToolButton
type="button"
icon={clipboard}
title={t("labels.copy")}
aria-label={t("labels.copy")}
onClick={copyRoomLink}
/>
<input
value={activeRoomLink}
readOnly={true}
className="RoomDialog-link"
ref={roomLinkInput}
onPointerDown={selectInput}
/>
</div>
<div className="RoomDialog-usernameContainer">
<label className="RoomDialog-usernameLabel" htmlFor="username">
{t("labels.yourName")}
</label>
<input
id="username"
value={username || ""}
className="RoomDialog-username TextInput"
onChange={(event) => onUsernameChange(event.target.value)}
onBlur={() => trackEvent(EVENT_SHARE, "name")}
onKeyPress={(event) =>
event.key === KEYS.ENTER && onPressingEnter()
}
/>
</div>
<p>
<span role="img" aria-hidden="true" className="RoomDialog-emoji">
{"🔒"}
</span>{" "}
{t("roomDialog.desc_privacy")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
<div className="RoomDialog-sessionStartButtonContainer">
<ToolButton
className="RoomDialog-stopSession"
type="button"
icon={stop}
title={t("roomDialog.button_stopSession")}
aria-label={t("roomDialog.button_stopSession")}
showAriaLabel={true}
onClick={onRoomDestroy}
/>
</div>
</>
)}
</div>
);
};
export const RoomDialog = ({
isCollaborating,
collaboratorCount,
username,
onUsernameChange,
onRoomCreate,
onRoomDestroy,
setErrorMessage,
}: {
isCollaborating: AppState["isCollaborating"];
collaboratorCount: number;
username: string;
onUsernameChange: (username: string) => void;
onRoomCreate: () => void;
onRoomDestroy: () => void;
setErrorMessage: (message: string) => void;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const [activeRoomLink, setActiveRoomLink] = useState("");
const triggerButton = useRef<HTMLButtonElement>(null);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
triggerButton.current?.focus();
}, []);
useEffect(() => {
setActiveRoomLink(isCollaborating ? window.location.href : "");
}, [isCollaborating]);
return (
<>
<ToolButton
className={clsx("RoomDialog-modalButton", {
"is-collaborating": isCollaborating,
})}
onClick={() => {
trackEvent(EVENT_DIALOG, "collaboration");
setModalIsShown(true);
}}
icon={users}
type="button"
title={t("buttons.roomDialog")}
aria-label={t("buttons.roomDialog")}
showAriaLabel={useIsMobile()}
ref={triggerButton}
>
{collaboratorCount > 0 && (
<div className="RoomDialog-modalButton-collaborators">
{collaboratorCount}
</div>
)}
</ToolButton>
{modalIsShown && (
<Dialog
maxWidth={800}
onCloseRequest={handleClose}
title={t("labels.createRoom")}
>
<RoomModal
activeRoomLink={activeRoomLink}
username={username}
onUsernameChange={onUsernameChange}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
onPressingEnter={handleClose}
setErrorMessage={setErrorMessage}
/>
</Dialog>
)}
</>
);
};

View File

@ -21,11 +21,6 @@ export const POINTER_BUTTON = {
TOUCH: -1,
};
export enum SCENE {
INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE",
}
export enum EVENT {
COPY = "copy",
PASTE = "paste",
@ -56,11 +51,6 @@ export const ENV = {
DEVELOPMENT: "development",
};
export const BROADCAST = {
SERVER_VOLATILE: "server-volatile-broadcast",
SERVER: "server-broadcast",
};
export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
};
@ -83,16 +73,15 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable?
export const LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG = "collabLinkForceLoadFlag";
export const MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
};
export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
};
// time in milliseconds
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;

View File

@ -12,162 +12,14 @@ import {
import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { ExportType } from "../scene/types";
import { AppState } from "../types";
import { canvasToBlob } from "./blob";
import { AppState } from "../types";
import { serializeAsJSON } from "./json";
import { restore } from "./restore";
import { ImportedDataState } from "./types";
export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
export type EncryptedData = {
data: ArrayBuffer;
iv: Uint8Array;
};
export type SocketUpdateDataSource = {
SCENE_INIT: {
type: "SCENE_INIT";
payload: {
elements: readonly ExcalidrawElement[];
};
};
SCENE_UPDATE: {
type: "SCENE_UPDATE";
payload: {
elements: readonly ExcalidrawElement[];
};
};
MOUSE_LOCATION: {
type: "MOUSE_LOCATION";
payload: {
socketId: string;
pointer: { x: number; y: number };
button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"];
username: string;
};
};
};
export type SocketUpdateDataIncoming =
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
| {
type: "INVALID_RESPONSE";
};
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
const generateRandomID = async () => {
const arr = new Uint8Array(10);
window.crypto.getRandomValues(arr);
return Array.from(arr, byteToHex).join("");
};
const generateEncryptionKey = async () => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
return (await window.crypto.subtle.exportKey("jwk", key)).k;
};
export const createIV = () => {
const arr = new Uint8Array(12);
return window.crypto.getRandomValues(arr);
};
export const getCollaborationLinkData = (link: string) => {
if (link.length === 0) {
return;
}
const hash = new URL(link).hash;
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
};
export const generateCollaborationLink = async () => {
const id = await generateRandomID();
const key = await generateEncryptionKey();
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
};
export const getImportedKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: 128,
},
false, // extractable
[usage],
);
export const encryptAESGEM = async (
data: Uint8Array,
key: string,
): Promise<EncryptedData> => {
const importedKey = await getImportedKey(key, "encrypt");
const iv = createIV();
return {
data: await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
data,
),
iv,
};
};
export const decryptAESGEM = async (
data: ArrayBuffer,
key: string,
iv: Uint8Array,
): Promise<SocketUpdateDataIncoming> => {
try {
const importedKey = await getImportedKey(key, "decrypt");
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
data,
);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any,
);
return JSON.parse(decodedData);
} catch (error) {
window.alert(t("alerts.decryptFailed"));
console.error(error);
}
return {
type: "INVALID_RESPONSE",
};
};
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
@ -226,53 +78,6 @@ export const exportToBackend = async (
}
};
const importFromBackend = async (
id: string | null,
privateKey?: string | null,
): Promise<ImportedDataState> => {
try {
const response = await fetch(
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
);
if (!response.ok) {
window.alert(t("alerts.importBackendFailed"));
return {};
}
let data: ImportedDataState;
if (privateKey) {
const buffer = await response.arrayBuffer();
const key = await getImportedKey(privateKey, "decrypt");
const iv = new Uint8Array(12);
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
buffer,
);
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any,
);
data = JSON.parse(string);
} else {
// Legacy format
data = await response.json();
}
trackEvent(EVENT_IO, "import");
return {
elements: data.elements || null,
appState: data.appState || null,
};
} catch (error) {
window.alert(t("alerts.importBackendFailed"));
console.error(error);
return {};
}
};
export const exportCanvas = async (
type: ExportType,
elements: readonly NonDeletedExcalidrawElement[],
@ -378,30 +183,3 @@ export const exportCanvas = async (
tempCanvas.remove();
}
};
export const loadScene = async (
id: string | null,
privateKey: string | null,
// Supply initialData even if importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
// Non-optional so we don't forget to pass it even if `undefined`.
initialData: ImportedDataState | undefined | null,
) => {
let data;
if (id != null) {
// the private key is used to decrypt the content from the server, take
// extra care not to leak it
data = restore(
await importFromBackend(id, privateKey),
initialData?.appState,
);
} else {
data = restore(initialData || {}, null);
}
return {
elements: data.elements,
appState: data.appState,
commitToHistory: false,
};
};

View File

@ -173,7 +173,7 @@ const restoreAppState = (
};
export const restore = (
data: ImportedDataState,
data: ImportedDataState | null,
/**
* Local AppState (`this.state` or initial state from localStorage) so that we
* don't overwrite local state with default values (when values not
@ -183,7 +183,7 @@ export const restore = (
localAppState: Partial<AppState> | null | undefined,
): DataState => {
return {
elements: restoreElements(data.elements),
appState: restoreAppState(data.appState, localAppState || null),
elements: restoreElements(data?.elements),
appState: restoreAppState(data?.appState, localAppState || null),
};
};

View File

@ -0,0 +1,14 @@
// time constants (ms)
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
export const BROADCAST = {
SERVER_VOLATILE: "server-volatile-broadcast",
SERVER: "server-broadcast",
};
export enum SCENE {
INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE",
}

View File

@ -0,0 +1,476 @@
import React, { PureComponent } from "react";
import throttle from "lodash.throttle";
import { ENV, EVENT } from "../../constants";
import {
decryptAESGEM,
SocketUpdateDataSource,
getCollaborationLinkData,
generateCollaborationLink,
SOCKET_SERVER,
} from "../data";
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
import Portal from "./Portal";
import { AppState, Collaborator, Gesture } from "../../types";
import { ExcalidrawElement } from "../../element/types";
import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
STORAGE_KEYS,
} from "../data/localStorage";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
getSceneVersion,
getSyncableElements,
} from "../../packages/excalidraw/index";
import RoomDialog from "./RoomDialog";
import { ErrorDialog } from "../../components/ErrorDialog";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawImperativeAPI } from "../../components/App";
import {
INITIAL_SCENE_UPDATE_TIMEOUT,
SCENE,
SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants";
import { EVENT_SHARE, trackEvent } from "../../analytics";
interface CollabState {
isCollaborating: boolean;
modalIsShown: boolean;
errorMessage: string;
username: string;
activeRoomLink: string;
}
type CollabInstance = InstanceType<typeof CollabWrapper>;
export interface CollabAPI {
isCollaborating: CollabState["isCollaborating"];
username: CollabState["username"];
onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
broadcastElements: CollabInstance["broadcastElements"];
}
type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
interface Props {
children: (collab: CollabAPI) => React.ReactNode;
// NOTE not type-safe because the refObject may in fact not be initialized
// with ExcalidrawImperativeAPI yet
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
}
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
private socketInitializationTimer?: NodeJS.Timeout;
private excalidrawRef: Props["excalidrawRef"];
excalidrawAppState?: AppState;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
constructor(props: Props) {
super(props);
this.state = {
isCollaborating: false,
modalIsShown: false,
errorMessage: "",
username: importUsernameFromLocalStorage() || "",
activeRoomLink: "",
};
this.portal = new Portal(this);
this.excalidrawRef = props.excalidrawRef;
}
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();
};
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
const syncableElements = getSyncableElements(
this.getSceneElementsIncludingDeleted(),
);
if (
this.state.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.state.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.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
),
) => {
try {
await saveToFirebase(this.portal, syncableElements);
} catch (error) {
console.error(error);
}
};
openPortal = async () => {
window.history.pushState(
{},
"Excalidraw",
await generateCollaborationLink(),
);
const elements = this.excalidrawRef.current!.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.excalidrawRef.current!.history.clear();
this.excalidrawRef.current!.updateScene({
elements,
commitToHistory: true,
});
trackEvent(EVENT_SHARE, "session start");
return this.initializeSocketClient();
};
closePortal = () => {
this.saveCollabRoomToFirebase();
window.history.pushState({}, "Excalidraw", window.location.origin);
this.destroySocketClient();
trackEvent(EVENT_SHARE, "session end");
};
private destroySocketClient = () => {
this.collaborators = new Map();
this.excalidrawRef.current!.updateScene({
collaborators: this.collaborators,
});
this.setState({
isCollaborating: false,
activeRoomLink: "",
});
this.portal.close();
};
private initializeSocketClient = async (): Promise<ImportedDataState | null> => {
if (this.portal.socket) {
return null;
}
const scenePromise = resolvablePromise<ImportedDataState | null>();
const roomMatch = getCollaborationLinkData(window.location.href);
if (roomMatch) {
const roomId = roomMatch[1];
const roomKey = roomMatch[2];
// 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);
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
);
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
// 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) {
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(
remoteElements,
);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
this.initializeSocket();
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.excalidrawRef.current!.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({
isCollaborating: true,
activeRoomLink: window.location.href,
});
return scenePromise;
}
return null;
};
private initializeSocket = () => {
this.portal.socketInitialized = true;
clearTimeout(this.socketInitializationTimer!);
};
private reconcileElements = (
elements: readonly ExcalidrawElement[],
): ReconciledElements => {
const newElements = this.portal.reconcileElements(elements);
// 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.excalidrawRef.current!.setScrollToCenter(elements);
}
this.excalidrawRef.current!.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.excalidrawRef.current!.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.excalidrawRef.current!.updateScene({ collaborators });
});
}
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
this.lastBroadcastedOrReceivedSceneVersion = version;
};
public getLastBroadcastedOrReceivedSceneVersion = () => {
return this.lastBroadcastedOrReceivedSceneVersion;
};
public getSceneElementsIncludingDeleted = () => {
return this.excalidrawRef.current!.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[],
state: AppState,
) => {
this.excalidrawAppState = state;
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.excalidrawRef.current!.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,
});
};
render() {
const { children } = this.props;
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: "" })}
/>
)}
{children({
isCollaborating: this.state.isCollaborating,
username: this.state.username,
onPointerUpdate: this.onPointerUpdate,
initializeSocketClient: this.initializeSocketClient,
onCollabButtonClick: this.onCollabButtonClick,
broadcastElements: this.broadcastElements,
})}
</>
);
}
}
export default CollabWrapper;

View File

@ -1,24 +1,27 @@
import { encryptAESGEM, SocketUpdateDataSource } from "../data";
import {
encryptAESGEM,
SocketUpdateData,
SocketUpdateDataSource,
} from "../data";
import CollabWrapper from "./CollabWrapper";
import { SocketUpdateData } from "../types";
import { BROADCAST, SCENE } from "../constants";
import App from "./App";
import {
getElementMap,
getSceneVersion,
getSyncableElements,
} from "../element";
import { ExcalidrawElement } from "../element/types";
} from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
class Portal {
app: App;
app: CollabWrapper;
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<string, number> = new Map();
constructor(app: App) {
constructor(app: CollabWrapper) {
this.app = app;
}
@ -34,7 +37,11 @@ class Portal {
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(SCENE.INIT, /* syncAll */ true);
this.broadcastScene(
SCENE.INIT,
getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: string[]) => {
this.app.setCollaborators(clients);
@ -81,16 +88,13 @@ class Portal {
broadcastScene = async (
sceneType: SCENE.INIT | SCENE.UPDATE,
syncableElements: ExcalidrawElement[],
syncAll: boolean,
) => {
if (sceneType === SCENE.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT");
}
let syncableElements = getSyncableElements(
this.app.getSceneElementsIncludingDeleted(),
);
if (!syncAll) {
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
@ -109,12 +113,6 @@ class Portal {
elements: syncableElements,
},
};
const currentVersion = this.app.getLastBroadcastedOrReceivedSceneVersion();
const newVersion = Math.max(
currentVersion,
getSceneVersion(this.app.getSceneElementsIncludingDeleted()),
);
this.app.setLastBroadcastedOrReceivedSceneVersion(newVersion);
for (const syncableElement of syncableElements) {
this.broadcastedElementVersions.set(
@ -148,7 +146,8 @@ class Portal {
socketId: this.socket.id,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds: this.app.state.selectedElementIds,
selectedElementIds:
this.app.excalidrawAppState?.selectedElementIds || {},
username: this.app.state.username,
},
};
@ -159,55 +158,60 @@ class Portal {
}
};
reconcileElements = (sceneElements: readonly ExcalidrawElement[]) => {
reconcileElements = (
sceneElements: readonly ExcalidrawElement[],
): readonly ExcalidrawElement[] => {
const currentElements = this.app.getSceneElementsIncludingDeleted();
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(currentElements);
// Reconcile
const newElements = sceneElements
.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 === this.app.state.editingElement?.id ||
element.id === this.app.state.resizingElement?.id ||
element.id === this.app.state.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);
return (
sceneElements
.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 === this.app.excalidrawAppState?.editingElement?.id ||
element.id === this.app.excalidrawAppState?.resizingElement?.id ||
element.id === this.app.excalidrawAppState?.draggingElement?.id
) {
return elements;
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
}, [] as Mutable<typeof sceneElements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap));
return newElements;
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 sceneElements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap))
);
};
}

View File

@ -1,32 +1,6 @@
@import "../css/_variables";
@import "../../css/_variables";
.excalidraw {
.RoomDialog-modalButton.is-collaborating {
background-color: var(--button-special-active-background-color);
.ToolIcon__icon svg {
color: var(--icon-green-fill-color);
}
}
.RoomDialog-modalButton-collaborators {
min-width: 1em;
position: absolute;
:root[dir="ltr"] & {
right: -5px;
}
:root[dir="rtl"] & {
left: -5px;
}
bottom: -5px;
padding: 3px;
border-radius: 50%;
background-color: $oc-green-6;
color: $oc-white;
font-size: 0.7em;
font-family: var(--ui-font);
}
.RoomDialog-linkContainer {
display: flex;
margin: 1.5em 0;

View File

@ -0,0 +1,136 @@
import React, { useRef } from "react";
import { t } from "../../i18n";
import { Dialog } from "../../components/Dialog";
import { copyTextToSystemClipboard } from "../../clipboard";
import { ToolButton } from "../../components/ToolButton";
import { clipboard, start, stop } from "../../components/icons";
import "./RoomDialog.scss";
import { EVENT_SHARE, trackEvent } from "../../analytics";
const RoomDialog = ({
handleClose,
activeRoomLink,
username,
onUsernameChange,
onRoomCreate,
onRoomDestroy,
setErrorMessage,
}: {
handleClose: () => void;
activeRoomLink: string;
username: string;
onUsernameChange: (username: string) => void;
onRoomCreate: () => void;
onRoomDestroy: () => void;
setErrorMessage: (message: string) => void;
}) => {
const roomLinkInput = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
trackEvent(EVENT_SHARE, "copy link");
} catch (error) {
setErrorMessage(error.message);
}
if (roomLinkInput.current) {
roomLinkInput.current.select();
}
};
const selectInput = (event: React.MouseEvent<HTMLInputElement>) => {
if (event.target !== document.activeElement) {
event.preventDefault();
(event.target as HTMLInputElement).select();
}
};
const renderRoomDialog = () => {
return (
<div className="RoomDialog-modal">
{!activeRoomLink && (
<>
<p>{t("roomDialog.desc_intro")}</p>
<p>{`🔒 ${t("roomDialog.desc_privacy")}`}</p>
<div className="RoomDialog-sessionStartButtonContainer">
<ToolButton
className="RoomDialog-startSession"
type="button"
icon={start}
title={t("roomDialog.button_startSession")}
aria-label={t("roomDialog.button_startSession")}
showAriaLabel={true}
onClick={onRoomCreate}
/>
</div>
</>
)}
{activeRoomLink && (
<>
<p>{t("roomDialog.desc_inProgressIntro")}</p>
<p>{t("roomDialog.desc_shareLink")}</p>
<div className="RoomDialog-linkContainer">
<ToolButton
type="button"
icon={clipboard}
title={t("labels.copy")}
aria-label={t("labels.copy")}
onClick={copyRoomLink}
/>
<input
value={activeRoomLink}
readOnly={true}
className="RoomDialog-link"
ref={roomLinkInput}
onPointerDown={selectInput}
/>
</div>
<div className="RoomDialog-usernameContainer">
<label className="RoomDialog-usernameLabel" htmlFor="username">
{t("labels.yourName")}
</label>
<input
id="username"
value={username || ""}
className="RoomDialog-username TextInput"
onChange={(event) => onUsernameChange(event.target.value)}
onBlur={() => trackEvent(EVENT_SHARE, "name")}
onKeyPress={(event) => event.key === "Enter" && handleClose()}
/>
</div>
<p>
<span role="img" aria-hidden="true" className="RoomDialog-emoji">
{"🔒"}
</span>{" "}
{t("roomDialog.desc_privacy")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
<div className="RoomDialog-sessionStartButtonContainer">
<ToolButton
className="RoomDialog-stopSession"
type="button"
icon={stop}
title={t("roomDialog.button_stopSession")}
aria-label={t("roomDialog.button_stopSession")}
showAriaLabel={true}
onClick={onRoomDestroy}
/>
</div>
</>
)}
</div>
);
};
return (
<Dialog
maxWidth={800}
onCloseRequest={handleClose}
title={t("labels.createRoom")}
>
{renderRoomDialog()}
</Dialog>
);
};
export default RoomDialog;

View File

@ -1,8 +1,9 @@
import { createIV, getImportedKey } from "./index";
import { ExcalidrawElement } from "../element/types";
import { getSceneVersion } from "../element";
import Portal from "../components/Portal";
import { restoreElements } from "./restore";
import { getImportedKey } from "../data";
import { createIV } from "./index";
import { ExcalidrawElement } from "../../element/types";
import { getSceneVersion } from "../../element";
import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore";
let firebasePromise: Promise<
typeof import("firebase/app").default
@ -26,8 +27,7 @@ const getFirebase = async (): Promise<
if (!firebasePromise) {
firebasePromise = loadFirebase();
}
const firebase = await firebasePromise!;
return firebase;
return await firebasePromise!;
};
interface FirebaseStoredScene {

View File

@ -0,0 +1,230 @@
import { t } from "../../i18n";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { ImportedDataState } from "../../data/types";
import { restore } from "../../data/restore";
import { EVENT_ACTION, trackEvent } from "../../analytics";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
const generateRandomID = async () => {
const arr = new Uint8Array(10);
window.crypto.getRandomValues(arr);
return Array.from(arr, byteToHex).join("");
};
const generateEncryptionKey = async () => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
return (await window.crypto.subtle.exportKey("jwk", key)).k;
};
export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
export type EncryptedData = {
data: ArrayBuffer;
iv: Uint8Array;
};
export type SocketUpdateDataSource = {
SCENE_INIT: {
type: "SCENE_INIT";
payload: {
elements: readonly ExcalidrawElement[];
};
};
SCENE_UPDATE: {
type: "SCENE_UPDATE";
payload: {
elements: readonly ExcalidrawElement[];
};
};
MOUSE_LOCATION: {
type: "MOUSE_LOCATION";
payload: {
socketId: string;
pointer: { x: number; y: number };
button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"];
username: string;
};
};
};
export type SocketUpdateDataIncoming =
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
| {
type: "INVALID_RESPONSE";
};
export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
_brand: "socketUpdateData";
};
export const createIV = () => {
const arr = new Uint8Array(12);
return window.crypto.getRandomValues(arr);
};
export const encryptAESGEM = async (
data: Uint8Array,
key: string,
): Promise<EncryptedData> => {
const importedKey = await getImportedKey(key, "encrypt");
const iv = createIV();
return {
data: await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
data,
),
iv,
};
};
export const decryptAESGEM = async (
data: ArrayBuffer,
key: string,
iv: Uint8Array,
): Promise<SocketUpdateDataIncoming> => {
try {
const importedKey = await getImportedKey(key, "decrypt");
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
data,
);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any,
);
return JSON.parse(decodedData);
} catch (error) {
window.alert(t("alerts.decryptFailed"));
console.error(error);
}
return {
type: "INVALID_RESPONSE",
};
};
export const getCollaborationLinkData = (link: string) => {
if (link.length === 0) {
return;
}
const hash = new URL(link).hash;
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
};
export const generateCollaborationLink = async () => {
const id = await generateRandomID();
const key = await generateEncryptionKey();
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
};
export const getImportedKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: 128,
},
false, // extractable
[usage],
);
const importFromBackend = async (
id: string | null,
privateKey?: string | null,
): Promise<ImportedDataState> => {
try {
const response = await fetch(
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
);
if (!response.ok) {
window.alert(t("alerts.importBackendFailed"));
return {};
}
let data: ImportedDataState;
if (privateKey) {
const buffer = await response.arrayBuffer();
const key = await getImportedKey(privateKey, "decrypt");
const iv = new Uint8Array(12);
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
buffer,
);
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted) as any,
);
data = JSON.parse(string);
} else {
// Legacy format
data = await response.json();
}
trackEvent(EVENT_ACTION, "import");
return {
elements: data.elements || null,
appState: data.appState || null,
};
} catch (error) {
window.alert(t("alerts.importBackendFailed"));
console.error(error);
return {};
}
};
export const loadScene = async (
id: string | null,
privateKey: string | null,
// Supply initialData even if importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
// Non-optional so we don't forget to pass it even if `undefined`.
initialData: ImportedDataState | undefined | null,
) => {
let data;
if (id != null) {
// the private key is used to decrypt the content from the server, take
// extra care not to leak it
data = restore(
await importFromBackend(id, privateKey),
initialData?.appState,
);
} else {
data = restore(initialData || null, null);
}
return {
elements: data.elements,
appState: data.appState,
commitToHistory: false,
};
};

View File

@ -1,8 +1,18 @@
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { clearAppStateForLocalStorage, getDefaultAppState } from "../appState";
import { STORAGE_KEYS } from "../constants";
import { clearElementsForLocalStorage } from "../element";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "../../appState";
import { clearElementsForLocalStorage } from "../../element";
import { STORAGE_KEYS as APP_STORAGE_KEYS } from "../../constants";
export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG: "collabLinkForceLoadFlag",
};
export const saveUsernameToLocalStorage = (username: string) => {
try {
@ -92,7 +102,7 @@ export const getTotalStorageSize = () => {
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
const library = localStorage.getItem(APP_STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
const appStateSize = appState ? JSON.stringify(appState).length : 0;
const collabSize = collab ? JSON.stringify(collab).length : 0;

View File

@ -1,21 +1,35 @@
import React, { useEffect, useLayoutEffect, useState } from "react";
import { EVENT_LOAD, trackEvent } from "../analytics";
import { LoadingMessage } from "../components/LoadingMessage";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import { EVENT } from "../constants";
import React, { useState, useLayoutEffect, useEffect, useRef } from "react";
import Excalidraw from "../packages/excalidraw/index";
import {
getTotalStorageSize,
importFromLocalStorage,
importUsernameFromLocalStorage,
saveToLocalStorage,
saveUsernameToLocalStorage,
} from "../data/localStorage";
STORAGE_KEYS,
} from "./data/localStorage";
import { ImportedDataState } from "../data/types";
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import { t } from "../i18n";
import { loadScene } from "./data";
import { getCollaborationLinkData } from "./data";
import { EVENT } from "../constants";
import { loadFromFirebase } from "./data/firebase";
import { ExcalidrawImperativeAPI } from "../components/App";
import { debounce, ResolvablePromise, resolvablePromise } from "../utils";
import { AppState, ExcalidrawAPIRefValue } from "../types";
import { ExcalidrawElement } from "../element/types";
import Excalidraw from "../packages/excalidraw/index";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "../time_constants";
import { AppState } from "../types";
import { debounce } from "../utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
import { EVENT_LOAD, EVENT_SHARE, trackEvent } from "../analytics";
const excalidrawRef: React.MutableRefObject<ExcalidrawAPIRefValue> = {
current: {
readyPromise: resolvablePromise(),
ready: false,
},
};
const saveDebounced = debounce(
(elements: readonly ExcalidrawElement[], state: AppState) => {
@ -24,19 +38,145 @@ const saveDebounced = debounce(
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
const onUsernameChange = (username: string) => {
saveUsernameToLocalStorage(username);
};
const onBlur = () => {
saveDebounced.flush();
};
export default function ExcalidrawApp() {
const shouldForceLoadScene = (
scene: ResolutionType<typeof loadScene>,
): boolean => {
if (!scene.elements.length) {
return true;
}
const roomMatch = getCollaborationLinkData(window.location.href);
if (!roomMatch) {
return false;
}
const roomId = roomMatch[1];
let collabForceLoadFlag;
try {
collabForceLoadFlag = localStorage?.getItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
);
} catch {}
if (collabForceLoadFlag) {
try {
const {
room: previousRoom,
timestamp,
}: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag);
// if loading same room as the one previously unloaded within 15sec
// force reload without prompting
if (previousRoom === roomId && Date.now() - timestamp < 15000) {
return true;
}
} catch {}
}
return false;
};
type Scene = ImportedDataState & { commitToHistory: boolean };
const initializeScene = async (opts: {
resetScene: ExcalidrawImperativeAPI["resetScene"];
initializeSocketClient: CollabAPI["initializeSocketClient"];
onLateInitialization?: (scene: Scene) => void;
}): Promise<Scene | null> => {
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
const jsonMatch = window.location.hash.match(
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
);
const initialData = importFromLocalStorage();
let scene = await loadScene(null, null, initialData);
let isCollabScene = !!getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonMatch || isCollabScene);
if (isExternalScene) {
if (
shouldForceLoadScene(scene) ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
// Backwards compatibility with legacy url format
if (id) {
scene = await loadScene(id, null, initialData);
} else if (jsonMatch) {
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
}
if (!isCollabScene) {
window.history.replaceState({}, "Excalidraw", window.location.origin);
}
} else {
// https://github.com/excalidraw/excalidraw/issues/1919
if (document.hidden) {
window.addEventListener(
"focus",
() =>
initializeScene(opts).then((_scene) => {
opts?.onLateInitialization?.(_scene || scene);
}),
{
once: true,
},
);
return null;
}
isCollabScene = false;
window.history.replaceState({}, "Excalidraw", window.location.origin);
}
}
if (isCollabScene) {
// when joining a room we don't want user's local scene data to be merged
// into the remote scene
opts.resetScene();
const scenePromise = opts.initializeSocketClient();
trackEvent(EVENT_SHARE, "session join");
try {
const [, roomId, roomKey] = getCollaborationLinkData(
window.location.href,
)!;
const elements = await loadFromFirebase(roomId, roomKey);
if (elements) {
return {
elements,
commitToHistory: true,
};
}
return {
...(await scenePromise),
commitToHistory: true,
};
} catch (error) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
}
return null;
} else if (scene) {
return scene;
}
return null;
};
function ExcalidrawWrapper(props: { collab: CollabAPI }) {
// dimensions
// ---------------------------------------------------------------------------
const [dimensions, setDimensions] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useLayoutEffect(() => {
const onResize = () => {
setDimensions({
@ -50,12 +190,17 @@ export default function ExcalidrawApp() {
return () => window.removeEventListener("resize", onResize);
}, []);
const [initialState, setInitialState] = useState<{
data: ImportedDataState;
user: {
name: string | null;
};
} | null>(null);
// initial state
// ---------------------------------------------------------------------------
const initialStatePromiseRef = useRef<{
promise: ResolvablePromise<ImportedDataState | null>;
}>({ promise: null! });
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
}
const { collab } = props;
useEffect(() => {
const storageSize = getTotalStorageSize();
@ -64,35 +209,80 @@ export default function ExcalidrawApp() {
} else {
trackEvent(EVENT_LOAD, "first time");
}
setInitialState({
data: importFromLocalStorage(),
user: {
name: importUsernameFromLocalStorage(),
},
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
initializeScene({
resetScene: excalidrawApi.resetScene,
initializeSocketClient: collab.initializeSocketClient,
onLateInitialization: (scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
},
}).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
});
});
}, []);
useEffect(() => {
const onHashChange = (_: HashChangeEvent) => {
const api = excalidrawRef.current!;
if (!api.ready) {
return;
}
if (window.location.hash.length > 1) {
initializeScene({
resetScene: api.resetScene,
initializeSocketClient: collab.initializeSocketClient,
}).then((scene) => {
if (scene) {
api.updateScene(scene);
}
});
}
};
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.addEventListener(EVENT.UNLOAD, onBlur, false);
window.addEventListener(EVENT.BLUR, onBlur, false);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
window.removeEventListener(EVENT.BLUR, onBlur, false);
};
}, []);
}, [collab.initializeSocketClient]);
return initialState ? (
<TopErrorBoundary>
<Excalidraw
width={dimensions.width}
height={dimensions.height}
onChange={saveDebounced}
initialData={initialState.data}
user={initialState.user}
onUsernameChange={onUsernameChange}
/>
</TopErrorBoundary>
) : (
<LoadingMessage />
const onChange = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
saveDebounced(elements, appState);
if (collab.isCollaborating) {
collab.broadcastElements(elements, appState);
}
};
return (
<Excalidraw
ref={excalidrawRef}
onChange={onChange}
width={dimensions.width}
height={dimensions.height}
initialData={initialStatePromiseRef.current.promise}
user={{ name: collab.username }}
onCollabButtonClick={collab.onCollabButtonClick}
isCollaborating={collab.isCollaborating}
onPointerUpdate={collab.onPointerUpdate}
/>
);
}
export default function ExcalidrawApp() {
return (
<TopErrorBoundary>
<CollabWrapper
excalidrawRef={
excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI>
}
>
{(collab) => <ExcalidrawWrapper collab={collab} />}
</CollabWrapper>
</TopErrorBoundary>
);
}

View File

@ -1,13 +1,14 @@
import React, { useEffect, forwardRef } from "react";
import { InitializeApp } from "../../components/InitializeApp";
import App, { ExcalidrawImperativeAPI } from "../../components/App";
import App from "../../components/App";
import "../../css/app.scss";
import "../../css/styles.scss";
import { ExcalidrawProps } from "../../types";
import { ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { IsMobileProvider } from "../../is-mobile";
import { noop } from "../../utils";
const Excalidraw = (props: ExcalidrawProps) => {
const {
@ -18,8 +19,10 @@ const Excalidraw = (props: ExcalidrawProps) => {
onChange,
initialData,
user,
onUsernameChange,
forwardedRef,
excalidrawRef,
onCollabButtonClick = noop,
isCollaborating,
onPointerUpdate,
} = props;
useEffect(() => {
@ -51,8 +54,10 @@ const Excalidraw = (props: ExcalidrawProps) => {
onChange={onChange}
initialData={initialData}
user={user}
onUsernameChange={onUsernameChange}
forwardedRef={forwardedRef}
excalidrawRef={excalidrawRef}
onCollabButtonClick={onCollabButtonClick}
isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate}
/>
</IsMobileProvider>
</InitializeApp>
@ -79,7 +84,12 @@ const areEqual = (
};
const forwardedRefComp = forwardRef<
ExcalidrawImperativeAPI,
ExcalidrawAPIRefValue,
PublicExcalidrawProps
>((props, ref) => <Excalidraw {...props} forwardedRef={ref} />);
>((props, ref) => <Excalidraw {...props} excalidrawRef={ref} />);
export default React.memo(forwardedRefComp, areEqual);
export {
getSceneVersion,
getSyncableElements,
getElementMap,
} from "../../element";

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { render } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import { setLanguage } from "../i18n";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { API } from "./helpers/api";
@ -20,15 +20,6 @@ const { h } = window;
const mouse = new Pointer("mouse");
beforeEach(async () => {
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
mouse.reset();
await setLanguage("en.json");
render(<App />);
});
const createAndSelectTwoRectangles = () => {
UI.clickTool("rectangle");
mouse.down();
@ -63,517 +54,528 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
});
};
it("aligns two objects correctly to the top", () => {
createAndSelectTwoRectangles();
describe("aligning", () => {
beforeEach(async () => {
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
mouse.reset();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_UP);
await setLanguage("en.json");
await render(<ExcalidrawApp />);
});
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
it("aligns two objects correctly to the top", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
it("aligns two objects correctly to the bottom", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_UP);
});
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
it("aligns two objects correctly to the bottom", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].y).toEqual(110);
expect(API.getSelectedElements()[1].y).toEqual(110);
});
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
it("aligns two objects correctly to the left", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(API.getSelectedElements()[0].y).toEqual(110);
expect(API.getSelectedElements()[1].y).toEqual(110);
});
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
it("aligns two objects correctly to the left", () => {
createAndSelectTwoRectangles();
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
});
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
it("aligns two objects correctly to the right", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
});
expect(API.getSelectedElements()[0].x).toEqual(110);
expect(API.getSelectedElements()[1].x).toEqual(110);
it("aligns two objects correctly to the right", () => {
createAndSelectTwoRectangles();
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
});
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
it("centers two objects with different sizes correctly vertically", () => {
createAndSelectTwoRectanglesWithDifferentSizes();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].x).toEqual(110);
expect(API.getSelectedElements()[1].x).toEqual(110);
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].y).toEqual(60);
expect(API.getSelectedElements()[1].y).toEqual(55);
});
it("centers two objects with different sizes correctly horizontally", () => {
createAndSelectTwoRectanglesWithDifferentSizes();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(60);
expect(API.getSelectedElements()[1].x).toEqual(55);
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
});
const createAndSelectGroupAndRectangle = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
});
h.app.actionManager.executeAction(actionGroup);
it("centers two objects with different sizes correctly vertically", () => {
createAndSelectTwoRectanglesWithDifferentSizes();
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
// Add the created group to the current selection
mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
};
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
it("aligns a group with another element correctly to the top", () => {
createAndSelectGroupAndRectangle();
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
h.app.actionManager.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns a group with another element correctly to the bottom", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
h.app.actionManager.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns a group with another element correctly to the left", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
h.app.actionManager.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns a group with another element correctly to the right", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
h.app.actionManager.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("centers a group with another element correctly vertically", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("centers a group with another element correctly horizontally", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
const createAndSelectTwoGroups = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already selected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
expect(API.getSelectedElements()[0].y).toEqual(60);
expect(API.getSelectedElements()[1].y).toEqual(55);
});
h.app.actionManager.executeAction(actionGroup);
it("centers two objects with different sizes correctly horizontally", () => {
createAndSelectTwoRectanglesWithDifferentSizes();
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
mouse.restorePosition(200, 200);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(60);
expect(API.getSelectedElements()[1].x).toEqual(55);
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
});
h.app.actionManager.executeAction(actionGroup);
const createAndSelectGroupAndRectangle = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
// Select the first group.
// The second group is already selected because it was the last group created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
};
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
it("aligns two groups correctly to the top", () => {
createAndSelectTwoGroups();
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionGroup);
h.app.actionManager.executeAction(actionAlignTop);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
expect(API.getSelectedElements()[3].y).toEqual(100);
});
// Add the created group to the current selection
mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
};
it("aligns two groups correctly to the bottom", () => {
createAndSelectTwoGroups();
it("aligns a group with another element correctly to the top", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
h.app.actionManager.executeAction(actionAlignBottom);
h.app.actionManager.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(200);
expect(API.getSelectedElements()[1].y).toEqual(300);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
});
it("aligns two groups correctly to the left", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
expect(API.getSelectedElements()[3].x).toEqual(100);
});
it("aligns two groups correctly to the right", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(200);
expect(API.getSelectedElements()[1].x).toEqual(300);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
});
it("centers two groups correctly vertically", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(100);
expect(API.getSelectedElements()[3].y).toEqual(200);
});
it("centers two groups correctly horizontally", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(100);
expect(API.getSelectedElements()[3].x).toEqual(200);
});
const createAndSelectNestedGroupAndRectangle = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
// Create first group of rectangles
h.app.actionManager.executeAction(actionGroup);
it("aligns a group with another element correctly to the bottom", () => {
createAndSelectGroupAndRectangle();
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
// Add group to current selection
mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
h.app.actionManager.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
// Create the nested group
h.app.actionManager.executeAction(actionGroup);
it("aligns a group with another element correctly to the left", () => {
createAndSelectGroupAndRectangle();
mouse.reset();
UI.clickTool("rectangle");
mouse.down(300, 300);
mouse.up(100, 100);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
// Select the nested group, the rectangle is already selected
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
h.app.actionManager.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
};
it("aligns nested group and other element correctly to the top", () => {
createAndSelectNestedGroupAndRectangle();
it("aligns a group with another element correctly to the right", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
h.app.actionManager.executeAction(actionAlignTop);
h.app.actionManager.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(0);
});
it("aligns nested group and other element correctly to the bottom", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(300);
expect(API.getSelectedElements()[3].y).toEqual(300);
});
it("aligns nested group and other element correctly to the left", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(0);
});
it("aligns nested group and other element correctly to the right", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(300);
expect(API.getSelectedElements()[3].x).toEqual(300);
});
it("centers nested group and other element correctly vertically", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(250);
expect(API.getSelectedElements()[3].y).toEqual(150);
});
it("centers nested group and other element correctly horizontally", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(250);
expect(API.getSelectedElements()[3].x).toEqual(150);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("centers a group with another element correctly vertically", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("centers a group with another element correctly horizontally", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
const createAndSelectTwoGroups = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already selected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
h.app.actionManager.executeAction(actionGroup);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.restorePosition(200, 200);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
h.app.actionManager.executeAction(actionGroup);
// Select the first group.
// The second group is already selected because it was the last group created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
};
it("aligns two groups correctly to the top", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
expect(API.getSelectedElements()[3].y).toEqual(100);
});
it("aligns two groups correctly to the bottom", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(200);
expect(API.getSelectedElements()[1].y).toEqual(300);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
});
it("aligns two groups correctly to the left", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
expect(API.getSelectedElements()[3].x).toEqual(100);
});
it("aligns two groups correctly to the right", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(200);
expect(API.getSelectedElements()[1].x).toEqual(300);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
});
it("centers two groups correctly vertically", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(100);
expect(API.getSelectedElements()[3].y).toEqual(200);
});
it("centers two groups correctly horizontally", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(100);
expect(API.getSelectedElements()[3].x).toEqual(200);
});
const createAndSelectNestedGroupAndRectangle = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
// Create first group of rectangles
h.app.actionManager.executeAction(actionGroup);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
// Add group to current selection
mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
// Create the nested group
h.app.actionManager.executeAction(actionGroup);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(300, 300);
mouse.up(100, 100);
// Select the nested group, the rectangle is already selected
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
};
it("aligns nested group and other element correctly to the top", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(0);
});
it("aligns nested group and other element correctly to the bottom", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(300);
expect(API.getSelectedElements()[3].y).toEqual(300);
});
it("aligns nested group and other element correctly to the left", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(0);
});
it("aligns nested group and other element correctly to the right", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(300);
expect(API.getSelectedElements()[3].x).toEqual(300);
});
it("centers nested group and other element correctly vertically", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(250);
expect(API.getSelectedElements()[3].y).toEqual(150);
});
it("centers nested group and other element correctly horizontally", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(250);
expect(API.getSelectedElements()[3].x).toEqual(150);
});
});

View File

@ -1,6 +1,6 @@
import React from "react";
import { render, waitFor } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import { API } from "./helpers/api";
import { getDefaultAppState } from "../appState";
@ -10,18 +10,15 @@ describe("appState", () => {
it("drag&drop file doesn't reset non-persisted appState", async () => {
const defaultAppState = getDefaultAppState();
const exportBackground = !defaultAppState.exportBackground;
render(
<App
initialData={{
appState: {
...defaultAppState,
exportBackground,
viewBackgroundColor: "#F00",
},
elements: [],
}}
/>,
);
await render(<ExcalidrawApp />, {
localStorageData: {
appState: {
exportBackground,
viewBackgroundColor: "#F00",
},
},
});
await waitFor(() => {
expect(h.state.exportBackground).toBe(exportBackground);

View File

@ -1,6 +1,6 @@
import React from "react";
import { render } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { getTransformHandles } from "../element/transformHandles";
import { API } from "./helpers/api";
@ -11,8 +11,8 @@ const { h } = window;
const mouse = new Pointer("mouse");
describe("element binding", () => {
beforeEach(() => {
render(<App />);
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("rotation of arrow should rebind both ends", () => {

View File

@ -1,9 +1,8 @@
import React from "react";
import { render, waitFor } from "./test-utils";
import App from "../components/App";
import { render, updateSceneData, waitFor } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { API } from "./helpers/api";
import { createUndoAction } from "../actions/actionHistory";
const { h } = window;
Object.defineProperty(window, "crypto", {
@ -17,7 +16,7 @@ Object.defineProperty(window, "crypto", {
},
});
jest.mock("../data/firebase.ts", () => {
jest.mock("../excalidraw-app/data/firebase.ts", () => {
const loadFromFirebase = async () => null;
const saveToFirebase = () => {};
const isSavedToFirebase = () => true;
@ -42,17 +41,18 @@ jest.mock("socket.io-client", () => {
describe("collaboration", () => {
it("creating room should reset deleted elements", async () => {
render(
<App
initialData={{
elements: [
API.createElement({ type: "rectangle", id: "A" }),
API.createElement({ type: "rectangle", id: "B", isDeleted: true }),
],
}}
/>,
);
await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab
updateSceneData({
elements: [
API.createElement({ type: "rectangle", id: "A" }),
API.createElement({
type: "rectangle",
id: "B",
isDeleted: true,
}),
],
});
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
@ -60,8 +60,7 @@ describe("collaboration", () => {
]);
expect(API.getStateHistory().length).toBe(1);
});
await h.app.openPortal();
h.collab.openPortal();
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);

View File

@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys";
import { render, fireEvent } from "./test-utils";
@ -20,8 +20,8 @@ beforeEach(() => {
const { h } = window;
describe("add element to the scene when pointer dragging long enough", () => {
it("rectangle", () => {
const { getByToolName, container } = render(<App />);
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
@ -37,7 +37,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -51,8 +51,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("ellipse", () => {
const { getByToolName, container } = render(<App />);
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("ellipse");
fireEvent.click(tool);
@ -68,7 +68,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -82,8 +82,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("diamond", () => {
const { getByToolName, container } = render(<App />);
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
@ -99,7 +99,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -113,8 +113,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("arrow", () => {
const { getByToolName, container } = render(<App />);
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("arrow");
fireEvent.click(tool);
@ -130,7 +130,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -148,8 +148,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("line", () => {
const { getByToolName, container } = render(<App />);
it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("line");
fireEvent.click(tool);
@ -165,7 +165,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -184,8 +184,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
});
describe("do not add element to the scene if size is too small", () => {
it("rectangle", () => {
const { getByToolName, container } = render(<App />);
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
@ -198,13 +198,13 @@ describe("do not add element to the scene if size is too small", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(4);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
it("ellipse", () => {
const { getByToolName, container } = render(<App />);
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("ellipse");
fireEvent.click(tool);
@ -217,13 +217,13 @@ describe("do not add element to the scene if size is too small", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(4);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
it("diamond", () => {
const { getByToolName, container } = render(<App />);
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
@ -236,13 +236,13 @@ describe("do not add element to the scene if size is too small", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(4);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
it("arrow", () => {
const { getByToolName, container } = render(<App />);
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("arrow");
fireEvent.click(tool);
@ -258,13 +258,13 @@ describe("do not add element to the scene if size is too small", () => {
// we need to finalize it because arrows and lines enter multi-mode
fireEvent.keyDown(document, { key: KEYS.ENTER });
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
it("line", () => {
const { getByToolName, container } = render(<App />);
it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("line");
fireEvent.click(tool);
@ -280,7 +280,7 @@ describe("do not add element to the scene if size is too small", () => {
// we need to finalize it because arrows and lines enter multi-mode
fireEvent.keyDown(document, { key: KEYS.ENTER });
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});

View File

@ -1,6 +1,6 @@
import React from "react";
import { render, waitFor } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import { API } from "./helpers/api";
import {
encodePngMetadata,
@ -38,8 +38,8 @@ Object.defineProperty(window, "TextDecoder", {
});
describe("export", () => {
beforeEach(() => {
render(<App />);
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("export embedded png and reimport", async () => {

View File

@ -1,6 +1,6 @@
import React from "react";
import { render } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import { UI } from "./helpers/ui";
import { API } from "./helpers/api";
import { getDefaultAppState } from "../appState";
@ -11,17 +11,14 @@ const { h } = window;
describe("history", () => {
it("initializing scene should end up with single history entry", async () => {
render(
<App
initialData={{
appState: {
...getDefaultAppState(),
zenModeEnabled: true,
},
elements: [API.createElement({ type: "rectangle", id: "A" })],
}}
/>,
);
await render(<ExcalidrawApp />, {
localStorageData: {
elements: [API.createElement({ type: "rectangle", id: "A" })],
appState: {
zenModeEnabled: true,
},
},
});
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
await waitFor(() =>
@ -61,17 +58,14 @@ describe("history", () => {
});
it("scene import via drag&drop should create new history entry", async () => {
render(
<App
initialData={{
appState: {
...getDefaultAppState(),
viewBackgroundColor: "#FFF",
},
elements: [API.createElement({ type: "rectangle", id: "A" })],
}}
/>,
);
await render(<ExcalidrawApp />, {
localStorageData: {
elements: [API.createElement({ type: "rectangle", id: "A" })],
appState: {
viewBackgroundColor: "#FFF",
},
},
});
await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
await waitFor(() =>

View File

@ -1,6 +1,6 @@
import React from "react";
import { render, waitFor } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import { API } from "./helpers/api";
import { MIME_TYPES } from "../constants";
import { LibraryItem } from "../types";
@ -8,9 +8,9 @@ import { LibraryItem } from "../types";
const { h } = window;
describe("library", () => {
beforeEach(() => {
beforeEach(async () => {
h.library.resetLibrary();
render(<App />);
await render(<ExcalidrawApp />);
});
it("import library via drag&drop", async () => {

View File

@ -1,7 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { bindOrUnbindLinearElement } from "../element/binding";
@ -26,8 +26,8 @@ beforeEach(() => {
const { h } = window;
describe("move element", () => {
it("rectangle", () => {
const { getByToolName, container } = render(<App />);
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
{
@ -38,7 +38,7 @@ describe("move element", () => {
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -59,8 +59,8 @@ describe("move element", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("rectangles with binding arrow", () => {
render(<App />);
it("rectangles with binding arrow", async () => {
await render(<ExcalidrawApp />);
// create elements
const rectA = UI.createElement("rectangle", { size: 100 });
@ -77,7 +77,7 @@ describe("move element", () => {
// select the second rectangles
new Pointer("mouse").clickOn(rectB);
expect(renderScene).toHaveBeenCalledTimes(19);
expect(renderScene).toHaveBeenCalledTimes(20);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@ -108,8 +108,8 @@ describe("move element", () => {
});
describe("duplicate element on move when ALT is clicked", () => {
it("rectangle", () => {
const { getByToolName, container } = render(<App />);
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
{
@ -120,7 +120,7 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

View File

@ -1,7 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys";
import { ExcalidrawLinearElement } from "../element/types";
@ -20,8 +20,8 @@ beforeEach(() => {
const { h } = window;
describe("remove shape in non linear elements", () => {
it("rectangle", () => {
const { getByToolName, container } = render(<App />);
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
@ -30,12 +30,12 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(4);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0);
});
it("ellipse", () => {
const { getByToolName, container } = render(<App />);
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("ellipse");
fireEvent.click(tool);
@ -44,12 +44,12 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(4);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0);
});
it("diamond", () => {
const { getByToolName, container } = render(<App />);
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
@ -58,14 +58,14 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(4);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0);
});
});
describe("multi point mode in linear elements", () => {
it("arrow", () => {
const { getByToolName, container } = render(<App />);
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("arrow");
fireEvent.click(tool);
@ -88,7 +88,7 @@ describe("multi point mode in linear elements", () => {
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { key: KEYS.ENTER });
expect(renderScene).toHaveBeenCalledTimes(11);
expect(renderScene).toHaveBeenCalledTimes(12);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
@ -105,8 +105,8 @@ describe("multi point mode in linear elements", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("line", () => {
const { getByToolName, container } = render(<App />);
it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("line");
fireEvent.click(tool);
@ -129,7 +129,7 @@ describe("multi point mode in linear elements", () => {
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { key: KEYS.ENTER });
expect(renderScene).toHaveBeenCalledTimes(11);
expect(renderScene).toHaveBeenCalledTimes(12);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;

View File

@ -9,7 +9,7 @@ import {
fireEvent,
GlobalTestState,
} from "./test-utils";
import App from "../components/App";
import Excalidraw from "../packages/excalidraw/index";
import { setLanguage } from "../i18n";
import { setDateTimeForTests } from "../utils";
import { ExcalidrawElement } from "../element/types";
@ -97,7 +97,7 @@ beforeEach(async () => {
finger2.reset();
await setLanguage("en.json");
render(<App offsetLeft={0} offsetTop={0} />);
await render(<Excalidraw offsetLeft={0} offsetTop={0} />);
});
afterEach(() => {

View File

@ -1,7 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui";
@ -22,8 +22,8 @@ beforeEach(() => {
const { h } = window;
describe("resize element", () => {
it("rectangle", () => {
const { getByToolName, container } = render(<App />);
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
{
@ -34,7 +34,7 @@ describe("resize element", () => {
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -65,8 +65,8 @@ describe("resize element", () => {
});
describe("resize element with aspect ratio when SHIFT is clicked", () => {
it("rectangle", () => {
render(<App />);
it("rectangle", async () => {
await render(<ExcalidrawApp />);
const rectangle = UI.createElement("rectangle", {
x: 0,

View File

@ -1,7 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys";
import { reseed } from "../random";
@ -19,8 +19,8 @@ beforeEach(() => {
const { h } = window;
describe("selection element", () => {
it("create selection element on pointer down", () => {
const { getByToolName, container } = render(<App />);
it("create selection element on pointer down", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("selection");
fireEvent.click(tool);
@ -28,7 +28,7 @@ describe("selection element", () => {
const canvas = container.querySelector("canvas")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
expect(renderScene).toHaveBeenCalledTimes(2);
expect(renderScene).toHaveBeenCalledTimes(3);
const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection");
@ -39,8 +39,8 @@ describe("selection element", () => {
fireEvent.pointerUp(canvas);
});
it("resize selection element on pointer move", () => {
const { getByToolName, container } = render(<App />);
it("resize selection element on pointer move", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("selection");
fireEvent.click(tool);
@ -49,7 +49,7 @@ describe("selection element", () => {
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(3);
expect(renderScene).toHaveBeenCalledTimes(4);
const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection");
@ -60,8 +60,8 @@ describe("selection element", () => {
fireEvent.pointerUp(canvas);
});
it("remove selection element on pointer up", () => {
const { getByToolName, container } = render(<App />);
it("remove selection element on pointer up", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("selection");
fireEvent.click(tool);
@ -71,14 +71,14 @@ describe("selection element", () => {
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(4);
expect(renderScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
});
});
describe("select single element on the scene", () => {
it("rectangle", () => {
const { getByToolName, container } = render(<App />);
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
{
// create element
@ -96,7 +96,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -104,8 +104,8 @@ describe("select single element on the scene", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("diamond", () => {
const { getByToolName, container } = render(<App />);
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
{
// create element
@ -123,7 +123,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -131,8 +131,8 @@ describe("select single element on the scene", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("ellipse", () => {
const { getByToolName, container } = render(<App />);
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
{
// create element
@ -150,7 +150,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -158,8 +158,8 @@ describe("select single element on the scene", () => {
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("arrow", () => {
const { getByToolName, container } = render(<App />);
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
{
// create element
@ -190,15 +190,15 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("arrow escape", () => {
const { getByToolName, container } = render(<App />);
it("arrow escape", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
{
// create element
@ -229,7 +229,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

View File

@ -5,9 +5,14 @@ import {
queries,
RenderResult,
RenderOptions,
waitFor,
} from "@testing-library/react";
import * as toolQueries from "./queries/toolQueries";
import { ImportedDataState } from "../data/types";
import { STORAGE_KEYS } from "../excalidraw-app/data/localStorage";
import { SceneData } from "../types";
const customQueries = {
...queries,
@ -16,17 +21,40 @@ const customQueries = {
type TestRenderFn = (
ui: React.ReactElement,
options?: Omit<RenderOptions, "queries">,
) => RenderResult<typeof customQueries>;
options?: Omit<
RenderOptions & { localStorageData?: ImportedDataState },
"queries"
>,
) => Promise<RenderResult<typeof customQueries>>;
const renderApp: TestRenderFn = async (ui, options) => {
if (options?.localStorageData) {
initLocalStorage(options.localStorageData);
delete options.localStorageData;
}
const renderApp: TestRenderFn = (ui, options) => {
const renderResult = render(ui, {
queries: customQueries,
...options,
});
GlobalTestState.renderResult = renderResult;
GlobalTestState.canvas = renderResult.container.querySelector("canvas")!;
Object.defineProperty(GlobalTestState, "canvas", {
// must be a getter because at the time of ExcalidrawApp render the
// child App component isn't likely mounted yet (and thus canvas not
// present in DOM)
get() {
return renderResult.container.querySelector("canvas")!;
},
});
await waitFor(() => {
const canvas = renderResult.container.querySelector("canvas");
if (!canvas) {
throw new Error("not initialized yet");
}
});
return renderResult;
};
@ -49,7 +77,28 @@ export class GlobalTestState {
*/
static renderResult: RenderResult<typeof customQueries> = null!;
/**
* automatically updated on each call to render()
* retrieves canvas for currently rendered app instance
*/
static canvas: HTMLCanvasElement = null!;
static get canvas(): HTMLCanvasElement {
return null!;
}
}
const initLocalStorage = (data: ImportedDataState) => {
if (data.elements) {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(data.elements),
);
}
if (data.appState) {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(data.appState),
);
}
};
export const updateSceneData = (data: SceneData) => {
(window.h.collab as any).excalidrawRef.current.updateScene(data);
};

View File

@ -1,7 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { render } from "./test-utils";
import App from "../components/App";
import ExcalidrawApp from "../excalidraw-app";
import { reseed } from "../random";
import {
actionSendBackward,
@ -107,8 +107,8 @@ const assertZindex = ({
};
describe("z-index manipulation", () => {
beforeEach(() => {
render(<App />);
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("send back", () => {

View File

@ -1,6 +0,0 @@
// time in milliseconds
export const TAP_TWICE_TIMEOUT = 300;
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;

View File

@ -11,11 +11,11 @@ import {
} from "./element/types";
import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry";
import { SocketUpdateDataSource } from "./data";
import { LinearElementEditor } from "./element/linearElementEditor";
import { SuggestedBinding } from "./element/binding";
import { ImportedDataState } from "./data/types";
import { ExcalidrawImperativeAPI } from "./components/App";
import type { ResolvablePromise } from "./utils";
export type FlooredNumber = number & { _brand: "FlooredNumber" };
export type Point = Readonly<RoughPoint>;
@ -69,8 +69,6 @@ export type AppState = {
cursorButton: "up" | "down";
scrolledOutside: boolean;
name: string;
username: string;
isCollaborating: boolean;
isResizing: boolean;
isRotating: boolean;
zoom: Zoom;
@ -78,7 +76,6 @@ export type AppState = {
lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean };
previousSelectedElementIds: { [id: string]: boolean };
collaborators: Map<string, Collaborator>;
shouldCacheIgnoreZoom: boolean;
showShortcutsDialog: boolean;
zenModeEnabled: boolean;
@ -97,6 +94,7 @@ export type AppState = {
isLibraryOpen: boolean;
fileHandle: import("browser-nativefs").FileSystemHandle | null;
collaborators: Map<string, Collaborator>;
};
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
@ -126,16 +124,22 @@ export declare class GestureEvent extends UIEvent {
readonly scale: number;
}
export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
_brand: "socketUpdateData";
};
export type LibraryItem = readonly NonDeleted<ExcalidrawElement>[];
export type LibraryItems = readonly LibraryItem[];
export type ExcalidrawAPIRefValue =
| (ExcalidrawImperativeAPI & {
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
})
| {
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: false;
};
export interface ExcalidrawProps {
width: number;
height: number;
width?: number;
height?: number;
/** if not supplied, calculated by Excalidraw */
offsetLeft?: number;
/** if not supplied, calculated by Excalidraw */
@ -144,10 +148,23 @@ export interface ExcalidrawProps {
elements: readonly ExcalidrawElement[],
appState: AppState,
) => void;
initialData?: ImportedDataState;
initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
user?: {
name?: string | null;
};
onUsernameChange?: (username: string) => void;
forwardedRef: ForwardRef<ExcalidrawImperativeAPI>;
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
onCollabButtonClick?: () => void;
isCollaborating?: boolean;
onPointerUpdate?: (payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => void;
}
export type SceneData = {
elements?: ImportedDataState["elements"];
appState?: ImportedDataState["appState"];
collaborators?: Map<string, Collaborator>;
commitToHistory?: boolean;
};

View File

@ -6,6 +6,7 @@ import {
} from "./constants";
import { FontFamily, FontString } from "./element/types";
import { Zoom } from "./types";
import { unstable_batchedUpdates } from "react-dom";
export const SVG_NS = "http://www.w3.org/2000/svg";
@ -128,7 +129,9 @@ export const debounce = <T extends any[]>(
};
ret.flush = () => {
clearTimeout(handle);
fn(...lastArgs);
if (lastArgs) {
fn(...lastArgs);
}
};
return ret;
};
@ -303,3 +306,33 @@ export const isTransparent = (color: string) => {
color === colors.elementBackground[0]
);
};
export const noop = () => ({});
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void;
};
export const resolvablePromise = <T>() => {
let resolve!: any;
let reject!: any;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
(promise as any).resolve = resolve;
(promise as any).reject = reject;
return promise as ResolvablePromise<T>;
};
/**
* @param func handler taking at most single parameter (event).
*/
export const withBatchedUpdates = <
TFunction extends ((event: any) => void) | (() => void)
>(
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
) =>
((event) => {
unstable_batchedUpdates(func as TFunction, event);
}) as TFunction;