Browse Source

Factor out collaboration code (#2313)

Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi 4 years ago
parent
commit
e617ccc252
41 changed files with 2147 additions and 1828 deletions
  1. 0 1
      src/actions/actionCanvas.tsx
  2. 0 1
      src/actions/actionNavigate.tsx
  3. 2 6
      src/appState.ts
  4. 134 482
      src/components/App.tsx
  5. 29 0
      src/components/CollabButton.scss
  6. 44 0
      src/components/CollabButton.tsx
  7. 14 21
      src/components/LayerUI.tsx
  8. 12 18
      src/components/MobileMenu.tsx
  9. 0 202
      src/components/RoomDialog.tsx
  10. 4 15
      src/constants.ts
  11. 1 223
      src/data/index.ts
  12. 3 3
      src/data/restore.ts
  13. 14 0
      src/excalidraw-app/app_constants.ts
  14. 476 0
      src/excalidraw-app/collab/CollabWrapper.tsx
  15. 64 60
      src/excalidraw-app/collab/Portal.tsx
  16. 1 27
      src/excalidraw-app/collab/RoomDialog.scss
  17. 136 0
      src/excalidraw-app/collab/RoomDialog.tsx
  18. 7 7
      src/excalidraw-app/data/firebase.ts
  19. 230 0
      src/excalidraw-app/data/index.ts
  20. 16 6
      src/excalidraw-app/data/localStorage.ts
  21. 232 42
      src/excalidraw-app/index.tsx
  22. 18 8
      src/packages/excalidraw/index.tsx
  23. 40 121
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  24. 406 404
      src/tests/align.test.tsx
  25. 10 13
      src/tests/appState.test.tsx
  26. 3 3
      src/tests/binding.test.tsx
  27. 16 17
      src/tests/collab.test.tsx
  28. 31 31
      src/tests/dragCreate.test.tsx
  29. 3 3
      src/tests/export.test.tsx
  30. 17 23
      src/tests/history.test.tsx
  31. 3 3
      src/tests/library.test.tsx
  32. 10 10
      src/tests/move.test.tsx
  33. 16 16
      src/tests/multiPointCreate.test.tsx
  34. 2 2
      src/tests/regressionTests.test.tsx
  35. 6 6
      src/tests/resize.test.tsx
  36. 25 25
      src/tests/selection.test.tsx
  37. 55 6
      src/tests/test-utils.ts
  38. 3 3
      src/tests/zindex.test.tsx
  39. 0 6
      src/time_constants.ts
  40. 30 13
      src/types.ts
  41. 34 1
      src/utils.ts

+ 0 - 1
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,
     };

+ 0 - 1
src/actions/actionNavigate.tsx

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

+ 2 - 6
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 = <ExportType extends "export" | "browser">(

+ 134 - 482
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<TFunction>["length"] extends 0 | 1 ? TFunction : never,
-) =>
-  ((event) => {
-    unstable_batchedUpdates(func as TFunction, event);
-  }) as TFunction;
-
 const { history } = createHistory();
 
 let didTapTwice: boolean = false;
@@ -275,58 +249,77 @@ export type PointerDownState = Readonly<{
   };
 }>;
 
