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

@ -461,6 +461,8 @@ import { isOverScrollBars } from "../scene/scrollbars";
import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@ -692,6 +694,7 @@ class App extends React.Component<AppProps, AppState> {
? "rgba(0, 0, 0, 0.2)"
: "rgba(255, 255, 255, 0.2)",
});
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
onChangeEmitter = new Emitter<
[
@ -1670,7 +1673,11 @@ class App extends React.Component<AppProps, AppState> {
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<SVGLayer
trails={[this.laserTrails, this.eraserTrail]}
trails={[
this.laserTrails,
this.eraserTrail,
this.lassoTrail,
]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
@ -4630,7 +4637,10 @@ class App extends React.Component<AppProps, AppState> {
this.state.openDialog?.name === "elementLinkSelector"
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.activeTool.type === "selection") {
} else if (
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso"
) {
resetCursor(this.interactiveCanvas);
} else {
setCursorForShape(this.interactiveCanvas, this.state);
@ -4738,7 +4748,8 @@ class App extends React.Component<AppProps, AppState> {
}
)
| { type: "custom"; customType: string }
) & { locked?: boolean },
) & { locked?: boolean; fromSelection?: boolean },
keepSelection = false,
) => {
if (!this.isToolSupported(tool.type)) {
console.warn(
@ -4780,7 +4791,21 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement();
}
if (nextActiveTool.type !== "selection") {
if (nextActiveTool.type === "lasso") {
return {
...prevState,
activeTool: nextActiveTool,
...(keepSelection
? {}
: {
selectedElementIds: makeNextSelectedElementIds({}, prevState),
selectedGroupIds: makeNextSelectedElementIds({}, prevState),
editingGroupId: null,
multiElement: null,
}),
...commonResets,
};
} else if (nextActiveTool.type !== "selection") {
return {
...prevState,
activeTool: nextActiveTool,
@ -6603,6 +6628,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.penMode ||
event.pointerType !== "touch" ||
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso" ||
this.state.activeTool.type === "text" ||
this.state.activeTool.type === "image";
@ -6610,7 +6636,13 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (this.state.activeTool.type === "text") {
if (this.state.activeTool.type === "lasso") {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
} else if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState);
} else if (
this.state.activeTool.type === "arrow" ||
@ -7067,7 +7099,10 @@ class App extends React.Component<AppProps, AppState> {
}
private clearSelectionIfNotUsingSelection = (): void => {
if (this.state.activeTool.type !== "selection") {
if (
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso"
) {
this.setState({
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
@ -8267,7 +8302,8 @@ class App extends React.Component<AppProps, AppState> {
if (
(hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor
!isSelectingPointsInLineEditor &&
this.state.activeTool.type !== "lasso"
) {
const selectedElements = this.scene.getSelectedElements(this.state);
@ -8539,7 +8575,37 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.selectionElement) {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event);
if (event.altKey) {
this.setActiveTool(
{ type: "lasso", fromSelection: true },
event.shiftKey,
);
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
this.setAppState({
selectionElement: null,
});
} else {
this.maybeDragNewGenericElement(pointerDownState, event);
}
} else if (this.state.activeTool.type === "lasso") {
if (!event.altKey && this.state.activeTool.fromSelection) {
this.setActiveTool({ type: "selection" });
this.createGenericElementOnPointerDown("selection", pointerDownState);
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event);
this.lassoTrail.endPath();
} else {
this.lassoTrail.addPointToPath(
pointerCoords.x,
pointerCoords.y,
event.shiftKey,
);
}
} else {
// It is very important to read this.state within each move event,
// otherwise we would read a stale one!
@ -8794,6 +8860,8 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null,
}));
// just in case, tool changes mid drag, always clean up
this.lassoTrail.endPath();
this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null);
@ -9510,6 +9578,8 @@ class App extends React.Component<AppProps, AppState> {
}
if (
// do not clear selection if lasso is active
this.state.activeTool.type !== "lasso" &&
// not elbow midpoint dragged
!(hitElement && isElbowArrow(hitElement)) &&
// not dragged
@ -9608,7 +9678,13 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw") {
if (
!activeTool.locked &&
activeTool.type !== "freedraw" &&
(activeTool.type !== "lasso" ||
// if lasso is turned on but from selection => reset to selection
(activeTool.type === "lasso" && activeTool.fromSelection))
) {
resetCursor(this.interactiveCanvas);
this.setState({
newElement: null,
@ -10463,7 +10539,7 @@ class App extends React.Component<AppProps, AppState> {
width: distance(pointerDownState.origin.x, pointerCoords.x),
height: distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event),
shouldResizeFromCenter: false,
zoom: this.state.zoom.value,
informMutation,
});