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>
This commit is contained in:
Gasim Gasimzada 2020-01-09 19:22:04 +04:00 committed by David Luzar
parent 1ea72e9134
commit 862231da4f
11 changed files with 239 additions and 157 deletions

View file

@ -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
) {

View file

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

View file

@ -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));

View file

@ -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,

View file

@ -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(