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:
co-authored by
dwelle
Marcel Mraz
parent
6e47fadb59
commit
ce267aa0d3
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user