import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { computeBoundTextPosition, doBoundsIntersect, getBoundTextElement, getElementBounds, getElementLineSegments, getFreedrawOutlineAsSegments, getFreedrawOutlinePoints, intersectElementWithLineSegment, isArrowElement, isFreeDrawElement, isLineElement, isPointInElement, } from "@excalidraw/element"; import { lineSegment, lineSegmentsDistance, pointFrom, polygon, polygonIncludesPointNonZero, } from "@excalidraw/math"; import { getElementsInGroup } from "@excalidraw/element"; import { shouldTestInside } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element"; import type { Bounds } from "@excalidraw/common"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import { AnimatedTrail } from "../animatedTrail"; import type App from "../components/App"; export class EraserTrail extends AnimatedTrail { private elementsToErase: Set = new Set(); private groupsToErase: Set = new Set(); constructor(app: App) { super(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) { const eraserPath: GlobalPoint[] = super .getCurrentTrail() ?.originalPoints?.map((p) => pointFrom(p[0], p[1])) || []; if (eraserPath.length < 2) { return []; } // for efficiency and avoid unnecessary calculations, // take only POINTS_ON_TRAIL points to form some number of segments const pathSegment = lineSegment( eraserPath[eraserPath.length - 1], eraserPath[eraserPath.length - 2], ); const candidateElements = this.app.visibleElements.filter( (el) => !el.locked, ); const candidateElementsMap = arrayToMap(candidateElements); for (const element of candidateElements) { // restore only if already added to the to-be-erased set if (restoreToErase && this.elementsToErase.has(element.id)) { const intersects = eraserTest( pathSegment, element, candidateElementsMap, this.app.state.zoom.value, ); 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( pathSegment, element, candidateElementsMap, this.app.state.zoom.value, ); 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(); } } const eraserTest = ( pathSegment: LineSegment, element: ExcalidrawElement, elementsMap: ElementsMap, zoom: number, ): boolean => { const lastPoint = pathSegment[1]; // PERF: Do a quick bounds intersection test first because it's cheap const threshold = isFreeDrawElement(element) ? 15 : element.strokeWidth / 2; const segmentBounds = [ Math.min(pathSegment[0][0], pathSegment[1][0]) - threshold, Math.min(pathSegment[0][1], pathSegment[1][1]) - threshold, Math.max(pathSegment[0][0], pathSegment[1][0]) + threshold, Math.max(pathSegment[0][1], pathSegment[1][1]) + threshold, ] as Bounds; const origElementBounds = getElementBounds(element, elementsMap); const elementBounds: Bounds = [ origElementBounds[0] - threshold, origElementBounds[1] - threshold, origElementBounds[2] + threshold, origElementBounds[3] + threshold, ]; if (!doBoundsIntersect(segmentBounds, elementBounds)) { return false; } // There are shapes where the inner area should trigger erasing // even though the eraser path segment doesn't intersect with or // get close to the shape's stroke if ( shouldTestInside(element) && isPointInElement(lastPoint, element, elementsMap) ) { return true; } // Freedraw elements are tested for erasure by measuring the distance // of the eraser path and the freedraw shape outline lines to a tolerance // which offers a good visual precision at various zoom levels if (isFreeDrawElement(element)) { const outlinePoints = getFreedrawOutlinePoints(element); const strokeSegments = getFreedrawOutlineAsSegments( element, outlinePoints, elementsMap, ); const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation for (const seg of strokeSegments) { if (lineSegmentsDistance(seg, pathSegment) <= tolerance) { return true; } } const poly = polygon( ...(outlinePoints.map(([x, y]) => pointFrom(element.x + x, element.y + y), ) as GlobalPoint[]), ); // PERF: Check only one point of the eraser segment. If the eraser segment // start is inside the closed freedraw shape, the other point is either also // inside or the eraser segment will intersect the shape outline anyway if (polygonIncludesPointNonZero(pathSegment[0], poly)) { return true; } return false; } const boundTextElement = getBoundTextElement(element, elementsMap); if (isArrowElement(element) || (isLineElement(element) && !element.polygon)) { const tolerance = Math.max( element.strokeWidth, (element.strokeWidth * 2) / zoom, ); // If the eraser movement is so fast that a large distance is covered // between the last two points, the distanceToElement miss, so we test // agaist each segment of the linear element const segments = getElementLineSegments(element, elementsMap); for (const seg of segments) { if (lineSegmentsDistance(seg, pathSegment) <= tolerance) { return true; } } return false; } return ( intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true) .length > 0 || (!!boundTextElement && intersectElementWithLineSegment( { ...boundTextElement, ...computeBoundTextPosition(element, boundTextElement, elementsMap), }, elementsMap, pathSegment, 0, true, ).length > 0) ); };