-export type ExcalidrawImperativeAPI =
-  | {
-      updateScene: InstanceType<typeof App>["updateScene"];
-      resetScene: InstanceType<typeof App>["resetScene"];
-      resetHistory: InstanceType<typeof App>["resetHistory"];
-      getSceneElementsIncludingDeleted: InstanceType<
-        typeof App
-      >["getSceneElementsIncludingDeleted"];
-    }
-  | undefined;
+export type ExcalidrawImperativeAPI = {
+  updateScene: InstanceType<typeof App>["updateScene"];
+  resetScene: InstanceType<typeof App>["resetScene"];
+  getSceneElementsIncludingDeleted: InstanceType<
+    typeof App
+  >["getSceneElementsIncludingDeleted"];
+  history: {
+    clear: InstanceType<typeof App>["resetHistory"];
+  };
+  setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
+  getSceneElements: InstanceType<typeof App>["getSceneElements"];
+  readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
+  ready: true;
+};
 
 class App extends React.Component<ExcalidrawProps, AppState> {
   canvas: HTMLCanvasElement | null = null;
   rc: RoughCanvas | null = null;
-  portal: Portal;
-  private lastBroadcastedOrReceivedSceneVersion: number = -1;
   unmounted: boolean = false;
   actionManager: ActionManager;
-  private excalidrawRef: any;
-  private socketInitializationTimer: any;
+  private excalidrawContainerRef = React.createRef<HTMLDivElement>();
 
   public static defaultProps: Partial<ExcalidrawProps> = {
     width: window.innerWidth,
     height: window.innerHeight,
   };
   private scene: Scene;
-
   constructor(props: ExcalidrawProps) {
     super(props);
     const defaultAppState = getDefaultAppState();
 
-    const { width, height, offsetLeft, offsetTop, user, forwardedRef } = props;
+    const {
+      width = window.innerWidth,
+      height = window.innerHeight,
+      offsetLeft,
+      offsetTop,
+      excalidrawRef,
+    } = props;
     this.state = {
       ...defaultAppState,
       isLoading: true,
       width,
       height,
-      username: user?.name || "",
       ...this.getCanvasOffsets({ offsetLeft, offsetTop }),
     };
-    if (forwardedRef && "current" in forwardedRef) {
-      forwardedRef.current = {
+    if (excalidrawRef) {
+      const readyPromise =
+        typeof excalidrawRef === "function"
+          ? resolvablePromise<ExcalidrawImperativeAPI>()
+          : excalidrawRef.current!.readyPromise;
+      const api: ExcalidrawImperativeAPI = {
+        ready: true,
+        readyPromise,
         updateScene: this.updateScene,
         resetScene: this.resetScene,
-        resetHistory: this.resetHistory,
         getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
-      };
+        history: {
+          clear: this.resetHistory,
+        },
+        setScrollToCenter: this.setScrollToCenter,
+        getSceneElements: this.getSceneElements,
+      } as const;
+      if (typeof excalidrawRef === "function") {
+        excalidrawRef(api);
+      } else {
+        excalidrawRef.current = api;
+      }
+      readyPromise.resolve(api);
     }
     this.scene = new Scene();
-    this.portal = new Portal(this);
 
-    this.excalidrawRef = React.createRef();
     this.actionManager = new ActionManager(
       this.syncActionResult,
       () => this.state,
@@ -347,7 +340,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       offsetLeft,
     } = this.state;
 
-    const { onUsernameChange } = this.props;
+    const { onCollabButtonClick } = this.props;
     const canvasScale = window.devicePixelRatio;
 
     const canvasWidth = canvasDOMWidth * canvasScale;
@@ -356,7 +349,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     return (
       <div
         className="excalidraw"
-        ref={this.excalidrawRef}
+        ref={this.excalidrawContainerRef}
         style={{
           width: canvasDOMWidth,
           height: canvasDOMHeight,
@@ -370,12 +363,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           setAppState={this.setAppState}
           actionManager={this.actionManager}
           elements={this.scene.getElements()}
-          onRoomCreate={this.openPortal}
-          onRoomDestroy={this.closePortal}
-          onUsernameChange={(username) => {
-            onUsernameChange && onUsernameChange(username);
-            this.setState({ username });
-          }}
+          onCollabButtonClick={onCollabButtonClick}
           onLockToggle={this.toggleLock}
           onInsertShape={(elements) =>
             this.addElementsFromPasteOrLibrary(elements)
@@ -383,6 +371,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           zenModeEnabled={zenModeEnabled}
           toggleZenMode={this.toggleZenMode}
           lng={getLanguage().lng}
+          isCollaborating={this.props.isCollaborating || false}
         />
         <main>
           <canvas
@@ -410,18 +399,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     );
   }
 
-  public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
-    this.lastBroadcastedOrReceivedSceneVersion = version;
-  };
-
-  public getLastBroadcastedOrReceivedSceneVersion = () => {
-    return this.lastBroadcastedOrReceivedSceneVersion;
-  };
-
   public getSceneElementsIncludingDeleted = () => {
     return this.scene.getElementsIncludingDeleted();
   };
 
+  public getSceneElements = () => {
+    return this.scene.getElements();
+  };
+
   private syncActionResult = withBatchedUpdates(
     (actionResult: ActionResult) => {
       if (this.unmounted || actionResult === false) {
@@ -454,8 +439,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             ...actionResult.appState,
             editingElement:
               editingElement || actionResult.appState?.editingElement || null,
-            isCollaborating: state.isCollaborating,
-            collaborators: state.collaborators,
             width: state.width,
             height: state.height,
             offsetTop: state.offsetTop,
@@ -482,7 +465,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   });
 
   private onUnload = () => {
-    this.destroySocketClient();
     this.onBlur();
   };
 
@@ -499,46 +481,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     this.onSceneUpdated();
   };
 
-  private shouldForceLoadScene(
-    scene: ResolutionType<typeof loadScene>,
-  ): boolean {
-    if (!scene.elements.length) {
-      return true;
-    }
-
-    const roomMatch = getCollaborationLinkData(window.location.href);
-
-    if (!roomMatch) {
-      return false;
-    }
-
-    const roomId = roomMatch[1];
-
-    let collabForceLoadFlag;
-    try {
-      collabForceLoadFlag = localStorage?.getItem(
-        LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
-      );
-    } catch {}
-
-    if (collabForceLoadFlag) {
-      try {
-        const {
-          room: previousRoom,
-          timestamp,
-        }: { room: string; timestamp: number } = JSON.parse(
-          collabForceLoadFlag,
-        );
-        // if loading same room as the one previously unloaded within 15sec
-        // force reload without prompting
-        if (previousRoom === roomId && Date.now() - timestamp < 15000) {
-          return true;
-        }
-      } catch {}
-    }
-    return false;
-  }
-
   private importLibraryFromUrl = async (url: string) => {
     window.history.replaceState({}, "Excalidraw", window.location.origin);
     try {
@@ -569,17 +511,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     history.clear();
   };
 
-  // Completely resets scene & history.
-  // Do not use for clear scene user action.
-  private resetScene = withBatchedUpdates(() => {
-    this.scene.replaceAllElements([]);
-    this.setState({
-      ...getDefaultAppState(),
-      appearance: this.state.appearance,
-      username: this.state.username,
-    });
-    this.resetHistory();
-  });
+  /**
+   * Resets scene & history.
+   * ! Do not use to clear scene user action !
+   */
+  private resetScene = withBatchedUpdates(
+    (opts?: { resetLoadingState: boolean }) => {
+      this.scene.replaceAllElements([]);
+      this.setState((state) => ({
+        ...getDefaultAppState(),
+        isLoading: opts?.resetLoadingState ? false : state.isLoading,
+        appearance: this.state.appearance,
+      }));
+      this.resetHistory();
+    },
+  );
 
   private initializeScene = async () => {
     if ("launchQueue" in window && "LaunchParams" in window) {
@@ -609,86 +555,42 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       );
     }
 
-    const searchParams = new URLSearchParams(window.location.search);
-    const id = searchParams.get("id");
-    const jsonMatch = window.location.hash.match(
-      /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
-    );
-
     if (!this.state.isLoading) {
       this.setState({ isLoading: true });
     }
 
-    let scene = await loadScene(null, null, this.props.initialData);
-
-    let isCollaborationScene = !!getCollaborationLinkData(window.location.href);
-    const isExternalScene = !!(id || jsonMatch || isCollaborationScene);
-
-    if (isExternalScene) {
-      if (
-        this.shouldForceLoadScene(scene) ||
-        window.confirm(t("alerts.loadSceneOverridePrompt"))
-      ) {
-        // Backwards compatibility with legacy url format
-        if (id) {
-          scene = await loadScene(id, null, this.props.initialData);
-        } else if (jsonMatch) {
-          scene = await loadScene(
-            jsonMatch[1],
-            jsonMatch[2],
-            this.props.initialData,
-          );
-        }
-        if (!isCollaborationScene) {
-          window.history.replaceState({}, "Excalidraw", window.location.origin);
-        }
-      } else {
-        // https://github.com/excalidraw/excalidraw/issues/1919
-        if (document.hidden) {
-          window.addEventListener("focus", () => this.initializeScene(), {
-            once: true,
-          });
-          return;
-        }
-
-        isCollaborationScene = false;
-        window.history.replaceState({}, "Excalidraw", window.location.origin);
-      }
+    let initialData = null;
+    try {
+      initialData = (await this.props.initialData) || null;
+    } catch (error) {
+      console.error(error);
     }
 
-    if (this.state.isLoading) {
-      this.setState({ isLoading: false });
-    }
+    const scene = restore(initialData, null);
 
-    if (isCollaborationScene) {
-      // when joining a room we don't want user's local scene data to be merged
-      // into the remote scene
-      this.resetScene();
-      this.initializeSocketClient({ showLoadingState: true });
-      trackEvent(EVENT_SHARE, "session join");
-    } else if (scene) {
-      if (scene.appState) {
-        scene.appState = {
+    scene.appState = {
+      ...scene.appState,
+      ...calculateScrollCenter(
+        scene.elements,
+        {
           ...scene.appState,
-          ...calculateScrollCenter(
-            scene.elements,
-            {
-              ...scene.appState,
-              offsetTop: this.state.offsetTop,
-              offsetLeft: this.state.offsetLeft,
-            },
-            null,
-          ),
-        };
-      }
-      this.resetHistory();
-      this.syncActionResult({
-        ...scene,
-        commitToHistory: true,
-      });
-    }
+          offsetTop: this.state.offsetTop,
+          offsetLeft: this.state.offsetLeft,
+        },
+        null,
+      ),
+      isLoading: false,
+    };
 
-    const addToLibraryUrl = searchParams.get("addLibrary");
+    this.resetHistory();
+    this.syncActionResult({
+      ...scene,
+      commitToHistory: true,
+    });
+
+    const addToLibraryUrl = new URLSearchParams(window.location.search).get(
+      "addLibrary",
+    );
 
     if (addToLibraryUrl) {
       await this.importLibraryFromUrl(addToLibraryUrl);
@@ -752,12 +654,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     this.setState({});
   });
 
-  private onHashChange = (_: HashChangeEvent) => {
-    if (window.location.hash.length > 1) {
-      this.initializeScene();
-    }
-  };
-
   private removeEventListeners() {
     document.removeEventListener(EVENT.COPY, this.onCopy);
     document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
@@ -775,7 +671,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     window.removeEventListener(EVENT.BLUR, this.onBlur, false);
     window.removeEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
     window.removeEventListener(EVENT.DROP, this.disableEvent, false);
-    window.removeEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
 
     document.removeEventListener(
       EVENT.GESTURE_START,
@@ -792,7 +687,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.onGestureEnd as any,
       false,
     );
-    window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
   }
 
   private addEventListeners() {
@@ -811,7 +705,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     window.addEventListener(EVENT.BLUR, this.onBlur, false);
     window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
     window.addEventListener(EVENT.DROP, this.disableEvent, false);
-    window.addEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
 
     // rerender text elements on font load to fix #637 && #1553
     document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
@@ -832,42 +725,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.onGestureEnd as any,
       false,
     );
-    window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
   }
 
-  private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
-    if (this.state.isCollaborating && this.portal.roomId) {
-      try {
-        localStorage?.setItem(
-          LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
-          JSON.stringify({
-            timestamp: Date.now(),
-            room: this.portal.roomId,
-          }),
-        );
-      } catch {}
-    }
-    const syncableElements = getSyncableElements(
-      this.scene.getElementsIncludingDeleted(),
-    );
-    if (
-      this.state.isCollaborating &&
-      !isSavedToFirebase(this.portal, syncableElements)
-    ) {
-      // this won't run in time if user decides to leave the site, but
-      // the purpose is to run in immediately after user decides to stay
-      this.saveCollabRoomToFirebase(syncableElements);
-
-      event.preventDefault();
-      // NOTE: modern browsers no longer allow showing a custom message here
-      event.returnValue = "";
-    }
-  });
-
-  queueBroadcastAllElements = throttle(() => {
-    this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
-  }, SYNC_FULL_SCENE_INTERVAL_MS);
-
   componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
     if (
       prevProps.width !== this.props.width ||
@@ -878,8 +737,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         prevProps.offsetTop !== this.props.offsetTop)
     ) {
       this.setState({
-        width: this.props.width,
-        height: this.props.height,
+        width: this.props.width ?? window.innerWidth,
+        height: this.props.height ?? window.innerHeight,
         ...this.getCanvasOffsets(this.props),
       });
     }
@@ -990,19 +849,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.setState({ scrolledOutside });
     }
 
-    if (
-      getSceneVersion(this.scene.getElementsIncludingDeleted()) >
-      this.lastBroadcastedOrReceivedSceneVersion
-    ) {
-      this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
-      this.queueBroadcastAllElements();
-    }
-
     history.record(this.state, this.scene.getElementsIncludingDeleted());
 
-    if (this.props.onChange) {
-      this.props.onChange(this.scene.getElementsIncludingDeleted(), this.state);
-    }
+    this.props.onChange?.(this.scene.getElementsIncludingDeleted(), this.state);
   }
 
   // Copy/paste
@@ -1254,31 +1103,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     gesture.pointers.delete(event.pointerId);
   };
 
-  openPortal = async () => {
-    window.history.pushState(
-      {},
-      "Excalidraw",
-      await generateCollaborationLink(),
-    );
-    // remove deleted elements from elements array & history to ensure we don't
-    // expose potentially sensitive user data in case user manually deletes
-    // existing elements (or clears scene), which would otherwise be persisted
-    // to database even if deleted before creating the room.
-    history.clear();
-    history.resumeRecording();
-    this.scene.replaceAllElements(this.scene.getElements());
-
-    await this.initializeSocketClient({ showLoadingState: false });
-    trackEvent(EVENT_SHARE, "session start");
-  };
-
-  closePortal = () => {
-    this.saveCollabRoomToFirebase();
-    window.history.pushState({}, "Excalidraw", window.location.origin);
-    this.destroySocketClient();
-    trackEvent(EVENT_SHARE, "session end");
-  };
-
   toggleLock = () => {
     this.setState((prevState) => {
       trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
@@ -1313,202 +1137,26 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     });
   };
 
