Преглед изворни кода

feat: lasso selection (#9169)

* lasso without 'real' shape detection

* select a single linear el

* improve ux

* feed segments to worker

* simplify path threshold adaptive to zoom

* add a tiny threshold for checks

* refactor code

* lasso tests

* fix: ts

* do not capture lasso tool

* try worker-loader in next config

* update config

* refactor

* lint

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

* keep lasso if selected from toolbar

* fix incorrect checks for resetting to selection

* shift for additive selection

* bound text related fixes

* lint

* keep alt toggled lasso selection if shift pressed

* fix regression

* fix 'dead' lassos

* lint

* use workerpool and polyfill

* fix worker bundled with window related code

* refactor

* add file extension for worker constructor error

* another attempt at constructor error

* attempt at build issue

* attempt with dynamic import

* test not importing from math

* narrow down imports

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

* Points on curve inside the shared chunk

* Give up on experimental code splitting

* Remove potentially unnecessary optimisation

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

* fix selecting text containers and containing frames together

* render fill directly from animated trail

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

* remove unnecessary property

* tweak trail animation

* slice points to remove notch

* always start alt-lasso from initial point

* revert build & worker changes (unused)

* remove `lasso` from `hasStrokeColor`

* label change

* remove unused props

* remove unsafe optimization

* snaps

---------

Co-authored-by: dwelle <[email protected]>
Co-authored-by: Marcel Mraz <[email protected]>
Ryan Di пре 5 месеци
родитељ
комит
ce267aa0d3
33 измењених фајлова са 2709 додато и 146 уклоњено
  1. 2 1
      examples/with-script-in-browser/package.json
  2. 17 5
      excalidraw-app/vite.config.mts
  3. 1 0
      packages/common/src/constants.ts
  4. 2 1
      packages/common/src/utils.ts
  5. 156 46
      packages/element/src/bounds.ts
  6. 1 0
      packages/element/src/showSelectedShapeActions.ts
  7. 34 3
      packages/excalidraw/actions/actionCanvas.tsx
  8. 0 1
      packages/excalidraw/actions/actionElementLock.ts
  9. 0 1
      packages/excalidraw/actions/actionToggleStats.tsx
  10. 0 1
      packages/excalidraw/actions/actionToggleViewMode.tsx
  11. 0 1
      packages/excalidraw/actions/actionToggleZenMode.tsx
  12. 2 1
      packages/excalidraw/actions/types.ts
  13. 54 6
      packages/excalidraw/animated-trail.ts
  14. 1 0
      packages/excalidraw/appState.ts
  15. 30 2
      packages/excalidraw/components/Actions.tsx
  16. 86 10
      packages/excalidraw/components/App.tsx
  17. 1 0
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  18. 2 2
      packages/excalidraw/components/HintViewer.tsx
  19. 30 28
      packages/excalidraw/components/canvases/StaticCanvas.tsx
  20. 16 1
      packages/excalidraw/components/icons.tsx
  21. 1 0
      packages/excalidraw/data/restore.ts
  22. 201 0
      packages/excalidraw/lasso/index.ts
  23. 111 0
      packages/excalidraw/lasso/utils.ts
  24. 1 0
      packages/excalidraw/locales/en.json
  25. 17 4
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  26. 58 0
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  27. 52 0
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  28. 4 1
      packages/excalidraw/tests/helpers/ui.ts
  29. 1813 0
      packages/excalidraw/tests/lasso.test.tsx
  30. 3 0
      packages/excalidraw/types.ts
  31. 7 3
      packages/math/src/segment.ts
  32. 1 0
      packages/utils/tests/__snapshots__/export.test.ts.snap
  33. 5 28
      yarn.lock

+ 2 - 1
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"
   }
 }

+ 17 - 5
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",

+ 1 - 0
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",

+ 2 - 1
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,
   };
 };
 

+ 156 - 46
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<GlobalPoint>[] => {
+  const shape = getElementShape(element, elementsMap);
   const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
     element,
     elementsMap,
   );
