Browse Source

feat: cache most of element selection (#6747)

David Luzar 2 years ago
parent
commit
9f76f8677b

+ 5 - 10
src/actions/actionAddToLibrary.ts

@@ -1,6 +1,4 @@
 import { register } from "./register";
-import { getSelectedElements } from "../scene";
-import { getNonDeletedElements } from "../element";
 import { deepCopyElement } from "../element/newElement";
 import { randomId } from "../random";
 import { t } from "../i18n";
@@ -9,14 +7,11 @@ export const actionAddToLibrary = register({
   name: "addToLibrary",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-        includeElementsInFrames: true,
-      },
-    );
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
     if (selectedElements.some((element) => element.type === "image")) {
       return {
         commitToHistory: false,

+ 32 - 34
src/actions/actionAlign.tsx

@@ -13,19 +13,18 @@ import { ExcalidrawElement } from "../element/types";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
 import { KEYS } from "../keys";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { AppState } from "../types";
+import { isSomeElementSelected } from "../scene";
+import { AppClassProperties, AppState } from "../types";
 import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 
 const alignActionsPredicate = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
+  _: unknown,
+  app: AppClassProperties,
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
   return (
     selectedElements.length > 1 &&
     // TODO enable aligning frames when implemented properly
@@ -36,12 +35,10 @@ const alignActionsPredicate = (
 const alignSelectedElements = (
   elements: readonly ExcalidrawElement[],
   appState: Readonly<AppState>,
+  app: AppClassProperties,
   alignment: Alignment,
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
 
   const updatedElements = alignElements(selectedElements, alignment);
 
@@ -50,6 +47,7 @@ const alignSelectedElements = (
   return updateFrameMembershipOfSelectedElements(
     elements.map((element) => updatedElementsMap.get(element.id) || element),
     appState,
+    app,
   );
 };
 
@@ -57,10 +55,10 @@ export const actionAlignTop = register({
   name: "alignTop",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "start",
         axis: "y",
       }),
@@ -69,9 +67,9 @@ export const actionAlignTop = register({
   },
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={AlignTopIcon}
       onClick={() => updateData(null)}
@@ -88,10 +86,10 @@ export const actionAlignBottom = register({
   name: "alignBottom",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "end",
         axis: "y",
       }),
@@ -100,9 +98,9 @@ export const actionAlignBottom = register({
   },
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={AlignBottomIcon}
       onClick={() => updateData(null)}
@@ -119,10 +117,10 @@ export const actionAlignLeft = register({
   name: "alignLeft",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "start",
         axis: "x",
       }),
@@ -131,9 +129,9 @@ export const actionAlignLeft = register({
   },
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={AlignLeftIcon}
       onClick={() => updateData(null)}
@@ -150,10 +148,10 @@ export const actionAlignRight = register({
   name: "alignRight",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "end",
         axis: "x",
       }),
@@ -162,9 +160,9 @@ export const actionAlignRight = register({
   },
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={AlignRightIcon}
       onClick={() => updateData(null)}
@@ -181,19 +179,19 @@ export const actionAlignVerticallyCentered = register({
   name: "alignVerticallyCentered",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "center",
         axis: "y",
       }),
       commitToHistory: true,
     };
   },
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={CenterVerticallyIcon}
       onClick={() => updateData(null)}
@@ -208,19 +206,19 @@ export const actionAlignHorizontallyCentered = register({
   name: "alignHorizontallyCentered",
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: alignSelectedElements(elements, appState, {
+      elements: alignSelectedElements(elements, appState, app, {
         position: "center",
         axis: "x",
       }),
       commitToHistory: true,
     };
   },
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!alignActionsPredicate(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState, null, app)}
       type="button"
       icon={CenterHorizontallyIcon}
       onClick={() => updateData(null)}

+ 13 - 23
src/actions/actionBoundText.tsx

@@ -4,7 +4,7 @@ import {
   VERTICAL_ALIGN,
   TEXT_ALIGN,
 } from "../constants";
-import { getNonDeletedElements, isTextElement, newElement } from "../element";
+import { isTextElement, newElement } from "../element";
 import { mutateElement } from "../element/mutateElement";
 import {
   computeBoundTextPosition,
@@ -29,7 +29,6 @@ import {
   ExcalidrawTextContainer,
   ExcalidrawTextElement,
 } from "../element/types";
-import { getSelectedElements } from "../scene";
 import { AppState } from "../types";
 import { Mutable } from "../utility-types";
 import { getFontString } from "../utils";
@@ -39,16 +38,13 @@ export const actionUnbindText = register({
   name: "unbindText",
   contextItemLabel: "labels.unbindText",
   trackEvent: { category: "element" },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
 
     return selectedElements.some((element) => hasBoundTextElement(element));
   },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     selectedElements.forEach((element) => {
       const boundTextElement = getBoundTextElement(element);
       if (boundTextElement) {
@@ -93,8 +89,8 @@ export const actionBindText = register({
   name: "bindText",
   contextItemLabel: "labels.bindText",
   trackEvent: { category: "element" },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
 
     if (selectedElements.length === 2) {
       const textElement =
@@ -117,11 +113,8 @@ export const actionBindText = register({
     }
     return false;
   },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
 
     let textElement: ExcalidrawTextElement;
     let container: ExcalidrawTextContainer;
@@ -201,16 +194,13 @@ export const actionWrapTextInContainer = register({
   name: "wrapTextInContainer",
   contextItemLabel: "labels.createContainerFromText",
   trackEvent: { category: "element" },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     const areTextElements = selectedElements.every((el) => isTextElement(el));
     return selectedElements.length > 0 && areTextElements;
   },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     let updatedElements: readonly ExcalidrawElement[] = elements.slice();
     const containerIds: Mutable<AppState["selectedElementIds"]> = {};
 

+ 5 - 11
src/actions/actionCanvas.tsx

@@ -6,7 +6,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { CODES, KEYS } from "../keys";
-import { getNormalizedZoom, getSelectedElements } from "../scene";
+import { getNormalizedZoom } from "../scene";
 import { centerScrollOn } from "../scene/scroll";
 import { getStateForZoom } from "../scene/zoom";
 import { AppState, NormalizedZoomValue } from "../types";
@@ -302,11 +302,8 @@ export const zoomToFit = ({
 export const actionZoomToFitSelectionInViewport = register({
   name: "zoomToFitSelectionInViewport",
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     return zoomToFit({
       targetElements: selectedElements.length ? selectedElements : elements,
       appState,
@@ -325,11 +322,8 @@ export const actionZoomToFitSelectionInViewport = register({
 export const actionZoomToFitSelection = register({
   name: "zoomToFitSelection",
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     return zoomToFit({
       targetElements: selectedElements.length ? selectedElements : elements,
       appState,

+ 24 - 30
src/actions/actionClipboard.tsx

@@ -7,7 +7,6 @@ import {
   probablySupportsClipboardWriteText,
 } from "../clipboard";
 import { actionDeleteSelected } from "./actionDeleteSelected";
-import { getSelectedElements } from "../scene/selection";
 import { exportCanvas } from "../data/index";
 import { getNonDeletedElements, isTextElement } from "../element";
 import { t } from "../i18n";
@@ -16,7 +15,8 @@ export const actionCopy = register({
   name: "copy",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
-    const elementsToCopy = getSelectedElements(elements, appState, {
+    const elementsToCopy = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
       includeBoundTextElement: true,
       includeElementsInFrames: true,
     });
@@ -75,14 +75,11 @@ export const actionCopyAsSvg = register({
         commitToHistory: false,
       };
     }
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-        includeElementsInFrames: true,
-      },
-    );
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
     try {
       await exportCanvas(
         "clipboard-svg",
@@ -122,14 +119,11 @@ export const actionCopyAsPng = register({
         commitToHistory: false,
       };
     }
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-        includeElementsInFrames: true,
-      },
-    );
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
     try {
       await exportCanvas(
         "clipboard",
@@ -177,14 +171,11 @@ export const actionCopyAsPng = register({
 export const copyText = register({
   name: "copyText",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-      },
-    );
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    });
 
     const text = selectedElements
       .reduce((acc: string[], element) => {
@@ -199,12 +190,15 @@ export const copyText = register({
       commitToHistory: false,
     };
   },
-  predicate: (elements, appState) => {
+  predicate: (elements, appState, _, app) => {
     return (
       probablySupportsClipboardWriteText &&
-      getSelectedElements(elements, appState, {
-        includeBoundTextElement: true,
-      }).some(isTextElement)
+      app.scene
+        .getSelectedElements({
+          selectedElementIds: appState.selectedElementIds,
+          includeBoundTextElement: true,
+        })
+        .some(isTextElement)
     );
   },
   contextItemLabel: "labels.copyText",

+ 15 - 22
src/actions/actionDistribute.tsx

@@ -9,19 +9,13 @@ import { ExcalidrawElement } from "../element/types";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
 import { CODES, KEYS } from "../keys";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { AppState } from "../types";
+import { isSomeElementSelected } from "../scene";
+import { AppClassProperties, AppState } from "../types";
 import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 
-const enableActionGroup = (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
+  const selectedElements = app.scene.getSelectedElements(appState);
   return (
     selectedElements.length > 1 &&
     // TODO enable distributing frames when implemented properly
@@ -32,12 +26,10 @@ const enableActionGroup = (
 const distributeSelectedElements = (
   elements: readonly ExcalidrawElement[],
   appState: Readonly<AppState>,
+  app: AppClassProperties,
   distribution: Distribution,
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const selectedElements = app.scene.getSelectedElements(appState);
 
   const updatedElements = distributeElements(selectedElements, distribution);
 
@@ -46,16 +38,17 @@ const distributeSelectedElements = (
   return updateFrameMembershipOfSelectedElements(
     elements.map((element) => updatedElementsMap.get(element.id) || element),
     appState,
+    app,
   );
 };
 
 export const distributeHorizontally = register({
   name: "distributeHorizontally",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: distributeSelectedElements(elements, appState, {
+      elements: distributeSelectedElements(elements, appState, app, {
         space: "between",
         axis: "x",
       }),
@@ -64,9 +57,9 @@ export const distributeHorizontally = register({
   },
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(appState, app)}
       type="button"
       icon={DistributeHorizontallyIcon}
       onClick={() => updateData(null)}
@@ -82,10 +75,10 @@ export const distributeHorizontally = register({
 export const distributeVertically = register({
   name: "distributeVertically",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       appState,
-      elements: distributeSelectedElements(elements, appState, {
+      elements: distributeSelectedElements(elements, appState, app, {
         space: "between",
         axis: "y",
       }),
@@ -94,9 +87,9 @@ export const distributeVertically = register({
   },
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(appState, app)}
       type="button"
       icon={DistributeVerticallyIcon}
       onClick={() => updateData(null)}

+ 1 - 0
src/actions/actionDuplicateSelection.tsx

@@ -275,6 +275,7 @@ const duplicateElements = (
       },
       getNonDeletedElements(finalElements),
       appState,
+      null,
     ),
   };
 };

+ 11 - 9
src/actions/actionElementLock.ts

@@ -1,7 +1,6 @@
 import { newElementWith } from "../element/mutateElement";
 import { ExcalidrawElement } from "../element/types";
 import { KEYS } from "../keys";
-import { getSelectedElements } from "../scene";
 import { arrayToMap } from "../utils";
 import { register } from "./register";
 
@@ -11,14 +10,15 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
 export const actionToggleElementLock = register({
   name: "toggleElementLock",
   trackEvent: { category: "element" },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     return !selectedElements.some(
       (element) => element.locked && element.frameId,
     );
   },
-  perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState, {
+  perform: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
       includeBoundTextElement: true,
       includeElementsInFrames: true,
     });
@@ -46,8 +46,9 @@ export const actionToggleElementLock = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: (elements, appState) => {
-    const selected = getSelectedElements(elements, appState, {
+  contextItemLabel: (elements, appState, app) => {
+    const selected = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
       includeBoundTextElement: false,
     });
     if (selected.length === 1 && selected[0].type !== "frame") {
@@ -60,12 +61,13 @@ export const actionToggleElementLock = register({
       ? "labels.elementLock.lockAll"
       : "labels.elementLock.unlockAll";
   },
-  keyTest: (event, appState, elements) => {
+  keyTest: (event, appState, elements, app) => {
     return (
       event.key.toLocaleLowerCase() === KEYS.L &&
       event[KEYS.CTRL_OR_CMD] &&
       event.shiftKey &&
-      getSelectedElements(elements, appState, {
+      app.scene.getSelectedElements({
+        selectedElementIds: appState.selectedElementIds,
         includeBoundTextElement: false,
       }).length > 0
     );

+ 4 - 2
src/actions/actionFlip.ts

@@ -17,11 +17,12 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
 export const actionFlipHorizontal = register({
   name: "flipHorizontal",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       elements: updateFrameMembershipOfSelectedElements(
         flipSelectedElements(elements, appState, "horizontal"),
         appState,
+        app,
       ),
       appState,
       commitToHistory: true,
@@ -34,11 +35,12 @@ export const actionFlipHorizontal = register({
 export const actionFlipVertical = register({
   name: "flipVertical",
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     return {
       elements: updateFrameMembershipOfSelectedElements(
         flipSelectedElements(elements, appState, "vertical"),
         appState,
+        app,
       ),
       appState,
       commitToHistory: true,

+ 11 - 22
src/actions/actionFrame.ts

@@ -3,19 +3,12 @@ import { ExcalidrawElement } from "../element/types";
 import { removeAllElementsFromFrame } from "../frame";
 import { getFrameElements } from "../frame";
 import { KEYS } from "../keys";
-import { getSelectedElements } from "../scene";
-import { AppState } from "../types";
+import { AppClassProperties, AppState } from "../types";
 import { setCursorForShape, updateActiveTool } from "../utils";
 import { register } from "./register";
 
-const isSingleFrameSelected = (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
+  const selectedElements = app.scene.getSelectedElements(appState);
 
   return selectedElements.length === 1 && selectedElements[0].type === "frame";
 };
@@ -23,11 +16,8 @@ const isSingleFrameSelected = (
 export const actionSelectAllElementsInFrame = register({
   name: "selectAllElementsInFrame",
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => {
-    const selectedFrame = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    )[0];
+  perform: (elements, appState, _, app) => {
+    const selectedFrame = app.scene.getSelectedElements(appState)[0];
 
     if (selectedFrame && selectedFrame.type === "frame") {
       const elementsInFrame = getFrameElements(
@@ -55,17 +45,15 @@ export const actionSelectAllElementsInFrame = register({
     };
   },
   contextItemLabel: "labels.selectAllElementsInFrame",
-  predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    isSingleFrameSelected(appState, app),
 });
 
 export const actionRemoveAllElementsFromFrame = register({
   name: "removeAllElementsFromFrame",
   trackEvent: { category: "history" },
-  perform: (elements, appState) => {
-    const selectedFrame = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    )[0];
+  perform: (elements, appState, _, app) => {
+    const selectedFrame = app.scene.getSelectedElements(appState)[0];
 
     if (selectedFrame && selectedFrame.type === "frame") {
       return {
@@ -87,7 +75,8 @@ export const actionRemoveAllElementsFromFrame = register({
     };
   },
   contextItemLabel: "labels.removeAllElementsFromFrame",
-  predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    isSingleFrameSelected(appState, app),
 });
 
 export const actionupdateFrameRendering = register({

+ 17 - 20
src/actions/actionGroup.tsx

@@ -4,7 +4,7 @@ import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 import { UngroupIcon, GroupIcon } from "../components/icons";
 import { newElementWith } from "../element/mutateElement";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { isSomeElementSelected } from "../scene";
 import {
   getSelectedGroupIds,
   selectGroup,
@@ -22,7 +22,7 @@ import {
   ExcalidrawFrameElement,
   ExcalidrawTextElement,
 } from "../element/types";
-import { AppState } from "../types";
+import { AppClassProperties, AppState } from "../types";
 import { isBoundToContainer } from "../element/typeChecks";
 import {
   getElementsInResizingFrame,
@@ -51,14 +51,12 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
 const enableActionGroup = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
+  app: AppClassProperties,
 ) => {
-  const selectedElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-    {
-      includeBoundTextElement: true,
-    },
-  );
+  const selectedElements = app.scene.getSelectedElements({
+    selectedElementIds: appState.selectedElementIds,
+    includeBoundTextElement: true,
+  });
   return (
     selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
   );
@@ -68,13 +66,10 @@ export const actionGroup = register({
   name: "group",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-      },
-    );
+    const selectedElements = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    });
     if (selectedElements.length < 2) {
       // nothing to group
       return { appState, elements, commitToHistory: false };
@@ -164,12 +159,13 @@ export const actionGroup = register({
     };
   },
   contextItemLabel: "labels.group",
-  predicate: (elements, appState) => enableActionGroup(elements, appState),
+  predicate: (elements, appState, _, app) =>
+    enableActionGroup(elements, appState, app),
   keyTest: (event) =>
     !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
-  PanelComponent: ({ elements, appState, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData, app }) => (
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!enableActionGroup(elements, appState, app)}
       type="button"
       icon={<GroupIcon theme={appState.theme} />}
       onClick={() => updateData(null)}
@@ -191,7 +187,7 @@ export const actionUngroup = register({
 
     let nextElements = [...elements];
 
-    const selectedElements = getSelectedElements(nextElements, appState);
+    const selectedElements = app.scene.getSelectedElements(appState);
     const frames = selectedElements
       .filter((element) => element.frameId)
       .map((element) =>
@@ -219,6 +215,7 @@ export const actionUngroup = register({
       { ...appState, selectedGroupIds: {} },
       getNonDeletedElements(nextElements),
       appState,
+      null,
     );
 
     frames.forEach((frame) => {

+ 11 - 19
src/actions/actionLinearEditor.ts

@@ -1,8 +1,6 @@
-import { getNonDeletedElements } from "../element";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { isLinearElement } from "../element/typeChecks";
 import { ExcalidrawLinearElement } from "../element/types";
-import { getSelectedElements } from "../scene";
 import { register } from "./register";
 
 export const actionToggleLinearEditor = register({
@@ -10,21 +8,18 @@ export const actionToggleLinearEditor = register({
   trackEvent: {
     category: "element",
   },
-  predicate: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState);
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
     if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
       return true;
     }
     return false;
   },
   perform(elements, appState, _, app) {
-    const selectedElement = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-      },
-    )[0] as ExcalidrawLinearElement;
+    const selectedElement = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    })[0] as ExcalidrawLinearElement;
 
     const editingLinearElement =
       appState.editingLinearElement?.elementId === selectedElement.id
@@ -38,14 +33,11 @@ export const actionToggleLinearEditor = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: (elements, appState) => {
-    const selectedElement = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-      {
-        includeBoundTextElement: true,
-      },
-    )[0] as ExcalidrawLinearElement;
+  contextItemLabel: (elements, appState, app) => {
+    const selectedElement = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    })[0] as ExcalidrawLinearElement;
     return appState.editingLinearElement?.elementId === selectedElement.id
       ? "labels.lineEditor.exit"
       : "labels.lineEditor.edit";

+ 1 - 0
src/actions/actionSelectAll.ts

@@ -42,6 +42,7 @@ export const actionSelectAll = register({
         },
         getNonDeletedElements(elements),
         appState,
+        app,
       ),
       commitToHistory: true,
     };

+ 2 - 0
src/actions/manager.tsx

@@ -90,6 +90,7 @@ export class ActionManager {
             event,
             this.getAppState(),
             this.getElementsIncludingDeleted(),
+            this.app,
           ),
       );
 
@@ -168,6 +169,7 @@ export class ActionManager {
           appState={this.getAppState()}
           updateData={updateData}
           appProps={this.app.props}
+          app={this.app}
           data={data}
         />
       );

+ 3 - 0
src/actions/types.ts

@@ -130,6 +130,7 @@ export type PanelComponentProps = {
   updateData: (formData?: any) => void;
   appProps: ExcalidrawProps;
   data?: Record<string, any>;
+  app: AppClassProperties;
 };
 
 export interface Action {
@@ -141,12 +142,14 @@ export interface Action {
     event: React.KeyboardEvent | KeyboardEvent,
     appState: AppState,
     elements: readonly ExcalidrawElement[],
+    app: AppClassProperties,
   ) => boolean;
   contextItemLabel?:
     | string
     | ((
         elements: readonly ExcalidrawElement[],
         appState: Readonly<AppState>,
+        app: AppClassProperties,
       ) => string);
   predicate?: (
     elements: readonly ExcalidrawElement[],

+ 50 - 82
src/components/App.tsx

@@ -798,10 +798,7 @@ class App extends React.Component<AppProps, AppState> {
   };
 
   public render() {
-    const selectedElement = getSelectedElements(
-      this.scene.getNonDeletedElements(),
-      this.state,
-    );
+    const selectedElement = this.scene.getSelectedElements(this.state);
     const { renderTopRightUI, renderCustomStats } = this.props;
 
     return (
@@ -858,6 +855,7 @@ class App extends React.Component<AppProps, AppState> {
                             !this.state.zenModeEnabled &&
                             !this.scene.getElementsIncludingDeleted().length
                           }
+                          app={this}
                         >
                           {this.props.children}
                         </LayerUI>
@@ -963,10 +961,7 @@ class App extends React.Component<AppProps, AppState> {
         const shouldUpdateStrokeColor =
           (type === "background" && event.altKey) ||
           (type === "stroke" && !event.altKey);
-        const selectedElements = getSelectedElements(
-          this.scene.getElementsIncludingDeleted(),
-          this.state,
-        );
+        const selectedElements = this.scene.getSelectedElements(this.state);
         if (
           !selectedElements.length ||
           this.state.activeTool.type !== "selection"
@@ -2046,6 +2041,7 @@ class App extends React.Component<AppProps, AppState> {
         },
         this.scene.getNonDeletedElements(),
         this.state,
+        this,
       ),
       () => {
         if (opts.files) {
@@ -2610,14 +2606,11 @@ class App extends React.Component<AppProps, AppState> {
           offsetY = step;
         }
 
-        const selectedElements = getSelectedElements(
-          this.scene.getNonDeletedElements(),
-          this.state,
-          {
-            includeBoundTextElement: true,
-            includeElementsInFrames: true,
-          },
-        );
+        const selectedElements = this.scene.getSelectedElements({
+          selectedElementIds: this.state.selectedElementIds,
+          includeBoundTextElement: true,
+          includeElementsInFrames: true,
+        });
 
         selectedElements.forEach((element) => {
           mutateElement(element, {
@@ -2634,10 +2627,7 @@ class App extends React.Component<AppProps, AppState> {
 
         event.preventDefault();
       } else if (event.key === KEYS.ENTER) {
-        const selectedElements = getSelectedElements(
-          this.scene.getNonDeletedElements(),
-          this.state,
-        );
+        const selectedElements = this.scene.getSelectedElements(this.state);
         if (selectedElements.length === 1) {
           const selectedElement = selectedElements[0];
           if (event[KEYS.CTRL_OR_CMD]) {
@@ -2713,10 +2703,7 @@ class App extends React.Component<AppProps, AppState> {
         !event.altKey &&
         !event[KEYS.CTRL_OR_CMD]
       ) {
-        const selectedElements = getSelectedElements(
-          this.scene.getNonDeletedElements(),
-          this.state,
-        );
+        const selectedElements = this.scene.getSelectedElements(this.state);
         if (
           this.state.activeTool.type === "selection" &&
           !selectedElements.length
@@ -2788,10 +2775,7 @@ class App extends React.Component<AppProps, AppState> {
       this.setState({ isBindingEnabled: true });
     }
     if (isArrowKey(event.key)) {
-      const selectedElements = getSelectedElements(
-        this.scene.getNonDeletedElements(),
-        this.state,
-      );
+      const selectedElements = this.scene.getSelectedElements(this.state);
       isBindingEnabled(this.state)
         ? bindOrUnbindSelectedElements(selectedElements)
         : unbindLinearElements(selectedElements);
@@ -3141,10 +3125,7 @@ class App extends React.Component<AppProps, AppState> {
     }
     let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
 
-    const selectedElements = getSelectedElements(
-      this.scene.getNonDeletedElements(),
-      this.state,
-    );
+    const selectedElements = this.scene.getSelectedElements(this.state);
 
     if (selectedElements.length === 1) {
       if (isTextElement(selectedElements[0])) {
@@ -3274,10 +3255,7 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
-    const selectedElements = getSelectedElements(
-      this.scene.getNonDeletedElements(),
-      this.state,
-    );
+    const selectedElements = this.scene.getSelectedElements(this.state);
 
     if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
       if (
@@ -3328,6 +3306,7 @@ class App extends React.Component<AppProps, AppState> {
             },
             this.scene.getNonDeletedElements(),
             prevState,
+            this,
           ),
         );
         return;
@@ -3704,7 +3683,7 @@ class App extends React.Component<AppProps, AppState> {
 
     const elements = this.scene.getNonDeletedElements();
 
-    const selectedElements = getSelectedElements(elements, this.state);
+    const selectedElements = this.scene.getSelectedElements(this.state);
     if (
       selectedElements.length === 1 &&
       !isOverScrollBar &&
@@ -4407,10 +4386,7 @@ class App extends React.Component<AppProps, AppState> {
     event: React.PointerEvent<HTMLElement>,
   ): PointerDownState {
     const origin = viewportCoordsToSceneCoords(event, this.state);
-    const selectedElements = getSelectedElements(
-      this.scene.getNonDeletedElements(),
-      this.state,
-    );
+    const selectedElements = this.scene.getSelectedElements(this.state);
     const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
 
     return {
@@ -4528,7 +4504,7 @@ class App extends React.Component<AppProps, AppState> {
   ): boolean => {
     if (this.state.activeTool.type === "selection") {
       const elements = this.scene.getNonDeletedElements();
-      const selectedElements = getSelectedElements(elements, this.state);
+      const selectedElements = this.scene.getSelectedElements(this.state);
       if (selectedElements.length === 1 && !this.state.editingLinearElement) {
         const elementWithTransformHandleType =
           getElementWithTransformHandleType(
@@ -4771,6 +4747,7 @@ class App extends React.Component<AppProps, AppState> {
                   },
                   this.scene.getNonDeletedElements(),
                   prevState,
+                  this,
                 );
               });
               pointerDownState.hit.wasAddedToSelection = true;
@@ -5198,7 +5175,7 @@ class App extends React.Component<AppProps, AppState> {
       if (pointerDownState.drag.offset === null) {
         pointerDownState.drag.offset = tupleToCoors(
           getDragOffsetXY(
-            getSelectedElements(this.scene.getNonDeletedElements(), this.state),
+            this.scene.getSelectedElements(this.state),
             pointerDownState.origin.x,
             pointerDownState.origin.y,
           ),
@@ -5361,10 +5338,7 @@ class App extends React.Component<AppProps, AppState> {
           pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
         !isSelectingPointsInLineEditor
       ) {
-        const selectedElements = getSelectedElements(
-          this.scene.getNonDeletedElements(),
-          this.state,
-        );
+        const selectedElements = this.scene.getSelectedElements(this.state);
 
         if (selectedElements.every((element) => element.locked)) {
           return;
@@ -5435,14 +5409,18 @@ class App extends React.Component<AppProps, AppState> {
             const groupIdMap = new Map();
             const oldIdToDuplicatedId = new Map();
             const hitElement = pointerDownState.hit.element;
-            const elements = this.scene.getElementsIncludingDeleted();
             const selectedElementIds = new Set(
-              getSelectedElements(elements, this.state, {
-                includeBoundTextElement: true,
-                includeElementsInFrames: true,
-              }).map((element) => element.id),
+              this.scene
+                .getSelectedElements({
+                  selectedElementIds: this.state.selectedElementIds,
+                  includeBoundTextElement: true,
+                  includeElementsInFrames: true,
+                })
+                .map((element) => element.id),
             );
 
+            const elements = this.scene.getNonDeletedElements();
+
             for (const element of elements) {
               if (
                 selectedElementIds.has(element.id) ||
@@ -5584,6 +5562,7 @@ class App extends React.Component<AppProps, AppState> {
                 },
                 this.scene.getNonDeletedElements(),
                 prevState,
+                this,
               ),
             );
           }
@@ -5641,6 +5620,7 @@ class App extends React.Component<AppProps, AppState> {
               },
               this.scene.getNonDeletedElements(),
               prevState,
+              this,
             );
           });
         }
@@ -5740,10 +5720,7 @@ class App extends React.Component<AppProps, AppState> {
           pointerDownState.hit?.element?.id !==
           this.state.selectedLinearElement.elementId
         ) {
-          const selectedELements = getSelectedElements(
-            this.scene.getNonDeletedElements(),
-            this.state,
-          );
+          const selectedELements = this.scene.getSelectedElements(this.state);
           // set selectedLinearElement to null if there is more than one element selected since we don't want to show linear element handles
           if (selectedELements.length > 1) {
             this.setState({ selectedLinearElement: null });
@@ -5985,10 +5962,7 @@ class App extends React.Component<AppProps, AppState> {
             const topLayerFrame =
               this.getTopLayerFrameAtSceneCoords(sceneCoords);
 
-            const selectedElements = getSelectedElements(
-              this.scene.getNonDeletedElements(),
-              this.state,
-            );
+            const selectedElements = this.scene.getSelectedElements(this.state);
             let nextElements = this.scene.getElementsIncludingDeleted();
 
             const updateGroupIdsAfterEditingGroup = (
@@ -6067,6 +6041,7 @@ class App extends React.Component<AppProps, AppState> {
             nextElements = updateFrameMembershipOfSelectedElements(
               this.scene.getElementsIncludingDeleted(),
               this.state,
+              this,
             );
 
             this.scene.replaceAllElements(nextElements);
@@ -6111,14 +6086,14 @@ class App extends React.Component<AppProps, AppState> {
         let nextElements = updateFrameMembershipOfSelectedElements(
           this.scene.getElementsIncludingDeleted(),
           this.state,
+          this,
         );
 
-        const selectedFrames = getSelectedElements(
-          this.scene.getElementsIncludingDeleted(),
-          this.state,
-        ).filter(
-          (element) => element.type === "frame",
-        ) as ExcalidrawFrameElement[];
+        const selectedFrames = this.scene
+          .getSelectedElements(this.state)
+          .filter(
+            (element) => element.type === "frame",
+          ) as ExcalidrawFrameElement[];
 
         for (const frame of selectedFrames) {
           nextElements = replaceAllElementsInFrame(
@@ -6143,10 +6118,7 @@ class App extends React.Component<AppProps, AppState> {
         this.state.selectedLinearElement?.elementId !== hitElement?.id &&
         isLinearElement(hitElement)
       ) {
-        const selectedELements = getSelectedElements(
-          this.scene.getNonDeletedElements(),
-          this.state,
-        );
+        const selectedELements = this.scene.getSelectedElements(this.state);
         // set selectedLinearElement when no other element selected except
         // the one we've hit
         if (selectedELements.length === 1) {
@@ -6248,7 +6220,7 @@ class App extends React.Component<AppProps, AppState> {
                 delete newSelectedElementIds[hitElement!.id];
                 const newSelectedElements = getSelectedElements(
                   this.scene.getNonDeletedElements(),
-                  { ...prevState, selectedElementIds: newSelectedElementIds },
+                  { selectedElementIds: newSelectedElementIds },
                 );
 
                 return selectGroupsForSelectedElements(
@@ -6267,6 +6239,7 @@ class App extends React.Component<AppProps, AppState> {
                   },
                   this.scene.getNonDeletedElements(),
                   prevState,
+                  this,
                 );
               });
             }
@@ -6303,6 +6276,7 @@ class App extends React.Component<AppProps, AppState> {
                 },
                 this.scene.getNonDeletedElements(),
                 prevState,
+                this,
               );
             });
           } else {
@@ -6333,6 +6307,7 @@ class App extends React.Component<AppProps, AppState> {
               },
               this.scene.getNonDeletedElements(),
               prevState,
+              this,
             ),
           }));
         }
@@ -6392,9 +6367,7 @@ class App extends React.Component<AppProps, AppState> {
       if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
         (isBindingEnabled(this.state)
           ? bindOrUnbindSelectedElements
-          : unbindLinearElements)(
-          getSelectedElements(this.scene.getNonDeletedElements(), this.state),
-        );
+          : unbindLinearElements)(this.scene.getSelectedElements(this.state));
       }
 
       if (!activeTool.locked && activeTool.type !== "freedraw") {
@@ -7101,10 +7074,7 @@ class App extends React.Component<AppProps, AppState> {
       includeLockedElements: true,
     });
 
-    const selectedElements = getSelectedElements(
-      this.scene.getNonDeletedElements(),
-      this.state,
-    );
+    const selectedElements = this.scene.getSelectedElements(this.state);
     const isHittignCommonBoundBox =
       this.isHittingCommonBoundingBoxOfSelectedElements(
         { x, y },
@@ -7134,6 +7104,7 @@ class App extends React.Component<AppProps, AppState> {
               },
               this.scene.getNonDeletedElements(),
               this.state,
+              this,
             )
           : this.state),
         showHyperlinkPopup: false,
@@ -7221,10 +7192,7 @@ class App extends React.Component<AppProps, AppState> {
     pointerDownState: PointerDownState,
     event: MouseEvent | KeyboardEvent,
   ): boolean => {
-    const selectedElements = getSelectedElements(
-      this.scene.getNonDeletedElements(),
-      this.state,
-    );
+    const selectedElements = this.scene.getSelectedElements(this.state);
     const selectedFrames = selectedElements.filter(
       (element) => element.type === "frame",
     ) as ExcalidrawFrameElement[];

+ 3 - 1
src/components/ContextMenu.tsx

@@ -82,7 +82,9 @@ export const ContextMenu = React.memo(
             let label = "";
             if (item.contextItemLabel) {
               if (typeof item.contextItemLabel === "function") {
-                label = t(item.contextItemLabel(elements, appState));
+                label = t(
+                  item.contextItemLabel(elements, appState, actionManager.app),
+                );
               } else {
                 label = t(item.contextItemLabel);
               }

+ 6 - 13
src/components/HintViewer.tsx

@@ -1,7 +1,5 @@
 import { t } from "../i18n";
-import { NonDeletedExcalidrawElement } from "../element/types";
-import { getSelectedElements } from "../scene";
-import { Device, UIAppState } from "../types";
+import { AppClassProperties, Device, UIAppState } from "../types";
 import {
   isImageElement,
   isLinearElement,
@@ -15,17 +13,12 @@ import "./HintViewer.scss";
 
 interface HintViewerProps {
   appState: UIAppState;
-  elements: readonly NonDeletedExcalidrawElement[];
   isMobile: boolean;
   device: Device;
+  app: AppClassProperties;
 }
 
-const getHints = ({
-  appState,
-  elements,
-  isMobile,
-  device,
-}: HintViewerProps) => {
+const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
 
@@ -55,7 +48,7 @@ const getHints = ({
     return t("hints.placeImage");
   }
 
-  const selectedElements = getSelectedElements(elements, appState);
+  const selectedElements = app.scene.getSelectedElements(appState);
 
   if (
     isResizing &&
@@ -115,15 +108,15 @@ const getHints = ({
 
 export const HintViewer = ({
   appState,
-  elements,
   isMobile,
   device,
+  app,
 }: HintViewerProps) => {
   let hint = getHints({
     appState,
-    elements,
     isMobile,
     device,
+    app,
   });
   if (!hint) {
     return null;

+ 4 - 1
src/components/LayerUI.tsx

@@ -72,6 +72,7 @@ interface LayerUIProps {
   onExportImage: AppClassProperties["onExportImage"];
   renderWelcomeScreen: boolean;
   children?: React.ReactNode;
+  app: AppClassProperties;
 }
 
 const DefaultMainMenu: React.FC<{
@@ -127,6 +128,7 @@ const LayerUI = ({
   onExportImage,
   renderWelcomeScreen,
   children,
+  app,
 }: LayerUIProps) => {
   const device = useDevice();
   const tunnels = useInitializeTunnels();
@@ -240,9 +242,9 @@ const LayerUI = ({
                       >
                         <HintViewer
                           appState={appState}
-                          elements={elements}
                           isMobile={device.isMobile}
                           device={device}
+                          app={app}
                         />
                         {heading}
                         <Stack.Row gap={1}>
@@ -401,6 +403,7 @@ const LayerUI = ({
       )}
       {device.isMobile && (
         <MobileMenu
+          app={app}
           appState={appState}
           elements={elements}
           actionManager={actionManager}

+ 10 - 2
src/components/MobileMenu.tsx

@@ -1,5 +1,11 @@
 import React from "react";
-import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
+import {
+  AppClassProperties,
+  AppState,
+  Device,
+  ExcalidrawProps,
+  UIAppState,
+} from "../types";
 import { ActionManager } from "../actions/manager";
 import { t } from "../i18n";
 import Stack from "./Stack";
@@ -41,6 +47,7 @@ type MobileMenuProps = {
   renderSidebars: () => JSX.Element | null;
   device: Device;
   renderWelcomeScreen: boolean;
+  app: AppClassProperties;
 };
 
 export const MobileMenu = ({
@@ -58,6 +65,7 @@ export const MobileMenu = ({
   renderSidebars,
   device,
   renderWelcomeScreen,
+  app,
 }: MobileMenuProps) => {
   const {
     WelcomeScreenCenterTunnel,
@@ -119,9 +127,9 @@ export const MobileMenu = ({
         </Section>
         <HintViewer
           appState={appState}
-          elements={elements}
           isMobile={true}
           device={device}
+          app={app}
         />
       </FixedSideContainer>
     );

+ 7 - 2
src/frame.ts

@@ -16,7 +16,7 @@ import {
 } from "./element/textElement";
 import { arrayToMap, findIndex } from "./utils";
 import { mutateElement } from "./element/mutateElement";
-import { AppState } from "./types";
+import { AppClassProperties, AppState } from "./types";
 import { getElementsWithinSelection, getSelectedElements } from "./scene";
 import { isFrameElement } from "./element";
 import { moveOneRight } from "./zindex";
@@ -571,8 +571,13 @@ export const replaceAllElementsInFrame = (
 export const updateFrameMembershipOfSelectedElements = (
   allElements: ExcalidrawElementsIncludingDeleted,
   appState: AppState,
+  app: AppClassProperties,
 ) => {
-  const selectedElements = getSelectedElements(allElements, appState);
+  const selectedElements = app.scene.getSelectedElements({
+    selectedElementIds: appState.selectedElementIds,
+    // supplying elements explicitly in case we're passed non-state elements
+    elements: allElements,
+  });
   const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
 
   if (appState.editingGroupId) {

+ 20 - 4
src/groups.ts

@@ -1,5 +1,10 @@
-import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
-import { AppState } from "./types";
+import {
+  GroupId,
+  ExcalidrawElement,
+  NonDeleted,
+  NonDeletedExcalidrawElement,
+} from "./element/types";
+import { AppClassProperties, AppState } from "./types";
 import { getSelectedElements } from "./scene";
 import { getBoundTextElement } from "./element/textElement";
 import { makeNextSelectedElementIds } from "./scene/selection";
@@ -67,12 +72,23 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
  */
 export const selectGroupsForSelectedElements = (
   appState: AppState,
-  elements: readonly NonDeleted<ExcalidrawElement>[],
+  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 = getSelectedElements(elements, appState);
+  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 {

+ 93 - 0
src/scene/Scene.ts

@@ -11,6 +11,9 @@ import {
 } from "../element";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { isFrameElement } from "../element/typeChecks";
+import { getSelectedElements } from "./selection";
+import { AppState } from "../types";
+import { Assert, SameType } from "../utility-types";
 
 type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
 type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -18,6 +21,31 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
 type SceneStateCallback = () => void;
 type SceneStateCallbackRemover = () => void;
 
+type SelectionHash = string & { __brand: "selectionHash" };
+
+const hashSelectionOpts = (
+  opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
+) => {
+  const keys = ["includeBoundTextElement", "includeElementsInFrames"] as const;
+
+  type HashableKeys = Omit<typeof opts, "selectedElementIds" | "elements">;
+
+  // just to ensure we're hashing all expected keys
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  type _ = Assert<
+    SameType<
+      Required<HashableKeys>,
+      Pick<Required<HashableKeys>, typeof keys[number]>
+    >
+  >;
+
+  let hash = "";
+  for (const key of keys) {
+    hash += `${key}:${opts[key] ? "1" : "0"}`;
+  }
+  return hash as SelectionHash;
+};
+
 // ideally this would be a branded type but it'd be insanely hard to work with
 // in our codebase
 export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
@@ -68,6 +96,15 @@ class Scene {
   private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
   private frames: readonly ExcalidrawFrameElement[] = [];
   private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
+  private selectedElementsCache: {
+    selectedElementIds: AppState["selectedElementIds"] | null;
+    elements: readonly NonDeletedExcalidrawElement[] | null;
+    cache: Map<SelectionHash, NonDeletedExcalidrawElement[]>;
+  } = {
+    selectedElementIds: null,
+    elements: null,
+    cache: new Map(),
+  };
 
   getElementsIncludingDeleted() {
     return this.elements;
@@ -81,6 +118,52 @@ class Scene {
     return this.frames;
   }
 
+  getSelectedElements(opts: {
+    // NOTE can be ommitted by making Scene constructor require App instance
+    selectedElementIds: AppState["selectedElementIds"];
+    /**
+     * for specific cases where you need to use elements not from current
+     * scene state. This in effect will likely result in cache-miss, and
+     * the cache won't be updated in this case.
+     */
+    elements?: readonly ExcalidrawElement[];
+    // selection-related options
+    includeBoundTextElement?: boolean;
+    includeElementsInFrames?: boolean;
+  }): NonDeleted<ExcalidrawElement>[] {
+    const hash = hashSelectionOpts(opts);
+
+    const elements = opts?.elements || this.nonDeletedElements;
+    if (
+      this.selectedElementsCache.elements === elements &&
+      this.selectedElementsCache.selectedElementIds === opts.selectedElementIds
+    ) {
+      const cached = this.selectedElementsCache.cache.get(hash);
+      if (cached) {
+        return cached;
+      }
+    } else if (opts?.elements == null) {
+      // if we're operating on latest scene elements and the cache is not
+      //  storing the latest elements, clear the cache
+      this.selectedElementsCache.cache.clear();
+    }
+
+    const selectedElements = getSelectedElements(
+      elements,
+      { selectedElementIds: opts.selectedElementIds },
+      opts,
+    );
+
+    // cache only if we're not using custom elements
+    if (opts?.elements == null) {
+      this.selectedElementsCache.selectedElementIds = opts.selectedElementIds;
+      this.selectedElementsCache.elements = this.nonDeletedElements;
+      this.selectedElementsCache.cache.set(hash, selectedElements);
+    }
+
+    return selectedElements;
+  }
+
   getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
     return this.nonDeletedFrames;
   }
@@ -168,11 +251,21 @@ class Scene {
   }
 
   destroy() {
+    this.nonDeletedElements = [];
+    this.elements = [];
+    this.nonDeletedFrames = [];
+    this.frames = [];
+    this.elementsMap.clear();
+    this.selectedElementsCache.selectedElementIds = null;
+    this.selectedElementsCache.elements = null;
+    this.selectedElementsCache.cache.clear();
+
     Scene.sceneMapById.forEach((scene, elementKey) => {
       if (scene === this) {
         Scene.sceneMapById.delete(elementKey);
       }
     });
+
     // done not for memory leaks, but to guard against possible late fires
     // (I guess?)
     this.callbacks.clear();

+ 36 - 36
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -1527,14 +1527,14 @@ Object {
   "roundness": Object {
     "type": 3,
   },
-  "seed": 449462985,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 2,
-  "versionNonce": 453191,
+  "versionNonce": 449462985,
   "width": 20,
   "x": -10,
   "y": 0,
@@ -1586,14 +1586,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 449462985,
           "width": 20,
           "x": -10,
           "y": 0,
@@ -4271,14 +4271,14 @@ Object {
   "roundness": Object {
     "type": 3,
   },
-  "seed": 449462985,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 238820263,
+  "versionNonce": 1014066025,
   "width": 20,
   "x": -10,
   "y": 0,
@@ -4303,14 +4303,14 @@ Object {
   "roundness": Object {
     "type": 3,
   },
-  "seed": 401146281,
+  "seed": 453191,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 400692809,
+  "versionNonce": 238820263,
   "width": 20,
   "x": 20,
   "y": 30,
@@ -4362,14 +4362,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 449462985,
           "width": 20,
           "x": -10,
           "y": 0,
@@ -4405,14 +4405,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 449462985,
           "width": 20,
           "x": -10,
           "y": 0,
@@ -4434,14 +4434,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 401146281,
+          "seed": 453191,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 401146281,
           "width": 20,
           "x": 20,
           "y": 30,
@@ -4482,14 +4482,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 3,
-          "versionNonce": 1116226695,
+          "versionNonce": 1150084233,
           "width": 20,
           "x": -10,
           "y": 0,
@@ -4513,14 +4513,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 401146281,
+          "seed": 453191,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 3,
-          "versionNonce": 1014066025,
+          "versionNonce": 1116226695,
           "width": 20,
           "x": 20,
           "y": 30,
@@ -4557,14 +4557,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 4,
-          "versionNonce": 238820263,
+          "versionNonce": 1014066025,
           "width": 20,
           "x": -10,
           "y": 0,
@@ -4586,14 +4586,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 401146281,
+          "seed": 453191,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 4,
-          "versionNonce": 400692809,
+          "versionNonce": 238820263,
           "width": 20,
           "x": 20,
           "y": 30,
@@ -5585,14 +5585,14 @@ Object {
   "roundness": Object {
     "type": 3,
   },
-  "seed": 449462985,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 3,
-  "versionNonce": 1014066025,
+  "versionNonce": 1116226695,
   "width": 10,
   "x": -10,
   "y": 0,
@@ -5619,14 +5619,14 @@ Object {
   "roundness": Object {
     "type": 3,
   },
-  "seed": 401146281,
+  "seed": 453191,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
   "updated": 1,
   "version": 3,
-  "versionNonce": 238820263,
+  "versionNonce": 1014066025,
   "width": 10,
   "x": 10,
   "y": 0,
@@ -5678,14 +5678,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 449462985,
           "width": 10,
           "x": -10,
           "y": 0,
@@ -5721,14 +5721,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 449462985,
           "width": 10,
           "x": -10,
           "y": 0,
@@ -5750,14 +5750,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 401146281,
+          "seed": 453191,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 401146281,
           "width": 10,
           "x": 10,
           "y": 0,
@@ -5798,14 +5798,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 3,
-          "versionNonce": 1014066025,
+          "versionNonce": 1116226695,
           "width": 10,
           "x": -10,
           "y": 0,
@@ -5829,14 +5829,14 @@ Object {
           "roundness": Object {
             "type": 3,
           },
-          "seed": 401146281,
+          "seed": 453191,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 1,
           "type": "rectangle",
           "updated": 1,
           "version": 3,
-          "versionNonce": 238820263,
+          "versionNonce": 1014066025,
           "width": 10,
           "x": 10,
           "y": 0,

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


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

@@ -90,6 +90,7 @@ const populateElements = (
       { ...h.state, ...appState, selectedElementIds },
       h.elements,
       h.state,
+      null,
     ),
     ...appState,
     selectedElementIds,

+ 3 - 0
src/utility-types.ts

@@ -47,3 +47,6 @@ export type ForwardRef<T, P = any> = Parameters<
 export type ExtractSetType<T extends Set<any>> = T extends Set<infer U>
   ? U
   : never;
+
+export type SameType<T, U> = T extends U ? (U extends T ? true : false) : false;
+export type Assert<T extends true> = T;

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