Ver Fonte

Refactor ExcalidrawElement (#874)

* Get rid of isSelected, canvas, canvasZoom, canvasOffsetX and canvasOffsetY on ExcalidrawElement.

* Fix most unit tests. Fix cmd a. Fix alt drag

* Focus on paste

* shift select should include previously selected items

* Fix last test

* Move this.shape out of ExcalidrawElement and into a WeakMap
Pete Hunt há 5 anos atrás
pai
commit
ccbbdb75a6

+ 3 - 0
package.json

@@ -102,5 +102,8 @@
   "repository": {
     "type": "git",
     "url": "https://github.com/excalidraw/excalidraw.git"
+  },
+  "engines": {
+    "node": ">=12.0.0"
   }
 }

+ 5 - 4
src/actions/actionDeleteSelected.tsx

@@ -10,22 +10,23 @@ export const actionDeleteSelected = register({
   name: "deleteSelectedElements",
   perform: (elements, appState) => {
     return {
-      elements: deleteSelectedElements(elements),
+      elements: deleteSelectedElements(elements, appState),
       appState: { ...appState, elementType: "selection", multiElement: null },
     };
   },
   contextItemLabel: "labels.delete",
   contextMenuOrder: 3,
-  commitToHistory: (_, elements) => isSomeElementSelected(elements),
+  commitToHistory: (appState, elements) =>
+    isSomeElementSelected(elements, appState),
   keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
-  PanelComponent: ({ elements, updateData }) => (
+  PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton
       type="button"
       icon={trash}
       title={t("labels.delete")}
       aria-label={t("labels.delete")}
       onClick={() => updateData(null)}
-      visible={isSomeElementSelected(elements)}
+      visible={isSomeElementSelected(elements, appState)}
     />
   ),
 });

+ 5 - 4
src/actions/actionFinalize.tsx

@@ -1,5 +1,4 @@
 import { KEYS } from "../keys";
-import { clearSelection } from "../scene";
 import { isInvisiblySmallElement } from "../element";
 import { resetCursor } from "../utils";
 import React from "react";
@@ -7,11 +6,12 @@ import { ToolButton } from "../components/ToolButton";
 import { done } from "../components/icons";
 import { t } from "../i18n";
 import { register } from "./register";
