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
|
@ -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);
|
||||||
|
if (this.trailAnimation) {
|
||||||
|
this.trailElement.setAttribute("fill", "transparent");
|
||||||
|
this.trailElement.setAttribute(
|
||||||
|
"stroke",
|
||||||
|
(this.options.fill ?? (() => "black"))(this),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
this.trailElement.setAttribute(
|
this.trailElement.setAttribute(
|
||||||
"fill",
|
"fill",
|
||||||
(this.options.fill ?? (() => "black"))(this),
|
(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,11 +6607,19 @@ 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"
|
||||||
) {
|
) {
|
||||||
|
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.createGenericElementOnPointerDown(
|
||||||
this.state.activeTool.type,
|
this.state.activeTool.type,
|
||||||
pointerDownState,
|
pointerDownState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.props?.onPointerDown?.(this.state.activeTool, pointerDownState);
|
this.props?.onPointerDown?.(this.state.activeTool, pointerDownState);
|
||||||
this.onPointerDownEmitter.trigger(
|
this.onPointerDownEmitter.trigger(
|
||||||
|
@ -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);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"))) ||
|
||||||
|
|
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": {
|
"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",
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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" ||
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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[];
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue