From 58f7d33d80198a2a80c6d3eb38b3432e062dfbea Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 16 Apr 2025 00:58:45 +1000 Subject: [PATCH] perf: make eraser great again (#9352) * perf: make eraser great again * lint * refactor and improve perf * lint --- packages/excalidraw/components/App.tsx | 127 ++---------- packages/excalidraw/eraser/index.ts | 239 +++++++++++++++++++++++ packages/excalidraw/lasso/utils.ts | 8 +- packages/excalidraw/tests/lasso.test.tsx | 3 +- packages/math/src/types.ts | 2 + 5 files changed, 259 insertions(+), 120 deletions(-) create mode 100644 packages/excalidraw/eraser/index.ts diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 976abfd765..242fd8e46e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -454,7 +454,6 @@ import { import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { Store, CaptureUpdateAction } from "../store"; -import { AnimatedTrail } from "../animated-trail"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; @@ -464,6 +463,8 @@ import { isMaybeMermaidDefinition } from "../mermaid"; import { LassoTrail } from "../lasso"; +import { EraserTrail } from "../eraser"; + import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import BraveMeasureTextError from "./BraveMeasureTextError"; import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; @@ -675,26 +676,7 @@ class App extends React.Component { animationFrameHandler = new AnimationFrameHandler(); laserTrails = new LaserTrails(this.animationFrameHandler, this); - eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, { - streamline: 0.2, - size: 5, - keepHead: true, - sizeMapping: (c) => { - const DECAY_TIME = 200; - const DECAY_LENGTH = 10; - const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME); - const l = - (DECAY_LENGTH - - Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) / - DECAY_LENGTH; - - return Math.min(easeOut(l), easeOut(t)); - }, - fill: () => - this.state.theme === THEME.LIGHT - ? "rgba(0, 0, 0, 0.2)" - : "rgba(255, 255, 255, 0.2)", - }); + eraserTrail = new EraserTrail(this.animationFrameHandler, this); lassoTrail = new LassoTrail(this.animationFrameHandler, this); onChangeEmitter = new Emitter< @@ -1676,8 +1658,8 @@ class App extends React.Component { {selectedElements.length === 1 && @@ -5163,7 +5145,7 @@ class App extends React.Component { return elements; } - private getElementHitThreshold() { + getElementHitThreshold() { return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value; } @@ -6219,101 +6201,16 @@ class App extends React.Component { private handleEraser = ( event: PointerEvent, - pointerDownState: PointerDownState, scenePointer: { x: number; y: number }, ) => { - this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y); - - let didChange = false; - - const processedGroups = new Set(); - const nonDeletedElements = this.scene.getNonDeletedElements(); - - const processElements = (elements: ExcalidrawElement[]) => { - for (const element of elements) { - if (element.locked) { - return; - } - - if (event.altKey) { - if (this.elementsPendingErasure.delete(element.id)) { - didChange = true; - } - } else if (!this.elementsPendingErasure.has(element.id)) { - didChange = true; - this.elementsPendingErasure.add(element.id); - } - - // (un)erase groups atomically - if (didChange && element.groupIds?.length) { - const shallowestGroupId = element.groupIds.at(-1)!; - if (!processedGroups.has(shallowestGroupId)) { - processedGroups.add(shallowestGroupId); - const elems = getElementsInGroup( - nonDeletedElements, - shallowestGroupId, - ); - for (const elem of elems) { - if (event.altKey) { - this.elementsPendingErasure.delete(elem.id); - } else { - this.elementsPendingErasure.add(elem.id); - } - } - } - } - } - }; - - const distance = pointDistance( - pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y), - pointFrom(scenePointer.x, scenePointer.y), + const elementsToErase = this.eraserTrail.addPointToPath( + scenePointer.x, + scenePointer.y, + event.altKey, ); - const threshold = this.getElementHitThreshold(); - const p = { ...pointerDownState.lastCoords }; - let samplingInterval = 0; - while (samplingInterval <= distance) { - const hitElements = this.getElementsAtPosition(p.x, p.y); - processElements(hitElements); - // Exit since we reached current point - if (samplingInterval === distance) { - break; - } - - // Calculate next point in the line at a distance of sampling interval - samplingInterval = Math.min(samplingInterval + threshold, distance); - - const distanceRatio = samplingInterval / distance; - const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x; - const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y; - p.x = nextX; - p.y = nextY; - } - - pointerDownState.lastCoords.x = scenePointer.x; - pointerDownState.lastCoords.y = scenePointer.y; - - if (didChange) { - for (const element of this.scene.getNonDeletedElements()) { - if ( - isBoundToContainer(element) && - (this.elementsPendingErasure.has(element.id) || - this.elementsPendingErasure.has(element.containerId)) - ) { - if (event.altKey) { - this.elementsPendingErasure.delete(element.id); - this.elementsPendingErasure.delete(element.containerId); - } else { - this.elementsPendingErasure.add(element.id); - this.elementsPendingErasure.add(element.containerId); - } - } - } - - this.elementsPendingErasure = new Set(this.elementsPendingErasure); - this.triggerRender(); - } + this.elementsPendingErasure = new Set(elementsToErase); + this.triggerRender(); }; // set touch moving for mobile context menu @@ -8159,7 +8056,7 @@ class App extends React.Component { } if (isEraserActive(this.state)) { - this.handleEraser(event, pointerDownState, pointerCoords); + this.handleEraser(event, pointerCoords); return; } diff --git a/packages/excalidraw/eraser/index.ts b/packages/excalidraw/eraser/index.ts new file mode 100644 index 0000000000..a9f9103f55 --- /dev/null +++ b/packages/excalidraw/eraser/index.ts @@ -0,0 +1,239 @@ +import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; +import { getElementLineSegments } from "@excalidraw/element/bounds"; +import { + lineSegment, + lineSegmentIntersectionPoints, + pointFrom, +} from "@excalidraw/math"; + +import { getElementsInGroup } from "@excalidraw/element/groups"; + +import { getElementShape } from "@excalidraw/element/shapes"; +import { shouldTestInside } from "@excalidraw/element/collision"; +import { isPointInShape } from "@excalidraw/utils/collision"; +import { + hasBoundTextElement, + isBoundToContainer, +} from "@excalidraw/element/typeChecks"; +import { getBoundTextElementId } from "@excalidraw/element/textElement"; + +import type { GeometricShape } from "@excalidraw/utils/shape"; +import type { + ElementsSegmentsMap, + GlobalPoint, + LineSegment, +} from "@excalidraw/math/types"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; + +import { AnimatedTrail } from "../animated-trail"; + +import type { AnimationFrameHandler } from "../animation-frame-handler"; + +import type App from "../components/App"; + +// just enough to form a segment; this is sufficient for eraser +const POINTS_ON_TRAIL = 2; + +export class EraserTrail extends AnimatedTrail { + private elementsToErase: Set = new Set(); + private groupsToErase: Set = new Set(); + private segmentsCache: Map[]> = new Map(); + private geometricShapesCache: Map> = + new Map(); + + constructor(animationFrameHandler: AnimationFrameHandler, app: App) { + super(animationFrameHandler, app, { + streamline: 0.2, + size: 5, + keepHead: true, + sizeMapping: (c) => { + const DECAY_TIME = 200; + const DECAY_LENGTH = 10; + const t = Math.max( + 0, + 1 - (performance.now() - c.pressure) / DECAY_TIME, + ); + const l = + (DECAY_LENGTH - + Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) / + DECAY_LENGTH; + + return Math.min(easeOut(l), easeOut(t)); + }, + fill: () => + app.state.theme === THEME.LIGHT + ? "rgba(0, 0, 0, 0.2)" + : "rgba(255, 255, 255, 0.2)", + }); + } + + startPath(x: number, y: number): void { + this.endPath(); + super.startPath(x, y); + this.elementsToErase.clear(); + } + + addPointToPath(x: number, y: number, restore = false) { + super.addPointToPath(x, y); + + const elementsToEraser = this.updateElementsToBeErased(restore); + + return elementsToEraser; + } + + private updateElementsToBeErased(restoreToErase?: boolean) { + let eraserPath: GlobalPoint[] = + super + .getCurrentTrail() + ?.originalPoints?.map((p) => pointFrom(p[0], p[1])) || []; + + // for efficiency and avoid unnecessary calculations, + // take only POINTS_ON_TRAIL points to form some number of segments + eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL); + + const visibleElementsMap = arrayToMap(this.app.visibleElements); + + const pathSegments = eraserPath.reduce((acc, point, index) => { + if (index === 0) { + return acc; + } + acc.push(lineSegment(eraserPath[index - 1], point)); + return acc; + }, [] as LineSegment[]); + + if (pathSegments.length === 0) { + return []; + } + + for (const element of this.app.visibleElements) { + // restore only if already added to the to-be-erased set + if (restoreToErase && this.elementsToErase.has(element.id)) { + const intersects = eraserTest( + pathSegments, + element, + this.segmentsCache, + this.geometricShapesCache, + visibleElementsMap, + this.app, + ); + + if (intersects) { + const shallowestGroupId = element.groupIds.at(-1)!; + + if (this.groupsToErase.has(shallowestGroupId)) { + const elementsInGroup = getElementsInGroup( + this.app.scene.getNonDeletedElementsMap(), + shallowestGroupId, + ); + for (const elementInGroup of elementsInGroup) { + this.elementsToErase.delete(elementInGroup.id); + } + this.groupsToErase.delete(shallowestGroupId); + } + + if (isBoundToContainer(element)) { + this.elementsToErase.delete(element.containerId); + } + + if (hasBoundTextElement(element)) { + const boundText = getBoundTextElementId(element); + + if (boundText) { + this.elementsToErase.delete(boundText); + } + } + + this.elementsToErase.delete(element.id); + } + } else if (!restoreToErase && !this.elementsToErase.has(element.id)) { + const intersects = eraserTest( + pathSegments, + element, + this.segmentsCache, + this.geometricShapesCache, + visibleElementsMap, + this.app, + ); + + if (intersects) { + const shallowestGroupId = element.groupIds.at(-1)!; + + if (!this.groupsToErase.has(shallowestGroupId)) { + const elementsInGroup = getElementsInGroup( + this.app.scene.getNonDeletedElementsMap(), + shallowestGroupId, + ); + + for (const elementInGroup of elementsInGroup) { + this.elementsToErase.add(elementInGroup.id); + } + this.groupsToErase.add(shallowestGroupId); + } + + if (hasBoundTextElement(element)) { + const boundText = getBoundTextElementId(element); + + if (boundText) { + this.elementsToErase.add(boundText); + } + } + + if (isBoundToContainer(element)) { + this.elementsToErase.add(element.containerId); + } + + this.elementsToErase.add(element.id); + } + } + } + + return Array.from(this.elementsToErase); + } + + endPath(): void { + super.endPath(); + super.clearTrails(); + this.elementsToErase.clear(); + this.groupsToErase.clear(); + this.segmentsCache.clear(); + } +} + +const eraserTest = ( + pathSegments: LineSegment[], + element: ExcalidrawElement, + elementsSegments: ElementsSegmentsMap, + shapesCache = new Map>(), + visibleElementsMap = new Map(), + app: App, +): boolean => { + let shape = shapesCache.get(element.id); + + if (!shape) { + shape = getElementShape(element, visibleElementsMap); + shapesCache.set(element.id, shape); + } + + const lastPoint = pathSegments[pathSegments.length - 1][1]; + if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) { + return true; + } + + let elementSegments = elementsSegments.get(element.id); + + if (!elementSegments) { + elementSegments = getElementLineSegments(element, visibleElementsMap); + elementsSegments.set(element.id, elementSegments); + } + + return pathSegments.some((pathSegment) => + elementSegments?.some( + (elementSegment) => + lineSegmentIntersectionPoints( + pathSegment, + elementSegment, + app.getElementHitThreshold(), + ) !== null, + ), + ); +}; diff --git a/packages/excalidraw/lasso/utils.ts b/packages/excalidraw/lasso/utils.ts index f5a7eefdc9..d05f39998a 100644 --- a/packages/excalidraw/lasso/utils.ts +++ b/packages/excalidraw/lasso/utils.ts @@ -7,11 +7,13 @@ import { polygonIncludesPointNonZero, } from "@excalidraw/math"; -import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; +import type { + ElementsSegmentsMap, + GlobalPoint, + LineSegment, +} from "@excalidraw/math/types"; import type { ExcalidrawElement } from "@excalidraw/element/types"; -export type ElementsSegmentsMap = Map[]>; - export const getLassoSelectedElementIds = (input: { lassoPath: GlobalPoint[]; elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/tests/lasso.test.tsx b/packages/excalidraw/tests/lasso.test.tsx index 00e0ec2b4d..aa32b13d61 100644 --- a/packages/excalidraw/tests/lasso.test.tsx +++ b/packages/excalidraw/tests/lasso.test.tsx @@ -19,6 +19,7 @@ import { type LocalPoint, pointFrom, type Radians, + type ElementsSegmentsMap, } from "@excalidraw/math"; import { getElementLineSegments } from "@excalidraw/element/bounds"; @@ -33,8 +34,6 @@ import { getLassoSelectedElementIds } from "../lasso/utils"; import { act, render } from "./test-utils"; -import type { ElementsSegmentsMap } from "../lasso/utils"; - const { h } = window; beforeEach(async () => { diff --git a/packages/math/src/types.ts b/packages/math/src/types.ts index a2a575bd7c..da7d5d6abd 100644 --- a/packages/math/src/types.ts +++ b/packages/math/src/types.ts @@ -138,3 +138,5 @@ export type Ellipse = { } & { _brand: "excalimath_ellipse"; }; + +export type ElementsSegmentsMap = Map[]>;