-  private handleRemoteSceneUpdate = (
-    elements: readonly ExcalidrawElement[],
-    {
-      init = false,
-      initFromSnapshot = false,
-    }: { init?: boolean; initFromSnapshot?: boolean } = {},
-  ) => {
-    if (init) {
+  public updateScene = withBatchedUpdates((sceneData: SceneData) => {
+    if (sceneData.commitToHistory) {
       history.resumeRecording();
     }
 
-    if (init || initFromSnapshot) {
-      this.setScrollToCenter(elements);
-    }
-    const newElements = this.portal.reconcileElements(elements);
-
-    // Avoid broadcasting to the rest of the collaborators the scene
-    // we just received!
-    // Note: this needs to be set before updating the scene as it
-    // syncronously calls render.
-    this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
-
-    this.updateScene({ elements: newElements });
-
-    // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
-    // when we receive any messages from another peer. This UX can be pretty rough -- if you
-    // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
-    // right now we think this is the right tradeoff.
-    this.resetHistory();
-
-    if (!this.portal.socketInitialized && !initFromSnapshot) {
-      this.initializeSocket();
-    }
-  };
-
-  private destroySocketClient = () => {
-    this.setState({
-      isCollaborating: false,
-      collaborators: new Map(),
-    });
-    this.portal.close();
-  };
-
-  public updateScene = withBatchedUpdates(
-    (sceneData: {
-      elements: readonly ExcalidrawElement[];
-      appState?: AppState;
-    }) => {
-      // currently we only support syncing background color
-      if (sceneData.appState?.viewBackgroundColor) {
-        this.setState({
-          viewBackgroundColor: sceneData.appState.viewBackgroundColor,
-        });
-      }
-
-      this.scene.replaceAllElements(sceneData.elements);
-    },
-  );
-
-  private initializeSocket = () => {
-    this.portal.socketInitialized = true;
-    clearTimeout(this.socketInitializationTimer);
-    if (this.state.isLoading && !this.unmounted) {
-      this.setState({ isLoading: false });
-    }
-  };
-
-  private initializeSocketClient = async (opts: {
-    showLoadingState: boolean;
-  }) => {
-    if (this.portal.socket) {
-      return;
-    }
-
-    const roomMatch = getCollaborationLinkData(window.location.href);
-    if (roomMatch) {
-      const roomId = roomMatch[1];
-      const roomKey = roomMatch[2];
-
-      // fallback in case you're not alone in the room but still don't receive
-      // initial SCENE_UPDATE message
-      this.socketInitializationTimer = setTimeout(
-        this.initializeSocket,
-        INITIAL_SCENE_UPDATE_TIMEOUT,
-      );
-
-      const { default: socketIOClient }: any = await import("socket.io-client");
-
-      this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
-
-      // All socket listeners are moving to Portal
-      this.portal.socket!.on(
-        "client-broadcast",
-        async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
-          if (!this.portal.roomKey) {
-            return;
-          }
-          const decryptedData = await decryptAESGEM(
-            encryptedData,
-            this.portal.roomKey,
-            iv,
-          );
-
-          switch (decryptedData.type) {
-            case "INVALID_RESPONSE":
-              return;
-            case SCENE.INIT: {
-              if (!this.portal.socketInitialized) {
-                const remoteElements = decryptedData.payload.elements;
-                this.handleRemoteSceneUpdate(remoteElements, { init: true });
-              }
-              break;
-            }
-            case SCENE.UPDATE:
-              this.handleRemoteSceneUpdate(decryptedData.payload.elements);
-              break;
-            case "MOUSE_LOCATION": {
-              const {
-                socketId,
-                pointer,
-                button,
-                username,
-                selectedElementIds,
-              } = decryptedData.payload;
-              // NOTE purposefully mutating collaborators map in case of
-              // pointer updates so as not to trigger LayerUI rerender
-              this.setState((state) => {
-                if (!state.collaborators.has(socketId)) {
-                  state.collaborators.set(socketId, {});
-                }
-                const user = state.collaborators.get(socketId)!;
-                user.pointer = pointer;
-                user.button = button;
-                user.selectedElementIds = selectedElementIds;
-                user.username = username;
-                state.collaborators.set(socketId, user);
-                return state;
-              });
-              break;
-            }
-          }
-        },
-      );
-      this.portal.socket!.on("first-in-room", () => {
-        if (this.portal.socket) {
-          this.portal.socket.off("first-in-room");
-        }
-        this.initializeSocket();
-      });
-
+    // currently we only support syncing background color
+    if (sceneData.appState?.viewBackgroundColor) {
       this.setState({
-        isCollaborating: true,
-        isLoading: opts.showLoadingState ? true : this.state.isLoading,
+        viewBackgroundColor: sceneData.appState.viewBackgroundColor,
       });
-
-      try {
-        const elements = await loadFromFirebase(roomId, roomKey);
-        if (elements) {
-          this.handleRemoteSceneUpdate(elements, { initFromSnapshot: true });
-        }
-      } catch (error) {
-        // log the error and move on. other peers will sync us the scene.
-        console.error(error);
-      }
     }
-  };
 
-  // Portal-only
-  setCollaborators(sockets: string[]) {
-    this.setState((state) => {
-      const collaborators: typeof state.collaborators = new Map();
-      for (const socketId of sockets) {
-        if (state.collaborators.has(socketId)) {
-          collaborators.set(socketId, state.collaborators.get(socketId)!);
-        } else {
-          collaborators.set(socketId, {});
-        }
-      }
-      return {
-        ...state,
-        collaborators,
-      };
-    });
-  }
+    if (sceneData.elements) {
+      this.scene.replaceAllElements(sceneData.elements);
+    }
 
-  saveCollabRoomToFirebase = async (
-    syncableElements: ExcalidrawElement[] = getSyncableElements(
-      this.scene.getElementsIncludingDeleted(),
-    ),
-  ) => {
-    try {
-      await saveToFirebase(this.portal, syncableElements);
-    } catch (error) {
-      console.error(error);
+    if (sceneData.collaborators) {
+      this.setState({ collaborators: sceneData.collaborators });
     }
-  };
+  });
 
   private onSceneUpdated = () => {
     this.setState({});
@@ -3989,15 +3637,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
     if (isNaN(pointer.x) || isNaN(pointer.y)) {
       // sometimes the pointer goes off screen
-      return;
     }
-    this.portal.socket &&
-      // do not broadcast when more than 1 pointer since that shows flickering on the other side
-      gesture.pointers.size < 2 &&
-      this.portal.broadcastMouseLocation({
-        pointer,
-        button,
-      });
+
+    this.props.onPointerUpdate?.({
+      pointer,
+      button,
+      pointersMap: gesture.pointers,
+    });
   };
 
   private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
@@ -4017,8 +3663,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         offsetTop: offsets.offsetTop,
       };
     }
-    if (this.excalidrawRef?.current) {
-      const parentElement = this.excalidrawRef.current.parentElement;
+    if (this.excalidrawContainerRef?.current?.parentElement) {
+      const parentElement = this.excalidrawContainerRef.current.parentElement;
       const { left, top } = parentElement.getBoundingClientRect();
       return {
         offsetLeft:
@@ -4048,6 +3694,9 @@ declare global {
       history: SceneHistory;
       app: InstanceType<typeof App>;
       library: typeof Library;
+      collab: InstanceType<
+        typeof import("../excalidraw-app/collab/CollabWrapper").default
+      >;
     };
   }
 }
@@ -4056,10 +3705,11 @@ if (
   process.env.NODE_ENV === ENV.TEST ||
   process.env.NODE_ENV === ENV.DEVELOPMENT
 ) {
-  window.h = {} as Window["h"];
+  window.h = window.h || ({} as Window["h"]);
 
   Object.defineProperties(window.h, {
     elements: {
+      configurable: true,
       get() {
         return this.app.scene.getElementsIncludingDeleted();
       },
@@ -4068,9 +3718,11 @@ if (
       },
     },
     history: {
+      configurable: true,
       get: () => history,
     },
     library: {
+      configurable: true,
       value: Library,
     },
   });

+ 29 - 0
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);
+  }
+}

+ 44 - 0
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 (
+    <>
+      <ToolButton
+        className={clsx("CollabButton", {
+          "is-collaborating": isCollaborating,
+        })}
+        onClick={() => {
+          trackEvent(EVENT_DIALOG, "collaboration");
+          onClick();
+        }}
+        icon={users}
+        type="button"
+        title={t("buttons.roomDialog")}
+        aria-label={t("buttons.roomDialog")}
+        showAriaLabel={useIsMobile()}
+      >
+        {collaboratorCount > 0 && (
+          <div className="CollabButton-collaborators">{collaboratorCount}</div>
+        )}
+      </ToolButton>
+    </>
+  );
+};
+
+export default CollabButton;

+ 14 - 21
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<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
-  onRoomCreate: () => void;
-  onUsernameChange: (username: string) => void;
-  onRoomDestroy: () => void;
+  onCollabButtonClick?: () => void;
   onLockToggle: () => void;
   onInsertShape: (elements: LibraryItem) => void;
   zenModeEnabled: boolean;
   toggleZenMode: () => void;
   lng: string;
