Browse Source

feat: partition main canvas vertically (#6759)

Co-authored-by: Marcel Mraz <[email protected]>
Co-authored-by: dwelle <[email protected]>
Marcel Mraz 1 year ago
parent
commit
a376bd9495
69 changed files with 2752 additions and 1834 deletions
  1. 1 1
      package.json
  2. 1 1
      src/actions/actionCanvas.tsx
  3. 20 18
      src/actions/actionDuplicateSelection.tsx
  4. 7 2
      src/actions/actionFinalize.tsx
  5. 1 1
      src/actions/actionFrame.ts
  6. 10 7
      src/actions/actionGroup.tsx
  7. 18 16
      src/actions/actionSelectAll.ts
  8. 3 3
      src/components/Actions.tsx
  9. 2 2
      src/components/App.test.tsx
  10. 236 327
      src/components/App.tsx
  11. 2 2
      src/components/EyeDropper.tsx
  12. 2 2
      src/components/JSONExportDialog.tsx
  13. 18 6
      src/components/LayerUI.tsx
  14. 4 4
      src/components/MobileMenu.tsx
  15. 222 0
      src/components/canvases/InteractiveCanvas.tsx
  16. 113 0
      src/components/canvases/StaticCanvas.tsx
  17. 4 0
      src/components/canvases/index.tsx
  18. 12 2
      src/css/styles.scss
  19. 1 5
      src/data/blob.ts
  20. 5 7
      src/element/Hyperlink.tsx
  21. 1 0
      src/element/binding.ts
  22. 3 5
      src/element/bounds.ts
  23. 4 4
      src/element/collision.ts
  24. 11 6
      src/element/linearElementEditor.ts
  25. 2 2
      src/element/mutateElement.ts
  26. 39 1
      src/element/sizeHelpers.ts
  27. 5 5
      src/element/textWysiwyg.test.tsx
  28. 1 1
      src/element/textWysiwyg.tsx
  29. 3 3
      src/element/transformHandles.ts
  30. 16 6
      src/excalidraw-app/data/tabSync.ts
  31. 1 1
      src/excalidraw-app/index.tsx
  32. 14 6
      src/frame.ts
  33. 171 75
      src/groups.ts
  34. 2 2
      src/math.ts
  35. 122 112
      src/renderer/renderElement.ts
  36. 641 571
      src/renderer/renderScene.ts
  37. 2 2
      src/scene/Fonts.ts
  38. 131 0
      src/scene/Renderer.ts
  39. 8 0
      src/scene/Scene.ts
  40. 61 0
      src/scene/ShapeCache.ts
  41. 9 12
      src/scene/export.ts
  42. 2 7
      src/scene/scroll.ts
  43. 8 14
      src/scene/scrollbars.ts
  44. 2 2
      src/scene/selection.ts
  45. 51 21
      src/scene/types.ts
  46. 118 118
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  47. 10 10
      src/tests/__snapshots__/dragCreate.test.tsx.snap
  48. 1 1
      src/tests/__snapshots__/linearElementEditor.test.tsx.snap
  49. 12 12
      src/tests/__snapshots__/move.test.tsx.snap
  50. 4 4
      src/tests/__snapshots__/multiPointCreate.test.tsx.snap
  51. 119 119
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  52. 10 10
      src/tests/__snapshots__/selection.test.tsx.snap
  53. 22 22
      src/tests/contextmenu.test.tsx
  54. 36 22
      src/tests/dragCreate.test.tsx
  55. 2 2
      src/tests/helpers/api.ts
  56. 17 10
      src/tests/helpers/ui.ts
  57. 55 40
      src/tests/linearElementEditor.test.tsx
  58. 25 13
      src/tests/move.test.tsx
  59. 20 12
      src/tests/multiPointCreate.test.tsx
  60. 6 6
      src/tests/packages/excalidraw.test.tsx
  61. 25 3
      src/tests/regressionTests.test.tsx
  62. 3 2
      src/tests/resize.test.tsx
  63. 30 19
      src/tests/selection.test.tsx
  64. 24 3
      src/tests/test-utils.ts
  65. 15 5
      src/tests/viewMode.test.tsx
  66. 1 1
      src/tests/zindex.test.tsx
  67. 50 3
      src/types.ts
  68. 89 14
      src/utils.ts
  69. 66 119
      yarn.lock

+ 1 - 1
package.json

@@ -90,7 +90,7 @@
     "vite-plugin-ejs": "1.6.4",
     "vite-plugin-pwa": "0.16.4",
     "vite-plugin-svgr": "2.4.0",
-    "vitest": "0.32.2",
+    "vitest": "0.34.1",
     "vitest-canvas-mock": "0.3.2"
   },
   "engines": {

+ 1 - 1
src/actions/actionCanvas.tsx

@@ -423,7 +423,7 @@ export const actionToggleHandTool = register({
         type: "hand",
         lastActiveToolBeforeEraser: appState.activeTool,
       });
-      setCursor(app.canvas, CURSOR_TYPE.GRAB);
+      setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
     }
 
     return {

+ 20 - 18
src/actions/actionDuplicateSelection.tsx

@@ -259,23 +259,25 @@ const duplicateElements = (
 
   return {
     elements: finalElements,
-    appState: selectGroupsForSelectedElements(
-      {
-        ...appState,
-        selectedGroupIds: {},
-        selectedElementIds: nextElementsToSelect.reduce(
-          (acc: Record<ExcalidrawElement["id"], true>, element) => {
-            if (!isBoundToContainer(element)) {
-              acc[element.id] = true;
-            }
-            return acc;
-          },
-          {},
-        ),
-      },
-      getNonDeletedElements(finalElements),
-      appState,
-      null,
-    ),
+    appState: {
+      ...appState,
+      ...selectGroupsForSelectedElements(
+        {
+          editingGroupId: appState.editingGroupId,
+          selectedElementIds: nextElementsToSelect.reduce(
+            (acc: Record<ExcalidrawElement["id"], true>, element) => {
+              if (!isBoundToContainer(element)) {
+                acc[element.id] = true;
+              }
+              return acc;
+            },
+            {},
+          ),
+        },
+        getNonDeletedElements(finalElements),
+        appState,
+        null,
+      ),
+    },
   };
 };

+ 7 - 2
src/actions/actionFinalize.tsx

@@ -19,7 +19,12 @@ import { AppState } from "../types";
 export const actionFinalize = register({
   name: "finalize",
   trackEvent: false,
-  perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
+  perform: (
+    elements,
+    appState,
+    _,
+    { interactiveCanvas, focusContainer, scene },
+  ) => {
     if (appState.editingLinearElement) {
       const { elementId, startBindingElement, endBindingElement } =
         appState.editingLinearElement;
@@ -132,7 +137,7 @@ export const actionFinalize = register({
         appState.activeTool.type !== "freedraw") ||
       !multiPointElement
     ) {
-      resetCursor(canvas);
+      resetCursor(interactiveCanvas);
     }
 
     let activeTool: AppState["activeTool"];

+ 1 - 1
src/actions/actionFrame.ts

@@ -108,7 +108,7 @@ export const actionSetFrameAsActiveTool = register({
       type: "frame",
     });
 
-    setCursorForShape(app.canvas, {
+    setCursorForShape(app.interactiveCanvas, {
       ...appState,
       activeTool: nextActiveTool,
     });

+ 10 - 7
src/actions/actionGroup.tsx

@@ -149,11 +149,14 @@ export const actionGroup = register({
     ];
 
     return {
-      appState: selectGroup(
-        newGroupId,
-        { ...appState, selectedGroupIds: {} },
-        getNonDeletedElements(nextElements),
-      ),
+      appState: {
+        ...appState,
+        ...selectGroup(
+          newGroupId,
+          { ...appState, selectedGroupIds: {} },
+          getNonDeletedElements(nextElements),
+        ),
+      },
       elements: nextElements,
       commitToHistory: true,
     };
@@ -212,7 +215,7 @@ export const actionUngroup = register({
     });
 
     const updateAppState = selectGroupsForSelectedElements(
-      { ...appState, selectedGroupIds: {} },
+      appState,
       getNonDeletedElements(nextElements),
       appState,
       null,
@@ -243,7 +246,7 @@ export const actionUngroup = register({
     );
 
     return {
-      appState: updateAppState,
+      appState: { ...appState, ...updateAppState },
       elements: nextElements,
       commitToHistory: true,
     };

+ 18 - 16
src/actions/actionSelectAll.ts

@@ -28,22 +28,24 @@ export const actionSelectAll = register({
     }, {});
 
     return {
-      appState: selectGroupsForSelectedElements(
-        {
-          ...appState,
-          selectedLinearElement:
-            // single linear element selected
-            Object.keys(selectedElementIds).length === 1 &&
-            isLinearElement(elements[0])
-              ? new LinearElementEditor(elements[0], app.scene)
-              : null,
-          editingGroupId: null,
-          selectedElementIds,
-        },
-        getNonDeletedElements(elements),
-        appState,
-        app,
-      ),
+      appState: {
+        ...appState,
+        ...selectGroupsForSelectedElements(
+          {
+            editingGroupId: null,
+            selectedElementIds,
+          },
+          getNonDeletedElements(elements),
+          appState,
+          app,
+        ),
+        selectedLinearElement:
+          // single linear element selected
+          Object.keys(selectedElementIds).length === 1 &&
+          isLinearElement(elements[0])
+            ? new LinearElementEditor(elements[0], app.scene)
+            : null,
+      },
       commitToHistory: true,
     };
   },

+ 3 - 3
src/components/Actions.tsx

@@ -213,13 +213,13 @@ export const SelectedShapeActions = ({
 };
 
 export const ShapesSwitcher = ({
-  canvas,
+  interactiveCanvas,
   activeTool,
   setAppState,
   onImageAction,
   appState,
 }: {
-  canvas: HTMLCanvasElement | null;
+  interactiveCanvas: HTMLCanvasElement | null;
   activeTool: UIAppState["activeTool"];
   setAppState: React.Component<any, UIAppState>["setState"];
   onImageAction: (data: { pointerType: PointerType | null }) => void;
@@ -270,7 +270,7 @@ export const ShapesSwitcher = ({
                 multiElement: null,
                 selectedElementIds: {},
               });
-              setCursorForShape(canvas, {
+              setCursorForShape(interactiveCanvas, {
                 ...appState,
                 activeTool: nextActiveTool,
               });

+ 2 - 2
src/components/App.test.tsx

@@ -6,14 +6,14 @@ import { render, queryByTestId } from "../tests/test-utils";
 import ExcalidrawApp from "../excalidraw-app";
 import { vi } from "vitest";
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
 
 describe("Test <App/>", () => {
   beforeEach(async () => {
     // Unmount ReactDOM from root
     ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
     localStorage.clear();
-    renderScene.mockClear();
+    renderStaticScene.mockClear();
     reseed(7);
   });
 

File diff suppressed because it is too large
+ 236 - 327
src/components/App.tsx


+ 2 - 2
src/components/EyeDropper.tsx

@@ -8,9 +8,9 @@ import { mutateElement } from "../element/mutateElement";
 import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
 import { useOutsideClick } from "../hooks/useOutsideClick";
 import { KEYS } from "../keys";
-import { invalidateShapeForElement } from "../renderer/renderElement";
 import { getSelectedElements } from "../scene";
 import Scene from "../scene/Scene";
+import { ShapeCache } from "../scene/ShapeCache";
 import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
 
 import "./EyeDropper.scss";
@@ -98,7 +98,7 @@ export const EyeDropper: React.FC<{
             },
             false,
           );
-          invalidateShapeForElement(element);
+          ShapeCache.delete(element);
         }
         Scene.getScene(
           metaStuffRef.current.selectedElements[0],

+ 2 - 2
src/components/JSONExportDialog.tsx

@@ -34,7 +34,7 @@ const JSONExportModal = ({
   actionManager: ActionManager;
   onCloseRequest: () => void;
   exportOpts: ExportOpts;
-  canvas: HTMLCanvasElement | null;
+  canvas: HTMLCanvasElement;
 }) => {
   const { onExportToBackend } = exportOpts;
   return (
@@ -100,7 +100,7 @@ export const JSONExportDialog = ({
   files: BinaryFiles;
   actionManager: ActionManager;
   exportOpts: ExportOpts;
-  canvas: HTMLCanvasElement | null;
+  canvas: HTMLCanvasElement;
   setAppState: React.Component<any, UIAppState>["setState"];
 }) => {
   const handleClose = React.useCallback(() => {

+ 18 - 6
src/components/LayerUI.tsx

@@ -57,7 +57,8 @@ interface LayerUIProps {
   actionManager: ActionManager;
   appState: UIAppState;
   files: BinaryFiles;
-  canvas: HTMLCanvasElement | null;
+  canvas: HTMLCanvasElement;
+  interactiveCanvas: HTMLCanvasElement | null;
   setAppState: React.Component<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
   onLockToggle: () => void;
@@ -117,6 +118,7 @@ const LayerUI = ({
   setAppState,
   elements,
   canvas,
+  interactiveCanvas,
   onLockToggle,
   onHandToolToggle,
   onPenModeToggle,
@@ -272,7 +274,7 @@ const LayerUI = ({
 
                           <ShapesSwitcher
                             appState={appState}
-                            canvas={canvas}
+                            interactiveCanvas={interactiveCanvas}
                             activeTool={appState.activeTool}
                             setAppState={setAppState}
                             onImageAction={({ pointerType }) => {
@@ -413,7 +415,7 @@ const LayerUI = ({
           onLockToggle={onLockToggle}
           onHandToolToggle={onHandToolToggle}
           onPenModeToggle={onPenModeToggle}
-          canvas={canvas}
+          interactiveCanvas={interactiveCanvas}
           onImageAction={onImageAction}
           renderTopRightUI={renderTopRightUI}
           renderCustomStats={renderCustomStats}
@@ -464,7 +466,7 @@ const LayerUI = ({
                 className="scroll-back-to-content"
                 onClick={() => {
                   setAppState((appState) => ({
-                    ...calculateScrollCenter(elements, appState, canvas),
+                    ...calculateScrollCenter(elements, appState),
                   }));
                 }}
               >
@@ -507,8 +509,18 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
     return false;
   }
 
-  const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
-  const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
+  const {
+    canvas: _pC,
+    interactiveCanvas: _pIC,
+    appState: prevAppState,
+    ...prev
+  } = prevProps;
+  const {
+    canvas: _nC,
+    interactiveCanvas: _nIC,
+    appState: nextAppState,
+    ...next
+  } = nextProps;
 
   return (
     isShallowEqual(

+ 4 - 4
src/components/MobileMenu.tsx

@@ -36,7 +36,7 @@ type MobileMenuProps = {
   onLockToggle: () => void;
   onHandToolToggle: () => void;
   onPenModeToggle: () => void;
-  canvas: HTMLCanvasElement | null;
+  interactiveCanvas: HTMLCanvasElement | null;
 
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   renderTopRightUI?: (
@@ -58,7 +58,7 @@ export const MobileMenu = ({
   onLockToggle,
   onHandToolToggle,
   onPenModeToggle,
-  canvas,
+  interactiveCanvas,
   onImageAction,
   renderTopRightUI,
   renderCustomStats,
@@ -85,7 +85,7 @@ export const MobileMenu = ({
                   <Stack.Row gap={1}>
                     <ShapesSwitcher
                       appState={appState}
-                      canvas={canvas}
+                      interactiveCanvas={interactiveCanvas}
                       activeTool={appState.activeTool}
                       setAppState={setAppState}
                       onImageAction={({ pointerType }) => {
@@ -202,7 +202,7 @@ export const MobileMenu = ({
                   className="scroll-back-to-content"
                   onClick={() => {
                     setAppState((appState) => ({
-                      ...calculateScrollCenter(elements, appState, canvas),
+                      ...calculateScrollCenter(elements, appState),
                     }));
                   }}
                 >

+ 222 - 0
src/components/canvases/InteractiveCanvas.tsx

@@ -0,0 +1,222 @@
+import React, { useEffect, useRef } from "react";
+import { renderInteractiveScene } from "../../renderer/renderScene";
+import {
+  isRenderThrottlingEnabled,
+  isShallowEqual,
+  sceneCoordsToViewportCoords,
+} from "../../utils";
+import { CURSOR_TYPE } from "../../constants";
+import { t } from "../../i18n";
+import type { DOMAttributes } from "react";
+import type { AppState, InteractiveCanvasAppState } from "../../types";
+import type {
+  InteractiveCanvasRenderConfig,
+  RenderInteractiveSceneCallback,
+} from "../../scene/types";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+
+type InteractiveCanvasProps = {
+  canvas: HTMLCanvasElement | null;
+  elements: readonly NonDeletedExcalidrawElement[];
+  visibleElements: readonly NonDeletedExcalidrawElement[];
+  selectedElements: readonly NonDeletedExcalidrawElement[];
+  versionNonce: number | undefined;
+  selectionNonce: number | undefined;
+  scale: number;
+  appState: InteractiveCanvasAppState;
+  renderInteractiveSceneCallback: (
+    data: RenderInteractiveSceneCallback,
+  ) => void;
+  handleCanvasRef: (canvas: HTMLCanvasElement | null) => void;
+  onContextMenu: Exclude<
+    DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
+    undefined
+  >;
+  onPointerMove: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onPointerMove"],
+    undefined
+  >;
+  onPointerUp: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onPointerUp"],
+    undefined
+  >;
+  onPointerCancel: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onPointerCancel"],
+    undefined
+  >;
+  onTouchMove: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onTouchMove"],
+    undefined
+  >;
+  onPointerDown: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onPointerDown"],
+    undefined
+  >;
+  onDoubleClick: Exclude<
+    DOMAttributes<HTMLCanvasElement>["onDoubleClick"],
+    undefined
+  >;
+};
+
+const InteractiveCanvas = (props: InteractiveCanvasProps) => {
+  const isComponentMounted = useRef(false);
+
+  useEffect(() => {
+    if (!isComponentMounted.current) {
+      isComponentMounted.current = true;
+      return;
+    }
+
+    const cursorButton: {
+      [id: string]: string | undefined;
+    } = {};
+    const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
+      {};
+    const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
+      {};
+    const pointerUsernames: { [id: string]: string } = {};
+    const pointerUserStates: { [id: string]: string } = {};
+
+    props.appState.collaborators.forEach((user, socketId) => {
+      if (user.selectedElementIds) {
+        for (const id of Object.keys(user.selectedElementIds)) {
+          if (!(id in remoteSelectedElementIds)) {
+            remoteSelectedElementIds[id] = [];
+          }
+          remoteSelectedElementIds[id].push(socketId);
+        }
+      }
+      if (!user.pointer) {
+        return;
+      }
+      if (user.username) {
+        pointerUsernames[socketId] = user.username;
+      }
+      if (user.userState) {
+        pointerUserStates[socketId] = user.userState;
+      }
+      pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
+        {
+          sceneX: user.pointer.x,
+          sceneY: user.pointer.y,
+        },
+        props.appState,
+      );
+      cursorButton[socketId] = user.button;
+    });
+
+    const selectionColor = getComputedStyle(
+      document.querySelector(".excalidraw")!,
+    ).getPropertyValue("--color-selection");
+
+    renderInteractiveScene(
+      {
+        canvas: props.canvas,
+        elements: props.elements,
+        visibleElements: props.visibleElements,
+        selectedElements: props.selectedElements,
+        scale: window.devicePixelRatio,
+        appState: props.appState,
+        renderConfig: {
+          remotePointerViewportCoords: pointerViewportCoords,
+          remotePointerButton: cursorButton,
+          remoteSelectedElementIds,
+          remotePointerUsernames: pointerUsernames,
+          remotePointerUserStates: pointerUserStates,
+          selectionColor,
+          renderScrollbars: false,
+        },
+        callback: props.renderInteractiveSceneCallback,
+      },
+      isRenderThrottlingEnabled(),
+    );
+  });
+
+  return (
+    <canvas
+      className="excalidraw__canvas interactive"
+      style={{
+        width: props.appState.width,
+        height: props.appState.height,
+        cursor: props.appState.viewModeEnabled
+          ? CURSOR_TYPE.GRAB
+          : CURSOR_TYPE.AUTO,
+      }}
+      width={props.appState.width * props.scale}
+      height={props.appState.height * props.scale}
+      ref={props.handleCanvasRef}
+      onContextMenu={props.onContextMenu}
+      onPointerMove={props.onPointerMove}
+      onPointerUp={props.onPointerUp}
+      onPointerCancel={props.onPointerCancel}
+      onTouchMove={props.onTouchMove}
+      onPointerDown={props.onPointerDown}
+      onDoubleClick={
+        props.appState.viewModeEnabled ? undefined : props.onDoubleClick
+      }
+    >
+      {t("labels.drawingCanvas")}
+    </canvas>
+  );
+};
+
+const getRelevantAppStateProps = (
+  appState: AppState,
+): Omit<InteractiveCanvasAppState, "editingElement"> => ({
+  zoom: appState.zoom,
+  scrollX: appState.scrollX,
+  scrollY: appState.scrollY,
+  width: appState.width,
+  height: appState.height,
+  viewModeEnabled: appState.viewModeEnabled,
+  editingGroupId: appState.editingGroupId,
+  editingLinearElement: appState.editingLinearElement,
+  selectedElementIds: appState.selectedElementIds,
+  frameToHighlight: appState.frameToHighlight,
+  offsetLeft: appState.offsetLeft,
+  offsetTop: appState.offsetTop,
+  theme: appState.theme,
+  pendingImageElementId: appState.pendingImageElementId,
+  selectionElement: appState.selectionElement,
+  selectedGroupIds: appState.selectedGroupIds,
+  selectedLinearElement: appState.selectedLinearElement,
+  multiElement: appState.multiElement,
+  isBindingEnabled: appState.isBindingEnabled,
+  suggestedBindings: appState.suggestedBindings,
+  isRotating: appState.isRotating,
+  elementsToHighlight: appState.elementsToHighlight,
+  openSidebar: appState.openSidebar,
+  showHyperlinkPopup: appState.showHyperlinkPopup,
+  collaborators: appState.collaborators, // Necessary for collab. sessions
+  activeEmbeddable: appState.activeEmbeddable,
+});
+
+const areEqual = (
+  prevProps: InteractiveCanvasProps,
+  nextProps: InteractiveCanvasProps,
+) => {
+  // This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
+  if (
+    prevProps.selectionNonce !== nextProps.selectionNonce ||
+    prevProps.versionNonce !== nextProps.versionNonce ||
+    prevProps.scale !== nextProps.scale ||
+    // we need to memoize on element arrays because they may have renewed
+    // even if versionNonce didn't change (e.g. we filter elements out based
+    // on appState)
+    prevProps.elements !== nextProps.elements ||
+    prevProps.visibleElements !== nextProps.visibleElements ||
+    prevProps.selectedElements !== nextProps.selectedElements
+  ) {
+    return false;
+  }
+
+  // Comparing the interactive appState for changes in case of some edge cases
+  return isShallowEqual(
+    // asserting AppState because we're being passed the whole AppState
+    // but resolve to only the InteractiveCanvas-relevant props
+    getRelevantAppStateProps(prevProps.appState as AppState),
+    getRelevantAppStateProps(nextProps.appState as AppState),
+  );
+};
+
+export default React.memo(InteractiveCanvas, areEqual);

+ 113 - 0
src/components/canvases/StaticCanvas.tsx

@@ -0,0 +1,113 @@
+import React, { useEffect, useRef } from "react";
+import { RoughCanvas } from "roughjs/bin/canvas";
+import { renderStaticScene } from "../../renderer/renderScene";
+import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils";
+import type { AppState, StaticCanvasAppState } from "../../types";
+import type { StaticCanvasRenderConfig } from "../../scene/types";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+
+type StaticCanvasProps = {
+  canvas: HTMLCanvasElement;
+  rc: RoughCanvas;
+  elements: readonly NonDeletedExcalidrawElement[];
+  visibleElements: readonly NonDeletedExcalidrawElement[];
+  versionNonce: number | undefined;
+  selectionNonce: number | undefined;
+  scale: number;
+  appState: StaticCanvasAppState;
+  renderConfig: StaticCanvasRenderConfig;
+};
+
+const StaticCanvas = (props: StaticCanvasProps) => {
+  const wrapperRef = useRef<HTMLDivElement>(null);
+  const isComponentMounted = useRef(false);
+
+  useEffect(() => {
+    const wrapper = wrapperRef.current;
+    if (!wrapper) {
+      return;
+    }
+
+    const canvas = props.canvas;
+
+    if (!isComponentMounted.current) {
+      isComponentMounted.current = true;
+
+      wrapper.replaceChildren(canvas);
+      canvas.classList.add("excalidraw__canvas", "static");
+    }
+
+    canvas.style.width = `${props.appState.width}px`;
+    canvas.style.height = `${props.appState.height}px`;
+    canvas.width = props.appState.width * props.scale;
+    canvas.height = props.appState.height * props.scale;
+
+    renderStaticScene(
+      {
+        canvas,
+        rc: props.rc,
+        scale: props.scale,
+        elements: props.elements,
+        visibleElements: props.visibleElements,
+        appState: props.appState,
+        renderConfig: props.renderConfig,
+      },
+      isRenderThrottlingEnabled(),
+    );
+  });
+
+  return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
+};
+
+const getRelevantAppStateProps = (
+  appState: AppState,
+): Omit<
+  StaticCanvasAppState,
+  | "editingElement"
+  | "selectedElementIds"
+  | "editingGroupId"
+  | "frameToHighlight"
+> => ({
+  zoom: appState.zoom,
+  scrollX: appState.scrollX,
+  scrollY: appState.scrollY,
+  width: appState.width,
+  height: appState.height,
+  viewModeEnabled: appState.viewModeEnabled,
+  offsetLeft: appState.offsetLeft,
+  offsetTop: appState.offsetTop,
+  theme: appState.theme,
+  pendingImageElementId: appState.pendingImageElementId,
+  shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
+  viewBackgroundColor: appState.viewBackgroundColor,
+  exportScale: appState.exportScale,
+  selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
+  gridSize: appState.gridSize,
+  frameRendering: appState.frameRendering,
+});
+
+const areEqual = (
+  prevProps: StaticCanvasProps,
+  nextProps: StaticCanvasProps,
+) => {
+  if (
+    prevProps.versionNonce !== nextProps.versionNonce ||
+    prevProps.scale !== nextProps.scale ||
+    // we need to memoize on element arrays because they may have renewed
+    // even if versionNonce didn't change (e.g. we filter elements out based
+    // on appState)
+    prevProps.elements !== nextProps.elements ||
+    prevProps.visibleElements !== nextProps.visibleElements
+  ) {
+    return false;
+  }
+
+  return isShallowEqual(
+    // asserting AppState because we're being passed the whole AppState
+    // but resolve to only the StaticCanvas-relevant props
+    getRelevantAppStateProps(prevProps.appState as AppState),
+    getRelevantAppStateProps(nextProps.appState as AppState),
+  );
+};
+
+export default React.memo(StaticCanvas, areEqual);

+ 4 - 0
src/components/canvases/index.tsx

@@ -0,0 +1,4 @@
+import InteractiveCanvas from "./InteractiveCanvas";
+import StaticCanvas from "./StaticCanvas";
+
+export { InteractiveCanvas, StaticCanvas };

+ 12 - 2
src/css/styles.scss

@@ -3,8 +3,9 @@
 
 :root {
   --zIndex-canvas: 1;
-  --zIndex-wysiwyg: 2;
-  --zIndex-layerUI: 3;
+  --zIndex-interactiveCanvas: 2;
+  --zIndex-wysiwyg: 3;
+  --zIndex-layerUI: 4;
 
   --zIndex-modal: 1000;
   --zIndex-popup: 1001;
@@ -69,10 +70,19 @@
 
     z-index: var(--zIndex-canvas);
 
+    &.interactive {
+      z-index: var(--zIndex-interactiveCanvas);
+    }
+
     // Remove the main canvas from document flow to avoid resizeObserver
     // feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
   }
 
+  &__canvas-wrapper,
+  &__canvas.static {
+    pointer-events: none;
+  }
+
   &__canvas {
     position: absolute;
   }

+ 1 - 5
src/data/blob.ts

@@ -144,11 +144,7 @@ export const loadSceneOrLibraryFromBlob = async (
               fileHandle: fileHandle || blob.handle || null,
               ...cleanAppStateForExport(data.appState || {}),
               ...(localAppState
-                ? calculateScrollCenter(
-                    data.elements || [],
-                    localAppState,
-                    null,
-                  )
+                ? calculateScrollCenter(data.elements || [], localAppState)
                 : {}),
             },
             files: data.files,

+ 5 - 7
src/element/Hyperlink.tsx

@@ -25,10 +25,7 @@ import {
 } from "react";
 import clsx from "clsx";
 import { KEYS } from "../keys";
-import {
-  DEFAULT_LINK_SIZE,
-  invalidateShapeForElement,
-} from "../renderer/renderElement";
+import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
 import { rotate } from "../math";
 import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
 import { Bounds } from "./bounds";
@@ -42,6 +39,7 @@ import "./Hyperlink.scss";
 import { trackEvent } from "../analytics";
 import { useAppProps, useExcalidrawAppState } from "../components/App";
 import { isEmbeddableElement } from "./typeChecks";
+import { ShapeCache } from "../scene/ShapeCache";
 
 const CONTAINER_WIDTH = 320;
 const SPACE_BOTTOM = 85;
@@ -115,7 +113,7 @@ export const Hyperlink = ({
           validated: false,
           link,
         });
-        invalidateShapeForElement(element);
+        ShapeCache.delete(element);
       } else {
         const { width, height } = element;
         const embedLink = getEmbedLink(link);
@@ -147,7 +145,7 @@ export const Hyperlink = ({
           validated: true,
           link,
         });
-        invalidateShapeForElement(element);
+        ShapeCache.delete(element);
         if (embeddableLinkCache.has(element.id)) {
           embeddableLinkCache.delete(element.id);
         }
@@ -393,7 +391,7 @@ export const getContextMenuLabel = (
 export const getLinkHandleFromCoords = (
   [x1, y1, x2, y2]: Bounds,
   angle: number,
-  appState: UIAppState,
+  appState: Pick<UIAppState, "zoom">,
 ): [x: number, y: number, width: number, height: number] => {
   const size = DEFAULT_LINK_SIZE;
   const linkWidth = size / appState.zoom.value;

+ 1 - 0
src/element/binding.ts

@@ -474,6 +474,7 @@ const maybeCalculateNewGapWhenScaling = (
   return { elementId, gap: newGap, focus };
 };
 
+// TODO: this is a bottleneck, optimise
 export const getEligibleElementsForBinding = (
   elements: NonDeleted<ExcalidrawElement>[],
 ): SuggestedBinding[] => {

+ 3 - 5
src/element/bounds.ts

@@ -10,10 +10,7 @@ import { distance2d, rotate, rotatePoint } from "../math";
 import rough from "roughjs/bin/rough";
 import { Drawable, Op } from "roughjs/bin/core";
 import { Point } from "../types";
-import {
-  getShapeForElement,
-  generateRoughOptions,
-} from "../renderer/renderElement";
+import { generateRoughOptions } from "../renderer/renderElement";
 import {
   isArrowElement,
   isFreeDrawElement,
@@ -24,6 +21,7 @@ import { rescalePoints } from "../points";
 import { getBoundTextElement, getContainerElement } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { Mutable } from "../utility-types";
+import { ShapeCache } from "../scene/ShapeCache";
 
 export type RectangleBox = {
   x: number;
@@ -621,7 +619,7 @@ const getLinearElementRotatedBounds = (
   }
 
   // first element is always the curve
-  const cachedShape = getShapeForElement(element)?.[0];
+  const cachedShape = ShapeCache.get(element)?.[0];
   const shape = cachedShape ?? generateLinearElementShape(element);
   const ops = getCurvePathOps(shape);
   const transformXY = (x: number, y: number) =>

+ 4 - 4
src/element/collision.ts

@@ -39,7 +39,6 @@ import {
 import { FrameNameBoundsCache, Point } from "../types";
 import { Drawable } from "roughjs/bin/core";
 import { AppState } from "../types";
-import { getShapeForElement } from "../renderer/renderElement";
 import {
   hasBoundTextElement,
   isEmbeddableElement,
@@ -50,6 +49,7 @@ import { isTransparent } from "../utils";
 import { shouldShowBoundingBox } from "./transformHandles";
 import { getBoundTextElement } from "./textElement";
 import { Mutable } from "../utility-types";
+import { ShapeCache } from "../scene/ShapeCache";
 
 const isElementDraggableFromInside = (
   element: NonDeletedExcalidrawElement,
@@ -489,7 +489,7 @@ const hitTestFreeDrawElement = (
     B = element.points[i + 1];
   }
 
-  const shape = getShapeForElement(element);
+  const shape = ShapeCache.get(element);
 
   // for filled freedraw shapes, support
   // selecting from inside
@@ -502,7 +502,7 @@ const hitTestFreeDrawElement = (
 
 const hitTestLinear = (args: HitTestArgs): boolean => {
   const { element, threshold } = args;
-  if (!getShapeForElement(element)) {
+  if (!ShapeCache.get(element)) {
     return false;
   }
 
@@ -520,7 +520,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
   }
   const [relX, relY] = GAPoint.toTuple(point);
 
-  const shape = getShapeForElement(element as ExcalidrawLinearElement);
+  const shape = ShapeCache.get(element as ExcalidrawLinearElement);
 
   if (!shape) {
     return false;

+ 11 - 6
src/element/linearElementEditor.ts

@@ -25,7 +25,12 @@ import {
   getElementPointsCoords,
   getMinMaxXYFromCurvePathOps,
 } from "./bounds";
-import { Point, AppState, PointerCoords } from "../types";
+import {
+  Point,
+  AppState,
+  PointerCoords,
+  InteractiveCanvasAppState,
+} from "../types";
 import { mutateElement } from "./mutateElement";
 import History from "../history";
 
@@ -39,9 +44,9 @@ import { tupleToCoors } from "../utils";
 import { isBindingElement } from "./typeChecks";
 import { shouldRotateWithDiscreteAngle } from "../keys";
 import { getBoundTextElement, handleBindTextResize } from "./textElement";
-import { getShapeForElement } from "../renderer/renderElement";
 import { DRAGGING_THRESHOLD } from "../constants";
 import { Mutable } from "../utility-types";
+import { ShapeCache } from "../scene/ShapeCache";
 
 const editorMidPointsCache: {
   version: number | null;
@@ -398,7 +403,7 @@ export class LinearElementEditor {
 
   static getEditorMidPoints = (
     element: NonDeleted<ExcalidrawLinearElement>,
-    appState: AppState,
+    appState: InteractiveCanvasAppState,
   ): typeof editorMidPointsCache["points"] => {
     const boundText = getBoundTextElement(element);
 
@@ -422,7 +427,7 @@ export class LinearElementEditor {
 
   static updateEditorMidPointsCache = (
     element: NonDeleted<ExcalidrawLinearElement>,
-    appState: AppState,
+    appState: InteractiveCanvasAppState,
   ) => {
     const points = LinearElementEditor.getPointsGlobalCoordinates(element);
 
@@ -1418,7 +1423,7 @@ export class LinearElementEditor {
     let y1;
     let x2;
     let y2;
-    if (element.points.length < 2 || !getShapeForElement(element)) {
+    if (element.points.length < 2 || !ShapeCache.get(element)) {
       // XXX this is just a poor estimate and not very useful
       const { minX, minY, maxX, maxY } = element.points.reduce(
         (limits, [x, y]) => {
@@ -1437,7 +1442,7 @@ export class LinearElementEditor {
       x2 = maxX + element.x;
       y2 = maxY + element.y;
     } else {
-      const shape = getShapeForElement(element)!;
+      const shape = ShapeCache.generateElementShape(element);
 
       // first element is always the curve
       const ops = getCurvePathOps(shape[0]);

+ 2 - 2
src/element/mutateElement.ts

@@ -1,11 +1,11 @@
 import { ExcalidrawElement } from "./types";
-import { invalidateShapeForElement } from "../renderer/renderElement";
 import Scene from "../scene/Scene";
 import { getSizeFromPoints } from "../points";
 import { randomInteger } from "../random";
 import { Point } from "../types";
 import { getUpdatedTimestamp } from "../utils";
 import { Mutable } from "../utility-types";
+import { ShapeCache } from "../scene/ShapeCache";
 
 type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
   Partial<TElement>,
@@ -89,7 +89,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
     typeof fileId != "undefined" ||
     typeof points !== "undefined"
   ) {
-    invalidateShapeForElement(element);
+    ShapeCache.delete(element);
   }
 
   element.version++;

+ 39 - 1
src/element/sizeHelpers.ts

@@ -2,7 +2,9 @@ import { ExcalidrawElement } from "./types";
 import { mutateElement } from "./mutateElement";
 import { isFreeDrawElement, isLinearElement } from "./typeChecks";
 import { SHIFT_LOCKING_ANGLE } from "../constants";
-import { AppState } from "../types";
+import { AppState, Zoom } from "../types";
+import { getElementBounds } from "./bounds";
+import { viewportCoordsToSceneCoords } from "../utils";
 
 export const isInvisiblySmallElement = (
   element: ExcalidrawElement,
@@ -13,6 +15,42 @@ export const isInvisiblySmallElement = (
   return element.width === 0 && element.height === 0;
 };
 
+export const isElementInViewport = (
+  element: ExcalidrawElement,
+  width: number,
+  height: number,
+  viewTransformations: {
+    zoom: Zoom;
+    offsetLeft: number;
+    offsetTop: number;
+    scrollX: number;
+    scrollY: number;
+  },
+) => {
+  const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates
+  const topLeftSceneCoords = viewportCoordsToSceneCoords(
+    {
+      clientX: viewTransformations.offsetLeft,
+      clientY: viewTransformations.offsetTop,
+    },
+    viewTransformations,
+  );
+  const bottomRightSceneCoords = viewportCoordsToSceneCoords(
+    {
+      clientX: viewTransformations.offsetLeft + width,
+      clientY: viewTransformations.offsetTop + height,
+    },
+    viewTransformations,
+  );
+
+  return (
+    topLeftSceneCoords.x <= x2 &&
+    topLeftSceneCoords.y <= y2 &&
+    bottomRightSceneCoords.x >= x1 &&
+    bottomRightSceneCoords.y >= y1
+  );
+};
+
 /**
  * Makes a perfect shape or diagonal/horizontal/vertical line
  */

+ 5 - 5
src/element/textWysiwyg.test.tsx

@@ -759,7 +759,7 @@ describe("textWysiwyg", () => {
       expect(h.elements[1].type).toBe("text");
 
       API.setSelectedElements([h.elements[0], h.elements[1]]);
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 20,
         clientY: 30,
@@ -903,7 +903,7 @@ describe("textWysiwyg", () => {
       mouse.clickAt(10, 20);
       mouse.down();
       mouse.up();
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 20,
         clientY: 30,
@@ -1154,7 +1154,7 @@ describe("textWysiwyg", () => {
 
       h.elements = [container, text];
       API.setSelectedElements([container, text]);
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 20,
         clientY: 30,
@@ -1168,7 +1168,7 @@ describe("textWysiwyg", () => {
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
         "Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
       );
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 20,
         clientY: 30,
@@ -1406,7 +1406,7 @@ describe("textWysiwyg", () => {
 
       API.setSelectedElements([textElement]);
 
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 20,
         clientY: 30,

+ 1 - 1
src/element/textWysiwyg.tsx

@@ -116,7 +116,7 @@ export const textWysiwyg = ({
   }) => void;
   getViewportCoords: (x: number, y: number) => [number, number];
   element: ExcalidrawTextElement;
-  canvas: HTMLCanvasElement | null;
+  canvas: HTMLCanvasElement;
   excalidrawContainer: HTMLDivElement | null;
   app: App;
 }) => {

+ 3 - 3
src/element/transformHandles.ts

@@ -6,7 +6,7 @@ import {
 
 import { getElementAbsoluteCoords } from "./bounds";
 import { rotate } from "../math";
-import { AppState, Zoom } from "../types";
+import { InteractiveCanvasAppState, Zoom } from "../types";
 import { isTextElement } from ".";
 import { isFrameElement, isLinearElement } from "./typeChecks";
 import { DEFAULT_SPACING } from "../renderer/renderScene";
@@ -276,8 +276,8 @@ export const getTransformHandles = (
 };
 
 export const shouldShowBoundingBox = (
-  elements: NonDeletedExcalidrawElement[],
-  appState: AppState,
+  elements: readonly NonDeletedExcalidrawElement[],
+  appState: InteractiveCanvasAppState,
 ) => {
   if (appState.editingLinearElement) {
     return false;

+ 16 - 6
src/excalidraw-app/data/tabSync.ts

@@ -16,14 +16,24 @@ export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => {
 
 export const updateBrowserStateVersion = (type: BrowserStateTypes) => {
   const timestamp = Date.now();
-  localStorage.setItem(type, JSON.stringify(timestamp));
-  LOCAL_STATE_VERSIONS[type] = timestamp;
+  try {
+    localStorage.setItem(type, JSON.stringify(timestamp));
+    LOCAL_STATE_VERSIONS[type] = timestamp;
+  } catch (error) {
+    console.error("error while updating browser state verison", error);
+  }
 };
 
 export const resetBrowserStateVersions = () => {
-  for (const key of Object.keys(LOCAL_STATE_VERSIONS) as BrowserStateTypes[]) {
-    const timestamp = -1;
-    localStorage.setItem(key, JSON.stringify(timestamp));
-    LOCAL_STATE_VERSIONS[key] = timestamp;
+  try {
+    for (const key of Object.keys(
+      LOCAL_STATE_VERSIONS,
+    ) as BrowserStateTypes[]) {
+      const timestamp = -1;
+      localStorage.setItem(key, JSON.stringify(timestamp));
+      LOCAL_STATE_VERSIONS[key] = timestamp;
+    }
+  } catch (error) {
+    console.error("error while resetting browser state verison", error);
   }
 };

+ 1 - 1
src/excalidraw-app/index.tsx

@@ -598,7 +598,7 @@ const ExcalidrawWrapper = () => {
     exportedElements: readonly NonDeletedExcalidrawElement[],
     appState: Partial<AppState>,
     files: BinaryFiles,
-    canvas: HTMLCanvasElement | null,
+    canvas: HTMLCanvasElement,
   ) => {
     if (exportedElements.length === 0) {
       return window.alert(t("alerts.cannotExportEmptyCanvas"));

+ 14 - 6
src/frame.ts

@@ -16,7 +16,7 @@ import {
 } from "./element/textElement";
 import { arrayToMap, findIndex } from "./utils";
 import { mutateElement } from "./element/mutateElement";
-import { AppClassProperties, AppState } from "./types";
+import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
 import { getElementsWithinSelection, getSelectedElements } from "./scene";
 import { isFrameElement } from "./element";
 import { moveOneRight } from "./zindex";
@@ -469,9 +469,16 @@ export const addElementsToFrame = (
   }
 
   let nextElements = allElements.slice();
+  // Optimisation since findIndex on "newElements" is slow
+  const nextElementsIndex = nextElements.reduce(
+    (acc: Record<string, number | undefined>, element, index) => {
+      acc[element.id] = index;
+      return acc;
+    },
+    {},
+  );
 
   const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
-
   for (const element of omitGroupsContainingFrames(
     allElements,
     _elementsToAdd,
@@ -485,8 +492,8 @@ export const addElementsToFrame = (
         false,
       );
 
-      const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
-      const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
+      const frameIndex = nextElementsIndex[frame.id] ?? -1;
+      const elementIndex = nextElementsIndex[element.id] ?? -1;
 
       if (elementIndex < frameBoundary) {
         nextElements = [
@@ -648,7 +655,7 @@ export const omitGroupsContainingFrames = (
  */
 export const getTargetFrame = (
   element: ExcalidrawElement,
-  appState: AppState,
+  appState: StaticCanvasAppState,
 ) => {
   const _element = isTextElement(element)
     ? getContainerElement(element) || element
@@ -660,11 +667,12 @@ export const getTargetFrame = (
     : getContainingFrame(_element);
 };
 
+// TODO: this a huge bottleneck for large scenes, optimise
 // given an element, return if the element is in some frame
 export const isElementInFrame = (
   element: ExcalidrawElement,
   allElements: ExcalidrawElementsIncludingDeleted,
-  appState: AppState,
+  appState: StaticCanvasAppState,
 ) => {
   const frame = getTargetFrame(element, appState);
   const _element = isTextElement(element)

+ 171 - 75
src/groups.ts

@@ -4,27 +4,40 @@ import {
   NonDeleted,
   NonDeletedExcalidrawElement,
 } from "./element/types";
-import { AppClassProperties, AppState } from "./types";
+import {
+  AppClassProperties,
+  AppState,
+  InteractiveCanvasAppState,
+} from "./types";
 import { getSelectedElements } from "./scene";
 import { getBoundTextElement } from "./element/textElement";
 import { makeNextSelectedElementIds } from "./scene/selection";
 
 export const selectGroup = (
   groupId: GroupId,
-  appState: AppState,
+  appState: InteractiveCanvasAppState,
   elements: readonly NonDeleted<ExcalidrawElement>[],
-): AppState => {
-  const elementsInGroup = elements.filter((element) =>
-    element.groupIds.includes(groupId),
+): Pick<
+  InteractiveCanvasAppState,
+  "selectedGroupIds" | "selectedElementIds" | "editingGroupId"
+> => {
+  const elementsInGroup = elements.reduce(
+    (acc: Record<string, true>, element) => {
+      if (element.groupIds.includes(groupId)) {
+        acc[element.id] = true;
+      }
+      return acc;
+    },
+    {},
   );
 
-  if (elementsInGroup.length < 2) {
+  if (Object.keys(elementsInGroup).length < 2) {
     if (
       appState.selectedGroupIds[groupId] ||
       appState.editingGroupId === groupId
     ) {
       return {
-        ...appState,
+        selectedElementIds: appState.selectedElementIds,
         selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: false },
         editingGroupId: null,
       };
@@ -33,104 +46,184 @@ export const selectGroup = (
   }
 
   return {
-    ...appState,
+    editingGroupId: appState.editingGroupId,
     selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
     selectedElementIds: {
       ...appState.selectedElementIds,
-      ...Object.fromEntries(
-        elementsInGroup.map((element) => [element.id, true]),
-      ),
+      ...elementsInGroup,
     },
   };
 };
 
+export const selectGroupsForSelectedElements = (function () {
+  type SelectGroupsReturnType = Pick<
+    InteractiveCanvasAppState,
+    "selectedGroupIds" | "editingGroupId" | "selectedElementIds"
+  >;
+
+  let lastSelectedElements: readonly NonDeleted<ExcalidrawElement>[] | null =
+    null;
+  let lastElements: readonly NonDeleted<ExcalidrawElement>[] | null = null;
+  let lastReturnValue: SelectGroupsReturnType | null = null;
+
+  const _selectGroups = (
+    selectedElements: readonly NonDeleted<ExcalidrawElement>[],
+    elements: readonly NonDeleted<ExcalidrawElement>[],
+    appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
+  ): SelectGroupsReturnType => {
+    if (
+      lastReturnValue !== undefined &&
+      elements === lastElements &&
+      selectedElements === lastSelectedElements &&
+      appState.editingGroupId === lastReturnValue?.editingGroupId
+    ) {
+      return lastReturnValue;
+    }
+
+    const selectedGroupIds: Record<GroupId, boolean> = {};
+    // Gather all the groups withing selected elements
+    for (const selectedElement of selectedElements) {
+      let groupIds = selectedElement.groupIds;
+      if (appState.editingGroupId) {
+        // handle the case where a group is nested within a group
+        const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
+        if (indexOfEditingGroup > -1) {
+          groupIds = groupIds.slice(0, indexOfEditingGroup);
+        }
+      }
+      if (groupIds.length > 0) {
+        const lastSelectedGroup = groupIds[groupIds.length - 1];
+        selectedGroupIds[lastSelectedGroup] = true;
+      }
+    }
+
+    // Gather all the elements within selected groups
+    const groupElementsIndex: Record<GroupId, string[]> = {};
+    const selectedElementIdsInGroups = elements.reduce(
+      (acc: Record<string, true>, element) => {
+        const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
+
+        if (groupId) {
+          acc[element.id] = true;
+
+          // Populate the index
+          if (!Array.isArray(groupElementsIndex[groupId])) {
+            groupElementsIndex[groupId] = [element.id];
+          } else {
+            groupElementsIndex[groupId].push(element.id);
+          }
+        }
+        return acc;
+      },
+      {},
+    );
+
+    for (const groupId of Object.keys(groupElementsIndex)) {
+      // If there is one element in the group, and the group is selected or it's being edited, it's not a group
+      if (groupElementsIndex[groupId].length < 2) {
+        if (selectedGroupIds[groupId]) {
+          selectedGroupIds[groupId] = false;
+        }
+      }
+    }
+
+    lastElements = elements;
+    lastSelectedElements = selectedElements;
+
+    lastReturnValue = {
+      editingGroupId: appState.editingGroupId,
+      selectedGroupIds,
+      selectedElementIds: {
+        ...appState.selectedElementIds,
+        ...selectedElementIdsInGroups,
+      },
+    };
+
+    return lastReturnValue;
+  };
+
+  /**
+   * When you select an element, you often want to actually select the whole group it's in, unless
+   * you're currently editing that group.
+   */
+  const selectGroupsForSelectedElements = (
+    appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
+    elements: readonly NonDeletedExcalidrawElement[],
+    prevAppState: InteractiveCanvasAppState,
+    /**
+     * supply null in cases where you don't have access to App instance and
+     * you don't care about optimizing selectElements retrieval
+     */
+    app: AppClassProperties | null,
+  ): Pick<
+    InteractiveCanvasAppState,
+    "selectedGroupIds" | "editingGroupId" | "selectedElementIds"
+  > => {
+    const selectedElements = app
+      ? app.scene.getSelectedElements({
+          selectedElementIds: appState.selectedElementIds,
+          // supplying elements explicitly in case we're passed non-state elements
+          elements,
+        })
+      : getSelectedElements(elements, appState);
+
+    if (!selectedElements.length) {
+      return {
+        selectedGroupIds: {},
+        editingGroupId: null,
+        selectedElementIds: makeNextSelectedElementIds(
+          appState.selectedElementIds,
+          prevAppState,
+        ),
+      };
+    }
+
+    return _selectGroups(selectedElements, elements, appState);
+  };
+
+  selectGroupsForSelectedElements.clearCache = () => {
+    lastElements = null;
+    lastSelectedElements = null;
+    lastReturnValue = null;
+  };
+
+  return selectGroupsForSelectedElements;
+})();
+
 /**
  * If the element's group is selected, don't render an individual
  * selection border around it.
  */
 export const isSelectedViaGroup = (
-  appState: AppState,
+  appState: InteractiveCanvasAppState,
   element: ExcalidrawElement,
 ) => getSelectedGroupForElement(appState, element) != null;
 
 export const getSelectedGroupForElement = (
-  appState: AppState,
+  appState: InteractiveCanvasAppState,
   element: ExcalidrawElement,
 ) =>
   element.groupIds
     .filter((groupId) => groupId !== appState.editingGroupId)
     .find((groupId) => appState.selectedGroupIds[groupId]);
 
-export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
+export const getSelectedGroupIds = (
+  appState: InteractiveCanvasAppState,
+): GroupId[] =>
   Object.entries(appState.selectedGroupIds)
     .filter(([groupId, isSelected]) => isSelected)
     .map(([groupId, isSelected]) => groupId);
 
-/**
- * When you select an element, you often want to actually select the whole group it's in, unless
- * you're currently editing that group.
- */
-export const selectGroupsForSelectedElements = (
-  appState: AppState,
-  elements: readonly NonDeletedExcalidrawElement[],
-  prevAppState: AppState,
-  /**
-   * supply null in cases where you don't have access to App instance and
-   * you don't care about optimizing selectElements retrieval
-   */
-  app: AppClassProperties | null,
-): AppState => {
-  let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
-
-  const selectedElements = app
-    ? app.scene.getSelectedElements({
-        selectedElementIds: appState.selectedElementIds,
-        // supplying elements explicitly in case we're passed non-state elements
-        elements,
-      })
-    : getSelectedElements(elements, appState);
-
-  if (!selectedElements.length) {
-    return {
-      ...nextAppState,
-      editingGroupId: null,
-      selectedElementIds: makeNextSelectedElementIds(
-        nextAppState.selectedElementIds,
-        prevAppState,
-      ),
-    };
-  }
-
-  for (const selectedElement of selectedElements) {
-    let groupIds = selectedElement.groupIds;
-    if (appState.editingGroupId) {
-      // handle the case where a group is nested within a group
-      const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
-      if (indexOfEditingGroup > -1) {
-        groupIds = groupIds.slice(0, indexOfEditingGroup);
-      }
-    }
-    if (groupIds.length > 0) {
-      const groupId = groupIds[groupIds.length - 1];
-      nextAppState = selectGroup(groupId, nextAppState, elements);
-    }
-  }
-
-  nextAppState.selectedElementIds = makeNextSelectedElementIds(
-    nextAppState.selectedElementIds,
-    prevAppState,
-  );
-
-  return nextAppState;
-};
-
 // given a list of elements, return the the actual group ids that should be selected
 // or used to update the elements
 export const selectGroupsFromGivenElements = (
   elements: readonly NonDeleted<ExcalidrawElement>[],
-  appState: AppState,
+  appState: InteractiveCanvasAppState,
 ) => {
-  let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
+  let nextAppState: InteractiveCanvasAppState = {
+    ...appState,
+    selectedGroupIds: {},
+  };
 
   for (const element of elements) {
     let groupIds = element.groupIds;
@@ -142,7 +235,10 @@ export const selectGroupsFromGivenElements = (
     }
     if (groupIds.length > 0) {
       const groupId = groupIds[groupIds.length - 1];
-      nextAppState = selectGroup(groupId, nextAppState, elements);
+      nextAppState = {
+        ...nextAppState,
+        ...selectGroup(groupId, nextAppState, elements),
+      };
     }
   }
 

+ 2 - 2
src/math.ts

@@ -10,9 +10,9 @@ import {
   ExcalidrawLinearElement,
   NonDeleted,
 } from "./element/types";
-import { getShapeForElement } from "./renderer/renderElement";
 import { getCurvePathOps } from "./element/bounds";
 import { Mutable } from "./utility-types";
+import { ShapeCache } from "./scene/ShapeCache";
 
 export const rotate = (
   x1: number,
@@ -303,7 +303,7 @@ export const getControlPointsForBezierCurve = (
   element: NonDeleted<ExcalidrawLinearElement>,
   endPoint: Point,
 ) => {
-  const shape = getShapeForElement(element as ExcalidrawLinearElement);
+  const shape = ShapeCache.generateElementShape(element);
   if (!shape) {
     return null;
   }

+ 122 - 112
src/renderer/renderElement.ts

@@ -26,7 +26,7 @@ import { Drawable, Options } from "roughjs/bin/core";
 import { RoughSVG } from "roughjs/bin/svg";
 import { RoughGenerator } from "roughjs/bin/generator";
 
-import { RenderConfig } from "../scene/types";
+import { StaticCanvasRenderConfig } from "../scene/types";
 import {
   distance,
   getFontString,
@@ -36,7 +36,13 @@ import {
 } from "../utils";
 import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
 import rough from "roughjs/bin/rough";
-import { AppState, BinaryFiles, Zoom } from "../types";
+import {
+  AppState,
+  StaticCanvasAppState,
+  BinaryFiles,
+  Zoom,
+  InteractiveCanvasAppState,
+} from "../types";
 import { getDefaultAppState } from "../appState";
 import {
   BOUND_TEXT_PADDING,
@@ -61,6 +67,7 @@ import {
 } from "../element/embeddable";
 import { getContainingFrame } from "../frame";
 import { normalizeLink, toValidURL } from "../data/url";
+import { ShapeCache } from "../scene/ShapeCache";
 
 // using a stronger invert (100% vs our regular 93%) and saturate
 // as a temp hack to make images in dark theme look closer to original
@@ -72,17 +79,18 @@ const defaultAppState = getDefaultAppState();
 
 const isPendingImageElement = (
   element: ExcalidrawElement,
-  renderConfig: RenderConfig,
+  renderConfig: StaticCanvasRenderConfig,
 ) =>
   isInitializedImageElement(element) &&
   !renderConfig.imageCache.has(element.fileId);
 
 const shouldResetImageFilter = (
   element: ExcalidrawElement,
-  renderConfig: RenderConfig,
+  renderConfig: StaticCanvasRenderConfig,
+  appState: StaticCanvasAppState,
 ) => {
   return (
-    renderConfig.theme === "dark" &&
+    appState.theme === "dark" &&
     isInitializedImageElement(element) &&
     !isPendingImageElement(element, renderConfig) &&
     renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
@@ -99,9 +107,9 @@ const getCanvasPadding = (element: ExcalidrawElement) =>
 export interface ExcalidrawElementWithCanvas {
   element: ExcalidrawElement | ExcalidrawTextElement;
   canvas: HTMLCanvasElement;
-  theme: RenderConfig["theme"];
+  theme: AppState["theme"];
   scale: number;
-  zoomValue: RenderConfig["zoom"]["value"];
+  zoomValue: AppState["zoom"]["value"];
   canvasOffsetX: number;
   canvasOffsetY: number;
   boundTextElementVersion: number | null;
@@ -165,7 +173,8 @@ const cappedElementCanvasSize = (
 const generateElementCanvas = (
   element: NonDeletedExcalidrawElement,
   zoom: Zoom,
-  renderConfig: RenderConfig,
+  renderConfig: StaticCanvasRenderConfig,
+  appState: StaticCanvasAppState,
 ): ExcalidrawElementWithCanvas => {
   const canvas = document.createElement("canvas");
   const context = canvas.getContext("2d")!;
@@ -205,17 +214,17 @@ const generateElementCanvas = (
   const rc = rough.canvas(canvas);
 
   // in dark theme, revert the image color filter
-  if (shouldResetImageFilter(element, renderConfig)) {
+  if (shouldResetImageFilter(element, renderConfig, appState)) {
     context.filter = IMAGE_INVERT_FILTER;
   }
 
-  drawElementOnCanvas(element, rc, context, renderConfig);
+  drawElementOnCanvas(element, rc, context, renderConfig, appState);
   context.restore();
 
   return {
     element,
     canvas,
-    theme: renderConfig.theme,
+    theme: appState.theme,
     scale,
     zoomValue: zoom.value,
     canvasOffsetX,
@@ -262,11 +271,13 @@ const drawImagePlaceholder = (
     size,
   );
 };
+
 const drawElementOnCanvas = (
   element: NonDeletedExcalidrawElement,
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
-  renderConfig: RenderConfig,
+  renderConfig: StaticCanvasRenderConfig,
+  appState: StaticCanvasAppState,
 ) => {
   context.globalAlpha =
     ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
@@ -277,7 +288,7 @@ const drawElementOnCanvas = (
     case "ellipse": {
       context.lineJoin = "round";
       context.lineCap = "round";
-      rc.draw(getShapeForElement(element)!);
+      rc.draw(ShapeCache.get(element)!);
       break;
     }
     case "arrow":
@@ -285,7 +296,7 @@ const drawElementOnCanvas = (
       context.lineJoin = "round";
       context.lineCap = "round";
 
-      getShapeForElement(element)!.forEach((shape) => {
+      ShapeCache.get(element)!.forEach((shape) => {
         rc.draw(shape);
       });
       break;
@@ -296,7 +307,7 @@ const drawElementOnCanvas = (
       context.fillStyle = element.strokeColor;
 
       const path = getFreeDrawPath2D(element) as Path2D;
-      const fillShape = getShapeForElement(element);
+      const fillShape = ShapeCache.get(element);
 
       if (fillShape) {
         rc.draw(fillShape);
@@ -321,7 +332,7 @@ const drawElementOnCanvas = (
           element.height,
         );
       } else {
-        drawImagePlaceholder(element, context, renderConfig.zoom.value);
+        drawImagePlaceholder(element, context, appState.zoom.value);
       }
       break;
     }
@@ -378,33 +389,6 @@ const elementWithCanvasCache = new WeakMap<
   ExcalidrawElementWithCanvas
 >();
 
-const shapeCache = new WeakMap<ExcalidrawElement, ElementShape>();
-
-type ElementShape = Drawable | Drawable[] | null;
-
-type ElementShapes = {
-  freedraw: Drawable | null;
-  arrow: Drawable[];
-  line: Drawable[];
-  text: null;
-  image: null;
-};
-
-export const getShapeForElement = <T extends ExcalidrawElement>(element: T) =>
-  shapeCache.get(element) as T["type"] extends keyof ElementShapes
-    ? ElementShapes[T["type"]] | undefined
-    : Drawable | null | undefined;
-
-export const setShapeForElement = <T extends ExcalidrawElement>(
-  element: T,
-  shape: T["type"] extends keyof ElementShapes
-    ? ElementShapes[T["type"]]
-    : Drawable,
-) => shapeCache.set(element, shape);
-
-export const invalidateShapeForElement = (element: ExcalidrawElement) =>
-  shapeCache.delete(element);
-
 export const generateRoughOptions = (
   element: ExcalidrawElement,
   continuousPath = false,
@@ -494,16 +478,22 @@ const modifyEmbeddableForRoughOptions = (
  * @param element
  * @param generator
  */
-const generateElementShape = (
+export const generateElementShape = (
   element: NonDeletedExcalidrawElement,
   generator: RoughGenerator,
   isExporting: boolean = false,
-) => {
-  let shape = isExporting ? undefined : shapeCache.get(element);
+): Drawable | Drawable[] | null => {
+  const cachedShape = isExporting ? undefined : ShapeCache.get(element);
+
+  if (cachedShape) {
+    return cachedShape;
+  }
 
   // `null` indicates no rc shape applicable for this element type
   // (= do not generate anything)
-  if (shape === undefined) {
+  if (cachedShape === undefined) {
+    let shape: Drawable | Drawable[] | null = null;
+
     elementWithCanvasCache.delete(element);
 
     switch (element.type) {
@@ -539,7 +529,7 @@ const generateElementShape = (
             ),
           );
         }
-        setShapeForElement(element, shape);
+        ShapeCache.set(element, shape);
 
         break;
       }
@@ -589,7 +579,7 @@ const generateElementShape = (
             generateRoughOptions(element),
           );
         }
-        setShapeForElement(element, shape);
+        ShapeCache.set(element, shape);
 
         break;
       }
@@ -601,7 +591,7 @@ const generateElementShape = (
           element.height,
           generateRoughOptions(element),
         );
-        setShapeForElement(element, shape);
+        ShapeCache.set(element, shape);
 
         break;
       case "line":
@@ -726,7 +716,7 @@ const generateElementShape = (
           }
         }
 
-        setShapeForElement(element, shape);
+        ShapeCache.set(element, shape);
 
         break;
       }
@@ -742,36 +732,39 @@ const generateElementShape = (
         } else {
           shape = null;
         }
-        setShapeForElement(element, shape);
+        ShapeCache.set(element, shape);
         break;
       }
       case "text":
       case "image": {
         // just to ensure we don't regenerate element.canvas on rerenders
-        setShapeForElement(element, null);
+        ShapeCache.set(element, null);
         break;
       }
     }
+    return shape;
   }
+  return null;
 };
 
 const generateElementWithCanvas = (
   element: NonDeletedExcalidrawElement,
-  renderConfig: RenderConfig,
+  renderConfig: StaticCanvasRenderConfig,
+  appState: StaticCanvasAppState,
 ) => {
-  const zoom: Zoom = renderConfig ? renderConfig.zoom : defaultAppState.zoom;
+  const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom;
   const prevElementWithCanvas = elementWithCanvasCache.get(element);
   const shouldRegenerateBecauseZoom =
     prevElementWithCanvas &&
     prevElementWithCanvas.zoomValue !== zoom.value &&
-    !renderConfig?.shouldCacheIgnoreZoom;
+    !appState?.shouldCacheIgnoreZoom;
   const boundTextElementVersion = getBoundTextElement(element)?.version || null;
   const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
 
   if (
     !prevElementWithCanvas ||
     shouldRegenerateBecauseZoom ||
-    prevElementWithCanvas.theme !== renderConfig.theme ||
+    prevElementWithCanvas.theme !== appState.theme ||
     prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
     prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
   ) {
@@ -779,6 +772,7 @@ const generateElementWithCanvas = (
       element,
       zoom,
       renderConfig,
+      appState,
     );
 
     elementWithCanvasCache.set(element, elementWithCanvas);
@@ -790,9 +784,9 @@ const generateElementWithCanvas = (
 
 const drawElementFromCanvas = (
   elementWithCanvas: ExcalidrawElementWithCanvas,
-  rc: RoughCanvas,
   context: CanvasRenderingContext2D,
-  renderConfig: RenderConfig,
+  renderConfig: StaticCanvasRenderConfig,
+  appState: StaticCanvasAppState,
 ) => {
   const element = elementWithCanvas.element;
   const padding = getCanvasPadding(element);
@@ -807,8 +801,8 @@ const drawElementFromCanvas = (
     y2 = Math.ceil(y2);
   }
 
-  const cx = ((x1 + x2) / 2 + renderConfig.scrollX) * window.devicePixelRatio;
-  const cy = ((y1 + y2) / 2 + renderConfig.scrollY) * window.devicePixelRatio;
+  const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
+  const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
 
   context.save();
   context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
@@ -906,9 +900,9 @@ const drawElementFromCanvas = (
 
     context.drawImage(
       elementWithCanvas.canvas!,
-      (x1 + renderConfig.scrollX) * window.devicePixelRatio -
+      (x1 + appState.scrollX) * window.devicePixelRatio -
         (padding * elementWithCanvas.scale) / elementWithCanvas.scale,
-      (y1 + renderConfig.scrollY) * window.devicePixelRatio -
+      (y1 + appState.scrollY) * window.devicePixelRatio -
         (padding * elementWithCanvas.scale) / elementWithCanvas.scale,
       elementWithCanvas.canvas!.width / elementWithCanvas.scale,
       elementWithCanvas.canvas!.height / elementWithCanvas.scale,
@@ -926,8 +920,8 @@ const drawElementFromCanvas = (
       context.strokeStyle = "#c92a2a";
       context.lineWidth = 3;
       context.strokeRect(
-        (coords.x + renderConfig.scrollX) * window.devicePixelRatio,
-        (coords.y + renderConfig.scrollY) * window.devicePixelRatio,
+        (coords.x + appState.scrollX) * window.devicePixelRatio,
+        (coords.y + appState.scrollY) * window.devicePixelRatio,
         getBoundTextMaxWidth(element) * window.devicePixelRatio,
         getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
       );
@@ -938,40 +932,38 @@ const drawElementFromCanvas = (
   // Clear the nested element we appended to the DOM
 };
 
+export const renderSelectionElement = (
+  element: NonDeletedExcalidrawElement,
+  context: CanvasRenderingContext2D,
+  appState: InteractiveCanvasAppState,
+) => {
+  context.save();
+  context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
+  context.fillStyle = "rgba(0, 0, 200, 0.04)";
+
+  // render from 0.5px offset  to get 1px wide line
+  // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
+  // TODO can be be improved by offseting to the negative when user selects
+  // from right to left
+  const offset = 0.5 / appState.zoom.value;
+
+  context.fillRect(offset, offset, element.width, element.height);
+  context.lineWidth = 1 / appState.zoom.value;
+  context.strokeStyle = " rgb(105, 101, 219)";
+  context.strokeRect(offset, offset, element.width, element.height);
+
+  context.restore();
+};
+
 export const renderElement = (
   element: NonDeletedExcalidrawElement,
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
-  renderConfig: RenderConfig,
-  appState: AppState,
+  renderConfig: StaticCanvasRenderConfig,
+  appState: StaticCanvasAppState,
 ) => {
   const generator = rc.generator;
   switch (element.type) {
-    case "selection": {
-      // do not render selection when exporting
-      if (!renderConfig.isExporting) {
-        context.save();
-        context.translate(
-          element.x + renderConfig.scrollX,
-          element.y + renderConfig.scrollY,
-        );
-        context.fillStyle = "rgba(0, 0, 200, 0.04)";
-
-        // render from 0.5px offset  to get 1px wide line
-        // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
-        // TODO can be be improved by offseting to the negative when user selects
-        // from right to left
-        const offset = 0.5 / renderConfig.zoom.value;
-
-        context.fillRect(offset, offset, element.width, element.height);
-        context.lineWidth = 1 / renderConfig.zoom.value;
-        context.strokeStyle = " rgb(105, 101, 219)";
-        context.strokeRect(offset, offset, element.width, element.height);
-
-        context.restore();
-      }
-      break;
-    }
     case "frame": {
       if (
         !renderConfig.isExporting &&
@@ -980,12 +972,12 @@ export const renderElement = (
       ) {
         context.save();
         context.translate(
-          element.x + renderConfig.scrollX,
-          element.y + renderConfig.scrollY,
+          element.x + appState.scrollX,
+          element.y + appState.scrollY,
         );
         context.fillStyle = "rgba(0, 0, 200, 0.04)";
 
-        context.lineWidth = 2 / renderConfig.zoom.value;
+        context.lineWidth = 2 / appState.zoom.value;
         context.strokeStyle = FRAME_STYLE.strokeColor;
 
         if (FRAME_STYLE.radius && context.roundRect) {
@@ -995,7 +987,7 @@ export const renderElement = (
             0,
             element.width,
             element.height,
-            FRAME_STYLE.radius / renderConfig.zoom.value,
+            FRAME_STYLE.radius / appState.zoom.value,
           );
           context.stroke();
           context.closePath();
@@ -1012,22 +1004,28 @@ export const renderElement = (
 
       if (renderConfig.isExporting) {
         const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-        const cx = (x1 + x2) / 2 + renderConfig.scrollX;
-        const cy = (y1 + y2) / 2 + renderConfig.scrollY;
+        const cx = (x1 + x2) / 2 + appState.scrollX;
+        const cy = (y1 + y2) / 2 + appState.scrollY;
         const shiftX = (x2 - x1) / 2 - (element.x - x1);
         const shiftY = (y2 - y1) / 2 - (element.y - y1);
         context.save();
         context.translate(cx, cy);
         context.rotate(element.angle);
         context.translate(-shiftX, -shiftY);
-        drawElementOnCanvas(element, rc, context, renderConfig);
+        drawElementOnCanvas(element, rc, context, renderConfig, appState);
         context.restore();
       } else {
         const elementWithCanvas = generateElementWithCanvas(
           element,
           renderConfig,
+          appState,
+        );
+        drawElementFromCanvas(
+          elementWithCanvas,
+          context,
+          renderConfig,
+          appState,
         );
-        drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
       }
 
       break;
@@ -1043,8 +1041,8 @@ export const renderElement = (
       generateElementShape(element, generator, renderConfig.isExporting);
       if (renderConfig.isExporting) {
         const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-        const cx = (x1 + x2) / 2 + renderConfig.scrollX;
-        const cy = (y1 + y2) / 2 + renderConfig.scrollY;
+        const cx = (x1 + x2) / 2 + appState.scrollX;
+        const cy = (y1 + y2) / 2 + appState.scrollY;
         let shiftX = (x2 - x1) / 2 - (element.x - x1);
         let shiftY = (y2 - y1) / 2 - (element.y - y1);
         if (isTextElement(element)) {
@@ -1062,7 +1060,7 @@ export const renderElement = (
         context.save();
         context.translate(cx, cy);
 
-        if (shouldResetImageFilter(element, renderConfig)) {
+        if (shouldResetImageFilter(element, renderConfig, appState)) {
           context.filter = "none";
         }
         const boundTextElement = getBoundTextElement(element);
@@ -1096,7 +1094,13 @@ export const renderElement = (
 
           tempCanvasContext.translate(-shiftX, -shiftY);
 
-          drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
+          drawElementOnCanvas(
+            element,
+            tempRc,
+            tempCanvasContext,
+            renderConfig,
+            appState,
+          );
 
           tempCanvasContext.translate(shiftX, shiftY);
 
@@ -1133,7 +1137,7 @@ export const renderElement = (
           }
 
           context.translate(-shiftX, -shiftY);
-          drawElementOnCanvas(element, rc, context, renderConfig);
+          drawElementOnCanvas(element, rc, context, renderConfig, appState);
         }
 
         context.restore();
@@ -1143,6 +1147,7 @@ export const renderElement = (
         const elementWithCanvas = generateElementWithCanvas(
           element,
           renderConfig,
+          appState,
         );
 
         const currentImageSmoothingStatus = context.imageSmoothingEnabled;
@@ -1150,7 +1155,7 @@ export const renderElement = (
         if (
           // do not disable smoothing during zoom as blurry shapes look better
           // on low resolution (while still zooming in) than sharp ones
-          !renderConfig?.shouldCacheIgnoreZoom &&
+          !appState?.shouldCacheIgnoreZoom &&
           // angle is 0 -> always disable smoothing
           (!element.angle ||
             // or check if angle is a right angle in which case we can still
@@ -1167,7 +1172,12 @@ export const renderElement = (
           context.imageSmoothingEnabled = false;
         }
 
-        drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
+        drawElementFromCanvas(
+          elementWithCanvas,
+          context,
+          renderConfig,
+          appState,
+        );
 
         // reset
         context.imageSmoothingEnabled = currentImageSmoothingStatus;
@@ -1273,7 +1283,7 @@ export const renderElementToSvg = (
       generateElementShape(element, generator);
       const node = roughSVGDrawWithPrecision(
         rsvg,
-        getShapeForElement(element)!,
+        ShapeCache.get(element)!,
         MAX_DECIMALS_FOR_SVG_EXPORT,
       );
       if (opacity !== 1) {
@@ -1303,7 +1313,7 @@ export const renderElementToSvg = (
       generateElementShape(element, generator, true);
       const node = roughSVGDrawWithPrecision(
         rsvg,
-        getShapeForElement(element)!,
+        ShapeCache.get(element)!,
         MAX_DECIMALS_FOR_SVG_EXPORT,
       );
       const opacity = element.opacity / 100;
@@ -1337,7 +1347,7 @@ export const renderElementToSvg = (
       // render embeddable element + iframe
       const embeddableNode = roughSVGDrawWithPrecision(
         rsvg,
-        getShapeForElement(element)!,
+        ShapeCache.get(element)!,
         MAX_DECIMALS_FOR_SVG_EXPORT,
       );
       embeddableNode.setAttribute("stroke-linecap", "round");
@@ -1450,7 +1460,7 @@ export const renderElementToSvg = (
       }
       group.setAttribute("stroke-linecap", "round");
 
-      getShapeForElement(element)!.forEach((shape) => {
+      ShapeCache.get(element)!.forEach((shape) => {
         const node = roughSVGDrawWithPrecision(
           rsvg,
           shape,
@@ -1493,7 +1503,7 @@ export const renderElementToSvg = (
     case "freedraw": {
       generateElementShape(element, generator);
       generateFreeDrawShape(element);
-      const shape = getShapeForElement(element);
+      const shape = ShapeCache.get(element);
       const node = shape
         ? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
         : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");

File diff suppressed because it is too large
+ 641 - 571
src/renderer/renderScene.ts


+ 2 - 2
src/scene/Fonts.ts

@@ -2,9 +2,9 @@ import { isTextElement, refreshTextDimensions } from "../element";
 import { newElementWith } from "../element/mutateElement";
 import { isBoundToContainer } from "../element/typeChecks";
 import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
-import { invalidateShapeForElement } from "../renderer/renderElement";
 import { getFontString } from "../utils";
 import type Scene from "./Scene";
+import { ShapeCache } from "./ShapeCache";
 
 export class Fonts {
   private scene: Scene;
@@ -54,7 +54,7 @@ export class Fonts {
 
     this.scene.mapElements((element) => {
       if (isTextElement(element) && !isBoundToContainer(element)) {
-        invalidateShapeForElement(element);
+        ShapeCache.delete(element);
         didUpdate = true;
         return newElementWith(element, {
           ...refreshTextDimensions(element),

+ 131 - 0
src/scene/Renderer.ts

@@ -0,0 +1,131 @@
+import { isElementInViewport } from "../element/sizeHelpers";
+import { isImageElement } from "../element/typeChecks";
+import { NonDeletedExcalidrawElement } from "../element/types";
+import { cancelRender } from "../renderer/renderScene";
+import { AppState } from "../types";
+import { memoize } from "../utils";
+import Scene from "./Scene";
+
+export class Renderer {
+  private scene: Scene;
+
+  constructor(scene: Scene) {
+    this.scene = scene;
+  }
+
+  public getRenderableElements = (() => {
+    const getVisibleCanvasElements = ({
+      elements,
+      zoom,
+      offsetLeft,
+      offsetTop,
+      scrollX,
+      scrollY,
+      height,
+      width,
+    }: {
+      elements: readonly NonDeletedExcalidrawElement[];
+      zoom: AppState["zoom"];
+      offsetLeft: AppState["offsetLeft"];
+      offsetTop: AppState["offsetTop"];
+      scrollX: AppState["scrollX"];
+      scrollY: AppState["scrollY"];
+      height: AppState["height"];
+      width: AppState["width"];
+    }): readonly NonDeletedExcalidrawElement[] => {
+      return elements.filter((element) =>
+        isElementInViewport(element, width, height, {
+          zoom,
+          offsetLeft,
+          offsetTop,
+          scrollX,
+          scrollY,
+        }),
+      );
+    };
+
+    const getCanvasElements = ({
+      editingElement,
+      elements,
+      pendingImageElementId,
+    }: {
+      elements: readonly NonDeletedExcalidrawElement[];
+      editingElement: AppState["editingElement"];
+      pendingImageElementId: AppState["pendingImageElementId"];
+    }) => {
+      return elements.filter((element) => {
+        if (isImageElement(element)) {
+          if (
+            // => not placed on canvas yet (but in elements array)
+            pendingImageElementId === element.id
+          ) {
+            return false;
+          }
+        }
+        // we don't want to render text element that's being currently edited
+        // (it's rendered on remote only)
+        return (
+          !editingElement ||
+          editingElement.type !== "text" ||
+          element.id !== editingElement.id
+        );
+      });
+    };
+
+    return memoize(
+      ({
+        zoom,
+        offsetLeft,
+        offsetTop,
+        scrollX,
+        scrollY,
+        height,
+        width,
+        editingElement,
+        pendingImageElementId,
+        // unused but serves we cache on it to invalidate elements if they
+        // get mutated
+        versionNonce: _versionNonce,
+      }: {
+        zoom: AppState["zoom"];
+        offsetLeft: AppState["offsetLeft"];
+        offsetTop: AppState["offsetTop"];
+        scrollX: AppState["scrollX"];
+        scrollY: AppState["scrollY"];
+        height: AppState["height"];
+        width: AppState["width"];
+        editingElement: AppState["editingElement"];
+        pendingImageElementId: AppState["pendingImageElementId"];
+        versionNonce: ReturnType<InstanceType<typeof Scene>["getVersionNonce"]>;
+      }) => {
+        const elements = this.scene.getNonDeletedElements();
+
+        const canvasElements = getCanvasElements({
+          elements,
+          editingElement,
+          pendingImageElementId,
+        });
+
+        const visibleElements = getVisibleCanvasElements({
+          elements: canvasElements,
+          zoom,
+          offsetLeft,
+          offsetTop,
+          scrollX,
+          scrollY,
+          height,
+          width,
+        });
+
+        return { canvasElements, visibleElements };
+      },
+    );
+  })();
+
+  // NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
+  // safe to break TS contract here (for upstream cases)
+  public destroy() {
+    cancelRender();
+    this.getRenderableElements.clear();
+  }
+}

+ 8 - 0
src/scene/Scene.ts

@@ -14,6 +14,7 @@ import { isFrameElement } from "../element/typeChecks";
 import { getSelectedElements } from "./selection";
 import { AppState } from "../types";
 import { Assert, SameType } from "../utility-types";
+import { randomInteger } from "../random";
 
 type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
 type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -105,6 +106,7 @@ class Scene {
     elements: null,
     cache: new Map(),
   };
+  private versionNonce: number | undefined;
 
   getElementsIncludingDeleted() {
     return this.elements;
@@ -172,6 +174,10 @@ class Scene {
     return (this.elementsMap.get(id) as T | undefined) || null;
   }
 
+  getVersionNonce() {
+    return this.versionNonce;
+  }
+
   getNonDeletedElement(
     id: ExcalidrawElement["id"],
   ): NonDeleted<ExcalidrawElement> | null {
@@ -230,6 +236,8 @@ class Scene {
   }
 
   informMutation() {
+    this.versionNonce = randomInteger();
+
     for (const callback of Array.from(this.callbacks)) {
       callback();
     }

+ 61 - 0
src/scene/ShapeCache.ts

@@ -0,0 +1,61 @@
+import { Drawable } from "roughjs/bin/core";
+import { RoughGenerator } from "roughjs/bin/generator";
+import { ExcalidrawElement } from "../element/types";
+import { generateElementShape } from "../renderer/renderElement";
+
+type ElementShape = Drawable | Drawable[] | null;
+
+type ElementShapes = {
+  freedraw: Drawable | null;
+  arrow: Drawable[];
+  line: Drawable[];
+  text: null;
+  image: null;
+};
+
+export class ShapeCache {
+  private static rg = new RoughGenerator();
+  private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
+
+  public static get = <T extends ExcalidrawElement>(element: T) => {
+    return ShapeCache.cache.get(
+      element,
+    ) as T["type"] extends keyof ElementShapes
+      ? ElementShapes[T["type"]] | undefined
+      : Drawable | null | undefined;
+  };
+
+  public static set = <T extends ExcalidrawElement>(
+    element: T,
+    shape: T["type"] extends keyof ElementShapes
+      ? ElementShapes[T["type"]]
+      : Drawable,
+  ) => ShapeCache.cache.set(element, shape);
+
+  public static delete = (element: ExcalidrawElement) =>
+    ShapeCache.cache.delete(element);
+
+  public static destroy = () => {
+    ShapeCache.cache = new WeakMap();
+  };
+
+  /**
+   * Generates & caches shape for element if not already cached, otherwise
+   * return cached shape.
+   */
+  public static generateElementShape = <T extends ExcalidrawElement>(
+    element: T,
+  ) => {
+    const shape = generateElementShape(
+      element,
+      ShapeCache.rg,
+      /* so it prefers cache */ false,
+    ) as T["type"] extends keyof ElementShapes
+      ? ElementShapes[T["type"]]
+      : Drawable | null;
+
+    ShapeCache.cache.set(element, shape);
+
+    return shape;
+  };
+}

+ 9 - 12
src/scene/export.ts

@@ -1,7 +1,7 @@
 import rough from "roughjs/bin/rough";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
-import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
+import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
 import { distance, isOnlyExportingSingleFrame } from "../utils";
 import { AppState, BinaryFiles } from "../types";
 import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
@@ -54,26 +54,23 @@ export const exportToCanvas = async (
 
   const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
 
-  renderScene({
+  renderStaticScene({
+    canvas,
+    rc: rough.canvas(canvas),
     elements,
-    appState,
+    visibleElements: elements,
     scale,
-    rc: rough.canvas(canvas),
-    canvas,
-    renderConfig: {
+    appState: {
+      ...appState,
       viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
       scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
       scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
       zoom: defaultAppState.zoom,
-      remotePointerViewportCoords: {},
-      remoteSelectedElementIds: {},
       shouldCacheIgnoreZoom: false,
-      remotePointerUsernames: {},
-      remotePointerUserStates: {},
       theme: appState.exportWithDarkMode ? "dark" : "light",
+    },
+    renderConfig: {
       imageCache,
-      renderScrollbars: false,
-      renderSelection: false,
       renderGrid: false,
       isExporting: true,
     },

+ 2 - 7
src/scene/scroll.ts

@@ -11,11 +11,7 @@ import {
   viewportCoordsToSceneCoords,
 } from "../utils";
 
-const isOutsideViewPort = (
-  appState: AppState,
-  canvas: HTMLCanvasElement | null,
-  cords: Array<number>,
-) => {
+const isOutsideViewPort = (appState: AppState, cords: Array<number>) => {
   const [x1, y1, x2, y2] = cords;
   const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords(
     { sceneX: x1, sceneY: y1 },
@@ -49,7 +45,6 @@ export const centerScrollOn = ({
 export const calculateScrollCenter = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
-  canvas: HTMLCanvasElement | null,
 ): { scrollX: number; scrollY: number } => {
   elements = getVisibleElements(elements);
 
@@ -61,7 +56,7 @@ export const calculateScrollCenter = (
   }
   let [x1, y1, x2, y2] = getCommonBounds(elements);
 
-  if (isOutsideViewPort(appState, canvas, [x1, y1, x2, y2])) {
+  if (isOutsideViewPort(appState, [x1, y1, x2, y2])) {
     [x1, y1, x2, y2] = getClosestElementBounds(
       elements,
       viewportCoordsToSceneCoords(

+ 8 - 14
src/scene/scrollbars.ts

@@ -1,6 +1,6 @@
 import { ExcalidrawElement } from "../element/types";
 import { getCommonBounds } from "../element";
-import { Zoom } from "../types";
+import { InteractiveCanvasAppState } from "../types";
 import { ScrollBars } from "./types";
 import { getGlobalCSSVariable } from "../utils";
 import { getLanguage } from "../i18n";
@@ -13,15 +13,7 @@ export const getScrollBars = (
   elements: readonly ExcalidrawElement[],
   viewportWidth: number,
   viewportHeight: number,
-  {
-    scrollX,
-    scrollY,
-    zoom,
-  }: {
-    scrollX: number;
-    scrollY: number;
-    zoom: Zoom;
-  },
+  appState: InteractiveCanvasAppState,
 ): ScrollBars => {
   if (elements.length === 0) {
     return {
@@ -34,8 +26,8 @@ export const getScrollBars = (
     getCommonBounds(elements);
 
   // Apply zoom
-  const viewportWidthWithZoom = viewportWidth / zoom.value;
-  const viewportHeightWithZoom = viewportHeight / zoom.value;
+  const viewportWidthWithZoom = viewportWidth / appState.zoom.value;
+  const viewportHeightWithZoom = viewportHeight / appState.zoom.value;
 
   const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
   const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
@@ -50,8 +42,10 @@ export const getScrollBars = (
   const isRTL = getLanguage().rtl;
 
   // The viewport is the rectangle currently visible for the user
-  const viewportMinX = -scrollX + viewportWidthDiff / 2 + safeArea.left;
-  const viewportMinY = -scrollY + viewportHeightDiff / 2 + safeArea.top;
+  const viewportMinX =
+    -appState.scrollX + viewportWidthDiff / 2 + safeArea.left;
+  const viewportMinY =
+    -appState.scrollY + viewportHeightDiff / 2 + safeArea.top;
   const viewportMaxX = viewportMinX + viewportWidthWithZoom - safeArea.right;
   const viewportMaxY = viewportMinY + viewportHeightWithZoom - safeArea.bottom;
 

+ 2 - 2
src/scene/selection.ts

@@ -3,7 +3,7 @@ import {
   NonDeletedExcalidrawElement,
 } from "../element/types";
 import { getElementAbsoluteCoords, getElementBounds } from "../element";
-import { AppState } from "../types";
+import { AppState, InteractiveCanvasAppState } from "../types";
 import { isBoundToContainer } from "../element/typeChecks";
 import {
   elementOverlapsWithFrame,
@@ -146,7 +146,7 @@ export const getCommonAttributeOfSelectedElements = <T>(
 
 export const getSelectedElements = (
   elements: readonly NonDeletedExcalidrawElement[],
-  appState: Pick<AppState, "selectedElementIds">,
+  appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
   opts?: {
     includeBoundTextElement?: boolean;
     includeElementsInFrames?: boolean;

+ 51 - 21
src/scene/types.ts

@@ -1,33 +1,63 @@
-import { ExcalidrawTextElement } from "../element/types";
-import { AppClassProperties, AppState } from "../types";
+import { RoughCanvas } from "roughjs/bin/canvas";
+import {
+  ExcalidrawTextElement,
+  NonDeletedExcalidrawElement,
+} from "../element/types";
+import {
+  AppClassProperties,
+  InteractiveCanvasAppState,
+  StaticCanvasAppState,
+} from "../types";
 
-export type RenderConfig = {
-  // AppState values
+export type StaticCanvasRenderConfig = {
+  // extra options passed to the renderer
   // ---------------------------------------------------------------------------
-  scrollX: AppState["scrollX"];
-  scrollY: AppState["scrollY"];
-  /** null indicates transparent bg */
-  viewBackgroundColor: AppState["viewBackgroundColor"] | null;
-  zoom: AppState["zoom"];
-  shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
-  theme: AppState["theme"];
+  imageCache: AppClassProperties["imageCache"];
+  renderGrid: boolean;
+  /** when exporting the behavior is slightly different (e.g. we can't use
+   CSS filters), and we disable render optimizations for best output */
+  isExporting: boolean;
+};
+
+export type InteractiveCanvasRenderConfig = {
   // collab-related state
   // ---------------------------------------------------------------------------
-  remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
-  remotePointerButton?: { [id: string]: string | undefined };
   remoteSelectedElementIds: { [elementId: string]: string[] };
-  remotePointerUsernames: { [id: string]: string };
+  remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
   remotePointerUserStates: { [id: string]: string };
+  remotePointerUsernames: { [id: string]: string };
+  remotePointerButton?: { [id: string]: string | undefined };
+  selectionColor?: string;
   // extra options passed to the renderer
   // ---------------------------------------------------------------------------
-  imageCache: AppClassProperties["imageCache"];
   renderScrollbars?: boolean;
-  renderSelection?: boolean;
-  renderGrid?: boolean;
-  /** when exporting the behavior is slightly different (e.g. we can't use
-    CSS filters), and we disable render optimizations for best output */
-  isExporting: boolean;
-  selectionColor?: string;
+};
+
+export type RenderInteractiveSceneCallback = {
+  atLeastOneVisibleElement: boolean;
+  elements: readonly NonDeletedExcalidrawElement[];
+  scrollBars?: ScrollBars;
+};
+
+export type StaticSceneRenderConfig = {
+  canvas: HTMLCanvasElement;
+  rc: RoughCanvas;
+  elements: readonly NonDeletedExcalidrawElement[];
+  visibleElements: readonly NonDeletedExcalidrawElement[];
+  scale: number;
+  appState: StaticCanvasAppState;
+  renderConfig: StaticCanvasRenderConfig;
+};
+
+export type InteractiveSceneRenderConfig = {
+  canvas: HTMLCanvasElement | null;
+  elements: readonly NonDeletedExcalidrawElement[];
+  visibleElements: readonly NonDeletedExcalidrawElement[];
+  selectedElements: readonly NonDeletedExcalidrawElement[];
+  scale: number;
+  appState: InteractiveCanvasAppState;
+  renderConfig: InteractiveCanvasRenderConfig;
+  callback: (data: RenderInteractiveSceneCallback) => void;
 };
 
 export type SceneScroll = {

File diff suppressed because it is too large
+ 118 - 118
src/tests/__snapshots__/contextmenu.test.tsx.snap


+ 10 - 10
src/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -33,7 +33,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "startArrowhead": null,
   "startBinding": null,
   "strokeColor": "#1e1e1e",
@@ -42,7 +42,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "type": "arrow",
   "updated": 1,
   "version": 3,
-  "versionNonce": 449462985,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -69,14 +69,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "diamond",
   "updated": 1,
   "version": 2,
-  "versionNonce": 1278240551,
+  "versionNonce": 453191,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -103,14 +103,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "ellipse",
   "updated": 1,
   "version": 2,
-  "versionNonce": 1278240551,
+  "versionNonce": 453191,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -148,7 +148,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "startArrowhead": null,
   "startBinding": null,
   "strokeColor": "#1e1e1e",
@@ -157,7 +157,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "type": "line",
   "updated": 1,
   "version": 3,
-  "versionNonce": 449462985,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -184,14 +184,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "roundness": {
     "type": 3,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 2,
-  "versionNonce": 1278240551,
+  "versionNonce": 453191,
   "width": 30,
   "x": 30,
   "y": 20,

+ 1 - 1
src/tests/__snapshots__/linearElementEditor.test.tsx.snap

@@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
   class="excalidraw-wysiwyg"
   data-type="wysiwyg"
   dir="auto"
-  style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
+  style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
   tabindex="0"
   wrap="off"
 />

+ 12 - 12
src/tests/__snapshots__/move.test.tsx.snap

@@ -18,14 +18,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 1`] = `
   "roundness": {
     "type": 3,
   },
-  "seed": 401146281,
+  "seed": 1014066025,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 2019559783,
+  "versionNonce": 238820263,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -50,14 +50,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 2`] = `
   "roundness": {
     "type": 3,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 1150084233,
+  "versionNonce": 1604849351,
   "width": 30,
   "x": -10,
   "y": 60,
@@ -82,14 +82,14 @@ exports[`move element > rectangle 1`] = `
   "roundness": {
     "type": 3,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 3,
-  "versionNonce": 453191,
+  "versionNonce": 1150084233,
   "width": 30,
   "x": 0,
   "y": 40,
@@ -119,14 +119,14 @@ exports[`move element > rectangles with binding arrow 1`] = `
   "roundness": {
     "type": 3,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 3,
-  "versionNonce": 1014066025,
+  "versionNonce": 81784553,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -156,14 +156,14 @@ exports[`move element > rectangles with binding arrow 2`] = `
   "roundness": {
     "type": 3,
   },
-  "seed": 449462985,
+  "seed": 2019559783,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 6,
-  "versionNonce": 1723083209,
+  "versionNonce": 927333447,
   "width": 300,
   "x": 201,
   "y": 2,
@@ -205,7 +205,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 401146281,
+  "seed": 238820263,
   "startArrowhead": null,
   "startBinding": {
     "elementId": "id0",
@@ -218,7 +218,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
   "type": "line",
   "updated": 1,
   "version": 11,
-  "versionNonce": 1006504105,
+  "versionNonce": 1051383431,
   "width": 81,
   "x": 110,
   "y": 49.981789081137734,

+ 4 - 4
src/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -38,7 +38,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "startArrowhead": null,
   "startBinding": null,
   "strokeColor": "#1e1e1e",
@@ -47,7 +47,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
   "type": "arrow",
   "updated": 1,
   "version": 7,
-  "versionNonce": 1150084233,
+  "versionNonce": 1505387817,
   "width": 70,
   "x": 30,
   "y": 30,
@@ -92,7 +92,7 @@ exports[`multi point mode in linear elements > line 1`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "startArrowhead": null,
   "startBinding": null,
   "strokeColor": "#1e1e1e",
@@ -101,7 +101,7 @@ exports[`multi point mode in linear elements > line 1`] = `
   "type": "line",
   "updated": 1,
   "version": 7,
-  "versionNonce": 1150084233,
+  "versionNonce": 1505387817,
   "width": 70,
   "x": 30,
   "y": 30,

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


+ 10 - 10
src/tests/__snapshots__/selection.test.tsx.snap

@@ -31,7 +31,7 @@ exports[`select single element on the scene > arrow 1`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "startArrowhead": null,
   "startBinding": null,
   "strokeColor": "#1e1e1e",
@@ -40,7 +40,7 @@ exports[`select single element on the scene > arrow 1`] = `
   "type": "arrow",
   "updated": 1,
   "version": 3,
-  "versionNonce": 449462985,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 10,
   "y": 10,
@@ -78,7 +78,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "startArrowhead": null,
   "startBinding": null,
   "strokeColor": "#1e1e1e",
@@ -87,7 +87,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
   "type": "line",
   "updated": 1,
   "version": 3,
-  "versionNonce": 449462985,
+  "versionNonce": 401146281,
   "width": 30,
   "x": 10,
   "y": 10,
@@ -112,14 +112,14 @@ exports[`select single element on the scene > diamond 1`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "diamond",
   "updated": 1,
   "version": 2,
-  "versionNonce": 1278240551,
+  "versionNonce": 453191,
   "width": 30,
   "x": 10,
   "y": 10,
@@ -144,14 +144,14 @@ exports[`select single element on the scene > ellipse 1`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "ellipse",
   "updated": 1,
   "version": 2,
-  "versionNonce": 1278240551,
+  "versionNonce": 453191,
   "width": 30,
   "x": 10,
   "y": 10,
@@ -176,14 +176,14 @@ exports[`select single element on the scene > rectangle 1`] = `
   "roundness": {
     "type": 3,
   },
-  "seed": 337897,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 2,
-  "versionNonce": 1278240551,
+  "versionNonce": 453191,
   "width": 30,
   "x": 10,
   "y": 10,

+ 22 - 22
src/tests/contextmenu.test.tsx

@@ -24,7 +24,7 @@ import { LibraryItem } from "../types";
 import { vi } from "vitest";
 
 const checkpoint = (name: string) => {
-  expect(renderScene.mock.calls.length).toMatchSnapshot(
+  expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
     `[${name}] number of renders`,
   );
   expect(h.state).toMatchSnapshot(`[${name}] appState`);
@@ -40,10 +40,10 @@ const mouse = new Pointer("mouse");
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
 beforeEach(() => {
   localStorage.clear();
-  renderScene.mockClear();
+  renderStaticScene.mockClear();
   reseed(7);
 });
 
@@ -52,7 +52,7 @@ const { h } = window;
 describe("contextMenu element", () => {
   beforeEach(async () => {
     localStorage.clear();
-    renderScene.mockClear();
+    renderStaticScene.mockClear();
     reseed(7);
     setDateTimeForTests("201933152653");
 
@@ -75,7 +75,7 @@ describe("contextMenu element", () => {
   });
 
   it("shows context menu for canvas", () => {
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -105,7 +105,7 @@ describe("contextMenu element", () => {
     mouse.down(10, 10);
     mouse.up(20, 20);
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -159,7 +159,7 @@ describe("contextMenu element", () => {
     API.setSelectedElements([rect1]);
 
     // lower z-index
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 100,
       clientY: 100,
@@ -169,7 +169,7 @@ describe("contextMenu element", () => {
 
     // higher z-index
     API.setSelectedElements([rect2]);
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 100,
       clientY: 100,
@@ -193,7 +193,7 @@ describe("contextMenu element", () => {
       mouse.click(20, 0);
     });
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -246,7 +246,7 @@ describe("contextMenu element", () => {
       Keyboard.keyPress(KEYS.G);
     });
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -285,7 +285,7 @@ describe("contextMenu element", () => {
     mouse.down(10, 10);
     mouse.up(20, 20);
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -333,7 +333,7 @@ describe("contextMenu element", () => {
     mouse.reset();
 
     // Copy styles of second rectangle
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 40,
       clientY: 40,
@@ -346,7 +346,7 @@ describe("contextMenu element", () => {
 
     mouse.reset();
     // Paste styles to first rectangle
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 10,
       clientY: 10,
@@ -370,7 +370,7 @@ describe("contextMenu element", () => {
     mouse.down(10, 10);
     mouse.up(20, 20);
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -386,7 +386,7 @@ describe("contextMenu element", () => {
     mouse.down(10, 10);
     mouse.up(20, 20);
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -407,7 +407,7 @@ describe("contextMenu element", () => {
     mouse.down(10, 10);
     mouse.up(20, 20);
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -430,7 +430,7 @@ describe("contextMenu element", () => {
     mouse.up(20, 20);
 
     mouse.reset();
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 40,
       clientY: 40,
@@ -452,7 +452,7 @@ describe("contextMenu element", () => {
     mouse.up(20, 20);
 
     mouse.reset();
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 10,
       clientY: 10,
@@ -474,7 +474,7 @@ describe("contextMenu element", () => {
     mouse.up(20, 20);
 
     mouse.reset();
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 40,
       clientY: 40,
@@ -495,7 +495,7 @@ describe("contextMenu element", () => {
     mouse.up(20, 20);
 
     mouse.reset();
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 10,
       clientY: 10,
@@ -520,7 +520,7 @@ describe("contextMenu element", () => {
       mouse.click(10, 10);
     });
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,
@@ -550,7 +550,7 @@ describe("contextMenu element", () => {
       Keyboard.keyPress(KEYS.G);
     });
 
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: 1,
       clientY: 1,

+ 36 - 22
src/tests/dragCreate.test.tsx

@@ -15,10 +15,13 @@ import { vi } from "vitest";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
+
 beforeEach(() => {
   localStorage.clear();
-  renderScene.mockClear();
+  renderInteractiveScene.mockClear();
+  renderStaticScene.mockClear();
   reseed(7);
 });
 
@@ -32,7 +35,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("rectangle");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -43,7 +46,8 @@ describe("Test dragCreate", () => {
       // finish (position does not matter)
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(9);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
 
       expect(h.elements.length).toEqual(1);
@@ -63,7 +67,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("ellipse");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -74,7 +78,9 @@ describe("Test dragCreate", () => {
       // finish (position does not matter)
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(9);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
+
       expect(h.state.selectionElement).toBeNull();
 
       expect(h.elements.length).toEqual(1);
@@ -94,7 +100,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("diamond");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -105,7 +111,8 @@ describe("Test dragCreate", () => {
       // finish (position does not matter)
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(9);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
 
       expect(h.elements.length).toEqual(1);
@@ -125,7 +132,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("arrow");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -136,7 +143,8 @@ describe("Test dragCreate", () => {
       // finish (position does not matter)
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(9);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
 
       expect(h.elements.length).toEqual(1);
@@ -160,7 +168,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("line");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -171,7 +179,8 @@ describe("Test dragCreate", () => {
       // finish (position does not matter)
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(9);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
 
       expect(h.elements.length).toEqual(1);
@@ -203,7 +212,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("rectangle");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -211,7 +220,8 @@ describe("Test dragCreate", () => {
       // finish (position does not matter)
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(7);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+      expect(renderStaticScene).toHaveBeenCalledTimes(5);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(0);
     });
@@ -222,7 +232,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("ellipse");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -230,7 +240,8 @@ describe("Test dragCreate", () => {
       // finish (position does not matter)
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(7);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+      expect(renderStaticScene).toHaveBeenCalledTimes(5);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(0);
     });
@@ -241,7 +252,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("diamond");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -249,7 +260,8 @@ describe("Test dragCreate", () => {
       // finish (position does not matter)
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(7);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+      expect(renderStaticScene).toHaveBeenCalledTimes(5);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(0);
     });
@@ -260,7 +272,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("arrow");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -273,7 +285,8 @@ describe("Test dragCreate", () => {
         key: KEYS.ENTER,
       });
 
-      expect(renderScene).toHaveBeenCalledTimes(8);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(0);
     });
@@ -284,7 +297,7 @@ describe("Test dragCreate", () => {
       const tool = getByToolName("line");
       fireEvent.click(tool);
 
-      const canvas = container.querySelector("canvas")!;
+      const canvas = container.querySelector("canvas.interactive")!;
 
       // start from (30, 20)
       fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@@ -297,7 +310,8 @@ describe("Test dragCreate", () => {
         key: KEYS.ENTER,
       });
 
-      expect(renderScene).toHaveBeenCalledTimes(8);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(0);
     });

+ 2 - 2
src/tests/helpers/api.ts

@@ -279,7 +279,7 @@ export class API {
   };
 
   static drop = async (blob: Blob) => {
-    const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
+    const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
     const text = await new Promise<string>((resolve, reject) => {
       try {
         const reader = new FileReader();
@@ -306,6 +306,6 @@ export class API {
         },
       },
     });
-    fireEvent(GlobalTestState.canvas, fileDropEvent);
+    fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
   };
 }

+ 17 - 10
src/tests/helpers/ui.ts

@@ -107,7 +107,7 @@ export class Pointer {
   restorePosition(x = 0, y = 0) {
     this.clientX = x;
     this.clientY = y;
-    fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
+    fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
   }
 
   private getEvent() {
@@ -129,18 +129,18 @@ export class Pointer {
     if (dx !== 0 || dy !== 0) {
       this.clientX += dx;
       this.clientY += dy;
-      fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
+      fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
     }
   }
 
   down(dx = 0, dy = 0) {
     this.move(dx, dy);
-    fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent());
+    fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
   }
 
   up(dx = 0, dy = 0) {
     this.move(dx, dy);
-    fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent());
+    fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
   }
 
   click(dx = 0, dy = 0) {
@@ -150,7 +150,7 @@ export class Pointer {
 
   doubleClick(dx = 0, dy = 0) {
     this.move(dx, dy);
-    fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
+    fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
   }
 
   // absolute coords
@@ -159,19 +159,19 @@ export class Pointer {
   moveTo(x: number = this.clientX, y: number = this.clientY) {
     this.clientX = x;
     this.clientY = y;
-    fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
+    fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
   }
 
   downAt(x = this.clientX, y = this.clientY) {
     this.clientX = x;
     this.clientY = y;
-    fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent());
+    fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
   }
 
   upAt(x = this.clientX, y = this.clientY) {
     this.clientX = x;
     this.clientY = y;
-    fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent());
+    fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
   }
 
   clickAt(x: number, y: number) {
@@ -180,7 +180,7 @@ export class Pointer {
   }
 
   rightClickAt(x: number, y: number) {
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: x,
       clientY: y,
@@ -189,7 +189,7 @@ export class Pointer {
 
   doubleClickAt(x: number, y: number) {
     this.moveTo(x, y);
-    fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
+    fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
   }
 
   // ---------------------------------------------------------------------------
@@ -327,6 +327,13 @@ export class UI {
     });
   }
 
+  static ungroup(elements: ExcalidrawElement[]) {
+    mouse.select(elements);
+    Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+      Keyboard.keyPress(KEYS.G);
+    });
+  }
+
   static queryContextMenu = () => {
     return GlobalTestState.renderResult.container.querySelector(
       ".context-menu",

+ 55 - 40
src/tests/linearElementEditor.test.tsx

@@ -26,26 +26,28 @@ import * as textElementUtils from "../element/textElement";
 import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
 import { vi } from "vitest";
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
 
 const { h } = window;
 const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
 
 describe("Test Linear Elements", () => {
   let container: HTMLElement;
-  let canvas: HTMLCanvasElement;
+  let interactiveCanvas: HTMLCanvasElement;
 
   beforeEach(async () => {
     // Unmount ReactDOM from root
     ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
     localStorage.clear();
-    renderScene.mockClear();
+    renderInteractiveScene.mockClear();
+    renderStaticScene.mockClear();
     reseed(7);
     const comp = await render(<ExcalidrawApp />);
+    h.state.width = 1000;
+    h.state.height = 1000;
     container = comp.container;
-    canvas = container.querySelector("canvas")!;
-    canvas.width = 1000;
-    canvas.height = 1000;
+    interactiveCanvas = container.querySelector("canvas.interactive")!;
   });
 
   const p1: Point = [20, 20];
@@ -120,26 +122,26 @@ describe("Test Linear Elements", () => {
   };
 
   const drag = (startPoint: Point, endPoint: Point) => {
-    fireEvent.pointerDown(canvas, {
+    fireEvent.pointerDown(interactiveCanvas, {
       clientX: startPoint[0],
       clientY: startPoint[1],
     });
-    fireEvent.pointerMove(canvas, {
+    fireEvent.pointerMove(interactiveCanvas, {
       clientX: endPoint[0],
       clientY: endPoint[1],
     });
-    fireEvent.pointerUp(canvas, {
+    fireEvent.pointerUp(interactiveCanvas, {
       clientX: endPoint[0],
       clientY: endPoint[1],
     });
   };
 
   const deletePoint = (point: Point) => {
-    fireEvent.pointerDown(canvas, {
+    fireEvent.pointerDown(interactiveCanvas, {
       clientX: point[0],
       clientY: point[1],
     });
-    fireEvent.pointerUp(canvas, {
+    fireEvent.pointerUp(interactiveCanvas, {
       clientX: point[0],
       clientY: point[1],
     });
@@ -172,12 +174,14 @@ describe("Test Linear Elements", () => {
     createTwoPointerLinearElement("line");
     const line = h.elements[0] as ExcalidrawLinearElement;
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+    expect(renderStaticScene).toHaveBeenCalledTimes(4);
     expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
 
     // drag line from midpoint
     drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
-    expect(renderScene).toHaveBeenCalledTimes(11);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderStaticScene).toHaveBeenCalledTimes(5);
     expect(line.points.length).toEqual(3);
     expect(line.points).toMatchInlineSnapshot(`
       [
@@ -199,14 +203,14 @@ describe("Test Linear Elements", () => {
 
   it("should allow entering and exiting line editor via context menu", () => {
     createTwoPointerLinearElement("line");
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: midpoint[0],
       clientY: midpoint[1],
     });
     // Enter line editor
     let contextMenu = document.querySelector(".context-menu");
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: midpoint[0],
       clientY: midpoint[1],
@@ -216,13 +220,13 @@ describe("Test Linear Elements", () => {
     expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
 
     // Exiting line editor
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: midpoint[0],
       clientY: midpoint[1],
     });
     contextMenu = document.querySelector(".context-menu");
-    fireEvent.contextMenu(GlobalTestState.canvas, {
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       clientX: midpoint[0],
       clientY: midpoint[1],
@@ -270,7 +274,8 @@ describe("Test Linear Elements", () => {
 
       // drag line from midpoint
       drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
-      expect(renderScene).toHaveBeenCalledTimes(15);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
+      expect(renderStaticScene).toHaveBeenCalledTimes(5);
 
       expect(line.points.length).toEqual(3);
       expect(line.points).toMatchInlineSnapshot(`
@@ -307,7 +312,9 @@ describe("Test Linear Elements", () => {
       // update roundness
       fireEvent.click(screen.getByTitle("Round"));
 
-      expect(renderScene).toHaveBeenCalledTimes(12);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
+
       const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
         h.elements[0] as ExcalidrawLinearElement,
         h.state,
@@ -351,7 +358,9 @@ describe("Test Linear Elements", () => {
       // Move the element
       drag(startPoint, endPoint);
 
-      expect(renderScene).toHaveBeenCalledTimes(16);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
+      expect(renderStaticScene).toHaveBeenCalledTimes(7);
+
       expect([line.x, line.y]).toEqual([
         points[0][0] + deltaX,
         points[0][1] + deltaY,
@@ -408,7 +417,9 @@ describe("Test Linear Elements", () => {
           lastSegmentMidpoint[1] + delta,
         ]);
 
-        expect(renderScene).toHaveBeenCalledTimes(21);
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
+        expect(renderStaticScene).toHaveBeenCalledTimes(7);
+
         expect(line.points.length).toEqual(5);
 
         expect((h.elements[0] as ExcalidrawLinearElement).points)
@@ -447,7 +458,8 @@ describe("Test Linear Elements", () => {
         // Drag from first point
         drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
 
-        expect(renderScene).toHaveBeenCalledTimes(16);
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
+        expect(renderStaticScene).toHaveBeenCalledTimes(6);
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
         expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@@ -473,7 +485,8 @@ describe("Test Linear Elements", () => {
         // Drag from first point
         drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
 
-        expect(renderScene).toHaveBeenCalledTimes(16);
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
+        expect(renderStaticScene).toHaveBeenCalledTimes(6);
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
         expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@@ -507,7 +520,8 @@ describe("Test Linear Elements", () => {
         // delete 3rd point
         deletePoint(points[2]);
         expect(line.points.length).toEqual(3);
-        expect(renderScene).toHaveBeenCalledTimes(22);
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
+        expect(renderStaticScene).toHaveBeenCalledTimes(7);
 
         const newMidPoints = LinearElementEditor.getEditorMidPoints(
           line,
@@ -553,8 +567,8 @@ describe("Test Linear Elements", () => {
           lastSegmentMidpoint[0] + delta,
           lastSegmentMidpoint[1] + delta,
         ]);
-        expect(renderScene).toHaveBeenCalledTimes(21);
-
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
+        expect(renderStaticScene).toHaveBeenCalledTimes(7);
         expect(line.points.length).toEqual(5);
 
         expect((h.elements[0] as ExcalidrawLinearElement).points)
@@ -629,7 +643,8 @@ describe("Test Linear Elements", () => {
         // Drag from first point
         drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
 
-        expect(renderScene).toHaveBeenCalledTimes(16);
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
+        expect(renderStaticScene).toHaveBeenCalledTimes(6);
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
         expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@@ -870,10 +885,10 @@ describe("Test Linear Elements", () => {
       ]);
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
         .toMatchInlineSnapshot(`
-        "Online whiteboard 
-        collaboration made 
-        easy"
-      `);
+          "Online whiteboard 
+          collaboration made 
+          easy"
+        `);
     });
 
     it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
@@ -904,10 +919,10 @@ describe("Test Linear Elements", () => {
       ]);
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
         .toMatchInlineSnapshot(`
-        "Online whiteboard 
-        collaboration made 
-        easy"
-      `);
+          "Online whiteboard 
+          collaboration made 
+          easy"
+        `);
     });
 
     it("should not bind text to line when double clicked", async () => {
@@ -1046,9 +1061,9 @@ describe("Test Linear Elements", () => {
         `);
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
         .toMatchInlineSnapshot(`
-        "Online whiteboard 
-        collaboration made easy"
-      `);
+          "Online whiteboard 
+          collaboration made easy"
+        `);
       expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
         .toMatchInlineSnapshot(`
           [
@@ -1206,7 +1221,7 @@ describe("Test Linear Elements", () => {
 
       const container = h.elements[0];
       API.setSelectedElements([container, text]);
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 20,
         clientY: 30,
@@ -1231,7 +1246,7 @@ describe("Test Linear Elements", () => {
       mouse.up();
       API.setSelectedElements([h.elements[0], h.elements[1]]);
 
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 20,
         clientY: 30,

+ 25 - 13
src/tests/move.test.tsx

@@ -17,10 +17,13 @@ import { vi } from "vitest";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
+
 beforeEach(() => {
   localStorage.clear();
-  renderScene.mockClear();
+  renderInteractiveScene.mockClear();
+  renderStaticScene.mockClear();
   reseed(7);
 });
 
@@ -29,7 +32,7 @@ const { h } = window;
 describe("move element", () => {
   it("rectangle", async () => {
     const { getByToolName, container } = await render(<ExcalidrawApp />);
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
 
     {
       // create element
@@ -39,20 +42,23 @@ describe("move element", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(9);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
       expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
 
-      renderScene.mockClear();
+      renderInteractiveScene.mockClear();
+      renderStaticScene.mockClear();
     }
 
     fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 });
     fireEvent.pointerMove(canvas, { clientX: 20, clientY: 40 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
+    expect(renderStaticScene).toHaveBeenCalledTimes(2);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect([h.elements[0].x, h.elements[0].y]).toEqual([0, 40]);
@@ -78,7 +84,8 @@ describe("move element", () => {
     // select the second rectangles
     new Pointer("mouse").clickOn(rectB);
 
-    expect(renderScene).toHaveBeenCalledTimes(23);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
+    expect(renderStaticScene).toHaveBeenCalledTimes(16);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(3);
     expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@@ -87,7 +94,8 @@ describe("move element", () => {
     expect([line.x, line.y]).toEqual([110, 50]);
     expect([line.width, line.height]).toEqual([80, 80]);
 
-    renderScene.mockClear();
+    renderInteractiveScene.mockClear();
+    renderStaticScene.mockClear();
 
     // Move selected rectangle
     Keyboard.keyDown(KEYS.ARROW_RIGHT);
@@ -95,7 +103,8 @@ describe("move element", () => {
     Keyboard.keyDown(KEYS.ARROW_DOWN);
 
     // Check that the arrow size has been changed according to moving the rectangle
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
+    expect(renderStaticScene).toHaveBeenCalledTimes(3);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(3);
     expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@@ -111,7 +120,7 @@ describe("move element", () => {
 describe("duplicate element on move when ALT is clicked", () => {
   it("rectangle", async () => {
     const { getByToolName, container } = await render(<ExcalidrawApp />);
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
 
     {
       // create element
@@ -121,13 +130,15 @@ describe("duplicate element on move when ALT is clicked", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(9);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
+      expect(renderStaticScene).toHaveBeenCalledTimes(6);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
       expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
 
-      renderScene.mockClear();
+      renderInteractiveScene.mockClear();
+      renderStaticScene.mockClear();
     }
 
     fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 });
@@ -141,7 +152,8 @@ describe("duplicate element on move when ALT is clicked", () => {
 
     // TODO: This used to be 4, but binding made it go up to 5. Do we need
     // that additional render?
-    expect(renderScene).toHaveBeenCalledTimes(5);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+    expect(renderStaticScene).toHaveBeenCalledTimes(3);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(2);
 

+ 20 - 12
src/tests/multiPointCreate.test.tsx

@@ -15,10 +15,13 @@ import { vi } from "vitest";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
+
 beforeEach(() => {
   localStorage.clear();
-  renderScene.mockClear();
+  renderInteractiveScene.mockClear();
+  renderStaticScene.mockClear();
   reseed(7);
 });
 
@@ -39,11 +42,12 @@ describe("remove shape in non linear elements", () => {
     const tool = getByToolName("rectangle");
     fireEvent.click(tool);
 
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+    expect(renderStaticScene).toHaveBeenCalledTimes(5);
     expect(h.elements.length).toEqual(0);
   });
 
@@ -53,11 +57,12 @@ describe("remove shape in non linear elements", () => {
     const tool = getByToolName("ellipse");
     fireEvent.click(tool);
 
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+    expect(renderStaticScene).toHaveBeenCalledTimes(5);
     expect(h.elements.length).toEqual(0);
   });
 
@@ -67,11 +72,12 @@ describe("remove shape in non linear elements", () => {
     const tool = getByToolName("diamond");
     fireEvent.click(tool);
 
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+    expect(renderStaticScene).toHaveBeenCalledTimes(5);
     expect(h.elements.length).toEqual(0);
   });
 });
@@ -83,7 +89,7 @@ describe("multi point mode in linear elements", () => {
     const tool = getByToolName("arrow");
     fireEvent.click(tool);
 
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     // first point is added on pointer down
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30 });
 
@@ -103,7 +109,8 @@ describe("multi point mode in linear elements", () => {
       key: KEYS.ENTER,
     });
 
-    expect(renderScene).toHaveBeenCalledTimes(15);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
+    expect(renderStaticScene).toHaveBeenCalledTimes(10);
     expect(h.elements.length).toEqual(1);
 
     const element = h.elements[0] as ExcalidrawLinearElement;
@@ -126,7 +133,7 @@ describe("multi point mode in linear elements", () => {
     const tool = getByToolName("line");
     fireEvent.click(tool);
 
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     // first point is added on pointer down
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30 });
 
@@ -146,7 +153,8 @@ describe("multi point mode in linear elements", () => {
       key: KEYS.ENTER,
     });
 
-    expect(renderScene).toHaveBeenCalledTimes(15);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
+    expect(renderStaticScene).toHaveBeenCalledTimes(10);
     expect(h.elements.length).toEqual(1);
 
     const element = h.elements[0] as ExcalidrawLinearElement;

+ 6 - 6
src/tests/packages/excalidraw.test.tsx

@@ -1,6 +1,6 @@
 import { fireEvent, GlobalTestState, toggleMenu, render } from "../test-utils";
 import { Excalidraw, Footer, MainMenu } from "../../packages/excalidraw/index";
-import { queryByText, queryByTestId, screen } from "@testing-library/react";
+import { queryByText, queryByTestId } from "@testing-library/react";
 import { GRID_SIZE, THEME } from "../../constants";
 import { t } from "../../i18n";
 import { useMemo } from "react";
@@ -23,7 +23,7 @@ describe("<Excalidraw/>", () => {
       ).toBe(0);
       expect(h.state.zenModeEnabled).toBe(false);
 
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 1,
         clientY: 1,
@@ -42,8 +42,8 @@ describe("<Excalidraw/>", () => {
         container.getElementsByClassName("disable-zen-mode--visible").length,
       ).toBe(0);
       expect(h.state.zenModeEnabled).toBe(true);
-      screen.debug();
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 1,
         clientY: 1,
@@ -95,7 +95,7 @@ describe("<Excalidraw/>", () => {
       expect(
         container.getElementsByClassName("disable-zen-mode--visible").length,
       ).toBe(0);
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 1,
         clientY: 1,
@@ -114,7 +114,7 @@ describe("<Excalidraw/>", () => {
       expect(
         container.getElementsByClassName("disable-zen-mode--visible").length,
       ).toBe(0);
-      fireEvent.contextMenu(GlobalTestState.canvas, {
+      fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
         button: 2,
         clientX: 1,
         clientY: 1,

+ 25 - 3
src/tests/regressionTests.test.tsx

@@ -21,7 +21,7 @@ import { vi } from "vitest";
 
 const { h } = window;
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
 
 const mouse = new Pointer("mouse");
 const finger1 = new Pointer("touch", 1);
@@ -33,7 +33,7 @@ const finger2 = new Pointer("touch", 2);
  * to debug where a test failure came from.
  */
 const checkpoint = (name: string) => {
-  expect(renderScene.mock.calls.length).toMatchSnapshot(
+  expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
     `[${name}] number of renders`,
   );
   expect(h.state).toMatchSnapshot(`[${name}] appState`);
@@ -48,7 +48,7 @@ beforeEach(async () => {
   ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
   localStorage.clear();
-  renderScene.mockClear();
+  renderStaticScene.mockClear();
   reseed(7);
   setDateTimeForTests("201933152653");
 
@@ -1056,6 +1056,28 @@ describe("regression tests", () => {
     expect(API.getSelectedElements()).toEqual(selectedElements_prev);
   });
 
+  it("deleting last but one element in editing group should unselect the group", () => {
+    const rect1 = UI.createElement("rectangle", { x: 10 });
+    const rect2 = UI.createElement("rectangle", { x: 50 });
+
+    UI.group([rect1, rect2]);
+
+    mouse.doubleClickOn(rect1);
+    Keyboard.keyDown(KEYS.DELETE);
+
+    // Clicking on the deleted element, hence in the empty space
+    mouse.clickOn(rect1);
+
+    expect(h.state.selectedGroupIds).toEqual({});
+    expect(API.getSelectedElements()).toEqual([]);
+
+    // Clicking back in and expecting no group selection
+    mouse.clickOn(rect2);
+
+    expect(h.state.selectedGroupIds).toEqual({ [rect2.groupIds[0]]: false });
+    expect(API.getSelectedElements()).toEqual([rect2.get()]);
+  });
+
   it("Cmd/Ctrl-click exclusively select element under pointer", () => {
     const rect1 = UI.createElement("rectangle", { x: 0 });
     const rect2 = UI.createElement("rectangle", { x: 30 });

+ 3 - 2
src/tests/resize.test.tsx

@@ -14,10 +14,11 @@ import { vi } from "vitest";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
+
 beforeEach(() => {
   localStorage.clear();
-  renderScene.mockClear();
+  renderStaticScene.mockClear();
   reseed(7);
 });
 

+ 30 - 19
src/tests/selection.test.tsx

@@ -18,10 +18,13 @@ import { vi } from "vitest";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = vi.spyOn(Renderer, "renderScene");
+const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
+const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
+
 beforeEach(() => {
   localStorage.clear();
-  renderScene.mockClear();
+  renderInteractiveScene.mockClear();
+  renderStaticScene.mockClear();
   reseed(7);
 });
 
@@ -201,7 +204,7 @@ describe("inner box-selection", () => {
     });
     h.elements = [rect1, rect2, rect3];
     Keyboard.withModifierKeys({ ctrl: true }, () => {
-      mouse.downAt(rect2.x - 20, rect2.x - 20);
+      mouse.downAt(rect2.x - 20, rect2.y - 20);
       mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
       assertSelectedElements([rect2.id, rect3.id]);
       expect(h.state.selectedGroupIds).toEqual({ A: true });
@@ -220,10 +223,11 @@ describe("selection element", () => {
     const tool = getByToolName("selection");
     fireEvent.click(tool);
 
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
 
-    expect(renderScene).toHaveBeenCalledTimes(5);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
+    expect(renderStaticScene).toHaveBeenCalledTimes(3);
     const selectionElement = h.state.selectionElement!;
     expect(selectionElement).not.toBeNull();
     expect(selectionElement.type).toEqual("selection");
@@ -240,11 +244,12 @@ describe("selection element", () => {
     const tool = getByToolName("selection");
     fireEvent.click(tool);
 
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
     fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(6);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(4);
+    expect(renderStaticScene).toHaveBeenCalledTimes(3);
     const selectionElement = h.state.selectionElement!;
     expect(selectionElement).not.toBeNull();
     expect(selectionElement.type).toEqual("selection");
@@ -261,12 +266,13 @@ describe("selection element", () => {
     const tool = getByToolName("selection");
     fireEvent.click(tool);
 
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
     fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
+    expect(renderStaticScene).toHaveBeenCalledTimes(3);
     expect(h.state.selectionElement).toBeNull();
   });
 });
@@ -282,7 +288,7 @@ describe("select single element on the scene", () => {
 
   it("rectangle", async () => {
     const { getByToolName, container } = await render(<ExcalidrawApp />);
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     {
       // create element
       const tool = getByToolName("rectangle");
@@ -301,7 +307,8 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(11);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderStaticScene).toHaveBeenCalledTimes(7);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -311,7 +318,7 @@ describe("select single element on the scene", () => {
 
   it("diamond", async () => {
     const { getByToolName, container } = await render(<ExcalidrawApp />);
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     {
       // create element
       const tool = getByToolName("diamond");
@@ -330,7 +337,8 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(11);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderStaticScene).toHaveBeenCalledTimes(7);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -340,7 +348,7 @@ describe("select single element on the scene", () => {
 
   it("ellipse", async () => {
     const { getByToolName, container } = await render(<ExcalidrawApp />);
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     {
       // create element
       const tool = getByToolName("ellipse");
@@ -359,7 +367,8 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(11);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderStaticScene).toHaveBeenCalledTimes(7);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -369,7 +378,7 @@ describe("select single element on the scene", () => {
 
   it("arrow", async () => {
     const { getByToolName, container } = await render(<ExcalidrawApp />);
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     {
       // create element
       const tool = getByToolName("arrow");
@@ -401,7 +410,8 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(11);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderStaticScene).toHaveBeenCalledTimes(7);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -410,7 +420,7 @@ describe("select single element on the scene", () => {
 
   it("arrow escape", async () => {
     const { getByToolName, container } = await render(<ExcalidrawApp />);
-    const canvas = container.querySelector("canvas")!;
+    const canvas = container.querySelector("canvas.interactive")!;
     {
       // create element
       const tool = getByToolName("line");
@@ -442,7 +452,8 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(11);
+    expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
+    expect(renderStaticScene).toHaveBeenCalledTimes(7);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

+ 24 - 3
src/tests/test-utils.ts

@@ -49,15 +49,30 @@ const renderApp: TestRenderFn = async (ui, options) => {
     // child App component isn't likely mounted yet (and thus canvas not
     // present in DOM)
     get() {
-      return renderResult.container.querySelector("canvas")!;
+      return renderResult.container.querySelector("canvas.static")!;
+    },
+  });
+
+  Object.defineProperty(GlobalTestState, "interactiveCanvas", {
+    // 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.interactive")!;
     },
   });
 
   await waitFor(() => {
-    const canvas = renderResult.container.querySelector("canvas");
+    const canvas = renderResult.container.querySelector("canvas.static");
     if (!canvas) {
       throw new Error("not initialized yet");
     }
+
+    const interactiveCanvas =
+      renderResult.container.querySelector("canvas.interactive");
+    if (!interactiveCanvas) {
+      throw new Error("not initialized yet");
+    }
   });
 
   return renderResult;
@@ -81,11 +96,17 @@ export class GlobalTestState {
    */
   static renderResult: RenderResult<typeof customQueries> = null!;
   /**
-   * retrieves canvas for currently rendered app instance
+   * retrieves static canvas for currently rendered app instance
    */
   static get canvas(): HTMLCanvasElement {
     return null!;
   }
+  /**
+   * retrieves interactive canvas for currently rendered app instance
+   */
+  static get interactiveCanvas(): HTMLCanvasElement {
+    return null!;
+  }
 }
 
 const initLocalStorage = (data: ImportedDataState) => {

+ 15 - 5
src/tests/viewMode.test.tsx

@@ -17,7 +17,9 @@ describe("view mode", () => {
 
   it("after switching to view mode – cursor type should be pointer", async () => {
     h.setState({ viewModeEnabled: true });
-    expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
+    expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
+      CURSOR_TYPE.GRAB,
+    );
   });
 
   it("after switching to view mode, moving, clicking, and pressing space key – cursor type should be pointer", async () => {
@@ -29,7 +31,9 @@ describe("view mode", () => {
       pointer.move(100, 100);
       pointer.click();
       Keyboard.keyPress(KEYS.SPACE);
-      expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
+      expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
+        CURSOR_TYPE.GRAB,
+      );
     });
   });
 
@@ -45,13 +49,19 @@ describe("view mode", () => {
       pointer.moveTo(50, 50);
       // eslint-disable-next-line dot-notation
       if (pointerType["pointerType"] === "mouse") {
-        expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.MOVE);
+        expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
+          CURSOR_TYPE.MOVE,
+        );
       } else {
-        expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
+        expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
+          CURSOR_TYPE.GRAB,
+        );
       }
 
       h.setState({ viewModeEnabled: true });
-      expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
+      expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
+        CURSOR_TYPE.GRAB,
+      );
     });
   });
 });

+ 1 - 1
src/tests/zindex.test.tsx

@@ -94,7 +94,7 @@ const populateElements = (
     ),
     ...appState,
     selectedElementIds,
-  });
+  } as AppState);
 
   return selectedElementIds;
 };

+ 50 - 3
src/types.ts

@@ -104,6 +104,52 @@ export type LastActiveTool =
 export type SidebarName = string;
 export type SidebarTabName = string;
 
+export type CommonCanvasAppState = {
+  zoom: AppState["zoom"];
+  scrollX: AppState["scrollX"];
+  scrollY: AppState["scrollY"];
+  width: AppState["width"];
+  height: AppState["height"];
+  viewModeEnabled: AppState["viewModeEnabled"];
+  editingElement: AppState["editingElement"];
+  editingGroupId: AppState["editingGroupId"]; // TODO: move to interactive canvas if possible
+  selectedElementIds: AppState["selectedElementIds"]; // TODO: move to interactive canvas if possible
+  frameToHighlight: AppState["frameToHighlight"]; // TODO: move to interactive canvas if possible
+  offsetLeft: AppState["offsetLeft"];
+  offsetTop: AppState["offsetTop"];
+  theme: AppState["theme"];
+  pendingImageElementId: AppState["pendingImageElementId"];
+};
+
+export type StaticCanvasAppState = CommonCanvasAppState & {
+  shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
+  /** null indicates transparent bg */
+  viewBackgroundColor: AppState["viewBackgroundColor"] | null;
+  exportScale: AppState["exportScale"];
+  selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
+  gridSize: AppState["gridSize"];
+  frameRendering: AppState["frameRendering"];
+};
+
+export type InteractiveCanvasAppState = CommonCanvasAppState & {
+  // renderInteractiveScene
+  activeEmbeddable: AppState["activeEmbeddable"];
+  editingLinearElement: AppState["editingLinearElement"];
+  selectionElement: AppState["selectionElement"];
+  selectedGroupIds: AppState["selectedGroupIds"];
+  selectedLinearElement: AppState["selectedLinearElement"];
+  multiElement: AppState["multiElement"];
+  isBindingEnabled: AppState["isBindingEnabled"];
+  suggestedBindings: AppState["suggestedBindings"];
+  isRotating: AppState["isRotating"];
+  elementsToHighlight: AppState["elementsToHighlight"];
+  // App
+  openSidebar: AppState["openSidebar"];
+  showHyperlinkPopup: AppState["showHyperlinkPopup"];
+  // Collaborators
+  collaborators: AppState["collaborators"];
+};
+
 export type AppState = {
   contextMenu: {
     items: ContextMenuItems;
@@ -407,13 +453,13 @@ export type ExportOpts = {
     exportedElements: readonly NonDeletedExcalidrawElement[],
     appState: UIAppState,
     files: BinaryFiles,
-    canvas: HTMLCanvasElement | null,
+    canvas: HTMLCanvasElement,
   ) => void;
   renderCustomUI?: (
     exportedElements: readonly NonDeletedExcalidrawElement[],
     appState: UIAppState,
     files: BinaryFiles,
-    canvas: HTMLCanvasElement | null,
+    canvas: HTMLCanvasElement,
   ) => JSX.Element;
 };
 
@@ -458,7 +504,8 @@ export type AppProps = Merge<
  * in the app, eg Manager. Factored out into a separate type to keep DRY. */
 export type AppClassProperties = {
   props: AppProps;
-  canvas: HTMLCanvasElement | null;
+  canvas: HTMLCanvasElement;
+  interactiveCanvas: HTMLCanvasElement | null;
   focusContainer(): void;
   library: Library;
   imageCache: Map<

+ 89 - 14
src/utils.ts

@@ -20,6 +20,7 @@ import { unstable_batchedUpdates } from "react-dom";
 import { SHAPES } from "./shapes";
 import { isEraserActive, isHandToolActive } from "./appState";
 import { ResolutionType } from "./utility-types";
+import React from "react";
 
 let mockDateTime: string | null = null;
 
@@ -399,22 +400,25 @@ export const updateActiveTool = (
   };
 };
 
-export const resetCursor = (canvas: HTMLCanvasElement | null) => {
-  if (canvas) {
-    canvas.style.cursor = "";
+export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
+  if (interactiveCanvas) {
+    interactiveCanvas.style.cursor = "";
   }
 };
 
-export const setCursor = (canvas: HTMLCanvasElement | null, cursor: string) => {
-  if (canvas) {
-    canvas.style.cursor = cursor;
+export const setCursor = (
+  interactiveCanvas: HTMLCanvasElement | null,
+  cursor: string,
+) => {
+  if (interactiveCanvas) {
+    interactiveCanvas.style.cursor = cursor;
   }
 };
 
 let eraserCanvasCache: any;
 let previewDataURL: string;
 export const setEraserCursor = (
-  canvas: HTMLCanvasElement | null,
+  interactiveCanvas: HTMLCanvasElement | null,
   theme: AppState["theme"],
 ) => {
   const cursorImageSizePx = 20;
@@ -446,7 +450,7 @@ export const setEraserCursor = (
   }
 
   setCursor(
-    canvas,
+    interactiveCanvas,
     `url(${previewDataURL}) ${cursorImageSizePx / 2} ${
       cursorImageSizePx / 2
     }, auto`,
@@ -454,23 +458,23 @@ export const setEraserCursor = (
 };
 
 export const setCursorForShape = (
-  canvas: HTMLCanvasElement | null,
+  interactiveCanvas: HTMLCanvasElement | null,
   appState: Pick<AppState, "activeTool" | "theme">,
 ) => {
-  if (!canvas) {
+  if (!interactiveCanvas) {
     return;
   }
   if (appState.activeTool.type === "selection") {
-    resetCursor(canvas);
+    resetCursor(interactiveCanvas);
   } else if (isHandToolActive(appState)) {
-    canvas.style.cursor = CURSOR_TYPE.GRAB;
+    interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
   } else if (isEraserActive(appState)) {
-    setEraserCursor(canvas, appState.theme);
+    setEraserCursor(interactiveCanvas, appState.theme);
     // do nothing if image tool is selected which suggests there's
     // a image-preview set as the cursor
     // Ignore custom type as well and let host decide
   } else if (!["image", "custom"].includes(appState.activeTool.type)) {
-    canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
+    interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
   }
 };
 
@@ -927,3 +931,74 @@ export const assertNever = (
 
   throw new Error(message);
 };
+
+/**
+ * Memoizes on values of `opts` object (strict equality).
+ */
+export const memoize = <T extends Record<string, any>, R extends any>(
+  func: (opts: T) => R,
+) => {
+  let lastArgs: Map<string, any> | undefined;
+  let lastResult: R | undefined;
+
+  const ret = function (opts: T) {
+    const currentArgs = Object.entries(opts);
+
+    if (lastArgs) {
+      let argsAreEqual = true;
+      for (const [key, value] of currentArgs) {
+        if (lastArgs.get(key) !== value) {
+          argsAreEqual = false;
+          break;
+        }
+      }
+      if (argsAreEqual) {
+        return lastResult;
+      }
+    }
+
+    const result = func(opts);
+
+    lastArgs = new Map(currentArgs);
+    lastResult = result;
+
+    return result;
+  };
+
+  ret.clear = () => {
+    lastArgs = undefined;
+    lastResult = undefined;
+  };
+
+  return ret as typeof func & { clear: () => void };
+};
+
+export const isRenderThrottlingEnabled = (() => {
+  // we don't want to throttle in react < 18 because of #5439 and it was
+  // getting more complex to maintain the fix
+  let IS_REACT_18_AND_UP: boolean;
+  try {
+    const version = React.version.split(".");
+    IS_REACT_18_AND_UP = Number(version[0]) > 17;
+  } catch {
+    IS_REACT_18_AND_UP = false;
+  }
+
+  let hasWarned = false;
+
+  return () => {
+    if (window.EXCALIDRAW_THROTTLE_RENDER === true) {
+      if (!IS_REACT_18_AND_UP) {
+        if (!hasWarned) {
+          hasWarned = true;
+          console.warn(
+            "Excalidraw: render throttling is disabled on React versions < 18.",
+          );
+        }
+        return false;
+      }
+      return true;
+    }
+    return false;
+  };
+})();

+ 66 - 119
yarn.lock

@@ -2813,40 +2813,39 @@
     test-exclude "^6.0.0"
     v8-to-istanbul "^9.1.0"
 
-"@vitest/[email protected]2.2":
-  version "0.32.2"
-  resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.32.2.tgz#8111f6ab1ff3b203efbe3a25e8bb2d160ce4b720"
-  integrity sha512-6q5yzweLnyEv5Zz1fqK5u5E83LU+gOMVBDuxBl2d2Jfx1BAp5M+rZgc5mlyqdnxquyoiOXpXmFNkcGcfFnFH3Q==
+"@vitest/[email protected]4.1":
+  version "0.34.1"
+  resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.34.1.tgz#2ba6cb96695f4b4388c6d955423a81afc79b8da0"
+  integrity sha512-q2CD8+XIsQ+tHwypnoCk8Mnv5e6afLFvinVGCq3/BOT4kQdVQmY6rRfyKkwcg635lbliLPqbunXZr+L1ssUWiQ==
   dependencies:
-    "@vitest/spy" "0.32.2"
-    "@vitest/utils" "0.32.2"
+    "@vitest/spy" "0.34.1"
+    "@vitest/utils" "0.34.1"
     chai "^4.3.7"
 
-"@vitest/[email protected]2.2":
-  version "0.32.2"
-  resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.32.2.tgz#18dd979ce4e8766bcc90948d11b4c8ae6ed90b89"
-  integrity sha512-06vEL0C1pomOEktGoLjzZw+1Fb+7RBRhmw/06WkDrd1akkT9i12su0ku+R/0QM69dfkIL/rAIDTG+CSuQVDcKw==
+"@vitest/[email protected]4.1":
+  version "0.34.1"
+  resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.34.1.tgz#23c21ba1db8bff610988c72744db590d0fb6c4ba"
+  integrity sha512-YfQMpYzDsYB7yqgmlxZ06NI4LurHWfrH7Wy3Pvf/z/vwUSgq1zLAb1lWcItCzQG+NVox+VvzlKQrYEXb47645g==
   dependencies:
-    "@vitest/utils" "0.32.2"
-    concordance "^5.0.4"
+    "@vitest/utils" "0.34.1"
     p-limit "^4.0.0"
-    pathe "^1.1.0"
+    pathe "^1.1.1"
 
-"@vitest/[email protected]2.2":
-  version "0.32.2"
-  resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.32.2.tgz#500b6453e88e4c50a0aded39839352c16b519b9e"
-  integrity sha512-JwhpeH/PPc7GJX38vEfCy9LtRzf9F4er7i4OsAJyV7sjPwjj+AIR8cUgpMTWK4S3TiamzopcTyLsZDMuldoi5A==
+"@vitest/[email protected]4.1":
+  version "0.34.1"
+  resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.34.1.tgz#814c65f8e714eaf255f47838541004b2a2ba28e6"
+  integrity sha512-0O9LfLU0114OqdF8lENlrLsnn024Tb1CsS9UwG0YMWY2oGTQfPtkW+B/7ieyv0X9R2Oijhi3caB1xgGgEgclSQ==
   dependencies:
-    magic-string "^0.30.0"
-    pathe "^1.1.0"
-    pretty-format "^27.5.1"
+    magic-string "^0.30.1"
+    pathe "^1.1.1"
+    pretty-format "^29.5.0"
 
-"@vitest/[email protected]2.2":
-  version "0.32.2"
-  resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.32.2.tgz#f3ef7afe0d34e863b90df7c959fa5af540a6aaf9"
-  integrity sha512-Q/ZNILJ4ca/VzQbRM8ur3Si5Sardsh1HofatG9wsJY1RfEaw0XKP8IVax2lI1qnrk9YPuG9LA2LkZ0EI/3d4ug==
+"@vitest/[email protected]4.1":
+  version "0.34.1"
+  resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.34.1.tgz#2f77234a3d554c5dea664943f2caaab92d304f3c"
+  integrity sha512-UT4WcI3EAPUNO8n6y9QoEqynGGEPmmRxC+cLzneFFXpmacivjHZsNbiKD88KUScv5DCHVDgdBsLD7O7s1enFcQ==
   dependencies:
-    tinyspy "^2.1.0"
+    tinyspy "^2.1.1"
 
 "@vitest/[email protected]":
   version "0.32.2"
@@ -2870,6 +2869,15 @@
     loupe "^2.3.6"
     pretty-format "^27.5.1"
 
+"@vitest/[email protected]":
+  version "0.34.1"
+  resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-0.34.1.tgz#e5545c6618775fb9a2dae2a80d94fc2f35222233"
+  integrity sha512-/ql9dsFi4iuEbiNcjNHQWXBum7aL8pyhxvfnD9gNtbjR9fUKAjxhj4AA3yfLXg6gJpMGGecvtF8Au2G9y3q47Q==
+  dependencies:
+    diff-sequences "^29.4.3"
+    loupe "^2.3.6"
+    pretty-format "^29.5.0"
+
 abab@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
@@ -3236,11 +3244,6 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
   integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
 
-blueimp-md5@^2.10.0:
-  version "2.19.0"
-  resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
-  integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==
-
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -3510,20 +3513,6 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
-concordance@^5.0.4:
-  version "5.0.4"
-  resolved "https://registry.yarnpkg.com/concordance/-/concordance-5.0.4.tgz#9896073261adced72f88d60e4d56f8efc4bbbbd2"
-  integrity sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==
-  dependencies:
-    date-time "^3.1.0"
-    esutils "^2.0.3"
-    fast-diff "^1.2.0"
-    js-string-escape "^1.0.1"
-    lodash "^4.17.15"
-    md5-hex "^3.0.1"
-    semver "^7.3.2"
-    well-known-symbols "^2.0.0"
-
 confusing-browser-globals@^1.0.11:
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81"
@@ -3638,13 +3627,6 @@ data-urls@^4.0.0:
     whatwg-mimetype "^3.0.0"
     whatwg-url "^12.0.0"
 
-date-time@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/date-time/-/date-time-3.1.0.tgz#0d1e934d170579f481ed8df1e2b8ff70ee845e1e"
-  integrity sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==
-  dependencies:
-    time-zone "^1.0.0"
-
 debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
@@ -4294,7 +4276,7 @@ estree-walker@^2.0.2:
   resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
   integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
 
-esutils@^2.0.2, esutils@^2.0.3:
+esutils@^2.0.2:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
@@ -4347,11 +4329,6 @@ fast-diff@^1.1.2:
   resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
   integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
 
-fast-diff@^1.2.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0"
-  integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
-
 fast-glob@^3.2.12, fast-glob@^3.2.9:
   version "3.2.12"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
@@ -5245,11 +5222,6 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.13.1.tgz#20cc46454cbb39096b12fddfa635b873b3668236"
   integrity sha512-RUmH1S4vLsG3V6fbGlKzGJnLrDcC/HNb5gH2AeA9DzuJknoVxSGvvg8OBB7lke+gDc4oXmdVsaKn/xDUhWZ0vw==
 
-js-string-escape@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"
-  integrity sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==
-
 "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -5561,13 +5533,6 @@ magic-string@^0.27.0:
   dependencies:
     "@jridgewell/sourcemap-codec" "^1.4.13"
 
-magic-string@^0.30.0:
-  version "0.30.0"
-  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.0.tgz#fd58a4748c5c4547338a424e90fa5dd17f4de529"
-  integrity sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==
-  dependencies:
-    "@jridgewell/sourcemap-codec" "^1.4.13"
-
 magic-string@^0.30.1:
   version "0.30.2"
   resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.2.tgz#dcf04aad3d0d1314bc743d076c50feb29b3c7aca"
@@ -5582,13 +5547,6 @@ make-dir@^4.0.0:
   dependencies:
     semver "^7.5.3"
 
-md5-hex@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/md5-hex/-/md5-hex-3.0.1.tgz#be3741b510591434b2784d79e556eefc2c9a8e5c"
-  integrity sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==
-  dependencies:
-    blueimp-md5 "^2.10.0"
-
 merge-stream@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@@ -5660,7 +5618,7 @@ mkdirp@^0.5.6:
   dependencies:
     minimist "^1.2.6"
 
-mlly@^1.2.0:
+mlly@^1.2.0, mlly@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.0.tgz#830c10d63f1f97bd8785377b24dc2a15d972832b"
   integrity sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==
@@ -6525,7 +6483,7 @@ semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-semver@^7.2.1, semver@^7.3.2, semver@^7.3.7:
+semver@^7.2.1, semver@^7.3.7:
   version "7.4.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.4.0.tgz#8481c92feffc531ab1e012a8ffc15bdd3a0f4318"
   integrity sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw==
@@ -6703,7 +6661,7 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
   integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
 
-std-env@^3.3.2, std-env@^3.3.3:
+std-env@^3.3.3:
   version "3.3.3"
   resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.3.3.tgz#a54f06eb245fdcfef53d56f3c0251f1d5c3d01fe"
   integrity sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==
@@ -6930,11 +6888,6 @@ through@^2.3.8:
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
   integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
 
-time-zone@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/time-zone/-/time-zone-1.0.0.tgz#99c5bf55958966af6d06d83bdf3800dc82faec5d"
-  integrity sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==
-
 tiny-invariant@^1.1.0:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642"
@@ -6945,12 +6898,12 @@ tinybench@^2.5.0:
   resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.5.0.tgz#4711c99bbf6f3e986f67eb722fed9cddb3a68ba5"
   integrity sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==
 
-tinypool@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.5.0.tgz#3861c3069bf71e4f1f5aa2d2e6b3aaacc278961e"
-  integrity sha512-paHQtnrlS1QZYKF/GnLoOM/DN9fqaGOFbCbxzAhwniySnzl9Ebk8w73/dd34DAhe/obUbPAOldTyYXQZxnPBPQ==
+tinypool@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.7.0.tgz#88053cc99b4a594382af23190c609d93fddf8021"
+  integrity sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==
 
-tinyspy@^2.1.0:
+tinyspy@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.1.1.tgz#9e6371b00c259e5c5b301917ca18c01d40ae558c"
   integrity sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w==
@@ -7231,15 +7184,15 @@ v8-to-istanbul@^9.1.0:
     "@types/istanbul-lib-coverage" "^2.0.1"
     convert-source-map "^1.6.0"
 
[email protected]2.2:
-  version "0.32.2"
-  resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.32.2.tgz#bfccdfeb708b2309ea9e5fe424951c75bb9c0096"
-  integrity sha512-dTQ1DCLwl2aEseov7cfQ+kDMNJpM1ebpyMMMwWzBvLbis8Nla/6c9WQcqpPssTwS6Rp/+U6KwlIj8Eapw4bLdA==
[email protected]4.1:
+  version "0.34.1"
+  resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.34.1.tgz#144900ca4bd54cc419c501d671350bcbc07eb1ee"
+  integrity sha512-odAZAL9xFMuAg8aWd7nSPT+hU8u2r9gU3LRm9QKjxBEF2rRdWpMuqkrkjvyVQEdNFiBctqr2Gg4uJYizm5Le6w==
   dependencies:
     cac "^6.7.14"
     debug "^4.3.4"
-    mlly "^1.2.0"
-    pathe "^1.1.0"
+    mlly "^1.4.0"
+    pathe "^1.1.1"
     picocolors "^1.0.0"
     vite "^3.0.0 || ^4.0.0"
 
@@ -7321,35 +7274,34 @@ [email protected]:
   dependencies:
     jest-canvas-mock "~2.4.0"
 
[email protected]2.2:
-  version "0.32.2"
-  resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.32.2.tgz#758ce2220f609e240ac054eca7ad11a5140679ab"
-  integrity sha512-hU8GNNuQfwuQmqTLfiKcqEhZY72Zxb7nnN07koCUNmntNxbKQnVbeIS6sqUgR3eXSlbOpit8+/gr1KpqoMgWCQ==
[email protected]4.1:
+  version "0.34.1"
+  resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.34.1.tgz#3ad7f845e7a9fb0d72ab703cae832a54b8469e1e"
+  integrity sha512-G1PzuBEq9A75XSU88yO5G4vPT20UovbC/2osB2KEuV/FisSIIsw7m5y2xMdB7RsAGHAfg2lPmp2qKr3KWliVlQ==
   dependencies:
     "@types/chai" "^4.3.5"
     "@types/chai-subset" "^1.3.3"
     "@types/node" "*"
-    "@vitest/expect" "0.32.2"
-    "@vitest/runner" "0.32.2"
-    "@vitest/snapshot" "0.32.2"
-    "@vitest/spy" "0.32.2"
-    "@vitest/utils" "0.32.2"
-    acorn "^8.8.2"
+    "@vitest/expect" "0.34.1"
+    "@vitest/runner" "0.34.1"
+    "@vitest/snapshot" "0.34.1"
+    "@vitest/spy" "0.34.1"
+    "@vitest/utils" "0.34.1"
+    acorn "^8.9.0"
     acorn-walk "^8.2.0"
     cac "^6.7.14"
     chai "^4.3.7"
-    concordance "^5.0.4"
     debug "^4.3.4"
     local-pkg "^0.4.3"
-    magic-string "^0.30.0"
-    pathe "^1.1.0"
+    magic-string "^0.30.1"
+    pathe "^1.1.1"
     picocolors "^1.0.0"
-    std-env "^3.3.2"
+    std-env "^3.3.3"
     strip-literal "^1.0.1"
     tinybench "^2.5.0"
-    tinypool "^0.5.0"
+    tinypool "^0.7.0"
     vite "^3.0.0 || ^4.0.0"
-    vite-node "0.32.2"
+    vite-node "0.34.1"
     why-is-node-running "^2.2.2"
 
 [email protected]:
@@ -7437,11 +7389,6 @@ webworkify@^1.5.0:
   resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c"
   integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==
 
-well-known-symbols@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/well-known-symbols/-/well-known-symbols-2.0.0.tgz#e9c7c07dbd132b7b84212c8174391ec1f9871ba5"
-  integrity sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==
-
 whatwg-encoding@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53"
@@ -7534,9 +7481,9 @@ why-is-node-running@^2.2.2:
     stackback "0.0.2"
 
 word-wrap@^1.2.3:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
-  integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
+  integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
 [email protected]:
   version "7.0.0"

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