lasso without 'real' shape detection

This commit is contained in:
Ryan Di 2025-02-21 17:45:54 +11:00
parent 9ee0b8ffcb
commit 5cba71972e
19 changed files with 706 additions and 23 deletions

View file

@ -169,7 +169,7 @@ export default defineConfig(({ mode }) => {
}, },
], ],
start_url: "/", start_url: "/",
id:"excalidraw", id: "excalidraw",
display: "standalone", display: "standalone",
theme_color: "#121212", theme_color: "#121212",
background_color: "#ffffff", background_color: "#ffffff",

View file

@ -17,6 +17,7 @@ export interface Trail {
export interface AnimatedTrailOptions { export interface AnimatedTrailOptions {
fill: (trail: AnimatedTrail) => string; fill: (trail: AnimatedTrail) => string;
animateTrail?: boolean;
} }
export class AnimatedTrail implements Trail { export class AnimatedTrail implements Trail {
@ -25,16 +26,28 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement; private container?: SVGSVGElement;
private trailElement: SVGPathElement; private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement;
constructor( constructor(
private animationFrameHandler: AnimationFrameHandler, private animationFrameHandler: AnimationFrameHandler,
private app: App, protected app: App,
private options: Partial<LaserPointerOptions> & private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>, Partial<AnimatedTrailOptions>,
) { ) {
this.animationFrameHandler.register(this, this.onFrame.bind(this)); this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.trailElement = document.createElementNS(SVG_NS, "path"); this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
// TODO: make this configurable
this.trailAnimation.setAttribute("attributeName", "stroke-dashoffset");
this.trailElement.setAttribute("stroke-dasharray", "10 10");
this.trailElement.setAttribute("stroke-dashoffset", "10");
this.trailAnimation.setAttribute("from", "0");
this.trailAnimation.setAttribute("to", `-20`);
this.trailAnimation.setAttribute("dur", "0.2s");
this.trailElement.appendChild(this.trailAnimation);
}
} }
get hasCurrentTrail() { get hasCurrentTrail() {
@ -98,8 +111,23 @@ export class AnimatedTrail implements Trail {
} }
} }
getCurrentTrail() {
return this.currentTrail;
}
clearTrails() {
this.pastTrails = [];
this.currentTrail = undefined;
this.update();
}
private update() { private update() {
this.pastTrails = [];
this.start(); this.start();
if (this.trailAnimation) {
this.trailAnimation.setAttribute("begin", "indefinite");
this.trailAnimation.setAttribute("repeatCount", "indefinite");
}
} }
private onFrame() { private onFrame() {
@ -126,14 +154,22 @@ export class AnimatedTrail implements Trail {
const svgPaths = paths.join(" ").trim(); const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths); this.trailElement.setAttribute("d", svgPaths);
this.trailElement.setAttribute( if (this.trailAnimation) {
"fill", this.trailElement.setAttribute("fill", "transparent");
(this.options.fill ?? (() => "black"))(this), this.trailElement.setAttribute(
); "stroke",
(this.options.fill ?? (() => "black"))(this),
);
} else {
this.trailElement.setAttribute(
"fill",
(this.options.fill ?? (() => "black"))(this),
);
}
} }
private drawTrail(trail: LaserPointer, state: AppState): string { private drawTrail(trail: LaserPointer, state: AppState): string {
const stroke = trail const _stroke = trail
.getStrokeOutline(trail.options.size / state.zoom.value) .getStrokeOutline(trail.options.size / state.zoom.value)
.map(([x, y]) => { .map(([x, y]) => {
const result = sceneCoordsToViewportCoords( const result = sceneCoordsToViewportCoords(
@ -144,6 +180,10 @@ export class AnimatedTrail implements Trail {
return [result.x, result.y]; return [result.x, result.y];
}); });
const stroke = this.trailAnimation
? _stroke.slice(0, _stroke.length / 2)
: _stroke;
return getSvgPathFromStroke(stroke, true); return getSvgPathFromStroke(stroke, true);
} }
} }

View file

@ -88,6 +88,7 @@ export const getDefaultAppState = (): Omit<
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
selectionElement: null, selectionElement: null,
lassoSelection: null,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
stats: { stats: {
open: false, open: false,
@ -219,6 +220,7 @@ const APP_STATE_STORAGE_CONF = (<
server: false, server: false,
}, },
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
lassoSelection: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false }, stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },

View file

@ -47,6 +47,7 @@ import {
mermaidLogoIcon, mermaidLogoIcon,
laserPointerToolIcon, laserPointerToolIcon,
MagicIcon, MagicIcon,
LassoIcon,
} from "./icons"; } from "./icons";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
@ -69,7 +70,6 @@ export const canChangeStrokeColor = (
return ( return (
(hasStrokeColor(appState.activeTool.type) && (hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" && commonSelectedType !== "image" &&
commonSelectedType !== "frame" && commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") || commonSelectedType !== "magicframe") ||
@ -285,6 +285,8 @@ export const ShapesSwitcher = ({
const { TTDDialogTriggerTunnel } = useTunnels(); const { TTDDialogTriggerTunnel } = useTunnels();
const lasso = appState.activeTool.type === "lasso";
return ( return (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@ -302,13 +304,18 @@ export const ShapesSwitcher = ({
const shortcut = letter const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}` ? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`; : `${numericKey}`;
const _icon = value === "selection" && lasso ? LassoIcon : icon;
const _fillable = value === "selection" && lasso ? false : fillable;
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable })} className={clsx("Shape", { fillable: _fillable })}
key={value} key={value}
type="radio" type="radio"
icon={icon} icon={_icon}
checked={activeTool.type === value} checked={
activeTool.type === value || (lasso && value === "selection")
}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter} keyBindingLabel={numericKey || letter}
@ -319,6 +326,14 @@ export const ShapesSwitcher = ({
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true); app.togglePenMode(true);
} }
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
}
}
}} }}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) { if (appState.activeTool.type !== value) {

View file

@ -467,6 +467,7 @@ import {
getApproxMinLineHeight, getApproxMinLineHeight,
getMinTextElementWidth, getMinTextElementWidth,
} from "../element/textMeasurements"; } from "../element/textMeasurements";
import { LassoTrail } from "../lasso";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@ -636,6 +637,7 @@ class App extends React.Component<AppProps, AppState> {
? "rgba(0, 0, 0, 0.2)" ? "rgba(0, 0, 0, 0.2)"
: "rgba(255, 255, 255, 0.2)", : "rgba(255, 255, 255, 0.2)",
}); });
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
onChangeEmitter = new Emitter< onChangeEmitter = new Emitter<
[ [
@ -1613,7 +1615,11 @@ class App extends React.Component<AppProps, AppState> {
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" /> <div className="excalidraw-eye-dropper-container" />
<SVGLayer <SVGLayer
trails={[this.laserTrails, this.eraserTrail]} trails={[
this.laserTrails,
this.eraserTrail,
this.lassoTrail,
]}
/> />
{selectedElements.length === 1 && {selectedElements.length === 1 &&
this.state.openDialog?.name !== this.state.openDialog?.name !==
@ -4528,6 +4534,14 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (event.key === KEYS[1] && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "selection") {
this.setActiveTool({ type: "lasso" });
} else {
this.setActiveTool({ type: "selection" });
}
}
if ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@ -6516,6 +6530,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.penMode || !this.state.penMode ||
event.pointerType !== "touch" || event.pointerType !== "touch" ||
this.state.activeTool.type === "selection" || this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso" ||
this.state.activeTool.type === "text" || this.state.activeTool.type === "text" ||
this.state.activeTool.type === "image"; this.state.activeTool.type === "image";
@ -6523,7 +6538,12 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (this.state.activeTool.type === "text") { if (this.state.activeTool.type === "lasso") {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
} else if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState); this.handleTextOnPointerDown(event, pointerDownState);
} else if ( } else if (
this.state.activeTool.type === "arrow" || this.state.activeTool.type === "arrow" ||
@ -6587,10 +6607,18 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type !== "eraser" && this.state.activeTool.type !== "eraser" &&
this.state.activeTool.type !== "hand" this.state.activeTool.type !== "hand"
) { ) {
this.createGenericElementOnPointerDown( if (this.state.activeTool.type === "selection" && event.altKey) {
this.state.activeTool.type, this.setActiveTool({ type: "lasso" });
pointerDownState, this.lassoTrail.startPath(
); pointerDownState.origin.x,
pointerDownState.origin.y,
);
} else {
this.createGenericElementOnPointerDown(
this.state.activeTool.type,
pointerDownState,
);
}
} }
this.props?.onPointerDown?.(this.state.activeTool, pointerDownState); this.props?.onPointerDown?.(this.state.activeTool, pointerDownState);
@ -8495,6 +8523,8 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event); this.maybeDragNewGenericElement(pointerDownState, event);
} else if (this.state.activeTool.type === "lasso") {
this.lassoTrail.addPointToPath(pointerCoords.x, pointerCoords.y);
} else { } else {
// It is very important to read this.state within each move event, // It is very important to read this.state within each move event,
// otherwise we would read a stale one! // otherwise we would read a stale one!
@ -8749,6 +8779,8 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null, originSnapOffset: null,
})); }));
// just in case, tool changes mid drag, always clean up
this.lassoTrail.endPath();
this.lastPointerMoveCoords = null; this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null); SnapCache.setReferenceSnapPoints(null);