+  isCollaborating: boolean;
 }
 
 const useOnClickOutside = (
@@ -299,13 +298,12 @@ const LayerUI = ({
   setAppState,
   canvas,
   elements,
-  onRoomCreate,
-  onUsernameChange,
-  onRoomDestroy,
+  onCollabButtonClick,
   onLockToggle,
   onInsertShape,
   zenModeEnabled,
   toggleZenMode,
+  isCollaborating,
 }: LayerUIProps) => {
   const isMobile = useIsMobile();
 
@@ -400,17 +398,13 @@ const LayerUI = ({
             {actionManager.renderAction("saveAsScene")}
             {renderExportDialog()}
             {actionManager.renderAction("clearCanvas")}
-            <RoomDialog
-              isCollaborating={appState.isCollaborating}
-              collaboratorCount={appState.collaborators.size}
-              username={appState.username}
-              onUsernameChange={onUsernameChange}
-              onRoomCreate={onRoomCreate}
-              onRoomDestroy={onRoomDestroy}
-              setErrorMessage={(message: string) =>
-                setAppState({ errorMessage: message })
-              }
-            />
+            {onCollabButtonClick && (
+              <CollabButton
+                isCollaborating={isCollaborating}
+                collaboratorCount={appState.collaborators.size}
+                onClick={onCollabButtonClick}
+              />
+            )}
           </Stack.Row>
           <BackgroundPickerAndDarkModeToggle
             actionManager={actionManager}
@@ -602,11 +596,10 @@ const LayerUI = ({
       libraryMenu={libraryMenu}
       exportButton={renderExportDialog()}
       setAppState={setAppState}
-      onUsernameChange={onUsernameChange}
-      onRoomCreate={onRoomCreate}
-      onRoomDestroy={onRoomDestroy}
+      onCollabButtonClick={onCollabButtonClick}
       onLockToggle={onLockToggle}
       canvas={canvas}
+      isCollaborating={isCollaborating}
     />
   ) : (
     <div className="layer-ui__wrapper">

+ 12 - 18
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<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
   libraryMenu: JSX.Element | null;
-  onRoomCreate: () => void;
-  onUsernameChange: (username: string) => void;
-  onRoomDestroy: () => void;
+  onCollabButtonClick?: () => void;
   onLockToggle: () => void;
   canvas: HTMLCanvasElement | null;
+  isCollaborating: boolean;
 };
 
 export const MobileMenu = ({
@@ -41,11 +40,10 @@ export const MobileMenu = ({
   actionManager,
   exportButton,
   setAppState,
-  onRoomCreate,
-  onUsernameChange,
-  onRoomDestroy,
+  onCollabButtonClick,
   onLockToggle,
   canvas,
+  isCollaborating,
 }: MobileMenuProps) => (
   <>
     {appState.isLoading && <LoadingMessage />}
@@ -94,17 +92,13 @@ export const MobileMenu = ({
                 {actionManager.renderAction("saveAsScene")}
                 {exportButton}
                 {actionManager.renderAction("clearCanvas")}
-                <RoomDialog
-                  isCollaborating={appState.isCollaborating}
-                  collaboratorCount={appState.collaborators.size}
-                  username={appState.username}
-                  onUsernameChange={onUsernameChange}
-                  onRoomCreate={onRoomCreate}
-                  onRoomDestroy={onRoomDestroy}
-                  setErrorMessage={(message: string) =>
-                    setAppState({ errorMessage: message })
-                  }
-                />
+                {onCollabButtonClick && (
+                  <CollabButton
+                    isCollaborating={isCollaborating}
+                    collaboratorCount={appState.collaborators.size}
+                    onClick={onCollabButtonClick}
+                  />
+                )}
                 <BackgroundPickerAndDarkModeToggle
                   actionManager={actionManager}
                   appState={appState}

+ 0 - 202
src/components/RoomDialog.tsx

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

+ 4 - 15
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;

+ 1 - 223
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<EncryptedData> => {
-  const importedKey = await getImportedKey(key, "encrypt");
-  const iv = createIV();
-  return {
-    data: await window.crypto.subtle.encrypt(
-      {
-        name: "AES-GCM",
-        iv,
-      },
-      importedKey,
-      data,
-    ),
-    iv,
-  };
-};
-
-export const decryptAESGEM = async (
-  data: ArrayBuffer,
-  key: string,
-  iv: Uint8Array,
-): Promise<SocketUpdateDataIncoming> => {
-  try {
-    const importedKey = await getImportedKey(key, "decrypt");
-    const decrypted = await window.crypto.subtle.decrypt(
-      {
-        name: "AES-GCM",
-        iv,
-      },
-      importedKey,
-      data,
-    );
-
-    const decodedData = new TextDecoder("utf-8").decode(
-      new Uint8Array(decrypted) as any,
-    );
-    return JSON.parse(decodedData);
-  } catch (error) {
-    window.alert(t("alerts.decryptFailed"));
-    console.error(error);
-  }
-  return {
-    type: "INVALID_RESPONSE",
-  };
-};
 
 export const exportToBackend = async (
   elements: readonly ExcalidrawElement[],
@@ -226,53 +78,6 @@ export const exportToBackend = async (
   }
 };
 
-const importFromBackend = async (
-  id: string | null,
-  privateKey?: string | null,
-): Promise<ImportedDataState> => {
-  try {
-    const response = await fetch(
-      privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
-    );
-    if (!response.ok) {
-      window.alert(t("alerts.importBackendFailed"));
-      return {};
-    }
-    let data: ImportedDataState;
-    if (privateKey) {
-      const buffer = await response.arrayBuffer();
-      const key = await getImportedKey(privateKey, "decrypt");
-      const iv = new Uint8Array(12);
-      const decrypted = await window.crypto.subtle.decrypt(
-        {
-          name: "AES-GCM",
-          iv,
-        },
-        key,
-        buffer,
-      );
-      // We need to convert the decrypted array buffer to a string
-      const string = new window.TextDecoder("utf-8").decode(
-        new Uint8Array(decrypted) as any,
-      );
-      data = JSON.parse(string);
-    } else {
-      // Legacy format
-      data = await response.json();
-    }
-
-    trackEvent(EVENT_IO, "import");
-    return {
-      elements: data.elements || null,
-      appState: data.appState || null,
-    };
-  } catch (error) {
-    window.alert(t("alerts.importBackendFailed"));
-    console.error(error);
-    return {};
-  }
-};
-
 export const exportCanvas = async (
   type: ExportType,
   elements: readonly NonDeletedExcalidrawElement[],
@@ -378,30 +183,3 @@ export const exportCanvas = async (
     tempCanvas.remove();
   }
 };
-
-export const loadScene = async (
-  id: string | null,
-  privateKey: string | null,
-  // Supply initialData even if importing from backend to ensure we restore
-  // localStorage user settings which we do not persist on server.
-  // Non-optional so we don't forget to pass it even if `undefined`.
-  initialData: ImportedDataState | undefined | null,
-) => {
-  let data;
-  if (id != null) {
-    // the private key is used to decrypt the content from the server, take
-    // extra care not to leak it
-    data = restore(
-      await importFromBackend(id, privateKey),
-      initialData?.appState,
-    );
-  } else {
-    data = restore(initialData || {}, null);
-  }
-
-  return {
-    elements: data.elements,
-    appState: data.appState,
-    commitToHistory: false,
-  };
-};

+ 3 - 3
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<AppState> | null | undefined,
 ): DataState => {
   return {
-    elements: restoreElements(data.elements),
-    appState: restoreAppState(data.appState, localAppState || null),
+    elements: restoreElements(data?.elements),
+    appState: restoreAppState(data?.appState, localAppState || null),
   };
 };

+ 14 - 0
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",
+}

+ 476 - 0
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<typeof CollabWrapper>;
+
+export interface CollabAPI {
+  isCollaborating: CollabState["isCollaborating"];
+  username: CollabState["username"];
+  onPointerUpdate: CollabInstance["onPointerUpdate"];
+  initializeSocketClient: CollabInstance["initializeSocketClient"];
+  onCollabButtonClick: CollabInstance["onCollabButtonClick"];
+  broadcastElements: CollabInstance["broadcastElements"];
+}
+
+type ReconciledElements = readonly ExcalidrawElement[] & {
+  _brand: "reconciledElements";
+};
+
+interface Props {
+  children: (collab: CollabAPI) => React.ReactNode;
+  // NOTE not type-safe because the refObject may in fact not be initialized
+  // with ExcalidrawImperativeAPI yet
+  excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
+}
+
+class CollabWrapper extends PureComponent<Props, CollabState> {
+  portal: Portal;
+  private socketInitializationTimer?: NodeJS.Timeout;
+  private excalidrawRef: Props["excalidrawRef"];
+  excalidrawAppState?: AppState;
+  private lastBroadcastedOrReceivedSceneVersion: number = -1;
+  private collaborators = new Map<string, Collaborator>();
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      isCollaborating: false,
+      modalIsShown: false,
+      errorMessage: "",
+      username: importUsernameFromLocalStorage() || "",
+      activeRoomLink: "",
+    };
+    this.portal = new Portal(this);
+    this.excalidrawRef = props.excalidrawRef;
+  }
+
+  componentDidMount() {
+    window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
+    window.addEventListener(EVENT.UNLOAD, this.onUnload);
+
+    if (
+      process.env.NODE_ENV === ENV.TEST ||
+      process.env.NODE_ENV === ENV.DEVELOPMENT
+    ) {
+      window.h = window.h || ({} as Window["h"]);
+      Object.defineProperties(window.h, {
+        collab: {
+          configurable: true,
+          value: this,
+        },
+      });
+    }
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
+    window.removeEventListener(EVENT.UNLOAD, this.onUnload);
+  }
+
+  private onUnload = () => {
+    this.destroySocketClient();
+  };
+
+  private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
+    const syncableElements = getSyncableElements(
+      this.getSceneElementsIncludingDeleted(),
+    );
+    if (
+      this.state.isCollaborating &&
+      !isSavedToFirebase(this.portal, syncableElements)
+    ) {
+      // this won't run in time if user decides to leave the site, but
+      //  the purpose is to run in immediately after user decides to stay
+      this.saveCollabRoomToFirebase(syncableElements);
+
+      event.preventDefault();
+      // NOTE: modern browsers no longer allow showing a custom message here
+      event.returnValue = "";
+    }
+
+    if (this.state.isCollaborating || this.portal.roomId) {
+      try {
+        localStorage?.setItem(
+          STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
+          JSON.stringify({
+            timestamp: Date.now(),
+            room: this.portal.roomId,
+          }),
+        );
+      } catch {}
+    }
+  });
+
+  saveCollabRoomToFirebase = async (
+    syncableElements: ExcalidrawElement[] = getSyncableElements(
+      this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
+    ),
+  ) => {
+    try {
+      await saveToFirebase(this.portal, syncableElements);
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  openPortal = async () => {
+    window.history.pushState(
+      {},
+      "Excalidraw",
+      await generateCollaborationLink(),
+    );
+    const elements = this.excalidrawRef.current!.getSceneElements();
+    // remove deleted elements from elements array & history to ensure we don't
+    // expose potentially sensitive user data in case user manually deletes
+    // existing elements (or clears scene), which would otherwise be persisted
+    // to database even if deleted before creating the room.
+    this.excalidrawRef.current!.history.clear();
+    this.excalidrawRef.current!.updateScene({
+      elements,
+      commitToHistory: true,
+    });
+    trackEvent(EVENT_SHARE, "session start");
+    return this.initializeSocketClient();
+  };
+
+  closePortal = () => {
+    this.saveCollabRoomToFirebase();
+    window.history.pushState({}, "Excalidraw", window.location.origin);
+    this.destroySocketClient();
+    trackEvent(EVENT_SHARE, "session end");
+  };
+
+  private destroySocketClient = () => {
+    this.collaborators = new Map();
+    this.excalidrawRef.current!.updateScene({
+      collaborators: this.collaborators,
+    });
+    this.setState({
+      isCollaborating: false,
+      activeRoomLink: "",
+    });
+    this.portal.close();
+  };
+
+  private initializeSocketClient = async (): Promise<ImportedDataState | null> => {
+    if (this.portal.socket) {
+      return null;
+    }
+
+    const scenePromise = resolvablePromise<ImportedDataState | null>();
+
+    const roomMatch = getCollaborationLinkData(window.location.href);
+
+    if (roomMatch) {
+      const roomId = roomMatch[1];
+      const roomKey = roomMatch[2];
+
+      // fallback in case you're not alone in the room but still don't receive
+      // initial SCENE_UPDATE message
+      this.socketInitializationTimer = setTimeout(() => {
+        this.initializeSocket();
+        scenePromise.resolve(null);
+      }, INITIAL_SCENE_UPDATE_TIMEOUT);
+
+      const { default: socketIOClient }: any = await import(
+        /* webpackChunkName: "socketIoClient" */ "socket.io-client"
+      );
+
+      this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
+
+      // All socket listeners are moving to Portal
+      this.portal.socket!.on(
+        "client-broadcast",
+        async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
+          if (!this.portal.roomKey) {
+            return;
+          }
+          const decryptedData = await decryptAESGEM(
+            encryptedData,
+            this.portal.roomKey,
+            iv,
+          );
+
+          switch (decryptedData.type) {
+            case "INVALID_RESPONSE":
+              return;
+            case SCENE.INIT: {
+              if (!this.portal.socketInitialized) {
+                const remoteElements = decryptedData.payload.elements;
+                const reconciledElements = this.reconcileElements(
+                  remoteElements,
+                );
+                this.handleRemoteSceneUpdate(reconciledElements, {
+                  init: true,
+                });
+                this.initializeSocket();
+                scenePromise.resolve({ elements: reconciledElements });
+              }
+              break;
+            }
+            case SCENE.UPDATE:
+              this.handleRemoteSceneUpdate(
+                this.reconcileElements(decryptedData.payload.elements),
+              );
+              break;
+            case "MOUSE_LOCATION": {
+              const {
+                pointer,
+                button,
+                username,
+                selectedElementIds,
+              } = decryptedData.payload;
+              const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
+                decryptedData.payload.socketId ||
+                // @ts-ignore legacy, see #2094 (#2097)
+                decryptedData.payload.socketID;
+
+              const collaborators = new Map(this.collaborators);
+              const user = collaborators.get(socketId) || {}!;
+              user.pointer = pointer;
+              user.button = button;
+              user.selectedElementIds = selectedElementIds;
+              user.username = username;
+              collaborators.set(socketId, user);
+              this.excalidrawRef.current!.updateScene({
+                collaborators,
+              });
+              break;
+            }
+          }
+        },
+      );
+      this.portal.socket!.on("first-in-room", () => {
+        if (this.portal.socket) {
+          this.portal.socket.off("first-in-room");
+        }
+        this.initializeSocket();
+        scenePromise.resolve(null);
+      });
+
+      this.setState({
+        isCollaborating: true,
+        activeRoomLink: window.location.href,
+      });
+
+      return scenePromise;
+    }
+
+    return null;
+  };
+
+  private initializeSocket = () => {
+    this.portal.socketInitialized = true;
+    clearTimeout(this.socketInitializationTimer!);
+  };
+
+  private reconcileElements = (
+    elements: readonly ExcalidrawElement[],
+  ): ReconciledElements => {
+    const newElements = this.portal.reconcileElements(elements);
+
+    // Avoid broadcasting to the rest of the collaborators the scene
+    // we just received!
+    // Note: this needs to be set before updating the scene as it
+    // syncronously calls render.
+    this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
+
+    return newElements as ReconciledElements;
+  };
+
+  private handleRemoteSceneUpdate = (
+    elements: ReconciledElements,
+    {
+      init = false,
+      initFromSnapshot = false,
+    }: { init?: boolean; initFromSnapshot?: boolean } = {},
+  ) => {
+    if (init || initFromSnapshot) {
+      this.excalidrawRef.current!.setScrollToCenter(elements);
+    }
+
+    this.excalidrawRef.current!.updateScene({
+      elements,
+      commitToHistory: !!init,
+    });
+
+    // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
+    // when we receive any messages from another peer. This UX can be pretty rough -- if you
+    // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
+    // right now we think this is the right tradeoff.
+    this.excalidrawRef.current!.history.clear();
+  };
+
+  setCollaborators(sockets: string[]) {
+    this.setState((state) => {
+      const collaborators: InstanceType<
+        typeof CollabWrapper
+      >["collaborators"] = new Map();
+      for (const socketId of sockets) {
+        if (this.collaborators.has(socketId)) {
+          collaborators.set(socketId, this.collaborators.get(socketId)!);
+        } else {
+          collaborators.set(socketId, {});
+        }
+      }
+      this.collaborators = collaborators;
+      this.excalidrawRef.current!.updateScene({ collaborators });
+    });
+  }
+
+  public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
+    this.lastBroadcastedOrReceivedSceneVersion = version;
+  };
+
+  public getLastBroadcastedOrReceivedSceneVersion = () => {
+    return this.lastBroadcastedOrReceivedSceneVersion;
+  };
+
+  public getSceneElementsIncludingDeleted = () => {
+    return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
+  };
+
+  onPointerUpdate = (payload: {
+    pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
+    button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
+    pointersMap: Gesture["pointers"];
+  }) => {
+    payload.pointersMap.size < 2 &&
+      this.portal.socket &&
+      this.portal.broadcastMouseLocation(payload);
+  };
+
+  broadcastElements = (
+    elements: readonly ExcalidrawElement[],
+    state: AppState,
+  ) => {
+    this.excalidrawAppState = state;
+    if (
+      getSceneVersion(elements) >
+      this.getLastBroadcastedOrReceivedSceneVersion()
+    ) {
+      this.portal.broadcastScene(
+        SCENE.UPDATE,
+        getSyncableElements(elements),
+        false,
+      );
+      this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
+      this.queueBroadcastAllElements();
+    }
+  };
+
+  queueBroadcastAllElements = throttle(() => {
+    this.portal.broadcastScene(
+      SCENE.UPDATE,
+      getSyncableElements(
+        this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
+      ),
+      true,
+    );
+    const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
+    const newVersion = Math.max(
+      currentVersion,
+      getSceneVersion(this.getSceneElementsIncludingDeleted()),
+    );
+    this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
+  }, SYNC_FULL_SCENE_INTERVAL_MS);
+
+  handleClose = () => {
+    this.setState({ modalIsShown: false });
+    const collabIcon = document.querySelector(".CollabButton") as HTMLElement;
+    collabIcon.focus();
+  };
+
+  onUsernameChange = (username: string) => {
+    this.setState({ username });
+    saveUsernameToLocalStorage(username);
+  };
+
+  onCollabButtonClick = () => {
+    this.setState({
+      modalIsShown: true,
+    });
+  };
+
+  render() {
+    const { children } = this.props;
+    const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
+
+    return (
+      <>
+        {modalIsShown && (
+          <RoomDialog
+            handleClose={this.handleClose}
+            activeRoomLink={activeRoomLink}
+            username={username}
+            onUsernameChange={this.onUsernameChange}
+            onRoomCreate={this.openPortal}
+            onRoomDestroy={this.closePortal}
+            setErrorMessage={(errorMessage) => {
+              this.setState({ errorMessage });
+            }}
+          />
+        )}
+        {errorMessage && (
+          <ErrorDialog
+            message={errorMessage}
+            onClose={() => this.setState({ errorMessage: "" })}
+          />
+        )}
+        {children({
+          isCollaborating: this.state.isCollaborating,
+          username: this.state.username,
+          onPointerUpdate: this.onPointerUpdate,
+          initializeSocketClient: this.initializeSocketClient,
+          onCollabButtonClick: this.onCollabButtonClick,
+          broadcastElements: this.broadcastElements,
+        })}
+      </>
+    );
+  }
+}
+
+export default CollabWrapper;

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

+ 1 - 27
src/components/RoomDialog.scss → 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;

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

+ 7 - 7
src/data/firebase.ts → 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 {

+ 230 - 0
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<EncryptedData> => {
+  const importedKey = await getImportedKey(key, "encrypt");
+  const iv = createIV();
+  return {
+    data: await window.crypto.subtle.encrypt(
+      {
+        name: "AES-GCM",
+        iv,
+      },
+      importedKey,
+      data,
+    ),
+    iv,
+  };
+};
+
+export const decryptAESGEM = async (
+  data: ArrayBuffer,
+  key: string,
+  iv: Uint8Array,
+): Promise<SocketUpdateDataIncoming> => {
+  try {
+    const importedKey = await getImportedKey(key, "decrypt");
+    const decrypted = await window.crypto.subtle.decrypt(
+      {
+        name: "AES-GCM",
+        iv,
+      },
+      importedKey,
+      data,
+    );
+
+    const decodedData = new TextDecoder("utf-8").decode(
+      new Uint8Array(decrypted) as any,
+    );
+    return JSON.parse(decodedData);
+  } catch (error) {
+    window.alert(t("alerts.decryptFailed"));
+    console.error(error);
+  }
+  return {
+    type: "INVALID_RESPONSE",
+  };
+};
+
+export const getCollaborationLinkData = (link: string) => {
+  if (link.length === 0) {
+    return;
+  }
+  const hash = new URL(link).hash;
+  return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
+};
+
+export const generateCollaborationLink = async () => {
+  const id = await generateRandomID();
+  const key = await generateEncryptionKey();
+  return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
+};
+
+export const getImportedKey = (key: string, usage: KeyUsage) =>
+  window.crypto.subtle.importKey(
+    "jwk",
+    {
+      alg: "A128GCM",
+      ext: true,
+      k: key,
+      key_ops: ["encrypt", "decrypt"],
+      kty: "oct",
+    },
+    {
+      name: "AES-GCM",
+      length: 128,
+    },
+    false, // extractable
+    [usage],
+  );
+
+const importFromBackend = async (
+  id: string | null,
+  privateKey?: string | null,
+): Promise<ImportedDataState> => {
+  try {
+    const response = await fetch(
+      privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
+    );
+    if (!response.ok) {
+      window.alert(t("alerts.importBackendFailed"));
+      return {};
+    }
+    let data: ImportedDataState;
+    if (privateKey) {
+      const buffer = await response.arrayBuffer();
+      const key = await getImportedKey(privateKey, "decrypt");
+      const iv = new Uint8Array(12);
+      const decrypted = await window.crypto.subtle.decrypt(
+        {
+          name: "AES-GCM",
+          iv,
+        },
+        key,
+        buffer,
+      );
+      // We need to convert the decrypted array buffer to a string
+      const string = new window.TextDecoder("utf-8").decode(
+        new Uint8Array(decrypted) as any,
+      );
+      data = JSON.parse(string);
+    } else {
+      // Legacy format
+      data = await response.json();
+    }
+
+    trackEvent(EVENT_ACTION, "import");
+    return {
+      elements: data.elements || null,
+      appState: data.appState || null,
+    };
+  } catch (error) {
+    window.alert(t("alerts.importBackendFailed"));
+    console.error(error);
+    return {};
+  }
+};
+
+export const loadScene = async (
+  id: string | null,
+  privateKey: string | null,
+  // Supply initialData even if importing from backend to ensure we restore
+  // localStorage user settings which we do not persist on server.
+  // Non-optional so we don't forget to pass it even if `undefined`.
+  initialData: ImportedDataState | undefined | null,
+) => {
+  let data;
+  if (id != null) {
+    // the private key is used to decrypt the content from the server, take
+    // extra care not to leak it
+    data = restore(
+      await importFromBackend(id, privateKey),
+      initialData?.appState,
+    );
+  } else {
+    data = restore(initialData || null, null);
+  }
+
+  return {
+    elements: data.elements,
+    appState: data.appState,
+    commitToHistory: false,
+  };
+};

+ 16 - 6
src/data/localStorage.ts → 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;

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

+ 18 - 8
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}
         />
       </IsMobileProvider>
     </InitializeApp>
@@ -79,7 +84,12 @@ const areEqual = (
 };
 
 const forwardedRefComp = forwardRef<
-  ExcalidrawImperativeAPI,
+  ExcalidrawAPIRefValue,
   PublicExcalidrawProps
->((props, ref) => <Excalidraw {...props} forwardedRef={ref} />);
+>((props, ref) => <Excalidraw {...props} excalidrawRef={ref} />);
 export default React.memo(forwardedRefComp, areEqual);
+export {
+  getSceneVersion,
+  getSyncableElements,
+  getElementMap,
+} from "../../element";

File diff suppressed because it is too large
+ 40 - 121
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 406 - 404
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(<App />);
-});
-
 const createAndSelectTwoRectangles = () => {
   UI.clickTool("rectangle");
   mouse.down();
@@ -63,517 +54,528 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
   });
 };
 
