diff --git a/package.json b/package.json index e77368ab..79b9777c 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "start": "react-scripts start", "test": "npm run test:app", + "test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false", "test:update": "npm run test:app -- --updateSnapshot --watchAll=false", "test:app": "react-scripts test --env=jsdom --passWithNoTests", "test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .", diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 0b67d8c9..0cf282d6 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/src/actions/actionDeleteSelected.tsx @@ -5,6 +5,7 @@ import React from "react"; import { trash } from "../components/icons"; import { t } from "../i18n"; import { register } from "./register"; +import { getNonDeletedElements } from "../element"; export const actionDeleteSelected = register({ name: "deleteSelectedElements", @@ -20,7 +21,10 @@ export const actionDeleteSelected = register({ elementType: "selection", multiElement: null, }, - commitToHistory: isSomeElementSelected(elements, appState), + commitToHistory: isSomeElementSelected( + getNonDeletedElements(elements), + appState, + ), }; }, contextItemLabel: "labels.delete", @@ -33,7 +37,7 @@ export const actionDeleteSelected = register({ title={t("labels.delete")} aria-label={t("labels.delete")} onClick={() => updateData(null)} - visible={isSomeElementSelected(elements, appState)} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} /> ), }); diff --git a/src/actions/actionDuplicateSelection.tsx b/src/actions/actionDuplicateSelection.tsx index 5ab54e5b..2723214d 100644 --- a/src/actions/actionDuplicateSelection.tsx +++ b/src/actions/actionDuplicateSelection.tsx @@ -2,7 +2,7 @@ import React from "react"; import { KEYS } from "../keys"; import { register } from "./register"; import { ExcalidrawElement } from "../element/types"; -import { duplicateElement } from "../element"; +import { duplicateElement, getNonDeletedElements } from "../element"; import { isSomeElementSelected } from "../scene"; import { ToolButton } from "../components/ToolButton"; import { clone } from "../components/icons"; @@ -43,7 +43,7 @@ export const actionDuplicateSelection = register({ )}`} aria-label={t("labels.duplicateSelection")} onClick={() => updateData(null)} - visible={isSomeElementSelected(elements, appState)} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} /> ), }); diff --git a/src/actions/actionMenu.tsx b/src/actions/actionMenu.tsx index a46f2205..a87b62fc 100644 --- a/src/actions/actionMenu.tsx +++ b/src/actions/actionMenu.tsx @@ -2,7 +2,7 @@ import React from "react"; import { menu, palette } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; -import { showSelectedShapeActions } from "../element"; +import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { register } from "./register"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { KEYS } from "../keys"; @@ -39,7 +39,10 @@ export const actionToggleEditMenu = register({ }), PanelComponent: ({ elements, appState, updateData }) => ( ( defaultValue?: T, ): T | null { const editingElement = appState.editingElement; + const nonDeletedElements = getNonDeletedElements(elements); return ( (editingElement && getAttribute(editingElement)) ?? - (isSomeElementSelected(elements, appState) - ? getCommonAttributeOfSelectedElements(elements, appState, getAttribute) + (isSomeElementSelected(nonDeletedElements, appState) + ? getCommonAttributeOfSelectedElements( + nonDeletedElements, + appState, + getAttribute, + ) : defaultValue) ?? null ); diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 2e4e866e..86aed55a 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -9,6 +9,7 @@ import { import { ExcalidrawElement } from "../element/types"; import { AppState } from "../types"; import { t } from "../i18n"; +import { globalSceneState } from "../scene"; export class ActionManager implements ActionsManagerInterface { actions = {} as ActionsManagerInterface["actions"]; @@ -17,16 +18,18 @@ export class ActionManager implements ActionsManagerInterface { getAppState: () => AppState; - getElements: () => readonly ExcalidrawElement[]; + getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; constructor( updater: UpdaterFn, getAppState: () => AppState, - getElements: () => readonly ExcalidrawElement[], + getElementsIncludingDeleted: () => ReturnType< + typeof globalSceneState["getElementsIncludingDeleted"] + >, ) { this.updater = updater; this.getAppState = getAppState; - this.getElements = getElements; + this.getElementsIncludingDeleted = getElementsIncludingDeleted; } registerAction(action: Action) { @@ -43,7 +46,11 @@ export class ActionManager implements ActionsManagerInterface { .filter( (action) => action.keyTest && - action.keyTest(event, this.getAppState(), this.getElements()), + action.keyTest( + event, + this.getAppState(), + this.getElementsIncludingDeleted(), + ), ); if (data.length === 0) { @@ -51,12 +58,24 @@ export class ActionManager implements ActionsManagerInterface { } event.preventDefault(); - this.updater(data[0].perform(this.getElements(), this.getAppState(), null)); + this.updater( + data[0].perform( + this.getElementsIncludingDeleted(), + this.getAppState(), + null, + ), + ); return true; } executeAction(action: Action) { - this.updater(action.perform(this.getElements(), this.getAppState(), null)); + this.updater( + action.perform( + this.getElementsIncludingDeleted(), + this.getAppState(), + null, + ), + ); } getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) { @@ -72,7 +91,11 @@ export class ActionManager implements ActionsManagerInterface { label: action.contextItemLabel ? t(action.contextItemLabel) : "", action: () => { this.updater( - action.perform(this.getElements(), this.getAppState(), null), + action.perform( + this.getElementsIncludingDeleted(), + this.getAppState(), + null, + ), ); }, })); @@ -84,13 +107,17 @@ export class ActionManager implements ActionsManagerInterface { const PanelComponent = action.PanelComponent!; const updateData = (formState?: any) => { this.updater( - action.perform(this.getElements(), this.getAppState(), formState), + action.perform( + this.getElementsIncludingDeleted(), + this.getAppState(), + formState, + ), ); }; return ( diff --git a/src/clipboard.ts b/src/clipboard.ts index 18fad0c6..959f861f 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -1,4 +1,7 @@ -import { ExcalidrawElement } from "./element/types"; +import { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "./element/types"; import { getSelectedElements } from "./scene"; import { AppState } from "./types"; import { SVG_EXPORT_TAG } from "./scene/export"; @@ -19,7 +22,7 @@ export const probablySupportsClipboardBlob = "toBlob" in HTMLCanvasElement.prototype; export async function copyToAppClipboard( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, ) { CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState)); diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 44f74982..40f87bda 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -9,6 +9,7 @@ import { ToolButton } from "./ToolButton"; import { capitalizeString, setCursorForShape } from "../utils"; import Stack from "./Stack"; import useIsMobile from "../is-mobile"; +import { getNonDeletedElements } from "../element"; export function SelectedShapeActions({ appState, @@ -21,7 +22,10 @@ export function SelectedShapeActions({ renderAction: ActionManager["renderAction"]; elementType: ExcalidrawElement["type"]; }) { - const targetElements = getTargetElement(elements, appState); + const targetElements = getTargetElement( + getNonDeletedElements(elements), + appState, + ); const isEditing = Boolean(appState.editingElement); const isMobile = useIsMobile(); @@ -82,13 +86,9 @@ export function SelectedShapeActions({ export function ShapesSwitcher({ elementType, setAppState, - setElements, - elements, }: { elementType: ExcalidrawElement["type"]; setAppState: any; - setElements: any; - elements: readonly ExcalidrawElement[]; }) { return ( <> diff --git a/src/components/App.tsx b/src/components/App.tsx index 6ff12967..f9ac523e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -20,7 +20,6 @@ import { getElementMap, getDrawingVersion, getSyncableElements, - hasNonDeletedElements, newLinearElement, ResizeArrowFnType, resizeElements, @@ -185,7 +184,7 @@ export class App extends React.Component { this.actionManager = new ActionManager( this.syncActionResult, () => this.state, - () => globalSceneState.getAllElements(), + () => globalSceneState.getElementsIncludingDeleted(), ); this.actionManager.registerAll(actions); @@ -209,10 +208,7 @@ export class App extends React.Component { appState={this.state} setAppState={this.setAppState} actionManager={this.actionManager} - elements={globalSceneState.getAllElements().filter((element) => { - return !element.isDeleted; - })} - setElements={this.setElements} + elements={globalSceneState.getElements()} onRoomCreate={this.openPortal} onRoomDestroy={this.closePortal} onLockToggle={this.toggleLock} @@ -310,7 +306,7 @@ export class App extends React.Component { try { await Promise.race([ document.fonts?.ready?.then(() => { - globalSceneState.getAllElements().forEach((element) => { + globalSceneState.getElementsIncludingDeleted().forEach((element) => { if (isTextElement(element)) { invalidateShapeForElement(element); } @@ -431,7 +427,7 @@ export class App extends React.Component { } private onResize = withBatchedUpdates(() => { globalSceneState - .getAllElements() + .getElementsIncludingDeleted() .forEach((element) => invalidateShapeForElement(element)); this.setState({}); }); @@ -439,7 +435,7 @@ export class App extends React.Component { private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => { if ( this.state.isCollaborating && - hasNonDeletedElements(globalSceneState.getAllElements()) + globalSceneState.getElements().length > 0 ) { event.preventDefault(); // NOTE: modern browsers no longer allow showing a custom message here @@ -484,8 +480,9 @@ export class App extends React.Component { ); cursorButton[socketID] = user.button; }); + const elements = globalSceneState.getElements(); const { atLeastOneVisibleElement, scrollBars } = renderScene( - globalSceneState.getAllElements().filter((element) => { + elements.filter((element) => { // don't render text element that's being currently edited (it's // rendered on remote only) return ( @@ -517,22 +514,20 @@ export class App extends React.Component { if (scrollBars) { currentScrollBars = scrollBars; } - const scrolledOutside = - !atLeastOneVisibleElement && - hasNonDeletedElements(globalSceneState.getAllElements()); + const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0; if (this.state.scrolledOutside !== scrolledOutside) { this.setState({ scrolledOutside: scrolledOutside }); } this.saveDebounced(); if ( - getDrawingVersion(globalSceneState.getAllElements()) > + getDrawingVersion(globalSceneState.getElementsIncludingDeleted()) > this.lastBroadcastedOrReceivedSceneVersion ) { this.broadcastScene("SCENE_UPDATE"); } - history.record(this.state, globalSceneState.getAllElements()); + history.record(this.state, globalSceneState.getElementsIncludingDeleted()); } // Copy/paste @@ -543,7 +538,7 @@ export class App extends React.Component { } this.copyAll(); const { elements: nextElements, appState } = deleteSelectedElements( - globalSceneState.getAllElements(), + globalSceneState.getElementsIncludingDeleted(), this.state, ); globalSceneState.replaceAllElements(nextElements); @@ -561,19 +556,16 @@ export class App extends React.Component { }); private copyAll = () => { - copyToAppClipboard(globalSceneState.getAllElements(), this.state); + copyToAppClipboard(globalSceneState.getElements(), this.state); }; private copyToClipboardAsPng = () => { - const selectedElements = getSelectedElements( - globalSceneState.getAllElements(), - this.state, - ); + const elements = globalSceneState.getElements(); + + const selectedElements = getSelectedElements(elements, this.state); exportCanvas( "clipboard", - selectedElements.length - ? selectedElements - : globalSceneState.getAllElements(), + selectedElements.length ? selectedElements : elements, this.state, this.canvas!, this.state, @@ -582,14 +574,14 @@ export class App extends React.Component { private copyToClipboardAsSvg = () => { const selectedElements = getSelectedElements( - globalSceneState.getAllElements(), + globalSceneState.getElements(), this.state, ); exportCanvas( "clipboard-svg", selectedElements.length ? selectedElements - : globalSceneState.getAllElements(), + : globalSceneState.getElements(), this.state, this.canvas!, this.state, @@ -669,7 +661,7 @@ export class App extends React.Component { ); globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements(), + ...globalSceneState.getElementsIncludingDeleted(), ...newElements, ]); history.resumeRecording(); @@ -703,7 +695,7 @@ export class App extends React.Component { }); globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements(), + ...globalSceneState.getElementsIncludingDeleted(), element, ]); this.setState({ selectedElementIds: { [element.id]: true } }); @@ -789,15 +781,15 @@ export class App extends React.Component { // elements with more staler versions than ours, ignore them // and keep ours. if ( - globalSceneState.getAllElements() == null || - globalSceneState.getAllElements().length === 0 + globalSceneState.getElementsIncludingDeleted() == null || + globalSceneState.getElementsIncludingDeleted().length === 0 ) { globalSceneState.replaceAllElements(remoteElements); } else { // create a map of ids so we don't have to iterate // over the array more than once. const localElementMap = getElementMap( - globalSceneState.getAllElements(), + globalSceneState.getElementsIncludingDeleted(), ); // Reconcile @@ -982,12 +974,14 @@ export class App extends React.Component { const data: SocketUpdateDataSource[typeof sceneType] = { type: sceneType, payload: { - elements: getSyncableElements(globalSceneState.getAllElements()), + elements: getSyncableElements( + globalSceneState.getElementsIncludingDeleted(), + ), }, }; this.lastBroadcastedOrReceivedSceneVersion = Math.max( this.lastBroadcastedOrReceivedSceneVersion, - getDrawingVersion(globalSceneState.getAllElements()), + getDrawingVersion(globalSceneState.getElementsIncludingDeleted()), ); return this._broadcastSocketData( data as typeof data & { _brand: "socketUpdateData" }, @@ -1063,7 +1057,7 @@ export class App extends React.Component { ? ELEMENT_SHIFT_TRANSLATE_AMOUNT : ELEMENT_TRANSLATE_AMOUNT; globalSceneState.replaceAllElements( - globalSceneState.getAllElements().map((el) => { + globalSceneState.getElementsIncludingDeleted().map((el) => { if (this.state.selectedElementIds[el.id]) { const update: { x?: number; y?: number } = {}; if (event.key === KEYS.ARROW_LEFT) { @@ -1083,7 +1077,7 @@ export class App extends React.Component { event.preventDefault(); } else if (event.key === KEYS.ENTER) { const selectedElements = getSelectedElements( - globalSceneState.getAllElements(), + globalSceneState.getElements(), this.state, ); @@ -1188,7 +1182,7 @@ export class App extends React.Component { const deleteElement = () => { globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements().map((_element) => { + ...globalSceneState.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id) { return newElementWith(_element, { isDeleted: true }); } @@ -1199,7 +1193,7 @@ export class App extends React.Component { const updateElement = (text: string) => { globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements().map((_element) => { + ...globalSceneState.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id) { return newTextElement({ ...(_element as ExcalidrawTextElement), @@ -1271,7 +1265,7 @@ export class App extends React.Component { centerIfPossible?: boolean; }) => { const elementAtPosition = getElementAtPosition( - globalSceneState.getAllElements(), + globalSceneState.getElements(), this.state, x, y, @@ -1326,7 +1320,7 @@ export class App extends React.Component { }); } else { globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements(), + ...globalSceneState.getElementsIncludingDeleted(), element, ]); @@ -1503,13 +1497,12 @@ export class App extends React.Component { return; } - const selectedElements = getSelectedElements( - globalSceneState.getAllElements(), - this.state, - ); + const elements = globalSceneState.getElements(); + + const selectedElements = getSelectedElements(elements, this.state); if (selectedElements.length === 1 && !isOverScrollBar) { const elementWithResizeHandler = getElementWithResizeHandler( - globalSceneState.getAllElements(), + elements, this.state, { x, y }, this.state.zoom, @@ -1538,7 +1531,7 @@ export class App extends React.Component { } } const hitElement = getElementAtPosition( - globalSceneState.getAllElements(), + elements, this.state, x, y, @@ -1737,13 +1730,11 @@ export class App extends React.Component { let hitElement: ExcalidrawElement | null = null; let hitElementWasAddedToSelection = false; if (this.state.elementType === "selection") { - const selectedElements = getSelectedElements( - globalSceneState.getAllElements(), - this.state, - ); + const elements = globalSceneState.getElements(); + const selectedElements = getSelectedElements(elements, this.state); if (selectedElements.length === 1) { const elementWithResizeHandler = getElementWithResizeHandler( - globalSceneState.getAllElements(), + elements, this.state, { x, y }, this.state.zoom, @@ -1781,7 +1772,7 @@ export class App extends React.Component { } if (!isResizingElements) { hitElement = getElementAtPosition( - globalSceneState.getAllElements(), + elements, this.state, x, y, @@ -1809,7 +1800,7 @@ export class App extends React.Component { }, })); globalSceneState.replaceAllElements( - globalSceneState.getAllElements(), + globalSceneState.getElementsIncludingDeleted(), ); hitElementWasAddedToSelection = true; } @@ -1820,7 +1811,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 globalSceneState.getAllElements()) { + for (const element of globalSceneState.getElementsIncludingDeleted()) { if ( this.state.selectedElementIds[element.id] || (element.id === hitElement.id && hitElementWasAddedToSelection) @@ -1930,7 +1921,7 @@ export class App extends React.Component { points: [...element.points, [0, 0]], }); globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements(), + ...globalSceneState.getElementsIncludingDeleted(), element, ]); this.setState({ @@ -1958,7 +1949,7 @@ export class App extends React.Component { }); } else { globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements(), + ...globalSceneState.getElementsIncludingDeleted(), element, ]); this.setState({ @@ -2047,7 +2038,7 @@ export class App extends React.Component { // if elements should be deselected on pointerup draggingOccurred = true; const selectedElements = getSelectedElements( - globalSceneState.getAllElements(), + globalSceneState.getElements(), this.state, ); if (selectedElements.length > 0) { @@ -2123,14 +2114,12 @@ export class App extends React.Component { } if (this.state.elementType === "selection") { - if ( - !event.shiftKey && - isSomeElementSelected(globalSceneState.getAllElements(), this.state) - ) { + const elements = globalSceneState.getElements(); + if (!event.shiftKey && isSomeElementSelected(elements, this.state)) { this.setState({ selectedElementIds: {} }); } const elementsWithinSelection = getElementsWithinSelection( - globalSceneState.getAllElements(), + elements, draggingElement, ); this.setState((prevState) => ({ @@ -2223,7 +2212,7 @@ export class App extends React.Component { ) { // remove invisible element which was added in onPointerDown globalSceneState.replaceAllElements( - globalSceneState.getAllElements().slice(0, -1), + globalSceneState.getElementsIncludingDeleted().slice(0, -1), ); this.setState({ draggingElement: null, @@ -2240,7 +2229,7 @@ export class App extends React.Component { if (resizingElement && isInvisiblySmallElement(resizingElement)) { globalSceneState.replaceAllElements( globalSceneState - .getAllElements() + .getElementsIncludingDeleted() .filter((el) => el.id !== resizingElement.id), ); } @@ -2285,7 +2274,7 @@ export class App extends React.Component { if ( elementType !== "selection" || - isSomeElementSelected(globalSceneState.getAllElements(), this.state) + isSomeElementSelected(globalSceneState.getElements(), this.state) ) { history.resumeRecording(); } @@ -2366,8 +2355,9 @@ export class App extends React.Component { window.devicePixelRatio, ); + const elements = globalSceneState.getElements(); const element = getElementAtPosition( - globalSceneState.getAllElements(), + elements, this.state, x, y, @@ -2381,12 +2371,12 @@ export class App extends React.Component { action: () => this.pasteFromClipboard(null), }, probablySupportsClipboardBlob && - hasNonDeletedElements(globalSceneState.getAllElements()) && { + elements.length > 0 && { label: t("labels.copyAsPng"), action: this.copyToClipboardAsPng, }, probablySupportsClipboardWriteText && - hasNonDeletedElements(globalSceneState.getAllElements()) && { + elements.length > 0 && { label: t("labels.copyAsSvg"), action: this.copyToClipboardAsSvg, }, @@ -2468,7 +2458,7 @@ export class App extends React.Component { scale: number, ) { const elementClickedInside = getElementContainingPosition( - globalSceneState.getAllElements(), + globalSceneState.getElementsIncludingDeleted(), x, y, ); @@ -2522,7 +2512,10 @@ export class App extends React.Component { }, 300); private saveDebounced = debounce(() => { - saveToLocalStorage(globalSceneState.getAllElements(), this.state); + saveToLocalStorage( + globalSceneState.getElementsIncludingDeleted(), + this.state, + ); }, 300); } @@ -2548,7 +2541,7 @@ if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") { Object.defineProperties(window.h, { elements: { get() { - return globalSceneState.getAllElements(); + return globalSceneState.getElementsIncludingDeleted(); }, set(elements: ExcalidrawElement[]) { return globalSceneState.replaceAllElements(elements); diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 1b100e94..4e6cd9bd 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef } from "react"; import { ToolButton } from "./ToolButton"; import { clipboard, exportFile, link } from "./icons"; -import { ExcalidrawElement } from "../element/types"; +import { NonDeletedExcalidrawElement } from "../element/types"; import { AppState } from "../types"; import { exportToCanvas } from "../scene/export"; import { ActionsManagerInterface } from "../actions/types"; @@ -20,7 +20,7 @@ const scales = [1, 2, 3]; const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1; export type ExportCB = ( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], scale?: number, ) => void; @@ -35,7 +35,7 @@ function ExportModal({ onExportToBackend, }: { appState: AppState; - elements: readonly ExcalidrawElement[]; + elements: readonly NonDeletedExcalidrawElement[]; exportPadding?: number; actionManager: ActionsManagerInterface; onExportToPng: ExportCB; @@ -166,7 +166,7 @@ export function ExportDialog({ onExportToBackend, }: { appState: AppState; - elements: readonly ExcalidrawElement[]; + elements: readonly NonDeletedExcalidrawElement[]; exportPadding?: number; actionManager: ActionsManagerInterface; onExportToPng: ExportCB; diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index efc5ddd5..4e3e398f 100644 --- a/src/components/HintViewer.tsx +++ b/src/components/HintViewer.tsx @@ -1,6 +1,6 @@ import React from "react"; import { t } from "../i18n"; -import { ExcalidrawElement } from "../element/types"; +import { NonDeletedExcalidrawElement } from "../element/types"; import { getSelectedElements } from "../scene"; import "./HintViewer.scss"; @@ -9,7 +9,7 @@ import { isLinearElement } from "../element/typeChecks"; interface Hint { appState: AppState; - elements: readonly ExcalidrawElement[]; + elements: readonly NonDeletedExcalidrawElement[]; } const getHints = ({ appState, elements }: Hint) => { diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index fe746e45..53c00311 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -4,7 +4,7 @@ import { calculateScrollCenter } from "../scene"; import { exportCanvas } from "../data"; import { AppState } from "../types"; -import { ExcalidrawElement } from "../element/types"; +import { NonDeletedExcalidrawElement } from "../element/types"; import { ActionManager } from "../actions/manager"; import { Island } from "./Island"; @@ -31,8 +31,7 @@ interface LayerUIProps { appState: AppState; canvas: HTMLCanvasElement | null; setAppState: any; - elements: readonly ExcalidrawElement[]; - setElements: (elements: readonly ExcalidrawElement[]) => void; + elements: readonly NonDeletedExcalidrawElement[]; onRoomCreate: () => void; onRoomDestroy: () => void; onLockToggle: () => void; @@ -45,7 +44,6 @@ export const LayerUI = React.memo( setAppState, canvas, elements, - setElements, onRoomCreate, onRoomDestroy, onLockToggle, @@ -96,7 +94,6 @@ export const LayerUI = React.memo( diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 148d3ad1..315203ef 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -5,7 +5,7 @@ import { t, setLanguage } from "../i18n"; import Stack from "./Stack"; import { LanguageList } from "./LanguageList"; import { showSelectedShapeActions } from "../element"; -import { ExcalidrawElement } from "../element/types"; +import { NonDeletedExcalidrawElement } from "../element/types"; import { FixedSideContainer } from "./FixedSideContainer"; import { Island } from "./Island"; import { HintViewer } from "./HintViewer"; @@ -22,8 +22,7 @@ type MobileMenuProps = { actionManager: ActionManager; exportButton: React.ReactNode; setAppState: any; - elements: readonly ExcalidrawElement[]; - setElements: any; + elements: readonly NonDeletedExcalidrawElement[]; onRoomCreate: () => void; onRoomDestroy: () => void; onLockToggle: () => void; @@ -32,7 +31,6 @@ type MobileMenuProps = { export function MobileMenu({ appState, elements, - setElements, actionManager, exportButton, setAppState, @@ -54,8 +52,6 @@ export function MobileMenu({ diff --git a/src/data/index.ts b/src/data/index.ts index 1a1ec995..6a2d0cfa 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,4 +1,7 @@ -import { ExcalidrawElement } from "../element/types"; +import { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "../element/types"; import { getDefaultAppState } from "../appState"; @@ -16,7 +19,6 @@ import { serializeAsJSON } from "./json"; import { ExportType } from "../scene/types"; import { restore } from "./restore"; import { restoreFromLocalStorage } from "./localStorage"; -import { hasNonDeletedElements } from "../element"; export { loadFromBlob } from "./blob"; export { saveAsJSON, loadFromJSON } from "./json"; @@ -283,7 +285,7 @@ export async function importFromBackend( export async function exportCanvas( type: ExportType, - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, canvas: HTMLCanvasElement, { @@ -300,7 +302,7 @@ export async function exportCanvas( scale?: number; }, ) { - if (!hasNonDeletedElements(elements)) { + if (elements.length === 0) { return window.alert(t("alerts.cannotExportEmptyCanvas")); } if (type === "svg" || type === "clipboard-svg") { diff --git a/src/element/collision.ts b/src/element/collision.ts index 06be5d11..3a315306 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -1,6 +1,6 @@ import { distanceBetweenPointAndSegment } from "../math"; -import { ExcalidrawElement } from "./types"; +import { NonDeletedExcalidrawElement } from "./types"; import { getDiamondPoints, getElementAbsoluteCoords } from "./bounds"; import { Point } from "../types"; @@ -11,7 +11,7 @@ import { isLinearElement } from "./typeChecks"; import { rotate } from "../math"; function isElementDraggableFromInside( - element: ExcalidrawElement, + element: NonDeletedExcalidrawElement, appState: AppState, ): boolean { return ( @@ -21,7 +21,7 @@ function isElementDraggableFromInside( } export function hitTest( - element: ExcalidrawElement, + element: NonDeletedExcalidrawElement, appState: AppState, x: number, y: number, diff --git a/src/element/index.ts b/src/element/index.ts index d3a4b38c..bfbb636c 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -1,4 +1,4 @@ -import { ExcalidrawElement } from "./types"; +import { ExcalidrawElement, NonDeletedExcalidrawElement } from "./types"; import { isInvisiblySmallElement } from "./sizeHelpers"; export { @@ -63,6 +63,9 @@ export function getDrawingVersion(elements: readonly ExcalidrawElement[]) { return elements.reduce((acc, el) => acc + el.version, 0); } -export function hasNonDeletedElements(elements: readonly ExcalidrawElement[]) { - return elements.some((element) => !element.isDeleted); +export function getNonDeletedElements(elements: readonly ExcalidrawElement[]) { + return ( + elements.filter((element) => !element.isDeleted) as + readonly NonDeletedExcalidrawElement[] + ); } diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 674b7a53..b76fcc20 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -3,6 +3,7 @@ import { ExcalidrawTextElement, ExcalidrawLinearElement, ExcalidrawGenericElement, + NonDeleted, } from "../element/types"; import { measureText } from "../utils"; import { randomInteger, randomId } from "../random"; @@ -56,7 +57,7 @@ function _newElementBase( seed: rest.seed ?? randomInteger(), version: rest.version || 1, versionNonce: rest.versionNonce ?? 0, - isDeleted: rest.isDeleted ?? false, + isDeleted: false as false, }; } @@ -64,7 +65,7 @@ export function newElement( opts: { type: ExcalidrawGenericElement["type"]; } & ElementConstructorOpts, -): ExcalidrawGenericElement { +): NonDeleted { return _newElementBase(opts.type, opts); } @@ -73,13 +74,12 @@ export function newTextElement( text: string; font: string; } & ElementConstructorOpts, -): ExcalidrawTextElement { +): NonDeleted { const { text, font } = opts; const metrics = measureText(text, font); const textElement = newElementWith( { ..._newElementBase("text", opts), - isDeleted: false, text: text, font: font, // Center the text @@ -100,7 +100,7 @@ export function newLinearElement( type: ExcalidrawLinearElement["type"]; lastCommittedPoint?: ExcalidrawLinearElement["lastCommittedPoint"]; } & ElementConstructorOpts, -): ExcalidrawLinearElement { +): NonDeleted { return { ..._newElementBase(opts.type, opts), points: [], diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 78d9a5b2..d02e9d6e 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -3,7 +3,11 @@ import { SHIFT_LOCKING_ANGLE } from "../constants"; import { getSelectedElements, globalSceneState } from "../scene"; import { rescalePoints } from "../points"; import { rotate, adjustXYWithRotation } from "../math"; -import { ExcalidrawElement, ExcalidrawLinearElement } from "./types"; +import { + ExcalidrawLinearElement, + NonDeletedExcalidrawElement, + NonDeleted, +} from "./types"; import { getElementAbsoluteCoords, getCommonBounds } from "./bounds"; import { isLinearElement } from "./typeChecks"; import { mutateElement } from "./mutateElement"; @@ -17,7 +21,7 @@ import { type ResizeTestType = ReturnType; export type ResizeArrowFnType = ( - element: ExcalidrawLinearElement, + element: NonDeleted, pointIndex: number, deltaX: number, deltaY: number, @@ -27,13 +31,13 @@ export type ResizeArrowFnType = ( ) => void; const arrowResizeOrigin: ResizeArrowFnType = ( - element: ExcalidrawLinearElement, - pointIndex: number, - deltaX: number, - deltaY: number, - pointerX: number, - pointerY: number, - perfect: boolean, + element, + pointIndex, + deltaX, + deltaY, + pointerX, + pointerY, + perfect, ) => { const [px, py] = element.points[pointIndex]; let x = element.x + deltaX; @@ -63,13 +67,13 @@ const arrowResizeOrigin: ResizeArrowFnType = ( }; const arrowResizeEnd: ResizeArrowFnType = ( - element: ExcalidrawLinearElement, - pointIndex: number, - deltaX: number, - deltaY: number, - pointerX: number, - pointerY: number, - perfect: boolean, + element, + pointIndex, + deltaX, + deltaY, + pointerX, + pointerY, + perfect, ) => { const [px, py] = element.points[pointIndex]; if (perfect) { @@ -110,7 +114,7 @@ export function resizeElements( isRotating: resizeHandle === "rotation", }); const selectedElements = getSelectedElements( - globalSceneState.getAllElements(), + globalSceneState.getElements(), appState, ); if (selectedElements.length === 1) { @@ -451,7 +455,7 @@ export function resizeElements( } export function canResizeMutlipleElements( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], ) { return elements.every((element) => ["rectangle", "diamond", "ellipse"].includes(element.type), diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index 02d61fcd..64d2fd50 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -1,4 +1,8 @@ -import { ExcalidrawElement, PointerType } from "./types"; +import { + ExcalidrawElement, + PointerType, + NonDeletedExcalidrawElement, +} from "./types"; import { OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, @@ -24,7 +28,7 @@ function isInHandlerRect( } export function resizeTest( - element: ExcalidrawElement, + element: NonDeletedExcalidrawElement, appState: AppState, x: number, y: number, @@ -66,7 +70,7 @@ export function resizeTest( } export function getElementWithResizeHandler( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, { x, y }: { x: number; y: number }, zoom: number, @@ -78,7 +82,7 @@ export function getElementWithResizeHandler( } const resizeHandle = resizeTest(element, appState, x, y, zoom, pointerType); return resizeHandle ? { element, resizeHandle } : null; - }, null as { element: ExcalidrawElement; resizeHandle: ReturnType } | null); + }, null as { element: NonDeletedExcalidrawElement; resizeHandle: ReturnType } | null); } export function getResizeHandlerFromCoords( diff --git a/src/element/showSelectedShapeActions.ts b/src/element/showSelectedShapeActions.ts index 643021a5..d0a7439c 100644 --- a/src/element/showSelectedShapeActions.ts +++ b/src/element/showSelectedShapeActions.ts @@ -1,10 +1,10 @@ import { AppState } from "../types"; -import { ExcalidrawElement } from "./types"; +import { NonDeletedExcalidrawElement } from "./types"; import { getSelectedElements } from "../scene"; export const showSelectedShapeActions = ( appState: AppState, - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], ) => Boolean( appState.editingElement || diff --git a/src/element/types.ts b/src/element/types.ts index daea434c..7900dcf2 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -33,6 +33,12 @@ export type ExcalidrawElement = | ExcalidrawTextElement | ExcalidrawLinearElement; +export type NonDeleted = TElement & { + isDeleted: false; +}; + +export type NonDeletedExcalidrawElement = NonDeleted; + export type ExcalidrawTextElement = _ExcalidrawElementBase & Readonly<{ type: "text"; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index afe7c047..125afdff 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -1,4 +1,8 @@ -import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawTextElement, + NonDeletedExcalidrawElement, +} from "../element/types"; import { isTextElement } from "../element/typeChecks"; import { getDiamondPoints, @@ -24,7 +28,7 @@ export interface ExcalidrawElementWithCanvas { } function generateElementCanvas( - element: ExcalidrawElement, + element: NonDeletedExcalidrawElement, zoom: number, ): ExcalidrawElementWithCanvas { const canvas = document.createElement("canvas"); @@ -72,7 +76,7 @@ function generateElementCanvas( } function drawElementOnCanvas( - element: ExcalidrawElement, + element: NonDeletedExcalidrawElement, rc: RoughCanvas, context: CanvasRenderingContext2D, ) { @@ -133,7 +137,7 @@ export function invalidateShapeForElement(element: ExcalidrawElement) { } function generateElement( - element: ExcalidrawElement, + element: NonDeletedExcalidrawElement, generator: RoughGenerator, sceneState?: SceneState, ) { @@ -285,7 +289,7 @@ function drawElementFromCanvas( } export function renderElement( - element: ExcalidrawElement, + element: NonDeletedExcalidrawElement, rc: RoughCanvas, context: CanvasRenderingContext2D, renderOptimizations: boolean, @@ -342,7 +346,7 @@ export function renderElement( } export function renderElementToSvg( - element: ExcalidrawElement, + element: NonDeletedExcalidrawElement, rsvg: RoughSVG, svgRoot: SVGElement, offsetX?: number, diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 16b9b584..6b0f718c 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -2,7 +2,10 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughSVG } from "roughjs/bin/svg"; import { FlooredNumber, AppState } from "../types"; -import { ExcalidrawElement } from "../element/types"; +import { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "../element/types"; import { getElementAbsoluteCoords, OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, @@ -74,9 +77,9 @@ function strokeCircle( } export function renderScene( - allElements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, - selectionElement: ExcalidrawElement | null, + selectionElement: NonDeletedExcalidrawElement | null, scale: number, rc: RoughCanvas, canvas: HTMLCanvasElement, @@ -99,8 +102,6 @@ export function renderScene( return { atLeastOneVisibleElement: false }; } - const elements = allElements.filter((element) => !element.isDeleted); - const context = canvas.getContext("2d")!; context.scale(scale, scale); @@ -493,7 +494,7 @@ function isVisibleElement( // This should be only called for exporting purposes export function renderSceneToSvg( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], rsvg: RoughSVG, svgRoot: SVGElement, { diff --git a/src/scene/comparisons.ts b/src/scene/comparisons.ts index da4683c9..72c49d42 100644 --- a/src/scene/comparisons.ts +++ b/src/scene/comparisons.ts @@ -1,4 +1,7 @@ -import { ExcalidrawElement } from "../element/types"; +import { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "../element/types"; import { getElementAbsoluteCoords, hitTest } from "../element"; import { AppState } from "../types"; @@ -16,7 +19,7 @@ export const hasStroke = (type: string) => export const hasText = (type: string) => type === "text"; export function getElementAtPosition( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, x: number, y: number, diff --git a/src/scene/export.ts b/src/scene/export.ts index 603adba1..311a2518 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -1,5 +1,5 @@ import rough from "roughjs/bin/rough"; -import { ExcalidrawElement } from "../element/types"; +import { NonDeletedExcalidrawElement } from "../element/types"; import { getCommonBounds } from "../element/bounds"; import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; import { distance, SVG_NS } from "../utils"; @@ -9,7 +9,7 @@ import { AppState } from "../types"; export const SVG_EXPORT_TAG = ``; export function exportToCanvas( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, { exportBackground, @@ -66,7 +66,7 @@ export function exportToCanvas( } export function exportToSvg( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], { exportBackground, exportPadding = 10, diff --git a/src/scene/globalScene.ts b/src/scene/globalScene.ts index 13c87367..1f521a8c 100644 --- a/src/scene/globalScene.ts +++ b/src/scene/globalScene.ts @@ -1,4 +1,8 @@ -import { ExcalidrawElement } from "../element/types"; +import { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { getNonDeletedElements } from "../element"; export interface SceneStateCallback { (): void; @@ -8,15 +12,19 @@ export interface SceneStateCallbackRemover { (): void; } -class SceneState { +class GlobalScene { private callbacks: Set = new Set(); constructor(private _elements: readonly ExcalidrawElement[] = []) {} - getAllElements() { + getElementsIncludingDeleted() { return this._elements; } + getElements(): readonly NonDeletedExcalidrawElement[] { + return getNonDeletedElements(this._elements); + } + replaceAllElements(nextElements: readonly ExcalidrawElement[]) { this._elements = nextElements; this.informMutation(); @@ -44,4 +52,4 @@ class SceneState { } } -export const globalSceneState = new SceneState(); +export const globalSceneState = new GlobalScene(); diff --git a/src/scene/selection.ts b/src/scene/selection.ts index 4fff25e5..ae93877c 100644 --- a/src/scene/selection.ts +++ b/src/scene/selection.ts @@ -1,11 +1,14 @@ -import { ExcalidrawElement } from "../element/types"; +import { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "../element/types"; import { getElementAbsoluteCoords, getElementBounds } from "../element"; import { AppState } from "../types"; import { newElementWith } from "../element/mutateElement"; export function getElementsWithinSelection( - elements: readonly ExcalidrawElement[], - selection: ExcalidrawElement, + elements: readonly NonDeletedExcalidrawElement[], + selection: NonDeletedExcalidrawElement, ) { const [ selectionX1, @@ -47,7 +50,7 @@ export function deleteSelectedElements( } export function isSomeElementSelected( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, ): boolean { return elements.some((element) => appState.selectedElementIds[element.id]); @@ -58,7 +61,7 @@ export function isSomeElementSelected( * elements. If elements don't share the same value, returns `null`. */ export function getCommonAttributeOfSelectedElements( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, getAttribute: (element: ExcalidrawElement) => T, ): T | null { @@ -73,14 +76,14 @@ export function getCommonAttributeOfSelectedElements( } export function getSelectedElements( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, -): readonly ExcalidrawElement[] { +) { return elements.filter((element) => appState.selectedElementIds[element.id]); } export function getTargetElement( - elements: readonly ExcalidrawElement[], + elements: readonly NonDeletedExcalidrawElement[], appState: AppState, ) { return appState.editingElement diff --git a/src/tests/zindex.test.tsx b/src/tests/zindex.test.tsx index 82342963..da793c5f 100644 --- a/src/tests/zindex.test.tsx +++ b/src/tests/zindex.test.tsx @@ -10,6 +10,7 @@ import { actionBringToFront, actionSendToBack, } from "../actions"; +import { ExcalidrawElement } from "../element/types"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -27,7 +28,7 @@ function populateElements( const selectedElementIds: any = {}; h.elements = elements.map(({ id, isDeleted = false, isSelected = false }) => { - const element: Mutable> = newElement({ + const element: Mutable = newElement({ type: "rectangle", x: 100, y: 100, diff --git a/src/types.ts b/src/types.ts index 46e61755..6bce5334 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,8 @@ import { - ExcalidrawElement, PointerType, ExcalidrawLinearElement, + NonDeletedExcalidrawElement, + NonDeleted, } from "./element/types"; import { SHAPES } from "./shapes"; import { Point as RoughPoint } from "roughjs/bin/geometry"; @@ -12,13 +13,13 @@ export type Point = Readonly; export type AppState = { isLoading: boolean; errorMessage: string | null; - draggingElement: ExcalidrawElement | null; - resizingElement: ExcalidrawElement | null; - multiElement: ExcalidrawLinearElement | null; - selectionElement: ExcalidrawElement | null; + draggingElement: NonDeletedExcalidrawElement | null; + resizingElement: NonDeletedExcalidrawElement | null; + multiElement: NonDeleted | null; + selectionElement: NonDeletedExcalidrawElement | null; // element being edited, but not necessarily added to elements array yet // (e.g. text element when typing into the input) - editingElement: ExcalidrawElement | null; + editingElement: NonDeletedExcalidrawElement | null; elementType: typeof SHAPES[number]["value"]; elementLocked: boolean; exportBackground: boolean;