Ver Fonte

Make all operations on elements array immutable (#283)

* Make scene functions return array instead of mutate array

- Not all functions were changes; so the given argument was a new array to some

* Make data restoration functions immutable

- Make mutations in App component

* Make history actions immutable

* Fix an issue in change property that was causing elements to be removed

* mark elements params as readonly & remove unnecessary copying

* Make `clearSelection` return a new array

* Perform Id comparisons instead of reference comparisons in onDoubleClick

* Allow deselecting items with SHIFT key

- Refactor hit detection code

* Fix a bug in element selection and revert drag functionality

Co-authored-by: David Luzar <luzar.david@gmail.com>
Gasim Gasimzada há 5 anos atrás
pai
commit
862231da4f

+ 0 - 2
CONTRIBUTING.md

@@ -1,6 +1,5 @@
 # Contributing
 
-
 ## Setup
 
 ### Option 1 - Manual
@@ -17,7 +16,6 @@
 > git fetch upstream
 > git branch --set-upstream-to=upstream/master master
 > ```
->
 
 ### Option 2 - Codesandbox
 

+ 1 - 1
src/element/resizeTest.ts

@@ -34,7 +34,7 @@ export function resizeTest(
 }
 
 export function getElementWithResizeHandler(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   { x, y }: { x: number; y: number },
   { scrollX, scrollY }: SceneScroll
 ) {

+ 16 - 11
src/history.ts

@@ -5,7 +5,7 @@ class SceneHistory {
   private stateHistory: string[] = [];
   private redoStack: string[] = [];
 
-  generateCurrentEntry(elements: ExcalidrawElement[]) {
+  generateCurrentEntry(elements: readonly ExcalidrawElement[]) {
     return JSON.stringify(
       elements.map(element => ({ ...element, isSelected: false }))
     );
@@ -22,30 +22,33 @@ class SceneHistory {
     this.stateHistory.push(newEntry);
   }
 
-  restoreEntry(elements: ExcalidrawElement[], entry: string) {
-    const newElements = JSON.parse(entry);
-    elements.splice(0, elements.length);
-    newElements.forEach((newElement: ExcalidrawElement) => {
-      elements.push(newElement);
-    });
+  restoreEntry(entry: string) {
     // When restoring, we shouldn't add an history entry otherwise we'll be stuck with it and can't go back
     this.skipRecording();
+
+    try {
+      return JSON.parse(entry);
+    } catch {
+      return null;
+    }
   }
 
   clearRedoStack() {
     this.redoStack.splice(0, this.redoStack.length);
   }
 
-  redoOnce(elements: ExcalidrawElement[]) {
+  redoOnce(elements: readonly ExcalidrawElement[]) {
     const currentEntry = this.generateCurrentEntry(elements);
     const entryToRestore = this.redoStack.pop();
     if (entryToRestore !== undefined) {
-      this.restoreEntry(elements, entryToRestore);
       this.stateHistory.push(currentEntry);
+      return this.restoreEntry(entryToRestore);
     }
+
+    return null;
   }
 
-  undoOnce(elements: ExcalidrawElement[]) {
+  undoOnce(elements: readonly ExcalidrawElement[]) {
     const currentEntry = this.generateCurrentEntry(elements);
     let entryToRestore = this.stateHistory.pop();
 
@@ -54,9 +57,11 @@ class SceneHistory {
       entryToRestore = this.stateHistory.pop();
     }
     if (entryToRestore !== undefined) {
-      this.restoreEntry(elements, entryToRestore);
       this.redoStack.push(currentEntry);
+      return this.restoreEntry(entryToRestore);
     }
+
+    return null;
   }
 
   isRecording() {

+ 134 - 82
src/index.tsx

@@ -56,7 +56,7 @@ import { Panel } from "./components/Panel";
 import "./styles.scss";
 import { getElementWithResizeHandler } from "./element/resizeTest";
 
-const { elements } = createScene();
+let { elements } = createScene();
 const { history } = createHistory();
 const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
 
@@ -119,9 +119,16 @@ export class App extends React.Component<{}, AppState> {
     document.addEventListener("mousemove", this.getCurrentCursorPosition);
     window.addEventListener("resize", this.onResize, false);
 
-    const savedState = restoreFromLocalStorage(elements);
-    if (savedState) {
-      this.setState(savedState);
+    const { elements: newElements, appState } = restoreFromLocalStorage();
+
+    if (newElements) {
+      elements = newElements;
+    }
+
+    if (appState) {
+      this.setState(appState);
+    } else {
+      this.forceUpdate();
     }
   }
 
@@ -163,7 +170,7 @@ export class App extends React.Component<{}, AppState> {
     if (isInputLike(event.target)) return;
 
     if (event.key === KEYS.ESCAPE) {
-      clearSelection(elements);
+      elements = clearSelection(elements);
       this.forceUpdate();
       event.preventDefault();
     } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {
@@ -173,13 +180,16 @@ export class App extends React.Component<{}, AppState> {
       const step = event.shiftKey
         ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
         : ELEMENT_TRANSLATE_AMOUNT;
-      elements.forEach(element => {
-        if (element.isSelected) {
+      elements = elements.map(el => {
+        if (el.isSelected) {
+          const element = { ...el };
           if (event.key === KEYS.ARROW_LEFT) element.x -= step;
           else if (event.key === KEYS.ARROW_RIGHT) element.x += step;
           else if (event.key === KEYS.ARROW_UP) element.y -= step;
           else if (event.key === KEYS.ARROW_DOWN) element.y += step;
+          return element;
         }
+        return el;
       });
       this.forceUpdate();
       event.preventDefault();
@@ -215,9 +225,12 @@ export class App extends React.Component<{}, AppState> {
       event.preventDefault();
       // Select all: Cmd-A
     } else if (event[META_KEY] && event.code === "KeyA") {
-      elements.forEach(element => {
+      let newElements = [...elements];
+      newElements.forEach(element => {
         element.isSelected = true;
       });
+
+      elements = newElements;
       this.forceUpdate();
       event.preventDefault();
     } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) {
@@ -225,10 +238,16 @@ export class App extends React.Component<{}, AppState> {
     } else if (event[META_KEY] && event.code === "KeyZ") {
       if (event.shiftKey) {
         // Redo action
-        history.redoOnce(elements);
+        const data = history.redoOnce(elements);
+        if (data !== null) {
+          elements = data;
+        }
       } else {
         // undo action
-        history.undoOnce(elements);
+        const data = history.undoOnce(elements);
+        if (data !== null) {
+          elements = data;
+        }
       }
       this.forceUpdate();
       event.preventDefault();
@@ -243,13 +262,13 @@ export class App extends React.Component<{}, AppState> {
   };
 
   private deleteSelectedElements = () => {
-    deleteSelectedElements(elements);
+    elements = deleteSelectedElements(elements);
     this.forceUpdate();
   };
 
   private clearCanvas = () => {
     if (window.confirm("This will clear the whole canvas. Are you sure?")) {
-      elements.splice(0, elements.length);
+      elements = [];
       this.setState({
         viewBackgroundColor: "#ffffff",
         scrollX: 0,
@@ -268,40 +287,45 @@ export class App extends React.Component<{}, AppState> {
 
   private pasteStyles = () => {
     const pastedElement = JSON.parse(copiedStyles);
-    elements.forEach(element => {
+    elements = elements.map(element => {
       if (element.isSelected) {
-        element.backgroundColor = pastedElement?.backgroundColor;
-        element.strokeWidth = pastedElement?.strokeWidth;
-        element.strokeColor = pastedElement?.strokeColor;
-        element.fillStyle = pastedElement?.fillStyle;
-        element.opacity = pastedElement?.opacity;
-        element.roughness = pastedElement?.roughness;
-        if (isTextElement(element)) {
-          element.font = pastedElement?.font;
-          this.redrawTextBoundingBox(element);
+        const newElement = {
+          ...element,
+          backgroundColor: pastedElement?.backgroundColor,
+          strokeWidth: pastedElement?.strokeWidth,
+          strokeColor: pastedElement?.strokeColor,
+          fillStyle: pastedElement?.fillStyle,
+          opacity: pastedElement?.opacity,
+          roughness: pastedElement?.roughness
+        };
+        if (isTextElement(newElement)) {
+          newElement.font = pastedElement?.font;
+          this.redrawTextBoundingBox(newElement);
         }
+        return newElement;
       }
+      return element;
     });
     this.forceUpdate();
   };
 
   private moveAllLeft = () => {
-    moveAllLeft(elements, getSelectedIndices(elements));
+    elements = moveAllLeft([...elements], getSelectedIndices(elements));
     this.forceUpdate();
   };
 
   private moveOneLeft = () => {
-    moveOneLeft(elements, getSelectedIndices(elements));
+    elements = moveOneLeft([...elements], getSelectedIndices(elements));
     this.forceUpdate();
   };
 
   private moveAllRight = () => {
-    moveAllRight(elements, getSelectedIndices(elements));
+    elements = moveAllRight([...elements], getSelectedIndices(elements));
     this.forceUpdate();
   };
 
   private moveOneRight = () => {
-    moveOneRight(elements, getSelectedIndices(elements));
+    elements = moveOneRight([...elements], getSelectedIndices(elements));
     this.forceUpdate();
   };
 
@@ -311,27 +335,39 @@ export class App extends React.Component<{}, AppState> {
     this.setState({ name });
   }
 
-  private changeProperty = (callback: (element: ExcalidrawElement) => void) => {
-    elements.forEach(element => {
+  private changeProperty = (
+    callback: (element: ExcalidrawElement) => ExcalidrawElement
+  ) => {
+    elements = elements.map(element => {
       if (element.isSelected) {
-        callback(element);
+        return callback(element);
       }
+      return element;
     });
 
     this.forceUpdate();
   };
 
   private changeOpacity = (event: React.ChangeEvent<HTMLInputElement>) => {
-    this.changeProperty(element => (element.opacity = +event.target.value));
+    this.changeProperty(element => ({
+      ...element,
+      opacity: +event.target.value
+    }));
   };
 
   private changeStrokeColor = (color: string) => {
-    this.changeProperty(element => (element.strokeColor = color));
+    this.changeProperty(element => ({
+      ...element,
+      strokeColor: color
+    }));
     this.setState({ currentItemStrokeColor: color });
   };
 
   private changeBackgroundColor = (color: string) => {
-    this.changeProperty(element => (element.backgroundColor = color));
+    this.changeProperty(element => ({
+      ...element,
+      backgroundColor: color
+    }));
     this.setState({ currentItemBackgroundColor: color });
   };
 
@@ -357,7 +393,6 @@ export class App extends React.Component<{}, AppState> {
     element.width = metrics.width;
     element.height = metrics.height;
     element.baseline = metrics.baseline;
-    this.forceUpdate();
   };
 
   public render() {
@@ -372,7 +407,7 @@ export class App extends React.Component<{}, AppState> {
             "text/plain",
             JSON.stringify(elements.filter(element => element.isSelected))
           );
-          deleteSelectedElements(elements);
+          elements = deleteSelectedElements(elements);
           this.forceUpdate();
           e.preventDefault();
         }}
@@ -394,7 +429,7 @@ export class App extends React.Component<{}, AppState> {
             activeTool={this.state.elementType}
             onToolChange={value => {
               this.setState({ elementType: value });
-              clearSelection(elements);
+              elements = clearSelection(elements);
               document.documentElement.style.cursor =
                 value === "text" ? "text" : "crosshair";
               this.forceUpdate();
@@ -440,9 +475,10 @@ export class App extends React.Component<{}, AppState> {
                     element => element.fillStyle
                   )}
                   onChange={value => {
-                    this.changeProperty(element => {
-                      element.fillStyle = value;
-                    });
+                    this.changeProperty(element => ({
+                      ...element,
+                      fillStyle: value
+                    }));
                   }}
                 />
               </>
@@ -462,9 +498,10 @@ export class App extends React.Component<{}, AppState> {
                     element => element.strokeWidth
                   )}
                   onChange={value => {
-                    this.changeProperty(element => {
-                      element.strokeWidth = value;
-                    });
+                    this.changeProperty(element => ({
+                      ...element,
+                      strokeWidth: value
+                    }));
                   }}
                 />
 
@@ -480,9 +517,10 @@ export class App extends React.Component<{}, AppState> {
                     element => element.roughness
                   )}
                   onChange={value =>
-                    this.changeProperty(element => {
-                      element.roughness = value;
-                    })
+                    this.changeProperty(element => ({
+                      ...element,
+                      roughness: value
+                    }))
                   }
                 />
               </>
@@ -511,6 +549,8 @@ export class App extends React.Component<{}, AppState> {
                         }`;
                         this.redrawTextBoundingBox(element);
                       }