-it("aligns two objects correctly to the top", () => {
-  createAndSelectTwoRectangles();
+describe("aligning", () => {
+  beforeEach(async () => {
+    // Unmount ReactDOM from root
+    ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+    mouse.reset();
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-  expect(API.getSelectedElements()[1].y).toEqual(110);
-
-  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
-    Keyboard.keyPress(KEYS.ARROW_UP);
+    await setLanguage("en.json");
+    await render(<ExcalidrawApp />);
   });
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
+  it("aligns two objects correctly to the top", () => {
+    createAndSelectTwoRectangles();
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-  expect(API.getSelectedElements()[1].y).toEqual(0);
-});
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(110);
 
-it("aligns two objects correctly to the bottom", () => {
-  createAndSelectTwoRectangles();
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(110);
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
+    Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+      Keyboard.keyPress(KEYS.ARROW_UP);
+    });
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-  expect(API.getSelectedElements()[1].y).toEqual(110);
+    // Check if x position did not change
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(110);
 
-  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
-    Keyboard.keyPress(KEYS.ARROW_DOWN);
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(0);
   });
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
+  it("aligns two objects correctly to the bottom", () => {
+    createAndSelectTwoRectangles();
 
-  expect(API.getSelectedElements()[0].y).toEqual(110);
-  expect(API.getSelectedElements()[1].y).toEqual(110);
-});
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(110);
 
-it("aligns two objects correctly to the left", () => {
-  createAndSelectTwoRectangles();
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(110);
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
+    Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+      Keyboard.keyPress(KEYS.ARROW_DOWN);
+    });
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-  expect(API.getSelectedElements()[1].y).toEqual(110);
+    // Check if x position did not change
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(110);
 
-  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
-    Keyboard.keyPress(KEYS.ARROW_LEFT);
+    expect(API.getSelectedElements()[0].y).toEqual(110);
+    expect(API.getSelectedElements()[1].y).toEqual(110);
   });
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(0);
+  it("aligns two objects correctly to the left", () => {
+    createAndSelectTwoRectangles();
 
-  // Check if y position did not change
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-  expect(API.getSelectedElements()[1].y).toEqual(110);
-});
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(110);
 
