From e1e2249f577d73229902ad23ad76b2c880a39350 Mon Sep 17 00:00:00 2001 From: Pete Hunt Date: Sat, 14 Mar 2020 21:48:51 -0700 Subject: [PATCH] Abstract away or eliminate most of the mutation of the Elements array (#955) --- src/actions/actionFinalize.tsx | 3 +- src/actions/actionProperties.tsx | 6 +- src/actions/actionStyles.ts | 4 +- src/components/App.tsx | 433 ++++++++++++++++--------------- src/data/json.ts | 2 +- src/data/localStorage.ts | 5 +- src/data/restore.ts | 2 +- src/element/bounds.ts | 6 +- src/element/collision.ts | 4 +- src/element/mutateElement.ts | 63 ++--- src/element/newElement.ts | 4 +- src/element/sizeHelpers.ts | 3 - src/element/textElement.ts | 4 +- src/math.ts | 2 +- src/renderer/renderElement.ts | 7 +- src/scene/createScene.ts | 15 +- src/types.ts | 2 + 17 files changed, 293 insertions(+), 272 deletions(-) diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index e82c4c5e..9eeda4b0 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -6,7 +6,6 @@ import { ToolButton } from "../components/ToolButton"; import { done } from "../components/icons"; import { t } from "../i18n"; import { register } from "./register"; -import { invalidateShapeForElement } from "../renderer/renderElement"; import { mutateElement } from "../element/mutateElement"; export const actionFinalize = register({ @@ -29,7 +28,7 @@ export const actionFinalize = register({ if (isInvisiblySmallElement(appState.multiElement)) { newElements = newElements.slice(0, -1); } - invalidateShapeForElement(appState.multiElement); + if (!appState.elementLocked) { appState.selectedElementIds[appState.multiElement.id] = true; } diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 43516b71..0781d991 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -11,7 +11,7 @@ import { AppState } from "../../src/types"; import { t } from "../i18n"; import { DEFAULT_FONT } from "../appState"; import { register } from "./register"; -import { newElementWith, newTextElementWith } from "../element/mutateElement"; +import { newElementWith } from "../element/mutateElement"; const changeProperty = ( elements: readonly ExcalidrawElement[], @@ -266,7 +266,7 @@ export const actionChangeFontSize = register({ return { elements: changeProperty(elements, appState, el => { if (isTextElement(el)) { - const element: ExcalidrawTextElement = newTextElementWith(el, { + const element: ExcalidrawTextElement = newElementWith(el, { font: `${value}px ${el.font.split("px ")[1]}`, }); redrawTextBoundingBox(element); @@ -313,7 +313,7 @@ export const actionChangeFontFamily = register({ return { elements: changeProperty(elements, appState, el => { if (isTextElement(el)) { - const element: ExcalidrawTextElement = newTextElementWith(el, { + const element: ExcalidrawTextElement = newElementWith(el, { font: `${el.font.split("px ")[0]}px ${value}`, }); redrawTextBoundingBox(element); diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index 4c5d936d..e66f4179 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -6,7 +6,7 @@ import { import { KEYS } from "../keys"; import { DEFAULT_FONT } from "../appState"; import { register } from "./register"; -import { mutateTextElement, newElementWith } from "../element/mutateElement"; +import { mutateElement, newElementWith } from "../element/mutateElement"; let copiedStyles: string = "{}"; @@ -44,7 +44,7 @@ export const actionPasteStyles = register({ roughness: pastedElement?.roughness, }); if (isTextElement(newElement)) { - mutateTextElement(newElement, { + mutateElement(newElement, { font: pastedElement?.font || DEFAULT_FONT, }); redrawTextBoundingBox(newElement); diff --git a/src/components/App.tsx b/src/components/App.tsx index 993f1268..804f2b66 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -3,7 +3,6 @@ import React from "react"; import socketIOClient from "socket.io-client"; import rough from "roughjs/bin/rough"; import { RoughCanvas } from "roughjs/bin/canvas"; -import { Point } from "roughjs/bin/geometry"; import { newElement, @@ -95,9 +94,9 @@ import { } from "../constants"; import { LayerUI } from "./LayerUI"; import { ScrollBars } from "../scene/types"; -import { invalidateShapeForElement } from "../renderer/renderElement"; import { generateCollaborationLink, getCollaborationLinkData } from "../data"; import { mutateElement, newElementWith } from "../element/mutateElement"; +import { invalidateShapeForElement } from "../renderer/renderElement"; // ----------------------------------------------------------------------------- // TEST HOOKS @@ -106,27 +105,24 @@ import { mutateElement, newElementWith } from "../element/mutateElement"; declare global { interface Window { __TEST__: { - elements: typeof elements; + elements: readonly ExcalidrawElement[]; appState: AppState; }; - // TEMPORARY until we have a UI to support this - generateCollaborationLink: () => Promise; } } if (process.env.NODE_ENV === "test") { window.__TEST__ = {} as Window["__TEST__"]; } -window.generateCollaborationLink = generateCollaborationLink; // ----------------------------------------------------------------------------- -let { elements } = createScene(); +const scene = createScene(); if (process.env.NODE_ENV === "test") { Object.defineProperty(window.__TEST__, "elements", { get() { - return elements; + return scene.getAllElements(); }, }); } @@ -173,7 +169,7 @@ export class App extends React.Component { this.actionManager = new ActionManager( this.syncActionResult, () => this.state, - () => elements, + () => scene.getAllElements(), ); this.actionManager.registerAll(actions); @@ -181,6 +177,11 @@ export class App extends React.Component { this.actionManager.registerAction(createRedoAction(history)); } + private replaceElements = (nextElements: readonly ExcalidrawElement[]) => { + scene.replaceAllElements(nextElements); + this.setState({}); + }; + private syncActionResult = ( res: ActionResult, commitToHistory: boolean = true, @@ -189,11 +190,10 @@ export class App extends React.Component { return; } if (res.elements) { - elements = res.elements; + this.replaceElements(res.elements); if (commitToHistory) { history.resumeRecording(); } - this.setState({}); } if (res.appState) { @@ -212,12 +212,12 @@ export class App extends React.Component { if (isWritableElement(event.target)) { return; } - copyToAppClipboard(elements, this.state); + copyToAppClipboard(scene.getAllElements(), this.state); const { elements: nextElements, appState } = deleteSelectedElements( - elements, + scene.getAllElements(), this.state, ); - elements = nextElements; + this.replaceElements(nextElements); history.resumeRecording(); this.setState({ ...appState }); event.preventDefault(); @@ -226,7 +226,7 @@ export class App extends React.Component { if (isWritableElement(event.target)) { return; } - copyToAppClipboard(elements, this.state); + copyToAppClipboard(scene.getAllElements(), this.state); event.preventDefault(); }; @@ -291,70 +291,75 @@ export class App extends React.Component { // Perform reconciliation - in collaboration, if we encounter // elements with more staler versions than ours, ignore them // and keep ours. - if (elements == null || elements.length === 0) { - elements = restoredState.elements; + if ( + scene.getAllElements() == null || + scene.getAllElements().length === 0 + ) { + this.replaceElements(restoredState.elements); } else { // create a map of ids so we don't have to iterate // over the array more than once. - const localElementMap = getElementMap(elements); + const localElementMap = getElementMap(scene.getAllElements()); // Reconcile - elements = restoredState.elements - .reduce((elements, element) => { - // if the remote element references one that's currently - // edited on local, skip it (it'll be added in the next - // step) - if ( - element.id === this.state.editingElement?.id || - element.id === this.state.resizingElement?.id || - element.id === this.state.draggingElement?.id - ) { - return elements; - } - - if ( - localElementMap.hasOwnProperty(element.id) && - localElementMap[element.id].version > element.version - ) { - elements.push(localElementMap[element.id]); - delete localElementMap[element.id]; - } else if ( - localElementMap.hasOwnProperty(element.id) && - localElementMap[element.id].version === element.version && - localElementMap[element.id].versionNonce !== - element.versionNonce - ) { - // resolve conflicting edits deterministically by taking the one with the lowest versionNonce + this.replaceElements( + restoredState.elements + .reduce((elements, element) => { + // if the remote element references one that's currently + // edited on local, skip it (it'll be added in the next + // step) if ( - localElementMap[element.id].versionNonce < - element.versionNonce + element.id === this.state.editingElement?.id || + element.id === this.state.resizingElement?.id || + element.id === this.state.draggingElement?.id + ) { + return elements; + } + + if ( + localElementMap.hasOwnProperty(element.id) && + localElementMap[element.id].version > element.version ) { elements.push(localElementMap[element.id]); + delete localElementMap[element.id]; + } else if ( + localElementMap.hasOwnProperty(element.id) && + localElementMap[element.id].version === + element.version && + localElementMap[element.id].versionNonce !== + element.versionNonce + ) { + // resolve conflicting edits deterministically by taking the one with the lowest versionNonce + if ( + localElementMap[element.id].versionNonce < + element.versionNonce + ) { + elements.push(localElementMap[element.id]); + } else { + // it should be highly unlikely that the two versionNonces are the same. if we are + // really worried about this, we can replace the versionNonce with the socket id. + elements.push(element); + } + delete localElementMap[element.id]; } else { - // it should be highly unlikely that the two versionNonces are the same. if we are - // really worried about this, we can replace the versionNonce with the socket id. elements.push(element); + delete localElementMap[element.id]; } - delete localElementMap[element.id]; - } else { - elements.push(element); - delete localElementMap[element.id]; - } - return elements; - }, [] as any) - // add local elements that weren't deleted or on remote - .concat(...Object.values(localElementMap)); + return elements; + }, [] as any) + // add local elements that weren't deleted or on remote + .concat(...Object.values(localElementMap)), + ); } this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion( - elements, + scene.getAllElements(), ); // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack // when we receive any messages from another peer. This UX can be pretty rough -- if you // undo, a user makes a change, and then try to redo, your element(s) will be lost. However, // right now we think this is the right tradeoff. history.clear(); - this.setState({}); if (this.socketInitialized === false) { this.socketInitialized = true; } @@ -423,12 +428,12 @@ export class App extends React.Component { const data: SocketUpdateDataSource["SCENE_UPDATE"] = { type: "SCENE_UPDATE", payload: { - elements: getSyncableElements(elements), + elements: getSyncableElements(scene.getAllElements()), }, }; this.lastBroadcastedOrReceivedSceneVersion = Math.max( this.lastBroadcastedOrReceivedSceneVersion, - getDrawingVersion(elements), + getDrawingVersion(scene.getAllElements()), ); return this._broadcastSocketData( data as typeof data & { _brand: "socketUpdateData" }, @@ -553,7 +558,9 @@ export class App extends React.Component { public state: AppState = getDefaultAppState(); private onResize = () => { - elements.forEach(element => invalidateShapeForElement(element)); + scene + .getAllElements() + .forEach(element => invalidateShapeForElement(element)); this.setState({}); }; @@ -587,23 +594,24 @@ export class App extends React.Component { const step = event.shiftKey ? ELEMENT_SHIFT_TRANSLATE_AMOUNT : ELEMENT_TRANSLATE_AMOUNT; - elements = elements.map(el => { - if (this.state.selectedElementIds[el.id]) { - const update: { x?: number; y?: number } = {}; - if (event.key === KEYS.ARROW_LEFT) { - update.x = el.x - step; - } else if (event.key === KEYS.ARROW_RIGHT) { - update.x = el.x + step; - } else if (event.key === KEYS.ARROW_UP) { - update.y = el.y - step; - } else if (event.key === KEYS.ARROW_DOWN) { - update.y = el.y + step; + this.replaceElements( + scene.getAllElements().map(el => { + if (this.state.selectedElementIds[el.id]) { + const update: { x?: number; y?: number } = {}; + if (event.key === KEYS.ARROW_LEFT) { + update.x = el.x - step; + } else if (event.key === KEYS.ARROW_RIGHT) { + update.x = el.x + step; + } else if (event.key === KEYS.ARROW_UP) { + update.y = el.y - step; + } else if (event.key === KEYS.ARROW_DOWN) { + update.y = el.y + step; + } + return newElementWith(el, update); } - return newElementWith(el, update); - } - return el; - }); - this.setState({}); + return el; + }), + ); event.preventDefault(); } else if ( shapesShortcutKeys.includes(event.key.toLowerCase()) && @@ -635,14 +643,17 @@ export class App extends React.Component { }; private copyToAppClipboard = () => { - copyToAppClipboard(elements, this.state); + copyToAppClipboard(scene.getAllElements(), this.state); }; private copyToClipboardAsPng = () => { - const selectedElements = getSelectedElements(elements, this.state); + const selectedElements = getSelectedElements( + scene.getAllElements(), + this.state, + ); exportCanvas( "clipboard", - selectedElements.length ? selectedElements : elements, + selectedElements.length ? selectedElements : scene.getAllElements(), this.state, this.canvas!, this.state, @@ -686,7 +697,7 @@ export class App extends React.Component { this.state.currentItemFont, ); - elements = [...elements, element]; + this.replaceElements([...scene.getAllElements(), element]); this.setState({ selectedElementIds: { [element.id]: true } }); history.resumeRecording(); } @@ -729,11 +740,6 @@ export class App extends React.Component { this.setState(obj); }; - setElements = (elements_: readonly ExcalidrawElement[]) => { - elements = elements_; - this.setState({}); - }; - removePointer = (event: React.PointerEvent) => { gesture.pointers.delete(event.pointerId); }; @@ -768,8 +774,8 @@ export class App extends React.Component { appState={this.state} setAppState={this.setAppState} actionManager={this.actionManager} - elements={elements} - setElements={this.setElements} + elements={scene.getAllElements()} + setElements={this.replaceElements} language={getLanguage()} onRoomCreate={this.createRoom} onRoomDestroy={this.destroyRoom} @@ -810,7 +816,7 @@ export class App extends React.Component { ); const element = getElementAtPosition( - elements, + scene.getAllElements(), this.state, x, y, @@ -824,7 +830,7 @@ export class App extends React.Component { action: () => this.pasteFromClipboard(null), }, probablySupportsClipboardBlob && - hasNonDeletedElements(elements) && { + hasNonDeletedElements(scene.getAllElements()) && { label: t("labels.copyAsPng"), action: this.copyToClipboardAsPng, }, @@ -908,7 +914,7 @@ export class App extends React.Component { ); const elementAtPosition = getElementAtPosition( - elements, + scene.getAllElements(), this.state, x, y, @@ -940,10 +946,11 @@ export class App extends React.Component { let textY = event.clientY; if (elementAtPosition && isTextElement(elementAtPosition)) { - elements = elements.filter( - element => element.id !== elementAtPosition.id, + this.replaceElements( + scene + .getAllElements() + .filter(element => element.id !== elementAtPosition.id), ); - this.setState({}); const centerElementX = elementAtPosition.x + elementAtPosition.width / 2; const centerElementY = elementAtPosition.y + elementAtPosition.height / 2; @@ -998,14 +1005,14 @@ export class App extends React.Component { zoom: this.state.zoom, onSubmit: text => { if (text) { - elements = [ - ...elements, + this.replaceElements([ + ...scene.getAllElements(), { // we need to recreate the element to update dimensions & // position ...newTextElement(element, text, element.font), }, - ]; + ]); } this.setState(prevState => ({ selectedElementIds: { @@ -1083,11 +1090,11 @@ export class App extends React.Component { const originX = multiElement.x; const originY = multiElement.y; const points = multiElement.points; - const pnt = points[points.length - 1]; - pnt[0] = x - originX; - pnt[1] = y - originY; - mutateElement(multiElement); - invalidateShapeForElement(multiElement); + + mutateElement(multiElement, { + points: [...points.slice(0, -1), [x - originX, y - originY]], + }); + this.setState({}); return; } @@ -1097,10 +1104,13 @@ export class App extends React.Component { return; } - const selectedElements = getSelectedElements(elements, this.state); + const selectedElements = getSelectedElements( + scene.getAllElements(), + this.state, + ); if (selectedElements.length === 1 && !isOverScrollBar) { const resizeElement = getElementWithResizeHandler( - elements, + scene.getAllElements(), this.state, { x, y }, this.state.zoom, @@ -1114,7 +1124,7 @@ export class App extends React.Component { } } const hitElement = getElementAtPosition( - elements, + scene.getAllElements(), this.state, x, y, @@ -1306,14 +1316,17 @@ export class App extends React.Component { let elementIsAddedToSelection = false; if (this.state.elementType === "selection") { const resizeElement = getElementWithResizeHandler( - elements, + scene.getAllElements(), this.state, { x, y }, this.state.zoom, event.pointerType, ); - const selectedElements = getSelectedElements(elements, this.state); + const selectedElements = getSelectedElements( + scene.getAllElements(), + this.state, + ); if (selectedElements.length === 1 && resizeElement) { this.setState({ resizingElement: resizeElement ? resizeElement.element : null, @@ -1326,7 +1339,7 @@ export class App extends React.Component { isResizingElements = true; } else { hitElement = getElementAtPosition( - elements, + scene.getAllElements(), this.state, x, y, @@ -1353,7 +1366,7 @@ export class App extends React.Component { [hitElement!.id]: true, }, })); - elements = elements.slice(); + this.replaceElements(scene.getAllElements()); elementIsAddedToSelection = true; } @@ -1363,7 +1376,7 @@ export class App extends React.Component { // put the duplicates where the selected elements used to be. const nextElements = []; const elementsToAppend = []; - for (const element of elements) { + for (const element of scene.getAllElements()) { if (this.state.selectedElementIds[element.id]) { nextElements.push(duplicateElement(element)); elementsToAppend.push(element); @@ -1371,7 +1384,7 @@ export class App extends React.Component { nextElements.push(element); } } - elements = [...nextElements, ...elementsToAppend]; + this.replaceElements([...nextElements, ...elementsToAppend]); } } } @@ -1421,12 +1434,12 @@ export class App extends React.Component { zoom: this.state.zoom, onSubmit: text => { if (text) { - elements = [ - ...elements, + this.replaceElements([ + ...scene.getAllElements(), { ...newTextElement(element, text, this.state.currentItemFont), }, - ]; + ]); } this.setState(prevState => ({ selectedElementIds: { @@ -1469,9 +1482,9 @@ export class App extends React.Component { [multiElement.id]: true, }, })); - multiElement.points.push([x - rx, y - ry]); - mutateElement(multiElement); - invalidateShapeForElement(multiElement); + mutateElement(multiElement, { + points: [...multiElement.points, [x - rx, y - ry]], + }); } else { this.setState(prevState => ({ selectedElementIds: { @@ -1479,10 +1492,10 @@ export class App extends React.Component { [element.id]: false, }, })); - element.points.push([0, 0]); - mutateElement(element); - invalidateShapeForElement(element); - elements = [...elements, element]; + mutateElement(element, { + points: [...element.points, [0, 0]], + }); + this.replaceElements([...scene.getAllElements(), element]); this.setState({ draggingElement: element, editingElement: element, @@ -1494,7 +1507,7 @@ export class App extends React.Component { draggingElement: element, }); } else { - elements = [...elements, element]; + this.replaceElements([...scene.getAllElements(), element]); this.setState({ multiElement: null, draggingElement: element, @@ -1505,7 +1518,7 @@ export class App extends React.Component { let resizeArrowFn: | (( element: ExcalidrawElement, - p1: Point, + pointIndex: number, deltaX: number, deltaY: number, pointerX: number, @@ -1516,13 +1529,14 @@ export class App extends React.Component { const arrowResizeOrigin = ( element: ExcalidrawElement, - p1: Point, + pointIndex: number, deltaX: number, deltaY: number, pointerX: number, pointerY: number, perfect: boolean, ) => { + const p1 = element.points[pointIndex]; if (perfect) { const absPx = p1[0] + element.x; const absPy = p1[1] + element.y; @@ -1535,44 +1549,52 @@ export class App extends React.Component { const dx = element.x + width + p1[0]; const dy = element.y + height + p1[1]; - p1[0] = absPx - element.x; - p1[1] = absPy - element.y; mutateElement(element, { x: dx, y: dy, + points: element.points.map((point, i) => + i === pointIndex ? [absPx - element.x, absPy - element.y] : point, + ), }); } else { - p1[0] -= deltaX; - p1[1] -= deltaY; mutateElement(element, { x: element.x + deltaX, y: element.y + deltaY, + points: element.points.map((point, i) => + i === pointIndex ? [p1[0] - deltaX, p1[1] - deltaY] : point, + ), }); } }; const arrowResizeEnd = ( element: ExcalidrawElement, - p1: Point, + pointIndex: number, deltaX: number, deltaY: number, pointerX: number, pointerY: number, perfect: boolean, ) => { + const p1 = element.points[pointIndex]; if (perfect) { const { width, height } = getPerfectElementSize( element.type, pointerX - element.x, pointerY - element.y, ); - p1[0] = width; - p1[1] = height; + mutateElement(element, { + points: element.points.map((point, i) => + i === pointIndex ? [width, height] : point, + ), + }); } else { - p1[0] += deltaX; - p1[1] += deltaY; + mutateElement(element, { + points: element.points.map((point, i) => + i === pointIndex ? [p1[0] + deltaX, p1[1] + deltaY] : point, + ), + }); } - mutateElement(element); }; const onPointerMove = (event: PointerEvent) => { @@ -1623,7 +1645,10 @@ export class App extends React.Component { if (isResizingElements && this.state.resizingElement) { this.setState({ isResizing: true }); const el = this.state.resizingElement; - const selectedElements = getSelectedElements(elements, this.state); + const selectedElements = getSelectedElements( + scene.getAllElements(), + this.state, + ); if (selectedElements.length === 1) { const { x, y } = viewportCoordsToSceneCoords( event, @@ -1646,15 +1671,7 @@ export class App extends React.Component { resizeArrowFn = arrowResizeOrigin; } } - resizeArrowFn( - element, - p1, - deltaX, - deltaY, - x, - y, - event.shiftKey, - ); + resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); } else { mutateElement(element, { x: element.x + deltaX, @@ -1678,15 +1695,7 @@ export class App extends React.Component { resizeArrowFn = arrowResizeOrigin; } } - resizeArrowFn( - element, - p1, - deltaX, - deltaY, - x, - y, - event.shiftKey, - ); + resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); } else { const nextWidth = element.width + deltaX; mutateElement(element, { @@ -1708,15 +1717,7 @@ export class App extends React.Component { resizeArrowFn = arrowResizeOrigin; } } - resizeArrowFn( - element, - p1, - deltaX, - deltaY, - x, - y, - event.shiftKey, - ); + resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); } else { mutateElement(element, { x: element.x + deltaX, @@ -1737,15 +1738,7 @@ export class App extends React.Component { resizeArrowFn = arrowResizeOrigin; } } - resizeArrowFn( - element, - p1, - deltaX, - deltaY, - x, - y, - event.shiftKey, - ); + resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); } else { mutateElement(element, { width: element.width + deltaX, @@ -1756,9 +1749,13 @@ export class App extends React.Component { } break; case "n": { + let points; if (element.points.length > 0) { const len = element.points.length; - const points = [...element.points].sort((a, b) => a[1] - b[1]); + points = [...element.points].sort((a, b) => a[1] - b[1]) as [ + number, + number, + ][]; for (let i = 1; i < points.length; ++i) { const pnt = points[i]; @@ -1769,13 +1766,18 @@ export class App extends React.Component { mutateElement(element, { height: element.height - deltaY, y: element.y + deltaY, + points, }); break; } case "w": { + let points; if (element.points.length > 0) { const len = element.points.length; - const points = [...element.points].sort((a, b) => a[0] - b[0]); + points = [...element.points].sort((a, b) => a[0] - b[0]) as [ + number, + number, + ][]; for (let i = 0; i < points.length; ++i) { const pnt = points[i]; @@ -1786,13 +1788,19 @@ export class App extends React.Component { mutateElement(element, { width: element.width - deltaX, x: element.x + deltaX, + points, }); break; } case "s": { + let points; + if (element.points.length > 0) { const len = element.points.length; - const points = [...element.points].sort((a, b) => a[1] - b[1]); + points = [...element.points].sort((a, b) => a[1] - b[1]) as [ + number, + number, + ][]; for (let i = 1; i < points.length; ++i) { const pnt = points[i]; @@ -1802,14 +1810,18 @@ export class App extends React.Component { mutateElement(element, { height: element.height + deltaY, - points: element.points, // no-op, but signifies that we mutated points in-place above + points, }); break; } case "e": { + let points; if (element.points.length > 0) { const len = element.points.length; - const points = [...element.points].sort((a, b) => a[0] - b[0]); + points = [...element.points].sort((a, b) => a[0] - b[0]) as [ + number, + number, + ][]; for (let i = 1; i < points.length; ++i) { const pnt = points[i]; @@ -1819,7 +1831,7 @@ export class App extends React.Component { mutateElement(element, { width: element.width + deltaX, - points: element.points, // no-op, but signifies that we mutated points in-place above + points, }); break; } @@ -1838,7 +1850,6 @@ export class App extends React.Component { x: element.x, y: element.y, }); - invalidateShapeForElement(el); lastX = x; lastY = y; @@ -1851,7 +1862,10 @@ export class App extends React.Component { // Marking that click was used for dragging to check // if elements should be deselected on pointerup draggingOccurred = true; - const selectedElements = getSelectedElements(elements, this.state); + const selectedElements = getSelectedElements( + scene.getAllElements(), + this.state, + ); if (selectedElements.length > 0) { const { x, y } = viewportCoordsToSceneCoords( event, @@ -1906,14 +1920,12 @@ export class App extends React.Component { } if (points.length === 1) { - points.push([dx, dy]); + mutateElement(draggingElement, { points: [...points, [dx, dy]] }); } else if (points.length > 1) { - const pnt = points[points.length - 1]; - pnt[0] = dx; - pnt[1] = dy; + mutateElement(draggingElement, { + points: [...points.slice(0, -1), [dx, dy]], + }); } - - mutateElement(draggingElement, { points }); } else { if (event.shiftKey) { ({ width, height } = getPerfectElementSize( @@ -1935,14 +1947,15 @@ export class App extends React.Component { }); } - invalidateShapeForElement(draggingElement); - if (this.state.elementType === "selection") { - if (!event.shiftKey && isSomeElementSelected(elements, this.state)) { + if ( + !event.shiftKey && + isSomeElementSelected(scene.getAllElements(), this.state) + ) { this.setState({ selectedElementIds: {} }); } const elementsWithinSelection = getElementsWithinSelection( - elements, + scene.getAllElements(), draggingElement, ); this.setState(prevState => ({ @@ -1990,12 +2003,12 @@ export class App extends React.Component { this.state, this.canvas, ); - draggingElement.points.push([ - x - draggingElement.x, - y - draggingElement.y, - ]); - mutateElement(draggingElement); - invalidateShapeForElement(draggingElement); + mutateElement(draggingElement, { + points: [ + ...draggingElement.points, + [x - draggingElement.x, y - draggingElement.y], + ], + }); this.setState({ multiElement: this.state.draggingElement, editingElement: this.state.draggingElement, @@ -2030,7 +2043,7 @@ export class App extends React.Component { isInvisiblySmallElement(draggingElement) ) { // remove invisible element which was added in onPointerDown - elements = elements.slice(0, -1); + this.replaceElements(scene.getAllElements().slice(0, -1)); this.setState({ draggingElement: null, }); @@ -2047,7 +2060,9 @@ export class App extends React.Component { } if (resizingElement && isInvisiblySmallElement(resizingElement)) { - elements = elements.filter(el => el.id !== resizingElement.id); + this.replaceElements( + scene.getAllElements().filter(el => el.id !== resizingElement.id), + ); } // If click occurred on already selected element @@ -2090,7 +2105,7 @@ export class App extends React.Component { if ( elementType !== "selection" || - isSomeElementSelected(elements, this.state) + isSomeElementSelected(scene.getAllElements(), this.state) ) { history.resumeRecording(); } @@ -2163,7 +2178,7 @@ export class App extends React.Component { return duplicate; }); - elements = [...elements, ...newElements]; + this.replaceElements([...scene.getAllElements(), ...newElements]); history.resumeRecording(); this.setState({ selectedElementIds: newElements.reduce((map, element) => { @@ -2174,7 +2189,11 @@ export class App extends React.Component { }; private getTextWysiwygSnappedToCenterPosition(x: number, y: number) { - const elementClickedInside = getElementContainingPosition(elements, x, y); + const elementClickedInside = getElementContainingPosition( + scene.getAllElements(), + x, + y, + ); if (elementClickedInside) { const elementCenterX = elementClickedInside.x + elementClickedInside.width / 2; @@ -2209,7 +2228,7 @@ export class App extends React.Component { }; private saveDebounced = debounce(() => { - saveToLocalStorage(elements, this.state); + saveToLocalStorage(scene.getAllElements(), this.state); }, 300); componentDidUpdate() { @@ -2233,7 +2252,7 @@ export class App extends React.Component { ); }); const { atLeastOneVisibleElement, scrollBars } = renderScene( - elements, + scene.getAllElements(), this.state, this.state.selectionElement, window.devicePixelRatio, @@ -2254,20 +2273,22 @@ export class App extends React.Component { currentScrollBars = scrollBars; } const scrolledOutside = - !atLeastOneVisibleElement && hasNonDeletedElements(elements); + !atLeastOneVisibleElement && + hasNonDeletedElements(scene.getAllElements()); if (this.state.scrolledOutside !== scrolledOutside) { this.setState({ scrolledOutside: scrolledOutside }); } this.saveDebounced(); if ( - getDrawingVersion(elements) > this.lastBroadcastedOrReceivedSceneVersion + getDrawingVersion(scene.getAllElements()) > + this.lastBroadcastedOrReceivedSceneVersion ) { this.broadcastSceneUpdate(); } if (history.isRecording()) { - history.pushEntry(this.state, elements); + history.pushEntry(this.state, scene.getAllElements()); history.skipRecording(); } } diff --git a/src/data/json.ts b/src/data/json.ts index 34517298..612492d5 100644 --- a/src/data/json.ts +++ b/src/data/json.ts @@ -14,7 +14,7 @@ export function serializeAsJSON( type: "excalidraw", version: 1, source: window.location.origin, - elements, + elements: elements.filter(element => !element.isDeleted), appState: cleanAppStateForExport(appState), }, null, diff --git a/src/data/localStorage.ts b/src/data/localStorage.ts index f8101e3d..f6469116 100644 --- a/src/data/localStorage.ts +++ b/src/data/localStorage.ts @@ -10,7 +10,10 @@ export function saveToLocalStorage( elements: readonly ExcalidrawElement[], appState: AppState, ) { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements)); + localStorage.setItem( + LOCAL_STORAGE_KEY, + JSON.stringify(elements.filter(element => !element.isDeleted)), + ); localStorage.setItem( LOCAL_STORAGE_KEY_STATE, JSON.stringify(clearAppStateForLocalStorage(appState)), diff --git a/src/data/restore.ts b/src/data/restore.ts index 1abd61ef..6d339b52 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -1,4 +1,4 @@ -import { Point } from "roughjs/bin/geometry"; +import { Point } from "../types"; import { ExcalidrawElement } from "../element/types"; import { AppState } from "../types"; diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 181e48f5..5850d851 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -1,7 +1,7 @@ import { ExcalidrawElement } from "./types"; import { rotate } from "../math"; import { Drawable } from "roughjs/bin/core"; -import { Point } from "roughjs/bin/geometry"; +import { Point } from "../types"; import { getShapeForElement } from "../renderer/renderElement"; // If the element is created from right to left, the width is going to be negative @@ -68,7 +68,7 @@ export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) { // move, bcurveTo, lineTo, and curveTo if (op === "move") { // change starting point - currentP = data as Point; + currentP = (data as unknown) as Point; // move operation does not draw anything; so, it always // returns false } else if (op === "bcurveTo") { @@ -133,7 +133,7 @@ export function getArrowPoints(element: ExcalidrawElement, shape: Drawable[]) { const prevOp = ops[ops.length - 2]; let p0: Point = [0, 0]; if (prevOp.op === "move") { - p0 = prevOp.data as Point; + p0 = (prevOp.data as unknown) as Point; } else if (prevOp.op === "bcurveTo") { p0 = [prevOp.data[4], prevOp.data[5]]; } diff --git a/src/element/collision.ts b/src/element/collision.ts index 9c3fcc39..e5b4e3ec 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -7,7 +7,7 @@ import { getElementAbsoluteCoords, getLinearElementAbsoluteBounds, } from "./bounds"; -import { Point } from "roughjs/bin/geometry"; +import { Point } from "../types"; import { Drawable, OpSet } from "roughjs/bin/core"; import { AppState } from "../types"; import { getShapeForElement } from "../renderer/renderElement"; @@ -231,7 +231,7 @@ const hitTestRoughShape = (opSet: OpSet[], x: number, y: number) => { // move, bcurveTo, lineTo, and curveTo if (op === "move") { // change starting point - currentP = data as Point; + currentP = (data as unknown) as Point; // move operation does not draw anything; so, it always // returns false } else if (op === "bcurveTo") { diff --git a/src/element/mutateElement.ts b/src/element/mutateElement.ts index 9410039f..b1c96aeb 100644 --- a/src/element/mutateElement.ts +++ b/src/element/mutateElement.ts @@ -1,5 +1,6 @@ -import { ExcalidrawElement, ExcalidrawTextElement } from "./types"; +import { ExcalidrawElement } from "./types"; import { randomSeed } from "roughjs/bin/math"; +import { invalidateShapeForElement } from "../renderer/renderElement"; type ElementUpdate = Omit< Partial, @@ -9,45 +10,35 @@ type ElementUpdate = Omit< // This function tracks updates of text elements for the purposes for collaboration. // The version is used to compare updates when more than one user is working in // the same drawing. -export function mutateElement( - element: ExcalidrawElement, - updates?: ElementUpdate, +export function mutateElement( + element: TElement, + updates: ElementUpdate, ) { - if (updates) { - Object.assign(element, updates); + const mutableElement = element as any; + + for (const key in updates) { + const value = (updates as any)[key]; + if (typeof value !== "undefined") { + mutableElement[key] = value; + } } - (element as any).version++; - (element as any).versionNonce = randomSeed(); + + if ( + typeof updates.height !== "undefined" || + typeof updates.width !== "undefined" || + typeof updates.points !== "undefined" + ) { + invalidateShapeForElement(element); + } + + mutableElement.version++; + mutableElement.versionNonce = randomSeed(); } -export function newElementWith( - element: ExcalidrawElement, - updates: ElementUpdate, -): ExcalidrawElement { - return { - ...element, - ...updates, - version: element.version + 1, - versionNonce: randomSeed(), - }; -} - -// This function tracks updates of text elements for the purposes for collaboration. -// The version is used to compare updates when more than one user is working in -// the same document. -export function mutateTextElement( - element: ExcalidrawTextElement, - updates: ElementUpdate, -): void { - Object.assign(element, updates); - (element as any).version++; - (element as any).versionNonce = randomSeed(); -} - -export function newTextElementWith( - element: ExcalidrawTextElement, - updates: ElementUpdate, -): ExcalidrawTextElement { +export function newElementWith( + element: TElement, + updates: ElementUpdate, +): TElement { return { ...element, ...updates, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 2b27571b..960ce767 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -1,6 +1,6 @@ import { randomSeed } from "roughjs/bin/math"; import nanoid from "nanoid"; -import { Point } from "roughjs/bin/geometry"; +import { Point } from "../types"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { measureText } from "../utils"; @@ -32,7 +32,7 @@ export function newElement( roughness, opacity, seed: randomSeed(), - points: [] as Point[], + points: [] as readonly Point[], version: 1, versionNonce: 0, isDeleted: false, diff --git a/src/element/sizeHelpers.ts b/src/element/sizeHelpers.ts index 1a29f2a1..681a67d9 100644 --- a/src/element/sizeHelpers.ts +++ b/src/element/sizeHelpers.ts @@ -1,5 +1,4 @@ import { ExcalidrawElement } from "./types"; -import { invalidateShapeForElement } from "../renderer/renderElement"; import { mutateElement } from "./mutateElement"; export function isInvisiblySmallElement(element: ExcalidrawElement): boolean { @@ -101,7 +100,5 @@ export function normalizeDimensions( }); } - invalidateShapeForElement(element); - return true; } diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 15132cd4..394faee6 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -1,10 +1,10 @@ import { measureText } from "../utils"; import { ExcalidrawTextElement } from "./types"; -import { mutateTextElement } from "./mutateElement"; +import { mutateElement } from "./mutateElement"; export const redrawTextBoundingBox = (element: ExcalidrawTextElement) => { const metrics = measureText(element.text, element.font); - mutateTextElement(element, { + mutateElement(element, { width: metrics.width, height: metrics.height, baseline: metrics.baseline, diff --git a/src/math.ts b/src/math.ts index fae4d950..87472ee1 100644 --- a/src/math.ts +++ b/src/math.ts @@ -1,4 +1,4 @@ -import { Point } from "roughjs/bin/geometry"; +import { Point } from "./types"; // https://stackoverflow.com/a/6853926/232122 export function distanceBetweenPointAndSegment( diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 53ff104f..2b771242 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -7,7 +7,6 @@ import { } from "../element/bounds"; import { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; -import { Point } from "roughjs/bin/geometry"; import { RoughSVG } from "roughjs/bin/svg"; import { RoughGenerator } from "roughjs/bin/generator"; import { SceneState } from "../scene/types"; @@ -214,13 +213,11 @@ function generateElement( }; // points array can be empty in the beginning, so it is important to add // initial position to it - const points: Point[] = element.points.length - ? element.points - : [[0, 0]]; + const points = element.points.length ? element.points : [[0, 0]]; // curve is always the first element // this simplifies finding the curve for an element - shape = [generator.curve(points, options)]; + shape = [generator.curve(points as [number, number][], options)]; // add lines only in arrow if (element.type === "arrow") { diff --git a/src/scene/createScene.ts b/src/scene/createScene.ts index 00476029..bad6d3cd 100644 --- a/src/scene/createScene.ts +++ b/src/scene/createScene.ts @@ -1,6 +1,17 @@ import { ExcalidrawElement } from "../element/types"; +class SceneState { + constructor(private _elements: readonly ExcalidrawElement[] = []) {} + + getAllElements() { + return this._elements; + } + + replaceAllElements(nextElements: readonly ExcalidrawElement[]) { + this._elements = nextElements; + } +} + export const createScene = () => { - const elements: readonly ExcalidrawElement[] = []; - return { elements }; + return new SceneState(); }; diff --git a/src/types.ts b/src/types.ts index 6e96b833..1cdc1e1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,9 @@ import { ExcalidrawElement, PointerType } from "./element/types"; import { SHAPES } from "./shapes"; +import { Point as RoughPoint } from "roughjs/bin/geometry"; export type FlooredNumber = number & { _brand: "FlooredNumber" }; +export type Point = Readonly; export type AppState = { draggingElement: ExcalidrawElement | null;