View file

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

View file

@ -192,6 +192,7 @@ const getRelevantAppStateProps = (
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId, pendingImageElementId: appState.pendingImageElementId,
selectionElement: appState.selectionElement, selectionElement: appState.selectionElement,
lassoSelection: appState.lassoSelection,
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
selectedLinearElement: appState.selectedLinearElement, selectedLinearElement: appState.selectedLinearElement,
multiElement: appState.multiElement, multiElement: appState.multiElement,

View file

@ -273,6 +273,16 @@ export const SelectionIcon = createIcon(
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 }, { fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
); );
export const LassoIcon = createIcon(
<g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<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 // tabler-icons: square
export const RectangleIcon = createIcon( export const RectangleIcon = createIcon(
<g strokeWidth="1.5"> <g strokeWidth="1.5">

View file

@ -417,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites // use these constants to easily identify reference sites
export const TOOL_TYPE = { export const TOOL_TYPE = {
selection: "selection", selection: "selection",
lasso: "lasso",
rectangle: "rectangle", rectangle: "rectangle",
diamond: "diamond", diamond: "diamond",
ellipse: "ellipse", ellipse: "ellipse",

View file

@ -71,6 +71,7 @@ export const AllowedExcalidrawActiveTools: Record<
boolean boolean
> = { > = {
selection: true, selection: true,
lasso: true,
text: true, text: true,
rectangle: true, rectangle: true,
diamond: true, diamond: true,

View file

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

View file

@ -0,0 +1,134 @@
import { GlobalPoint, pointFrom } from "../../math";
import { AnimatedTrail } from "../animated-trail";
import { AnimationFrameHandler } from "../animation-frame-handler";
import App from "../components/App";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { getFrameChildren } from "../frame";
import { selectGroupsForSelectedElements } from "../groups";
import { easeOut } from "../utils";
import { LassoWorkerInput, LassoWorkerOutput } from "./worker";
export class LassoTrail extends AnimatedTrail {
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
private worker: Worker | null = null;
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(0,118,255)",
});
}
startPath(x: number, y: number) {
super.startPath(x, y);
this.intersectedElements.clear();
this.enclosedElements.clear();
this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
this.worker.onmessage = (event: MessageEvent<LassoWorkerOutput>) => {
const { selectedElementIds } = event.data;
this.app.setState((prevState) => {
const nextSelectedElementIds = selectedElementIds.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>);
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,
);
return {
selectedElementIds: nextSelection.selectedElementIds,
selectedGroupIds: nextSelection.selectedGroupIds,
};
});
};
this.worker.onerror = (error) => {
console.error("Worker error:", error);
};
}
addPointToPath = (x: number, y: number) => {
super.addPointToPath(x, y);
this.app.setState({
lassoSelection: {
points:
(this.getCurrentTrail()?.originalPoints?.map((p) =>
pointFrom<GlobalPoint>(p[0], p[1]),
) as readonly GlobalPoint[]) ?? null,
},
});
this.updateSelection();
};
private updateSelection = () => {
const lassoPath = super
.getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1]));
if (lassoPath) {
const message: LassoWorkerInput = {
lassoPath,
elements: this.app.visibleElements,
intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements,
};
this.worker?.postMessage(message);
}
};
endPath(): void {
super.endPath();
super.clearTrails();
this.intersectedElements.clear();
this.enclosedElements.clear();
this.app.setState({
lassoSelection: null,
});
this.worker?.terminate();
}
}