-it("aligns two objects correctly to the right", () => {
-  createAndSelectTwoRectangles();
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(110);
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
+    Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+      Keyboard.keyPress(KEYS.ARROW_LEFT);
+    });
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-  expect(API.getSelectedElements()[1].y).toEqual(110);
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(0);
 
-  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
-    Keyboard.keyPress(KEYS.ARROW_RIGHT);
+    // Check if y position did not change
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(110);
   });
 
-  expect(API.getSelectedElements()[0].x).toEqual(110);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
-
-  // Check if y position did not change
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-  expect(API.getSelectedElements()[1].y).toEqual(110);
-});
-
-it("centers two objects with different sizes correctly vertically", () => {
-  createAndSelectTwoRectanglesWithDifferentSizes();
+  it("aligns two objects correctly to the right", () => {
+    createAndSelectTwoRectangles();
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
+    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);
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(110);
 
-  h.app.actionManager.executeAction(actionAlignVerticallyCentered);
+    Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+      Keyboard.keyPress(KEYS.ARROW_RIGHT);
+    });
 
-  // 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].x).toEqual(110);
+    expect(API.getSelectedElements()[1].x).toEqual(110);
 
-  expect(API.getSelectedElements()[0].y).toEqual(60);
-  expect(API.getSelectedElements()[1].y).toEqual(55);
-});
+    // Check if y position did not change
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(110);
+  });
 
-it("centers two objects with different sizes correctly horizontally", () => {
-  createAndSelectTwoRectanglesWithDifferentSizes();
+  it("centers two objects with different sizes correctly vertically", () => {
+    createAndSelectTwoRectanglesWithDifferentSizes();
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(110);
+    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);
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(110);
 
-  h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
+    h.app.actionManager.executeAction(actionAlignVerticallyCentered);
 
-  expect(API.getSelectedElements()[0].x).toEqual(60);
-  expect(API.getSelectedElements()[1].x).toEqual(55);
+    // Check if x position did not change
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(110);
 
-  // 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].y).toEqual(60);
+    expect(API.getSelectedElements()[1].y).toEqual(55);
+  });
 
-const createAndSelectGroupAndRectangle = () => {
-  UI.clickTool("rectangle");
-  mouse.down();
-  mouse.up(100, 100);
+  it("centers two objects with different sizes correctly horizontally", () => {
+    createAndSelectTwoRectanglesWithDifferentSizes();
 
-  UI.clickTool("rectangle");
-  mouse.down(0, 0);
-  mouse.up(100, 100);
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(110);
 
-  // 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(110);
 
-  h.app.actionManager.executeAction(actionGroup);
+    h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
 
-  mouse.reset();
-  UI.clickTool("rectangle");
-  mouse.down(200, 200);
-  mouse.up(100, 100);
+    expect(API.getSelectedElements()[0].x).toEqual(60);
+    expect(API.getSelectedElements()[1].x).toEqual(55);
 
-  // Add the created group to the current selection
-  mouse.restorePosition(0, 0);
-  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);
   });
-};
 
-it("aligns a group with another element correctly to the top", () => {
-  createAndSelectGroupAndRectangle();
+  const createAndSelectGroupAndRectangle = () => {
+    UI.clickTool("rectangle");
+    mouse.down();
+    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);
+    UI.clickTool("rectangle");
+    mouse.down(0, 0);
+    mouse.up(100, 100);
 
-  h.app.actionManager.executeAction(actionAlignTop);
+    // 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);
-});
+    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 the created group to the current selection
+    mouse.restorePosition(0, 0);
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.click();
+    });
+  };
 
-  h.app.actionManager.executeAction(actionAlignBottom);
+  it("aligns a group with another element correctly to the top", () => {
+    createAndSelectGroupAndRectangle();
 
-  expect(API.getSelectedElements()[0].y).toEqual(100);
-  expect(API.getSelectedElements()[1].y).toEqual(200);
-  expect(API.getSelectedElements()[2].y).toEqual(200);
-});
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
 
-it("aligns a group with another element correctly to the left", () => {
-  createAndSelectGroupAndRectangle();
+    h.app.actionManager.executeAction(actionAlignTop);
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(100);
-  expect(API.getSelectedElements()[2].x).toEqual(200);
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(0);
+  });
 