+import { invalidateShapeForElement } from "../renderer/renderElement";
 
 export const actionFinalize = register({
   name: "finalize",
   perform: (elements, appState) => {
-    let newElements = clearSelection(elements);
+    let newElements = elements;
     if (window.document.activeElement instanceof HTMLElement) {
       window.document.activeElement.blur();
     }
@@ -26,9 +26,9 @@ export const actionFinalize = register({
       if (isInvisiblySmallElement(appState.multiElement)) {
         newElements = newElements.slice(0, -1);
       }
-      appState.multiElement.shape = null;
+      invalidateShapeForElement(appState.multiElement);
       if (!appState.elementLocked) {
-        appState.multiElement.isSelected = true;
+        appState.selectedElementIds[appState.multiElement.id] = true;
       }
     }
     if (!appState.elementLocked || !appState.multiElement) {
@@ -44,6 +44,7 @@ export const actionFinalize = register({
             : "selection",
         draggingElement: null,
         multiElement: null,
+        selectedElementIds: {},
       },
     };
   },

+ 22 - 28
src/actions/actionProperties.tsx

@@ -14,10 +14,11 @@ import { register } from "./register";
 
 const changeProperty = (
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   callback: (element: ExcalidrawElement) => ExcalidrawElement,
 ) => {
   return elements.map(element => {
-    if (element.isSelected) {
+    if (appState.selectedElementIds[element.id]) {
       return callback(element);
     }
     return element;
@@ -25,15 +26,16 @@ const changeProperty = (
 };
 
 const getFormValue = function<T>(
-  editingElement: AppState["editingElement"],
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   getAttribute: (element: ExcalidrawElement) => T,
   defaultValue?: T,
 ): T | null {
+  const editingElement = appState.editingElement;
   return (
     (editingElement && getAttribute(editingElement)) ??
-    (isSomeElementSelected(elements)
-      ? getCommonAttributeOfSelectedElements(elements, getAttribute)
+    (isSomeElementSelected(elements, appState)
+      ? getCommonAttributeOfSelectedElements(elements, appState, getAttribute)
       : defaultValue) ??
     null
   );
@@ -43,9 +45,8 @@ export const actionChangeStrokeColor = register({
   name: "changeStrokeColor",
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, el => ({
+      elements: changeProperty(elements, appState, el => ({
         ...el,
-        shape: null,
         strokeColor: value,
       })),
       appState: { ...appState, currentItemStrokeColor: value },
@@ -59,8 +60,8 @@ export const actionChangeStrokeColor = register({
         type="elementStroke"
         label={t("labels.stroke")}
         color={getFormValue(
-          appState.editingElement,
           elements,
+          appState,
           element => element.strokeColor,
           appState.currentItemStrokeColor,
         )}
@@ -74,9 +75,8 @@ export const actionChangeBackgroundColor = register({
   name: "changeBackgroundColor",
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, el => ({
+      elements: changeProperty(elements, appState, el => ({
         ...el,
-        shape: null,
         backgroundColor: value,
       })),
       appState: { ...appState, currentItemBackgroundColor: value },
@@ -90,8 +90,8 @@ export const actionChangeBackgroundColor = register({
         type="elementBackground"
         label={t("labels.background")}
         color={getFormValue(
-          appState.editingElement,
           elements,
+          appState,
           element => element.backgroundColor,
           appState.currentItemBackgroundColor,
         )}
@@ -105,9 +105,8 @@ export const actionChangeFillStyle = register({
   name: "changeFillStyle",
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, el => ({
+      elements: changeProperty(elements, appState, el => ({
         ...el,
-        shape: null,
         fillStyle: value,
       })),
       appState: { ...appState, currentItemFillStyle: value },
@@ -125,8 +124,8 @@ export const actionChangeFillStyle = register({
         ]}
         group="fill"
         value={getFormValue(
-          appState.editingElement,
           elements,
+          appState,
           element => element.fillStyle,
           appState.currentItemFillStyle,
         )}
@@ -142,9 +141,8 @@ export const actionChangeStrokeWidth = register({
   name: "changeStrokeWidth",
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, el => ({
+      elements: changeProperty(elements, appState, el => ({
         ...el,
-        shape: null,
         strokeWidth: value,
       })),
       appState: { ...appState, currentItemStrokeWidth: value },
@@ -162,8 +160,8 @@ export const actionChangeStrokeWidth = register({
           { value: 4, text: t("labels.extraBold") },
         ]}
         value={getFormValue(
-          appState.editingElement,
           elements,
+          appState,
           element => element.strokeWidth,
           appState.currentItemStrokeWidth,
         )}
@@ -177,9 +175,8 @@ export const actionChangeSloppiness = register({
   name: "changeSloppiness",
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, el => ({
+      elements: changeProperty(elements, appState, el => ({
         ...el,
-        shape: null,
         roughness: value,
       })),
       appState: { ...appState, currentItemRoughness: value },
@@ -197,8 +194,8 @@ export const actionChangeSloppiness = register({
           { value: 2, text: t("labels.cartoonist") },
         ]}
         value={getFormValue(
-          appState.editingElement,
           elements,
+          appState,
           element => element.roughness,
           appState.currentItemRoughness,
         )}
@@ -212,9 +209,8 @@ export const actionChangeOpacity = register({
   name: "changeOpacity",
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, el => ({
+      elements: changeProperty(elements, appState, el => ({
         ...el,
-        shape: null,
         opacity: value,
       })),
       appState: { ...appState, currentItemOpacity: value },
@@ -246,8 +242,8 @@ export const actionChangeOpacity = register({
         }}
         value={
           getFormValue(
-            appState.editingElement,
             elements,
+            appState,
             element => element.opacity,
             appState.currentItemOpacity,
           ) ?? undefined
@@ -261,11 +257,10 @@ export const actionChangeFontSize = register({
   name: "changeFontSize",
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, el => {
+      elements: changeProperty(elements, appState, el => {
         if (isTextElement(el)) {
           const element: ExcalidrawTextElement = {
             ...el,
-            shape: null,
             font: `${value}px ${el.font.split("px ")[1]}`,
           };
           redrawTextBoundingBox(element);
@@ -295,8 +290,8 @@ export const actionChangeFontSize = register({
           { value: 36, text: t("labels.veryLarge") },
         ]}
         value={getFormValue(
-          appState.editingElement,
           elements,
+          appState,
           element => isTextElement(element) && +element.font.split("px ")[0],
           +(appState.currentItemFont || DEFAULT_FONT).split("px ")[0],
         )}
@@ -310,11 +305,10 @@ export const actionChangeFontFamily = register({
   name: "changeFontFamily",
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, el => {
+      elements: changeProperty(elements, appState, el => {
         if (isTextElement(el)) {
           const element: ExcalidrawTextElement = {
             ...el,
-            shape: null,
             font: `${el.font.split("px ")[0]}px ${value}`,
           };
           redrawTextBoundingBox(element);
@@ -343,8 +337,8 @@ export const actionChangeFontFamily = register({
           { value: "Cascadia", text: t("labels.code") },
         ]}
         value={getFormValue(
-          appState.editingElement,
           elements,
+          appState,
           element => isTextElement(element) && element.font.split("px ")[1],
           (appState.currentItemFont || DEFAULT_FONT).split("px ")[1],
         )}

+ 7 - 2
src/actions/actionSelectAll.ts

@@ -3,9 +3,14 @@ import { register } from "./register";
 
 export const actionSelectAll = register({
   name: "selectAll",
-  perform: elements => {
+  perform: (elements, appState) => {
     return {
-      elements: elements.map(elem => ({ ...elem, isSelected: true })),
+      appState: {
+        ...appState,
+        selectedElementIds: Object.fromEntries(
+          elements.map(element => [element.id, true]),
+        ),
+      },
     };
   },
   contextItemLabel: "labels.selectAll",

+ 4 - 5
src/actions/actionStyles.ts

@@ -11,8 +11,8 @@ let copiedStyles: string = "{}";
 
 export const actionCopyStyles = register({
   name: "copyStyles",
-  perform: elements => {
-    const element = elements.find(el => el.isSelected);
+  perform: (elements, appState) => {
+    const element = elements.find(el => appState.selectedElementIds[el.id]);
     if (element) {
       copiedStyles = JSON.stringify(element);
     }
@@ -25,17 +25,16 @@ export const actionCopyStyles = register({
 
 export const actionPasteStyles = register({
   name: "pasteStyles",
-  perform: elements => {
+  perform: (elements, appState) => {
     const pastedElement = JSON.parse(copiedStyles);
     if (!isExcalidrawElement(pastedElement)) {
       return { elements };
     }
     return {
       elements: elements.map(element => {
-        if (element.isSelected) {
+        if (appState.selectedElementIds[element.id]) {
           const newElement = {
             ...element,
-            shape: null,
             backgroundColor: pastedElement?.backgroundColor,
             strokeWidth: pastedElement?.strokeWidth,
             strokeColor: pastedElement?.strokeColor,

+ 16 - 4
src/actions/actionZindex.tsx

@@ -20,7 +20,10 @@ export const actionSendBackward = register({
   name: "sendBackward",
   perform: (elements, appState) => {
     return {
-      elements: moveOneLeft([...elements], getSelectedIndices(elements)),
+      elements: moveOneLeft(
+        [...elements],
+        getSelectedIndices(elements, appState),
+      ),
       appState,
     };
   },
@@ -44,7 +47,10 @@ export const actionBringForward = register({
   name: "bringForward",
   perform: (elements, appState) => {
     return {
-      elements: moveOneRight([...elements], getSelectedIndices(elements)),
+      elements: moveOneRight(
+        [...elements],
+        getSelectedIndices(elements, appState),
+      ),
       appState,
     };
   },
@@ -68,7 +74,10 @@ export const actionSendToBack = register({
   name: "sendToBack",
   perform: (elements, appState) => {
     return {
-      elements: moveAllLeft([...elements], getSelectedIndices(elements)),
+      elements: moveAllLeft(
+        [...elements],
+        getSelectedIndices(elements, appState),
+      ),
       appState,
     };
   },
@@ -91,7 +100,10 @@ export const actionBringToFront = register({
   name: "bringToFront",
   perform: (elements, appState) => {
     return {
-      elements: moveAllRight([...elements], getSelectedIndices(elements)),
+      elements: moveAllRight(
+        [...elements],
+        getSelectedIndices(elements, appState),
+      ),
       appState,
     };
   },

+ 1 - 0
src/appState.ts

@@ -32,6 +32,7 @@ export function getDefaultAppState(): AppState {
     zoom: 1,
     openMenu: null,
     lastPointerDownWith: "mouse",
+    selectedElementIds: {},
   };
 }
 

+ 3 - 3
src/clipboard.ts

@@ -1,5 +1,6 @@
 import { ExcalidrawElement } from "./element/types";
 import { getSelectedElements } from "./scene";
+import { AppState } from "./types";
 
 let CLIPBOARD = "";
 let PREFER_APP_CLIPBOARD = false;
@@ -18,10 +19,9 @@ export const probablySupportsClipboardBlob =
 
 export async function copyToAppClipboard(
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
 ) {
-  CLIPBOARD = JSON.stringify(
-    getSelectedElements(elements).map(({ shape, canvas, ...el }) => el),
-  );
+  CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
   try {
     // when copying to in-app clipboard, clear system clipboard so that if
     //  system clip contains text on paste we know it was copied *after* user

+ 6 - 3
src/components/Actions.tsx

@@ -1,7 +1,7 @@
 import React from "react";
 import { ExcalidrawElement } from "../element/types";
 import { ActionManager } from "../actions/manager";
-import { hasBackground, hasStroke, hasText, clearSelection } from "../scene";
+import { hasBackground, hasStroke, hasText } from "../scene";
 import { t } from "../i18n";
 import { SHAPES } from "../shapes";
 import { ToolButton } from "./ToolButton";
@@ -92,8 +92,11 @@ export function ShapesSwitcher({
             aria-label={capitalizeString(label)}
             aria-keyshortcuts={`${label[0]} ${index + 1}`}
             onChange={() => {
-              setAppState({ elementType: value, multiElement: null });
-              setElements(clearSelection(elements));
+              setAppState({
+                elementType: value,
+                multiElement: null,
+                selectedElementIds: {},
+              });
               document.documentElement.style.cursor =
                 value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
               setAppState({});

+ 158 - 78
src/components/App.tsx

@@ -19,7 +19,6 @@ import {
   normalizeDimensions,
 } from "../element";
 import {
-  clearSelection,
   deleteSelectedElements,
   getElementsWithinSelection,
   isOverScrollBars,
@@ -77,6 +76,7 @@ import {
 } from "../constants";
 import { LayerUI } from "./LayerUI";
 import { ScrollBars } from "../scene/types";
+import { invalidateShapeForElement } from "../renderer/renderElement";
 
 // -----------------------------------------------------------------------------
 // TEST HOOKS
@@ -179,8 +179,8 @@ export class App extends React.Component<any, AppState> {
     if (isWritableElement(event.target)) {
       return;
     }
-    copyToAppClipboard(elements);
-    elements = deleteSelectedElements(elements);
+    copyToAppClipboard(elements, this.state);
+    elements = deleteSelectedElements(elements, this.state);
     history.resumeRecording();
     this.setState({});
     event.preventDefault();
@@ -189,7 +189,7 @@ export class App extends React.Component<any, AppState> {
     if (isWritableElement(event.target)) {
       return;
     }
-    copyToAppClipboard(elements);
+    copyToAppClipboard(elements, this.state);
     event.preventDefault();
   };
 
@@ -296,7 +296,7 @@ export class App extends React.Component<any, AppState> {
   public state: AppState = getDefaultAppState();
 
   private onResize = () => {
-    elements = elements.map(el => ({ ...el, shape: null }));
+    elements.forEach(element => invalidateShapeForElement(element));
     this.setState({});
   };
 
@@ -325,7 +325,7 @@ export class App extends React.Component<any, AppState> {
         ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
         : ELEMENT_TRANSLATE_AMOUNT;
       elements = elements.map(el => {
-        if (el.isSelected) {
+        if (this.state.selectedElementIds[el.id]) {
           const element = { ...el };
           if (event.key === KEYS.ARROW_LEFT) {
             element.x -= step;
@@ -361,19 +361,18 @@ export class App extends React.Component<any, AppState> {
       if (this.state.elementType === "selection") {
         resetCursor();
       } else {
-        elements = clearSelection(elements);
         document.documentElement.style.cursor =
           this.state.elementType === "text"
             ? CURSOR_TYPE.TEXT
             : CURSOR_TYPE.CROSSHAIR;
-        this.setState({});
+        this.setState({ selectedElementIds: {} });
       }
       isHoldingSpace = false;
     }
   };
 
   private copyToAppClipboard = () => {
-    copyToAppClipboard(elements);
+    copyToAppClipboard(elements, this.state);
   };
 
   private pasteFromClipboard = async (event: ClipboardEvent | null) => {
@@ -413,9 +412,8 @@ export class App extends React.Component<any, AppState> {
           this.state.currentItemFont,
         );
 
-        element.isSelected = true;
-
-        elements = [...clearSelection(elements), element];
+        elements = [...elements, element];
+        this.setState({ selectedElementIds: { [element.id]: true } });
         history.resumeRecording();
       }
       this.selectShapeTool("selection");
@@ -431,9 +429,10 @@ export class App extends React.Component<any, AppState> {
       document.activeElement.blur();
     }
     if (elementType !== "selection") {
-      elements = clearSelection(elements);
+      this.setState({ elementType, selectedElementIds: {} });
+    } else {
+      this.setState({ elementType });
     }
-    this.setState({ elementType });
   }
 
   private onGestureStart = (event: GestureEvent) => {
@@ -524,6 +523,7 @@ export class App extends React.Component<any, AppState> {
 
               const element = getElementAtPosition(
                 elements,
+                this.state,
                 x,
                 y,
                 this.state.zoom,
@@ -545,10 +545,8 @@ export class App extends React.Component<any, AppState> {
                 return;
               }
 
-              if (!element.isSelected) {
-                elements = clearSelection(elements);
-                element.isSelected = true;
-                this.setState({});
+              if (!this.state.selectedElementIds[element.id]) {
+                this.setState({ selectedElementIds: { [element.id]: true } });
               }
 
               ContextMenu.push({
@@ -760,12 +758,16 @@ export class App extends React.Component<any, AppState> {
               if (this.state.elementType === "selection") {
                 const resizeElement = getElementWithResizeHandler(
                   elements,
+                  this.state,
                   { x, y },
                   this.state.zoom,
                   event.pointerType,
                 );
 
-                const selectedElements = getSelectedElements(elements);
+                const selectedElements = getSelectedElements(
+                  elements,
+                  this.state,
+                );
                 if (selectedElements.length === 1 && resizeElement) {
                   this.setState({
                     resizingElement: resizeElement
@@ -781,13 +783,19 @@ export class App extends React.Component<any, AppState> {
                 } else {
                   hitElement = getElementAtPosition(
                     elements,
+                    this.state,
                     x,
                     y,
                     this.state.zoom,
                   );
                   // clear selection if shift is not clicked
-                  if (!hitElement?.isSelected && !event.shiftKey) {
-                    elements = clearSelection(elements);
+                  if (
+                    !(
+                      hitElement && this.state.selectedElementIds[hitElement.id]
+                    ) &&
+                    !event.shiftKey
+                  ) {
+                    this.setState({ selectedElementIds: {} });
                   }
 
                   // If we click on something
@@ -796,30 +804,37 @@ export class App extends React.Component<any, AppState> {
                     // if shift is not clicked, this will always return true
                     // otherwise, it will trigger selection based on current
                     // state of the box
-                    if (!hitElement.isSelected) {
-                      hitElement.isSelected = true;
+                    if (!this.state.selectedElementIds[hitElement.id]) {
+                      this.setState(prevState => ({
+                        selectedElementIds: {
+                          ...prevState.selectedElementIds,
+                          [hitElement!.id]: true,
+                        },
+                      }));
                       elements = elements.slice();
                       elementIsAddedToSelection = true;
                     }
 
                     // We duplicate the selected element if alt is pressed on pointer down
                     if (event.altKey) {
-                      elements = [
-                        ...elements.map(element => ({
-                          ...element,
-                          isSelected: false,
-                        })),
-                        ...getSelectedElements(elements).map(element => {
-                          const newElement = duplicateElement(element);
-                          newElement.isSelected = true;
-                          return newElement;
-                        }),
-                      ];
+                      // Move the currently selected elements to the top of the z index stack, and
+                      // put the duplicates where the selected elements used to be.
+                      const nextElements = [];
+                      const elementsToAppend = [];
+                      for (const element of elements) {
+                        if (this.state.selectedElementIds[element.id]) {
+                          nextElements.push(duplicateElement(element));
+                          elementsToAppend.push(element);
+                        } else {
+                          nextElements.push(element);
+                        }
+                      }
+                      elements = [...nextElements, ...elementsToAppend];
                     }
                   }
                 }
               } else {
-                elements = clearSelection(elements);
+                this.setState({ selectedElementIds: {} });
               }
 
               if (isTextElement(element)) {
@@ -872,10 +887,15 @@ export class App extends React.Component<any, AppState> {
                             text,
                             this.state.currentItemFont,
                           ),
-                          isSelected: true,
                         },
                       ];
                     }
+                    this.setState(prevState => ({
+                      selectedElementIds: {
+                        ...prevState.selectedElementIds,
+                        [element.id]: true,
+                      },
+                    }));
                     if (this.state.elementLocked) {
                       setCursorForShape(this.state.elementType);
                     }
@@ -905,13 +925,23 @@ export class App extends React.Component<any, AppState> {
                 if (this.state.multiElement) {
                   const { multiElement } = this.state;
                   const { x: rx, y: ry } = multiElement;
-                  multiElement.isSelected = true;
+                  this.setState(prevState => ({
+                    selectedElementIds: {
+                      ...prevState.selectedElementIds,
+                      [multiElement.id]: true,
+                    },
+                  }));
                   multiElement.points.push([x - rx, y - ry]);
-                  multiElement.shape = null;
+                  invalidateShapeForElement(multiElement);
                 } else {
-                  element.isSelected = false;
+                  this.setState(prevState => ({
+                    selectedElementIds: {
+                      ...prevState.selectedElementIds,
+                      [element.id]: false,
+                    },
+                  }));
                   element.points.push([0, 0]);
-                  element.shape = null;
+                  invalidateShapeForElement(element);
                   elements = [...elements, element];
                   this.setState({
                     draggingElement: element,
@@ -1047,7 +1077,10 @@ export class App extends React.Component<any, AppState> {
                 if (isResizingElements && this.state.resizingElement) {
                   this.setState({ isResizing: true });
                   const el = this.state.resizingElement;
-                  const selectedElements = getSelectedElements(elements);
+                  const selectedElements = getSelectedElements(
+                    elements,
+                    this.state,
+                  );
                   if (selectedElements.length === 1) {
                     const { x, y } = viewportCoordsToSceneCoords(
                       event,
@@ -1261,7 +1294,7 @@ export class App extends React.Component<any, AppState> {
                     );
                     el.x = element.x;
                     el.y = element.y;
-                    el.shape = null;
+                    invalidateShapeForElement(el);
 
                     lastX = x;
                     lastY = y;
@@ -1270,11 +1303,17 @@ export class App extends React.Component<any, AppState> {
                   }
                 }
 
-                if (hitElement?.isSelected) {
+                if (
+                  hitElement &&
+                  this.state.selectedElementIds[hitElement.id]
+                ) {
                   // Marking that click was used for dragging to check
                   // if elements should be deselected on pointerup
                   draggingOccurred = true;
-                  const selectedElements = getSelectedElements(elements);
+                  const selectedElements = getSelectedElements(
+                    elements,
+                    this.state,
+                  );
                   if (selectedElements.length > 0) {
                     const { x, y } = viewportCoordsToSceneCoords(
                       event,
@@ -1354,19 +1393,30 @@ export class App extends React.Component<any, AppState> {
                   draggingElement.height = height;
                 }
 
-                draggingElement.shape = null;
+                invalidateShapeForElement(draggingElement);
 
                 if (this.state.elementType === "selection") {
-                  if (!event.shiftKey && isSomeElementSelected(elements)) {
-                    elements = clearSelection(elements);
+                  if (
+                    !event.shiftKey &&
+                    isSomeElementSelected(elements, this.state)
+                  ) {
+                    this.setState({ selectedElementIds: {} });
                   }
                   const elementsWithinSelection = getElementsWithinSelection(
                     elements,
                     draggingElement,
                   );
-                  elementsWithinSelection.forEach(element => {
-                    element.isSelected = true;
-                  });
+                  this.setState(prevState => ({
+                    selectedElementIds: {
+                      ...prevState.selectedElementIds,
+                      ...Object.fromEntries(
+                        elementsWithinSelection.map(element => [
+                          element.id,
+                          true,
+                        ]),
+                      ),
+                    },
+                  }));
                 }
                 this.setState({});
               };
@@ -1406,20 +1456,27 @@ export class App extends React.Component<any, AppState> {
                       x - draggingElement.x,
                       y - draggingElement.y,
                     ]);
-                    draggingElement.shape = null;
+                    invalidateShapeForElement(draggingElement);
                     this.setState({ multiElement: this.state.draggingElement });
                   } else if (draggingOccurred && !multiElement) {
-                    this.state.draggingElement!.isSelected = true;
                     if (!elementLocked) {
                       resetCursor();
-                      this.setState({
+                      this.setState(prevState => ({
                         draggingElement: null,
                         elementType: "selection",
-                      });
+                        selectedElementIds: {
+                          ...prevState.selectedElementIds,
+                          [this.state.draggingElement!.id]: true,
+                        },
+                      }));
                     } else {
-                      this.setState({
+                      this.setState(prevState => ({
                         draggingElement: null,
-                      });
+                        selectedElementIds: {
+                          ...prevState.selectedElementIds,
+                          [this.state.draggingElement!.id]: true,
+                        },
+                      }));
                     }
                   }
                   return;
@@ -1470,27 +1527,37 @@ export class App extends React.Component<any, AppState> {
                   !elementIsAddedToSelection
                 ) {
                   if (event.shiftKey) {
-                    hitElement.isSelected = false;
+                    this.setState(prevState => ({
+                      selectedElementIds: {
+                        ...prevState.selectedElementIds,
+                        [hitElement!.id]: false,
+                      },
+                    }));
                   } else {
-                    elements = clearSelection(elements);
-                    hitElement.isSelected = true;
+                    this.setState(prevState => ({
+                      selectedElementIds: { [hitElement!.id]: true },
+                    }));
                   }
                 }
 
                 if (draggingElement === null) {
                   // if no element is clicked, clear the selection and redraw
-                  elements = clearSelection(elements);
-                  this.setState({});
+                  this.setState({ selectedElementIds: {} });
                   return;
                 }
 
                 if (!elementLocked) {
-                  draggingElement.isSelected = true;
+                  this.setState(prevState => ({
+                    selectedElementIds: {
+                      ...prevState.selectedElementIds,
+                      [draggingElement.id]: true,
+                    },
+                  }));
                 }
 
                 if (
                   elementType !== "selection" ||
-                  isSomeElementSelected(elements)
+                  isSomeElementSelected(elements, this.state)
                 ) {
                   history.resumeRecording();
                 }
@@ -1524,6 +1591,7 @@ export class App extends React.Component<any, AppState> {
 
               const elementAtPosition = getElementAtPosition(
                 elements,
+                this.state,
                 x,
                 y,
                 this.state.zoom,
@@ -1616,10 +1684,15 @@ export class App extends React.Component<any, AppState> {
                         // we need to recreate the element to update dimensions &
                         //  position
                         ...newTextElement(element, text, element.font),
-                        isSelected: true,
                       },
                     ];
                   }
+                  this.setState(prevState => ({
+                    selectedElementIds: {
+                      ...prevState.selectedElementIds,
+                      [element.id]: true,
+                    },
+                  }));
                   history.resumeRecording();
                   resetSelection();
                 },
@@ -1695,7 +1768,7 @@ export class App extends React.Component<any, AppState> {
                 const pnt = points[points.length - 1];
                 pnt[0] = x - originX;
                 pnt[1] = y - originY;
-                multiElement.shape = null;
+                invalidateShapeForElement(multiElement);
                 this.setState({});
                 return;
               }
@@ -1708,10 +1781,14 @@ export class App extends React.Component<any, AppState> {
                 return;
               }
 
-              const selectedElements = getSelectedElements(elements);
+              const selectedElements = getSelectedElements(
+                elements,
+                this.state,
+              );
               if (selectedElements.length === 1 && !isOverScrollBar) {
                 const resizeElement = getElementWithResizeHandler(
                   elements,
+                  this.state,
                   { x, y },
                   this.state.zoom,
                   event.pointerType,
@@ -1725,6 +1802,7 @@ export class App extends React.Component<any, AppState> {
               }
               const hitElement = getElementAtPosition(
                 elements,
+                this.state,
                 x,
                 y,
                 this.state.zoom,
@@ -1782,8 +1860,6 @@ export class App extends React.Component<any, AppState> {
   private addElementsFromPaste = (
     clipboardElements: readonly ExcalidrawElement[],
   ) => {
-    elements = clearSelection(elements);
-
     const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
 
     const elementsCenterX = distance(minX, maxX) / 2;
@@ -1798,17 +1874,20 @@ export class App extends React.Component<any, AppState> {
     const dx = x - elementsCenterX;
     const dy = y - elementsCenterY;
 
-    elements = [
-      ...elements,
-      ...clipboardElements.map(clipboardElements => {
-        const duplicate = duplicateElement(clipboardElements);
-        duplicate.x += dx - minX;
-        duplicate.y += dy - minY;
-        return duplicate;
-      }),
-    ];
+    const newElements = clipboardElements.map(clipboardElements => {
+      const duplicate = duplicateElement(clipboardElements);
+      duplicate.x += dx - minX;
+      duplicate.y += dy - minY;
+      return duplicate;
+    });
+
+    elements = [...elements, ...newElements];
     history.resumeRecording();
-    this.setState({});
+    this.setState({
+      selectedElementIds: Object.fromEntries(
+        newElements.map(element => [element.id, true]),
+      ),
+    });
   };
 
   private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {
@@ -1845,6 +1924,7 @@ export class App extends React.Component<any, AppState> {
   componentDidUpdate() {
     const { atLeastOneVisibleElement, scrollBars } = renderScene(
       elements,
+      this.state,
       this.state.selectionElement,
       this.rc!,
       this.canvas!,

+ 4 - 3
src/components/ExportDialog.tsx

@@ -48,7 +48,7 @@ function ExportModal({
   onExportToBackend: ExportCB;
   onCloseRequest: () => void;
 }) {
-  const someElementIsSelected = isSomeElementSelected(elements);
+  const someElementIsSelected = isSomeElementSelected(elements, appState);
   const [scale, setScale] = useState(defaultScale);
   const [exportSelected, setExportSelected] = useState(someElementIsSelected);
   const previewRef = useRef<HTMLDivElement>(null);
@@ -58,7 +58,7 @@ function ExportModal({
   const onlySelectedInput = useRef<HTMLInputElement>(null);
 
   const exportedElements = exportSelected
-    ? getSelectedElements(elements)
+    ? getSelectedElements(elements, appState)
     : elements;
 
   useEffect(() => {
@@ -67,7 +67,7 @@ function ExportModal({
 
   useEffect(() => {
     const previewNode = previewRef.current;
-    const canvas = exportToCanvas(exportedElements, {
+    const canvas = exportToCanvas(exportedElements, appState, {
       exportBackground,
       viewBackgroundColor,
       exportPadding,
@@ -78,6 +78,7 @@ function ExportModal({
       previewNode?.removeChild(canvas);
     };
   }, [
+    appState,
     exportedElements,
     exportBackground,
     exportPadding,

+ 8 - 14
src/components/HintViewer.tsx

@@ -4,15 +4,16 @@ import { ExcalidrawElement } from "../element/types";
 import { getSelectedElements } from "../scene";
 
 import "./HintViewer.css";
+import { AppState } from "../types";
 
 interface Hint {
-  elementType: string;
-  multiMode: boolean;
-  isResizing: boolean;
+  appState: AppState;
   elements: readonly ExcalidrawElement[];
 }
 
-const getHints = ({ elementType, multiMode, isResizing, elements }: Hint) => {
+const getHints = ({ appState, elements }: Hint) => {
+  const { elementType, isResizing } = appState;
+  const multiMode = appState.multiElement !== null;
   if (elementType === "arrow" || elementType === "line") {
     if (!multiMode) {
       return t("hints.linearElement");
@@ -21,7 +22,7 @@ const getHints = ({ elementType, multiMode, isResizing, elements }: Hint) => {
   }
 
   if (isResizing) {
-    const selectedElements = getSelectedElements(elements);
+    const selectedElements = getSelectedElements(elements, appState);
     if (
       selectedElements.length === 1 &&
       (selectedElements[0].type === "arrow" ||
@@ -36,16 +37,9 @@ const getHints = ({ elementType, multiMode, isResizing, elements }: Hint) => {
   return null;
 };
 
-export const HintViewer = ({
-  elementType,
-  multiMode,
-  isResizing,
-  elements,
-}: Hint) => {
+export const HintViewer = ({ appState, elements }: Hint) => {
   const hint = getHints({
-    elementType,
-    multiMode,
-    isResizing,
+    appState,
     elements,
   });
   if (!hint) {

+ 8 - 15
src/components/LayerUI.tsx

@@ -50,7 +50,7 @@ export const LayerUI = React.memo(
         scale,
       ) => {
         if (canvas) {
-          exportCanvas(type, exportedElements, canvas, {
+          exportCanvas(type, exportedElements, appState, canvas, {
             exportBackground: appState.exportBackground,
             name: appState.name,
             viewBackgroundColor: appState.viewBackgroundColor,
@@ -70,10 +70,11 @@ export const LayerUI = React.memo(
             if (canvas) {
               exportCanvas(
                 "backend",
-                exportedElements.map(element => ({
-                  ...element,
-                  isSelected: false,
-                })),
+                exportedElements,
+                {
+                  ...appState,
+                  selectedElementIds: {},
+                },
                 canvas,
                 appState,
               );
@@ -95,12 +96,7 @@ export const LayerUI = React.memo(
     ) : (
       <>
         <FixedSideContainer side="top">
-          <HintViewer
-            elementType={appState.elementType}
-            multiMode={appState.multiElement !== null}
-            isResizing={appState.isResizing}
-            elements={elements}
-          />
+          <HintViewer appState={appState} elements={elements} />
           <div className="App-menu App-menu_top">
             <Stack.Col gap={4} align="end">
               <Section className="App-right-menu" heading="canvasActions">
@@ -123,10 +119,7 @@ export const LayerUI = React.memo(
                 >
                   <Island padding={4}>
                     <SelectedShapeActions
-                      targetElements={getTargetElement(
-                        appState.editingElement,
-                        elements,
-                      )}
+                      targetElements={getTargetElement(elements, appState)}
                       renderAction={actionManager.renderAction}
                       elementType={appState.elementType}
                     />

+ 2 - 10
src/components/MobileMenu.tsx

@@ -58,10 +58,7 @@ export function MobileMenu({
         <Section className="App-mobile-menu" heading="selectedShapeActions">
           <div className="App-mobile-menu-scroller">
             <SelectedShapeActions
-              targetElements={getTargetElement(
-                appState.editingElement,
-                elements,
-              )}
+              targetElements={getTargetElement(elements, appState)}
               renderAction={actionManager.renderAction}
               elementType={appState.elementType}
             />
@@ -88,12 +85,7 @@ export function MobileMenu({
             </Stack.Col>
           )}
         </Section>
-        <HintViewer
-          elementType={appState.elementType}
-          multiMode={appState.multiElement !== null}
-          isResizing={appState.isResizing}
-          elements={elements}
-        />
+        <HintViewer appState={appState} elements={elements} />
       </FixedSideContainer>
       <footer className="App-toolbar">
         <div className="App-toolbar-content">

+ 2 - 1
src/data/index.ts

@@ -149,6 +149,7 @@ export async function importFromBackend(
 export async function exportCanvas(
   type: ExportType,
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   canvas: HTMLCanvasElement,
   {
     exportBackground,
@@ -181,7 +182,7 @@ export async function exportCanvas(
     return;
   }
 
-  const tempCanvas = exportToCanvas(elements, {
+  const tempCanvas = exportToCanvas(elements, appState, {
     exportBackground,
     viewBackgroundColor,
     exportPadding,

+ 1 - 1
src/data/json.ts

@@ -14,7 +14,7 @@ export function serializeAsJSON(
       type: "excalidraw",
       version: 1,
       source: window.location.origin,
-      elements: elements.map(({ shape, canvas, isSelected, ...el }) => el),
+      elements,
       appState: cleanAppStateForExport(appState),
     },
     null,

+ 2 - 11
src/data/localStorage.ts

@@ -10,14 +10,7 @@ export function saveToLocalStorage(
   elements: readonly ExcalidrawElement[],
   appState: AppState,
 ) {
-  localStorage.setItem(
-    LOCAL_STORAGE_KEY,
-    JSON.stringify(
-      elements.map(
-        ({ shape, canvas, ...element }: ExcalidrawElement) => element,
-      ),
-    ),
-  );
+  localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));
   localStorage.setItem(
     LOCAL_STORAGE_KEY_STATE,
     JSON.stringify(clearAppStateForLocalStorage(appState)),
@@ -31,9 +24,7 @@ export function restoreFromLocalStorage() {
   let elements = [];
   if (savedElements) {
     try {
-      elements = JSON.parse(savedElements).map(
-        ({ shape, ...element }: ExcalidrawElement) => element,
-      );
+      elements = JSON.parse(savedElements);
     } catch {
       // Do nothing because elements array is already empty
     }

+ 0 - 4
src/data/restore.ts

@@ -57,10 +57,6 @@ export function restore(
             ? 100
             : element.opacity,
         points,
-        shape: null,
-        canvas: null,
-        canvasOffsetX: element.canvasOffsetX || 0,
-        canvasOffsetY: element.canvasOffsetY || 0,
       };
     });
 

+ 4 - 4
src/element/bounds.ts

@@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./types";
 import { rotate } from "../math";
 import { Drawable } from "roughjs/bin/core";
 import { Point } from "roughjs/bin/geometry";
+import { getShapeForElement } from "../renderer/renderElement";
 
 // If the element is created from right to left, the width is going to be negative
 // This set of functions retrieves the absolute position of the 4 points.
@@ -33,7 +34,7 @@ export function getDiamondPoints(element: ExcalidrawElement) {
 }
 
 export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
-  if (element.points.length < 2 || !element.shape) {
+  if (element.points.length < 2 || !getShapeForElement(element)) {
     const { minX, minY, maxX, maxY } = element.points.reduce(
       (limits, [x, y]) => {
         limits.minY = Math.min(limits.minY, y);
@@ -54,7 +55,7 @@ export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
     ];
   }
 
-  const shape = element.shape as Drawable[];
+  const shape = getShapeForElement(element) as Drawable[];
 
   // first element is always the curve
   const ops = shape[0].sets[0].ops;
@@ -118,8 +119,7 @@ export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
   ];
 }
 
-export function getArrowPoints(element: ExcalidrawElement) {
-  const shape = element.shape as Drawable[];
+export function getArrowPoints(element: ExcalidrawElement, shape: Drawable[]) {
   const ops = shape[0].sets[0].ops;
 
   const data = ops[ops.length - 1].data;

+ 16 - 7
src/element/collision.ts

@@ -9,13 +9,22 @@ import {
 } from "./bounds";
 import { Point } from "roughjs/bin/geometry";
 import { Drawable, OpSet } from "roughjs/bin/core";
+import { AppState } from "../types";
+import { getShapeForElement } from "../renderer/renderElement";
 
-function isElementDraggableFromInside(element: ExcalidrawElement): boolean {
-  return element.backgroundColor !== "transparent" || element.isSelected;
+function isElementDraggableFromInside(
+  element: ExcalidrawElement,
+  appState: AppState,
+): boolean {
+  return (
+    element.backgroundColor !== "transparent" ||
+    appState.selectedElementIds[element.id]
+  );
 }
 
 export function hitTest(
   element: ExcalidrawElement,
+  appState: AppState,
   x: number,
   y: number,
   zoom: number,
@@ -58,7 +67,7 @@ export function hitTest(
       ty /= t;
     });
 
-    if (isElementDraggableFromInside(element)) {
+    if (isElementDraggableFromInside(element, appState)) {
       return (
         a * tx - (px - lineThreshold) >= 0 && b * ty - (py - lineThreshold) >= 0
       );
@@ -67,7 +76,7 @@ export function hitTest(
   } else if (element.type === "rectangle") {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
 
-    if (isElementDraggableFromInside(element)) {
+    if (isElementDraggableFromInside(element, appState)) {
       return (
         x > x1 - lineThreshold &&
         x < x2 + lineThreshold &&
@@ -99,7 +108,7 @@ export function hitTest(
       leftY,
     ] = getDiamondPoints(element);
 
-    if (isElementDraggableFromInside(element)) {
+    if (isElementDraggableFromInside(element, appState)) {
       // TODO: remove this when we normalize coordinates globally
       if (topY > bottomY) {
         [bottomY, topY] = [topY, bottomY];
@@ -150,10 +159,10 @@ export function hitTest(
         lineThreshold
     );
   } else if (element.type === "arrow" || element.type === "line") {
-    if (!element.shape) {
+    if (!getShapeForElement(element)) {
       return false;
     }
-    const shape = element.shape as Drawable[];
+    const shape = getShapeForElement(element) as Drawable[];
 
     const [x1, y1, x2, y2] = getLinearElementAbsoluteBounds(element);
     if (x < x1 || y < y1 - 10 || x > x2 || y > y2 + 10) {

+ 0 - 2
src/element/newElement.test.ts

@@ -54,8 +54,6 @@ it("clones arrow element", () => {
     ...element,
     id: copy.id,
     seed: copy.seed,
-    shape: undefined,
-    canvas: undefined,
   });
 });
 

+ 0 - 8
src/element/newElement.ts

@@ -1,6 +1,5 @@
 import { randomSeed } from "roughjs/bin/math";
 import nanoid from "nanoid";
-import { Drawable } from "roughjs/bin/core";
 import { Point } from "roughjs/bin/geometry";
 
 import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
@@ -32,14 +31,8 @@ export function newElement(
     strokeWidth,
     roughness,
     opacity,
-    isSelected: false,
     seed: randomSeed(),
-    shape: null as Drawable | Drawable[] | null,
     points: [] as Point[],
-    canvas: null as HTMLCanvasElement | null,
-    canvasZoom: 1, // The zoom level used to render the cached canvas
-    canvasOffsetX: 0,
-    canvasOffsetY: 0,
   };
   return element;
 }
@@ -52,7 +45,6 @@ export function newTextElement(
   const metrics = measureText(text, font);
   const textElement: ExcalidrawTextElement = {
     ...element,
-    shape: null,
     type: "text",
     text: text,
     font: font,

+ 5 - 2
src/element/resizeTest.ts

@@ -1,17 +1,19 @@
 import { ExcalidrawElement, PointerType } from "./types";
 
 import { handlerRectangles } from "./handlerRectangles";
+import { AppState } from "../types";
 
 type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
 
 export function resizeTest(
   element: ExcalidrawElement,
+  appState: AppState,
   x: number,
   y: number,
   zoom: number,
   pointerType: PointerType,
 ): HandlerRectanglesRet | false {
-  if (!element.isSelected || element.type === "text") {
+  if (!appState.selectedElementIds[element.id] || element.type === "text") {
     return false;
   }
 
@@ -40,6 +42,7 @@ export function resizeTest(
 
 export function getElementWithResizeHandler(
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   { x, y }: { x: number; y: number },
   zoom: number,
   pointerType: PointerType,
@@ -48,7 +51,7 @@ export function getElementWithResizeHandler(
     if (result) {
       return result;
     }
-    const resizeHandle = resizeTest(element, x, y, zoom, pointerType);
+    const resizeHandle = resizeTest(element, appState, x, y, zoom, pointerType);
     return resizeHandle ? { element, resizeHandle } : null;
   }, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
 }

+ 1 - 1
src/element/showSelectedShapeActions.ts

@@ -8,6 +8,6 @@ export const showSelectedShapeActions = (
 ) =>
   Boolean(
     appState.editingElement ||
-      getSelectedElements(elements).length ||
+      getSelectedElements(elements, appState).length ||
       appState.elementType !== "selection",
   );

+ 2 - 1
src/element/sizeHelpers.ts

@@ -1,4 +1,5 @@
 import { ExcalidrawElement } from "./types";
+import { invalidateShapeForElement } from "../renderer/renderElement";
 
 export function isInvisiblySmallElement(element: ExcalidrawElement): boolean {
   if (element.type === "arrow" || element.type === "line") {
@@ -86,7 +87,7 @@ export function normalizeDimensions(
     element.y -= element.height;
   }
 
-  element.shape = null;
+  invalidateShapeForElement(element);
 
   return true;
 }

+ 5 - 0
src/element/types.ts

@@ -1,5 +1,10 @@
 import { newElement } from "./newElement";
 
+/**
+ * ExcalidrawElement should be JSON serializable and (eventually) contain
+ * no computed data. The list of all ExcalidrawElements should be shareable
+ * between peers and contain no state local to the peer.
+ */
 export type ExcalidrawElement = ReturnType<typeof newElement>;
 export type ExcalidrawTextElement = ExcalidrawElement & {
   type: "text";

+ 1 - 3
src/history.ts

@@ -18,10 +18,8 @@ export class SceneHistory {
   ) {
     return JSON.stringify({
       appState: clearAppStatePropertiesForHistory(appState),
-      elements: elements.map(({ shape, canvas, ...element }) => ({
+      elements: elements.map(element => ({
         ...element,
-        shape: null,
-        canvas: null,
         points:
           appState.multiElement && appState.multiElement.id === element.id
             ? element.points.slice(0, -1)

+ 2 - 3
src/index-node.ts

@@ -1,4 +1,5 @@
 import { exportToCanvas } from "./scene/export";
+import { getDefaultAppState } from "./appState";
 
 const { registerFont, createCanvas } = require("canvas");
 
@@ -16,7 +17,6 @@ const elements = [
     strokeWidth: 1,
     roughness: 1,
     opacity: 100,
-    isSelected: false,
     seed: 749612521,
   },
   {
@@ -32,7 +32,6 @@ const elements = [
     strokeWidth: 1,
     roughness: 1,
     opacity: 100,
-    isSelected: false,
     seed: 952056308,
   },
   {
@@ -48,7 +47,6 @@ const elements = [
     strokeWidth: 1,
     roughness: 1,
     opacity: 100,
-    isSelected: false,
     seed: 1683771448,
     text: "test",
     font: "20px Virgil",
@@ -60,6 +58,7 @@ registerFont("./public/FG_Virgil.ttf", { family: "Virgil" });
 registerFont("./public/Cascadia.ttf", { family: "Cascadia" });
 const canvas = exportToCanvas(
   elements as any,
+  getDefaultAppState(),
   {
     exportBackground: true,
     viewBackgroundColor: "#ffffff",

+ 80 - 51
src/renderer/renderElement.ts

@@ -16,12 +16,26 @@ import rough from "roughjs/bin/rough";
 
 const CANVAS_PADDING = 20;
 
-function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
+export interface ExcalidrawElementWithCanvas {
+  element: ExcalidrawElement | ExcalidrawTextElement;
+  canvas: HTMLCanvasElement;
+  canvasZoom: number;
+  canvasOffsetX: number;
+  canvasOffsetY: number;
+}
+
+function generateElementCanvas(
+  element: ExcalidrawElement,
+  zoom: number,
+): ExcalidrawElementWithCanvas {
   const canvas = document.createElement("canvas");
-  var context = canvas.getContext("2d")!;
+  const context = canvas.getContext("2d")!;
 
   const isLinear = /\b(arrow|line)\b/.test(element.type);
 
+  let canvasOffsetX = 0;
+  let canvasOffsetY = 0;
+
   if (isLinear) {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
     canvas.width =
@@ -29,18 +43,15 @@ function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
     canvas.height =
       distance(y1, y2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
 
-    element.canvasOffsetX =
+    canvasOffsetX =
       element.x > x1
         ? Math.floor(distance(element.x, x1)) * window.devicePixelRatio
         : 0;
-    element.canvasOffsetY =
+    canvasOffsetY =
       element.y > y1
         ? Math.floor(distance(element.y, y1)) * window.devicePixelRatio
         : 0;
-    context.translate(
-      element.canvasOffsetX * zoom,
-      element.canvasOffsetY * zoom,
-    );
+    context.translate(canvasOffsetX * zoom, canvasOffsetY * zoom);
   } else {
     canvas.width =
       element.width * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
@@ -53,9 +64,8 @@ function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
 
   const rc = rough.canvas(canvas);
   drawElementOnCanvas(element, rc, context);
-  element.canvas = canvas;
-  element.canvasZoom = zoom;
   context.translate(-CANVAS_PADDING, -CANVAS_PADDING);
+  return { element, canvas, canvasZoom: zoom, canvasOffsetX, canvasOffsetY };
 }
 
 function drawElementOnCanvas(
@@ -68,12 +78,14 @@ function drawElementOnCanvas(
     case "rectangle":
     case "diamond":
     case "ellipse": {
-      rc.draw(element.shape as Drawable);
+      rc.draw(getShapeForElement(element) as Drawable);
       break;
     }
     case "arrow":
     case "line": {
-      (element.shape as Drawable[]).forEach(shape => rc.draw(shape));
+      (getShapeForElement(element) as Drawable[]).forEach(shape =>
+        rc.draw(shape),
+      );
       break;
     }
     default: {
@@ -99,32 +111,44 @@ function drawElementOnCanvas(
   context.globalAlpha = 1;
 }
 
+const elementWithCanvasCache = new WeakMap<
+  ExcalidrawElement,
+  ExcalidrawElementWithCanvas
+>();
+
+const shapeCache = new WeakMap<
+  ExcalidrawElement,
+  Drawable | Drawable[] | null
+>();
+
+export function getShapeForElement(element: ExcalidrawElement) {
+  return shapeCache.get(element);
+}
+
+export function invalidateShapeForElement(element: ExcalidrawElement) {
+  shapeCache.delete(element);
+}
+
 function generateElement(
   element: ExcalidrawElement,
   generator: RoughGenerator,
   sceneState?: SceneState,
 ) {
-  if (!element.shape) {
-    element.canvas = null;
+  let shape = shapeCache.get(element) || null;
+  if (!shape) {
     switch (element.type) {
       case "rectangle":
-        element.shape = generator.rectangle(
-          0,
-          0,
-          element.width,
-          element.height,
-          {
-            stroke: element.strokeColor,
-            fill:
-              element.backgroundColor === "transparent"
-                ? undefined
-                : element.backgroundColor,
-            fillStyle: element.fillStyle,
-            strokeWidth: element.strokeWidth,
-            roughness: element.roughness,
-            seed: element.seed,
-          },
-        );
+        shape = generator.rectangle(0, 0, element.width, element.height, {
+          stroke: element.strokeColor,
+          fill:
+            element.backgroundColor === "transparent"
+              ? undefined
+              : element.backgroundColor,
+          fillStyle: element.fillStyle,
+          strokeWidth: element.strokeWidth,
+          roughness: element.roughness,
+          seed: element.seed,
+        });
 
         break;
       case "diamond": {
@@ -138,7 +162,7 @@ function generateElement(
           leftX,
           leftY,
         ] = getDiamondPoints(element);
-        element.shape = generator.polygon(
+        shape = generator.polygon(
           [
             [topX, topY],
             [rightX, rightY],
@@ -160,7 +184,7 @@ function generateElement(
         break;
       }
       case "ellipse":
-        element.shape = generator.ellipse(
+        shape = generator.ellipse(
           element.width / 2,
           element.height / 2,
           element.width,
@@ -195,12 +219,12 @@ function generateElement(
 
         // curve is always the first element
         // this simplifies finding the curve for an element
-        element.shape = [generator.curve(points, options)];
+        shape = [generator.curve(points, options)];
 
         // add lines only in arrow
         if (element.type === "arrow") {
-          const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
-          element.shape.push(
+          const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element, shape);
+          shape.push(
             ...[
               generator.line(x3, y3, x2, y2, options),
               generator.line(x4, y4, x2, y2, options),
@@ -211,19 +235,22 @@ function generateElement(
       }
       case "text": {
         // just to ensure we don't regenerate element.canvas on rerenders
-        element.shape = [];
+        shape = [];
         break;
       }
     }
+    shapeCache.set(element, shape);
   }
   const zoom = sceneState ? sceneState.zoom : 1;
-  if (!element.canvas || element.canvasZoom !== zoom) {
-    generateElementCanvas(element, zoom);
+  const prevElementWithCanvas = elementWithCanvasCache.get(element);
+  if (!prevElementWithCanvas || prevElementWithCanvas.canvasZoom !== zoom) {
+    return generateElementCanvas(element, zoom);
   }
+  return prevElementWithCanvas;
 }
 
 function drawElementFromCanvas(
-  element: ExcalidrawElement | ExcalidrawTextElement,
+  elementWithCanvas: ExcalidrawElementWithCanvas,
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
   sceneState: SceneState,
@@ -234,17 +261,19 @@ function drawElementFromCanvas(
     -CANVAS_PADDING / sceneState.zoom,
   );
   context.drawImage(
-    element.canvas!,
+    elementWithCanvas.canvas!,
     Math.floor(
-      -element.canvasOffsetX +
-        (Math.floor(element.x) + sceneState.scrollX) * window.devicePixelRatio,
+      -elementWithCanvas.canvasOffsetX +
+        (Math.floor(elementWithCanvas.element.x) + sceneState.scrollX) *
+          window.devicePixelRatio,
     ),
     Math.floor(
-      -element.canvasOffsetY +
-        (Math.floor(element.y) + sceneState.scrollY) * window.devicePixelRatio,
+      -elementWithCanvas.canvasOffsetY +
+        (Math.floor(elementWithCanvas.element.y) + sceneState.scrollY) *
+          window.devicePixelRatio,
     ),
-    element.canvas!.width / sceneState.zoom,
-    element.canvas!.height / sceneState.zoom,
+    elementWithCanvas.canvas!.width / sceneState.zoom,
+    elementWithCanvas.canvas!.height / sceneState.zoom,
   );
   context.translate(
     CANVAS_PADDING / sceneState.zoom,
@@ -279,10 +308,10 @@ export function renderElement(
     case "line":
     case "arrow":
     case "text": {
-      generateElement(element, generator, sceneState);
+      const elementWithCanvas = generateElement(element, generator, sceneState);
 
       if (renderOptimizations) {
-        drawElementFromCanvas(element, rc, context, sceneState);
+        drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
       } else {
         const offsetX = Math.floor(element.x + sceneState.scrollX);
         const offsetY = Math.floor(element.y + sceneState.scrollY);
@@ -316,7 +345,7 @@ export function renderElementToSvg(
     case "diamond":
     case "ellipse": {
       generateElement(element, generator);
-      const node = rsvg.draw(element.shape as Drawable);
+      const node = rsvg.draw(getShapeForElement(element) as Drawable);
       const opacity = element.opacity / 100;
       if (opacity !== 1) {
         node.setAttribute("stroke-opacity", `${opacity}`);
@@ -334,7 +363,7 @@ export function renderElementToSvg(
       generateElement(element, generator);
       const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
       const opacity = element.opacity / 100;
-      (element.shape as Drawable[]).forEach(shape => {
+      (getShapeForElement(element) as Drawable[]).forEach(shape => {
         const node = rsvg.draw(shape);
         if (opacity !== 1) {
           node.setAttribute("stroke-opacity", `${opacity}`);

+ 3 - 2
src/renderer/renderScene.ts

@@ -1,7 +1,7 @@
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { RoughSVG } from "roughjs/bin/svg";
 
-import { FlooredNumber } from "../types";
+import { FlooredNumber, AppState } from "../types";
 import { ExcalidrawElement } from "../element/types";
 import { getElementAbsoluteCoords, handlerRectangles } from "../element";
 
@@ -19,6 +19,7 @@ import { renderElement, renderElementToSvg } from "./renderElement";
 
 export function renderScene(
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   selectionElement: ExcalidrawElement | null,
   rc: RoughCanvas,
   canvas: HTMLCanvasElement,
@@ -129,7 +130,7 @@ export function renderScene(
 
   // Pain selected elements
   if (renderSelection) {
-    const selectedElements = getSelectedElements(elements);
+    const selectedElements = getSelectedElements(elements, appState);
     const dashledLinePadding = 4 / sceneState.zoom;
 
     applyZoom(context);

+ 3 - 1
src/scene/comparisons.ts

@@ -1,6 +1,7 @@
 import { ExcalidrawElement } from "../element/types";
 
 import { getElementAbsoluteCoords, hitTest } from "../element";
+import { AppState } from "../types";
 
 export const hasBackground = (type: string) =>
   type === "rectangle" || type === "ellipse" || type === "diamond";
@@ -16,6 +17,7 @@ export const hasText = (type: string) => type === "text";
 
 export function getElementAtPosition(
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   x: number,
   y: number,
   zoom: number,
@@ -23,7 +25,7 @@ export function getElementAtPosition(
   let hitElement = null;
   // We need to to hit testing from front (end of the array) to back (beginning of the array)
   for (let i = elements.length - 1; i >= 0; --i) {
-    if (hitTest(elements[i], x, y, zoom)) {
+    if (hitTest(elements[i], appState, x, y, zoom)) {
       hitElement = elements[i];
       break;
     }

+ 3 - 0
src/scene/export.ts

@@ -4,9 +4,11 @@ import { getCommonBounds } from "../element/bounds";
 import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
 import { distance, SVG_NS } from "../utils";
 import { normalizeScroll } from "./scroll";
+import { AppState } from "../types";
 
 export function exportToCanvas(
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   {
     exportBackground,
     exportPadding = 10,
@@ -38,6 +40,7 @@ export function exportToCanvas(
 
   renderScene(
     elements,
+    appState,
     null,
     rough.canvas(tempCanvas),
     tempCanvas,

+ 0 - 1
src/scene/index.ts

@@ -1,6 +1,5 @@
 export { isOverScrollBars } from "./scrollbars";
 export {
-  clearSelection,
   getSelectedIndices,
   deleteSelectedElements,
   isSomeElementSelected,

+ 23 - 21
src/scene/selection.ts

@@ -1,5 +1,6 @@
 import { ExcalidrawElement } from "../element/types";
 import { getElementAbsoluteCoords } from "../element";
+import { AppState } from "../types";
 
 export function getElementsWithinSelection(
   elements: readonly ExcalidrawElement[],
@@ -29,26 +30,20 @@ export function getElementsWithinSelection(
   });
 }
 
-export function clearSelection(elements: readonly ExcalidrawElement[]) {
-  let someWasSelected = false;
-  elements.forEach(element => {
-    if (element.isSelected) {
-      someWasSelected = true;
-      element.isSelected = false;
-    }
-  });
-
-  return someWasSelected ? elements.slice() : elements;
-}
-
-export function deleteSelectedElements(elements: readonly ExcalidrawElement[]) {
-  return elements.filter(el => !el.isSelected);
+export function deleteSelectedElements(
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) {
+  return elements.filter(el => !appState.selectedElementIds[el.id]);
 }
 
-export function getSelectedIndices(elements: readonly ExcalidrawElement[]) {
+export function getSelectedIndices(
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) {
   const selectedIndices: number[] = [];
   elements.forEach((element, index) => {
-    if (element.isSelected) {
+    if (appState.selectedElementIds[element.id]) {
       selectedIndices.push(index);
     }
   });
@@ -57,8 +52,9 @@ export function getSelectedIndices(elements: readonly ExcalidrawElement[]) {
 
 export function isSomeElementSelected(
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
 ): boolean {
-  return elements.some(element => element.isSelected);
+  return elements.some(element => appState.selectedElementIds[element.id]);
 }
 
 /**
@@ -67,11 +63,14 @@ export function isSomeElementSelected(
  */
 export function getCommonAttributeOfSelectedElements<T>(
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   getAttribute: (element: ExcalidrawElement) => T,
 ): T | null {
   const attributes = Array.from(
     new Set(
-      getSelectedElements(elements).map(element => getAttribute(element)),
+      getSelectedElements(elements, appState).map(element =>
+        getAttribute(element),
+      ),
     ),
   );
   return attributes.length === 1 ? attributes[0] : null;
@@ -79,13 +78,16 @@ export function getCommonAttributeOfSelectedElements<T>(
 
 export function getSelectedElements(
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
 ): readonly ExcalidrawElement[] {
-  return elements.filter(element => element.isSelected);
+  return elements.filter(element => appState.selectedElementIds[element.id]);
 }
 
 export function getTargetElement(
-  editingElement: ExcalidrawElement | null,
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
 ) {
-  return editingElement ? [editingElement] : getSelectedElements(elements);
+  return appState.editingElement
+    ? [appState.editingElement]
+    : getSelectedElements(elements, appState);
 }

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

@@ -31,7 +31,7 @@ describe("move element", () => {
       expect(renderScene).toHaveBeenCalledTimes(4);
       expect(h.appState.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
-      expect(h.elements[0].isSelected).toBeTruthy();
+      expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
       expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
 
       renderScene.mockClear();
@@ -64,7 +64,7 @@ describe("duplicate element on move when ALT is clicked", () => {
       expect(renderScene).toHaveBeenCalledTimes(4);
       expect(h.appState.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
-      expect(h.elements[0].isSelected).toBeTruthy();
+      expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
       expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
 
       renderScene.mockClear();
@@ -77,6 +77,7 @@ describe("duplicate element on move when ALT is clicked", () => {
     expect(renderScene).toHaveBeenCalledTimes(3);
     expect(h.appState.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(2);
+
     // previous element should stay intact
     expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
     expect([h.elements[1].x, h.elements[1].y]).toEqual([0, 40]);

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

@@ -31,7 +31,7 @@ describe("resize element", () => {
       expect(renderScene).toHaveBeenCalledTimes(4);
       expect(h.appState.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
-      expect(h.elements[0].isSelected).toBeTruthy();
+      expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
       expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
 
       expect([h.elements[0].width, h.elements[0].height]).toEqual([30, 50]);
@@ -72,7 +72,7 @@ describe("resize element with aspect ratio when SHIFT is clicked", () => {
       expect(renderScene).toHaveBeenCalledTimes(4);
       expect(h.appState.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
-      expect(h.elements[0].isSelected).toBeTruthy();
+      expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
       expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
       expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
       expect([h.elements[0].width, h.elements[0].height]).toEqual([30, 50]);

+ 5 - 5
src/tests/selection.test.tsx

@@ -97,7 +97,7 @@ describe("select single element on the scene", () => {
     expect(renderScene).toHaveBeenCalledTimes(7);
     expect(h.appState.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
-    expect(h.elements[0].isSelected).toBeTruthy();
+    expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
   });
 
   it("diamond", () => {
@@ -122,7 +122,7 @@ describe("select single element on the scene", () => {
     expect(renderScene).toHaveBeenCalledTimes(7);
     expect(h.appState.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
-    expect(h.elements[0].isSelected).toBeTruthy();
+    expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
   });
 
   it("ellipse", () => {
@@ -147,7 +147,7 @@ describe("select single element on the scene", () => {
     expect(renderScene).toHaveBeenCalledTimes(7);
     expect(h.appState.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
-    expect(h.elements[0].isSelected).toBeTruthy();
+    expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
   });
 
   it("arrow", () => {
@@ -172,7 +172,7 @@ describe("select single element on the scene", () => {
     expect(renderScene).toHaveBeenCalledTimes(7);
     expect(h.appState.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
-    expect(h.elements[0].isSelected).toBeTruthy();
+    expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
   });
 
   it("arrow", () => {
@@ -197,6 +197,6 @@ describe("select single element on the scene", () => {
     expect(renderScene).toHaveBeenCalledTimes(7);
     expect(h.appState.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
-    expect(h.elements[0].isSelected).toBeTruthy();
+    expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
   });
 });

+ 1 - 0
src/types.ts

@@ -33,6 +33,7 @@ export type AppState = {
   zoom: number;
   openMenu: "canvas" | "shape" | null;
   lastPointerDownWith: PointerType;
+  selectedElementIds: { [id: string]: boolean };
 };
 
 export type Pointer = Readonly<{