import { type GlobalPoint, type LineSegment, pointFrom, } from "@excalidraw/math"; import { getElementLineSegments } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element"; import { isFrameLikeElement, isLinearElement } from "@excalidraw/element"; import { getFrameChildren } from "@excalidraw/element"; import { selectGroupsForSelectedElements } from "@excalidraw/element"; import { arrayToMap, easeOut, isShallowEqual, setColorAlpha, } from "@excalidraw/common"; import type { ExcalidrawElement, ExcalidrawLinearElement, NonDeleted, } from "@excalidraw/element/types"; import { AnimatedTrail } from "../animatedTrail"; import { getSelectionColor } from "../renderer/helpers"; import { getLassoSelectedElementIds } from "./utils"; import type App from "../components/App"; type CanvasTranslate = { scrollX: number; scrollY: number; zoom: number; }; export class LassoTrail extends AnimatedTrail { private intersectedElements: Set = new Set(); private enclosedElements: Set = new Set(); private elementsSegments: Map[]> | null = null; private canvasTranslate: CanvasTranslate | null = null; private keepPreviousSelection: boolean = false; constructor(app: App) { super(app, { animateTrail: true, streamline: 0.4, sizeMapping: (c) => { const DECAY_TIME = Infinity; const DECAY_LENGTH = 5000; 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)); }, // same color as the marquee selection (theme-aware, host-overridable) fill: () => setColorAlpha(getSelectionColor(app.interactiveCanvas), 0.05), stroke: () => getSelectionColor(app.interactiveCanvas), }); } startPath(x: number, y: number, keepPreviousSelection = false) { // clear any existing trails just in case this.endPath(); super.startPath(x, y); this.intersectedElements.clear(); this.enclosedElements.clear(); this.keepPreviousSelection = keepPreviousSelection; if (!this.keepPreviousSelection) { this.app.setState({ selectedElementIds: {}, selectedGroupIds: {}, selectedLinearElement: null, }); } } selectElementsFromIds = (ids: string[]) => { this.app.setState((prevState) => { const nextSelectedElementIds = ids.reduce((acc, id) => { acc[id] = true; return acc; }, {} as Record); if (this.keepPreviousSelection) { for (const id of Object.keys(prevState.selectedElementIds)) { nextSelectedElementIds[id] = true; } } // remove all children of selected frames for (const [id] of Object.entries(nextSelectedElementIds)) { const element = this.app.scene.getNonDeletedElement(id); if (element && isFrameLikeElement(element)) { const elementsInFrame = getFrameChildren( this.app.scene.getNonDeletedElementsMap(), element.id, ); for (const child of elementsInFrame) { delete nextSelectedElementIds[child.id]; } } } const nextSelection = selectGroupsForSelectedElements( { editingGroupId: prevState.editingGroupId, selectedElementIds: nextSelectedElementIds, }, this.app.scene.getNonDeletedElements(), prevState, this.app, ); const selectedIds = [...Object.keys(nextSelection.selectedElementIds)]; const selectedGroupIds = [...Object.keys(nextSelection.selectedGroupIds)]; return { selectedElementIds: nextSelection.selectedElementIds, selectedGroupIds: nextSelection.selectedGroupIds, selectedLinearElement: selectedIds.length === 1 && !selectedGroupIds.length && isLinearElement(this.app.scene.getNonDeletedElement(selectedIds[0])) ? new LinearElementEditor( this.app.scene.getNonDeletedElement( selectedIds[0], ) as NonDeleted, this.app.scene.getNonDeletedElementsMap(), ) : null, }; }); }; addPointToPath = (x: number, y: number, keepPreviousSelection = false) => { super.addPointToPath(x, y); this.keepPreviousSelection = keepPreviousSelection; this.updateSelection(); }; private updateSelection = () => { const lassoPath = super .getCurrentTrail() ?.originalPoints?.map((p) => pointFrom(p[0], p[1])); const currentCanvasTranslate: CanvasTranslate = { scrollX: this.app.state.scrollX, scrollY: this.app.state.scrollY, zoom: this.app.state.zoom.value, }; if ( !this.elementsSegments || !isShallowEqual(currentCanvasTranslate, this.canvasTranslate ?? {}) ) { this.canvasTranslate = currentCanvasTranslate; this.elementsSegments = new Map(); const visibleElementsMap = arrayToMap(this.app.visibleElements); for (const element of this.app.visibleElements) { const segments = getElementLineSegments(element, visibleElementsMap); this.elementsSegments.set(element.id, segments); } } if (lassoPath) { const { selectedElementIds } = getLassoSelectedElementIds({ lassoPath, elements: this.app.visibleElements, elementsMap: this.app.scene.getNonDeletedElementsMap(), elementsSegments: this.elementsSegments, intersectedElements: this.intersectedElements, enclosedElements: this.enclosedElements, simplifyDistance: 5 / this.app.state.zoom.value, mode: this.app.state.boxSelectionMode, }); this.selectElementsFromIds(selectedElementIds); } }; endPath(): void { super.endPath(); super.clearTrails(); this.intersectedElements.clear(); this.enclosedElements.clear(); this.elementsSegments = null; } }