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
parent 6e47fadb59
commit ce267aa0d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2709 additions and 146 deletions

View file

@ -13,7 +13,10 @@ import {
import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve";
import type {
Curve,
Degrees,
GlobalPoint,
LineSegment,
@ -37,6 +40,13 @@ import {
isTextElement,
} from "./typeChecks";
import { getElementShape } from "./shapes";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -45,6 +55,8 @@ import type {
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
@ -254,50 +266,82 @@ export const getElementAbsoluteCoords = (
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
*/
/**
* Given an element, return the line segments that make up the element.
*
* Uses helpers from /math
*/
export const getElementLineSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): LineSegment<GlobalPoint>[] => {
const shape = getElementShape(element, elementsMap);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const center = pointFrom<GlobalPoint>(cx, cy);
const center: GlobalPoint = pointFrom(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: LineSegment<GlobalPoint>[] = [];
if (shape.type === "polycurve") {
const curves = shape.data;
const points = curves
.map((curve) => pointsOnBezierCurves(curve, 10))
.flat();
let i = 0;
while (i < element.points.length - 1) {
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads(
pointFrom(
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
center,
element.angle,
),
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
return segments;
} else if (shape.type === "polyline") {
return shape.data as LineSegment<GlobalPoint>[];
} else if (_isRectanguloidElement(element)) {
const [sides, corners] = deconstructRectanguloidElement(element);
const cornerSegments: LineSegment<GlobalPoint>[] = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (element.type === "diamond") {
const [sides, corners] = deconstructDiamondElement(element);
const cornerSegments = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (shape.type === "polygon") {
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (container && isLinearElement(container)) {
const segments: LineSegment<GlobalPoint>[] = [
lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
];
return segments;
}
}
const points = shape.data as GlobalPoint[];
const segments: LineSegment<GlobalPoint>[] = [];
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
return segments;
} else if (shape.type === "ellipse") {
return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
}
const [nw, ne, sw, se, n, s, w, e] = (
const [nw, ne, sw, se, , , w, e] = (
[
[x1, y1],
[x2, y1],
@ -310,28 +354,6 @@ export const getElementLineSegments = (
] as GlobalPoint[]
).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
if (element.type === "ellipse") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
return [
lineSegment(nw, ne),
lineSegment(sw, se),
@ -344,6 +366,94 @@ export const getElementLineSegments = (
];
};
const _isRectanguloidElement = (
element: ExcalidrawElement,
): element is ExcalidrawRectanguloidElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
const getRotatedSides = (
sides: LineSegment<GlobalPoint>[],
center: GlobalPoint,
angle: Radians,
) => {
return sides.map((side) => {
return lineSegment(
pointRotateRads<GlobalPoint>(side[0], center, angle),
pointRotateRads<GlobalPoint>(side[1], center, angle),
);
});
};
const getSegmentsOnCurve = (
curve: Curve<GlobalPoint>,
center: GlobalPoint,
angle: Radians,
): LineSegment<GlobalPoint>[] => {
const points = pointsOnBezierCurves(curve, 10);
let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads<GlobalPoint>(
pointFrom(points[i][0], points[i][1]),
center,
angle,
),
pointRotateRads<GlobalPoint>(
pointFrom(points[i + 1][0], points[i + 1][1]),
center,
angle,
),
),
);
i++;
}
return segments;
};
const getSegmentsOnEllipse = (
ellipse: ExcalidrawEllipseElement,
): LineSegment<GlobalPoint>[] => {
const center = pointFrom<GlobalPoint>(
ellipse.x + ellipse.width / 2,
ellipse.y + ellipse.height / 2,
);
const a = ellipse.width / 2;
const b = ellipse.height / 2;
const segments: LineSegment<GlobalPoint>[] = [];
const points: GlobalPoint[] = [];
const n = 90;
const deltaT = (Math.PI * 2) / n;
for (let i = 0; i < n; i++) {
const t = i * deltaT;
const x = center[0] + a * Math.cos(t);
const y = center[1] + b * Math.sin(t);
points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
}
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
segments.push(lineSegment(points[points.length - 1], points[0]));
return segments;
};
/**
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
*

View file

@ -14,6 +14,7 @@ export const showSelectedShapeActions = (
((appState.activeTool.type !== "custom" &&
(appState.editingTextElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "lasso" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser"))) ||