feat: lasso selection (#9169)

* lasso without 'real' shape detection

* select a single linear el

* improve ux

* feed segments to worker

* simplify path threshold adaptive to zoom

* add a tiny threshold for checks

* refactor code

* lasso tests

* fix: ts

* do not capture lasso tool

* try worker-loader in next config

* update config

* refactor

* lint

* feat: show active tool when using "more tools"

* keep lasso if selected from toolbar

* fix incorrect checks for resetting to selection

* shift for additive selection

* bound text related fixes

* lint

* keep alt toggled lasso selection if shift pressed

* fix regression

* fix 'dead' lassos

* lint

* use workerpool and polyfill

* fix worker bundled with window related code

* refactor

* add file extension for worker constructor error

* another attempt at constructor error

* attempt at build issue

* attempt with dynamic import

* test not importing from math

* narrow down imports

* Reusing existing workers infrastructure (fallback to the main thread, type-safety)

* Points on curve inside the shared chunk

* Give up on experimental code splitting

* Remove potentially unnecessary optimisation

* Removing workers as the complexit is much worse, while perf. does not seem to be much better

* fix selecting text containers and containing frames together

* render fill directly from animated trail

* do not re-render static when setting selected element ids in lasso

* remove unnecessary property

* tweak trail animation

* slice points to remove notch

* always start alt-lasso from initial point

* revert build & worker changes (unused)

* remove `lasso` from `hasStrokeColor`

* label change

* remove unused props

* remove unsafe optimization

* snaps

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
This commit is contained in:
Ryan Di 2025-04-07 16:44:25 +10:00 committed by GitHub
parent 6e47fadb59
commit ce267aa0d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2709 additions and 146 deletions

View file

@ -15,7 +15,8 @@
"scripts": {
"start": "vite",
"build": "vite build",
"build:preview": "yarn build && vite preview --port 5002",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
}
}

View file

