mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
lasso without 'real' shape detection
This commit is contained in:
parent
9ee0b8ffcb
commit
5cba71972e
19 changed files with 706 additions and 23 deletions
|
@ -169,7 +169,7 @@ export default defineConfig(({ mode }) => {
|
|||
},
|
||||
],
|
||||
start_url: "/",
|
||||
id:"excalidraw",
|
||||
id: "excalidraw",
|
||||
display: "standalone",
|
||||
theme_color: "#121212",
|
||||
background_color: "#ffffff",
|
||||
|
|
|
@ -17,6 +17,7 @@ export interface Trail {
|
|||
|
||||
export interface AnimatedTrailOptions {
|
||||
fill: (trail: AnimatedTrail) => string;
|
||||
animateTrail?: boolean;
|
||||
}
|
||||
|
||||
export class AnimatedTrail implements Trail {
|
||||
|
@ -25,16 +26,28 @@ export class AnimatedTrail implements Trail {
|
|||
|
||||
private container?: SVGSVGElement;
|
||||
private trailElement: SVGPathElement;
|
||||
private trailAnimation?: SVGAnimateElement;
|
||||
|
||||
constructor(
|
||||
private animationFrameHandler: AnimationFrameHandler,
|
||||
private app: App,
|
||||
protected app: App,
|
||||
private options: Partial<LaserPointerOptions> &
|
||||
Partial<AnimatedTrailOptions>,
|
||||
) {
|
||||
this.animationFrameHandler.register(this, this.onFrame.bind(this));
|
||||
|
||||
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() {
|
||||
|
@ -98,8 +111,23 @@ export class AnimatedTrail implements Trail {
|
|||
}
|
||||
}
|
||||
|
||||
getCurrentTrail() {
|
||||
return this.currentTrail;
|
||||
}
|
||||
|
||||
clearTrails() {
|
||||
this.pastTrails = [];
|
||||
this.currentTrail = undefined;
|
||||
this.update();
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.pastTrails = [];
|
||||
this.start();
|
||||
if (this.trailAnimation) {
|
||||
this.trailAnimation.setAttribute("begin", "indefinite");
|
||||
this.trailAnimation.setAttribute("repeatCount", "indefinite");
|
||||
}
|
||||
}
|
||||
|
||||
private onFrame() {
|
||||
|
@ -126,14 +154,22 @@ export class AnimatedTrail implements Trail {
|
|||
const svgPaths = paths.join(" ").trim();
|
||||
|
||||
this.trailElement.setAttribute("d", svgPaths);
|
||||
this.trailElement.setAttribute(
|
||||
"fill",
|
||||
(this.options.fill ?? (() => "black"))(this),
|
||||
);
|
||||
if (this.trailAnimation) {
|
||||
this.trailElement.setAttribute("fill", "transparent");
|
||||
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 {
|
||||
const stroke = trail
|
||||
const _stroke = trail
|
||||
.getStrokeOutline(trail.options.size / state.zoom.value)
|
||||
.map(([x, y]) => {
|
||||
const result = sceneCoordsToViewportCoords(
|
||||
|
@ -144,6 +180,10 @@ export class AnimatedTrail implements Trail {
|
|||
return [result.x, result.y];
|
||||
});
|
||||
|
||||
const stroke = this.trailAnimation
|
||||
? _stroke.slice(0, _stroke.length / 2)
|
||||
: _stroke;
|
||||
|
||||
return getSvgPathFromStroke(stroke, true);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,6 +88,7 @@ export const getDefaultAppState = (): Omit<
|
|||
selectedGroupIds: {},
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
lassoSelection: null,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
stats: {
|
||||
open: false,
|
||||
|
@ -219,6 +220,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
server: false,
|
||||
},
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
lassoSelection: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
stats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
|
|
|
@ -47,6 +47,7 @@ import {
|
|||
mermaidLogoIcon,
|
||||
laserPointerToolIcon,
|
||||
MagicIcon,
|
||||
LassoIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
@ -69,7 +70,6 @@ export const canChangeStrokeColor = (
|
|||
|
||||
return (
|
||||
(hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image" &&
|
||||
commonSelectedType !== "frame" &&
|
||||
commonSelectedType !== "magicframe") ||
|
||||
|
@ -285,6 +285,8 @@ export const ShapesSwitcher = ({
|
|||
|
||||
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||
|
||||
const lasso = appState.activeTool.type === "lasso";
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
|
@ -302,13 +304,18 @@ export const ShapesSwitcher = ({
|
|||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
|
||||
const _icon = value === "selection" && lasso ? LassoIcon : icon;
|
||||
const _fillable = value === "selection" && lasso ? false : fillable;
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
className={clsx("Shape", { fillable: _fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
icon={_icon}
|
||||
checked={
|
||||
activeTool.type === value || (lasso && value === "selection")
|
||||
}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
|
@ -319,6 +326,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) {
|
||||
|
|
|
@ -467,6 +467,7 @@ import {
|
|||
getApproxMinLineHeight,
|
||||
getMinTextElementWidth,
|
||||
} from "../element/textMeasurements";
|
||||
import { LassoTrail } from "../lasso";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(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(255, 255, 255, 0.2)",
|
||||
});
|
||||
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
||||
|
||||
onChangeEmitter = new Emitter<
|
||||
[
|
||||
|
@ -1613,7 +1615,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 !==
|
||||
|
@ -4528,6 +4534,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
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 (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||
|
@ -6516,6 +6530,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";
|
||||
|
||||
|
@ -6523,7 +6538,12 @@ 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,
|
||||
);
|
||||
} else if (this.state.activeTool.type === "text") {
|
||||
this.handleTextOnPointerDown(event, pointerDownState);
|
||||
} else if (
|
||||
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 !== "hand"
|
||||
) {
|
||||
this.createGenericElementOnPointerDown(
|
||||
this.state.activeTool.type,
|
||||
pointerDownState,
|
||||
);
|
||||
if (this.state.activeTool.type === "selection" && event.altKey) {
|
||||
this.setActiveTool({ type: "lasso" });
|
||||
this.lassoTrail.startPath(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
} else {
|
||||
this.createGenericElementOnPointerDown(
|
||||
this.state.activeTool.type,
|
||||
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.y = pointerCoords.y;
|
||||
this.maybeDragNewGenericElement(pointerDownState, event);
|
||||
} else if (this.state.activeTool.type === "lasso") {
|
||||
this.lassoTrail.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||
} else {
|
||||
// It is very important to read this.state within each move event,
|
||||
// otherwise we would read a stale one!
|
||||
|
@ -8749,6 +8779,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);
|
||||
|
|
|
@ -115,7 +115,7 @@ const getHints = ({
|
|||
!appState.editingTextElement &&
|
||||
!appState.editingLinearElement
|
||||
) {
|
||||
return t("hints.deepBoxSelect");
|
||||
return [t("hints.deepBoxSelect")];
|
||||
}
|
||||
|
||||
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
||||
|
@ -123,7 +123,7 @@ const getHints = ({
|
|||
}
|
||||
|
||||
if (!selectedElements.length && !isMobile) {
|
||||
return t("hints.canvasPanning");
|
||||
return [t("hints.canvasPanning"), t("hints.lassoSelect")];
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1) {
|
||||
|
|
|
@ -192,6 +192,7 @@ const getRelevantAppStateProps = (
|
|||
theme: appState.theme,
|
||||
pendingImageElementId: appState.pendingImageElementId,
|
||||
selectionElement: appState.selectionElement,
|
||||
lassoSelection: appState.lassoSelection,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
selectedLinearElement: appState.selectedLinearElement,
|
||||
multiElement: appState.multiElement,
|
||||
|
|
|
@ -273,6 +273,16 @@ export const SelectionIcon = createIcon(
|
|||
{ 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
|
||||
export const RectangleIcon = createIcon(
|
||||
<g strokeWidth="1.5">
|
||||
|
|
|
@ -417,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
|
|||
// use these constants to easily identify reference sites
|
||||
export const TOOL_TYPE = {
|
||||
selection: "selection",
|
||||
lasso: "lasso",
|
||||
rectangle: "rectangle",
|
||||
diamond: "diamond",
|
||||
ellipse: "ellipse",
|
||||
|
|
|
@ -71,6 +71,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||
boolean
|
||||
> = {
|
||||
selection: true,
|
||||
lasso: true,
|
||||
text: true,
|
||||
rectangle: true,
|
||||
diamond: true,
|
||||
|
|
|
@ -12,6 +12,7 @@ export const showSelectedShapeActions = (
|
|||
((appState.activeTool.type !== "custom" &&
|
||||
(appState.editingTextElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "lasso" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand" &&
|
||||
appState.activeTool.type !== "laser"))) ||
|
||||
|
|
134
packages/excalidraw/lasso/index.ts
Normal file
134
packages/excalidraw/lasso/index.ts
Normal 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();
|
||||
}
|
||||
}
|
393
packages/excalidraw/lasso/worker.ts
Normal file
393
packages/excalidraw/lasso/worker.ts
Normal 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());
|
||||
};
|
|
@ -276,6 +276,7 @@
|
|||
},
|
||||
"toolBar": {
|
||||
"selection": "Selection",
|
||||
"lasso": "Lasso",
|
||||
"image": "Insert image",
|
||||
"rectangle": "Rectangle",
|
||||
"diamond": "Diamond",
|
||||
|
@ -341,6 +342,7 @@
|
|||
"bindTextToElement": "Press enter to add text",
|
||||
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",
|
||||
"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",
|
||||
"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",
|
||||
|
|
|
@ -13,7 +13,10 @@ import {
|
|||
SCROLLBAR_WIDTH,
|
||||
} from "../scene/scrollbars";
|
||||
|
||||
import { renderSelectionElement } from "../renderer/renderElement";
|
||||
import {
|
||||
renderLassoSelection,
|
||||
renderSelectionElement,
|
||||
} from "../renderer/renderElement";
|
||||
import { getClientColor, renderRemoteCursors } from "../clients";
|
||||
import {
|
||||
isSelectedViaGroup,
|
||||
|
@ -826,6 +829,15 @@ const _renderInteractiveScene = ({
|
|||
}
|
||||
}
|
||||
|
||||
if (appState.lassoSelection) {
|
||||
renderLassoSelection(
|
||||
appState.lassoSelection,
|
||||
context,
|
||||
appState,
|
||||
renderConfig.selectionColor,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
appState.editingTextElement &&
|
||||
isTextElement(appState.editingTextElement)
|
||||
|
|
|
@ -60,7 +60,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
|||
import { getContainingFrame } from "../frame";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { getVerticalOffset } from "../fonts";
|
||||
import { isRightAngleRads } from "../../math";
|
||||
import { GlobalPoint, isRightAngleRads } from "../../math";
|
||||
import { getCornerRadius } from "../shapes";
|
||||
import { getUncroppedImageElement } from "../element/cropElement";
|
||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||
|
@ -695,6 +695,33 @@ export const renderSelectionElement = (
|
|||
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 = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: RenderableElementsMap,
|
||||
|
|
|
@ -10,7 +10,10 @@ export const hasBackground = (type: ElementOrToolType) =>
|
|||
type === "freedraw";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
||||
type !== "image" &&
|
||||
type !== "frame" &&
|
||||
type !== "magicframe" &&
|
||||
type !== "lasso";
|
||||
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
|
|
|
@ -394,7 +394,10 @@ const proxy = <T extends ExcalidrawElement>(
|
|||
};
|
||||
|
||||
/** 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"
|
||||
? ExcalidrawLinearElement
|
||||
|
|
|
@ -41,6 +41,7 @@ import type { ContextMenuItems } from "./components/ContextMenu";
|
|||
import type { SnapLine } from "./snapping";
|
||||
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
|
||||
import type { StoreActionType } from "./store";
|
||||
import { GlobalPoint } from "../math";
|
||||
|
||||
export type SocketId = string & { _brand: "SocketId" };
|
||||
|
||||
|
@ -119,6 +120,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
|
|||
|
||||
export type ToolType =
|
||||
| "selection"
|
||||
| "lasso"
|
||||
| "rectangle"
|
||||
| "diamond"
|
||||
| "ellipse"
|
||||
|
@ -194,6 +196,7 @@ export type InteractiveCanvasAppState = Readonly<
|
|||
activeEmbeddable: AppState["activeEmbeddable"];
|
||||
editingLinearElement: AppState["editingLinearElement"];
|
||||
selectionElement: AppState["selectionElement"];
|
||||
lassoSelection: AppState["lassoSelection"];
|
||||
selectedGroupIds: AppState["selectedGroupIds"];
|
||||
selectedLinearElement: AppState["selectedLinearElement"];
|
||||
multiElement: AppState["multiElement"];
|
||||
|
@ -267,6 +270,9 @@ export interface AppState {
|
|||
* - set on pointer down, updated during pointer move
|
||||
*/
|
||||
selectionElement: NonDeletedExcalidrawElement | null;
|
||||
lassoSelection: {
|
||||
points: readonly GlobalPoint[];
|
||||
} | null;
|
||||
isBindingEnabled: boolean;
|
||||
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
|
||||
suggestedBindings: SuggestedBinding[];
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue