Bläddra i källkod

refactor: decoupling global Scene state part-1 (#7577)

David Luzar 1 år sedan
förälder
incheckning
0415c616b1
31 ändrade filer med 631 tillägg och 385 borttagningar
  1. 28 10
      packages/excalidraw/actions/actionFlip.ts
  2. 1 5
      packages/excalidraw/actions/actionFrame.ts
  3. 1 6
      packages/excalidraw/actions/actionGroup.tsx
  4. 27 15
      packages/excalidraw/actions/actionProperties.tsx
  5. 7 11
      packages/excalidraw/components/Actions.tsx
  6. 28 24
      packages/excalidraw/components/App.tsx
  7. 1 1
      packages/excalidraw/components/LayerUI.tsx
  8. 1 1
      packages/excalidraw/components/MobileMenu.tsx
  9. 5 4
      packages/excalidraw/components/canvases/InteractiveCanvas.tsx
  10. 8 5
      packages/excalidraw/components/canvases/StaticCanvas.tsx
  11. 12 9
      packages/excalidraw/data/restore.ts
  12. 8 5
      packages/excalidraw/element/bounds.ts
  13. 2 22
      packages/excalidraw/element/embeddable.ts
  14. 3 3
      packages/excalidraw/element/newElement.ts
  15. 34 19
      packages/excalidraw/element/resizeElements.ts
  16. 18 31
      packages/excalidraw/element/textElement.ts
  17. 13 4
      packages/excalidraw/element/textWysiwyg.tsx
  18. 29 1
      packages/excalidraw/element/types.ts
  19. 65 47
      packages/excalidraw/frame.ts
  20. 11 2
      packages/excalidraw/groups.ts
  21. 10 3
      packages/excalidraw/renderer/renderElement.ts
  22. 105 87
      packages/excalidraw/renderer/renderScene.ts
  23. 8 1
      packages/excalidraw/scene/Fonts.ts
  24. 39 23
      packages/excalidraw/scene/Renderer.ts
  25. 60 12
      packages/excalidraw/scene/Scene.ts
  26. 37 19
      packages/excalidraw/scene/export.ts
  27. 3 4
      packages/excalidraw/scene/scrollbars.ts
  28. 10 7
      packages/excalidraw/scene/selection.ts
  29. 8 3
      packages/excalidraw/scene/types.ts
  30. 8 0
      packages/excalidraw/utility-types.ts
  31. 41 1
      packages/excalidraw/utils.ts

+ 28 - 10
packages/excalidraw/actions/actionFlip.ts

@@ -1,9 +1,13 @@
 import { register } from "./register";
 import { getSelectedElements } from "../scene";
 import { getNonDeletedElements } from "../element";
-import { ExcalidrawElement, NonDeleted } from "../element/types";
+import {
+  ExcalidrawElement,
+  NonDeleted,
+  NonDeletedElementsMap,
+} from "../element/types";
 import { resizeMultipleElements } from "../element/resizeElements";
-import { AppState, PointerDownState } from "../types";
+import { AppState } from "../types";
 import { arrayToMap } from "../utils";
 import { CODES, KEYS } from "../keys";
 import { getCommonBoundingBox } from "../element/bounds";
@@ -20,7 +24,12 @@ export const actionFlipHorizontal = register({
   perform: (elements, appState, _, app) => {
     return {
       elements: updateFrameMembershipOfSelectedElements(
-        flipSelectedElements(elements, appState, "horizontal"),
+        flipSelectedElements(
+          elements,
+          app.scene.getNonDeletedElementsMap(),
+          appState,
+          "horizontal",
+        ),
         appState,
         app,
       ),
@@ -38,7 +47,12 @@ export const actionFlipVertical = register({
   perform: (elements, appState, _, app) => {
     return {
       elements: updateFrameMembershipOfSelectedElements(
-        flipSelectedElements(elements, appState, "vertical"),
+        flipSelectedElements(
+          elements,
+          app.scene.getNonDeletedElementsMap(),
+          appState,
+          "vertical",
+        ),
         appState,
         app,
       ),
@@ -53,6 +67,7 @@ export const actionFlipVertical = register({
 
 const flipSelectedElements = (
   elements: readonly ExcalidrawElement[],
+  elementsMap: NonDeletedElementsMap,
   appState: Readonly<AppState>,
   flipDirection: "horizontal" | "vertical",
 ) => {
@@ -67,6 +82,7 @@ const flipSelectedElements = (
 
   const updatedElements = flipElements(
     selectedElements,
+    elementsMap,
     appState,
     flipDirection,
   );
@@ -79,15 +95,17 @@ const flipSelectedElements = (
 };
 
 const flipElements = (
-  elements: NonDeleted<ExcalidrawElement>[],
+  selectedElements: NonDeleted<ExcalidrawElement>[],
+  elementsMap: NonDeletedElementsMap,
   appState: AppState,
   flipDirection: "horizontal" | "vertical",
 ): ExcalidrawElement[] => {
-  const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements);
+  const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
 
   resizeMultipleElements(
-    { originalElements: arrayToMap(elements) } as PointerDownState,
-    elements,
+    elementsMap,
+    selectedElements,
+    elementsMap,
     "nw",
     true,
     flipDirection === "horizontal" ? maxX : minX,
@@ -96,7 +114,7 @@ const flipElements = (
 
   (isBindingEnabled(appState)
     ? bindOrUnbindSelectedElements
-    : unbindLinearElements)(elements);
+    : unbindLinearElements)(selectedElements);
 
-  return elements;
+  return selectedElements;
 };

+ 1 - 5
packages/excalidraw/actions/actionFrame.ts

@@ -63,11 +63,7 @@ export const actionRemoveAllElementsFromFrame = register({
 
     if (isFrameLikeElement(selectedElement)) {
       return {
-        elements: removeAllElementsFromFrame(
-          elements,
-          selectedElement,
-          appState,
-        ),
+        elements: removeAllElementsFromFrame(elements, selectedElement),
         appState: {
           ...appState,
           selectedElementIds: {

+ 1 - 6
packages/excalidraw/actions/actionGroup.tsx

@@ -105,11 +105,7 @@ export const actionGroup = register({
       const frameElementsMap = groupByFrameLikes(selectedElements);
 
       frameElementsMap.forEach((elementsInFrame, frameId) => {
-        nextElements = removeElementsFromFrame(
-          nextElements,
-          elementsInFrame,
-          appState,
-        );
+        removeElementsFromFrame(elementsInFrame);
       });
     }
 
@@ -229,7 +225,6 @@ export const actionUngroup = register({
           nextElements,
           getElementsInResizingFrame(nextElements, frame, appState),
           frame,
-          appState,
         );
       }
     });

+ 27 - 15
packages/excalidraw/actions/actionProperties.tsx

@@ -1,4 +1,4 @@
-import { AppState, Primitive } from "../types";
+import { AppClassProperties, AppState, Primitive } from "../types";
 import {
   DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
   DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -66,7 +66,6 @@ import {
 import { mutateElement, newElementWith } from "../element/mutateElement";
 import {
   getBoundTextElement,
-  getContainerElement,
   getDefaultLineHeight,
 } from "../element/textElement";
 import {
@@ -189,6 +188,7 @@ const offsetElementAfterFontResize = (
 const changeFontSize = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
+  app: AppClassProperties,
   getNewFontSize: (element: ExcalidrawTextElement) => number,
   fallbackValue?: ExcalidrawTextElement["fontSize"],
 ) => {
@@ -206,7 +206,10 @@ const changeFontSize = (
           let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
             fontSize: newFontSize,
           });
-          redrawTextBoundingBox(newElement, getContainerElement(oldElement));
+          redrawTextBoundingBox(
+            newElement,
+            app.scene.getContainerElement(oldElement),
+          );
 
           newElement = offsetElementAfterFontResize(oldElement, newElement);
 
@@ -600,8 +603,8 @@ export const actionChangeOpacity = register({
 export const actionChangeFontSize = register({
   name: "changeFontSize",
   trackEvent: false,
-  perform: (elements, appState, value) => {
-    return changeFontSize(elements, appState, () => value, value);
+  perform: (elements, appState, value, app) => {
+    return changeFontSize(elements, appState, app, () => value, value);
   },
   PanelComponent: ({ elements, appState, updateData }) => (
     <fieldset>
@@ -663,8 +666,8 @@ export const actionChangeFontSize = register({
 export const actionDecreaseFontSize = register({
   name: "decreaseFontSize",
   trackEvent: false,
-  perform: (elements, appState, value) => {
-    return changeFontSize(elements, appState, (element) =>
+  perform: (elements, appState, value, app) => {
+    return changeFontSize(elements, appState, app, (element) =>
       Math.round(
         // get previous value before relative increase (doesn't work fully
         // due to rounding and float precision issues)
@@ -685,8 +688,8 @@ export const actionDecreaseFontSize = register({
 export const actionIncreaseFontSize = register({
   name: "increaseFontSize",
   trackEvent: false,
-  perform: (elements, appState, value) => {
-    return changeFontSize(elements, appState, (element) =>
+  perform: (elements, appState, value, app) => {
+    return changeFontSize(elements, appState, app, (element) =>
       Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
     );
   },
@@ -703,7 +706,7 @@ export const actionIncreaseFontSize = register({
 export const actionChangeFontFamily = register({
   name: "changeFontFamily",
   trackEvent: false,
-  perform: (elements, appState, value) => {
+  perform: (elements, appState, value, app) => {
     return {
       elements: changeProperty(
         elements,
@@ -717,7 +720,10 @@ export const actionChangeFontFamily = register({
                 lineHeight: getDefaultLineHeight(value),
               },
             );
-            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
+            redrawTextBoundingBox(
+              newElement,
+              app.scene.getContainerElement(oldElement),
+            );
             return newElement;
           }
 
@@ -795,7 +801,7 @@ export const actionChangeFontFamily = register({
 export const actionChangeTextAlign = register({
   name: "changeTextAlign",
   trackEvent: false,
-  perform: (elements, appState, value) => {
+  perform: (elements, appState, value, app) => {
     return {
       elements: changeProperty(
         elements,
@@ -806,7 +812,10 @@ export const actionChangeTextAlign = register({
               oldElement,
               { textAlign: value },
             );
-            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
+            redrawTextBoundingBox(
+              newElement,
+              app.scene.getContainerElement(oldElement),
+            );
             return newElement;
           }
 
@@ -875,7 +884,7 @@ export const actionChangeTextAlign = register({
 export const actionChangeVerticalAlign = register({
   name: "changeVerticalAlign",
   trackEvent: { category: "element" },
-  perform: (elements, appState, value) => {
+  perform: (elements, appState, value, app) => {
     return {
       elements: changeProperty(
         elements,
@@ -887,7 +896,10 @@ export const actionChangeVerticalAlign = register({
               { verticalAlign: value },
             );
 
-            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
+            redrawTextBoundingBox(
+              newElement,
+              app.scene.getContainerElement(oldElement),
+            );
             return newElement;
           }
 

+ 7 - 11
packages/excalidraw/components/Actions.tsx

@@ -1,7 +1,6 @@
-import React, { useState } from "react";
+import { useState } from "react";
 import { ActionManager } from "../actions/manager";
-import { getNonDeletedElements } from "../element";
-import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
+import { ExcalidrawElementType, NonDeletedElementsMap } from "../element/types";
 import { t } from "../i18n";
 import { useDevice } from "./App";
 import {
@@ -44,17 +43,14 @@ import { useTunnels } from "../context/tunnels";
 
 export const SelectedShapeActions = ({
   appState,
-  elements,
+  elementsMap,
   renderAction,
 }: {
   appState: UIAppState;
-  elements: readonly ExcalidrawElement[];
+  elementsMap: NonDeletedElementsMap;
   renderAction: ActionManager["renderAction"];
 }) => {
-  const targetElements = getTargetElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
+  const targetElements = getTargetElements(elementsMap, appState);
 
   let isSingleElementBoundContainer = false;
   if (
@@ -137,12 +133,12 @@ export const SelectedShapeActions = ({
           {renderAction("changeFontFamily")}
 
           {(appState.activeTool.type === "text" ||
-            suppportsHorizontalAlign(targetElements)) &&
+            suppportsHorizontalAlign(targetElements, elementsMap)) &&
             renderAction("changeTextAlign")}
         </>
       )}
 
-      {shouldAllowVerticalAlign(targetElements) &&
+      {shouldAllowVerticalAlign(targetElements, elementsMap) &&
         renderAction("changeVerticalAlign")}
       {(canHaveArrowheads(appState.activeTool.type) ||
         targetElements.some((element) => canHaveArrowheads(element.type))) && (

+ 28 - 24
packages/excalidraw/components/App.tsx

@@ -1417,7 +1417,7 @@ class App extends React.Component<AppProps, AppState> {
     const { renderTopRightUI, renderCustomStats } = this.props;
 
     const versionNonce = this.scene.getVersionNonce();
-    const { canvasElements, visibleElements } =
+    const { elementsMap, visibleElements } =
       this.renderer.getRenderableElements({
         versionNonce,
         zoom: this.state.zoom,
@@ -1627,7 +1627,7 @@ class App extends React.Component<AppProps, AppState> {
                         <StaticCanvas
                           canvas={this.canvas}
                           rc={this.rc}
-                          elements={canvasElements}
+                          elementsMap={elementsMap}
                           visibleElements={visibleElements}
                           versionNonce={versionNonce}
                           selectionNonce={
@@ -1648,7 +1648,7 @@ class App extends React.Component<AppProps, AppState> {
                         <InteractiveCanvas
                           containerRef={this.excalidrawContainerRef}
                           canvas={this.interactiveCanvas}
-                          elements={canvasElements}
+                          elementsMap={elementsMap}
                           visibleElements={visibleElements}
                           selectedElements={selectedElements}
                           versionNonce={versionNonce}
@@ -2780,7 +2780,7 @@ class App extends React.Component<AppProps, AppState> {
   private renderInteractiveSceneCallback = ({
     atLeastOneVisibleElement,
     scrollBars,
-    elements,
+    elementsMap,
   }: RenderInteractiveSceneCallback) => {
     if (scrollBars) {
       currentScrollBars = scrollBars;
@@ -2789,7 +2789,7 @@ class App extends React.Component<AppProps, AppState> {
       // hide when editing text
       isTextElement(this.state.editingElement)
         ? false
-        : !atLeastOneVisibleElement && elements.length > 0;
+        : !atLeastOneVisibleElement && elementsMap.size > 0;
     if (this.state.scrolledOutside !== scrolledOutside) {
       this.setState({ scrolledOutside });
     }
@@ -3119,7 +3119,10 @@ class App extends React.Component<AppProps, AppState> {
 
     newElements.forEach((newElement) => {
       if (isTextElement(newElement) && isBoundToContainer(newElement)) {
-        const container = getContainerElement(newElement);
+        const container = getContainerElement(
+          newElement,
+          this.scene.getElementsMapIncludingDeleted(),
+        );
         redrawTextBoundingBox(newElement, container);
       }
     });
@@ -4183,11 +4186,18 @@ class App extends React.Component<AppProps, AppState> {
       this.scene.replaceAllElements([
         ...this.scene.getElementsIncludingDeleted().map((_element) => {
           if (_element.id === element.id && isTextElement(_element)) {
-            return updateTextElement(_element, {
-              text,
-              isDeleted,
-              originalText,
-            });
+            return updateTextElement(
+              _element,
+              getContainerElement(
+                _element,
+                this.scene.getElementsMapIncludingDeleted(),
+              ),
+              {
+                text,
+                isDeleted,
+                originalText,
+              },
+            );
           }
           return _element;
         }),
@@ -7700,13 +7710,9 @@ class App extends React.Component<AppProps, AppState> {
                     groupIds: [],
                   });
 
-                  this.scene.replaceAllElements(
-                    removeElementsFromFrame(
-                      this.scene.getElementsIncludingDeleted(),
-                      [linearElement],
-                      this.state,
-                    ),
-                  );
+                  removeElementsFromFrame([linearElement]);
+
+                  this.scene.informMutation();
                 }
               }
             }
@@ -7716,7 +7722,7 @@ class App extends React.Component<AppProps, AppState> {
               this.getTopLayerFrameAtSceneCoords(sceneCoords);
 
             const selectedElements = this.scene.getSelectedElements(this.state);
-            let nextElements = this.scene.getElementsIncludingDeleted();
+            let nextElements = this.scene.getElementsMapIncludingDeleted();
 
             const updateGroupIdsAfterEditingGroup = (
               elements: ExcalidrawElement[],
@@ -7809,7 +7815,7 @@ class App extends React.Component<AppProps, AppState> {
 
           this.scene.replaceAllElements(
             addElementsToFrame(
-              this.scene.getElementsIncludingDeleted(),
+              this.scene.getElementsMapIncludingDeleted(),
               elementsInsideFrame,
               draggingElement,
             ),
@@ -7857,7 +7863,6 @@ class App extends React.Component<AppProps, AppState> {
               this.state,
             ),
             frame,
-            this.state,
           );
         }
 
@@ -9137,10 +9142,10 @@ class App extends React.Component<AppProps, AppState> {
 
     if (
       transformElements(
-        pointerDownState,
+        pointerDownState.originalElements,
         transformHandleType,
         selectedElements,
-        pointerDownState.resize.arrowDirection,
+        this.scene.getElementsMapIncludingDeleted(),
         shouldRotateWithDiscreteAngle(event),
         shouldResizeFromCenter(event),
         selectedElements.length === 1 && isImageElement(selectedElements[0])
@@ -9150,7 +9155,6 @@ class App extends React.Component<AppProps, AppState> {
         resizeY,
         pointerDownState.resize.center.x,
         pointerDownState.resize.center.y,
-        this.state,
       )
     ) {
       this.maybeSuggestBindingForAll(selectedElements);

+ 1 - 1
packages/excalidraw/components/LayerUI.tsx

@@ -226,7 +226,7 @@ const LayerUI = ({
       >
         <SelectedShapeActions
           appState={appState}
-          elements={elements}
+          elementsMap={app.scene.getNonDeletedElementsMap()}
           renderAction={actionManager.renderAction}
         />
       </Island>

+ 1 - 1
packages/excalidraw/components/MobileMenu.tsx

@@ -183,7 +183,7 @@ export const MobileMenu = ({
             <Section className="App-mobile-menu" heading="selectedShapeActions">
               <SelectedShapeActions
                 appState={appState}
-                elements={elements}
+                elementsMap={app.scene.getNonDeletedElementsMap()}
                 renderAction={actionManager.renderAction}
               />
             </Section>

+ 5 - 4
packages/excalidraw/components/canvases/InteractiveCanvas.tsx

@@ -7,6 +7,7 @@ import type { DOMAttributes } from "react";
 import type { AppState, InteractiveCanvasAppState } from "../../types";
 import type {
   InteractiveCanvasRenderConfig,
+  RenderableElementsMap,
   RenderInteractiveSceneCallback,
 } from "../../scene/types";
 import type { NonDeletedExcalidrawElement } from "../../element/types";
@@ -15,7 +16,7 @@ import { isRenderThrottlingEnabled } from "../../reactUtils";
 type InteractiveCanvasProps = {
   containerRef: React.RefObject<HTMLDivElement>;
   canvas: HTMLCanvasElement | null;
-  elements: readonly NonDeletedExcalidrawElement[];
+  elementsMap: RenderableElementsMap;
   visibleElements: readonly NonDeletedExcalidrawElement[];
   selectedElements: readonly NonDeletedExcalidrawElement[];
   versionNonce: number | undefined;
@@ -113,7 +114,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
     renderInteractiveScene(
       {
         canvas: props.canvas,
-        elements: props.elements,
+        elementsMap: props.elementsMap,
         visibleElements: props.visibleElements,
         selectedElements: props.selectedElements,
         scale: window.devicePixelRatio,
@@ -201,10 +202,10 @@ const areEqual = (
     prevProps.selectionNonce !== nextProps.selectionNonce ||
     prevProps.versionNonce !== nextProps.versionNonce ||
     prevProps.scale !== nextProps.scale ||
-    // we need to memoize on element arrays because they may have renewed
+    // we need to memoize on elementsMap 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.elementsMap !== nextProps.elementsMap ||
     prevProps.visibleElements !== nextProps.visibleElements ||
     prevProps.selectedElements !== nextProps.selectedElements
   ) {

+ 8 - 5
packages/excalidraw/components/canvases/StaticCanvas.tsx

@@ -3,14 +3,17 @@ import { RoughCanvas } from "roughjs/bin/canvas";
 import { renderStaticScene } from "../../renderer/renderScene";
 import { isShallowEqual } from "../../utils";
 import type { AppState, StaticCanvasAppState } from "../../types";
-import type { StaticCanvasRenderConfig } from "../../scene/types";
+import type {
+  RenderableElementsMap,
+  StaticCanvasRenderConfig,
+} from "../../scene/types";
 import type { NonDeletedExcalidrawElement } from "../../element/types";
 import { isRenderThrottlingEnabled } from "../../reactUtils";
 
 type StaticCanvasProps = {
   canvas: HTMLCanvasElement;
   rc: RoughCanvas;
-  elements: readonly NonDeletedExcalidrawElement[];
+  elementsMap: RenderableElementsMap;
   visibleElements: readonly NonDeletedExcalidrawElement[];
   versionNonce: number | undefined;
   selectionNonce: number | undefined;
@@ -63,7 +66,7 @@ const StaticCanvas = (props: StaticCanvasProps) => {
         canvas,
         rc: props.rc,
         scale: props.scale,
-        elements: props.elements,
+        elementsMap: props.elementsMap,
         visibleElements: props.visibleElements,
         appState: props.appState,
         renderConfig: props.renderConfig,
@@ -106,10 +109,10 @@ const areEqual = (
   if (
     prevProps.versionNonce !== nextProps.versionNonce ||
     prevProps.scale !== nextProps.scale ||
-    // we need to memoize on element arrays because they may have renewed
+    // we need to memoize on elementsMap 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.elementsMap !== nextProps.elementsMap ||
     prevProps.visibleElements !== nextProps.visibleElements
   ) {
     return false;

+ 12 - 9
packages/excalidraw/data/restore.ts

@@ -40,6 +40,7 @@ import { arrayToMap } from "../utils";
 import { MarkOptional, Mutable } from "../utility-types";
 import {
   detectLineHeight,
+  getContainerElement,
   getDefaultLineHeight,
   measureBaseline,
 } from "../element/textElement";
@@ -179,7 +180,6 @@ const restoreElementWithProperties = <
 
 const restoreElement = (
   element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
-  refreshDimensions = false,
 ): typeof element | null => {
   switch (element.type) {
     case "text":
@@ -232,10 +232,6 @@ const restoreElement = (
         element = bumpVersion(element);
       }
 
-      if (refreshDimensions) {
-        element = { ...element, ...refreshTextDimensions(element) };
-      }
-
       return element;
     case "freedraw": {
       return restoreElementWithProperties(element, {
@@ -426,10 +422,7 @@ export const restoreElements = (
     // filtering out selection, which is legacy, no longer kept in elements,
     // and causing issues if retained
     if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
-      let migratedElement: ExcalidrawElement | null = restoreElement(
-        element,
-        opts?.refreshDimensions,
-      );
+      let migratedElement: ExcalidrawElement | null = restoreElement(element);
       if (migratedElement) {
         const localElement = localElementsMap?.get(element.id);
         if (localElement && localElement.version > migratedElement.version) {
@@ -462,6 +455,16 @@ export const restoreElements = (
     } else if (element.boundElements) {
       repairContainerElement(element, restoredElementsMap);
     }
+
+    if (opts.refreshDimensions && isTextElement(element)) {
+      Object.assign(
+        element,
+        refreshTextDimensions(
+          element,
+          getContainerElement(element, restoredElementsMap),
+        ),
+      );
+    }
   }
 
   return restoredElements;

+ 8 - 5
packages/excalidraw/element/bounds.ts

@@ -5,6 +5,7 @@ import {
   ExcalidrawFreeDrawElement,
   NonDeleted,
   ExcalidrawTextElementWithContainer,
+  ElementsMapOrArray,
 } from "./types";
 import { distance2d, rotate, rotatePoint } from "../math";
 import rough from "roughjs/bin/rough";
@@ -161,7 +162,11 @@ export const getElementAbsoluteCoords = (
       includeBoundText,
     );
   } else if (isTextElement(element)) {
-    const container = getContainerElement(element);
+    const elementsMap =
+      Scene.getScene(element)?.getElementsMapIncludingDeleted();
+    const container = elementsMap
+      ? getContainerElement(element, elementsMap)
+      : null;
     if (isArrowElement(container)) {
       const coords = LinearElementEditor.getBoundTextElementPosition(
         container,
@@ -729,10 +734,8 @@ const getLinearElementRotatedBounds = (
 export const getElementBounds = (element: ExcalidrawElement): Bounds => {
   return ElementBounds.getBounds(element);
 };
-export const getCommonBounds = (
-  elements: readonly ExcalidrawElement[],
-): Bounds => {
-  if (!elements.length) {
+export const getCommonBounds = (elements: ElementsMapOrArray): Bounds => {
+  if ("size" in elements ? !elements.size : !elements.length) {
     return [0, 0, 0, 0];
   }
 

+ 2 - 22
packages/excalidraw/element/embeddable.ts

@@ -5,17 +5,12 @@ import { ExcalidrawProps } from "../types";
 import { getFontString, updateActiveTool } from "../utils";
 import { setCursorForShape } from "../cursor";
 import { newTextElement } from "./newElement";
-import { getContainerElement, wrapText } from "./textElement";
-import {
-  isFrameLikeElement,
-  isIframeElement,
-  isIframeLikeElement,
-} from "./typeChecks";
+import { wrapText } from "./textElement";
+import { isIframeElement } from "./typeChecks";
 import {
   ExcalidrawElement,
   ExcalidrawIframeLikeElement,
   IframeData,
-  NonDeletedExcalidrawElement,
 } from "./types";
 
 const embeddedLinkCache = new Map<string, IframeData>();
@@ -217,21 +212,6 @@ export const getEmbedLink = (
   return { link, intrinsicSize: aspectRatio, type };
 };
 
-export const isIframeLikeOrItsLabel = (
-  element: NonDeletedExcalidrawElement,
-): Boolean => {
-  if (isIframeLikeElement(element)) {
-    return true;
-  }
-  if (element.type === "text") {
-    const container = getContainerElement(element);
-    if (container && isFrameLikeElement(container)) {
-      return true;
-    }
-  }
-  return false;
-};
-
 export const createPlaceholderEmbeddableLabel = (
   element: ExcalidrawIframeLikeElement,
 ): ExcalidrawElement => {

+ 3 - 3
packages/excalidraw/element/newElement.ts

@@ -31,7 +31,6 @@ import { getElementAbsoluteCoords } from ".";
 import { adjustXYWithRotation } from "../math";
 import { getResizedElementAbsoluteCoords } from "./bounds";
 import {
-  getContainerElement,
   measureText,
   normalizeText,
   wrapText,
@@ -333,12 +332,12 @@ const getAdjustedDimensions = (
 
 export const refreshTextDimensions = (
   textElement: ExcalidrawTextElement,
+  container: ExcalidrawTextContainer | null,
   text = textElement.text,
 ) => {
   if (textElement.isDeleted) {
     return;
   }
-  const container = getContainerElement(textElement);
   if (container) {
     text = wrapText(
       text,
@@ -352,6 +351,7 @@ export const refreshTextDimensions = (
 
 export const updateTextElement = (
   textElement: ExcalidrawTextElement,
+  container: ExcalidrawTextContainer | null,
   {
     text,
     isDeleted,
@@ -365,7 +365,7 @@ export const updateTextElement = (
   return newElementWith(textElement, {
     originalText,
     isDeleted: isDeleted ?? textElement.isDeleted,
-    ...refreshTextDimensions(textElement, originalText),
+    ...refreshTextDimensions(textElement, container, originalText),
   });
 };
 

+ 34 - 19
packages/excalidraw/element/resizeElements.ts

@@ -15,6 +15,7 @@ import {
   ExcalidrawElement,
   ExcalidrawTextElementWithContainer,
   ExcalidrawImageElement,
+  ElementsMap,
 } from "./types";
 import type { Mutable } from "../utility-types";
 import {
@@ -41,7 +42,7 @@ import {
   MaybeTransformHandleType,
   TransformHandleDirection,
 } from "./transformHandles";
-import { AppState, Point, PointerDownState } from "../types";
+import { Point, PointerDownState } from "../types";
 import Scene from "../scene/Scene";
 import {
   getApproxMinLineWidth,
@@ -68,10 +69,10 @@ export const normalizeAngle = (angle: number): number => {
 
 // Returns true when transform (resizing/rotation) happened
 export const transformElements = (
-  pointerDownState: PointerDownState,
+  originalElements: PointerDownState["originalElements"],
   transformHandleType: MaybeTransformHandleType,
   selectedElements: readonly NonDeletedExcalidrawElement[],
-  resizeArrowDirection: "origin" | "end",
+  elementsMap: ElementsMap,
   shouldRotateWithDiscreteAngle: boolean,
   shouldResizeFromCenter: boolean,
   shouldMaintainAspectRatio: boolean,
@@ -79,7 +80,6 @@ export const transformElements = (
   pointerY: number,
   centerX: number,
   centerY: number,
-  appState: AppState,
 ) => {
   if (selectedElements.length === 1) {
     const [element] = selectedElements;
@@ -89,7 +89,6 @@ export const transformElements = (
         pointerX,
         pointerY,
         shouldRotateWithDiscreteAngle,
-        pointerDownState.originalElements,
       );
       updateBoundElements(element);
     } else if (
@@ -101,6 +100,7 @@ export const transformElements = (
     ) {
       resizeSingleTextElement(
         element,
+        elementsMap,
         transformHandleType,
         shouldResizeFromCenter,
         pointerX,
@@ -109,9 +109,10 @@ export const transformElements = (
       updateBoundElements(element);
     } else if (transformHandleType) {
       resizeSingleElement(
-        pointerDownState.originalElements,
+        originalElements,
         shouldMaintainAspectRatio,
         element,
+        elementsMap,
         transformHandleType,
         shouldResizeFromCenter,
         pointerX,
@@ -123,7 +124,7 @@ export const transformElements = (
   } else if (selectedElements.length > 1) {
     if (transformHandleType === "rotation") {
       rotateMultipleElements(
-        pointerDownState,
+        originalElements,
         selectedElements,
         pointerX,
         pointerY,
@@ -139,8 +140,9 @@ export const transformElements = (
       transformHandleType === "se"
     ) {
       resizeMultipleElements(
-        pointerDownState,
+        originalElements,
         selectedElements,
+        elementsMap,
         transformHandleType,
         shouldResizeFromCenter,
         pointerX,
@@ -157,7 +159,6 @@ const rotateSingleElement = (
   pointerX: number,
   pointerY: number,
   shouldRotateWithDiscreteAngle: boolean,
-  originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
 ) => {
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const cx = (x1 + x2) / 2;
@@ -207,6 +208,7 @@ const rescalePointsInElement = (
 
 const measureFontSizeFromWidth = (
   element: NonDeleted<ExcalidrawTextElement>,
+  elementsMap: ElementsMap,
   nextWidth: number,
   nextHeight: number,
 ): { size: number; baseline: number } | null => {
@@ -215,7 +217,7 @@ const measureFontSizeFromWidth = (
 
   const hasContainer = isBoundToContainer(element);
   if (hasContainer) {
-    const container = getContainerElement(element);
+    const container = getContainerElement(element, elementsMap);
     if (container) {
       width = getBoundTextMaxWidth(container);
     }
@@ -257,6 +259,7 @@ const getSidesForTransformHandle = (
 
 const resizeSingleTextElement = (
   element: NonDeleted<ExcalidrawTextElement>,
+  elementsMap: ElementsMap,
   transformHandleType: "nw" | "ne" | "sw" | "se",
   shouldResizeFromCenter: boolean,
   pointerX: number,
@@ -303,7 +306,12 @@ const resizeSingleTextElement = (
   if (scale > 0) {
     const nextWidth = element.width * scale;
     const nextHeight = element.height * scale;
-    const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight);
+    const metrics = measureFontSizeFromWidth(
+      element,
+      elementsMap,
+      nextWidth,
+      nextHeight,
+    );
     if (metrics === null) {
       return;
     }
@@ -342,6 +350,7 @@ export const resizeSingleElement = (
   originalElements: PointerDownState["originalElements"],
   shouldMaintainAspectRatio: boolean,
   element: NonDeletedExcalidrawElement,
+  elementsMap: ElementsMap,
   transformHandleDirection: TransformHandleDirection,
   shouldResizeFromCenter: boolean,
   pointerX: number,
@@ -448,6 +457,7 @@ export const resizeSingleElement = (
 
       const nextFont = measureFontSizeFromWidth(
         boundTextElement,
+        elementsMap,
         getBoundTextMaxWidth(updatedElement),
         getBoundTextMaxHeight(updatedElement, boundTextElement),
       );
@@ -637,8 +647,9 @@ export const resizeSingleElement = (
 };
 
 export const resizeMultipleElements = (
-  pointerDownState: PointerDownState,
+  originalElements: PointerDownState["originalElements"],
   selectedElements: readonly NonDeletedExcalidrawElement[],
+  elementsMap: ElementsMap,
   transformHandleType: "nw" | "ne" | "sw" | "se",
   shouldResizeFromCenter: boolean,
   pointerX: number,
@@ -658,7 +669,7 @@ export const resizeMultipleElements = (
       }[],
       element,
     ) => {
-      const origElement = pointerDownState.originalElements.get(element.id);
+      const origElement = originalElements.get(element.id);
       if (origElement) {
         acc.push({ orig: origElement, latest: element });
       }
@@ -679,7 +690,7 @@ export const resizeMultipleElements = (
     if (!textId) {
       return acc;
     }
-    const text = pointerDownState.originalElements.get(textId) ?? null;
+    const text = originalElements.get(textId) ?? null;
     if (!isBoundToContainer(text)) {
       return acc;
     }
@@ -825,7 +836,12 @@ export const resizeMultipleElements = (
     }
 
     if (isTextElement(orig)) {
-      const metrics = measureFontSizeFromWidth(orig, width, height);
+      const metrics = measureFontSizeFromWidth(
+        orig,
+        elementsMap,
+        width,
+        height,
+      );
       if (!metrics) {
         return;
       }
@@ -833,7 +849,7 @@ export const resizeMultipleElements = (
       update.baseline = metrics.baseline;
     }
 
-    const boundTextElement = pointerDownState.originalElements.get(
+    const boundTextElement = originalElements.get(
       getBoundTextElementId(orig) ?? "",
     ) as ExcalidrawTextElementWithContainer | undefined;
 
@@ -884,7 +900,7 @@ export const resizeMultipleElements = (
 };
 
 const rotateMultipleElements = (
-  pointerDownState: PointerDownState,
+  originalElements: PointerDownState["originalElements"],
   elements: readonly NonDeletedExcalidrawElement[],
   pointerX: number,
   pointerY: number,
@@ -906,8 +922,7 @@ const rotateMultipleElements = (
       const cx = (x1 + x2) / 2;
       const cy = (y1 + y2) / 2;
       const origAngle =
-        pointerDownState.originalElements.get(element.id)?.angle ??
-        element.angle;
+        originalElements.get(element.id)?.angle ?? element.angle;
       const [rotatedCX, rotatedCY] = rotate(
         cx,
         cy,

+ 18 - 31
packages/excalidraw/element/textElement.ts

@@ -1,5 +1,6 @@
 import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
 import {
+  ElementsMap,
   ExcalidrawElement,
   ExcalidrawElementType,
   ExcalidrawTextContainer,
@@ -682,17 +683,15 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
 };
 
 export const getContainerElement = (
-  element:
-    | (ExcalidrawElement & {
-        containerId: ExcalidrawElement["id"] | null;
-      })
-    | null,
-) => {
+  element: ExcalidrawTextElement | null,
+  elementsMap: ElementsMap,
+): ExcalidrawTextContainer | null => {
   if (!element) {
     return null;
   }
   if (element.containerId) {
-    return Scene.getScene(element)?.getElement(element.containerId) || null;
+    return (elementsMap.get(element.containerId) ||
+      null) as ExcalidrawTextContainer | null;
   }
   return null;
 };
@@ -752,28 +751,16 @@ export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
   };
 };
 
-export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
-  const container = getContainerElement(textElement);
+export const getTextElementAngle = (
+  textElement: ExcalidrawTextElement,
+  container: ExcalidrawTextContainer | null,
+) => {
   if (!container || isArrowElement(container)) {
     return textElement.angle;
   }
   return container.angle;
 };
 
-export const getBoundTextElementOffset = (
-  boundTextElement: ExcalidrawTextElement | null,
-) => {
-  const container = getContainerElement(boundTextElement);
-  if (!container || !boundTextElement) {
-    return 0;
-  }
-  if (isArrowElement(container)) {
-    return BOUND_TEXT_PADDING * 8;
-  }
-
-  return BOUND_TEXT_PADDING;
-};
-
 export const getBoundTextElementPosition = (
   container: ExcalidrawElement,
   boundTextElement: ExcalidrawTextElementWithContainer,
@@ -788,12 +775,12 @@ export const getBoundTextElementPosition = (
 
 export const shouldAllowVerticalAlign = (
   selectedElements: NonDeletedExcalidrawElement[],
+  elementsMap: ElementsMap,
 ) => {
   return selectedElements.some((element) => {
-    const hasBoundContainer = isBoundToContainer(element);
-    if (hasBoundContainer) {
-      const container = getContainerElement(element);
-      if (isTextElement(element) && isArrowElement(container)) {
+    if (isBoundToContainer(element)) {
+      const container = getContainerElement(element, elementsMap);
+      if (isArrowElement(container)) {
         return false;
       }
       return true;
@@ -804,12 +791,12 @@ export const shouldAllowVerticalAlign = (
 
 export const suppportsHorizontalAlign = (
   selectedElements: NonDeletedExcalidrawElement[],
+  elementsMap: ElementsMap,
 ) => {
   return selectedElements.some((element) => {
-    const hasBoundContainer = isBoundToContainer(element);
-    if (hasBoundContainer) {
-      const container = getContainerElement(element);
-      if (isTextElement(element) && isArrowElement(container)) {
+    if (isBoundToContainer(element)) {
+      const container = getContainerElement(element, elementsMap);
+      if (isArrowElement(container)) {
         return false;
       }
       return true;

+ 13 - 4
packages/excalidraw/element/textWysiwyg.tsx

@@ -153,7 +153,10 @@ export const textWysiwyg = ({
     if (updatedTextElement && isTextElement(updatedTextElement)) {
       let coordX = updatedTextElement.x;
       let coordY = updatedTextElement.y;
-      const container = getContainerElement(updatedTextElement);
+      const container = getContainerElement(
+        updatedTextElement,
+        app.scene.getElementsMapIncludingDeleted(),
+      );
       let maxWidth = updatedTextElement.width;
 
       let maxHeight = updatedTextElement.height;
@@ -277,7 +280,7 @@ export const textWysiwyg = ({
         transform: getTransform(
           textElementWidth,
           textElementHeight,
-          getTextElementAngle(updatedTextElement),
+          getTextElementAngle(updatedTextElement, container),
           appState,
           maxWidth,
           editorMaxHeight,
@@ -348,7 +351,10 @@ export const textWysiwyg = ({
       if (!data) {
         return;
       }
-      const container = getContainerElement(element);
+      const container = getContainerElement(
+        element,
+        app.scene.getElementsMapIncludingDeleted(),
+      );
 
       const font = getFontString({
         fontSize: app.state.currentItemFontSize,
@@ -528,7 +534,10 @@ export const textWysiwyg = ({
       return;
     }
     let text = editable.value;
-    const container = getContainerElement(updateElement);
+    const container = getContainerElement(
+      updateElement,
+      app.scene.getElementsMapIncludingDeleted(),
+    );
 
     if (container) {
       text = updateElement.text;

+ 29 - 1
packages/excalidraw/element/types.ts

@@ -6,7 +6,7 @@ import {
   THEME,
   VERTICAL_ALIGN,
 } from "../constants";
-import { MarkNonNullable, ValueOf } from "../utility-types";
+import { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
 import { MagicCacheData } from "../data/magic";
 
 export type ChartType = "bar" | "line";
@@ -254,3 +254,31 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
 export type FileId = string & { _brand: "FileId" };
 
 export type ExcalidrawElementType = ExcalidrawElement["type"];
+
+/**
+ * Map of excalidraw elements.
+ * Unspecified whether deleted or non-deleted.
+ * Can be a subset of Scene elements.
+ */
+export type ElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement>;
+
+/**
+ * Map of non-deleted elements.
+ * Can be a subset of Scene elements.
+ */
+export type NonDeletedElementsMap = Map<
+  ExcalidrawElement["id"],
+  NonDeletedExcalidrawElement
+> &
+  MakeBrand<"NonDeletedElementsMap">;
+
+/**
+ * Map of all excalidraw Scene elements, including deleted.
+ * Not a subset. Use this type when you need access to current Scene elements.
+ */
+export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
+  MakeBrand<"SceneElementsMap">;
+
+export type ElementsMapOrArray =
+  | readonly ExcalidrawElement[]
+  | Readonly<ElementsMap>;

+ 65 - 47
packages/excalidraw/frame.ts

@@ -4,6 +4,8 @@ import {
   isTextElement,
 } from "./element";
 import {
+  ElementsMap,
+  ElementsMapOrArray,
   ExcalidrawElement,
   ExcalidrawFrameLikeElement,
   NonDeleted,
@@ -26,6 +28,7 @@ import {
   elementsOverlappingBBox,
 } from "../utils/export";
 import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
+import { ReadonlySetLike } from "./utility-types";
 
 // --------------------------- Frame State ------------------------------------
 export const bindElementsToFramesAfterDuplication = (
@@ -211,9 +214,17 @@ export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => {
 };
 
 export const getFrameChildren = (
-  allElements: ExcalidrawElementsIncludingDeleted,
+  allElements: ElementsMapOrArray,
   frameId: string,
-) => allElements.filter((element) => element.frameId === frameId);
+) => {
+  const frameChildren: ExcalidrawElement[] = [];
+  for (const element of allElements.values()) {
+    if (element.frameId === frameId) {
+      frameChildren.push(element);
+    }
+  }
+  return frameChildren;
+};
 
 export const getFrameLikeElements = (
   allElements: ExcalidrawElementsIncludingDeleted,
@@ -425,23 +436,20 @@ export const filterElementsEligibleAsFrameChildren = (
  * Retains (or repairs for target frame) the ordering invriant where children
  * elements come right before the parent frame:
  * [el, el, child, child, frame, el]
+ *
+ * @returns mutated allElements (same data structure)
  */
-export const addElementsToFrame = (
-  allElements: ExcalidrawElementsIncludingDeleted,
+export const addElementsToFrame = <T extends ElementsMapOrArray>(
+  allElements: T,
   elementsToAdd: NonDeletedExcalidrawElement[],
   frame: ExcalidrawFrameLikeElement,
-) => {
-  const { currTargetFrameChildrenMap } = allElements.reduce(
-    (acc, element, index) => {
-      if (element.frameId === frame.id) {
-        acc.currTargetFrameChildrenMap.set(element.id, true);
-      }
-      return acc;
-    },
-    {
-      currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
-    },
-  );
+): T => {
+  const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
+  for (const element of allElements.values()) {
+    if (element.frameId === frame.id) {
+      currTargetFrameChildrenMap.set(element.id, true);
+    }
+  }
 
   const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
 
@@ -492,13 +500,12 @@ export const addElementsToFrame = (
       false,
     );
   }
-  return allElements.slice();
+
+  return allElements;
 };
 
 export const removeElementsFromFrame = (
-  allElements: ExcalidrawElementsIncludingDeleted,
-  elementsToRemove: NonDeletedExcalidrawElement[],
-  appState: AppState,
+  elementsToRemove: ReadonlySetLike<NonDeletedExcalidrawElement>,
 ) => {
   const _elementsToRemove = new Map<
     ExcalidrawElement["id"],
@@ -536,35 +543,34 @@ export const removeElementsFromFrame = (
       false,
     );
   }
-
-  return allElements.slice();
 };
 
-export const removeAllElementsFromFrame = (
-  allElements: ExcalidrawElementsIncludingDeleted,
+export const removeAllElementsFromFrame = <T extends ExcalidrawElement>(
+  allElements: readonly T[],
   frame: ExcalidrawFrameLikeElement,
-  appState: AppState,
 ) => {
   const elementsInFrame = getFrameChildren(allElements, frame.id);
-  return removeElementsFromFrame(allElements, elementsInFrame, appState);
+  removeElementsFromFrame(elementsInFrame);
+  return allElements;
 };
 
-export const replaceAllElementsInFrame = (
-  allElements: ExcalidrawElementsIncludingDeleted,
+export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
+  allElements: readonly T[],
   nextElementsInFrame: ExcalidrawElement[],
   frame: ExcalidrawFrameLikeElement,
-  appState: AppState,
-) => {
+): T[] => {
   return addElementsToFrame(
-    removeAllElementsFromFrame(allElements, frame, appState),
+    removeAllElementsFromFrame(allElements, frame),
     nextElementsInFrame,
     frame,
-  );
+  ).slice();
 };
 
 /** does not mutate elements, but returns new ones */
-export const updateFrameMembershipOfSelectedElements = (
-  allElements: ExcalidrawElementsIncludingDeleted,
+export const updateFrameMembershipOfSelectedElements = <
+  T extends ElementsMapOrArray,
+>(
+  allElements: T,
   appState: AppState,
   app: AppClassProperties,
 ) => {
@@ -589,19 +595,22 @@ export const updateFrameMembershipOfSelectedElements = (
 
   const elementsToRemove = new Set<ExcalidrawElement>();
 
+  const elementsMap = arrayToMap(allElements);
+
   elementsToFilter.forEach((element) => {
     if (
       element.frameId &&
       !isFrameLikeElement(element) &&
-      !isElementInFrame(element, allElements, appState)
+      !isElementInFrame(element, elementsMap, appState)
     ) {
       elementsToRemove.add(element);
     }
   });
 
-  return elementsToRemove.size > 0
-    ? removeElementsFromFrame(allElements, [...elementsToRemove], appState)
-    : allElements;
+  if (elementsToRemove.size > 0) {
+    removeElementsFromFrame(elementsToRemove);
+  }
+  return allElements;
 };
 
 /**
@@ -609,14 +618,16 @@ export const updateFrameMembershipOfSelectedElements = (
  * anywhere in the group tree
  */
 export const omitGroupsContainingFrameLikes = (
-  allElements: ExcalidrawElementsIncludingDeleted,
+  allElements: ElementsMapOrArray,
   /** subset of elements you want to filter. Optional perf optimization so we
    * don't have to filter all elements unnecessarily
    */
   selectedElements?: readonly ExcalidrawElement[],
 ) => {
   const uniqueGroupIds = new Set<string>();
-  for (const el of selectedElements || allElements) {
+  const elements = selectedElements || allElements;
+
+  for (const el of elements.values()) {
     const topMostGroupId = el.groupIds[el.groupIds.length - 1];
     if (topMostGroupId) {
       uniqueGroupIds.add(topMostGroupId);
@@ -634,9 +645,15 @@ export const omitGroupsContainingFrameLikes = (
     }
   }
 
-  return (selectedElements || allElements).filter(
-    (el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]),
-  );
+  const ret: ExcalidrawElement[] = [];
+
+  for (const element of elements.values()) {
+    if (!rejectedGroupIds.has(element.groupIds[element.groupIds.length - 1])) {
+      ret.push(element);
+    }
+  }
+
+  return ret;
 };
 
 /**
@@ -645,10 +662,11 @@ export const omitGroupsContainingFrameLikes = (
  */
 export const getTargetFrame = (
   element: ExcalidrawElement,
+  elementsMap: ElementsMap,
   appState: StaticCanvasAppState,
 ) => {
   const _element = isTextElement(element)
-    ? getContainerElement(element) || element
+    ? getContainerElement(element, elementsMap) || element
     : element;
 
   return appState.selectedElementIds[_element.id] &&
@@ -661,12 +679,12 @@ export const getTargetFrame = (
 // given an element, return if the element is in some frame
 export const isElementInFrame = (
   element: ExcalidrawElement,
-  allElements: ExcalidrawElementsIncludingDeleted,
+  allElements: ElementsMap,
   appState: StaticCanvasAppState,
 ) => {
-  const frame = getTargetFrame(element, appState);
+  const frame = getTargetFrame(element, allElements, appState);
   const _element = isTextElement(element)
-    ? getContainerElement(element) || element
+    ? getContainerElement(element, allElements) || element
     : element;
 
   if (frame) {

+ 11 - 2
packages/excalidraw/groups.ts

@@ -3,6 +3,7 @@ import {
   ExcalidrawElement,
   NonDeleted,
   NonDeletedExcalidrawElement,
+  ElementsMapOrArray,
 } from "./element/types";
 import {
   AppClassProperties,
@@ -270,9 +271,17 @@ export const isElementInGroup = (element: ExcalidrawElement, groupId: string) =>
   element.groupIds.includes(groupId);
 
 export const getElementsInGroup = (
-  elements: readonly ExcalidrawElement[],
+  elements: ElementsMapOrArray,
   groupId: string,
-) => elements.filter((element) => isElementInGroup(element, groupId));
+) => {
+  const elementsInGroup: ExcalidrawElement[] = [];
+  for (const element of elements.values()) {
+    if (isElementInGroup(element, groupId)) {
+      elementsInGroup.push(element);
+    }
+  }
+  return elementsInGroup;
+};
 
 export const getSelectedGroupIdForElement = (
   element: ExcalidrawElement,

+ 10 - 3
packages/excalidraw/renderer/renderElement.ts

@@ -21,7 +21,11 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
 import type { Drawable } from "roughjs/bin/core";
 import type { RoughSVG } from "roughjs/bin/svg";
 
-import { SVGRenderConfig, StaticCanvasRenderConfig } from "../scene/types";
+import {
+  SVGRenderConfig,
+  StaticCanvasRenderConfig,
+  RenderableElementsMap,
+} from "../scene/types";
 import {
   distance,
   getFontString,
@@ -611,6 +615,7 @@ export const renderSelectionElement = (
 
 export const renderElement = (
   element: NonDeletedExcalidrawElement,
+  elementsMap: RenderableElementsMap,
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
   renderConfig: StaticCanvasRenderConfig,
@@ -715,7 +720,7 @@ export const renderElement = (
         let shiftX = (x2 - x1) / 2 - (element.x - x1);
         let shiftY = (y2 - y1) / 2 - (element.y - y1);
         if (isTextElement(element)) {
-          const container = getContainerElement(element);
+          const container = getContainerElement(element, elementsMap);
           if (isArrowElement(container)) {
             const boundTextCoords =
               LinearElementEditor.getBoundTextElementPosition(
@@ -900,6 +905,7 @@ const maybeWrapNodesInFrameClipPath = (
 
 export const renderElementToSvg = (
   element: NonDeletedExcalidrawElement,
+  elementsMap: RenderableElementsMap,
   rsvg: RoughSVG,
   svgRoot: SVGElement,
   files: BinaryFiles,
@@ -912,7 +918,7 @@ export const renderElementToSvg = (
   let cx = (x2 - x1) / 2 - (element.x - x1);
   let cy = (y2 - y1) / 2 - (element.y - y1);
   if (isTextElement(element)) {
-    const container = getContainerElement(element);
+    const container = getContainerElement(element, elementsMap);
     if (isArrowElement(container)) {
       const [x1, y1, x2, y2] = getElementAbsoluteCoords(container);
 
@@ -1013,6 +1019,7 @@ export const renderElementToSvg = (
         createPlaceholderEmbeddableLabel(element);
       renderElementToSvg(
         label,
+        elementsMap,
         rsvg,
         root,
         files,

+ 105 - 87
packages/excalidraw/renderer/renderScene.ts

@@ -33,6 +33,7 @@ import {
   SVGRenderConfig,
   StaticCanvasRenderConfig,
   StaticSceneRenderConfig,
+  RenderableElementsMap,
 } from "../scene/types";
 import {
   getScrollBars,
@@ -61,7 +62,7 @@ import {
   TransformHandles,
   TransformHandleType,
 } from "../element/transformHandles";
-import { throttleRAF } from "../utils";
+import { arrayToMap, throttleRAF } from "../utils";
 import { UserIdleState } from "../types";
 import { FRAME_STYLE, THEME_FILTER } from "../constants";
 import {
@@ -75,10 +76,7 @@ import {
   isIframeLikeElement,
   isLinearElement,
 } from "../element/typeChecks";
-import {
-  isIframeLikeOrItsLabel,
-  createPlaceholderEmbeddableLabel,
-} from "../element/embeddable";
+import { createPlaceholderEmbeddableLabel } from "../element/embeddable";
 import {
   elementOverlapsWithFrame,
   getTargetFrame,
@@ -446,7 +444,7 @@ const bootstrapCanvas = ({
 
 const _renderInteractiveScene = ({
   canvas,
-  elements,
+  elementsMap,
   visibleElements,
   selectedElements,
   scale,
@@ -454,7 +452,7 @@ const _renderInteractiveScene = ({
   renderConfig,
 }: InteractiveSceneRenderConfig) => {
   if (canvas === null) {
-    return { atLeastOneVisibleElement: false, elements };
+    return { atLeastOneVisibleElement: false, elementsMap };
   }
 
   const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
@@ -562,75 +560,64 @@ const _renderInteractiveScene = ({
 
     if (showBoundingBox) {
       // Optimisation for finding quickly relevant element ids
-      const locallySelectedIds = selectedElements.reduce(
-        (acc: Record<string, boolean>, element) => {
-          acc[element.id] = true;
-          return acc;
-        },
-        {},
-      );
-
-      const selections = elements.reduce(
-        (
-          acc: {
-            angle: number;
-            elementX1: number;
-            elementY1: number;
-            elementX2: number;
-            elementY2: number;
-            selectionColors: string[];
-            dashed?: boolean;
-            cx: number;
-            cy: number;
-            activeEmbeddable: boolean;
-          }[],
-          element,
-        ) => {
-          const selectionColors = [];
-          // local user
-          if (
-            locallySelectedIds[element.id] &&
-            !isSelectedViaGroup(appState, element)
-          ) {
-            selectionColors.push(selectionColor);
-          }
-          // remote users
-          if (renderConfig.remoteSelectedElementIds[element.id]) {
-            selectionColors.push(
-              ...renderConfig.remoteSelectedElementIds[element.id].map(
-                (socketId: string) => {
-                  const background = getClientColor(socketId);
-                  return background;
-                },
-              ),
-            );
-          }
+      const locallySelectedIds = arrayToMap(selectedElements);
+
+      const selections: {
+        angle: number;
+        elementX1: number;
+        elementY1: number;
+        elementX2: number;
+        elementY2: number;
+        selectionColors: string[];
+        dashed?: boolean;
+        cx: number;
+        cy: number;
+        activeEmbeddable: boolean;
+      }[] = [];
+
+      for (const element of elementsMap.values()) {
+        const selectionColors = [];
+        // local user
+        if (
+          locallySelectedIds.has(element.id) &&
+          !isSelectedViaGroup(appState, element)
+        ) {
+          selectionColors.push(selectionColor);
+        }
+        // remote users
+        if (renderConfig.remoteSelectedElementIds[element.id]) {
+          selectionColors.push(
+            ...renderConfig.remoteSelectedElementIds[element.id].map(
+              (socketId: string) => {
+                const background = getClientColor(socketId);
+                return background;
+              },
+            ),
+          );
+        }
 
-          if (selectionColors.length) {
-            const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
-              getElementAbsoluteCoords(element, true);
-            acc.push({
-              angle: element.angle,
-              elementX1,
-              elementY1,
-              elementX2,
-              elementY2,
-              selectionColors,
-              dashed: !!renderConfig.remoteSelectedElementIds[element.id],
-              cx,
-              cy,
-              activeEmbeddable:
-                appState.activeEmbeddable?.element === element &&
-                appState.activeEmbeddable.state === "active",
-            });
-          }
-          return acc;
-        },
-        [],
-      );
+        if (selectionColors.length) {
+          const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
+            getElementAbsoluteCoords(element, true);
+          selections.push({
+            angle: element.angle,
+            elementX1,
+            elementY1,
+            elementX2,
+            elementY2,
+            selectionColors,
+            dashed: !!renderConfig.remoteSelectedElementIds[element.id],
+            cx,
+            cy,
+            activeEmbeddable:
+              appState.activeEmbeddable?.element === element &&
+              appState.activeEmbeddable.state === "active",
+          });
+        }
+      }
 
       const addSelectionForGroupId = (groupId: GroupId) => {
-        const groupElements = getElementsInGroup(elements, groupId);
+        const groupElements = getElementsInGroup(elementsMap, groupId);
         const [elementX1, elementY1, elementX2, elementY2] =
           getCommonBounds(groupElements);
         selections.push({
@@ -870,7 +857,7 @@ const _renderInteractiveScene = ({
   let scrollBars;
   if (renderConfig.renderScrollbars) {
     scrollBars = getScrollBars(
-      elements,
+      elementsMap,
       normalizedWidth,
       normalizedHeight,
       appState,
@@ -897,14 +884,14 @@ const _renderInteractiveScene = ({
   return {
     scrollBars,
     atLeastOneVisibleElement: visibleElements.length > 0,
-    elements,
+    elementsMap,
   };
 };
 
 const _renderStaticScene = ({
   canvas,
   rc,
-  elements,
+  elementsMap,
   visibleElements,
   scale,
   appState,
@@ -965,7 +952,7 @@ const _renderStaticScene = ({
 
   // Paint visible elements
   visibleElements
-    .filter((el) => !isIframeLikeOrItsLabel(el))
+    .filter((el) => !isIframeLikeElement(el))
     .forEach((element) => {
       try {
         const frameId = element.frameId || appState.frameToHighlight?.id;
@@ -977,16 +964,30 @@ const _renderStaticScene = ({
         ) {
           context.save();
 
-          const frame = getTargetFrame(element, appState);
+          const frame = getTargetFrame(element, elementsMap, appState);
 
           // TODO do we need to check isElementInFrame here?
-          if (frame && isElementInFrame(element, elements, appState)) {
+          if (frame && isElementInFrame(element, elementsMap, appState)) {
             frameClip(frame, context, renderConfig, appState);
           }
-          renderElement(element, rc, context, renderConfig, appState);
+          renderElement(
+            element,
+            elementsMap,
+            rc,
+            context,
+            renderConfig,
+            appState,
+          );
           context.restore();
         } else {
-          renderElement(element, rc, context, renderConfig, appState);
+          renderElement(
+            element,
+            elementsMap,
+            rc,
+            context,
+            renderConfig,
+            appState,
+          );
         }
         if (!isExporting) {
           renderLinkIcon(element, context, appState);
@@ -998,11 +999,18 @@ const _renderStaticScene = ({
 
   // render embeddables on top
   visibleElements
-    .filter((el) => isIframeLikeOrItsLabel(el))
+    .filter((el) => isIframeLikeElement(el))
     .forEach((element) => {
       try {
         const render = () => {
-          renderElement(element, rc, context, renderConfig, appState);
+          renderElement(
+            element,
+            elementsMap,
+            rc,
+            context,
+            renderConfig,
+            appState,
+          );
 
           if (
             isIframeLikeElement(element) &&
@@ -1014,7 +1022,14 @@ const _renderStaticScene = ({
             element.height
           ) {
             const label = createPlaceholderEmbeddableLabel(element);
-            renderElement(label, rc, context, renderConfig, appState);
+            renderElement(
+              label,
+              elementsMap,
+              rc,
+              context,
+              renderConfig,
+              appState,
+            );
           }
           if (!isExporting) {
             renderLinkIcon(element, context, appState);
@@ -1032,9 +1047,9 @@ const _renderStaticScene = ({
         ) {
           context.save();
 
-          const frame = getTargetFrame(element, appState);
+          const frame = getTargetFrame(element, elementsMap, appState);
 
-          if (frame && isElementInFrame(element, elements, appState)) {
+          if (frame && isElementInFrame(element, elementsMap, appState)) {
             frameClip(frame, context, renderConfig, appState);
           }
           render();
@@ -1448,6 +1463,7 @@ const renderLinkIcon = (
 // This should be only called for exporting purposes
 export const renderSceneToSvg = (
   elements: readonly NonDeletedExcalidrawElement[],
+  elementsMap: RenderableElementsMap,
   rsvg: RoughSVG,
   svgRoot: SVGElement,
   files: BinaryFiles,
@@ -1459,12 +1475,13 @@ export const renderSceneToSvg = (
 
   // render elements
   elements
-    .filter((el) => !isIframeLikeOrItsLabel(el))
+    .filter((el) => !isIframeLikeElement(el))
     .forEach((element) => {
       if (!element.isDeleted) {
         try {
           renderElementToSvg(
             element,
+            elementsMap,
             rsvg,
             svgRoot,
             files,
@@ -1486,6 +1503,7 @@ export const renderSceneToSvg = (
         try {
           renderElementToSvg(
             element,
+            elementsMap,
             rsvg,
             svgRoot,
             files,

+ 8 - 1
packages/excalidraw/scene/Fonts.ts

@@ -1,5 +1,6 @@
 import { isTextElement, refreshTextDimensions } from "../element";
 import { newElementWith } from "../element/mutateElement";
+import { getContainerElement } from "../element/textElement";
 import { isBoundToContainer } from "../element/typeChecks";
 import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 import { getFontString } from "../utils";
@@ -57,7 +58,13 @@ export class Fonts {
         ShapeCache.delete(element);
         didUpdate = true;
         return newElementWith(element, {
-          ...refreshTextDimensions(element),
+          ...refreshTextDimensions(
+            element,
+            getContainerElement(
+              element,
+              this.scene.getElementsMapIncludingDeleted(),
+            ),
+          ),
         });
       }
       return element;

+ 39 - 23
packages/excalidraw/scene/Renderer.ts

@@ -1,10 +1,14 @@
 import { isElementInViewport } from "../element/sizeHelpers";
 import { isImageElement } from "../element/typeChecks";
-import { NonDeletedExcalidrawElement } from "../element/types";
+import {
+  NonDeletedElementsMap,
+  NonDeletedExcalidrawElement,
+} from "../element/types";
 import { cancelRender } from "../renderer/renderScene";
 import { AppState } from "../types";
-import { memoize } from "../utils";
+import { memoize, toBrandedType } from "../utils";
 import Scene from "./Scene";
+import { RenderableElementsMap } from "./types";
 
 export class Renderer {
   private scene: Scene;
@@ -15,7 +19,7 @@ export class Renderer {
 
   public getRenderableElements = (() => {
     const getVisibleCanvasElements = ({
-      elements,
+      elementsMap,
       zoom,
       offsetLeft,
       offsetTop,
@@ -24,7 +28,7 @@ export class Renderer {
       height,
       width,
     }: {
-      elements: readonly NonDeletedExcalidrawElement[];
+      elementsMap: NonDeletedElementsMap;
       zoom: AppState["zoom"];
       offsetLeft: AppState["offsetLeft"];
       offsetTop: AppState["offsetTop"];
@@ -33,43 +37,55 @@ export class Renderer {
       height: AppState["height"];
       width: AppState["width"];
     }): readonly NonDeletedExcalidrawElement[] => {
-      return elements.filter((element) =>
-        isElementInViewport(element, width, height, {
-          zoom,
-          offsetLeft,
-          offsetTop,
-          scrollX,
-          scrollY,
-        }),
-      );
+      const visibleElements: NonDeletedExcalidrawElement[] = [];
+      for (const element of elementsMap.values()) {
+        if (
+          isElementInViewport(element, width, height, {
+            zoom,
+            offsetLeft,
+            offsetTop,
+            scrollX,
+            scrollY,
+          })
+        ) {
+          visibleElements.push(element);
+        }
+      }
+      return visibleElements;
     };
 
-    const getCanvasElements = ({
-      editingElement,
+    const getRenderableElements = ({
       elements,
+      editingElement,
       pendingImageElementId,
     }: {
       elements: readonly NonDeletedExcalidrawElement[];
       editingElement: AppState["editingElement"];
       pendingImageElementId: AppState["pendingImageElementId"];
     }) => {
-      return elements.filter((element) => {
+      const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
+
+      for (const element of elements) {
         if (isImageElement(element)) {
           if (
             // => not placed on canvas yet (but in elements array)
             pendingImageElementId === element.id
           ) {
-            return false;
+            continue;
           }
         }
+
         // we don't want to render text element that's being currently edited
         // (it's rendered on remote only)
-        return (
+        if (
           !editingElement ||
           editingElement.type !== "text" ||
           element.id !== editingElement.id
-        );
-      });
+        ) {
+          elementsMap.set(element.id, element);
+        }
+      }
+      return elementsMap;
     };
 
     return memoize(
@@ -100,14 +116,14 @@ export class Renderer {
       }) => {
         const elements = this.scene.getNonDeletedElements();
 
-        const canvasElements = getCanvasElements({
+        const elementsMap = getRenderableElements({
           elements,
           editingElement,
           pendingImageElementId,
         });
 
         const visibleElements = getVisibleCanvasElements({
-          elements: canvasElements,
+          elementsMap,
           zoom,
           offsetLeft,
           offsetTop,
@@ -117,7 +133,7 @@ export class Renderer {
           width,
         });
 
-        return { canvasElements, visibleElements };
+        return { elementsMap, visibleElements };
       },
     );
   })();

+ 60 - 12
packages/excalidraw/scene/Scene.ts

@@ -3,14 +3,18 @@ import {
   NonDeletedExcalidrawElement,
   NonDeleted,
   ExcalidrawFrameLikeElement,
+  ElementsMapOrArray,
+  NonDeletedElementsMap,
+  SceneElementsMap,
 } from "../element/types";
-import { getNonDeletedElements, isNonDeletedElement } from "../element";
+import { isNonDeletedElement } from "../element";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { isFrameLikeElement } from "../element/typeChecks";
 import { getSelectedElements } from "./selection";
 import { AppState } from "../types";
 import { Assert, SameType } from "../utility-types";
 import { randomInteger } from "../random";
+import { toBrandedType } from "../utils";
 
 type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
 type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -20,6 +24,20 @@ type SceneStateCallbackRemover = () => void;
 
 type SelectionHash = string & { __brand: "selectionHash" };
 
+const getNonDeletedElements = <T extends ExcalidrawElement>(
+  allElements: readonly T[],
+) => {
+  const elementsMap = new Map() as NonDeletedElementsMap;
+  const elements: T[] = [];
+  for (const element of allElements) {
+    if (!element.isDeleted) {
+      elements.push(element as NonDeleted<T>);
+      elementsMap.set(element.id, element as NonDeletedExcalidrawElement);
+    }
+  }
+  return { elementsMap, elements };
+};
+
 const hashSelectionOpts = (
   opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
 ) => {
@@ -102,11 +120,13 @@ class Scene {
   private callbacks: Set<SceneStateCallback> = new Set();
 
   private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
+  private nonDeletedElementsMap: NonDeletedElementsMap =
+    new Map() as NonDeletedElementsMap;
   private elements: readonly ExcalidrawElement[] = [];
   private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
     [];
   private frames: readonly ExcalidrawFrameLikeElement[] = [];
-  private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
+  private elementsMap = toBrandedType<SceneElementsMap>(new Map());
   private selectedElementsCache: {
     selectedElementIds: AppState["selectedElementIds"] | null;
     elements: readonly NonDeletedExcalidrawElement[] | null;
@@ -118,6 +138,14 @@ class Scene {
   };
   private versionNonce: number | undefined;
 
+  getElementsMapIncludingDeleted() {
+    return this.elementsMap;
+  }
+
+  getNonDeletedElementsMap() {
+    return this.nonDeletedElementsMap;
+  }
+
   getElementsIncludingDeleted() {
     return this.elements;
   }
@@ -138,7 +166,7 @@ class Scene {
      * scene state. This in effect will likely result in cache-miss, and
      * the cache won't be updated in this case.
      */
-    elements?: readonly ExcalidrawElement[];
+    elements?: ElementsMapOrArray;
     // selection-related options
     includeBoundTextElement?: boolean;
     includeElementsInFrames?: boolean;
@@ -227,23 +255,27 @@ class Scene {
     return didChange;
   }
 
-  replaceAllElements(
-    nextElements: readonly ExcalidrawElement[],
-    mapElementIds = true,
-  ) {
-    this.elements = nextElements;
+  replaceAllElements(nextElements: ElementsMapOrArray, mapElementIds = true) {
+    this.elements =
+      // ts doesn't like `Array.isArray` of `instanceof Map`
+      nextElements instanceof Array
+        ? nextElements
+        : Array.from(nextElements.values());
     const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
     this.elementsMap.clear();
-    nextElements.forEach((element) => {
+    this.elements.forEach((element) => {
       if (isFrameLikeElement(element)) {
         nextFrameLikes.push(element);
       }
       this.elementsMap.set(element.id, element);
-      Scene.mapElementToScene(element, this);
+      Scene.mapElementToScene(element, this, mapElementIds);
     });
-    this.nonDeletedElements = getNonDeletedElements(this.elements);
+    const nonDeletedElements = getNonDeletedElements(this.elements);
+    this.nonDeletedElements = nonDeletedElements.elements;
+    this.nonDeletedElementsMap = nonDeletedElements.elementsMap;
+
     this.frames = nextFrameLikes;
-    this.nonDeletedFramesLikes = getNonDeletedElements(this.frames);
+    this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
 
     this.informMutation();
   }
@@ -332,6 +364,22 @@ class Scene {
   getElementIndex(elementId: string) {
     return this.elements.findIndex((element) => element.id === elementId);
   }
+
+  getContainerElement = (
+    element:
+      | (ExcalidrawElement & {
+          containerId: ExcalidrawElement["id"] | null;
+        })
+      | null,
+  ) => {
+    if (!element) {
+      return null;
+    }
+    if (element.containerId) {
+      return this.getElement(element.containerId) || null;
+    }
+    return null;
+  };
 }
 
 export default Scene;

+ 37 - 19
packages/excalidraw/scene/export.ts

@@ -11,7 +11,13 @@ import {
   getElementAbsoluteCoords,
 } from "../element/bounds";
 import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
-import { cloneJSON, distance, getFontString } from "../utils";
+import {
+  arrayToMap,
+  cloneJSON,
+  distance,
+  getFontString,
+  toBrandedType,
+} from "../utils";
 import { AppState, BinaryFiles } from "../types";
 import {
   DEFAULT_EXPORT_PADDING,
@@ -37,6 +43,7 @@ import { Mutable } from "../utility-types";
 import { newElementWith } from "../element/mutateElement";
 import Scene from "./Scene";
 import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
+import { RenderableElementsMap } from "./types";
 
 const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 
@@ -59,7 +66,7 @@ const __createSceneForElementsHack__ = (
   // ids to Scene instances so that we don't override the editor elements
   // mapping.
   // We still need to clone the objects themselves to regen references.
-  scene.replaceAllElements(cloneJSON(elements), false);
+  scene.replaceAllElements(cloneJSON(elements));
   return scene;
 };
 
@@ -241,10 +248,14 @@ export const exportToCanvas = async (
     files,
   });
 
+  const elementsMap = toBrandedType<RenderableElementsMap>(
+    arrayToMap(elementsForRender),
+  );
+
   renderStaticScene({
     canvas,
     rc: rough.canvas(canvas),
-    elements: elementsForRender,
+    elementsMap,
     visibleElements: elementsForRender,
     scale,
     appState: {
@@ -432,22 +443,29 @@ export const exportToSvg = async (
 
   const renderEmbeddables = opts?.renderEmbeddables ?? false;
 
-  renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, {
-    offsetX,
-    offsetY,
-    isExporting: true,
-    exportWithDarkMode,
-    renderEmbeddables,
-    frameRendering,
-    canvasBackgroundColor: viewBackgroundColor,
-    embedsValidationStatus: renderEmbeddables
-      ? new Map(
-          elementsForRender
-            .filter((element) => isFrameLikeElement(element))
-            .map((element) => [element.id, true]),
-        )
-      : new Map(),
-  });
+  renderSceneToSvg(
+    elementsForRender,
+    toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
+    rsvg,
+    svgRoot,
+    files || {},
+    {
+      offsetX,
+      offsetY,
+      isExporting: true,
+      exportWithDarkMode,
+      renderEmbeddables,
+      frameRendering,
+      canvasBackgroundColor: viewBackgroundColor,
+      embedsValidationStatus: renderEmbeddables
+        ? new Map(
+            elementsForRender
+              .filter((element) => isFrameLikeElement(element))
+              .map((element) => [element.id, true]),
+          )
+        : new Map(),
+    },
+  );
 
   tempScene.destroy();
 

+ 3 - 4
packages/excalidraw/scene/scrollbars.ts

@@ -1,7 +1,6 @@
-import { ExcalidrawElement } from "../element/types";
 import { getCommonBounds } from "../element";
 import { InteractiveCanvasAppState } from "../types";
-import { ScrollBars } from "./types";
+import { RenderableElementsMap, ScrollBars } from "./types";
 import { getGlobalCSSVariable } from "../utils";
 import { getLanguage } from "../i18n";
 
@@ -10,12 +9,12 @@ export const SCROLLBAR_WIDTH = 6;
 export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)";
 
 export const getScrollBars = (
-  elements: readonly ExcalidrawElement[],
+  elements: RenderableElementsMap,
   viewportWidth: number,
   viewportHeight: number,
   appState: InteractiveCanvasAppState,
 ): ScrollBars => {
-  if (elements.length === 0) {
+  if (!elements.size) {
     return {
       horizontal: null,
       vertical: null,

+ 10 - 7
packages/excalidraw/scene/selection.ts

@@ -1,4 +1,5 @@
 import {
+  ElementsMapOrArray,
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
 } from "../element/types";
@@ -166,26 +167,28 @@ export const getCommonAttributeOfSelectedElements = <T>(
 };
 
 export const getSelectedElements = (
-  elements: readonly NonDeletedExcalidrawElement[],
+  elements: ElementsMapOrArray,
   appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
   opts?: {
     includeBoundTextElement?: boolean;
     includeElementsInFrames?: boolean;
   },
 ) => {
-  const selectedElements = elements.filter((element) => {
+  const selectedElements: ExcalidrawElement[] = [];
+  for (const element of elements.values()) {
     if (appState.selectedElementIds[element.id]) {
-      return element;
+      selectedElements.push(element);
+      continue;
     }
     if (
       opts?.includeBoundTextElement &&
       isBoundToContainer(element) &&
       appState.selectedElementIds[element?.containerId]
     ) {
-      return element;
+      selectedElements.push(element);
+      continue;
     }
-    return null;
-  });
+  }
 
   if (opts?.includeElementsInFrames) {
     const elementsToInclude: ExcalidrawElement[] = [];
@@ -205,7 +208,7 @@ export const getSelectedElements = (
 };
 
 export const getTargetElements = (
-  elements: readonly NonDeletedExcalidrawElement[],
+  elements: ElementsMapOrArray,
   appState: Pick<AppState, "selectedElementIds" | "editingElement">,
 ) =>
   appState.editingElement

+ 8 - 3
packages/excalidraw/scene/types.ts

@@ -2,6 +2,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
 import { Drawable } from "roughjs/bin/core";
 import {
   ExcalidrawTextElement,
+  NonDeletedElementsMap,
   NonDeletedExcalidrawElement,
 } from "../element/types";
 import {
@@ -12,6 +13,10 @@ import {
   InteractiveCanvasAppState,
   StaticCanvasAppState,
 } from "../types";
+import { MakeBrand } from "../utility-types";
+
+export type RenderableElementsMap = NonDeletedElementsMap &
+  MakeBrand<"RenderableElementsMap">;
 
 export type StaticCanvasRenderConfig = {
   canvasBackgroundColor: AppState["viewBackgroundColor"];
@@ -53,14 +58,14 @@ export type InteractiveCanvasRenderConfig = {
 
 export type RenderInteractiveSceneCallback = {
   atLeastOneVisibleElement: boolean;
-  elements: readonly NonDeletedExcalidrawElement[];
+  elementsMap: RenderableElementsMap;
   scrollBars?: ScrollBars;
 };
 
 export type StaticSceneRenderConfig = {
   canvas: HTMLCanvasElement;
   rc: RoughCanvas;
-  elements: readonly NonDeletedExcalidrawElement[];
+  elementsMap: RenderableElementsMap;
   visibleElements: readonly NonDeletedExcalidrawElement[];
   scale: number;
   appState: StaticCanvasAppState;
@@ -69,7 +74,7 @@ export type StaticSceneRenderConfig = {
 
 export type InteractiveSceneRenderConfig = {
   canvas: HTMLCanvasElement | null;
-  elements: readonly NonDeletedExcalidrawElement[];
+  elementsMap: RenderableElementsMap;
   visibleElements: readonly NonDeletedExcalidrawElement[];
   selectedElements: readonly NonDeletedExcalidrawElement[];
   scale: number;

+ 8 - 0
packages/excalidraw/utility-types.ts

@@ -54,3 +54,11 @@ export type Assert<T extends true> = T;
 export type NestedKeyOf<T, K = keyof T> = K extends keyof T & (string | number)
   ? `${K}` | (T[K] extends object ? `${K}.${NestedKeyOf<T[K]>}` : never)
   : never;
+
+export type SetLike<T> = Set<T> | T[];
+export type ReadonlySetLike<T> = ReadonlySet<T> | readonly T[];
+
+export type MakeBrand<T extends string> = {
+  /** @private using ~ to sort last in intellisense */
+  [K in `~brand~${T}`]: T;
+};

+ 41 - 1
packages/excalidraw/utils.ts

@@ -650,8 +650,11 @@ export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now());
  * or array of ids (strings), into a Map, keyd by `id`.
  */
 export const arrayToMap = <T extends { id: string } | string>(
-  items: readonly T[],
+  items: readonly T[] | Map<string, T>,
 ) => {
+  if (items instanceof Map) {
+    return items;
+  }
   return items.reduce((acc: Map<string, T>, element) => {
     acc.set(typeof element === "string" ? element : element.id, element);
     return acc;
@@ -1050,3 +1053,40 @@ export function getSvgPathFromStroke(points: number[][], closed = true) {
 export const normalizeEOL = (str: string) => {
   return str.replace(/\r?\n|\r/g, "\n");
 };
+
+// -----------------------------------------------------------------------------
+type HasBrand<T> = {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  [K in keyof T]: K extends `~brand${infer _}` ? true : never;
+}[keyof T];
+
+type RemoveAllBrands<T> = HasBrand<T> extends true
+  ? {
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      [K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
+    }
+  : never;
+
+// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
+// currently does not cover all types (e.g. tuples, promises...)
+type Unbrand<T> = T extends Map<infer E, infer F>
+  ? Map<E, F>
+  : T extends Set<infer E>
+  ? Set<E>
+  : T extends Array<infer E>
+  ? Array<E>
+  : RemoveAllBrands<T>;
+
+/**
+ * Makes type into a branded type, ensuring that value is assignable to
+ * the base ubranded type. Optionally you can explicitly supply current value
+ * type to combine both (useful for composite branded types. Make sure you
+ * compose branded types which are not composite themselves.)
+ */
+export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
+  value: Unbrand<BrandedType>,
+) => {
+  return value as CurrentType & BrandedType;
+};
+
+// -----------------------------------------------------------------------------