snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
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<ExcalidrawElement["id"]> = new Set();
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
private elementsSegments: Map<string, LineSegment<GlobalPoint>[]> | 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<ExcalidrawElement["id"], true>);
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<ExcalidrawLinearElement>,
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<GlobalPoint>(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;
}
}

View File

@@ -0,0 +1,400 @@
import { simplify } from "points-on-curve";
import {
polygonFromPoints,
lineSegment,
pointFrom,
pointOnLineSegment,
polygonIncludesPointNonZero,
PRECISION,
} from "@excalidraw/math";
import { type Bounds } from "@excalidraw/common";
import {
boundsContainBounds,
computeBoundTextPosition,
doBoundsIntersect,
elementOverlapsWithFrame,
getBoundTextElement,
getContainingFrame,
getElementBounds,
intersectElementWithLineSegment,
isBoundToContainer,
pointInsideBoundsInclusive,
} from "@excalidraw/element";
import type {
ElementsSegmentsMap,
GlobalPoint,
LineSegment,
Polygon,
} from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { BoxSelectionMode } from "../types";
export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
simplifyDistance?: number;
mode?: BoxSelectionMode;
}): {
selectedElementIds: string[];
} => {
const {
lassoPath,
elements,
elementsMap,
elementsSegments,
intersectedElements,
enclosedElements,
simplifyDistance,
mode = "contain",
} = input;
// simplify the path to reduce the number of points
let path: GlobalPoint[] = lassoPath;
if (simplifyDistance) {
path = simplify(lassoPath, simplifyDistance) as GlobalPoint[];
}
// bound text is never selected on its own, it's covered by its container
const selectableElements = elements.filter(
(el) => !el.locked && !isBoundToContainer(el),
);
// as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear();
intersectedElements.clear();
const lassoBounds = lassoPath.reduce(
(acc, item) => {
return [
Math.min(acc[0], item[0]),
Math.min(acc[1], item[1]),
Math.max(acc[2], item[0]),
Math.max(acc[3], item[1]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
) as Bounds;
const lassoPolygon = polygonFromPoints(path);
const containBounds: Bounds = [
lassoBounds[0] - PRECISION,
lassoBounds[1] - PRECISION,
lassoBounds[2] + PRECISION,
lassoBounds[3] + PRECISION,
];
for (const element of selectableElements) {
// Only the visible (frame-clipped) portion of an element is relevant for
// selection, so everything outside of its frame is disregarded below
const bounds = getElementBounds(element, elementsMap);
const clipBounds = getFrameClipBounds(element, bounds, elementsMap);
// First check if the lasso segment intersects the element's axis-aligned
// bounding box as it is much faster than checking intersection against
// the element's shape
const elementBounds = clipToBounds(bounds, clipBounds);
if (
doBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) &&
!enclosedElements.has(element.id)
) {
if (
mode === "contain" &&
!boundsContainBounds(containBounds, elementBounds)
) {
continue;
}
const segments = getSelectionSegments(
element,
elementsMap,
elementsSegments,
clipBounds,
);
const enclosed = enclosureTest(
lassoPolygon,
segments,
elementBounds,
mode,
);
if (mode === "contain") {
if (
enclosed &&
!intersectionTest(path, element, elementsMap, clipBounds)
) {
enclosedElements.add(element.id);
}
} else if (enclosed) {
enclosedElements.add(element.id);
} else if (intersectionTest(path, element, elementsMap, clipBounds)) {
intersectedElements.add(element.id);
}
}
}
const results =
mode === "contain"
? excludeIncompleteGroups([...enclosedElements], elementsMap)
: [...intersectedElements, ...enclosedElements];
return {
selectedElementIds: results,
};
};
/**
* The bounds an element is clipped to by the frame containing it, or `null`
* when the element isn't clipped at all.
*/
const getFrameClipBounds = (
element: ExcalidrawElement,
elementBounds: Bounds,
elementsMap: ElementsMap,
): Bounds | null => {
const frame = getContainingFrame(element, elementsMap);
if (!frame) {
return null;
}
const frameBounds = getElementBounds(frame, elementsMap);
// nothing is clipped away from an element fitting inside its frame
if (boundsContainBounds(frameBounds, elementBounds)) {
return null;
}
return elementOverlapsWithFrame(element, frame, elementsMap)
? frameBounds
: null;
};
const clipToBounds = (bounds: Bounds, clipBounds: Bounds | null): Bounds =>
clipBounds
? [
Math.max(bounds[0], clipBounds[0]),
Math.max(bounds[1], clipBounds[1]),
Math.min(bounds[2], clipBounds[2]),
Math.min(bounds[3], clipBounds[3]),
]
: bounds;
/**
* The segments taking part in the selection tests: the element's own outline
* together with its label's, clipped to the element's containing frame.
*/
const getSelectionSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
elementsSegments: ElementsSegmentsMap,
clipBounds: Bounds | null,
): LineSegment<GlobalPoint>[] => {
const elementSegments = elementsSegments.get(element.id) ?? [];
const boundTextElement = getBoundTextElement(element, elementsMap);
const boundTextSegments = boundTextElement
? elementsSegments.get(boundTextElement.id)
: null;
const segments = boundTextSegments?.length
? [...elementSegments, ...boundTextSegments]
: elementSegments;
return clipBounds ? clipSegmentsToBounds(segments, clipBounds) : segments;
};
const clipSegmentsToBounds = (
segments: LineSegment<GlobalPoint>[],
bounds: Bounds,
): LineSegment<GlobalPoint>[] => {
const [minX, minY, maxX, maxY] = bounds;
const clipped: LineSegment<GlobalPoint>[] = [];
for (const [[x1, y1], [x2, y2]] of segments) {
const dx = x2 - x1;
const dy = y2 - y1;
let t0 = 0;
let t1 = 1;
let inside = true;
// clip the segment against each of the four edges in turn, narrowing down
// the [t0, t1] portion of it which lies inside the bounds
for (const [p, q] of [
[-dx, x1 - minX],
[dx, maxX - x1],
[-dy, y1 - minY],
[dy, maxY - y1],
]) {
if (p === 0) {
// parallel to this edge: either entirely inside or entirely outside
if (q < 0) {
inside = false;
break;
}
continue;
}
const t = q / p;
if (p < 0) {
if (t > t1) {
inside = false;
break;
}
t0 = Math.max(t0, t);
} else {
if (t < t0) {
inside = false;
break;
}
t1 = Math.min(t1, t);
}
}
if (inside) {
clipped.push(
lineSegment(
pointFrom<GlobalPoint>(x1 + t0 * dx, y1 + t0 * dy),
pointFrom<GlobalPoint>(x1 + t1 * dx, y1 + t1 * dy),
),
);
}
}
return clipped;
};
/**
* In "contain" mode a group is only selected if every one of its members is,
* mirroring marquee selection.
*
* NOTE: currently we only support top-level group handling since we don't
* support box selecting while editing the group/subgroup
*/
const excludeIncompleteGroups = (
selectedElementIds: ExcalidrawElement["id"][],
elementsMap: ElementsMap,
): ExcalidrawElement["id"][] => {
if (!selectedElementIds.some((id) => elementsMap.get(id)?.groupIds.length)) {
return selectedElementIds;
}
const groups = new Map<string, ExcalidrawElement["id"][]>();
for (const element of elementsMap.values()) {
const groupId = element.groupIds.at(-1);
// ignored elements such as bound text and locked elements must not
// affect group selection
if (!groupId || element.locked || isBoundToContainer(element)) {
continue;
}
const group = groups.get(groupId);
if (group) {
group.push(element.id);
} else {
groups.set(groupId, [element.id]);
}
}
if (!groups.size) {
return selectedElementIds;
}
const selected = new Set(selectedElementIds);
return selectedElementIds.filter((id) => {
const groupId = elementsMap.get(id)?.groupIds.at(-1);
const group = groupId ? groups.get(groupId) : null;
return !group || group.every((memberId) => selected.has(memberId));
});
};
const enclosureTest = (
lassoPolygon: Polygon<GlobalPoint>,
segments: LineSegment<GlobalPoint>[],
elementBounds: Bounds,
mode: BoxSelectionMode,
): boolean => {
const quantifier = mode === "contain" ? "every" : "some";
const includesPoint = (point: GlobalPoint) =>
polygonIncludesPointNonZero(point, lassoPolygon) ||
(mode === "contain" && isPointOnPolygon(point, lassoPolygon));
// NOTE: elements without an outline (e.g. a single point freedraw dot) are
// tested by the corners of their bounding box instead
if (segments.length === 0) {
return boundsCorners(elementBounds)[quantifier](includesPoint);
}
return segments[quantifier]((segment) => segment[quantifier](includesPoint));
};
const boundsCorners = ([minX, minY, maxX, maxY]: Bounds): GlobalPoint[] => [
pointFrom(minX, minY),
pointFrom(maxX, minY),
pointFrom(maxX, maxY),
pointFrom(minX, maxY),
];
const isPointOnPolygon = (
point: GlobalPoint,
polygon: Polygon<GlobalPoint>,
): boolean =>
polygon.some(
(vertex, index) =>
index > 0 &&
pointOnLineSegment(point, lineSegment(polygon[index - 1], vertex)),
);
const intersectionTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsMap: ElementsMap,
clipBounds: Bounds | null,
): boolean => {
const lassoSegments = lassoPath
.slice(1)
.map((point: GlobalPoint, index) => lineSegment(lassoPath[index], point))
.concat([lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])]);
const boundTextElement = getBoundTextElement(element, elementsMap);
// hits on the part of the element clipped away by its frame are invisible,
// hence they do not count as an intersection
const intersects = (
target: ExcalidrawElement,
lassoSegment: LineSegment<GlobalPoint>,
) => {
const hits = intersectElementWithLineSegment(
target,
elementsMap,
lassoSegment,
0,
!clipBounds,
);
return clipBounds
? hits.some((hit) => pointInsideBoundsInclusive(hit, clipBounds))
: hits.length > 0;
};
return lassoSegments.some(
(lassoSegment) =>
intersects(element, lassoSegment) ||
(!!boundTextElement &&
intersects(
{
...boundTextElement,
...computeBoundTextPosition(element, boundTextElement, elementsMap),
},
lassoSegment,
)),
);
};