+  const center = pointFrom<GlobalPoint>(cx, cy);
 
-  const center: GlobalPoint = pointFrom(cx, cy);
-
-  if (isLinearElement(element) || isFreeDrawElement(element)) {
-    const segments: LineSegment<GlobalPoint>[] = [];
-
+  if (shape.type === "polycurve") {
+    const curves = shape.data;
+    const points = curves
+      .map((curve) => pointsOnBezierCurves(curve, 10))
+      .flat();
     let i = 0;
-
-    while (i < element.points.length - 1) {
+    const segments: LineSegment<GlobalPoint>[] = [];
+    while (i < points.length - 1) {
       segments.push(
         lineSegment(
-          pointRotateRads(
-            pointFrom(
-              element.points[i][0] + element.x,
-              element.points[i][1] + element.y,
-            ),
-            center,
-            element.angle,
-          ),
-          pointRotateRads(
-            pointFrom(
-              element.points[i + 1][0] + element.x,
-              element.points[i + 1][1] + element.y,
-            ),
-            center,
-            element.angle,
-          ),
+          pointFrom(points[i][0], points[i][1]),
+          pointFrom(points[i + 1][0], points[i + 1][1]),
         ),
       );
       i++;
     }
 
     return segments;
+  } else if (shape.type === "polyline") {
+    return shape.data as LineSegment<GlobalPoint>[];
+  } else if (_isRectanguloidElement(element)) {
+    const [sides, corners] = deconstructRectanguloidElement(element);
+    const cornerSegments: LineSegment<GlobalPoint>[] = corners
+      .map((corner) => getSegmentsOnCurve(corner, center, element.angle))
+      .flat();
+    const rotatedSides = getRotatedSides(sides, center, element.angle);
+    return [...rotatedSides, ...cornerSegments];
+  } else if (element.type === "diamond") {
+    const [sides, corners] = deconstructDiamondElement(element);
+    const cornerSegments = corners
+      .map((corner) => getSegmentsOnCurve(corner, center, element.angle))
+      .flat();
+    const rotatedSides = getRotatedSides(sides, center, element.angle);
+
+    return [...rotatedSides, ...cornerSegments];
+  } else if (shape.type === "polygon") {
+    if (isTextElement(element)) {
+      const container = getContainerElement(element, elementsMap);
+      if (container && isLinearElement(container)) {
+        const segments: LineSegment<GlobalPoint>[] = [
+          lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
+          lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
+          lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
+          lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
+        ];
+        return segments;
+      }
+    }
+
+    const points = shape.data as GlobalPoint[];
+    const segments: LineSegment<GlobalPoint>[] = [];
+    for (let i = 0; i < points.length - 1; i++) {
+      segments.push(lineSegment(points[i], points[i + 1]));
+    }
+    return segments;
+  } else if (shape.type === "ellipse") {
+    return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
   }
 
-  const [nw, ne, sw, se, n, s, w, e] = (
+  const [nw, ne, sw, se, , , w, e] = (
     [
       [x1, y1],
       [x2, y1],
@@ -310,28 +354,6 @@ export const getElementLineSegments = (
     ] as GlobalPoint[]
   ).map((point) => pointRotateRads(point, center, element.angle));
 
-  if (element.type === "diamond") {
-    return [
-      lineSegment(n, w),
-      lineSegment(n, e),
-      lineSegment(s, w),
-      lineSegment(s, e),
-    ];
-  }
-
-  if (element.type === "ellipse") {
-    return [
-      lineSegment(n, w),
-      lineSegment(n, e),
-      lineSegment(s, w),
-      lineSegment(s, e),
-      lineSegment(n, w),
-      lineSegment(n, e),
-      lineSegment(s, w),
-      lineSegment(s, e),
-    ];
-  }
-
   return [
     lineSegment(nw, ne),
     lineSegment(sw, se),
@@ -344,6 +366,94 @@ export const getElementLineSegments = (
   ];
 };
 
+const _isRectanguloidElement = (
+  element: ExcalidrawElement,
+): element is ExcalidrawRectanguloidElement => {
+  return (
+    element != null &&
+    (element.type === "rectangle" ||
+      element.type === "image" ||
+      element.type === "iframe" ||
+      element.type === "embeddable" ||
+      element.type === "frame" ||
+      element.type === "magicframe" ||
+      (element.type === "text" && !element.containerId))
+  );
+};
+
+const getRotatedSides = (
+  sides: LineSegment<GlobalPoint>[],
+  center: GlobalPoint,
+  angle: Radians,
+) => {
+  return sides.map((side) => {
+    return lineSegment(
+      pointRotateRads<GlobalPoint>(side[0], center, angle),
+      pointRotateRads<GlobalPoint>(side[1], center, angle),
+    );
+  });
+};
+
+const getSegmentsOnCurve = (
+  curve: Curve<GlobalPoint>,
+  center: GlobalPoint,
+  angle: Radians,
+): LineSegment<GlobalPoint>[] => {
+  const points = pointsOnBezierCurves(curve, 10);
+  let i = 0;
+  const segments: LineSegment<GlobalPoint>[] = [];
+  while (i < points.length - 1) {
+    segments.push(
+      lineSegment(
+        pointRotateRads<GlobalPoint>(
+          pointFrom(points[i][0], points[i][1]),
+          center,
+          angle,
+        ),
+        pointRotateRads<GlobalPoint>(
+          pointFrom(points[i + 1][0], points[i + 1][1]),
+          center,
+          angle,
+        ),
+      ),
+    );
+    i++;
+  }
+
+  return segments;
+};
+
+const getSegmentsOnEllipse = (
+  ellipse: ExcalidrawEllipseElement,
+): LineSegment<GlobalPoint>[] => {
+  const center = pointFrom<GlobalPoint>(
+    ellipse.x + ellipse.width / 2,
+    ellipse.y + ellipse.height / 2,
+  );
+
+  const a = ellipse.width / 2;
+  const b = ellipse.height / 2;
+
+  const segments: LineSegment<GlobalPoint>[] = [];
+  const points: GlobalPoint[] = [];
+  const n = 90;
+  const deltaT = (Math.PI * 2) / n;
+
+  for (let i = 0; i < n; i++) {
+    const t = i * deltaT;
+    const x = center[0] + a * Math.cos(t);
+    const y = center[1] + b * Math.sin(t);
+    points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
+  }
+
+  for (let i = 0; i < points.length - 1; i++) {
+    segments.push(lineSegment(points[i], points[i + 1]));
+  }
+
+  segments.push(lineSegment(points[points.length - 1], points[0]));
+  return segments;
+};
+
 /**
  * Scene -> Scene coords, but in x1,x2,y1,y2 format.
  *

+ 1 - 0
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"))) ||

+ 34 - 3
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,

+ 0 - 1
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,

+ 0 - 1
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"],

+ 0 - 1
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: {

+ 0 - 1
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",

+ 2 - 1
packages/excalidraw/actions/types.ts

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

+ 54 - 6
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<LaserPointerOptions> &
       Partial<AnimatedTrailOptions>,
   ) {
     this.animationFrameHandler.register(this, this.onFrame.bind(this));
 
     this.trailElement = document.createElementNS(SVG_NS, "path");
+    if (this.options.animateTrail) {
+      this.trailAnimation = document.createElementNS(SVG_NS, "animate");
+      // TODO: make this configurable
+      this.trailAnimation.setAttribute("attributeName", "stroke-dashoffset");
+      this.trailElement.setAttribute("stroke-dasharray", "7 7");
+      this.trailElement.setAttribute("stroke-dashoffset", "10");
+      this.trailAnimation.setAttribute("from", "0");
+      this.trailAnimation.setAttribute("to", `-14`);
+      this.trailAnimation.setAttribute("dur", "0.3s");
+      this.trailElement.appendChild(this.trailAnimation);
+    }
   }
 
   get hasCurrentTrail() {
@@ -104,8 +118,23 @@ export class AnimatedTrail implements Trail {
     }
   }
 
+  getCurrentTrail() {
+    return this.currentTrail;
+  }
+
+  clearTrails() {
+    this.pastTrails = [];
+    this.currentTrail = undefined;
+    this.update();
+  }
+
   private update() {
+    this.pastTrails = [];
     this.start();
+    if (this.trailAnimation) {
+      this.trailAnimation.setAttribute("begin", "indefinite");
+      this.trailAnimation.setAttribute("repeatCount", "indefinite");
+    }
   }
 
   private onFrame() {
@@ -132,14 +161,25 @@ export class AnimatedTrail implements Trail {
     const svgPaths = paths.join(" ").trim();
 
     this.trailElement.setAttribute("d", svgPaths);
-    this.trailElement.setAttribute(
-      "fill",
-      (this.options.fill ?? (() => "black"))(this),
-    );
+    if (this.trailAnimation) {
+      this.trailElement.setAttribute(
+        "fill",
+        (this.options.fill ?? (() => "black"))(this),
+      );
+      this.trailElement.setAttribute(
+        "stroke",
+        (this.options.stroke ?? (() => "black"))(this),
+      );
+    } else {
+      this.trailElement.setAttribute(
+        "fill",
+        (this.options.fill ?? (() => "black"))(this),
+      );
+    }
   }
 
   private drawTrail(trail: LaserPointer, state: AppState): string {
-    const stroke = trail
+    const _stroke = trail
       .getStrokeOutline(trail.options.size / state.zoom.value)
       .map(([x, y]) => {
         const result = sceneCoordsToViewportCoords(
@@ -150,6 +190,14 @@ export class AnimatedTrail implements Trail {
         return [result.x, result.y];
       });
 
+    const stroke = this.trailAnimation
+      ? _stroke.slice(
+          // slicing from 6th point to get rid of the initial notch type of thing
+          Math.min(_stroke.length, 6),
+          _stroke.length / 2,
+        )
+      : _stroke;
+
     return getSvgPathFromStroke(stroke, true);
   }
 }

+ 1 - 0
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,

+ 30 - 2
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 (
           <ToolButton
             className={clsx("Shape", { fillable })}
@@ -333,6 +336,14 @@ export const ShapesSwitcher = ({
               if (!appState.penDetected && pointerType === "pen") {
                 app.togglePenMode(true);
               }
+
+              if (value === "selection") {
+                if (appState.activeTool.type === "selection") {
+                  app.setActiveTool({ type: "lasso" });
+                } else {
+                  app.setActiveTool({ type: "selection" });
+                }
+              }
             }}
             onChange={({ pointerType }) => {
               if (appState.activeTool.type !== value) {
@@ -358,6 +369,7 @@ export const ShapesSwitcher = ({
             "App-toolbar__extra-tools-trigger--selected":
               frameToolSelected ||
               embeddableToolSelected ||
+              lassoToolSelected ||
               // in collab we're already highlighting the laser button
               // outside toolbar, so let's not highlight extra-tools button
               // on top of it
@@ -366,7 +378,15 @@ export const ShapesSwitcher = ({
           onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
           title={t("toolBar.extraTools")}
         >
-          {extraToolsIcon}
+          {frameToolSelected
+            ? frameToolIcon
+            : embeddableToolSelected
+            ? EmbedIcon
+            : laserToolSelected && !app.props.isCollaborating
+            ? laserPointerToolIcon
+            : lassoToolSelected
+            ? LassoIcon
+            : extraToolsIcon}
         </DropdownMenu.Trigger>
         <DropdownMenu.Content
           onClickOutside={() => setIsExtraToolsMenuOpen(false)}
@@ -399,6 +419,14 @@ export const ShapesSwitcher = ({
           >
             {t("toolBar.laser")}
           </DropdownMenu.Item>
+          <DropdownMenu.Item
+            onSelect={() => app.setActiveTool({ type: "lasso" })}
+            icon={LassoIcon}
+            data-testid="toolbar-lasso"
+            selected={lassoToolSelected}
+          >
+            {t("toolBar.lasso")}
+          </DropdownMenu.Item>
           <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
             Generate
           </div>

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

+ 1 - 0
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[] = [

+ 2 - 2
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) {

+ 30 - 28
packages/excalidraw/components/canvases/StaticCanvas.tsx

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

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

+ 1 - 0
packages/excalidraw/data/restore.ts

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

+ 201 - 0
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<ExcalidrawElement["id"]> = new Set();
+  private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
+  private elementsSegments: Map<string, LineSegment<GlobalPoint>[]> | null =
+    null;
+  private keepPreviousSelection: boolean = false;
+
+  constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
+    super(animationFrameHandler, app, {
+      animateTrail: true,
+      streamline: 0.4,
+      sizeMapping: (c) => {
+        const DECAY_TIME = Infinity;
+        const DECAY_LENGTH = 5000;
+        const t = Math.max(
+          0,
+          1 - (performance.now() - c.pressure) / DECAY_TIME,
+        );
+        const l =
+          (DECAY_LENGTH -
+            Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
+          DECAY_LENGTH;
+
+        return Math.min(easeOut(l), easeOut(t));
+      },
+      fill: () => "rgba(105,101,219,0.05)",
+      stroke: () => "rgba(105,101,219)",
+    });
+  }
+
+  startPath(x: number, y: number, keepPreviousSelection = false) {
+    // clear any existing trails just in case
+    this.endPath();
+
+    super.startPath(x, y);
+    this.intersectedElements.clear();
+    this.enclosedElements.clear();
+
+    this.keepPreviousSelection = keepPreviousSelection;
+
+    if (!this.keepPreviousSelection) {
+      this.app.setState({
+        selectedElementIds: {},
+        selectedGroupIds: {},
+        selectedLinearElement: null,
+      });
+    }
+  }
+
+  selectElementsFromIds = (ids: string[]) => {
+    this.app.setState((prevState) => {
+      const nextSelectedElementIds = ids.reduce((acc, id) => {
+        acc[id] = true;
+        return acc;
+      }, {} as Record<ExcalidrawElement["id"], true>);
+
+      if (this.keepPreviousSelection) {
+        for (const id of Object.keys(prevState.selectedElementIds)) {
+          nextSelectedElementIds[id] = true;
+        }
+      }
+
+      for (const [id] of Object.entries(nextSelectedElementIds)) {
+        const element = this.app.scene.getNonDeletedElement(id);
+
+        if (element && isTextElement(element)) {
+          const container = getContainerElement(
+            element,
+            this.app.scene.getNonDeletedElementsMap(),
+          );
+          if (container) {
+            nextSelectedElementIds[container.id] = true;
+            delete nextSelectedElementIds[element.id];
+          }
+        }
+      }
+
+      // remove all children of selected frames
+      for (const [id] of Object.entries(nextSelectedElementIds)) {
+        const element = this.app.scene.getNonDeletedElement(id);
+
+        if (element && isFrameLikeElement(element)) {
+          const elementsInFrame = getFrameChildren(
+            this.app.scene.getNonDeletedElementsMap(),
+            element.id,
+          );
+          for (const child of elementsInFrame) {
+            delete nextSelectedElementIds[child.id];
+          }
+        }
+      }
+
+      const nextSelection = selectGroupsForSelectedElements(
+        {
+          editingGroupId: prevState.editingGroupId,
+          selectedElementIds: nextSelectedElementIds,
+        },
+        this.app.scene.getNonDeletedElements(),
+        prevState,
+        this.app,
+      );
+
+      const selectedIds = [...Object.keys(nextSelection.selectedElementIds)];
+      const selectedGroupIds = [...Object.keys(nextSelection.selectedGroupIds)];
+
+      return {
+        selectedElementIds: nextSelection.selectedElementIds,
+        selectedGroupIds: nextSelection.selectedGroupIds,
+        selectedLinearElement:
+          selectedIds.length === 1 &&
+          !selectedGroupIds.length &&
+          isLinearElement(this.app.scene.getNonDeletedElement(selectedIds[0]))
+            ? new LinearElementEditor(
+                this.app.scene.getNonDeletedElement(
+                  selectedIds[0],
+                ) as NonDeleted<ExcalidrawLinearElement>,
+              )
+            : null,
+      };
+    });
+  };
+
+  addPointToPath = (x: number, y: number, keepPreviousSelection = false) => {
+    super.addPointToPath(x, y);
+
+    this.keepPreviousSelection = keepPreviousSelection;
+
+    this.updateSelection();
+  };
+
+  private updateSelection = () => {
+    const lassoPath = super
+      .getCurrentTrail()
+      ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1]));
+
+    if (!this.elementsSegments) {
+      this.elementsSegments = new Map();
+      const visibleElementsMap = arrayToMap(this.app.visibleElements);
+      for (const element of this.app.visibleElements) {
+        const segments = getElementLineSegments(element, visibleElementsMap);
+        this.elementsSegments.set(element.id, segments);
+      }
+    }
+
+    if (lassoPath) {
+      const { selectedElementIds } = getLassoSelectedElementIds({
+        lassoPath,
+        elements: this.app.visibleElements,
+        elementsSegments: this.elementsSegments,
+        intersectedElements: this.intersectedElements,
+        enclosedElements: this.enclosedElements,
+        simplifyDistance: 5 / this.app.state.zoom.value,
+      });
+
+      this.selectElementsFromIds(selectedElementIds);
+    }
+  };
+
+  endPath(): void {
+    super.endPath();
+    super.clearTrails();
+    this.intersectedElements.clear();
+    this.enclosedElements.clear();
+    this.elementsSegments = null;
+  }
+}

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

+ 1 - 0
packages/excalidraw/locales/en.json

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

+ 17 - 4
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
         </svg>,
         "label": "labels.elementLock.unlockAll",
         "name": "unlockAllElements",
-        "paletteName": "Unlock all elements",
         "perform": [Function],
         "predicate": [Function],
         "trackEvent": {
@@ -7559,7 +7573,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
         "keyTest": [Function],
         "label": "buttons.zenMode",
         "name": "zenMode",
-        "paletteName": "Toggle zen mode",
         "perform": [Function],
         "predicate": [Function],
         "trackEvent": {
@@ -7603,7 +7616,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
         "keyTest": [Function],
         "label": "labels.viewMode",
         "name": "viewMode",
-        "paletteName": "Toggle view mode",
         "perform": [Function],
         "predicate": [Function],
         "trackEvent": {
@@ -7677,7 +7689,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
         ],
         "label": "stats.fullTitle",
         "name": "stats",
-        "paletteName": "Toggle stats",
         "perform": [Function],
         "trackEvent": {
           "category": "menu",
@@ -7814,6 +7825,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "activeEmbeddable": null,
   "activeTool": {
     "customType": null,
+    "fromSelection": false,
     "lastActiveTool": null,
     "locked": false,
     "type": "selection",
@@ -8802,6 +8814,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "activeEmbeddable": null,
   "activeTool": {
     "customType": null,
+    "fromSelection": false,
     "lastActiveTool": null,
     "locked": false,
     "type": "selection",

+ 58 - 0
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",

+ 52 - 0
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",

+ 4 - 1
packages/excalidraw/tests/helpers/ui.ts

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

+ 1813 - 0
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(<Excalidraw handleKeyboardGlobally={true} />);
+  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<GlobalPoint>(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<GlobalPoint>(-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<GlobalPoint>(-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<GlobalPoint>(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<GlobalPoint>(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<GlobalPoint>(-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<GlobalPoint>(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);
+  });
+});

+ 3 - 0
packages/excalidraw/types.ts

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

+ 7 - 3
packages/math/src/segment.ts

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

+ 1 - 0
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",

+ 5 - 28
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"
-
[email protected], strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", [email protected], 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 @@ [email protected], 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"