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

@ -62,6 +62,7 @@ import {
mermaidLogoIcon,
laserPointerToolIcon,
MagicIcon,
LassoIcon,
} from "./icons";
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
@ -83,7 +84,6 @@ export const canChangeStrokeColor = (
return (
(hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
@ -295,6 +295,8 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = activeTool.type === "lasso";
const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
@ -316,6 +318,7 @@ export const ShapesSwitcher = ({
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
@ -333,6 +336,14 @@ export const ShapesSwitcher = ({
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
}
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
@ -358,6 +369,7 @@ export const ShapesSwitcher = ({
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
lassoToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
@ -366,7 +378,15 @@ export const ShapesSwitcher = ({
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
{frameToolSelected
? frameToolIcon
: embeddableToolSelected
? EmbedIcon
: laserToolSelected && !app.props.isCollaborating
? laserPointerToolIcon
: lassoToolSelected
? LassoIcon
: extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
@ -399,6 +419,14 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>

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,
});

View file

@ -315,6 +315,7 @@ function CommandPaletteInner({
const toolCommands: CommandPaletteItem[] = [
actionManager.actions.toggleHandTool,
actionManager.actions.setFrameAsActiveTool,
actionManager.actions.toggleLassoTool,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools));
const editorCommands: CommandPaletteItem[] = [

View file

@ -120,7 +120,7 @@ const getHints = ({
!appState.editingTextElement &&
!appState.editingLinearElement
) {
return t("hints.deepBoxSelect");
return [t("hints.deepBoxSelect")];
}
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
@ -128,7 +128,7 @@ const getHints = ({
}
if (!selectedElements.length && !isMobile) {
return t("hints.canvasPanning");
return [t("hints.canvasPanning")];
}
if (selectedElements.length === 1) {

View file

@ -87,34 +87,36 @@ const StaticCanvas = (props: StaticCanvasProps) => {
return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
};
const getRelevantAppStateProps = (
appState: AppState,
): StaticCanvasAppState => ({
zoom: appState.zoom,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
hoveredElementIds: appState.hoveredElementIds,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
});
const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
const relevantAppStateProps = {
zoom: appState.zoom,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
hoveredElementIds: appState.hoveredElementIds,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
};
return relevantAppStateProps;
};
const areEqual = (
prevProps: StaticCanvasProps,

View file

@ -274,6 +274,21 @@ export const SelectionIcon = createIcon(
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
);
export const LassoIcon = createIcon(
<g
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
>
<path d="M4.028 13.252c-.657 -.972 -1.028 -2.078 -1.028 -3.252c0 -3.866 4.03 -7 9 -7s9 3.134 9 7s-4.03 7 -9 7c-1.913 0 -3.686 -.464 -5.144 -1.255" />
<path d="M5 15m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 17c0 1.42 .316 2.805 1 4" />
</g>,
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
);
// tabler-icons: square
export const RectangleIcon = createIcon(
<g strokeWidth="1.5">
@ -406,7 +421,7 @@ export const TrashIcon = createIcon(
);
export const EmbedIcon = createIcon(
<g strokeWidth="1.25">
<g strokeWidth="1.5">
<polyline points="12 16 18 10 12 4" />
<polyline points="8 4 2 10 8 16" />
</g>,