-  h.app.actionManager.executeAction(actionAlignLeft);
+  it("aligns a group with another element correctly to the bottom", () => {
+    createAndSelectGroupAndRectangle();
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(100);
-  expect(API.getSelectedElements()[2].x).toEqual(0);
-});
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
 
-it("aligns a group with another element correctly to the right", () => {
-  createAndSelectGroupAndRectangle();
+    h.app.actionManager.executeAction(actionAlignBottom);
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(100);
-  expect(API.getSelectedElements()[2].x).toEqual(200);
+    expect(API.getSelectedElements()[0].y).toEqual(100);
+    expect(API.getSelectedElements()[1].y).toEqual(200);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+  });
 
-  h.app.actionManager.executeAction(actionAlignRight);
+  it("aligns a group with another element correctly to the left", () => {
+    createAndSelectGroupAndRectangle();
 
-  expect(API.getSelectedElements()[0].x).toEqual(100);
-  expect(API.getSelectedElements()[1].x).toEqual(200);
-  expect(API.getSelectedElements()[2].x).toEqual(200);
-});
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
 
-it("centers a group with another element correctly vertically", () => {
-  createAndSelectGroupAndRectangle();
+    h.app.actionManager.executeAction(actionAlignLeft);
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-  expect(API.getSelectedElements()[1].y).toEqual(100);
-  expect(API.getSelectedElements()[2].y).toEqual(200);
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(0);
+  });
 
-  h.app.actionManager.executeAction(actionAlignVerticallyCentered);
+  it("aligns a group with another element correctly to the right", () => {
+    createAndSelectGroupAndRectangle();
 
-  expect(API.getSelectedElements()[0].y).toEqual(50);
-  expect(API.getSelectedElements()[1].y).toEqual(150);
-  expect(API.getSelectedElements()[2].y).toEqual(100);
-});
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
 
-it("centers a group with another element correctly horizontally", () => {
-  createAndSelectGroupAndRectangle();
+    h.app.actionManager.executeAction(actionAlignRight);
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-  expect(API.getSelectedElements()[1].x).toEqual(100);
-  expect(API.getSelectedElements()[2].x).toEqual(200);
+    expect(API.getSelectedElements()[0].x).toEqual(100);
+    expect(API.getSelectedElements()[1].x).toEqual(200);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+  });
 
-  h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
+  it("centers a group with another element correctly vertically", () => {
+    createAndSelectGroupAndRectangle();
 
-  expect(API.getSelectedElements()[0].x).toEqual(50);
-  expect(API.getSelectedElements()[1].x).toEqual(150);
-  expect(API.getSelectedElements()[2].x).toEqual(100);
-});
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
 
-const createAndSelectTwoGroups = () => {
-  UI.clickTool("rectangle");
-  mouse.down();
-  mouse.up(100, 100);
+    h.app.actionManager.executeAction(actionAlignVerticallyCentered);
 
-  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(50);
+    expect(API.getSelectedElements()[1].y).toEqual(150);
+    expect(API.getSelectedElements()[2].y).toEqual(100);
   });
 
-  h.app.actionManager.executeAction(actionGroup);
+  it("centers a group with another element correctly horizontally", () => {
+    createAndSelectGroupAndRectangle();
 
-  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(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
 
-  UI.clickTool("rectangle");
-  mouse.down();
-  mouse.up(100, 100);
+    h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
 
-  mouse.restorePosition(200, 200);
-  Keyboard.withModifierKeys({ shift: true }, () => {
-    mouse.click();
+    expect(API.getSelectedElements()[0].x).toEqual(50);
+    expect(API.getSelectedElements()[1].x).toEqual(150);
+    expect(API.getSelectedElements()[2].x).toEqual(100);
   });
 
-  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();
+  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 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();
+  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);
-});
+    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);
 
-it("aligns two groups correctly to the right", () => {
-  createAndSelectTwoGroups();
+    h.app.actionManager.executeAction(actionAlignBottom);
 
-  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);
+    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);
+  });
 
-  h.app.actionManager.executeAction(actionAlignRight);
+  it("aligns two groups correctly to the left", () => {
+    createAndSelectTwoGroups();
 
-  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);
-});
+    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);
 
-it("centers two groups correctly vertically", () => {
-  createAndSelectTwoGroups();
+    h.app.actionManager.executeAction(actionAlignLeft);
 
-  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(0);
+    expect(API.getSelectedElements()[3].x).toEqual(100);
+  });
 
-  h.app.actionManager.executeAction(actionAlignVerticallyCentered);
+  it("aligns two groups correctly to the right", () => {
+    createAndSelectTwoGroups();
 
-  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);
-});
+    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);
 
-it("centers two groups correctly horizontally", () => {
-  createAndSelectTwoGroups();
+    h.app.actionManager.executeAction(actionAlignRight);
 
-  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);
+    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);
+  });
 
-  h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
+  it("centers two groups correctly vertically", () => {
+    createAndSelectTwoGroups();
 
-  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);
-});
+    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);
 
-const createAndSelectNestedGroupAndRectangle = () => {
-  UI.clickTool("rectangle");
-  mouse.down();
-  mouse.up(100, 100);
-
-  UI.clickTool("rectangle");
-  mouse.down(0, 0);
-  mouse.up(100, 100);
+    h.app.actionManager.executeAction(actionAlignVerticallyCentered);
 
-  // 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(100);
+    expect(API.getSelectedElements()[1].y).toEqual(200);
+    expect(API.getSelectedElements()[2].y).toEqual(100);
+    expect(API.getSelectedElements()[3].y).toEqual(200);
   });
 
-  // 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();
-  });
+  it("centers two groups correctly horizontally", () => {
+    createAndSelectTwoGroups();
 
-  // Create the nested group
-  h.app.actionManager.executeAction(actionGroup);
+    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);
 
-  mouse.reset();
-  UI.clickTool("rectangle");
-  mouse.down(300, 300);
-  mouse.up(100, 100);
+    h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
 
-  // Select the nested group, the rectangle is already selected
-  mouse.reset();
-  Keyboard.withModifierKeys({ shift: true }, () => {
-    mouse.click();
+    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);
   });
-};
-
-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);
-});
+  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();
+  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);
+    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);
+    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);
-});
+    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();
+  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);
+    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);
+    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);
-});
+    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();
+  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);
+    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);
+    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);
-});
+    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();
+  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);
+    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);
+    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);
-});
+    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();
+  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);
+    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);
+    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(50);
+    expect(API.getSelectedElements()[1].x).toEqual(150);
+    expect(API.getSelectedElements()[2].x).toEqual(250);
+    expect(API.getSelectedElements()[3].x).toEqual(150);
+  });
 });

+ 10 - 13
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(
-      <App
-        initialData={{
-          appState: {
-            ...defaultAppState,
-            exportBackground,
-            viewBackgroundColor: "#F00",
-          },
-          elements: [],
-        }}
-      />,
-    );
+
+    await render(<ExcalidrawApp />, {
+      localStorageData: {
+        appState: {
+          exportBackground,
+          viewBackgroundColor: "#F00",
+        },
+      },
+    });
 
     await waitFor(() => {
       expect(h.state.exportBackground).toBe(exportBackground);

+ 3 - 3
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(<App />);
+  beforeEach(async () => {
+    await render(<ExcalidrawApp />);
   });
 
   it("rotation of arrow should rebind both ends", () => {

+ 16 - 17
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(
-      <App
-        initialData={{
-          elements: [
-            API.createElement({ type: "rectangle", id: "A" }),
-            API.createElement({ type: "rectangle", id: "B", isDeleted: true }),
-          ],
-        }}
-      />,
-    );
-
+    await render(<ExcalidrawApp />);
+    // To update the scene with deleted elements before starting collab
+    updateSceneData({
+      elements: [
+        API.createElement({ type: "rectangle", id: "A" }),
+        API.createElement({
+          type: "rectangle",
+          id: "B",
+          isDeleted: true,
+        }),
+      ],
+    });
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ id: "A" }),
@@ -60,8 +60,7 @@ describe("collaboration", () => {
       ]);
       expect(API.getStateHistory().length).toBe(1);
     });
-
-    await h.app.openPortal();
+    h.collab.openPortal();
     await waitFor(() => {
       expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
       expect(API.getStateHistory().length).toBe(1);

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

+ 3 - 3
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(<App />);
+  beforeEach(async () => {
+    await render(<ExcalidrawApp />);
   });
 
   it("export embedded png and reimport", async () => {

+ 17 - 23
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(
-      <App
-        initialData={{
-          appState: {
-            ...getDefaultAppState(),
-            zenModeEnabled: true,
-          },
-          elements: [API.createElement({ type: "rectangle", id: "A" })],
-        }}
-      />,
-    );
+    await render(<ExcalidrawApp />, {
+      localStorageData: {
+        elements: [API.createElement({ type: "rectangle", id: "A" })],
+        appState: {
+          zenModeEnabled: true,
+        },
+      },
+    });
 
     await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
     await waitFor(() =>
@@ -61,17 +58,14 @@ describe("history", () => {
   });
 
   it("scene import via drag&drop should create new history entry", async () => {
-    render(
-      <App
-        initialData={{
-          appState: {
-            ...getDefaultAppState(),
-            viewBackgroundColor: "#FFF",
-          },
-          elements: [API.createElement({ type: "rectangle", id: "A" })],
-        }}
-      />,
-    );
+    await render(<ExcalidrawApp />, {
+      localStorageData: {
+        elements: [API.createElement({ type: "rectangle", id: "A" })],
+        appState: {
+          viewBackgroundColor: "#FFF",
+        },
+      },
+    });
 
     await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
     await waitFor(() =>

+ 3 - 3
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(<App />);
+    await render(<ExcalidrawApp />);
   });
 
   it("import library via drag&drop", async () => {

+ 10 - 10
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(<App />);
+  it("rectangle", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     const canvas = container.querySelector("canvas")!;
 
     {
@@ -38,7 +38,7 @@ describe("move element", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(5);
+      expect(renderScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -59,8 +59,8 @@ describe("move element", () => {
     h.elements.forEach((element) => expect(element).toMatchSnapshot());
   });
 
-  it("rectangles with binding arrow", () => {
-    render(<App />);
+  it("rectangles with binding arrow", async () => {
+    await render(<ExcalidrawApp />);
 
     // create elements
     const rectA = UI.createElement("rectangle", { size: 100 });
@@ -77,7 +77,7 @@ describe("move element", () => {
     // select the second rectangles
     new Pointer("mouse").clickOn(rectB);
 
-    expect(renderScene).toHaveBeenCalledTimes(19);
+    expect(renderScene).toHaveBeenCalledTimes(20);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(3);
     expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@@ -108,8 +108,8 @@ describe("move element", () => {
 });
 
 describe("duplicate element on move when ALT is clicked", () => {
-  it("rectangle", () => {
-    const { getByToolName, container } = render(<App />);
+  it("rectangle", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     const canvas = container.querySelector("canvas")!;
 
     {
@@ -120,7 +120,7 @@ describe("duplicate element on move when ALT is clicked", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(5);
+      expect(renderScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

+ 16 - 16
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(<App />);
+  it("rectangle", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     // select tool
     const tool = getByToolName("rectangle");
     fireEvent.click(tool);
@@ -30,12 +30,12 @@ describe("remove shape in non linear elements", () => {
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.elements.length).toEqual(0);
   });
 
-  it("ellipse", () => {
-    const { getByToolName, container } = render(<App />);
+  it("ellipse", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     // select tool
     const tool = getByToolName("ellipse");
     fireEvent.click(tool);
@@ -44,12 +44,12 @@ describe("remove shape in non linear elements", () => {
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.elements.length).toEqual(0);
   });
 
-  it("diamond", () => {
-    const { getByToolName, container } = render(<App />);
+  it("diamond", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     // select tool
     const tool = getByToolName("diamond");
     fireEvent.click(tool);
@@ -58,14 +58,14 @@ describe("remove shape in non linear elements", () => {
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.elements.length).toEqual(0);
   });
 });
 
 describe("multi point mode in linear elements", () => {
-  it("arrow", () => {
-    const { getByToolName, container } = render(<App />);
+  it("arrow", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     // select tool
     const tool = getByToolName("arrow");
     fireEvent.click(tool);
@@ -88,7 +88,7 @@ describe("multi point mode in linear elements", () => {
     fireEvent.pointerUp(canvas);
     fireEvent.keyDown(document, { key: KEYS.ENTER });
 
-    expect(renderScene).toHaveBeenCalledTimes(11);
+    expect(renderScene).toHaveBeenCalledTimes(12);
     expect(h.elements.length).toEqual(1);
 
     const element = h.elements[0] as ExcalidrawLinearElement;
@@ -105,8 +105,8 @@ describe("multi point mode in linear elements", () => {
     h.elements.forEach((element) => expect(element).toMatchSnapshot());
   });
 
-  it("line", () => {
-    const { getByToolName, container } = render(<App />);
+  it("line", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     // select tool
     const tool = getByToolName("line");
     fireEvent.click(tool);
@@ -129,7 +129,7 @@ describe("multi point mode in linear elements", () => {
     fireEvent.pointerUp(canvas);
     fireEvent.keyDown(document, { key: KEYS.ENTER });
 
-    expect(renderScene).toHaveBeenCalledTimes(11);
+    expect(renderScene).toHaveBeenCalledTimes(12);
     expect(h.elements.length).toEqual(1);
 
     const element = h.elements[0] as ExcalidrawLinearElement;

+ 2 - 2
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(<App offsetLeft={0} offsetTop={0} />);
+  await render(<Excalidraw offsetLeft={0} offsetTop={0} />);
 });
 
 afterEach(() => {

+ 6 - 6
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(<App />);
+  it("rectangle", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     const canvas = container.querySelector("canvas")!;
 
     {
@@ -34,7 +34,7 @@ describe("resize element", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(5);
+      expect(renderScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -65,8 +65,8 @@ describe("resize element", () => {
 });
 
 describe("resize element with aspect ratio when SHIFT is clicked", () => {
-  it("rectangle", () => {
-    render(<App />);
+  it("rectangle", async () => {
+    await render(<ExcalidrawApp />);
 
     const rectangle = UI.createElement("rectangle", {
       x: 0,

+ 25 - 25
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(<App />);
+  it("create selection element on pointer down", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     // select tool
     const tool = getByToolName("selection");
     fireEvent.click(tool);
@@ -28,7 +28,7 @@ describe("selection element", () => {
     const canvas = container.querySelector("canvas")!;
     fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
 
-    expect(renderScene).toHaveBeenCalledTimes(2);
+    expect(renderScene).toHaveBeenCalledTimes(3);
     const selectionElement = h.state.selectionElement!;
     expect(selectionElement).not.toBeNull();
     expect(selectionElement.type).toEqual("selection");
@@ -39,8 +39,8 @@ describe("selection element", () => {
     fireEvent.pointerUp(canvas);
   });
 
-  it("resize selection element on pointer move", () => {
-    const { getByToolName, container } = render(<App />);
+  it("resize selection element on pointer move", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     // select tool
     const tool = getByToolName("selection");
     fireEvent.click(tool);
@@ -49,7 +49,7 @@ describe("selection element", () => {
     fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
     fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene).toHaveBeenCalledTimes(4);
     const selectionElement = h.state.selectionElement!;
     expect(selectionElement).not.toBeNull();
     expect(selectionElement.type).toEqual("selection");
@@ -60,8 +60,8 @@ describe("selection element", () => {
     fireEvent.pointerUp(canvas);
   });
 
-  it("remove selection element on pointer up", () => {
-    const { getByToolName, container } = render(<App />);
+  it("remove selection element on pointer up", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     // select tool
     const tool = getByToolName("selection");
     fireEvent.click(tool);
@@ -71,14 +71,14 @@ describe("selection element", () => {
     fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
   });
 });
 
 describe("select single element on the scene", () => {
-  it("rectangle", () => {
-    const { getByToolName, container } = render(<App />);
+  it("rectangle", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     const canvas = container.querySelector("canvas")!;
     {
       // create element
@@ -96,7 +96,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(8);
+    expect(renderScene).toHaveBeenCalledTimes(9);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -104,8 +104,8 @@ describe("select single element on the scene", () => {
     h.elements.forEach((element) => expect(element).toMatchSnapshot());
   });
 
-  it("diamond", () => {
-    const { getByToolName, container } = render(<App />);
+  it("diamond", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     const canvas = container.querySelector("canvas")!;
     {
       // create element
@@ -123,7 +123,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(8);
+    expect(renderScene).toHaveBeenCalledTimes(9);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -131,8 +131,8 @@ describe("select single element on the scene", () => {
     h.elements.forEach((element) => expect(element).toMatchSnapshot());
   });
 
-  it("ellipse", () => {
-    const { getByToolName, container } = render(<App />);
+  it("ellipse", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     const canvas = container.querySelector("canvas")!;
     {
       // create element
@@ -150,7 +150,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(8);
+    expect(renderScene).toHaveBeenCalledTimes(9);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -158,8 +158,8 @@ describe("select single element on the scene", () => {
     h.elements.forEach((element) => expect(element).toMatchSnapshot());
   });
 
-  it("arrow", () => {
-    const { getByToolName, container } = render(<App />);
+  it("arrow", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     const canvas = container.querySelector("canvas")!;
     {
       // create element
@@ -190,15 +190,15 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(8);
+    expect(renderScene).toHaveBeenCalledTimes(9);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
     h.elements.forEach((element) => expect(element).toMatchSnapshot());
   });
 
-  it("arrow escape", () => {
-    const { getByToolName, container } = render(<App />);
+  it("arrow escape", async () => {
+    const { getByToolName, container } = await render(<ExcalidrawApp />);
     const canvas = container.querySelector("canvas")!;
     {
       // create element
@@ -229,7 +229,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(8);
+    expect(renderScene).toHaveBeenCalledTimes(9);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

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

+ 3 - 3
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(<App />);
+  beforeEach(async () => {
+    await render(<ExcalidrawApp />);
   });
 
   it("send back", () => {

+ 0 - 6
src/time_constants.ts

@@ -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;

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

+ 34 - 1
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 = <T extends any[]>(
   };
   ret.flush = () => {
     clearTimeout(handle);
-    fn(...lastArgs);
+    if (lastArgs) {
+      fn(...lastArgs);
+    }
   };
   return ret;
 };
@@ -303,3 +306,33 @@ export const isTransparent = (color: string) => {
     color === colors.elementBackground[0]
   );
 };
+
+export const noop = () => ({});
+
+export type ResolvablePromise<T> = Promise<T> & {
+  resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
+  reject: (error: Error) => void;
+};
+export const resolvablePromise = <T>() => {
+  let resolve!: any;
+  let reject!: any;
+  const promise = new Promise((_resolve, _reject) => {
+    resolve = _resolve;
+    reject = _reject;
+  });
+  (promise as any).resolve = resolve;
+  (promise as any).reject = reject;
+  return promise as ResolvablePromise<T>;
+};
+
+/**
+ * @param func handler taking at most single parameter (event).
+ */
+export const withBatchedUpdates = <
+  TFunction extends ((event: any) => void) | (() => void)
+>(
+  func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
+) =>
+  ((event) => {
+    unstable_batchedUpdates(func as TFunction, event);
+  }) as TFunction;

Some files were not shown because too many files changed in this diff