+
+                      return element;
                     })
                   }
                 />
@@ -534,6 +574,8 @@ export class App extends React.Component<{}, AppState> {
                         }px ${value}`;
                         this.redrawTextBoundingBox(element);
                       }
+
+                      return element;
                     })
                   }
                 />
@@ -575,7 +617,10 @@ export class App extends React.Component<{}, AppState> {
             }
             onSaveScene={() => saveAsJSON(elements, this.state.name)}
             onLoadScene={() =>
-              loadFromJSON(elements).then(() => this.forceUpdate())
+              loadFromJSON().then(({ elements: newElements }) => {
+                elements = newElements;
+                this.forceUpdate();
+              })
             }
           />
         </div>
@@ -638,7 +683,7 @@ export class App extends React.Component<{}, AppState> {
             }
 
             if (!element.isSelected) {
-              clearSelection(elements);
+              elements = clearSelection(elements);
               element.isSelected = true;
               this.forceUpdate();
             }
@@ -730,36 +775,41 @@ export class App extends React.Component<{}, AppState> {
                 document.documentElement.style.cursor = `${resizeHandle}-resize`;
                 isResizingElements = true;
               } else {
+                const selected = getElementAtPosition(
+                  elements.filter(el => el.isSelected),
+                  x,
+                  y
+                );
+                // clear selection if shift is not clicked
+                if (!selected && !e.shiftKey) {
+                  elements = clearSelection(elements);
+                }
                 const hitElement = getElementAtPosition(elements, x, y);
 
                 // If we click on something
                 if (hitElement) {
-                  if (hitElement.isSelected) {
-                    // If that element is already selected, do nothing,
-                    // we're likely going to drag it
-                  } else {
-                    // We unselect every other elements unless shift is pressed
-                    if (!e.shiftKey) {
-                      clearSelection(elements);
-                    }
-                  }
-                  // No matter what, we select it
+                  // deselect if item is selected
+                  // if shift is not clicked, this will always return true
+                  // otherwise, it will trigger selection based on current
+                  // state of the box
                   hitElement.isSelected = true;
+
+                  // No matter what, we select it
                   // We duplicate the selected element if alt is pressed on Mouse down
                   if (e.altKey) {
-                    elements.push(
+                    elements = [
+                      ...elements,
                       ...elements.reduce((duplicates, element) => {
                         if (element.isSelected) {
-                          duplicates.push(duplicateElement(element));
+                          duplicates = duplicates.concat(
+                            duplicateElement(element)
+                          );
                           element.isSelected = false;
                         }
                         return duplicates;
                       }, [] as typeof elements)
-                    );
+                    ];
                   }
-                } else {
-                  // If we don't click on anything, let's remove all the selected elements
-                  clearSelection(elements);
                 }
 
                 isDraggingElements = someElementIsSelected(elements);
@@ -794,8 +844,7 @@ export class App extends React.Component<{}, AppState> {
                 font: this.state.currentItemFont,
                 onSubmit: text => {
                   addTextElement(element, text, this.state.currentItemFont);
-                  elements.push(element);
-                  element.isSelected = true;
+                  elements = [...elements, { ...element, isSelected: true }];
                   this.setState({
                     draggingElement: null,
                     elementType: "selection"
@@ -805,14 +854,14 @@ export class App extends React.Component<{}, AppState> {
               return;
             }
 
-            elements.push(element);
             if (this.state.elementType === "text") {
+              elements = [...elements, { ...element, isSelected: true }];
               this.setState({
                 draggingElement: null,
                 elementType: "selection"
               });
-              element.isSelected = true;
             } else {
+              elements = [...elements, element];
               this.setState({ draggingElement: element });
             }
 
@@ -959,7 +1008,7 @@ export class App extends React.Component<{}, AppState> {
                 : height;
 
               if (this.state.elementType === "selection") {
-                setSelection(elements, draggingElement);
+                elements = setSelection(elements, draggingElement);
               }
               // We don't want to save history when moving an element
               history.skipRecording();
@@ -977,7 +1026,7 @@ export class App extends React.Component<{}, AppState> {
 
               // if no element is clicked, clear the selection and redraw
               if (draggingElement === null) {
-                clearSelection(elements);
+                elements = clearSelection(elements);
                 this.forceUpdate();
                 return;
               }
@@ -986,7 +1035,7 @@ export class App extends React.Component<{}, AppState> {
                 if (isDraggingElements) {
                   isDraggingElements = false;
                 }
-                elements.pop();
+                elements = elements.slice(0, -1);
               } else {
                 draggingElement.isSelected = true;
               }
@@ -1029,7 +1078,9 @@ export class App extends React.Component<{}, AppState> {
             let textY = e.clientY;
 
             if (elementAtPosition && isTextElement(elementAtPosition)) {
-              elements.splice(elements.indexOf(elementAtPosition), 1);
+              elements = elements.filter(
+                element => element.id !== elementAtPosition.id
+              );
               this.forceUpdate();
 
               Object.assign(element, elementAtPosition);
@@ -1073,8 +1124,7 @@ export class App extends React.Component<{}, AppState> {
                   text,
                   element.font || this.state.currentItemFont
                 );
-                elements.push(element);
-                element.isSelected = true;
+                elements = [...elements, { ...element, isSelected: true }];
                 this.setState({
                   draggingElement: null,
                   elementType: "selection"
@@ -1134,15 +1184,15 @@ export class App extends React.Component<{}, AppState> {
       parsedElements.length > 0 &&
       parsedElements[0].type // need to implement a better check here...
     ) {
-      clearSelection(elements);
+      elements = clearSelection(elements);
 
       let subCanvasX1 = Infinity;
       let subCanvasX2 = 0;
       let subCanvasY1 = Infinity;
       let subCanvasY2 = 0;
 
-      const minX = Math.min(...parsedElements.map(element => element.x));
-      const minY = Math.min(...parsedElements.map(element => element.y));
+      //const minX = Math.min(parsedElements.map(element => element.x));
+      //const minY = Math.min(parsedElements.map(element => element.y));
 
       const distance = (x: number, y: number) => {
         return Math.abs(x > y ? x - y : y - x);
@@ -1170,13 +1220,15 @@ export class App extends React.Component<{}, AppState> {
         CANVAS_WINDOW_OFFSET_TOP -
         elementsCenterY;
 
-      parsedElements.forEach(parsedElement => {
-        const duplicate = duplicateElement(parsedElement);
-        duplicate.x += dx - minX;
-        duplicate.y += dy - minY;
-        elements.push(duplicate);
-      });
-
+      elements = [
+        ...elements,
+        ...parsedElements.map(parsedElement => {
+          const duplicate = duplicateElement(parsedElement);
+          duplicate.x += dx;
+          duplicate.y += dy;
+          return duplicate;
+        })
+      ];
       this.forceUpdate();
     }
   };

+ 1 - 1
src/renderer/renderScene.ts

@@ -14,7 +14,7 @@ import {
 import { renderElement } from "./renderElement";
 
 export function renderScene(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   rc: RoughCanvas,
   canvas: HTMLCanvasElement,
   sceneState: SceneState,

+ 5 - 5
src/scene/comparisons.ts

@@ -2,7 +2,7 @@ import { ExcalidrawElement } from "../element/types";
 import { hitTest } from "../element/collision";
 import { getElementAbsoluteCoords } from "../element";
 
-export const hasBackground = (elements: ExcalidrawElement[]) =>
+export const hasBackground = (elements: readonly ExcalidrawElement[]) =>
   elements.some(
     element =>
       element.isSelected &&
@@ -11,7 +11,7 @@ export const hasBackground = (elements: ExcalidrawElement[]) =>
         element.type === "diamond")
   );
 
-export const hasStroke = (elements: ExcalidrawElement[]) =>
+export const hasStroke = (elements: readonly ExcalidrawElement[]) =>
   elements.some(
     element =>
       element.isSelected &&
@@ -21,11 +21,11 @@ export const hasStroke = (elements: ExcalidrawElement[]) =>
         element.type === "arrow")
   );
 
-export const hasText = (elements: ExcalidrawElement[]) =>
+export const hasText = (elements: readonly ExcalidrawElement[]) =>
   elements.some(element => element.isSelected && element.type === "text");
 
 export function getElementAtPosition(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   x: number,
   y: number
 ) {
@@ -42,7 +42,7 @@ export function getElementAtPosition(
 }
 
 export function getElementContainingPosition(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   x: number,
   y: number
 ) {

+ 1 - 1
src/scene/createScene.ts

@@ -1,6 +1,6 @@
 import { ExcalidrawElement } from "../element/types";
 
 export const createScene = () => {
-  const elements = Array.of<ExcalidrawElement>();
+  const elements: readonly ExcalidrawElement[] = [];
   return { elements };
 };

+ 58 - 40
src/scene/data.ts

@@ -22,7 +22,15 @@ function saveFile(name: string, data: string) {
   link.remove();
 }
 
-export function saveAsJSON(elements: ExcalidrawElement[], name: string) {
+interface DataState {
+  elements: readonly ExcalidrawElement[];
+  appState: any;
+}
+
+export function saveAsJSON(
+  elements: readonly ExcalidrawElement[],
+  name: string
+) {
   const serialized = JSON.stringify({
     version: 1,
     source: window.location.origin,
@@ -35,7 +43,7 @@ export function saveAsJSON(elements: ExcalidrawElement[], name: string) {
   );
 }
 
-export function loadFromJSON(elements: ExcalidrawElement[]) {
+export function loadFromJSON() {
   const input = document.createElement("input");
   const reader = new FileReader();
   input.type = "file";
@@ -52,19 +60,24 @@ export function loadFromJSON(elements: ExcalidrawElement[]) {
 
   input.click();
 
-  return new Promise(resolve => {
+  return new Promise<DataState>(resolve => {
     reader.onloadend = () => {
       if (reader.readyState === FileReader.DONE) {
-        const data = JSON.parse(reader.result as string);
-        restore(elements, data.elements, null);
-        resolve();
+        let elements = [];
+        try {
+          const data = JSON.parse(reader.result as string);
+          elements = data.elements || [];
+        } catch (e) {
+          // Do nothing because elements array is already empty
+        }
+        resolve(restore(elements, null));
       }
     };
   });
 }
 
 export function exportAsPNG(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   canvas: HTMLCanvasElement,
   {
     exportBackground,
@@ -130,47 +143,52 @@ export function exportAsPNG(
 }
 
 function restore(
-  elements: ExcalidrawElement[],
-  savedElements: string | ExcalidrawElement[] | null,
-  savedState: string | null
-) {
-  try {
-    if (savedElements) {
-      elements.splice(
-        0,
-        elements.length,
-        ...(typeof savedElements === "string"
-          ? JSON.parse(savedElements)
-          : savedElements)
-      );
-      elements.forEach((element: ExcalidrawElement) => {
-        element.id = element.id || nanoid();
-        element.fillStyle = element.fillStyle || "hachure";
-        element.strokeWidth = element.strokeWidth || 1;
-        element.roughness = element.roughness || 1;
-        element.opacity =
-          element.opacity === null || element.opacity === undefined
-            ? 100
-            : element.opacity;
-      });
-    }
-
-    return savedState ? JSON.parse(savedState) : null;
-  } catch (e) {
-    elements.splice(0, elements.length);
-    return null;
-  }
+  savedElements: readonly ExcalidrawElement[],
+  savedState: any
+): DataState {
+  return {
+    elements: savedElements.map(element => ({
+      ...element,
+      id: element.id || nanoid(),
+      fillStyle: element.fillStyle || "hachure",
+      strokeWidth: element.strokeWidth || 1,
+      roughness: element.roughness || 1,
+      opacity:
+        element.opacity === null || element.opacity === undefined
+          ? 100
+          : element.opacity
+    })),
+    appState: savedState
+  };
 }
 
-export function restoreFromLocalStorage(elements: ExcalidrawElement[]) {
+export function restoreFromLocalStorage() {
   const savedElements = localStorage.getItem(LOCAL_STORAGE_KEY);
   const savedState = localStorage.getItem(LOCAL_STORAGE_KEY_STATE);
 
-  return restore(elements, savedElements, savedState);
+  let elements = [];
+  if (savedElements) {
+    try {
+      elements = JSON.parse(savedElements);
+    } catch (e) {
+      // Do nothing because elements array is already empty
+    }
+  }
+
+  let appState = null;
+  if (savedState) {
+    try {
+      appState = JSON.parse(savedState);
+    } catch (e) {
+      // Do nothing because appState is already null
+    }
+  }
+
+  return restore(elements, appState);
 }
 
 export function saveToLocalStorage(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   state: AppState
 ) {
   localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));

+ 2 - 2
src/scene/scrollbars.ts

@@ -7,7 +7,7 @@ export const SCROLLBAR_WIDTH = 6;
 export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)";
 
 export function getScrollBars(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   canvasWidth: number,
   canvasHeight: number,
   scrollX: number,
@@ -76,7 +76,7 @@ export function getScrollBars(
 }
 
 export function isOverScrollBars(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   x: number,
   y: number,
   canvasWidth: number,

+ 14 - 12
src/scene/selection.ts

@@ -2,7 +2,7 @@ import { ExcalidrawElement } from "../element/types";
 import { getElementAbsoluteCoords } from "../element";
 
 export function setSelection(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   selection: ExcalidrawElement
 ) {
   const [
@@ -25,23 +25,25 @@ export function setSelection(
       selectionX2 >= elementX2 &&
       selectionY2 >= elementY2;
   });
+
+  return elements;
 }
 
-export function clearSelection(elements: ExcalidrawElement[]) {
-  elements.forEach(element => {
+export function clearSelection(elements: readonly ExcalidrawElement[]) {
+  const newElements = [...elements];
+
+  newElements.forEach(element => {
     element.isSelected = false;
   });
+
+  return newElements;
 }
 
-export function deleteSelectedElements(elements: ExcalidrawElement[]) {
-  for (let i = elements.length - 1; i >= 0; --i) {
-    if (elements[i].isSelected) {
-      elements.splice(i, 1);
-    }
-  }
+export function deleteSelectedElements(elements: readonly ExcalidrawElement[]) {
+  return elements.filter(el => !el.isSelected);
 }
 
-export function getSelectedIndices(elements: ExcalidrawElement[]) {
+export function getSelectedIndices(elements: readonly ExcalidrawElement[]) {
   const selectedIndices: number[] = [];
   elements.forEach((element, index) => {
     if (element.isSelected) {
@@ -51,11 +53,11 @@ export function getSelectedIndices(elements: ExcalidrawElement[]) {
   return selectedIndices;
 }
 
-export const someElementIsSelected = (elements: ExcalidrawElement[]) =>
+export const someElementIsSelected = (elements: readonly ExcalidrawElement[]) =>
   elements.some(element => element.isSelected);
 
 export function getSelectedAttribute<T>(
-  elements: ExcalidrawElement[],
+  elements: readonly ExcalidrawElement[],
   getAttribute: (element: ExcalidrawElement) => T
 ): T | null {
   const attributes = Array.from(

+ 7 - 0
src/zindex.ts

@@ -17,6 +17,8 @@ export function moveOneLeft<T>(elements: T[], indicesToMove: number[]) {
     }
     swap(elements, index - 1, index);
   });
+
+  return elements;
 }
 
 export function moveOneRight<T>(elements: T[], indicesToMove: number[]) {
@@ -35,6 +37,7 @@ export function moveOneRight<T>(elements: T[], indicesToMove: number[]) {
     }
     swap(elements, index + 1, index);
   });
+  return elements;
 }
 
 // Let's go through an example
@@ -112,6 +115,8 @@ export function moveAllLeft<T>(elements: T[], indicesToMove: number[]) {
   leftMostElements.forEach((element, i) => {
     elements[i] = element;
   });
+
+  return elements;
 }
 
 // Let's go through an example
@@ -190,4 +195,6 @@ export function moveAllRight<T>(elements: T[], indicesToMove: number[]) {
   rightMostElements.forEach((element, i) => {
     elements[elements.length - i - 1] = element;
   });
+
+  return elements;
 }