feat: lasso selection (#9169)

* lasso without 'real' shape detection

* select a single linear el

* improve ux

* feed segments to worker

* simplify path threshold adaptive to zoom

* add a tiny threshold for checks

* refactor code

* lasso tests

* fix: ts

* do not capture lasso tool

* try worker-loader in next config

* update config

* refactor

* lint

* feat: show active tool when using "more tools"

* keep lasso if selected from toolbar

* fix incorrect checks for resetting to selection

* shift for additive selection

* bound text related fixes

* lint

* keep alt toggled lasso selection if shift pressed

* fix regression

* fix 'dead' lassos

* lint

* use workerpool and polyfill

* fix worker bundled with window related code

* refactor

* add file extension for worker constructor error

* another attempt at constructor error

* attempt at build issue

* attempt with dynamic import

* test not importing from math

* narrow down imports

* Reusing existing workers infrastructure (fallback to the main thread, type-safety)

* Points on curve inside the shared chunk

* Give up on experimental code splitting

* Remove potentially unnecessary optimisation

* Removing workers as the complexit is much worse, while perf. does not seem to be much better

* fix selecting text containers and containing frames together

* render fill directly from animated trail

* do not re-render static when setting selected element ids in lasso

* remove unnecessary property

* tweak trail animation

* slice points to remove notch

* always start alt-lasso from initial point

* revert build & worker changes (unused)

* remove `lasso` from `hasStrokeColor`

* label change

* remove unused props

* remove unsafe optimization

* snaps

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
This commit is contained in:
Ryan Di
2025-04-07 16:44:25 +10:00
committed by GitHub
co-authored by dwelle Marcel Mraz
parent 6e47fadb59
commit ce267aa0d3
33 changed files with 2709 additions and 146 deletions
+201
View File
@@ -0,0 +1,201 @@
import {
type GlobalPoint,
type LineSegment,
pointFrom,
} from "@excalidraw/math";
import { getElementLineSegments } from "@excalidraw/element/bounds";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
import { getFrameChildren } from "@excalidraw/element/frame";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import { getContainerElement } from "@excalidraw/element/textElement";
import { arrayToMap, easeOut } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "@excalidraw/element/types";
import { type AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { getLassoSelectedElementIds } from "./utils";
import type App from "../components/App";
export class LassoTrail extends AnimatedTrail {
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
private elementsSegments: Map<string, LineSegment<GlobalPoint>[]> | null =
null;
private keepPreviousSelection: boolean = false;
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, 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));
},
fill: () => "rgba(105,101,219,0.05)",
stroke: () => "rgba(105,101,219)",
});
}
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<ExcalidrawElement["id"], true>);
if (this.keepPreviousSelection) {
for (const id of Object.keys(prevState.selectedElementIds)) {
nextSelectedElementIds[id] = true;
}
}
for (const [id] of Object.entries(nextSelectedElementIds)) {
const element = this.app.scene.getNonDeletedElement(id);
if (element && isTextElement(element)) {
const container = getContainerElement(
element,
this.app.scene.getNonDeletedElementsMap(),
);
if (container) {
nextSelectedElementIds[container.id] = true;
delete nextSelectedElementIds[element.id];
}
}
}
// 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<ExcalidrawLinearElement>,
)
: 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<GlobalPoint>(p[0], p[1]));
if (!this.elementsSegments) {
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,
elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements,
simplifyDistance: 5 / this.app.state.zoom.value,
});
this.selectElementsFromIds(selectedElementIds);
}
};
endPath(): void {
super.endPath();
super.clearTrails();
this.intersectedElements.clear();
this.enclosedElements.clear();
this.elementsSegments = null;
}
}
+111
View File
@@ -0,0 +1,111 @@
import { simplify } from "points-on-curve";
import {
polygonFromPoints,
polygonIncludesPoint,
lineSegment,
lineSegmentIntersectionPoints,
} from "@excalidraw/math";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[];
elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
simplifyDistance?: number;
}): {
selectedElementIds: string[];
} => {
const {
lassoPath,
elements,
elementsSegments,
intersectedElements,
enclosedElements,
simplifyDistance,
} = input;
// simplify the path to reduce the number of points
let path: GlobalPoint[] = lassoPath;
if (simplifyDistance) {
path = simplify(lassoPath, simplifyDistance) as GlobalPoint[];
}
// close the path to form a polygon for enclosure check
const closedPath = polygonFromPoints(path);
// as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear();
for (const element of elements) {
if (
!intersectedElements.has(element.id) &&
!enclosedElements.has(element.id)
) {
const enclosed = enclosureTest(closedPath, element, elementsSegments);
if (enclosed) {
enclosedElements.add(element.id);
} else {
const intersects = intersectionTest(
closedPath,
element,
elementsSegments,
);
if (intersects) {
intersectedElements.add(element.id);
}
}
}
}
const results = [...intersectedElements, ...enclosedElements];
return {
selectedElementIds: results,
};
};
const enclosureTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
): boolean => {
const lassoPolygon = polygonFromPoints(lassoPath);
const segments = elementsSegments.get(element.id);
if (!segments) {
return false;
}
return segments.some((segment) => {
return segment.some((point) => polygonIncludesPoint(point, lassoPolygon));
});
};
const intersectionTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
): boolean => {
const elementSegments = elementsSegments.get(element.id);
if (!elementSegments) {
return false;
}
const lassoSegments = lassoPath.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
acc.push(lineSegment(lassoPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
return lassoSegments.some((lassoSegment) =>
elementSegments.some(
(elementSegment) =>
// introduce a bit of tolerance to account for roughness and simplification of paths
lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null,
),
);
};