View file

@ -0,0 +1,393 @@
import {
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "../../math/types";
import { pointFrom, pointRotateRads } from "../../math/point";
import { polygonFromPoints } from "../../math/polygon";
import { ElementsMap, ExcalidrawElement } from "../element/types";
import { pointsOnBezierCurves, simplify } from "points-on-curve";
import { lineSegment } from "../../math/segment";
import throttle from "lodash.throttle";
import { RoughGenerator } from "roughjs/bin/generator";
import { Point } from "roughjs/bin/geometry";
import { Drawable, Op } from "roughjs/bin/core";
// variables to track processing state and latest input data
// for "backpressure" purposes
let isProcessing: boolean = false;
let latestInputData: LassoWorkerInput | null = null;
self.onmessage = (event: MessageEvent<LassoWorkerInput>) => {
if (!event.data) {
self.postMessage({
error: "No data received",
selectedElementIds: [],
});
return;
}
latestInputData = event.data;
if (!isProcessing) {
processInputData();
}
};
// function to process the latest data
const processInputData = () => {
// If no data to process, return
if (!latestInputData) return;
// capture the current data to process and reset latestData
const dataToProcess = latestInputData;
latestInputData = null; // reset to avoid re-processing the same data
isProcessing = true;
try {
const { lassoPath, elements, intersectedElements, enclosedElements } =
dataToProcess;
if (!Array.isArray(lassoPath) || !Array.isArray(elements)) {
throw new Error("Invalid input: lassoPath and elements must be arrays");
}
if (
!(intersectedElements instanceof Set) ||
!(enclosedElements instanceof Set)
) {
throw new Error(
"Invalid input: intersectedElements and enclosedElements must be Sets",
);
}
const result = updateSelection(dataToProcess);
self.postMessage(result);
} catch (error) {
self.postMessage({
error: error instanceof Error ? error.message : "Unknown error occurred",
selectedElementIds: [],
});
} finally {
isProcessing = false;
// if new data arrived during processing, process it
// as we're done with processing the previous data
if (latestInputData) {
processInputData();
}
}
};
export type LassoWorkerInput = {
lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[];
intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
};
export type LassoWorkerOutput = {
selectedElementIds: string[];
};
export const updateSelection = throttle(
(input: LassoWorkerInput): LassoWorkerOutput => {
const { lassoPath, elements, intersectedElements, enclosedElements } =
input;
const elementsMap = arrayToMap(elements);
// simplify the path to reduce the number of points
const simplifiedPath = simplify(lassoPath, 0.75) as GlobalPoint[];
// close the path to form a polygon for enclosure check
const closedPath = polygonFromPoints(simplifiedPath);
// as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear();
for (const [, element] of elementsMap) {
if (
!intersectedElements.has(element.id) &&
!enclosedElements.has(element.id)
) {
const enclosed = enclosureTest(closedPath, element, elementsMap);
if (enclosed) {
enclosedElements.add(element.id);
} else {
const intersects = intersectionTest(closedPath, element, elementsMap);
if (intersects) {
intersectedElements.add(element.id);
}
}
}
}
const results = [...intersectedElements, ...enclosedElements];
return {
selectedElementIds: results,
};
},
100,
);
const enclosureTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const lassoPolygon = polygonFromPoints(lassoPath);
const segments = getElementLineSegments(element, elementsMap);
return segments.some((segment) => {
return segment.some((point) => isPointInPolygon(point, lassoPolygon));
});
};
// // Helper function to check if a point is inside a polygon
const isPointInPolygon = (
point: GlobalPoint,
polygon: GlobalPoint[],
): boolean => {
let isInside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i][0],
yi = polygon[i][1];
const xj = polygon[j][0],
yj = polygon[j][1];
const intersect =
yi > point[1] !== yj > point[1] &&
point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi;
if (intersect) isInside = !isInside;
}
return isInside;
};
const intersectionTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const elementSegments = getElementLineSegments(element, elementsMap);
const lassoSegments = lassoPath.reduce((acc, point, index) => {
if (index === 0) return acc;
acc.push([lassoPath[index - 1], point] as [GlobalPoint, GlobalPoint]);
return acc;
}, [] as [GlobalPoint, GlobalPoint][]);
return lassoSegments.some((lassoSegment) =>
elementSegments.some((elementSegment) =>
doLineSegmentsIntersect(lassoSegment, elementSegment),
),
);
};
// Helper function to check if two line segments intersect
const doLineSegmentsIntersect = (
[p1, p2]: [GlobalPoint, GlobalPoint],
[p3, p4]: [GlobalPoint, GlobalPoint],
): boolean => {
const denominator =
(p4[1] - p3[1]) * (p2[0] - p1[0]) - (p4[0] - p3[0]) * (p2[1] - p1[1]);
if (denominator === 0) return false;
const ua =
((p4[0] - p3[0]) * (p1[1] - p3[1]) - (p4[1] - p3[1]) * (p1[0] - p3[0])) /
denominator;
const ub =
((p2[0] - p1[0]) * (p1[1] - p3[1]) - (p2[1] - p1[1]) * (p1[0] - p3[0])) /
denominator;
return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1;
};
const getCurvePathOps = (shape: Drawable): Op[] => {
for (const set of shape.sets) {
if (set.type === "path") {
return set.ops;
}
}
return shape.sets[0].ops;
};
const getElementLineSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): LineSegment<GlobalPoint>[] => {
const [x1, y1, x2, y2, cx, cy] = [
element.x,
element.y,
element.x + element.width,
element.y + element.height,
element.x + element.width / 2,
element.y + element.height / 2,
];
const center: GlobalPoint = pointFrom(cx, cy);
if (
element.type === "line" ||
element.type === "arrow" ||
element.type === "freedraw"
) {
const segments: LineSegment<GlobalPoint>[] = [];
const getPointsOnCurve = () => {
const generator = new RoughGenerator();
const drawable = generator.curve(element.points as unknown as Point[]);
const ops = getCurvePathOps(drawable);
const _points: LocalPoint[] = [];
// let odd = false;
// for (const operation of ops) {
// if (operation.op === "move") {
// odd = !odd;
// if (odd) {
// if (
// Array.isArray(operation.data) &&
// operation.data.length >= 2 &&
// operation.data.every(
// (d) => d !== undefined && typeof d === "number",
// )
// ) {
// _points.push(pointFrom(operation.data[0], operation.data[1]));
// }
// }
// } else if (operation.op === "bcurveTo") {
// if (odd) {
// if (
// Array.isArray(operation.data) &&
// operation.data.length === 6 &&
// operation.data.every(
// (d) => d !== undefined && typeof d === "number",
// )
// ) {
// _points.push(pointFrom(operation.data[0], operation.data[1]));
// _points.push(pointFrom(operation.data[2], operation.data[3]));
// _points.push(pointFrom(operation.data[4], operation.data[5]));
// }
// }
// } else if (operation.op === "lineTo") {
// if (
// Array.isArray(operation.data) &&
// operation.data.length >= 2 &&
// odd &&
// operation.data.every(
// (d) => d !== undefined && typeof d === "number",
// )
// ) {
// _points.push(pointFrom(operation.data[0], operation.data[1]));
// }
// }
// }
return pointsOnBezierCurves(_points, 10, 5);
};
let i = 0;
// const points =
// element.roughness !== 0 && element.type !== "freedraw"
// ? getPointsOnCurve()
// : element.points;
const points = element.points;
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,
),
),
);
i++;
}
return segments;
}
const [nw, ne, sw, se, n, s, w, e] = (
[
[x1, y1],
[x2, y1],
[x1, y2],
[x2, y2],
[cx, y1],
[cx, y2],
[x1, cy],
[x2, cy],
] 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),
];
}
if (element.type === "frame" || element.type === "magicframe") {
return [
lineSegment(nw, ne),
lineSegment(ne, se),
lineSegment(se, sw),
lineSegment(sw, nw),
];
}
return [
lineSegment(nw, ne),
lineSegment(sw, se),
lineSegment(nw, sw),
lineSegment(ne, se),
lineSegment(nw, e),
lineSegment(sw, e),
lineSegment(ne, w),
lineSegment(se, w),
];
};
// This is a copy of arrayToMap from utils.ts
// copy to avoid accessing DOM related things in worker
const arrayToMap = <T extends { id: string } | string>(
items: readonly T[] | Map<string, T>,
) => {
if (items instanceof Map) {
return items;
}
return items.reduce((acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element);
return acc;
}, new Map());
};