@ -25,7 +25,10 @@ export default defineConfig(({ mode }) => {
alias: [
{
find: /^@excalidraw\/common$/,
replacement: path.resolve(__dirname, "../packages/common/src/index.ts"),
replacement: path.resolve(
__dirname,
"../packages/common/src/index.ts",
),
},
{
find: /^@excalidraw\/common\/(.*?)/,
@ -33,7 +36,10 @@ export default defineConfig(({ mode }) => {
},
{
find: /^@excalidraw\/element$/,
replacement: path.resolve(__dirname, "../packages/element/src/index.ts"),
replacement: path.resolve(
__dirname,
"../packages/element/src/index.ts",
),
},
{
find: /^@excalidraw\/element\/(.*?)/,
@ -41,7 +47,10 @@ export default defineConfig(({ mode }) => {
},
{
find: /^@excalidraw\/excalidraw$/,
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"),
replacement: path.resolve(
__dirname,
"../packages/excalidraw/index.tsx",
),
},
{
find: /^@excalidraw\/excalidraw\/(.*?)/,
@ -57,7 +66,10 @@ export default defineConfig(({ mode }) => {
},
{
find: /^@excalidraw\/utils$/,
replacement: path.resolve(__dirname, "../packages/utils/src/index.ts"),
replacement: path.resolve(
__dirname,
"../packages/utils/src/index.ts",
),
},
{
find: /^@excalidraw\/utils\/(.*?)/,
@ -213,7 +225,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id:"excalidraw",
id: "excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",

View file

@ -419,6 +419,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",

View file

@ -385,7 +385,7 @@ export const updateActiveTool = (
type: ToolType;
}
| { type: "custom"; customType: string }
) & { locked?: boolean }) & {
) & { locked?: boolean; fromSelection?: boolean }) & {
lastActiveToolBeforeEraser?: ActiveTool | null;
},
): AppState["activeTool"] => {
@ -407,6 +407,7 @@ export const updateActiveTool = (
type: data.type,
customType: null,
locked: data.locked ?? appState.activeTool.locked,
fromSelection: data.fromSelection ?? false,
};
};

View file

@ -13,7 +13,10 @@ import {
import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve";
import type {
Curve,
Degrees,
GlobalPoint,
LineSegment,
@ -37,6 +40,13 @@ import {
isTextElement,
} from "./typeChecks";
import { getElementShape } from "./shapes";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -45,6 +55,8 @@ import type {
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
@ -254,50 +266,82 @@ export const getElementAbsoluteCoords = (
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
*/
/**
* Given an element, return the line segments that make up the element.
*
* Uses helpers from /math
*/
export const getElementLineSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): LineSegment<GlobalPoint>[] => {
const shape = getElementShape(element, elementsMap);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const center = pointFrom<GlobalPoint>(cx, cy);
const center: GlobalPoint = pointFrom(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: LineSegment<GlobalPoint>[] = [];
if (shape.type === "polycurve") {
const curves = shape.data;
const points = curves
.map((curve) => pointsOnBezierCurves(curve, 10))
.flat();
let i = 0;
while (i < element.points.length - 1) {
const segments: LineSegment<GlobalPoint>[] = [];
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,
),
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
return segments;
} else if (shape.type === "polyline") {
return shape.data as LineSegment<GlobalPoint>[];
} else if (_isRectanguloidElement(element)) {
const [sides, corners] = deconstructRectanguloidElement(element);
const cornerSegments: LineSegment<GlobalPoint>[] = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (element.type === "diamond") {
const [sides, corners] = deconstructDiamondElement(element);
const cornerSegments = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (shape.type === "polygon") {
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (container && isLinearElement(container)) {
const segments: LineSegment<GlobalPoint>[] = [
lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
];
return segments;
}
}
const points = shape.data as GlobalPoint[];
const segments: LineSegment<GlobalPoint>[] = [];
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
return segments;
} else if (shape.type === "ellipse") {
return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
}
const [nw, ne, sw, se, n, s, w, e] = (
const [nw, ne, sw, se, , , w, e] = (
[
[x1, y1],
[x2, y1],
@ -310,28 +354,6 @@ export const getElementLineSegments = (
] 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),
];
}
return [
lineSegment(nw, ne),
lineSegment(sw, se),
@ -344,6 +366,94 @@ export const getElementLineSegments = (
];
};
const _isRectanguloidElement = (
element: ExcalidrawElement,
): element is ExcalidrawRectanguloidElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
const getRotatedSides = (
sides: LineSegment<GlobalPoint>[],
center: GlobalPoint,
angle: Radians,
) => {
return sides.map((side) => {
return lineSegment(
pointRotateRads<GlobalPoint>(side[0], center, angle),
pointRotateRads<GlobalPoint>(side[1], center, angle),
);
});
};
const getSegmentsOnCurve = (
curve: Curve<GlobalPoint>,
center: GlobalPoint,
angle: Radians,
): LineSegment<GlobalPoint>[] => {
const points = pointsOnBezierCurves(curve, 10);
let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads<GlobalPoint>(
pointFrom(points[i][0], points[i][1]),
center,
angle,
),
pointRotateRads<GlobalPoint>(
pointFrom(points[i + 1][0], points[i + 1][1]),
center,
angle,
),
),
);
i++;
}
return segments;
};
const getSegmentsOnEllipse = (
ellipse: ExcalidrawEllipseElement,
): LineSegment<GlobalPoint>[] => {
const center = pointFrom<GlobalPoint>(
ellipse.x + ellipse.width / 2,
ellipse.y + ellipse.height / 2,
);
const a = ellipse.width / 2;
const b = ellipse.height / 2;
const segments: LineSegment<GlobalPoint>[] = [];
const points: GlobalPoint[] = [];
const n = 90;
const deltaT = (Math.PI * 2) / n;
for (let i = 0; i < n; i++) {
const t = i * deltaT;
const x = center[0] + a * Math.cos(t);
const y = center[1] + b * Math.sin(t);
points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
}
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
segments.push(lineSegment(points[points.length - 1], points[0]));
return segments;
};
/**
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
*

View file

@ -14,6 +14,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"))) ||

View file

@ -29,6 +29,7 @@ import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip";
import {
handIcon,
LassoIcon,
MoonIcon,
SunIcon,
TrashIcon,
@ -52,7 +53,6 @@ import type { AppState, Offsets } from "../types";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
paletteName: "Change canvas background color",
trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
@ -90,7 +90,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
label: "labels.clearCanvas",
paletteName: "Clear canvas",
icon: TrashIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => {
@ -525,10 +524,42 @@ export const actionToggleEraserTool = register({
keyTest: (event) => event.key === KEYS.E,
});
export const actionToggleLassoTool = register({
name: "toggleLassoTool",
label: "toolBar.lasso",
icon: LassoIcon,
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
if (appState.activeTool.type !== "lasso") {
activeTool = updateActiveTool(appState, {
type: "lasso",
fromSelection: false,
});
setCursor(app.interactiveCanvas, CURSOR_TYPE.CROSSHAIR);
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
});
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
});
export const actionToggleHandTool = register({
name: "toggleHandTool",
label: "toolBar.hand",
paletteName: "Toggle hand tool",
trackEvent: { category: "toolbar" },
icon: handIcon,
viewMode: false,

View file

@ -90,7 +90,6 @@ export const actionToggleElementLock = register({
export const actionUnlockAllElements = register({
name: "unlockAllElements",
paletteName: "Unlock all elements",
trackEvent: { category: "canvas" },
viewMode: false,
icon: UnlockedIcon,

View file

@ -9,7 +9,6 @@ export const actionToggleStats = register({
name: "stats",
label: "stats.fullTitle",
icon: abacusIcon,
paletteName: "Toggle stats",
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["edit", "attributes", "customize"],

View file

@ -8,7 +8,6 @@ import { register } from "./register";
export const actionToggleViewMode = register({
name: "viewMode",
label: "labels.viewMode",
paletteName: "Toggle view mode",
icon: eyeIcon,
viewMode: true,
trackEvent: {

View file

@ -9,7 +9,6 @@ export const actionToggleZenMode = register({
name: "zenMode",
label: "buttons.zenMode",
icon: coffeeIcon,
paletteName: "Toggle zen mode",
viewMode: true,
trackEvent: {
category: "canvas",

View file

@ -139,7 +139,8 @@ export type ActionName =
| "copyElementLink"
| "linkToElement"
| "cropEditor"
| "wrapSelectionInFrame";
| "wrapSelectionInFrame"
| "toggleLassoTool";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];

View file

@ -23,6 +23,8 @@ export interface Trail {
export interface AnimatedTrailOptions {
fill: (trail: AnimatedTrail) => string;
stroke?: (trail: AnimatedTrail) => string;
animateTrail?: boolean;
}
export class AnimatedTrail implements Trail {
@ -31,16 +33,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", "7 7");
this.trailElement.setAttribute("stroke-dashoffset", "10");
this.trailAnimation.setAttribute("from", "0");
this.trailAnimation.setAttribute("to", `-14`);
this.trailAnimation.setAttribute("dur", "0.3s");
this.trailElement.appendChild(this.trailAnimation);
}
}
get hasCurrentTrail() {
@ -104,8 +118,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() {
@ -132,14 +161,25 @@ 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",
(this.options.fill ?? (() => "black"))(this),
);
this.trailElement.setAttribute(
"stroke",
(this.options.stroke ?? (() => "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(
@ -150,6 +190,14 @@ export class AnimatedTrail implements Trail {
return [result.x, result.y];
});
const stroke = this.trailAnimation
? _stroke.slice(
// slicing from 6th point to get rid of the initial notch type of thing
Math.min(_stroke.length, 6),
_stroke.length / 2,
)
: _stroke;
return getSvgPathFromStroke(stroke, true);
}
}

View file

@ -52,6 +52,7 @@ export const getDefaultAppState = (): Omit<
type: "selection",
customType: null,
locked: DEFAULT_ELEMENT_PROPS.locked,
fromSelection: false,
lastActiveTool: null,
},
penMode: false,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,201 @@
import {
type GlobalPoint,
type LineSegment,
pointFrom,
} from "@excalidraw/math";
import { getElementLineSegments } from "@excalidraw/element/bounds";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
import { getFrameChildren } from "@excalidraw/element/frame";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import { getContainerElement } from "@excalidraw/element/textElement";
import { arrayToMap, easeOut } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "@excalidraw/element/types";
import { type AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { getLassoSelectedElementIds } from "./utils";
import type App from "../components/App";
export class LassoTrail extends AnimatedTrail {
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
private elementsSegments: Map<string, LineSegment<GlobalPoint>[]> | null =
null;
private keepPreviousSelection: boolean = false;
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(105,101,219,0.05)",
stroke: () => "rgba(105,101,219)",
});
}
startPath(x: number, y: number, keepPreviousSelection = false) {
// clear any existing trails just in case
this.endPath();
super.startPath(x, y);
this.intersectedElements.clear();
this.enclosedElements.clear();
this.keepPreviousSelection = keepPreviousSelection;
if (!this.keepPreviousSelection) {
this.app.setState({
selectedElementIds: {},
selectedGroupIds: {},
selectedLinearElement: null,
});
}
}
selectElementsFromIds = (ids: string[]) => {
this.app.setState((prevState) => {
const nextSelectedElementIds = ids.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>);
if (this.keepPreviousSelection) {
for (const id of Object.keys(prevState.selectedElementIds)) {
nextSelectedElementIds[id] = true;
}
}
for (const [id] of Object.entries(nextSelectedElementIds)) {
const element = this.app.scene.getNonDeletedElement(id);
if (element && isTextElement(element)) {
const container = getContainerElement(
element,
this.app.scene.getNonDeletedElementsMap(),
);
if (container) {
nextSelectedElementIds[container.id] = true;
delete nextSelectedElementIds[element.id];
}
}
}
// remove all children of selected frames
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,
);
const selectedIds = [...Object.keys(nextSelection.selectedElementIds)];
const selectedGroupIds = [...Object.keys(nextSelection.selectedGroupIds)];
return {
selectedElementIds: nextSelection.selectedElementIds,
selectedGroupIds: nextSelection.selectedGroupIds,
selectedLinearElement:
selectedIds.length === 1 &&
!selectedGroupIds.length &&
isLinearElement(this.app.scene.getNonDeletedElement(selectedIds[0]))
? new LinearElementEditor(
this.app.scene.getNonDeletedElement(
selectedIds[0],
) as NonDeleted<ExcalidrawLinearElement>,
)
: null,
};
});
};
addPointToPath = (x: number, y: number, keepPreviousSelection = false) => {
super.addPointToPath(x, y);
this.keepPreviousSelection = keepPreviousSelection;
this.updateSelection();
};
private updateSelection = () => {
const lassoPath = super
.getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1]));
if (!this.elementsSegments) {
this.elementsSegments = new Map();
const visibleElementsMap = arrayToMap(this.app.visibleElements);
for (const element of this.app.visibleElements) {
const segments = getElementLineSegments(element, visibleElementsMap);
this.elementsSegments.set(element.id, segments);
}
}
if (lassoPath) {
const { selectedElementIds } = getLassoSelectedElementIds({
lassoPath,
elements: this.app.visibleElements,
elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements,
simplifyDistance: 5 / this.app.state.zoom.value,
});
this.selectElementsFromIds(selectedElementIds);
}
};
endPath(): void {
super.endPath();
super.clearTrails();
this.intersectedElements.clear();
this.enclosedElements.clear();
this.elementsSegments = null;
}
}

View file

@ -0,0 +1,111 @@
import { simplify } from "points-on-curve";
import {
polygonFromPoints,
polygonIncludesPoint,
lineSegment,
lineSegmentIntersectionPoints,
} from "@excalidraw/math";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[];
elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
simplifyDistance?: number;
}): {
selectedElementIds: string[];
} => {
const {
lassoPath,
elements,
elementsSegments,
intersectedElements,
enclosedElements,
simplifyDistance,
} = input;
// simplify the path to reduce the number of points
let path: GlobalPoint[] = lassoPath;
if (simplifyDistance) {
path = simplify(lassoPath, simplifyDistance) as GlobalPoint[];
}
// close the path to form a polygon for enclosure check
const closedPath = polygonFromPoints(path);
// as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear();
for (const element of elements) {
if (
!intersectedElements.has(element.id) &&
!enclosedElements.has(element.id)
) {
const enclosed = enclosureTest(closedPath, element, elementsSegments);
if (enclosed) {
enclosedElements.add(element.id);
} else {
const intersects = intersectionTest(
closedPath,
element,
elementsSegments,
);
if (intersects) {
intersectedElements.add(element.id);
}
}
}
}
const results = [...intersectedElements, ...enclosedElements];
return {
selectedElementIds: results,
};
};
const enclosureTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
): boolean => {
const lassoPolygon = polygonFromPoints(lassoPath);
const segments = elementsSegments.get(element.id);
if (!segments) {
return false;
}
return segments.some((segment) => {
return segment.some((point) => polygonIncludesPoint(point, lassoPolygon));
});
};
const intersectionTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
): boolean => {
const elementSegments = elementsSegments.get(element.id);
if (!elementSegments) {
return false;
}
const lassoSegments = lassoPath.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
acc.push(lineSegment(lassoPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
return lassoSegments.some((lassoSegment) =>
elementSegments.some(
(elementSegment) =>
// introduce a bit of tolerance to account for roughness and simplification of paths
lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null,
),
);
};

View file

@ -276,6 +276,7 @@
},
"toolBar": {
"selection": "Selection",
"lasso": "Lasso selection",
"image": "Insert image",
"rectangle": "Rectangle",
"diamond": "Diamond",

View file

@ -5,6 +5,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1088,6 +1089,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1307,6 +1309,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1641,6 +1644,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1975,6 +1979,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2194,6 +2199,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2437,6 +2443,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2741,6 +2748,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3113,6 +3121,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3591,6 +3600,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3917,6 +3927,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4243,6 +4254,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4649,6 +4661,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5870,6 +5883,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7137,6 +7151,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7408,7 +7423,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
</svg>,
"label": "labels.elementLock.unlockAll",
"name": "unlockAllElements",
"paletteName": "Unlock all elements",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
@ -7559,7 +7573,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"keyTest": [Function],
"label": "buttons.zenMode",
"name": "zenMode",
"paletteName": "Toggle zen mode",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
@ -7603,7 +7616,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"keyTest": [Function],
"label": "labels.viewMode",
"name": "viewMode",
"paletteName": "Toggle view mode",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
@ -7677,7 +7689,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
],
"label": "stats.fullTitle",
"name": "stats",
"paletteName": "Toggle stats",
"perform": [Function],
"trackEvent": {
"category": "menu",
@ -7814,6 +7825,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8802,6 +8814,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",

View file

@ -5,6 +5,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -604,6 +605,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1111,6 +1113,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1482,6 +1485,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1854,6 +1858,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2124,6 +2129,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2563,6 +2569,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2865,6 +2872,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3152,6 +3160,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3449,6 +3458,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3738,6 +3748,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3976,6 +3987,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4238,6 +4250,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4514,6 +4527,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4748,6 +4762,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4982,6 +4997,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5214,6 +5230,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5446,6 +5463,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5708,6 +5726,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -6042,6 +6061,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -6470,6 +6490,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -6851,6 +6872,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7173,6 +7195,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7474,6 +7497,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7706,6 +7730,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8064,6 +8089,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8422,6 +8448,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8829,6 +8856,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -9119,6 +9147,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9387,6 +9416,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9654,6 +9684,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9888,6 +9919,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10192,6 +10224,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10535,6 +10568,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10773,6 +10807,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11225,6 +11260,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11482,6 +11518,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11724,6 +11761,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11968,6 +12006,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -12372,6 +12411,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12622,6 +12662,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12866,6 +12907,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13110,6 +13152,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13360,6 +13403,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13695,6 +13739,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13870,6 +13915,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14161,6 +14207,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14431,6 +14478,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14709,6 +14757,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14873,6 +14922,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -15570,6 +15620,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -16189,6 +16240,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -16808,6 +16860,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -17518,6 +17571,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -18265,6 +18319,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -18742,6 +18797,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -19267,6 +19323,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -19726,6 +19783,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",

View file

@ -5,6 +5,7 @@ exports[`given element A and group of elements B and given both are selected whe
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -420,6 +421,7 @@ exports[`given element A and group of elements B and given both are selected whe
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -826,6 +828,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1371,6 +1374,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1575,6 +1579,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -1950,6 +1955,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2188,6 +2194,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2368,6 +2375,7 @@ exports[`regression tests > can drag element that covers another element, while
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2688,6 +2696,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -2934,6 +2943,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3177,6 +3187,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3407,6 +3418,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3663,6 +3675,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -3974,6 +3987,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4396,6 +4410,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4679,6 +4694,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -4932,6 +4948,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5142,6 +5159,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5341,6 +5359,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -5723,6 +5742,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -6013,6 +6033,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -6821,6 +6842,7 @@ exports[`regression tests > given a group of selected elements with an element t
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7151,6 +7173,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7427,6 +7450,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7661,6 +7685,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -7898,6 +7923,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8078,6 +8104,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8258,6 +8285,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8438,6 +8466,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8661,6 +8690,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -8883,6 +8913,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -9077,6 +9108,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9300,6 +9332,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9480,6 +9513,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9702,6 +9736,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -9882,6 +9917,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "freedraw",
@ -10076,6 +10112,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10256,6 +10293,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -10764,6 +10802,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11041,6 +11080,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11167,6 +11207,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11366,6 +11407,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -11677,6 +11719,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12089,6 +12132,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12702,6 +12746,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -12831,6 +12876,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13415,6 +13461,7 @@ exports[`regression tests > switches from group of selected elements to another
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -13753,6 +13800,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14018,6 +14066,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14144,6 +14193,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",
@ -14523,6 +14573,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "text",
@ -14649,6 +14700,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",

View file

@ -402,7 +402,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

File diff suppressed because it is too large Load diff

View file

@ -136,6 +136,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type ToolType =
| "selection"
| "lasso"
| "rectangle"
| "diamond"
| "ellipse"
@ -308,6 +309,8 @@ export interface AppState {
*/
lastActiveTool: ActiveTool | null;
locked: boolean;
// indicates if the current tool is temporarily switched on from the selection tool
fromSelection: boolean;
} & ActiveTool;
penMode: boolean;
penDetected: boolean;

View file

@ -160,13 +160,17 @@ export const distanceToLineSegment = <Point extends LocalPoint | GlobalPoint>(
*/
export function lineSegmentIntersectionPoints<
Point extends GlobalPoint | LocalPoint,
>(l: LineSegment<Point>, s: LineSegment<Point>): Point | null {
>(
l: LineSegment<Point>,
s: LineSegment<Point>,
threshold?: number,
): Point | null {
const candidate = linesIntersectAt(line(l[0], l[1]), line(s[0], s[1]));
if (
!candidate ||
!pointOnLineSegment(candidate, s) ||
!pointOnLineSegment(candidate, l)
!pointOnLineSegment(candidate, s, threshold) ||
!pointOnLineSegment(candidate, l, threshold)
) {
return null;
}

View file

@ -5,6 +5,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"fromSelection": false,
"lastActiveTool": null,
"locked": false,
"type": "selection",

View file

@ -8770,16 +8770,8 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -8881,14 +8873,7 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -10021,7 +10006,8 @@ workbox-window@7.3.0, workbox-window@^7.3.0:
"@types/trusted-types" "^2.0.2"
workbox-core "7.3.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -10039,15 +10025,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"