diff --git a/examples/with-script-in-browser/package.json b/examples/with-script-in-browser/package.json index 3d61f1a1b7..2b43117117 100644 --- a/examples/with-script-in-browser/package.json +++ b/examples/with-script-in-browser/package.json @@ -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" } } diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index f10e742f5b..29e5c04300 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -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", diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 31046f1ef7..7e8c49ea1c 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -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", diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 10837a02f8..7fa98eb2da 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -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, }; }; diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index bab1a1871f..9e723c2146 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -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[] => { + const shape = getElementShape(element, elementsMap); const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( element, elementsMap, ); + const center = pointFrom(cx, cy); - const center: GlobalPoint = pointFrom(cx, cy); - - if (isLinearElement(element) || isFreeDrawElement(element)) { - const segments: LineSegment[] = []; - + 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[] = []; + 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[]; + } else if (_isRectanguloidElement(element)) { + const [sides, corners] = deconstructRectanguloidElement(element); + const cornerSegments: LineSegment[] = 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[] = [ + 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[] = []; + 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[], + center: GlobalPoint, + angle: Radians, +) => { + return sides.map((side) => { + return lineSegment( + pointRotateRads(side[0], center, angle), + pointRotateRads(side[1], center, angle), + ); + }); +}; + +const getSegmentsOnCurve = ( + curve: Curve, + center: GlobalPoint, + angle: Radians, +): LineSegment[] => { + const points = pointsOnBezierCurves(curve, 10); + let i = 0; + const segments: LineSegment[] = []; + while (i < points.length - 1) { + segments.push( + lineSegment( + pointRotateRads( + pointFrom(points[i][0], points[i][1]), + center, + angle, + ), + pointRotateRads( + pointFrom(points[i + 1][0], points[i + 1][1]), + center, + angle, + ), + ), + ); + i++; + } + + return segments; +}; + +const getSegmentsOnEllipse = ( + ellipse: ExcalidrawEllipseElement, +): LineSegment[] => { + const center = pointFrom( + ellipse.x + ellipse.width / 2, + ellipse.y + ellipse.height / 2, + ); + + const a = ellipse.width / 2; + const b = ellipse.height / 2; + + const segments: LineSegment[] = []; + 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. * diff --git a/packages/element/src/showSelectedShapeActions.ts b/packages/element/src/showSelectedShapeActions.ts index efcf83894a..6f918cfbdf 100644 --- a/packages/element/src/showSelectedShapeActions.ts +++ b/packages/element/src/showSelectedShapeActions.ts @@ -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"))) || diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 0aa83944f8..a8bd56e820 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -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, diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 79d13c63ef..6bc238a59b 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -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, diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index 93fd6395ba..ffa812e960 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -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"], diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index 81faa9ee61..e42a7a102d 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -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: { diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index 4d45b32046..e56e02ca76 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -9,7 +9,6 @@ export const actionToggleZenMode = register({ name: "zenMode", label: "buttons.zenMode", icon: coffeeIcon, - paletteName: "Toggle zen mode", viewMode: true, trackEvent: { category: "canvas", diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 152b9a0c7e..c63a122e04 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -139,7 +139,8 @@ export type ActionName = | "copyElementLink" | "linkToElement" | "cropEditor" - | "wrapSelectionInFrame"; + | "wrapSelectionInFrame" + | "toggleLassoTool"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts index 286eff6f34..0ffec7cf58 100644 --- a/packages/excalidraw/animated-trail.ts +++ b/packages/excalidraw/animated-trail.ts @@ -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 & Partial, ) { 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); } } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 434392ce74..a75745f2a2 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -52,6 +52,7 @@ export const getDefaultAppState = (): Omit< type: "selection", customType: null, locked: DEFAULT_ELEMENT_PROPS.locked, + fromSelection: false, lastActiveTool: null, }, penMode: false, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index b692048673..3a7df37a85 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -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 ( { 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} setIsExtraToolsMenuOpen(false)} @@ -399,6 +419,14 @@ export const ShapesSwitcher = ({ > {t("toolBar.laser")} + app.setActiveTool({ type: "lasso" })} + icon={LassoIcon} + data-testid="toolbar-lasso" + selected={lassoToolSelected} + > + {t("toolBar.lasso")} +
Generate
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d5fb55e1d5..0ca525828a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { ? "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 {
{selectedElements.length === 1 && this.state.openDialog?.name !== @@ -4630,7 +4637,10 @@ class App extends React.Component { 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 { } ) | { 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 { 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 { !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 { 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 { } 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 { 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 { 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 { 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 { } 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 { 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 { 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, }); diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 4391759d9b..8b45e3377e 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -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[] = [ diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 6eb1a21867..5072e4471f 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -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) { diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index b70c8ace6d..5a498ebacc 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -87,34 +87,36 @@ const StaticCanvas = (props: StaticCanvasProps) => { return
; }; -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, diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index c6d7f94732..f3808a69d0 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -274,6 +274,21 @@ export const SelectionIcon = createIcon( { fill: "none", width: 22, height: 22, strokeWidth: 1.25 }, ); +export const LassoIcon = createIcon( + + + + + , + + { fill: "none", width: 22, height: 22, strokeWidth: 1.25 }, +); + // tabler-icons: square export const RectangleIcon = createIcon( @@ -406,7 +421,7 @@ export const TrashIcon = createIcon( ); export const EmbedIcon = createIcon( - + , diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 30702f130e..4f050c922e 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -86,6 +86,7 @@ export const AllowedExcalidrawActiveTools: Record< boolean > = { selection: true, + lasso: true, text: true, rectangle: true, diamond: true, diff --git a/packages/excalidraw/lasso/index.ts b/packages/excalidraw/lasso/index.ts new file mode 100644 index 0000000000..d59b2d7439 --- /dev/null +++ b/packages/excalidraw/lasso/index.ts @@ -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 = new Set(); + private enclosedElements: Set = new Set(); + private elementsSegments: Map[]> | 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); + + 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, + ) + : 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(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; + } +} diff --git a/packages/excalidraw/lasso/utils.ts b/packages/excalidraw/lasso/utils.ts new file mode 100644 index 0000000000..b04e66db90 --- /dev/null +++ b/packages/excalidraw/lasso/utils.ts @@ -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[]>; + +export const getLassoSelectedElementIds = (input: { + lassoPath: GlobalPoint[]; + elements: readonly ExcalidrawElement[]; + elementsSegments: ElementsSegmentsMap; + intersectedElements: Set; + enclosedElements: Set; + 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[]); + + 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, + ), + ); +}; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index f14b797055..381f2b67f8 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -276,6 +276,7 @@ }, "toolBar": { "selection": "Selection", + "lasso": "Lasso selection", "image": "Insert image", "rectangle": "Rectangle", "diamond": "Diamond", diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 89629b93e8..349dd9e648 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -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 , "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", diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 3f523d005d..9ffb97128a 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -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", diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 4e9c659d0f..319287792c 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -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", diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index c328ae105a..0e5e433674 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -402,7 +402,10 @@ const proxy = ( }; /** Tools that can be used to draw shapes */ -type DrawingToolName = Exclude; +type DrawingToolName = Exclude< + ToolType, + "lock" | "selection" | "eraser" | "lasso" +>; type Element = T extends "line" | "freedraw" ? ExcalidrawLinearElement diff --git a/packages/excalidraw/tests/lasso.test.tsx b/packages/excalidraw/tests/lasso.test.tsx new file mode 100644 index 0000000000..00e0ec2b4d --- /dev/null +++ b/packages/excalidraw/tests/lasso.test.tsx @@ -0,0 +1,1813 @@ +/** + * Test case: + * + * create a few random elements on canvas + * creates a lasso path for each of these cases + * - do not intersect / enclose at all + * - intersects some, does not enclose/intersect the rest + * - intersects and encloses some + * - single linear element should be selected if lasso intersects/encloses it + * + * + * special cases: + * - selects only frame if frame and children both selected by lasso + * - selects group if any group from group is selected + */ + +import { + type GlobalPoint, + type LocalPoint, + pointFrom, + type Radians, +} from "@excalidraw/math"; + +import { getElementLineSegments } from "@excalidraw/element/bounds"; + +import type { ExcalidrawElement } from "@excalidraw/element/types"; + +import { Excalidraw } from "../index"; + +import { getSelectedElements } from "../scene"; + +import { getLassoSelectedElementIds } from "../lasso/utils"; + +import { act, render } from "./test-utils"; + +import type { ElementsSegmentsMap } from "../lasso/utils"; + +const { h } = window; + +beforeEach(async () => { + localStorage.clear(); + await render(); + h.state.width = 1000; + h.state.height = 1000; +}); + +const updatePath = (startPoint: GlobalPoint, points: LocalPoint[]) => { + act(() => { + h.app.lassoTrail.startPath(startPoint[0], startPoint[1]); + + points.forEach((point) => { + h.app.lassoTrail.addPointToPath( + startPoint[0] + point[0], + startPoint[1] + point[1], + ); + }); + + const elementsSegments: ElementsSegmentsMap = new Map(); + for (const element of h.elements) { + const segments = getElementLineSegments( + element, + h.app.scene.getElementsMapIncludingDeleted(), + ); + elementsSegments.set(element.id, segments); + } + + const result = getLassoSelectedElementIds({ + lassoPath: + h.app.lassoTrail + .getCurrentTrail() + ?.originalPoints?.map((p) => pointFrom(p[0], p[1])) ?? + [], + elements: h.elements, + elementsSegments, + intersectedElements: new Set(), + enclosedElements: new Set(), + }); + + act(() => + h.app.lassoTrail.selectElementsFromIds(result.selectedElementIds), + ); + + h.app.lassoTrail.endPath(); + }); +}; + +describe("Basic lasso selection tests", () => { + beforeEach(() => { + const elements: ExcalidrawElement[] = [ + { + id: "FLZN67ISZbMV-RH8SzS9W", + type: "rectangle", + x: 0, + y: 0, + width: 107.11328125, + height: 90.16015625, + angle: 5.40271241072378, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + index: "a8", + roundness: { + type: 3, + }, + seed: 1558764732, + version: 43, + versionNonce: 575357188, + isDeleted: false, + boundElements: [], + updated: 1740723127946, + link: null, + locked: false, + }, + { + id: "T3TSAFUwp--pT2b_q7Y5U", + type: "diamond", + x: 349.822265625, + y: -201.244140625, + width: 123.3828125, + height: 74.66796875, + angle: 0.6498998717212414, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + index: "a9", + roundness: { + type: 2, + }, + seed: 1720937276, + version: 69, + versionNonce: 1991578556, + isDeleted: false, + boundElements: [], + updated: 1740723132096, + link: null, + locked: false, + }, + { + id: "a9RZwSeqlZHyhses2iYZ0", + type: "ellipse", + x: 188.259765625, + y: -48.193359375, + width: 146.8984375, + height: 91.01171875, + angle: 0.6070652964532064, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + index: "aA", + roundness: { + type: 2, + }, + seed: 476696636, + version: 38, + versionNonce: 1903760444, + isDeleted: false, + boundElements: [], + updated: 1740723125079, + link: null, + locked: false, + }, + { + id: "vCw17KEn9h4sY2KMdnq0G", + type: "arrow", + x: -257.388671875, + y: 78.583984375, + width: 168.4765625, + height: 153.38671875, + angle: 0, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + index: "aB", + roundness: { + type: 2, + }, + seed: 1302309508, + version: 19, + versionNonce: 1230691388, + isDeleted: false, + boundElements: [], + updated: 1740723110578, + link: null, + locked: false, + points: [ + [0, 0], + [168.4765625, -153.38671875], + ], + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: null, + endArrowhead: "arrow", + elbowed: false, + }, + { + id: "dMsLoKhGsWQXpiKGWZ6Cn", + type: "line", + x: -113.748046875, + y: -165.224609375, + width: 206.12890625, + height: 35.4140625, + angle: 0, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + index: "aC", + roundness: { + type: 2, + }, + seed: 514585788, + version: 18, + versionNonce: 1338507580, + isDeleted: false, + boundElements: [], + updated: 1740723112995, + link: null, + locked: false, + points: [ + [0, 0], + [206.12890625, 35.4140625], + ], + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: null, + endArrowhead: null, + }, + { + id: "1GUDjUg8ibE_4qMFtdQiK", + type: "freedraw", + x: 384.404296875, + y: 91.580078125, + width: 537.55078125, + height: 288.48046875, + angle: 5.5342222396022285, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + index: "aD", + roundness: null, + seed: 103578044, + version: 167, + versionNonce: 1117299588, + isDeleted: false, + boundElements: [], + updated: 1740723137180, + link: null, + locked: false, + points: [ + [0, 0], + [-0.10546875, 0], + [-3.23046875, -0.859375], + [-18.09765625, -4.6953125], + [-54.40625, -13.765625], + [-103.48046875, -23.05859375], + [-155.6640625, -27.5390625], + [-205.5703125, -27.96484375], + [-239, -24.4765625], + [-257.27734375, -17.0390625], + [-270.1015625, -5.43359375], + [-279.94140625, 12.12109375], + [-286.828125, 36.6875], + [-291.03515625, 65.63671875], + [-292.5546875, 94.96875], + [-291.8203125, 122.1875], + [-286.140625, 144.703125], + [-274.60546875, 160.01953125], + [-257.1171875, 170.375], + [-237.7890625, 176.1953125], + [-218.85546875, 178.69921875], + [-199.33984375, 181.56640625], + [-182.4609375, 188.4765625], + [-168.97265625, 200.14453125], + [-160.83984375, 211.1875], + [-156.40234375, 220.0703125], + [-153.60546875, 226.12890625], + [-151.3203125, 229.30078125], + [-146.28125, 231.7421875], + [-136.140625, 233.30859375], + [-122.1953125, 233.80078125], + [-108.66015625, 234.23828125], + [-97.0234375, 235.0546875], + [-89.6171875, 235.7421875], + [-85.84375, 237.52734375], + [-82.546875, 240.41796875], + [-79.64453125, 243.2734375], + [-75.71875, 245.99609375], + [-69.734375, 248.4453125], + [-59.6640625, 250.87890625], + [-45.1171875, 252.4453125], + [-23.9453125, 251.7265625], + [7.41796875, 244.0546875], + [48.58203125, 223.734375], + [93.5078125, 192.859375], + [135.8359375, 153.9453125], + [168.875, 114.015625], + [186.5625, 86.640625], + [194.9765625, 71.19140625], + [199.0234375, 62.671875], + [199.875, 59.6171875], + [200.1796875, 58.72265625], + [200.4140625, 58.62109375], + [200.87109375, 58.57421875], + [203.1796875, 58.2734375], + [208.72265625, 55.671875], + [216.421875, 50.89453125], + [224.546875, 45.265625], + [234.40625, 36.30859375], + [241.71484375, 28.14453125], + [243.6875, 24.1171875], + [244.6171875, 21.34375], + [244.99609375, 18.5625], + [243.78515625, 12.41015625], + [237.6328125, -4.8125], + [222.91796875, -36.03515625], + [222.91796875, -36.03515625], + ], + pressures: [], + simulatePressure: true, + lastCommittedPoint: null, + }, + ].map( + (e) => + ({ + ...e, + angle: e.angle as Radians, + index: null, + } as ExcalidrawElement), + ); + + act(() => { + h.elements = elements; + h.app.setActiveTool({ type: "lasso" }); + }); + }); + + it("None should be selected", () => { + const startPoint = pointFrom(-533, 611); + + const points = [ + [0, 0], + [0.1015625, -0.09765625], + [10.16796875, -8.15625], + [25.71484375, -18.5078125], + [46.078125, -28.63671875], + [90.578125, -41.9140625], + [113.04296875, -45.0859375], + [133.95703125, -46.2890625], + [152.92578125, -46.2890625], + [170.921875, -44.98828125], + [190.1640625, -39.61328125], + [213.73046875, -29], + [238.859375, -16.59375], + [261.87890625, -5.80078125], + [281.63671875, 2.4453125], + [300.125, 9.01953125], + [320.09375, 14.046875], + [339.140625, 16.95703125], + [358.3203125, 18.41796875], + [377.5234375, 17.890625], + [396.45703125, 14.53515625], + [416.4921875, 8.015625], + [438.796875, -1.54296875], + [461.6328125, -11.5703125], + [483.36328125, -21.48828125], + [503.37109375, -30.87109375], + [517.0546875, -36.49609375], + [525.62109375, -39.6640625], + [531.45703125, -41.46875], + [534.1328125, -41.9375], + [535.32421875, -42.09375], + [544.4140625, -42.09375], + [567.2265625, -42.09375], + [608.1875, -38.5625], + [665.203125, -27.66796875], + [725.8984375, -11.30078125], + [785.05078125, 8.17578125], + [832.12109375, 25.55078125], + [861.62109375, 36.32421875], + [881.91796875, 42.203125], + [896.75, 45.125], + [907.04296875, 46.46484375], + [917.44921875, 46.42578125], + [930.671875, 42.59765625], + [945.953125, 34.66796875], + [964.08984375, 22.43359375], + [989.8125, 2.328125], + [1014.6640625, -17.79296875], + [1032.7734375, -32.70703125], + [1045.984375, -43.9921875], + [1052.48828125, -50.1875], + [1054.97265625, -53.3046875], + [1055.65234375, -54.38671875], + [1060.48046875, -54.83984375], + [1073.03125, -55.2734375], + [1095.6484375, -54], + [1125.41796875, -49.05859375], + [1155.33984375, -41.21484375], + [1182.33203125, -33.6875], + [1204.1171875, -27.75390625], + [1220.95703125, -23.58203125], + [1235.390625, -21.06640625], + [1248.078125, -19.3515625], + [1257.78125, -18.6484375], + [1265.6640625, -19.22265625], + [1271.5703125, -20.42578125], + [1276.046875, -21.984375], + [1280.328125, -25.23828125], + [1284.19140625, -29.953125], + [1288.22265625, -35.8125], + [1292.87109375, -43.21484375], + [1296.6796875, -50.44921875], + [1299.3828125, -56.40234375], + [1301.48828125, -61.08203125], + [1302.89453125, -64.75], + [1303.890625, -67.37890625], + [1304.41796875, -68.953125], + [1304.65234375, -69.8046875], + [1304.80078125, -70.2578125], + [1304.80078125, -70.2578125], + ] as LocalPoint[]; + + updatePath(startPoint, points); + + const selectedElements = getSelectedElements(h.elements, h.app.state); + + expect(selectedElements.length).toBe(0); + }); + + it("Intersects some, does not enclose/intersect the rest", () => { + const startPoint = pointFrom(-311, 50); + const points = [ + [0, 0], + [0.1015625, 0], + [3.40234375, -2.25390625], + [12.25390625, -7.84375], + [22.71484375, -13.89453125], + [39.09765625, -22.3359375], + [58.5546875, -31.9609375], + [79.91796875, -41.21875], + [90.53125, -44.76953125], + [99.921875, -47.16796875], + [107.46484375, -48.640625], + [113.92578125, -49.65625], + [119.57421875, -50.1953125], + [124.640625, -50.1953125], + [129.49609375, -50.1953125], + [134.53125, -50.1953125], + [140.59375, -50.1953125], + [147.27734375, -49.87109375], + [154.32421875, -48.453125], + [160.93359375, -46.0390625], + [166.58203125, -42.8828125], + [172.0078125, -38.8671875], + [176.75390625, -34.1015625], + [180.41796875, -29.609375], + [183.09375, -25.0390625], + [185.11328125, -19.70703125], + [186.8828125, -13.04296875], + [188.515625, -6.39453125], + [189.8515625, -1.04296875], + [190.9609375, 4.34375], + [191.9296875, 9.3125], + [193.06640625, 13.73046875], + [194.21875, 17.51953125], + [195.32421875, 20.83984375], + [196.5625, 23.4296875], + [198.2109375, 25.5234375], + [200.04296875, 27.38671875], + [202.1640625, 28.80078125], + [204.43359375, 30.33984375], + [207.10546875, 31.7109375], + [210.69921875, 33.1640625], + [214.6015625, 34.48828125], + [218.5390625, 35.18359375], + [222.703125, 35.71875], + [227.16015625, 35.98828125], + [232.01171875, 35.98828125], + [237.265625, 35.98828125], + [242.59765625, 35.015625], + [247.421875, 33.4140625], + [251.61328125, 31.90625], + [255.84375, 30.1328125], + [260.25390625, 28.62109375], + [264.44140625, 27.41796875], + [268.5546875, 26.34765625], + [272.6171875, 25.42578125], + [276.72265625, 24.37890625], + [281.234375, 23.140625], + [286.69921875, 22.046875], + [293.5859375, 20.82421875], + [300.6328125, 19.4140625], + [309.83984375, 18.1640625], + [320.28125, 16.7578125], + [329.46875, 15.91015625], + [337.453125, 15.53515625], + [344.515625, 14.8203125], + [350.45703125, 14.4453125], + [354.64453125, 14.5546875], + [358.10546875, 14.921875], + [360.83203125, 15.5234375], + [362.796875, 16.3671875], + [364.1328125, 17.43359375], + [365.13671875, 18.6015625], + [365.8984375, 19.8203125], + [366.71484375, 21.30078125], + [368.34375, 23.59765625], + [370.37890625, 26.70703125], + [372.15625, 30.5], + [374.16015625, 34.390625], + [376.21875, 38.4921875], + [378.19140625, 43.921875], + [380.4140625, 50.31640625], + [382.671875, 56.2890625], + [384.48046875, 61.34765625], + [385.7890625, 65.14453125], + [386.5390625, 66.98828125], + [386.921875, 67.60546875], + [387.171875, 67.80859375], + [388.0390625, 68.32421875], + [392.23828125, 70.3671875], + [403.59765625, 76.4296875], + [419.5390625, 85.5], + [435.5078125, 93.82421875], + [451.3046875, 101.015625], + [465.05078125, 107.02734375], + [476.828125, 111.97265625], + [487.38671875, 115.578125], + [495.98046875, 118.03125], + [503.203125, 120.3515625], + [510.375, 122.3828125], + [517.8203125, 124.32421875], + [525.38671875, 126.9375], + [532.9765625, 130.12890625], + [539.046875, 133.22265625], + [543.85546875, 136.421875], + [549.28125, 140.84375], + [554.41015625, 146.04296875], + [558.34375, 151.4921875], + [561.859375, 157.09375], + [564.734375, 162.71875], + [566.95703125, 168.375], + [568.87109375, 174.33984375], + [570.41796875, 181.26953125], + [571.74609375, 189.37890625], + [572.55859375, 197.3515625], + [573.046875, 204.26171875], + [573.7421875, 210.9453125], + [574.38671875, 216.91796875], + [574.75, 222.8515625], + [575.0703125, 228.78515625], + [575.67578125, 234.0078125], + [576.26171875, 238.3515625], + [576.84765625, 242.64453125], + [577.328125, 247.53125], + [577.6484375, 252.56640625], + [577.80859375, 257.91015625], + [578.12890625, 263.2578125], + [578.44921875, 269.1875], + [578.16796875, 275.17578125], + [577.5234375, 281.078125], + [576.14453125, 287.59375], + [574.19921875, 296.390625], + [571.96484375, 306.03125], + [568.765625, 315.54296875], + [564.68359375, 325.640625], + [560.3671875, 335.03125], + [555.93359375, 343.68359375], + [551.56640625, 352.03515625], + [547.86328125, 359.2734375], + [543.82421875, 365.2421875], + [539.91015625, 370.0078125], + [537.37109375, 372.5546875], + [535.4765625, 374.23828125], + [533.37890625, 375.5859375], + [531.2578125, 376.75390625], + [528.46875, 378.96875], + [524.296875, 381.8359375], + [519.03515625, 385.31640625], + [513.50390625, 389.2890625], + [506.43359375, 394.55078125], + [497.18359375, 401.51953125], + [488.43359375, 408.40625], + [481.15234375, 414.0703125], + [475.64453125, 417.7578125], + [471.55078125, 420.32421875], + [468.73828125, 421.828125], + [467.1640625, 422.328125], + [465.9296875, 422.6953125], + [464.7109375, 422.91796875], + [463.2734375, 423.12890625], + [462.06640625, 423.33203125], + [460.88671875, 423.33203125], + [459.484375, 423.33203125], + [458.57421875, 423.33203125], + [457.9296875, 423.10546875], + [457.15234375, 422.796875], + [456.3984375, 422.5625], + [455.8828125, 422.41015625], + [455.55859375, 422.41015625], + [455.453125, 422.3203125], + [455.4453125, 422.06640625], + [455.4453125, 422.06640625], + ] as LocalPoint[]; + + updatePath(startPoint, points); + const selectedElements = getSelectedElements(h.elements, h.state); + expect(selectedElements.length).toBe(3); + expect(selectedElements.filter((e) => e.type === "arrow").length).toBe(1); + expect(selectedElements.filter((e) => e.type === "rectangle").length).toBe( + 1, + ); + expect(selectedElements.filter((e) => e.type === "freedraw").length).toBe( + 1, + ); + }); + + it("Intersects some and encloses some", () => { + const startPoint = pointFrom(112, -190); + const points = [ + [0, 0], + [-0.1015625, 0], + [-6.265625, 3.09375], + [-18.3671875, 9.015625], + [-28.3125, 13.94921875], + [-38.03125, 19.0625], + [-52.578125, 28.72265625], + [-54.51953125, 33.00390625], + [-55.39453125, 36.07421875], + [-56.046875, 39.890625], + [-57.06640625, 45.2734375], + [-57.76171875, 51.2265625], + [-57.76171875, 56.16796875], + [-57.76171875, 60.96875], + [-57.76171875, 65.796875], + [-57.76171875, 70.54296875], + [-57.33203125, 75.21484375], + [-56.17578125, 79.5078125], + [-54.55078125, 83.5625], + [-51.88671875, 88.09375], + [-48.72265625, 92.46875], + [-45.32421875, 96.2421875], + [-41.62890625, 100.5859375], + [-37.9375, 104.92578125], + [-33.94921875, 108.91796875], + [-29.703125, 113.51953125], + [-24.45703125, 118.49609375], + [-18.66796875, 123.5390625], + [-12.7109375, 128.96484375], + [-6.2578125, 133.984375], + [0.203125, 138.5078125], + [7.1640625, 143.71875], + [16.08984375, 149.9765625], + [25.01953125, 156.1640625], + [33.8203125, 162.25], + [42.05078125, 167.79296875], + [48.75390625, 172.46484375], + [55.3984375, 177.90625], + [61.296875, 184.12890625], + [66.02734375, 191.21484375], + [69.765625, 198.109375], + [73.03515625, 204.79296875], + [76.09375, 212.26171875], + [78.984375, 219.52734375], + [81.58203125, 226.34765625], + [84.1640625, 232.3046875], + [86.7265625, 237.16796875], + [89.68359375, 241.34765625], + [93.83984375, 245.12890625], + [100.12109375, 249.328125], + [107.109375, 253.65625], + [114.08203125, 257.89453125], + [122.578125, 262.31640625], + [130.83984375, 266.359375], + [138.33203125, 269.8671875], + [144.984375, 272.3515625], + [150.265625, 274.1953125], + [155.42578125, 275.9296875], + [159.1328125, 276.73828125], + [161.2421875, 276.73828125], + [165.11328125, 276.7578125], + [172.546875, 276.76171875], + [183.14453125, 276.76171875], + [194.015625, 276.76171875], + [204.1796875, 276.76171875], + [213.484375, 276.76171875], + [221.40625, 276.76171875], + [228.47265625, 276.76171875], + [234.40234375, 276.67578125], + [240.28515625, 275.9765625], + [246.12109375, 274.59375], + [250.75390625, 272.8515625], + [255.046875, 270.18359375], + [259.6328125, 266.60546875], + [264.04296875, 262.4375], + [268.69140625, 256.69921875], + [273.25390625, 249.9375], + [277.85546875, 243.0546875], + [282.19140625, 236.5859375], + [285.24609375, 231.484375], + [287.39453125, 227.1875], + [289.078125, 223.78125], + [290.328125, 221.28125], + [291.0390625, 219.2109375], + [291.40625, 217.83984375], + [291.546875, 216.75390625], + [291.546875, 215.84375], + [291.75390625, 214.7734375], + [291.9609375, 213.15234375], + [291.9609375, 211.125], + [291.9609375, 208.6953125], + [291.9609375, 205.25], + [291.9609375, 201.4453125], + [291.62890625, 197.68359375], + [291.0625, 194.29296875], + [290.6484375, 192.21875], + [290.25390625, 190.8203125], + [289.88671875, 189.94140625], + [289.75, 189.53125], + [289.75, 189.2109375], + [289.7265625, 188.29296875], + [290.09375, 186.3125], + [293.04296875, 182.46875], + [298.671875, 177.46484375], + [305.45703125, 172.13671875], + [312.4921875, 167.35546875], + [318.640625, 163.6875], + [323.1484375, 161.0703125], + [326.484375, 159.37109375], + [329.8046875, 157.39453125], + [332.98046875, 155.2265625], + [336.09765625, 152.6875], + [339.14453125, 149.640625], + [342.37890625, 146.5078125], + [345.96875, 143.03125], + [349.4609375, 139.24609375], + [353.23046875, 134.83203125], + [356.68359375, 129.72265625], + [359.48828125, 123.9140625], + [362.76953125, 116.09765625], + [367.91796875, 93.69140625], + [368.23828125, 88.5546875], + [368.34375, 86.2890625], + [369.94921875, 80.15234375], + [372.7578125, 72.04296875], + [375.703125, 62.5], + [378.33203125, 52.72265625], + [380.109375, 44.4453125], + [381.40625, 37.59375], + [382.26953125, 31.95703125], + [382.71875, 26.60546875], + [382.81640625, 21.76171875], + [382.81640625, 17.84375], + [382.55859375, 13.9609375], + [382.27734375, 9.65625], + [381.67578125, 5.3515625], + [380.40625, 1.0703125], + [378.71484375, -3.2109375], + [376.48046875, -7.52734375], + [373.93359375, -11.71875], + [370.44140625, -16.32421875], + [365.86328125, -21.49609375], + [359.94921875, -26.8359375], + [353.33984375, -32.046875], + [345.84765625, -37.30859375], + [336.55859375, -43.21484375], + [326.34765625, -48.5859375], + [315.515625, -53.15234375], + [305.375, -56.67578125], + [296, -59.47265625], + [286.078125, -61.984375], + [276.078125, -63.78125], + [266.578125, -65.09765625], + [258.90625, -66.11328125], + [249.8984375, -67.34765625], + [238.84765625, -68.6796875], + [229.19921875, -70.01171875], + [219.66015625, -71.50390625], + [209.109375, -72.99609375], + [197.14453125, -74.625], + [186.52734375, -76.421875], + [176.66796875, -77.8203125], + [167.26953125, -79.1328125], + [159.57421875, -80.6328125], + [152.75, -81.4609375], + [146.4609375, -81.89453125], + [139.97265625, -82.23828125], + [133.546875, -82.23828125], + [127.84765625, -82.23828125], + [123.01953125, -82.23828125], + [117.9375, -81.9140625], + [112.59765625, -81.046875], + [107.3046875, -79.90234375], + [100.41796875, -78.45703125], + [92.74609375, -76.87890625], + [85.40625, -75.359375], + [77.546875, -73.80859375], + [69.71875, -72.6640625], + [62.4921875, -71.9609375], + [56.02734375, -71.23046875], + [50.37109375, -70.26171875], + [46.20703125, -69.32421875], + [43.45703125, -68.48046875], + [41.48046875, -67.5703125], + [39.99609375, -66.90234375], + [38.51171875, -66.23828125], + [36.7734375, -65.3671875], + [35.4609375, -64.359375], + [34.18359375, -63.328125], + [33.0078125, -62.54296875], + [31.8125, -61.76953125], + [30.5234375, -60.8984375], + [29.4921875, -60.09765625], + [28.5078125, -59.3828125], + [27.24609375, -58.61328125], + [25.49609375, -57.73828125], + [23.7421875, -56.859375], + [21.99609375, -55.984375], + [20.51953125, -55.16796875], + [19.4921875, -54.44140625], + [18.81640625, -53.84375], + [18.35546875, -53.52734375], + [18.0859375, -53.46484375], + [17.85546875, -53.44921875], + [17.85546875, -53.44921875], + ] as LocalPoint[]; + + updatePath(startPoint, points); + + const selectedElements = getSelectedElements(h.elements, h.state); + expect(selectedElements.length).toBe(4); + expect(selectedElements.filter((e) => e.type === "line").length).toBe(1); + expect(selectedElements.filter((e) => e.type === "ellipse").length).toBe(1); + expect(selectedElements.filter((e) => e.type === "diamond").length).toBe(1); + expect(selectedElements.filter((e) => e.type === "freedraw").length).toBe( + 1, + ); + }); + + it("Single linear element", () => { + const startPoint = pointFrom(62, -200); + const points = [ + [0, 0], + [0, 0.1015625], + [-1.65625, 2.2734375], + [-8.43359375, 12.265625], + [-17.578125, 25.83203125], + [-25.484375, 37.38671875], + [-31.453125, 47.828125], + [-34.92578125, 55.21875], + [-37.1171875, 60.05859375], + [-38.4375, 63.49609375], + [-39.5, 66.6328125], + [-40.57421875, 69.84375], + [-41.390625, 73.53515625], + [-41.9296875, 77.078125], + [-42.40625, 79.71484375], + [-42.66796875, 81.83203125], + [-42.70703125, 83.32421875], + [-42.70703125, 84.265625], + [-42.70703125, 85.171875], + [-42.70703125, 86.078125], + [-42.70703125, 86.6484375], + [-42.70703125, 87], + [-42.70703125, 87.1796875], + [-42.70703125, 87.4296875], + [-42.70703125, 87.83203125], + [-42.70703125, 88.86328125], + [-42.70703125, 91.27734375], + [-42.70703125, 95.0703125], + [-42.44140625, 98.46875], + [-42.17578125, 100.265625], + [-42.17578125, 101.16015625], + [-42.16015625, 101.76171875], + [-42.0625, 102.12109375], + [-42.0625, 102.12109375], + ] as LocalPoint[]; + updatePath(startPoint, points); + + const selectedElements = getSelectedElements(h.elements, h.state); + expect(selectedElements.length).toBe(1); + expect(h.app.state.selectedLinearElement).toBeDefined(); + }); +}); + +describe("Special cases", () => { + it("Select only frame if its children are also selected", () => { + act(() => { + const elements = [ + { + id: "CaUA2mmuudojzY98_oVXo", + type: "rectangle", + x: -96.64353835077907, + y: -270.1600585741129, + width: 146.8359375, + height: 104.921875, + angle: 0, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: "85VShCn1P9k81JqSeOg-c", + index: "aE", + roundness: { + type: 3, + }, + seed: 227442978, + version: 15, + versionNonce: 204983970, + isDeleted: false, + boundElements: [], + updated: 1740959550684, + link: null, + locked: false, + }, + { + id: "RZzDDA1DBJHw5OzHVNDvc", + type: "diamond", + x: 126.64943039922093, + y: -212.4920898241129, + width: 102.55859375, + height: 93.80078125, + angle: 0, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: "85VShCn1P9k81JqSeOg-c", + index: "aH", + roundness: { + type: 2, + }, + seed: 955233890, + version: 14, + versionNonce: 2135303358, + isDeleted: false, + boundElements: [], + updated: 1740959550684, + link: null, + locked: false, + }, + { + id: "CSVDDbC9vxqgO2uDahcE9", + type: "ellipse", + x: -20.999007100779068, + y: -87.0272460741129, + width: 116.13671875, + height: 70.7734375, + angle: 0, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: "85VShCn1P9k81JqSeOg-c", + index: "aI", + roundness: { + type: 2, + }, + seed: 807647870, + version: 16, + versionNonce: 455740962, + isDeleted: false, + boundElements: [], + updated: 1740959550684, + link: null, + locked: false, + }, + { + id: "85VShCn1P9k81JqSeOg-c", + type: "frame", + x: -164.95603835077907, + y: -353.5155273241129, + width: 451.04296875, + height: 397.09765625, + angle: 0, + strokeColor: "#bbb", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 0, + opacity: 100, + groupIds: [], + frameId: null, + index: "aJ", + roundness: null, + seed: 1134892578, + version: 57, + versionNonce: 1699466238, + isDeleted: false, + boundElements: [], + updated: 1740959550367, + link: null, + locked: false, + name: null, + }, + ].map((e) => ({ + ...e, + index: null, + angle: e.angle as Radians, + })) as ExcalidrawElement[]; + + h.elements = elements; + }); + + const startPoint = pointFrom(-352, -64); + const points = [ + [0, 0], + [0.1015625, 0], + [3.80078125, -1.05859375], + [14.38671875, -5.10546875], + [26.828125, -10.70703125], + [38.17578125, -16.10546875], + [49.6328125, -21.59375], + [79.890625, -34.078125], + [111.5859375, -46.4140625], + [125.61328125, -51.265625], + [139.20703125, -55.81640625], + [151.046875, -60.27734375], + [160.86328125, -64.140625], + [170.15625, -67.51171875], + [181.0234375, -71.5234375], + [192.6796875, -75.79296875], + [204.66015625, -80.19921875], + [218.22265625, -85.6875], + [233.359375, -91.9375], + [264.22265625, -103.91796875], + [280.390625, -109.80859375], + [295.48046875, -114.99609375], + [309.453125, -120.28125], + [323.5546875, -126.125], + [339.26953125, -132.6796875], + [354.67578125, -139.64453125], + [370.86328125, -146.53125], + [384.70703125, -152.4921875], + [394.7109375, -157.6796875], + [405.6171875, -163.07421875], + [416.390625, -167.96484375], + [425.41796875, -171.6484375], + [433.26171875, -174.78515625], + [440.76953125, -177.68359375], + [447.4140625, -179.71875], + [453.3828125, -181.11328125], + [458.421875, -182.13671875], + [462.82421875, -182.5546875], + [467.2109375, -182.640625], + [472.09765625, -182.640625], + [481.9609375, -182.640625], + [487.23828125, -182.5859375], + [492.03515625, -181.91796875], + [496.76953125, -180.640625], + [501.43359375, -179.2734375], + [505.203125, -177.73046875], + [508.33984375, -176.08984375], + [511.8671875, -174.16796875], + [515.9140625, -172.09375], + [519.703125, -170.125], + [523.6796875, -167.8828125], + [528.109375, -165.3984375], + [532.01953125, -163.3125], + [535.28125, -161.65625], + [537.62890625, -159.7734375], + [539.0859375, -157.53125], + [540.1640625, -155.7421875], + [540.98046875, -154.2578125], + [541.87890625, -152.33203125], + [542.69140625, -150.0078125], + [543.25390625, -147.671875], + [543.90625, -145.125], + [544.66796875, -142.01171875], + [545.34375, -138.1484375], + [546.03515625, -132.72265625], + [546.41015625, -126.80078125], + [546.44921875, -121.25390625], + [546.38671875, -116.3046875], + [545.21484375, -112], + [541.50390625, -107.2421875], + [536.515625, -102.83203125], + [531.44140625, -98.95703125], + [526.39453125, -95.23046875], + [521.15234375, -91.9921875], + [514.38671875, -87.984375], + [506.953125, -83.19140625], + [499.1171875, -77.52734375], + [491.37109375, -71.6484375], + [484.85546875, -66.3984375], + [477.8203125, -60.21875], + [469.921875, -53.26953125], + [460.84765625, -45.6171875], + [451.796875, -38.359375], + [444.33984375, -32.48046875], + [438.4296875, -27.68359375], + [435.2109375, -24.84375], + [433.07421875, -23.23828125], + [429.7421875, -21.125], + [424.8984375, -17.546875], + [418.7421875, -13.01171875], + [411.84375, -8.3359375], + [404.80078125, -3.65625], + [398.23828125, 0.6171875], + [392.32421875, 4.74609375], + [386.21875, 9.69921875], + [379.7421875, 14.734375], + [373.6015625, 19.95703125], + [367.34375, 26.72265625], + [360.73828125, 34.48046875], + [354.1484375, 42.51953125], + [347.21484375, 51.19140625], + [340.59765625, 59.7265625], + [334.46875, 67.703125], + [328.9921875, 74.82421875], + [323.78515625, 81.6796875], + [318.6640625, 88.34375], + [314.2109375, 93.8984375], + [309.10546875, 100.66015625], + [304.17578125, 107.2734375], + [299.97265625, 112.421875], + [295.890625, 117.99609375], + [291.8828125, 123.4453125], + [288.0078125, 128.25], + [284.91796875, 132.265625], + [282.453125, 135.66796875], + [279.80078125, 139.16015625], + [276.7734375, 143.53515625], + [274.3515625, 147.6484375], + [272.0859375, 151.0546875], + [269.5546875, 154.37890625], + [267.71484375, 156.73828125], + [266.62890625, 158.484375], + [265.5546875, 160.03125], + [264.73828125, 161.30078125], + [264.16015625, 162.51953125], + [263.46484375, 163.734375], + [262.9140625, 164.9453125], + [262.05078125, 166.3046875], + [261.234375, 167.390625], + [260.46484375, 168.53515625], + [259.5703125, 169.6640625], + [258.9296875, 170.1875], + [258.9296875, 170.1875], + ] as LocalPoint[]; + + updatePath(startPoint, points); + + const selectedElements = getSelectedElements(h.elements, h.state); + expect(selectedElements.length).toBe(1); + expect(selectedElements[0].type).toBe("frame"); + }); + + it("Selects group if any group from group is selected", () => { + act(() => { + const elements = [ + { + type: "line", + version: 594, + versionNonce: 1548428815, + isDeleted: false, + id: "FBFkTIUB1trLc6nEdp1Pu", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 170.81219641259787, + y: 391.1659993876855, + strokeColor: "#1e1e1e", + backgroundColor: "#846358", + width: 66.16406551308279, + height: 78.24124358133415, + seed: 838106785, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + startBinding: null, + endBinding: null, + lastCommittedPoint: null, + startArrowhead: null, + endArrowhead: null, + points: [ + [0, 0], + [-12.922669045523984, 78.24124358133415], + [53.24139646755881, 78.24124358133415], + [41.35254094567674, 4.2871914291142], + [0, 0], + ], + index: "aJ", + }, + { + type: "line", + version: 947, + versionNonce: 1038960225, + isDeleted: false, + id: "RsALsOjcB5dAyH4JNlfqJ", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 188.53119264021603, + y: 207.94959072391882, + strokeColor: "#1e1e1e", + backgroundColor: "#2f9e44", + width: 369.2312846526558, + height: 192.4489303545334, + seed: 319685249, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + startBinding: null, + endBinding: null, + lastCommittedPoint: null, + startArrowhead: null, + endArrowhead: null, + points: [ + [0, 0], + [-184.8271826294887, 192.4489303545334], + [184.4041020231671, 192.4489303545334], + [0, 0], + ], + index: "aK", + }, + { + type: "line", + version: 726, + versionNonce: 1463389231, + isDeleted: false, + id: "YNXwgpVIEUFgUZpJ564wo", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 184.66726071162367, + y: 123.16737006571739, + strokeColor: "#1e1e1e", + backgroundColor: "#2f9e44", + width: 290.9653230160535, + height: 173.62827429793325, + seed: 1108085345, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + startBinding: null, + endBinding: null, + lastCommittedPoint: null, + startArrowhead: null, + endArrowhead: null, + points: [ + [0, 0], + [-142.34630272423374, 173.62827429793325], + [148.61902029181974, 173.62827429793325], + [0, 0], + ], + index: "aL", + }, + { + type: "line", + version: 478, + versionNonce: 2081935937, + isDeleted: false, + id: "NV7XOz9ZIB8CbuqQIjt5k", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 189.05565121741444, + y: 54.65530340848173, + strokeColor: "#1e1e1e", + backgroundColor: "#2f9e44", + width: 194.196753378859, + height: 137.02921662223056, + seed: 398333505, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + startBinding: null, + endBinding: null, + lastCommittedPoint: null, + startArrowhead: null, + endArrowhead: null, + points: [ + [0, 0], + [-97.0316913876915, 135.70546644407042], + [97.1650619911675, 137.02921662223056], + [0, 0], + ], + index: "aM", + }, + { + type: "ellipse", + version: 282, + versionNonce: 1337339471, + isDeleted: false, + id: "b7FzLnG0L3-50bqij9mGX", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 73.9036924826674, + y: 334.3607129519222, + strokeColor: "#000000", + backgroundColor: "#c2255c", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 654550561, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aN", + }, + { + type: "ellipse", + version: 292, + versionNonce: 1355145761, + isDeleted: false, + id: "XzVfrVf3-sFJFPdOo51sb", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 138.21156391938035, + y: 380.4480208148999, + strokeColor: "#000000", + backgroundColor: "#f08c00", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 2060204545, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aO", + }, + { + type: "ellipse", + version: 288, + versionNonce: 1889111151, + isDeleted: false, + id: "D4m0Ex4rPc1-8T-uv5vGh", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 208.9502224997646, + y: 331.14531938008656, + strokeColor: "#000000", + backgroundColor: "#6741d9", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 337072609, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aP", + }, + { + type: "ellipse", + version: 296, + versionNonce: 686224897, + isDeleted: false, + id: "E0wxH4dAzQsv7Mj6OngC8", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 285.04787036654153, + y: 367.5864465275573, + strokeColor: "#000000", + backgroundColor: "#e8590c", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 670330305, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aQ", + }, + { + type: "ellipse", + version: 290, + versionNonce: 1974216335, + isDeleted: false, + id: "yKv_UI6iqa6zjVgYtXVcg", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 113.56021320197334, + y: 228.25272508134577, + strokeColor: "#000000", + backgroundColor: "#228be6", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 495127969, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aR", + }, + { + type: "ellipse", + version: 290, + versionNonce: 662343137, + isDeleted: false, + id: "udyW842HtUTlqjDEOxoPN", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 166.0783082086228, + y: 271.12463937248776, + strokeColor: "#000000", + backgroundColor: "#ffd43b", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 1274196353, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aS", + }, + { + type: "ellipse", + version: 300, + versionNonce: 229014703, + isDeleted: false, + id: "R3VRfgkowIgnr5dFXwWXa", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 234.67337107445002, + y: 237.89890579685272, + strokeColor: "#000000", + backgroundColor: "#38d9a9", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 2021841249, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aT", + }, + { + type: "ellipse", + version: 332, + versionNonce: 1670392257, + isDeleted: false, + id: "90W2w6zgGHdYda8UBiG2R", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 136.0679682048231, + y: 155.37047078640435, + strokeColor: "#000000", + backgroundColor: "#fa5252", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 344130881, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aU", + }, + { + type: "ellipse", + version: 337, + versionNonce: 2083589839, + isDeleted: false, + id: "nTDHvOk2mXLUFNn--7JvS", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 176.7962867814079, + y: 102.85237577975542, + strokeColor: "#000000", + backgroundColor: "#9775fa", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 995276065, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aV", + }, + { + type: "ellipse", + version: 313, + versionNonce: 1715947937, + isDeleted: false, + id: "iS2Q6cvQ5n_kxINfwu0HS", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 212.16561607160025, + y: 153.22687507184727, + strokeColor: "#000000", + backgroundColor: "#fab005", + width: 25.723148574685204, + height: 25.723148574685204, + seed: 1885432065, + groupIds: ["ts5VcKn3YXMmP8ipg8J5v", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: { + type: 2, + }, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + index: "aW", + }, + { + type: "line", + version: 1590, + versionNonce: 2078563567, + isDeleted: false, + id: "X4-EPaJDEnPZKN1bWhFvs", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 158.19616469467843, + y: 72.35608879274483, + strokeColor: "#000000", + backgroundColor: "#fab005", + width: 84.29101925982515, + height: 84.66090652809709, + seed: 489595105, + groupIds: ["uRBC-GT117eEzaf2ehdX_", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: null, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + startBinding: null, + endBinding: null, + lastCommittedPoint: null, + startArrowhead: null, + endArrowhead: null, + points: [ + [0, 0], + [20.062524675376327, -6.158183070239938], + [30.37468761946564, 12.304817735352257], + [40.379925616688446, -6.1461467434106165], + [60.1779773078079, -0.29196108982718233], + [54.38738874028317, -19.998927589317724], + [72.41919795710177, -30.209920701755497], + [54.27320673839876, -40.520131959038096], + [60.381292814514666, -60.13991664051316], + [40.445474389553596, -54.21058549574358], + [30.40022403822941, -72.35608879274483], + [20.373038782485533, -54.22107619387393], + [0.4211938029164521, -59.96466401524409], + [5.9466053348070105, -39.94020499773404], + [-11.871821302723378, -30.212694376435106], + [5.916318974536789, -20.128073448241587], + [0, 0], + ], + index: "aX", + }, + { + type: "line", + version: 1719, + versionNonce: 1758424449, + isDeleted: false, + id: "MHWh6yM-hxZbKvIX473TA", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + angle: 0, + x: 166.45958905094363, + y: 64.16037225967494, + strokeColor: "#000000", + backgroundColor: "#ffd43b", + width: 61.28316986803382, + height: 61.55209370467244, + seed: 1330240705, + groupIds: ["uRBC-GT117eEzaf2ehdX_", "-9NzH7Fa5JaHu4ArEFpa_"], + frameId: null, + roundness: null, + boundElements: [], + updated: 1740960278015, + link: null, + locked: false, + startBinding: null, + endBinding: null, + lastCommittedPoint: null, + startArrowhead: null, + endArrowhead: null, + points: [ + [0, 0], + [14.586312023026041, -4.477262020152575], + [22.08369476864762, 8.946127856709843], + [29.357929973514253, -4.468511096647962], + [43.751958845119866, -0.2122681777946347], + [39.54195372315489, -14.540074226137776], + [52.651848904941595, -21.96390218462942], + [39.45893853270161, -29.459865970613833], + [43.89977789919855, -43.724287114968824], + [29.40558673003957, -39.41341021563198], + [22.102260835384786, -52.605965847962594], + [14.812069036519228, -39.4210374010807], + [0.30622587789485506, -43.5968709738604], + [4.323435973028119, -29.038234309343977], + [-8.631320963092225, -21.96591876454555], + [4.301416496013572, -14.633968779551441], + [0, 0], + ], + index: "aY", + }, + ].map((e) => ({ + ...e, + index: null, + angle: e.angle as Radians, + })) as ExcalidrawElement[]; + + h.elements = elements; + }); + + const startPoint = pointFrom(117, 463); + const points = [ + [0, 0], + [0.09765625, 0], + [3.24609375, 0], + [6.9765625, 0], + [10.76171875, 0], + [14.03125, 0], + [23.24609375, 0.32421875], + [28.65625, 0.6484375], + [32.0546875, 0.6484375], + [35.4296875, 0.6484375], + [38.86328125, 0.3828125], + [41.9765625, -0.109375], + [45.0390625, -0.4296875], + [47.74609375, -0.5234375], + [49.953125, -0.73046875], + [52.12890625, -0.9375], + [54.25, -1.14453125], + [55.9921875, -1.3828125], + [57.67578125, -1.58984375], + [58.8125, -1.76953125], + [59.453125, -1.76953125], + [60.09375, -1.76953125], + [60.09375, -1.76953125], + ] as LocalPoint[]; + + updatePath(startPoint, points); + + const selectedElements = getSelectedElements(h.elements, h.state); + expect(selectedElements.length).toBe(16); + expect(h.app.state.selectedGroupIds["-9NzH7Fa5JaHu4ArEFpa_"]).toBe(true); + }); +}); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 31ce332f8d..717993b436 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -136,6 +136,7 @@ export type BinaryFiles = Record; 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; diff --git a/packages/math/src/segment.ts b/packages/math/src/segment.ts index e38978b7e5..dade79039b 100644 --- a/packages/math/src/segment.ts +++ b/packages/math/src/segment.ts @@ -160,13 +160,17 @@ export const distanceToLineSegment = ( */ export function lineSegmentIntersectionPoints< Point extends GlobalPoint | LocalPoint, ->(l: LineSegment, s: LineSegment): Point | null { +>( + l: LineSegment, + s: LineSegment, + 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; } diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index 54d4af4bc3..91108a6004 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -5,6 +5,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", diff --git a/yarn.lock b/yarn.lock index 86f64f9b5e..366a3f99fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"