From e617ccc2521b46bc1e25a1a771019e09d12ea338 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Sat, 5 Dec 2020 20:00:53 +0530 Subject: [PATCH] Factor out collaboration code (#2313) Co-authored-by: Lipis Co-authored-by: dwelle --- src/actions/actionCanvas.tsx | 1 - src/actions/actionNavigate.tsx | 1 - src/appState.ts | 8 +- src/components/App.tsx | 618 +++--------- src/components/CollabButton.scss | 29 + src/components/CollabButton.tsx | 44 + src/components/LayerUI.tsx | 35 +- src/components/MobileMenu.tsx | 30 +- src/components/RoomDialog.tsx | 202 ---- src/constants.ts | 19 +- src/data/index.ts | 224 +---- src/data/restore.ts | 6 +- src/excalidraw-app/app_constants.ts | 14 + src/excalidraw-app/collab/CollabWrapper.tsx | 476 +++++++++ .../collab}/Portal.tsx | 128 +-- .../collab}/RoomDialog.scss | 28 +- src/excalidraw-app/collab/RoomDialog.tsx | 136 +++ src/{ => excalidraw-app}/data/firebase.ts | 14 +- src/excalidraw-app/data/index.ts | 230 +++++ src/{ => excalidraw-app}/data/localStorage.ts | 22 +- src/excalidraw-app/index.tsx | 278 +++++- src/packages/excalidraw/index.tsx | 26 +- .../regressionTests.test.tsx.snap | 336 ++----- src/tests/align.test.tsx | 918 +++++++++--------- src/tests/appState.test.tsx | 23 +- src/tests/binding.test.tsx | 6 +- src/tests/collab.test.tsx | 33 +- src/tests/dragCreate.test.tsx | 62 +- src/tests/export.test.tsx | 6 +- src/tests/history.test.tsx | 40 +- src/tests/library.test.tsx | 6 +- src/tests/move.test.tsx | 20 +- src/tests/multiPointCreate.test.tsx | 32 +- src/tests/regressionTests.test.tsx | 4 +- src/tests/resize.test.tsx | 12 +- src/tests/selection.test.tsx | 50 +- src/tests/test-utils.ts | 61 +- src/tests/zindex.test.tsx | 6 +- src/time_constants.ts | 6 - src/types.ts | 43 +- src/utils.ts | 35 +- 41 files changed, 2250 insertions(+), 2018 deletions(-) create mode 100644 src/components/CollabButton.scss create mode 100644 src/components/CollabButton.tsx delete mode 100644 src/components/RoomDialog.tsx create mode 100644 src/excalidraw-app/app_constants.ts create mode 100644 src/excalidraw-app/collab/CollabWrapper.tsx rename src/{components => excalidraw-app/collab}/Portal.tsx (62%) rename src/{components => excalidraw-app/collab}/RoomDialog.scss (68%) create mode 100644 src/excalidraw-app/collab/RoomDialog.tsx rename src/{ => excalidraw-app}/data/firebase.ts (93%) create mode 100644 src/excalidraw-app/data/index.ts rename src/{ => excalidraw-app}/data/localStorage.ts (81%) delete mode 100644 src/time_constants.ts diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 7d5a371a..2a90dd78 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -64,7 +64,6 @@ export const actionClearCanvas = register({ exportEmbedScene: appState.exportEmbedScene, gridSize: appState.gridSize, shouldAddWatermark: appState.shouldAddWatermark, - username: appState.username, }, commitToHistory: true, }; diff --git a/src/actions/actionNavigate.tsx b/src/actions/actionNavigate.tsx index e41b2eb0..02d981f7 100644 --- a/src/actions/actionNavigate.tsx +++ b/src/actions/actionNavigate.tsx @@ -34,7 +34,6 @@ export const actionGoToCollaborator = register({ }, PanelComponent: ({ appState, updateData, id }) => { const clientId = id; - if (!clientId) { return null; } diff --git a/src/appState.ts b/src/appState.ts index 8452b18c..f99da991 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -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 = ( diff --git a/src/components/App.tsx b/src/components/App.tsx index c40b7e66..8a463a60 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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["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["updateScene"]; - resetScene: InstanceType["resetScene"]; - resetHistory: InstanceType["resetHistory"]; - getSceneElementsIncludingDeleted: InstanceType< - typeof App - >["getSceneElementsIncludingDeleted"]; - } - | undefined; +export type ExcalidrawImperativeAPI = { + updateScene: InstanceType["updateScene"]; + resetScene: InstanceType["resetScene"]; + getSceneElementsIncludingDeleted: InstanceType< + typeof App + >["getSceneElementsIncludingDeleted"]; + history: { + clear: InstanceType["resetHistory"]; + }; + setScrollToCenter: InstanceType["setScrollToCenter"]; + getSceneElements: InstanceType["getSceneElements"]; + readyPromise: ResolvablePromise; + ready: true; +}; class App extends React.Component { 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(); public static defaultProps: Partial = { 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() + : 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 { 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 { return (
{ 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 { zenModeEnabled={zenModeEnabled} toggleZenMode={this.toggleZenMode} lng={getLanguage().lng} + isCollaborating={this.props.isCollaborating || false} />
{ ); } - 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 { ...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 { }); private onUnload = () => { - this.destroySocketClient(); this.onBlur(); }; @@ -499,46 +481,6 @@ class App extends React.Component { this.onSceneUpdated(); }; - private shouldForceLoadScene( - scene: ResolutionType, - ): 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 { 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 { ); } - 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 { 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 { 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 { this.onGestureEnd as any, false, ); - window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); } private addEventListeners() { @@ -811,7 +705,6 @@ class App extends React.Component { 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 { 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 { 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 { 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 { 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 { }); }; - 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 { 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 { 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; 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, }, }); diff --git a/src/components/CollabButton.scss b/src/components/CollabButton.scss new file mode 100644 index 00000000..fd51cc05 --- /dev/null +++ b/src/components/CollabButton.scss @@ -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); + } +} diff --git a/src/components/CollabButton.tsx b/src/components/CollabButton.tsx new file mode 100644 index 00000000..297b5639 --- /dev/null +++ b/src/components/CollabButton.tsx @@ -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 ( + <> + { + trackEvent(EVENT_DIALOG, "collaboration"); + onClick(); + }} + icon={users} + type="button" + title={t("buttons.roomDialog")} + aria-label={t("buttons.roomDialog")} + showAriaLabel={useIsMobile()} + > + {collaboratorCount > 0 && ( +
{collaboratorCount}
+ )} +
+ + ); +}; + +export default CollabButton; diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 8cd86698..c6a06b89 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -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["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")} - - setAppState({ errorMessage: message }) - } - /> + {onCollabButtonClick && ( + + )} ) : (
diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 9b011568..9152dfd6 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -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["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 && } @@ -94,17 +92,13 @@ export const MobileMenu = ({ {actionManager.renderAction("saveAsScene")} {exportButton} {actionManager.renderAction("clearCanvas")} - - setAppState({ errorMessage: message }) - } - /> + {onCollabButtonClick && ( + + )} void; - onRoomCreate: () => void; - onRoomDestroy: () => void; - onPressingEnter: () => void; - setErrorMessage: (message: string) => void; -}) => { - const roomLinkInput = useRef(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) => { - if (event.target !== document.activeElement) { - event.preventDefault(); - (event.target as HTMLInputElement).select(); - } - }; - - return ( -
- {!activeRoomLink && ( - <> -

{t("roomDialog.desc_intro")}

-

{`🔒 ${t("roomDialog.desc_privacy")}`}

-
- -
- - )} - {activeRoomLink && ( - <> -

{t("roomDialog.desc_inProgressIntro")}

-

{t("roomDialog.desc_shareLink")}

-
- - -
-
- - onUsernameChange(event.target.value)} - onBlur={() => trackEvent(EVENT_SHARE, "name")} - onKeyPress={(event) => - event.key === KEYS.ENTER && onPressingEnter() - } - /> -
-

- {" "} - {t("roomDialog.desc_privacy")} -

-

{t("roomDialog.desc_exitSession")}

-
- -
- - )} -
- ); -}; - -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(null); - - const handleClose = React.useCallback(() => { - setModalIsShown(false); - triggerButton.current?.focus(); - }, []); - - useEffect(() => { - setActiveRoomLink(isCollaborating ? window.location.href : ""); - }, [isCollaborating]); - - return ( - <> - { - 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 && ( -
- {collaboratorCount} -
- )} -
- {modalIsShown && ( - - - - )} - - ); -}; diff --git a/src/constants.ts b/src/constants.ts index 17859204..cdf1250b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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; diff --git a/src/data/index.ts b/src/data/index.ts index b9b3f365..5989e931 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -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 => { - 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 => { - 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 => { - 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, - }; -}; diff --git a/src/data/restore.ts b/src/data/restore.ts index 5096151d..80c4ffa3 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -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 | null | undefined, ): DataState => { return { - elements: restoreElements(data.elements), - appState: restoreAppState(data.appState, localAppState || null), + elements: restoreElements(data?.elements), + appState: restoreAppState(data?.appState, localAppState || null), }; }; diff --git a/src/excalidraw-app/app_constants.ts b/src/excalidraw-app/app_constants.ts new file mode 100644 index 00000000..0ca86617 --- /dev/null +++ b/src/excalidraw-app/app_constants.ts @@ -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", +} diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/CollabWrapper.tsx new file mode 100644 index 00000000..aa5c0c1b --- /dev/null +++ b/src/excalidraw-app/collab/CollabWrapper.tsx @@ -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; + +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; +} + +class CollabWrapper extends PureComponent { + portal: Portal; + private socketInitializationTimer?: NodeJS.Timeout; + private excalidrawRef: Props["excalidrawRef"]; + excalidrawAppState?: AppState; + private lastBroadcastedOrReceivedSceneVersion: number = -1; + private collaborators = new Map(); + + 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 => { + if (this.portal.socket) { + return null; + } + + const scenePromise = resolvablePromise(); + + 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 && ( + { + this.setState({ errorMessage }); + }} + /> + )} + {errorMessage && ( + 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; diff --git a/src/components/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx similarity index 62% rename from src/components/Portal.tsx rename to src/excalidraw-app/collab/Portal.tsx index 40043135..2b66b0bd 100644 --- a/src/components/Portal.tsx +++ b/src/excalidraw-app/collab/Portal.tsx @@ -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 = 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) - // 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) + // add local elements that weren't deleted or on remote + .concat(...Object.values(localElementMap)) + ); }; } diff --git a/src/components/RoomDialog.scss b/src/excalidraw-app/collab/RoomDialog.scss similarity index 68% rename from src/components/RoomDialog.scss rename to src/excalidraw-app/collab/RoomDialog.scss index 5142ece2..de784d93 100644 --- a/src/components/RoomDialog.scss +++ b/src/excalidraw-app/collab/RoomDialog.scss @@ -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; diff --git a/src/excalidraw-app/collab/RoomDialog.tsx b/src/excalidraw-app/collab/RoomDialog.tsx new file mode 100644 index 00000000..afe8ef24 --- /dev/null +++ b/src/excalidraw-app/collab/RoomDialog.tsx @@ -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(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) => { + if (event.target !== document.activeElement) { + event.preventDefault(); + (event.target as HTMLInputElement).select(); + } + }; + + const renderRoomDialog = () => { + return ( +
+ {!activeRoomLink && ( + <> +

{t("roomDialog.desc_intro")}

+

{`🔒 ${t("roomDialog.desc_privacy")}`}

+
+ +
+ + )} + {activeRoomLink && ( + <> +

{t("roomDialog.desc_inProgressIntro")}

+

{t("roomDialog.desc_shareLink")}

+
+ + +
+
+ + onUsernameChange(event.target.value)} + onBlur={() => trackEvent(EVENT_SHARE, "name")} + onKeyPress={(event) => event.key === "Enter" && handleClose()} + /> +
+

+ {" "} + {t("roomDialog.desc_privacy")} +

+

{t("roomDialog.desc_exitSession")}

+
+ +
+ + )} +
+ ); + }; + return ( + + {renderRoomDialog()} + + ); +}; + +export default RoomDialog; diff --git a/src/data/firebase.ts b/src/excalidraw-app/data/firebase.ts similarity index 93% rename from src/data/firebase.ts rename to src/excalidraw-app/data/firebase.ts index ad6db613..5e2163df 100644 --- a/src/data/firebase.ts +++ b/src/excalidraw-app/data/firebase.ts @@ -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 { diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts new file mode 100644 index 00000000..38eae181 --- /dev/null +++ b/src/excalidraw-app/data/index.ts @@ -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 => { + 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 => { + 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 => { + 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, + }; +}; diff --git a/src/data/localStorage.ts b/src/excalidraw-app/data/localStorage.ts similarity index 81% rename from src/data/localStorage.ts rename to src/excalidraw-app/data/localStorage.ts index 8c106db9..21cfe097 100644 --- a/src/data/localStorage.ts +++ b/src/excalidraw-app/data/localStorage.ts @@ -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; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 91ebe65d..07768a46 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -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 = { + 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, +): 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 => { + 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; + }>({ promise: null! }); + if (!initialStatePromiseRef.current.promise) { + initialStatePromiseRef.current.promise = resolvablePromise(); + } + + 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 ? ( - - - - ) : ( - + const onChange = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + ) => { + saveDebounced(elements, appState); + if (collab.isCollaborating) { + collab.broadcastElements(elements, appState); + } + }; + + return ( + + ); +} + +export default function ExcalidrawApp() { + return ( + + + } + > + {(collab) => } + + ); } diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 23f76918..8879e48d 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -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} /> @@ -79,7 +84,12 @@ const areEqual = ( }; const forwardedRefComp = forwardRef< - ExcalidrawImperativeAPI, + ExcalidrawAPIRefValue, PublicExcalidrawProps ->((props, ref) => ); +>((props, ref) => ); export default React.memo(forwardedRefComp, areEqual); +export { + getSceneVersion, + getSyncableElements, + getElementMap, +} from "../../element"; diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index e8463f0c..51eadb89 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -32,7 +32,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -69,7 +68,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -452,7 +450,7 @@ Object { exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected: [end of test] number of elements 1`] = `3`; -exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected: [end of test] number of renders 1`] = `25`; +exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected: [end of test] number of renders 1`] = `24`; exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected: [end of test] appState 1`] = ` Object { @@ -486,7 +484,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -530,7 +527,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -912,7 +908,7 @@ Object { exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected: [end of test] number of elements 1`] = `3`; -exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected: [end of test] number of renders 1`] = `21`; +exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected: [end of test] number of renders 1`] = `20`; exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] appState 1`] = ` Object { @@ -946,7 +942,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": false, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -973,7 +968,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -1681,7 +1675,7 @@ Object { exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] number of elements 1`] = `3`; -exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] number of renders 1`] = `40`; +exports[`regression tests Cmd/Ctrl-click exclusively select element under pointer: [end of test] number of renders 1`] = `39`; exports[`regression tests Drags selected element when hitting only bounding box and keeps element selected: [end of test] appState 1`] = ` Object { @@ -1715,7 +1709,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -1744,7 +1737,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -1878,7 +1870,7 @@ Object { exports[`regression tests Drags selected element when hitting only bounding box and keeps element selected: [end of test] number of elements 1`] = `1`; -exports[`regression tests Drags selected element when hitting only bounding box and keeps element selected: [end of test] number of renders 1`] = `9`; +exports[`regression tests Drags selected element when hitting only bounding box and keeps element selected: [end of test] number of renders 1`] = `8`; exports[`regression tests adjusts z order when grouping: [end of test] appState 1`] = ` Object { @@ -1912,7 +1904,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -1946,7 +1937,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -2329,7 +2319,7 @@ Object { exports[`regression tests adjusts z order when grouping: [end of test] number of elements 1`] = `3`; -exports[`regression tests adjusts z order when grouping: [end of test] number of renders 1`] = `19`; +exports[`regression tests adjusts z order when grouping: [end of test] number of renders 1`] = `18`; exports[`regression tests alt-drag duplicates an element: [end of test] appState 1`] = ` Object { @@ -2363,7 +2353,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -2392,7 +2381,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -2575,7 +2563,7 @@ Object { exports[`regression tests alt-drag duplicates an element: [end of test] number of elements 1`] = `2`; -exports[`regression tests alt-drag duplicates an element: [end of test] number of renders 1`] = `9`; +exports[`regression tests alt-drag duplicates an element: [end of test] number of renders 1`] = `8`; exports[`regression tests arrow keys: [end of test] appState 1`] = ` Object { @@ -2609,7 +2597,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -2635,7 +2622,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -2732,7 +2718,7 @@ Object { exports[`regression tests arrow keys: [end of test] number of elements 1`] = `1`; -exports[`regression tests arrow keys: [end of test] number of renders 1`] = `18`; +exports[`regression tests arrow keys: [end of test] number of renders 1`] = `17`; exports[`regression tests can drag element that covers another element, while another elem is selected: [end of test] appState 1`] = ` Object { @@ -2766,7 +2752,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -2795,7 +2780,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -3202,7 +3186,7 @@ Object { exports[`regression tests can drag element that covers another element, while another elem is selected: [end of test] number of elements 1`] = `3`; -exports[`regression tests can drag element that covers another element, while another elem is selected: [end of test] number of renders 1`] = `17`; +exports[`regression tests can drag element that covers another element, while another elem is selected: [end of test] number of renders 1`] = `16`; exports[`regression tests change the properties of a shape: [end of test] appState 1`] = ` Object { @@ -3236,7 +3220,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -3262,7 +3245,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -3503,7 +3485,7 @@ Object { exports[`regression tests change the properties of a shape: [end of test] number of elements 1`] = `1`; -exports[`regression tests change the properties of a shape: [end of test] number of renders 1`] = `10`; +exports[`regression tests change the properties of a shape: [end of test] number of renders 1`] = `9`; exports[`regression tests click on an element and drag it: [dragged] appState 1`] = ` Object { @@ -3537,7 +3519,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -3566,7 +3547,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -3700,7 +3680,7 @@ Object { exports[`regression tests click on an element and drag it: [dragged] number of elements 1`] = `1`; -exports[`regression tests click on an element and drag it: [dragged] number of renders 1`] = `9`; +exports[`regression tests click on an element and drag it: [dragged] number of renders 1`] = `8`; exports[`regression tests click on an element and drag it: [end of test] appState 1`] = ` Object { @@ -3734,7 +3714,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -3765,7 +3744,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -3937,7 +3915,7 @@ Object { exports[`regression tests click on an element and drag it: [end of test] number of elements 1`] = `1`; -exports[`regression tests click on an element and drag it: [end of test] number of renders 1`] = `12`; +exports[`regression tests click on an element and drag it: [end of test] number of renders 1`] = `11`; exports[`regression tests click to select a shape: [end of test] appState 1`] = ` Object { @@ -3971,7 +3949,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -4000,7 +3977,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -4182,7 +4158,7 @@ Object { exports[`regression tests click to select a shape: [end of test] number of elements 1`] = `2`; -exports[`regression tests click to select a shape: [end of test] number of renders 1`] = `12`; +exports[`regression tests click to select a shape: [end of test] number of renders 1`] = `11`; exports[`regression tests click-drag to select a group: [end of test] appState 1`] = ` Object { @@ -4216,7 +4192,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -4246,7 +4221,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -4536,7 +4510,7 @@ Object { exports[`regression tests click-drag to select a group: [end of test] number of elements 1`] = `3`; -exports[`regression tests click-drag to select a group: [end of test] number of renders 1`] = `18`; +exports[`regression tests click-drag to select a group: [end of test] number of renders 1`] = `17`; exports[`regression tests deselects group of selected elements on pointer down when pointer doesn't hit any element: [end of test] appState 1`] = ` Object { @@ -4592,7 +4566,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -4642,7 +4615,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -4824,7 +4796,7 @@ Object { exports[`regression tests deselects group of selected elements on pointer down when pointer doesn't hit any element: [end of test] number of elements 1`] = `2`; -exports[`regression tests deselects group of selected elements on pointer down when pointer doesn't hit any element: [end of test] number of renders 1`] = `13`; +exports[`regression tests deselects group of selected elements on pointer down when pointer doesn't hit any element: [end of test] number of renders 1`] = `12`; exports[`regression tests deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element: [end of test] appState 1`] = ` Object { @@ -4880,7 +4852,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -4908,7 +4879,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -5124,7 +5094,7 @@ Object { exports[`regression tests deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element: [end of test] number of elements 1`] = `2`; -exports[`regression tests deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element: [end of test] number of renders 1`] = `14`; +exports[`regression tests deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element: [end of test] number of renders 1`] = `13`; exports[`regression tests deselects selected element on pointer down when pointer doesn't hit any element: [end of test] appState 1`] = ` Object { @@ -5180,7 +5150,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -5228,7 +5197,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -5325,7 +5293,7 @@ Object { exports[`regression tests deselects selected element on pointer down when pointer doesn't hit any element: [end of test] number of elements 1`] = `1`; -exports[`regression tests deselects selected element on pointer down when pointer doesn't hit any element: [end of test] number of renders 1`] = `7`; +exports[`regression tests deselects selected element on pointer down when pointer doesn't hit any element: [end of test] number of renders 1`] = `6`; exports[`regression tests deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element: [end of test] appState 1`] = ` Object { @@ -5381,7 +5349,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -5407,7 +5374,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -5504,7 +5470,7 @@ Object { exports[`regression tests deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element: [end of test] number of elements 1`] = `1`; -exports[`regression tests deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element: [end of test] number of renders 1`] = `8`; +exports[`regression tests deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element: [end of test] number of renders 1`] = `7`; exports[`regression tests double click to edit a group: [end of test] appState 1`] = ` Object { @@ -5538,7 +5504,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -5564,7 +5529,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -5950,7 +5914,7 @@ Object { exports[`regression tests double click to edit a group: [end of test] number of elements 1`] = `3`; -exports[`regression tests double click to edit a group: [end of test] number of renders 1`] = `17`; +exports[`regression tests double click to edit a group: [end of test] number of renders 1`] = `16`; exports[`regression tests drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging: [end of test] appState 1`] = ` Object { @@ -5984,7 +5948,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -6017,7 +5980,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -6261,7 +6223,7 @@ Object { exports[`regression tests drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging: [end of test] number of elements 1`] = `2`; -exports[`regression tests drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging: [end of test] number of renders 1`] = `15`; +exports[`regression tests drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging: [end of test] number of renders 1`] = `14`; exports[`regression tests draw every type of shape: [end of test] appState 1`] = ` Object { @@ -6295,7 +6257,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -6321,7 +6282,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -8234,7 +8194,7 @@ Object { exports[`regression tests draw every type of shape: [end of test] number of elements 1`] = `8`; -exports[`regression tests draw every type of shape: [end of test] number of renders 1`] = `50`; +exports[`regression tests draw every type of shape: [end of test] number of renders 1`] = `49`; exports[`regression tests given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up: [end of test] appState 1`] = ` Object { @@ -8268,7 +8228,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -8299,7 +8258,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -8589,7 +8547,7 @@ Object { exports[`regression tests given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up: [end of test] number of elements 1`] = `3`; -exports[`regression tests given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up: [end of test] number of renders 1`] = `18`; +exports[`regression tests given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up: [end of test] number of renders 1`] = `17`; exports[`regression tests given a selected element A and a not selected element B with higher z-index than A and given B partialy overlaps A when there's a shift-click on the overlapped section B is added to the selection: [end of test] appState 1`] = ` Object { @@ -8623,7 +8581,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -8655,7 +8612,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -8837,7 +8793,7 @@ Object { exports[`regression tests given a selected element A and a not selected element B with higher z-index than A and given B partialy overlaps A when there's a shift-click on the overlapped section B is added to the selection: [end of test] number of elements 1`] = `2`; -exports[`regression tests given a selected element A and a not selected element B with higher z-index than A and given B partialy overlaps A when there's a shift-click on the overlapped section B is added to the selection: [end of test] number of renders 1`] = `16`; +exports[`regression tests given a selected element A and a not selected element B with higher z-index than A and given B partialy overlaps A when there's a shift-click on the overlapped section B is added to the selection: [end of test] number of renders 1`] = `15`; exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when clicking intersection between A and B B should be selected on pointer up: [end of test] appState 1`] = ` Object { @@ -8871,7 +8827,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -8901,7 +8856,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -9083,7 +9037,7 @@ Object { exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when clicking intersection between A and B B should be selected on pointer up: [end of test] number of elements 1`] = `2`; -exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when clicking intersection between A and B B should be selected on pointer up: [end of test] number of renders 1`] = `16`; +exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when clicking intersection between A and B B should be selected on pointer up: [end of test] number of renders 1`] = `15`; exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when dragging on intersection between A and B A should be dragged and keep being selected: [end of test] appState 1`] = ` Object { @@ -9117,7 +9071,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -9148,7 +9101,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -9391,7 +9343,7 @@ Object { exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when dragging on intersection between A and B A should be dragged and keep being selected: [end of test] number of elements 1`] = `2`; -exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when dragging on intersection between A and B A should be dragged and keep being selected: [end of test] number of renders 1`] = `17`; +exports[`regression tests given selected element A with lower z-index than unselected element B and given B is partially over A when dragging on intersection between A and B A should be dragged and keep being selected: [end of test] number of renders 1`] = `16`; exports[`regression tests key 2 selects rectangle tool: [end of test] appState 1`] = ` Object { @@ -9425,7 +9377,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -9451,7 +9402,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -9548,7 +9498,7 @@ Object { exports[`regression tests key 2 selects rectangle tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key 2 selects rectangle tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key 2 selects rectangle tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key 3 selects diamond tool: [end of test] appState 1`] = ` Object { @@ -9582,7 +9532,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -9608,7 +9557,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -9705,7 +9653,7 @@ Object { exports[`regression tests key 3 selects diamond tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key 3 selects diamond tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key 3 selects diamond tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key 4 selects ellipse tool: [end of test] appState 1`] = ` Object { @@ -9739,7 +9687,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -9765,7 +9712,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -9862,7 +9808,7 @@ Object { exports[`regression tests key 4 selects ellipse tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key 4 selects ellipse tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key 4 selects ellipse tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key 5 selects arrow tool: [end of test] appState 1`] = ` Object { @@ -9896,7 +9842,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -9922,7 +9867,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -10045,7 +9989,7 @@ Object { exports[`regression tests key 5 selects arrow tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key 5 selects arrow tool: [end of test] number of renders 1`] = `7`; +exports[`regression tests key 5 selects arrow tool: [end of test] number of renders 1`] = `6`; exports[`regression tests key 6 selects line tool: [end of test] appState 1`] = ` Object { @@ -10079,7 +10023,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -10105,7 +10048,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -10228,7 +10170,7 @@ Object { exports[`regression tests key 6 selects line tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key 6 selects line tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key 6 selects line tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key 7 selects draw tool: [end of test] appState 1`] = ` Object { @@ -10262,7 +10204,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -10288,7 +10229,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -10411,7 +10351,7 @@ Object { exports[`regression tests key 7 selects draw tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key 7 selects draw tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key 7 selects draw tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key a selects arrow tool: [end of test] appState 1`] = ` Object { @@ -10445,7 +10385,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -10471,7 +10410,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -10594,7 +10532,7 @@ Object { exports[`regression tests key a selects arrow tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key a selects arrow tool: [end of test] number of renders 1`] = `7`; +exports[`regression tests key a selects arrow tool: [end of test] number of renders 1`] = `6`; exports[`regression tests key d selects diamond tool: [end of test] appState 1`] = ` Object { @@ -10628,7 +10566,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -10654,7 +10591,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -10751,7 +10687,7 @@ Object { exports[`regression tests key d selects diamond tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key d selects diamond tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key d selects diamond tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key e selects ellipse tool: [end of test] appState 1`] = ` Object { @@ -10785,7 +10721,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -10811,7 +10746,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -10908,7 +10842,7 @@ Object { exports[`regression tests key e selects ellipse tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key e selects ellipse tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key e selects ellipse tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key l selects line tool: [end of test] appState 1`] = ` Object { @@ -10942,7 +10876,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -10968,7 +10901,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -11091,7 +11023,7 @@ Object { exports[`regression tests key l selects line tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key l selects line tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key l selects line tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key r selects rectangle tool: [end of test] appState 1`] = ` Object { @@ -11125,7 +11057,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -11151,7 +11082,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -11248,7 +11178,7 @@ Object { exports[`regression tests key r selects rectangle tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key r selects rectangle tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key r selects rectangle tool: [end of test] number of renders 1`] = `5`; exports[`regression tests key x selects draw tool: [end of test] appState 1`] = ` Object { @@ -11282,7 +11212,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -11308,7 +11237,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -11431,7 +11359,7 @@ Object { exports[`regression tests key x selects draw tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests key x selects draw tool: [end of test] number of renders 1`] = `6`; +exports[`regression tests key x selects draw tool: [end of test] number of renders 1`] = `5`; exports[`regression tests make a group and duplicate it: [end of test] appState 1`] = ` Object { @@ -11465,7 +11393,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -11502,7 +11429,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -12140,7 +12066,7 @@ Object { exports[`regression tests make a group and duplicate it: [end of test] number of elements 1`] = `6`; -exports[`regression tests make a group and duplicate it: [end of test] number of renders 1`] = `21`; +exports[`regression tests make a group and duplicate it: [end of test] number of renders 1`] = `20`; exports[`regression tests noop interaction after undo shouldn't create history entry: [end of test] appState 1`] = ` Object { @@ -12174,7 +12100,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -12204,7 +12129,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -12386,7 +12310,7 @@ Object { exports[`regression tests noop interaction after undo shouldn't create history entry: [end of test] number of elements 1`] = `2`; -exports[`regression tests noop interaction after undo shouldn't create history entry: [end of test] number of renders 1`] = `18`; +exports[`regression tests noop interaction after undo shouldn't create history entry: [end of test] number of renders 1`] = `17`; exports[`regression tests pinch-to-zoom works: [end of test] appState 1`] = ` Object { @@ -12420,7 +12344,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -12446,7 +12369,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -12481,7 +12403,7 @@ Object { exports[`regression tests pinch-to-zoom works: [end of test] number of elements 1`] = `0`; -exports[`regression tests pinch-to-zoom works: [end of test] number of renders 1`] = `8`; +exports[`regression tests pinch-to-zoom works: [end of test] number of renders 1`] = `7`; exports[`regression tests rerenders UI on language change: [end of test] appState 1`] = ` Object { @@ -12515,7 +12437,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -12539,7 +12460,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -12574,7 +12494,7 @@ Object { exports[`regression tests rerenders UI on language change: [end of test] number of elements 1`] = `0`; -exports[`regression tests rerenders UI on language change: [end of test] number of renders 1`] = `5`; +exports[`regression tests rerenders UI on language change: [end of test] number of renders 1`] = `4`; exports[`regression tests resize an element, trying every resize handle: [end of test] appState 1`] = ` Object { @@ -12608,7 +12528,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -12650,7 +12569,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -13459,7 +13377,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [end of test] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [end of test] number of renders 1`] = `54`; +exports[`regression tests resize an element, trying every resize handle: [end of test] number of renders 1`] = `53`; exports[`regression tests resize an element, trying every resize handle: [resize handle ne (+5, +5)] appState 1`] = ` Object { @@ -13493,7 +13411,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -13526,7 +13443,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -13903,7 +13819,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [resize handle ne (+5, +5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [resize handle ne (+5, +5)] number of renders 1`] = `27`; +exports[`regression tests resize an element, trying every resize handle: [resize handle ne (+5, +5)] number of renders 1`] = `26`; exports[`regression tests resize an element, trying every resize handle: [resize handle ne (-5, -5)] appState 1`] = ` Object { @@ -13937,7 +13853,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -13968,7 +13883,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -14260,7 +14174,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [resize handle ne (-5, -5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [resize handle ne (-5, -5)] number of renders 1`] = `21`; +exports[`regression tests resize an element, trying every resize handle: [resize handle ne (-5, -5)] number of renders 1`] = `20`; exports[`regression tests resize an element, trying every resize handle: [resize handle nw (+5, +5)] appState 1`] = ` Object { @@ -14294,7 +14208,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -14323,7 +14236,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -14534,7 +14446,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [resize handle nw (+5, +5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [resize handle nw (+5, +5)] number of renders 1`] = `15`; +exports[`regression tests resize an element, trying every resize handle: [resize handle nw (+5, +5)] number of renders 1`] = `14`; exports[`regression tests resize an element, trying every resize handle: [resize handle nw (-5, -5)] appState 1`] = ` Object { @@ -14568,7 +14480,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -14595,7 +14506,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -14729,7 +14639,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [resize handle nw (-5, -5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [resize handle nw (-5, -5)] number of renders 1`] = `9`; +exports[`regression tests resize an element, trying every resize handle: [resize handle nw (-5, -5)] number of renders 1`] = `8`; exports[`regression tests resize an element, trying every resize handle: [resize handle se (+5, +5)] appState 1`] = ` Object { @@ -14763,7 +14673,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -14804,7 +14713,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -15561,7 +15469,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [resize handle se (+5, +5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [resize handle se (+5, +5)] number of renders 1`] = `51`; +exports[`regression tests resize an element, trying every resize handle: [resize handle se (+5, +5)] number of renders 1`] = `50`; exports[`regression tests resize an element, trying every resize handle: [resize handle se (-5, -5)] appState 1`] = ` Object { @@ -15595,7 +15503,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -15634,7 +15541,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -16290,7 +16196,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [resize handle se (-5, -5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [resize handle se (-5, -5)] number of renders 1`] = `45`; +exports[`regression tests resize an element, trying every resize handle: [resize handle se (-5, -5)] number of renders 1`] = `44`; exports[`regression tests resize an element, trying every resize handle: [resize handle sw (+5, +5)] appState 1`] = ` Object { @@ -16324,7 +16230,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -16361,7 +16266,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -16920,7 +16824,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [resize handle sw (+5, +5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [resize handle sw (+5, +5)] number of renders 1`] = `39`; +exports[`regression tests resize an element, trying every resize handle: [resize handle sw (+5, +5)] number of renders 1`] = `38`; exports[`regression tests resize an element, trying every resize handle: [resize handle sw (-5, -5)] appState 1`] = ` Object { @@ -16954,7 +16858,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -16989,7 +16892,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -17455,7 +17357,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [resize handle sw (-5, -5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [resize handle sw (-5, -5)] number of renders 1`] = `33`; +exports[`regression tests resize an element, trying every resize handle: [resize handle sw (-5, -5)] number of renders 1`] = `32`; exports[`regression tests resize an element, trying every resize handle: [unresize handle ne (+5, +5)] appState 1`] = ` Object { @@ -17489,7 +17391,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -17523,7 +17424,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -17944,7 +17844,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [unresize handle ne (+5, +5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [unresize handle ne (+5, +5)] number of renders 1`] = `30`; +exports[`regression tests resize an element, trying every resize handle: [unresize handle ne (+5, +5)] number of renders 1`] = `29`; exports[`regression tests resize an element, trying every resize handle: [unresize handle ne (-5, -5)] appState 1`] = ` Object { @@ -17978,7 +17878,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -18010,7 +17909,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -18344,7 +18242,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [unresize handle ne (-5, -5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [unresize handle ne (-5, -5)] number of renders 1`] = `24`; +exports[`regression tests resize an element, trying every resize handle: [unresize handle ne (-5, -5)] number of renders 1`] = `23`; exports[`regression tests resize an element, trying every resize handle: [unresize handle nw (+5, +5)] appState 1`] = ` Object { @@ -18378,7 +18276,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -18408,7 +18305,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -18659,7 +18555,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [unresize handle nw (+5, +5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [unresize handle nw (+5, +5)] number of renders 1`] = `18`; +exports[`regression tests resize an element, trying every resize handle: [unresize handle nw (+5, +5)] number of renders 1`] = `17`; exports[`regression tests resize an element, trying every resize handle: [unresize handle nw (-5, -5)] appState 1`] = ` Object { @@ -18693,7 +18589,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -18721,7 +18616,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -18893,7 +18787,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [unresize handle nw (-5, -5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [unresize handle nw (-5, -5)] number of renders 1`] = `12`; +exports[`regression tests resize an element, trying every resize handle: [unresize handle nw (-5, -5)] number of renders 1`] = `11`; exports[`regression tests resize an element, trying every resize handle: [unresize handle se (+5, +5)] appState 1`] = ` Object { @@ -18927,7 +18821,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -18969,7 +18862,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -19778,7 +19670,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [unresize handle se (+5, +5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [unresize handle se (+5, +5)] number of renders 1`] = `54`; +exports[`regression tests resize an element, trying every resize handle: [unresize handle se (+5, +5)] number of renders 1`] = `53`; exports[`regression tests resize an element, trying every resize handle: [unresize handle se (-5, -5)] appState 1`] = ` Object { @@ -19812,7 +19704,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -19852,7 +19743,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -20558,7 +20448,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [unresize handle se (-5, -5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [unresize handle se (-5, -5)] number of renders 1`] = `48`; +exports[`regression tests resize an element, trying every resize handle: [unresize handle se (-5, -5)] number of renders 1`] = `47`; exports[`regression tests resize an element, trying every resize handle: [unresize handle sw (+5, +5)] appState 1`] = ` Object { @@ -20592,7 +20482,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -20630,7 +20519,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -21237,7 +21125,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [unresize handle sw (+5, +5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [unresize handle sw (+5, +5)] number of renders 1`] = `42`; +exports[`regression tests resize an element, trying every resize handle: [unresize handle sw (+5, +5)] number of renders 1`] = `41`; exports[`regression tests resize an element, trying every resize handle: [unresize handle sw (-5, -5)] appState 1`] = ` Object { @@ -21271,7 +21159,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -21307,7 +21194,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -21819,7 +21705,7 @@ Object { exports[`regression tests resize an element, trying every resize handle: [unresize handle sw (-5, -5)] number of elements 1`] = `1`; -exports[`regression tests resize an element, trying every resize handle: [unresize handle sw (-5, -5)] number of renders 1`] = `36`; +exports[`regression tests resize an element, trying every resize handle: [unresize handle sw (-5, -5)] number of renders 1`] = `35`; exports[`regression tests selecting 'Add to library' in context menu adds element to library: [end of test] appState 1`] = ` Object { @@ -21853,7 +21739,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -21879,7 +21764,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -21976,7 +21860,7 @@ Object { exports[`regression tests selecting 'Add to library' in context menu adds element to library: [end of test] number of elements 1`] = `1`; -exports[`regression tests selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `6`; +exports[`regression tests selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `5`; exports[`regression tests selecting 'Bring forward' in context menu brings element forward: [end of test] appState 1`] = ` Object { @@ -22010,7 +21894,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -22036,7 +21919,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -22277,7 +22159,7 @@ Object { exports[`regression tests selecting 'Bring forward' in context menu brings element forward: [end of test] number of elements 1`] = `2`; -exports[`regression tests selecting 'Bring forward' in context menu brings element forward: [end of test] number of renders 1`] = `12`; +exports[`regression tests selecting 'Bring forward' in context menu brings element forward: [end of test] number of renders 1`] = `11`; exports[`regression tests selecting 'Bring to front' in context menu brings element to front: [end of test] appState 1`] = ` Object { @@ -22311,7 +22193,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -22337,7 +22218,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -22578,7 +22458,7 @@ Object { exports[`regression tests selecting 'Bring to front' in context menu brings element to front: [end of test] number of elements 1`] = `2`; -exports[`regression tests selecting 'Bring to front' in context menu brings element to front: [end of test] number of renders 1`] = `12`; +exports[`regression tests selecting 'Bring to front' in context menu brings element to front: [end of test] number of renders 1`] = `11`; exports[`regression tests selecting 'Copy styles' in context menu copies styles: [end of test] appState 1`] = ` Object { @@ -22612,7 +22492,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -22638,7 +22517,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -22735,7 +22613,7 @@ Object { exports[`regression tests selecting 'Copy styles' in context menu copies styles: [end of test] number of elements 1`] = `1`; -exports[`regression tests selecting 'Copy styles' in context menu copies styles: [end of test] number of renders 1`] = `6`; +exports[`regression tests selecting 'Copy styles' in context menu copies styles: [end of test] number of renders 1`] = `5`; exports[`regression tests selecting 'Delete' in context menu deletes element: [end of test] appState 1`] = ` Object { @@ -22769,7 +22647,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -22793,7 +22670,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -22924,7 +22800,7 @@ Object { exports[`regression tests selecting 'Delete' in context menu deletes element: [end of test] number of elements 1`] = `1`; -exports[`regression tests selecting 'Delete' in context menu deletes element: [end of test] number of renders 1`] = `7`; +exports[`regression tests selecting 'Delete' in context menu deletes element: [end of test] number of renders 1`] = `6`; exports[`regression tests selecting 'Duplicate' in context menu duplicates element: [end of test] appState 1`] = ` Object { @@ -22958,7 +22834,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -22984,7 +22859,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -23166,7 +23040,7 @@ Object { exports[`regression tests selecting 'Duplicate' in context menu duplicates element: [end of test] number of elements 1`] = `2`; -exports[`regression tests selecting 'Duplicate' in context menu duplicates element: [end of test] number of renders 1`] = `7`; +exports[`regression tests selecting 'Duplicate' in context menu duplicates element: [end of test] number of renders 1`] = `6`; exports[`regression tests selecting 'Group selection' in context menu groups selected elements: [end of test] appState 1`] = ` Object { @@ -23200,7 +23074,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -23232,7 +23105,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -23483,7 +23355,7 @@ Object { exports[`regression tests selecting 'Group selection' in context menu groups selected elements: [end of test] number of elements 1`] = `2`; -exports[`regression tests selecting 'Group selection' in context menu groups selected elements: [end of test] number of renders 1`] = `13`; +exports[`regression tests selecting 'Group selection' in context menu groups selected elements: [end of test] number of renders 1`] = `12`; exports[`regression tests selecting 'Paste styles' in context menu pastes styles: [end of test] appState 1`] = ` Object { @@ -23517,7 +23389,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -23543,7 +23414,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -24315,7 +24185,7 @@ Object { exports[`regression tests selecting 'Paste styles' in context menu pastes styles: [end of test] number of elements 1`] = `2`; -exports[`regression tests selecting 'Paste styles' in context menu pastes styles: [end of test] number of renders 1`] = `21`; +exports[`regression tests selecting 'Paste styles' in context menu pastes styles: [end of test] number of renders 1`] = `20`; exports[`regression tests selecting 'Send backward' in context menu sends element backward: [end of test] appState 1`] = ` Object { @@ -24349,7 +24219,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -24375,7 +24244,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -24616,7 +24484,7 @@ Object { exports[`regression tests selecting 'Send backward' in context menu sends element backward: [end of test] number of elements 1`] = `2`; -exports[`regression tests selecting 'Send backward' in context menu sends element backward: [end of test] number of renders 1`] = `11`; +exports[`regression tests selecting 'Send backward' in context menu sends element backward: [end of test] number of renders 1`] = `10`; exports[`regression tests selecting 'Send to back' in context menu sends element to back: [end of test] appState 1`] = ` Object { @@ -24650,7 +24518,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -24676,7 +24543,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -24917,7 +24783,7 @@ Object { exports[`regression tests selecting 'Send to back' in context menu sends element to back: [end of test] number of elements 1`] = `2`; -exports[`regression tests selecting 'Send to back' in context menu sends element to back: [end of test] number of renders 1`] = `11`; +exports[`regression tests selecting 'Send to back' in context menu sends element to back: [end of test] number of renders 1`] = `10`; exports[`regression tests selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] appState 1`] = ` Object { @@ -24951,7 +24817,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -24981,7 +24846,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -25289,7 +25153,7 @@ Object { exports[`regression tests selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] number of elements 1`] = `2`; -exports[`regression tests selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] number of renders 1`] = `14`; +exports[`regression tests selecting 'Ungroup selection' in context menu ungroups selected group: [end of test] number of renders 1`] = `13`; exports[`regression tests shift click on selected element should deselect it on pointer up: [end of test] appState 1`] = ` Object { @@ -25323,7 +25187,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -25352,7 +25215,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -25449,7 +25311,7 @@ Object { exports[`regression tests shift click on selected element should deselect it on pointer up: [end of test] number of elements 1`] = `1`; -exports[`regression tests shift click on selected element should deselect it on pointer up: [end of test] number of renders 1`] = `8`; +exports[`regression tests shift click on selected element should deselect it on pointer up: [end of test] number of renders 1`] = `7`; exports[`regression tests shift-click to multiselect, then drag: [end of test] appState 1`] = ` Object { @@ -25483,7 +25345,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -25518,7 +25379,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -25763,7 +25623,7 @@ Object { exports[`regression tests shift-click to multiselect, then drag: [end of test] number of elements 1`] = `2`; -exports[`regression tests shift-click to multiselect, then drag: [end of test] number of renders 1`] = `17`; +exports[`regression tests shift-click to multiselect, then drag: [end of test] number of renders 1`] = `16`; exports[`regression tests should show fill icons when element has non transparent background: [end of test] appState 1`] = ` Object { @@ -25797,7 +25657,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -25826,7 +25685,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -25995,7 +25853,7 @@ Object { exports[`regression tests should show fill icons when element has non transparent background: [end of test] number of elements 1`] = `1`; -exports[`regression tests should show fill icons when element has non transparent background: [end of test] number of renders 1`] = `10`; +exports[`regression tests should show fill icons when element has non transparent background: [end of test] number of renders 1`] = `9`; exports[`regression tests shows 'Group selection' in context menu for multiple selected elements: [end of test] appState 1`] = ` Object { @@ -26029,7 +25887,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -26061,7 +25918,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -26243,7 +26099,7 @@ Object { exports[`regression tests shows 'Group selection' in context menu for multiple selected elements: [end of test] number of elements 1`] = `2`; -exports[`regression tests shows 'Group selection' in context menu for multiple selected elements: [end of test] number of renders 1`] = `14`; +exports[`regression tests shows 'Group selection' in context menu for multiple selected elements: [end of test] number of renders 1`] = `13`; exports[`regression tests shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] appState 1`] = ` Object { @@ -26277,7 +26133,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -26311,7 +26166,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -26563,7 +26417,7 @@ Object { exports[`regression tests shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] number of elements 1`] = `2`; -exports[`regression tests shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] number of renders 1`] = `15`; +exports[`regression tests shows 'Ungroup selection' in context menu for group inside selected elements: [end of test] number of renders 1`] = `14`; exports[`regression tests shows context menu for canvas: [end of test] appState 1`] = ` Object { @@ -26597,7 +26451,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -26621,7 +26474,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -26656,7 +26508,7 @@ Object { exports[`regression tests shows context menu for canvas: [end of test] number of elements 1`] = `0`; -exports[`regression tests shows context menu for canvas: [end of test] number of renders 1`] = `2`; +exports[`regression tests shows context menu for canvas: [end of test] number of renders 1`] = `1`; exports[`regression tests shows context menu for element: [end of test] appState 1`] = ` Object { @@ -26690,7 +26542,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -26716,7 +26567,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -26813,7 +26663,7 @@ Object { exports[`regression tests shows context menu for element: [end of test] number of elements 1`] = `1`; -exports[`regression tests shows context menu for element: [end of test] number of renders 1`] = `6`; +exports[`regression tests shows context menu for element: [end of test] number of renders 1`] = `5`; exports[`regression tests single-clicking on a subgroup of a selected group should not alter selection: [end of test] appState 1`] = ` Object { @@ -26847,7 +26697,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -26884,7 +26733,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -27627,7 +27475,7 @@ Object { exports[`regression tests single-clicking on a subgroup of a selected group should not alter selection: [end of test] number of elements 1`] = `4`; -exports[`regression tests single-clicking on a subgroup of a selected group should not alter selection: [end of test] number of renders 1`] = `36`; +exports[`regression tests single-clicking on a subgroup of a selected group should not alter selection: [end of test] number of renders 1`] = `35`; exports[`regression tests spacebar + drag scrolls the canvas: [end of test] appState 1`] = ` Object { @@ -27661,7 +27509,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -27685,7 +27532,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -27720,7 +27566,7 @@ Object { exports[`regression tests spacebar + drag scrolls the canvas: [end of test] number of elements 1`] = `0`; -exports[`regression tests spacebar + drag scrolls the canvas: [end of test] number of renders 1`] = `5`; +exports[`regression tests spacebar + drag scrolls the canvas: [end of test] number of renders 1`] = `4`; exports[`regression tests supports nested groups: [end of test] appState 1`] = ` Object { @@ -27754,7 +27600,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -27780,7 +27625,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -28443,7 +28287,7 @@ Object { exports[`regression tests supports nested groups: [end of test] number of elements 1`] = `3`; -exports[`regression tests supports nested groups: [end of test] number of renders 1`] = `30`; +exports[`regression tests supports nested groups: [end of test] number of renders 1`] = `29`; exports[`regression tests switches from group of selected elements to another element on pointer down: [end of test] appState 1`] = ` Object { @@ -28499,7 +28343,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -28551,7 +28394,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -28841,7 +28683,7 @@ Object { exports[`regression tests switches from group of selected elements to another element on pointer down: [end of test] number of elements 1`] = `3`; -exports[`regression tests switches from group of selected elements to another element on pointer down: [end of test] number of renders 1`] = `17`; +exports[`regression tests switches from group of selected elements to another element on pointer down: [end of test] number of renders 1`] = `16`; exports[`regression tests switches selected element on pointer down: [end of test] appState 1`] = ` Object { @@ -28897,7 +28739,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -28947,7 +28788,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -29129,7 +28969,7 @@ Object { exports[`regression tests switches selected element on pointer down: [end of test] number of elements 1`] = `2`; -exports[`regression tests switches selected element on pointer down: [end of test] number of renders 1`] = `11`; +exports[`regression tests switches selected element on pointer down: [end of test] number of renders 1`] = `10`; exports[`regression tests two-finger scroll works: [end of test] appState 1`] = ` Object { @@ -29163,7 +29003,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -29189,7 +29028,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -29224,7 +29062,7 @@ Object { exports[`regression tests two-finger scroll works: [end of test] number of elements 1`] = `0`; -exports[`regression tests two-finger scroll works: [end of test] number of renders 1`] = `10`; +exports[`regression tests two-finger scroll works: [end of test] number of renders 1`] = `9`; exports[`regression tests undo/redo drawing an element: [end of test] appState 1`] = ` Object { @@ -29258,7 +29096,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -29284,7 +29121,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -29709,7 +29545,7 @@ Object { exports[`regression tests undo/redo drawing an element: [end of test] number of elements 1`] = `3`; -exports[`regression tests undo/redo drawing an element: [end of test] number of renders 1`] = `27`; +exports[`regression tests undo/redo drawing an element: [end of test] number of renders 1`] = `26`; exports[`regression tests updates fontSize & fontFamily appState: [end of test] appState 1`] = ` Object { @@ -29743,7 +29579,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -29767,7 +29602,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -29802,7 +29636,7 @@ Object { exports[`regression tests updates fontSize & fontFamily appState: [end of test] number of elements 1`] = `0`; -exports[`regression tests updates fontSize & fontFamily appState: [end of test] number of renders 1`] = `4`; +exports[`regression tests updates fontSize & fontFamily appState: [end of test] number of renders 1`] = `3`; exports[`regression tests zoom hotkeys: [end of test] appState 1`] = ` Object { @@ -29836,7 +29670,6 @@ Object { "gridSize": null, "height": 768, "isBindingEnabled": true, - "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, "isResizing": false, @@ -29860,7 +29693,6 @@ Object { "showShortcutsDialog": false, "startBoundElement": null, "suggestedBindings": Array [], - "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, @@ -29895,4 +29727,4 @@ Object { exports[`regression tests zoom hotkeys: [end of test] number of elements 1`] = `0`; -exports[`regression tests zoom hotkeys: [end of test] number of renders 1`] = `4`; +exports[`regression tests zoom hotkeys: [end of test] number of renders 1`] = `3`; diff --git a/src/tests/align.test.tsx b/src/tests/align.test.tsx index 2d17a249..27ab4cd8 100644 --- a/src/tests/align.test.tsx +++ b/src/tests/align.test.tsx @@ -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(); -}); - 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(); }); - // 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); + }); }); diff --git a/src/tests/appState.test.tsx b/src/tests/appState.test.tsx index 15d2644d..f14a8d36 100644 --- a/src/tests/appState.test.tsx +++ b/src/tests/appState.test.tsx @@ -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( - , - ); + + await render(, { + localStorageData: { + appState: { + exportBackground, + viewBackgroundColor: "#F00", + }, + }, + }); await waitFor(() => { expect(h.state.exportBackground).toBe(exportBackground); diff --git a/src/tests/binding.test.tsx b/src/tests/binding.test.tsx index 6c5b4e30..6f6bc7ed 100644 --- a/src/tests/binding.test.tsx +++ b/src/tests/binding.test.tsx @@ -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(); + beforeEach(async () => { + await render(); }); it("rotation of arrow should rebind both ends", () => { diff --git a/src/tests/collab.test.tsx b/src/tests/collab.test.tsx index d6aa4c40..ad76d295 100644 --- a/src/tests/collab.test.tsx +++ b/src/tests/collab.test.tsx @@ -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( - , - ); - + await render(); + // 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); diff --git a/src/tests/dragCreate.test.tsx b/src/tests/dragCreate.test.tsx index 137078b2..ae08977c 100644 --- a/src/tests/dragCreate.test.tsx +++ b/src/tests/dragCreate.test.tsx @@ -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(); + it("rectangle", async () => { + const { getByToolName, container } = await render(); // 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(); + it("ellipse", async () => { + const { getByToolName, container } = await render(); // 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(); + it("diamond", async () => { + const { getByToolName, container } = await render(); // 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(); + it("arrow", async () => { + const { getByToolName, container } = await render(); // 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(); + it("line", async () => { + const { getByToolName, container } = await render(); // 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(); + it("rectangle", async () => { + const { getByToolName, container } = await render(); // 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(); + it("ellipse", async () => { + const { getByToolName, container } = await render(); // 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(); + it("diamond", async () => { + const { getByToolName, container } = await render(); // 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(); + it("arrow", async () => { + const { getByToolName, container } = await render(); // 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(); + it("line", async () => { + const { getByToolName, container } = await render(); // 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); }); diff --git a/src/tests/export.test.tsx b/src/tests/export.test.tsx index 3db1c209..0159e233 100644 --- a/src/tests/export.test.tsx +++ b/src/tests/export.test.tsx @@ -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(); + beforeEach(async () => { + await render(); }); it("export embedded png and reimport", async () => { diff --git a/src/tests/history.test.tsx b/src/tests/history.test.tsx index a5bbdf7e..4da33439 100644 --- a/src/tests/history.test.tsx +++ b/src/tests/history.test.tsx @@ -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( - , - ); + await render(, { + 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( - , - ); + await render(, { + localStorageData: { + elements: [API.createElement({ type: "rectangle", id: "A" })], + appState: { + viewBackgroundColor: "#FFF", + }, + }, + }); await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF")); await waitFor(() => diff --git a/src/tests/library.test.tsx b/src/tests/library.test.tsx index b8ff3b5e..9a795245 100644 --- a/src/tests/library.test.tsx +++ b/src/tests/library.test.tsx @@ -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(); + await render(); }); it("import library via drag&drop", async () => { diff --git a/src/tests/move.test.tsx b/src/tests/move.test.tsx index 9626c2b1..9d58b087 100644 --- a/src/tests/move.test.tsx +++ b/src/tests/move.test.tsx @@ -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(); + it("rectangle", async () => { + const { getByToolName, container } = await render(); 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(); + it("rectangles with binding arrow", async () => { + await render(); // 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(); + it("rectangle", async () => { + const { getByToolName, container } = await render(); 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(); diff --git a/src/tests/multiPointCreate.test.tsx b/src/tests/multiPointCreate.test.tsx index 638f0ad1..406c5888 100644 --- a/src/tests/multiPointCreate.test.tsx +++ b/src/tests/multiPointCreate.test.tsx @@ -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(); + it("rectangle", async () => { + const { getByToolName, container } = await render(); // 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(); + it("ellipse", async () => { + const { getByToolName, container } = await render(); // 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(); + it("diamond", async () => { + const { getByToolName, container } = await render(); // 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(); + it("arrow", async () => { + const { getByToolName, container } = await render(); // 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(); + it("line", async () => { + const { getByToolName, container } = await render(); // 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; diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index efe78167..c58a44b8 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -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(); + await render(); }); afterEach(() => { diff --git a/src/tests/resize.test.tsx b/src/tests/resize.test.tsx index 72a75abe..799b1536 100644 --- a/src/tests/resize.test.tsx +++ b/src/tests/resize.test.tsx @@ -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(); + it("rectangle", async () => { + const { getByToolName, container } = await render(); 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(); + it("rectangle", async () => { + await render(); const rectangle = UI.createElement("rectangle", { x: 0, diff --git a/src/tests/selection.test.tsx b/src/tests/selection.test.tsx index dedbaa64..0d2350d1 100644 --- a/src/tests/selection.test.tsx +++ b/src/tests/selection.test.tsx @@ -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(); + it("create selection element on pointer down", async () => { + const { getByToolName, container } = await render(); // 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(); + it("resize selection element on pointer move", async () => { + const { getByToolName, container } = await render(); // 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(); + it("remove selection element on pointer up", async () => { + const { getByToolName, container } = await render(); // 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(); + it("rectangle", async () => { + const { getByToolName, container } = await render(); 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(); + it("diamond", async () => { + const { getByToolName, container } = await render(); 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(); + it("ellipse", async () => { + const { getByToolName, container } = await render(); 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(); + it("arrow", async () => { + const { getByToolName, container } = await render(); 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(); + it("arrow escape", async () => { + const { getByToolName, container } = await render(); 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(); diff --git a/src/tests/test-utils.ts b/src/tests/test-utils.ts index d3c27366..1822ecc5 100644 --- a/src/tests/test-utils.ts +++ b/src/tests/test-utils.ts @@ -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, -) => RenderResult; + options?: Omit< + RenderOptions & { localStorageData?: ImportedDataState }, + "queries" + >, +) => Promise>; + +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 = 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); +}; diff --git a/src/tests/zindex.test.tsx b/src/tests/zindex.test.tsx index 1d84e6f2..39d751de 100644 --- a/src/tests/zindex.test.tsx +++ b/src/tests/zindex.test.tsx @@ -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(); + beforeEach(async () => { + await render(); }); it("send back", () => { diff --git a/src/time_constants.ts b/src/time_constants.ts deleted file mode 100644 index 5e4099d5..00000000 --- a/src/time_constants.ts +++ /dev/null @@ -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; diff --git a/src/types.ts b/src/types.ts index 5d09b910..d31aa961 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; @@ -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; shouldCacheIgnoreZoom: boolean; showShortcutsDialog: boolean; zenModeEnabled: boolean; @@ -97,6 +94,7 @@ export type AppState = { isLibraryOpen: boolean; fileHandle: import("browser-nativefs").FileSystemHandle | null; + collaborators: Map; }; 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[]; export type LibraryItems = readonly LibraryItem[]; +export type ExcalidrawAPIRefValue = + | (ExcalidrawImperativeAPI & { + readyPromise: ResolvablePromise; + ready: true; + }) + | { + readyPromise: ResolvablePromise; + 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; user?: { name?: string | null; }; - onUsernameChange?: (username: string) => void; - forwardedRef: ForwardRef; + excalidrawRef?: ForwardRef; + 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; + commitToHistory?: boolean; +}; diff --git a/src/utils.ts b/src/utils.ts index c80d42a4..f22ca8c0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 = ( }; 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 = Promise & { + resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; + reject: (error: Error) => void; +}; +export const resolvablePromise = () => { + 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; +}; + +/** + * @param func handler taking at most single parameter (event). + */ +export const withBatchedUpdates = < + TFunction extends ((event: any) => void) | (() => void) +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => + ((event) => { + unstable_batchedUpdates(func as TFunction, event); + }) as TFunction;