From ccbbdb75a6fdf057d913a97caa9692923761c8ca Mon Sep 17 00:00:00 2001 From: Pete Hunt Date: Sun, 8 Mar 2020 10:20:55 -0700 Subject: [PATCH] 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 --- package.json | 3 + src/actions/actionDeleteSelected.tsx | 9 +- src/actions/actionFinalize.tsx | 9 +- src/actions/actionProperties.tsx | 50 +++-- src/actions/actionSelectAll.ts | 9 +- src/actions/actionStyles.ts | 9 +- src/actions/actionZindex.tsx | 20 +- src/appState.ts | 1 + src/clipboard.ts | 6 +- src/components/Actions.tsx | 9 +- src/components/App.tsx | 236 ++++++++++++++++-------- src/components/ExportDialog.tsx | 7 +- src/components/HintViewer.tsx | 22 +-- src/components/LayerUI.tsx | 23 +-- src/components/MobileMenu.tsx | 12 +- src/data/index.ts | 3 +- src/data/json.ts | 2 +- src/data/localStorage.ts | 13 +- src/data/restore.ts | 4 - src/element/bounds.ts | 8 +- src/element/collision.ts | 23 ++- src/element/newElement.test.ts | 2 - src/element/newElement.ts | 8 - src/element/resizeTest.ts | 7 +- src/element/showSelectedShapeActions.ts | 2 +- src/element/sizeHelpers.ts | 3 +- src/element/types.ts | 5 + src/history.ts | 4 +- src/index-node.ts | 5 +- src/renderer/renderElement.ts | 131 ++++++++----- src/renderer/renderScene.ts | 5 +- src/scene/comparisons.ts | 4 +- src/scene/export.ts | 3 + src/scene/index.ts | 1 - src/scene/selection.ts | 44 ++--- src/tests/move.test.tsx | 5 +- src/tests/resize.test.tsx | 4 +- src/tests/selection.test.tsx | 10 +- src/types.ts | 1 + 39 files changed, 416 insertions(+), 306 deletions(-) diff --git a/package.json b/package.json index d0e8190f..ce5274e1 100644 --- a/package.json +++ b/package.json @@ -102,5 +102,8 @@ "repository": { "type": "git", "url": "https://github.com/excalidraw/excalidraw.git" + }, + "engines": { + "node": ">=12.0.0" } } diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 115b30af..f77b2833 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/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 }) => ( updateData(null)} - visible={isSomeElementSelected(elements)} + visible={isSomeElementSelected(elements, appState)} /> ), }); diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index fe298212..526bc4e4 100644 --- a/src/actions/actionFinalize.tsx +++ b/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: {}, }, }; }, diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 8cd767a3..cc933bad 100644 --- a/src/actions/actionProperties.tsx +++ b/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( - 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], )} diff --git a/src/actions/actionSelectAll.ts b/src/actions/actionSelectAll.ts index fd0c0b39..aef19dac 100644 --- a/src/actions/actionSelectAll.ts +++ b/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", diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index 411163bc..f3b2dfed 100644 --- a/src/actions/actionStyles.ts +++ b/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, diff --git a/src/actions/actionZindex.tsx b/src/actions/actionZindex.tsx index f5b743dc..50aa9519 100644 --- a/src/actions/actionZindex.tsx +++ b/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, }; }, diff --git a/src/appState.ts b/src/appState.ts index 3ed68f55..fd88f99a 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -32,6 +32,7 @@ export function getDefaultAppState(): AppState { zoom: 1, openMenu: null, lastPointerDownWith: "mouse", + selectedElementIds: {}, }; } diff --git a/src/clipboard.ts b/src/clipboard.ts index 48d74378..3e109db1 100644 --- a/src/clipboard.ts +++ b/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 diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 79f98cf4..7fc41a75 100644 --- a/src/components/Actions.tsx +++ b/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({}); diff --git a/src/components/App.tsx b/src/components/App.tsx index e324dcc1..155b06e7 100644 --- a/src/components/App.tsx +++ b/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 { 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 { if (isWritableElement(event.target)) { return; } - copyToAppClipboard(elements); + copyToAppClipboard(elements, this.state); event.preventDefault(); }; @@ -296,7 +296,7 @@ export class App extends React.Component { 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 { ? 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 { 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 { 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 { 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 { const element = getElementAtPosition( elements, + this.state, x, y, this.state.zoom, @@ -545,10 +545,8 @@ export class App extends React.Component { 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 { 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 { } 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 { // 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 { 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 { 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 { 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 { ); 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 { } } - 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 { 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 { 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 { !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 { const elementAtPosition = getElementAtPosition( elements, + this.state, x, y, this.state.zoom, @@ -1616,10 +1684,15 @@ export class App extends React.Component { // 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 { 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 { 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 { } const hitElement = getElementAtPosition( elements, + this.state, x, y, this.state.zoom, @@ -1782,8 +1860,6 @@ export class App extends React.Component { 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 { 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 { componentDidUpdate() { const { atLeastOneVisibleElement, scrollBars } = renderScene( elements, + this.state, this.state.selectionElement, this.rc!, this.canvas!, diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index c34a7f76..5d2ce555 100644 --- a/src/components/ExportDialog.tsx +++ b/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(null); @@ -58,7 +58,7 @@ function ExportModal({ const onlySelectedInput = useRef(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, diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index 0111be10..6a46e34f 100644 --- a/src/components/HintViewer.tsx +++ b/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) { diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 7241af7a..4164528d 100644 --- a/src/components/LayerUI.tsx +++ b/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( ) : ( <> - +
@@ -123,10 +119,7 @@ export const LayerUI = React.memo( > diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 409d49b0..e2dc138e 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -58,10 +58,7 @@ export function MobileMenu({
@@ -88,12 +85,7 @@ export function MobileMenu({ )}
- +