View file

@ -276,6 +276,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Selection", "selection": "Selection",
"lasso": "Lasso",
"image": "Insert image", "image": "Insert image",
"rectangle": "Rectangle", "rectangle": "Rectangle",
"diamond": "Diamond", "diamond": "Diamond",
@ -341,6 +342,7 @@
"bindTextToElement": "Press enter to add text", "bindTextToElement": "Press enter to add text",
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart", "createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging", "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
"lassoSelect": "Hold Alt, or click on the selection again, to lasso select",
"eraserRevert": "Hold Alt to revert the elements marked for deletion", "eraserRevert": "Hold Alt to revert the elements marked for deletion",
"firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.", "firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
"disableSnapping": "Hold CtrlOrCmd to disable snapping", "disableSnapping": "Hold CtrlOrCmd to disable snapping",

View file

@ -13,7 +13,10 @@ import {
SCROLLBAR_WIDTH, SCROLLBAR_WIDTH,
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { renderSelectionElement } from "../renderer/renderElement"; import {
renderLassoSelection,
renderSelectionElement,
} from "../renderer/renderElement";
import { getClientColor, renderRemoteCursors } from "../clients"; import { getClientColor, renderRemoteCursors } from "../clients";
import { import {
isSelectedViaGroup, isSelectedViaGroup,
@ -826,6 +829,15 @@ const _renderInteractiveScene = ({
} }
} }
if (appState.lassoSelection) {
renderLassoSelection(
appState.lassoSelection,
context,
appState,
renderConfig.selectionColor,
);
}
if ( if (
appState.editingTextElement && appState.editingTextElement &&
isTextElement(appState.editingTextElement) isTextElement(appState.editingTextElement)

View file

@ -60,7 +60,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame"; import { getContainingFrame } from "../frame";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { getVerticalOffset } from "../fonts"; import { getVerticalOffset } from "../fonts";
import { isRightAngleRads } from "../../math"; import { GlobalPoint, isRightAngleRads } from "../../math";
import { getCornerRadius } from "../shapes"; import { getCornerRadius } from "../shapes";
import { getUncroppedImageElement } from "../element/cropElement"; import { getUncroppedImageElement } from "../element/cropElement";
import { getLineHeightInPx } from "../element/textMeasurements"; import { getLineHeightInPx } from "../element/textMeasurements";
@ -695,6 +695,33 @@ export const renderSelectionElement = (
context.restore(); context.restore();
}; };
export const renderLassoSelection = (
lassoPath: AppState["lassoSelection"],
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => {
if (!lassoPath || lassoPath.points.length < 2) {
return;
}
context.save();
context.translate(appState.scrollX, appState.scrollY);
context.beginPath();
for (const point of lassoPath.points) {
context.lineTo(point[0], point[1]);
}
context.closePath();
context.globalAlpha = 0.05;
context.fillStyle = selectionColor;
context.fill();
context.restore();
};
export const renderElement = ( export const renderElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap, elementsMap: RenderableElementsMap,

View file

@ -10,7 +10,10 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame" && type !== "magicframe"; type !== "image" &&
type !== "frame" &&
type !== "magicframe" &&
type !== "lasso";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||

View file

@ -394,7 +394,10 @@ const proxy = <T extends ExcalidrawElement>(
}; };
/** Tools that can be used to draw shapes */ /** Tools that can be used to draw shapes */
type DrawingToolName = Exclude<ToolType, "lock" | "selection" | "eraser">; type DrawingToolName = Exclude<
ToolType,
"lock" | "selection" | "eraser" | "lasso"
>;
type Element<T extends DrawingToolName> = T extends "line" | "freedraw" type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
? ExcalidrawLinearElement ? ExcalidrawLinearElement

View file

@ -41,6 +41,7 @@ import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping"; import type { SnapLine } from "./snapping";
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type { StoreActionType } from "./store"; import type { StoreActionType } from "./store";
import { GlobalPoint } from "../math";
export type SocketId = string & { _brand: "SocketId" }; export type SocketId = string & { _brand: "SocketId" };
@ -119,6 +120,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type ToolType = export type ToolType =
| "selection" | "selection"
| "lasso"
| "rectangle" | "rectangle"
| "diamond" | "diamond"
| "ellipse" | "ellipse"
@ -194,6 +196,7 @@ export type InteractiveCanvasAppState = Readonly<
activeEmbeddable: AppState["activeEmbeddable"]; activeEmbeddable: AppState["activeEmbeddable"];
editingLinearElement: AppState["editingLinearElement"]; editingLinearElement: AppState["editingLinearElement"];
selectionElement: AppState["selectionElement"]; selectionElement: AppState["selectionElement"];
lassoSelection: AppState["lassoSelection"];
selectedGroupIds: AppState["selectedGroupIds"]; selectedGroupIds: AppState["selectedGroupIds"];
selectedLinearElement: AppState["selectedLinearElement"]; selectedLinearElement: AppState["selectedLinearElement"];
multiElement: AppState["multiElement"]; multiElement: AppState["multiElement"];
@ -267,6 +270,9 @@ export interface AppState {
* - set on pointer down, updated during pointer move * - set on pointer down, updated during pointer move
*/ */
selectionElement: NonDeletedExcalidrawElement | null; selectionElement: NonDeletedExcalidrawElement | null;
lassoSelection: {
points: readonly GlobalPoint[];
} | null;
isBindingEnabled: boolean; isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null; startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBindings: SuggestedBinding[]; suggestedBindings: SuggestedBinding[];