diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a73a7d28bb..a615aea52d 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -1281,7 +1281,7 @@ export const elementCenterPoint = ( xOffset: number = 0, yOffset: number = 0, ) => { - if (isLinearElement(element)) { + if (isLinearElement(element) || isFreeDrawElement(element)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x, y] = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index b17d563dbe..4a5abf47d0 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -154,14 +154,11 @@ export const hitElementItself = ({ // Hit test against the extended, rotated bounding box of the element first const bounds = getElementBounds(element, elementsMap, true); - const hitBounds = isPointWithinBounds( - pointFrom(bounds[0] - threshold, bounds[1] - threshold), - pointRotateRads( - point, - getCenterForBounds(bounds), - -element.angle as Radians, - ), - pointFrom(bounds[2] + threshold, bounds[3] + threshold), + const hitBounds = isPointInRotatedBounds( + point, + bounds, + element.angle, + threshold, ); // PERF: Bail out early if the point is not even in the @@ -192,18 +189,98 @@ export const hitElementItself = ({ return result; }; +const getBoundsCorners = (bounds: Bounds, angle: Radians = 0 as Radians) => { + const [x1, y1, x2, y2] = bounds; + const center = getCenterForBounds(bounds); + const corners = [ + pointFrom(x1, y1), + pointFrom(x2, y1), + pointFrom(x2, y2), + pointFrom(x1, y2), + ] as const; + + if (angle === 0) { + return corners; + } + + return corners.map((point) => pointRotateRads(point, center, angle)) as [ + GlobalPoint, + GlobalPoint, + GlobalPoint, + GlobalPoint, + ]; +}; + +const getBoundsEdges = ( + corners: readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint], +) => + [ + lineSegment(corners[0], corners[1]), + lineSegment(corners[1], corners[2]), + lineSegment(corners[2], corners[3]), + lineSegment(corners[3], corners[0]), + ] as const; + +const isPointInRotatedBounds = ( + point: GlobalPoint, + bounds: Bounds, + angle: Radians, + tolerance = 0, +) => { + const adjustedPoint = + angle === 0 + ? point + : pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians); + + return isPointWithinBounds( + pointFrom(bounds[0] - tolerance, bounds[1] - tolerance), + adjustedPoint, + pointFrom(bounds[2] + tolerance, bounds[3] + tolerance), + ); +}; + export const hitElementBoundingBox = ( point: GlobalPoint, element: ExcalidrawElement, elementsMap: ElementsMap, tolerance = 0, ) => { - let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); - x1 -= tolerance; - y1 -= tolerance; - x2 += tolerance; - y2 += tolerance; - return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2)); + const bounds = getElementBounds(element, elementsMap, true); + return isPointInRotatedBounds(point, bounds, element.angle, tolerance); +}; + +export const doBoundsIntersectElementBoundingBox = ( + intersectorBounds: Bounds, + element: ExcalidrawElement, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = intersectorBounds; + const intersectorCorners = [ + pointFrom(x1, y1), + pointFrom(x2, y1), + pointFrom(x2, y2), + pointFrom(x1, y2), + ] as const; + const intersectorEdges = getBoundsEdges(intersectorCorners); + + const elementBounds = getElementBounds(element, elementsMap, true); + const elementBoundsCorners = getBoundsCorners(elementBounds, element.angle); + const elementBoundsEdges = getBoundsEdges(elementBoundsCorners); + + return ( + elementBoundsCorners.some((point) => + isPointWithinBounds(intersectorCorners[0], point, intersectorCorners[2]), + ) || + intersectorCorners.some((point) => + isPointInRotatedBounds(point, elementBounds, element.angle), + ) || + intersectorEdges.some((selectionEdge) => + elementBoundsEdges.some( + (elementBoundsEdge) => + !!lineSegmentIntersectionPoints(selectionEdge, elementBoundsEdge), + ), + ) + ); }; export const hitElementBoundingBoxOnly = ( diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 3c82099546..c5f0a7bee2 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -99,7 +99,7 @@ export const isElementContainingFrame = ( element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, elementsMap: ElementsMap, -) => { +): boolean => { return getElementsWithinSelection([frame], element, elementsMap).some( (e) => e.id === frame.id, ); @@ -140,7 +140,7 @@ export const elementOverlapsWithFrame = ( element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, elementsMap: ElementsMap, -) => { +): boolean => { return ( elementsAreInFrameBounds([element], frame, elementsMap) || isElementIntersectingFrame(element, frame, elementsMap) || diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index ea7fdb1b77..0c11328581 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -1,11 +1,27 @@ -import { arrayToMap, isShallowEqual } from "@excalidraw/common"; +import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; +import { + lineSegment, + pointFrom, + type GlobalPoint, + type LineSegment, +} from "@excalidraw/math"; import type { AppState, + BoxSelectionMode, InteractiveCanvasAppState, } from "@excalidraw/excalidraw/types"; -import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; +import { + getElementAbsoluteCoords, + getElementBounds, + getElementLineSegments, +} from "./bounds"; +import { + doBoundsIntersectElementBoundingBox, + intersectElementWithLineSegment, + shouldTestInside, +} from "./collision"; import { isElementInViewport } from "./sizeHelpers"; import { isBoundToContainer, @@ -30,6 +46,180 @@ import type { NonDeletedExcalidrawElement, } from "./types"; +// Broad-phase only for overlap mode. This decides whether plain AABB overlap +// should be refined via the element's rotated local bounds. Elements that fail +// `shouldTestInside()` still go through the outline-specific path below, so +// this is intentionally not the whole overlap-selection policy. +const shouldUseRotatedOverlapBroadPhase = ( + element: NonDeletedExcalidrawElement, +) => + element.angle !== 0 && + (isTextElement(element) || + element.type === "freedraw" || + element.type === "image"); + +const clipLineSegmentToBounds = ( + segment: LineSegment, + bounds: Bounds, +): LineSegment | null => { + const [minX, minY, maxX, maxY] = bounds; + const [[x1, y1], [x2, y2]] = segment; + const deltaX = x2 - x1; + const deltaY = y2 - y1; + let tMin = 0; + let tMax = 1; + + const clip = (p: number, q: number) => { + if (p === 0) { + return q >= 0; + } + + const ratio = q / p; + + if (p < 0) { + if (ratio > tMax) { + return false; + } + tMin = Math.max(tMin, ratio); + return true; + } + + if (ratio < tMin) { + return false; + } + tMax = Math.min(tMax, ratio); + return true; + }; + + if ( + !clip(-deltaX, x1 - minX) || + !clip(deltaX, maxX - x1) || + !clip(-deltaY, y1 - minY) || + !clip(deltaY, maxY - y1) + ) { + return null; + } + + return lineSegment( + pointFrom(x1 + tMin * deltaX, y1 + tMin * deltaY), + pointFrom(x1 + tMax * deltaX, y1 + tMax * deltaY), + ); +}; + +const isPointWithinAabb = (point: GlobalPoint, bounds: Bounds) => + point[0] >= bounds[0] && + point[0] <= bounds[2] && + point[1] >= bounds[1] && + point[1] <= bounds[3]; + +const shouldSkipElementFromSelection = (element: NonDeletedExcalidrawElement) => + element.locked || element.type === "selection" || isBoundToContainer(element); + +const getFrameBoundsForSelection = ( + element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, +): Bounds | null => { + if (!element.frameId) { + return null; + } + + const containingFrame = getContainingFrame(element, elementsMap); + return containingFrame + ? (getElementBounds(containingFrame, elementsMap) as Bounds) + : null; +}; + +const finalizeElementsInSelection = ( + elementsInSelection: NonDeletedExcalidrawElement[], + excludeElementsInFrames: boolean, + elementsMap: ElementsMap, +): NonDeletedExcalidrawElement[] => { + elementsInSelection = excludeElementsInFrames + ? excludeElementsInFramesFromSelection(elementsInSelection) + : elementsInSelection; + + return elementsInSelection.filter((element) => { + const containingFrame = getContainingFrame(element, elementsMap); + + if (containingFrame) { + return elementOverlapsWithFrame(element, containingFrame, elementsMap); + } + + return true; + }); +}; + +const getSelectionEdges = ( + selectionBounds: Bounds, +): readonly LineSegment[] => { + const selectionTopLeft = pointFrom( + selectionBounds[0], + selectionBounds[1], + ); + const selectionBottomRight = pointFrom( + selectionBounds[2], + selectionBounds[3], + ); + + return [ + lineSegment( + selectionTopLeft, + pointFrom(selectionBounds[2], selectionBounds[1]), + ), + lineSegment( + pointFrom(selectionBounds[2], selectionBounds[1]), + selectionBottomRight, + ), + lineSegment( + selectionBottomRight, + pointFrom(selectionBounds[0], selectionBounds[3]), + ), + lineSegment( + pointFrom(selectionBounds[0], selectionBounds[3]), + selectionTopLeft, + ), + ]; +}; + +const getVisibleElementOutlineSegments = ( + element: NonDeletedExcalidrawElement, + frameBounds: Bounds | null, + elementsMap: ElementsMap, +) => + frameBounds + ? getElementLineSegments(element, elementsMap).flatMap((segment) => { + const clippedSegment = clipLineSegmentToBounds(segment, frameBounds); + return clippedSegment ? [clippedSegment] : []; + }) + : getElementLineSegments(element, elementsMap); + +const doesSelectionIntersectElementOutline = ( + element: NonDeletedExcalidrawElement, + frameBounds: Bounds | null, + selectionEdges: readonly LineSegment[], + elementsMap: ElementsMap, +) => + selectionEdges.some((selectionEdge) => + intersectElementWithLineSegment( + element, + elementsMap, + selectionEdge, + 0, + true, + ).some((point) => !frameBounds || isPointWithinAabb(point, frameBounds)), + ); + +const doesSelectionContainElementOutline = ( + outlineSegments: readonly LineSegment[], + selectionBounds: Bounds, +) => + outlineSegments.length > 0 && + outlineSegments.every( + (outlineSegment) => + isPointWithinAabb(outlineSegment[0], selectionBounds) && + isPointWithinAabb(outlineSegment[1], selectionBounds), + ); + /** * Frames and their containing elements are not to be selected at the same time. * Given an array of selected elements, if there are frames and their containing elements @@ -62,55 +252,153 @@ export const getElementsWithinSelection = ( selection: NonDeletedExcalidrawElement, elementsMap: ElementsMap, excludeElementsInFrames: boolean = true, -) => { - const [selectionX1, selectionY1, selectionX2, selectionY2] = + boxSelectionMode: BoxSelectionMode = "contain", +): NonDeletedExcalidrawElement[] => { + const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] = getElementAbsoluteCoords(selection, elementsMap); + const selectionX1 = Math.min(selectionStartX, selectionEndX); + const selectionY1 = Math.min(selectionStartY, selectionEndY); + const selectionX2 = Math.max(selectionStartX, selectionEndX); + const selectionY2 = Math.max(selectionStartY, selectionEndY); + const selectionBounds = [ + selectionX1, + selectionY1, + selectionX2, + selectionY2, + ] as Bounds; - let elementsInSelection = elements.filter((element) => { - let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( - element, - elementsMap, - ); + if (boxSelectionMode !== "overlap") { + const elementsInSelection: NonDeletedExcalidrawElement[] = []; - const containingFrame = getContainingFrame(element, elementsMap); - if (containingFrame) { - const [fx1, fy1, fx2, fy2] = getElementBounds( - containingFrame, - elementsMap, - ); + for (const element of elements) { + if (shouldSkipElementFromSelection(element)) { + continue; + } - elementX1 = Math.max(fx1, elementX1); - elementY1 = Math.max(fy1, elementY1); - elementX2 = Math.min(fx2, elementX2); - elementY2 = Math.min(fy2, elementY2); + const elementBounds = getElementBounds(element, elementsMap) as Bounds; + const frameBounds = getFrameBoundsForSelection(element, elementsMap); + let elementX1 = elementBounds[0]; + let elementY1 = elementBounds[1]; + let elementX2 = elementBounds[2]; + let elementY2 = elementBounds[3]; + + if (frameBounds) { + elementX1 = Math.max(frameBounds[0], elementX1); + elementY1 = Math.max(frameBounds[1], elementY1); + elementX2 = Math.min(frameBounds[2], elementX2); + elementY2 = Math.min(frameBounds[3], elementY2); + } + + if ( + selectionX1 <= elementX1 && + selectionY1 <= elementY1 && + selectionX2 >= elementX2 && + selectionY2 >= elementY2 + ) { + elementsInSelection.push(element); + } } - return ( - element.locked === false && - element.type !== "selection" && - !isBoundToContainer(element) && + return finalizeElementsInSelection( + elementsInSelection, + excludeElementsInFrames, + elementsMap, + ); + } + + const selectionEdges = getSelectionEdges(selectionBounds); + const elementsInSelection: NonDeletedExcalidrawElement[] = []; + + for (const element of elements) { + if (shouldSkipElementFromSelection(element)) { + continue; + } + + const elementBounds = getElementBounds(element, elementsMap) as Bounds; + const frameBounds = getFrameBoundsForSelection(element, elementsMap); + let elementX1 = elementBounds[0]; + let elementY1 = elementBounds[1]; + let elementX2 = elementBounds[2]; + let elementY2 = elementBounds[3]; + + if (frameBounds) { + elementX1 = Math.max(frameBounds[0], elementX1); + elementY1 = Math.max(frameBounds[1], elementY1); + elementX2 = Math.min(frameBounds[2], elementX2); + elementY2 = Math.min(frameBounds[3], elementY2); + } + + const isSelectionContainingElement = selectionX1 <= elementX1 && selectionY1 <= elementY1 && selectionX2 >= elementX2 && - selectionY2 >= elementY2 - ); - }); + selectionY2 >= elementY2; - elementsInSelection = excludeElementsInFrames - ? excludeElementsInFramesFromSelection(elementsInSelection) - : elementsInSelection; + const isSelectionOverlappingElementAabb = + selectionX1 <= elementX2 && + selectionY1 <= elementY2 && + selectionX2 >= elementX1 && + selectionY2 >= elementY1; + const isSelectionOverlappingElement = shouldUseRotatedOverlapBroadPhase( + element, + ) + ? isSelectionOverlappingElementAabb && + doBoundsIntersectElementBoundingBox( + selectionBounds, + element, + elementsMap, + ) + : isSelectionOverlappingElementAabb; - elementsInSelection = elementsInSelection.filter((element) => { - const containingFrame = getContainingFrame(element, elementsMap); + const shouldSelectFromInside = shouldTestInside(element); - if (containingFrame) { - return elementOverlapsWithFrame(element, containingFrame, elementsMap); + if (shouldSelectFromInside) { + if (isSelectionOverlappingElement) { + elementsInSelection.push(element); + } + continue; } - return true; - }); + if (!isSelectionOverlappingElement) { + continue; + } - return elementsInSelection; + if (isSelectionContainingElement) { + elementsInSelection.push(element); + continue; + } + + if ( + doesSelectionIntersectElementOutline( + element, + frameBounds, + selectionEdges, + elementsMap, + ) + ) { + elementsInSelection.push(element); + continue; + } + + const outlineSegments = getVisibleElementOutlineSegments( + element, + frameBounds, + elementsMap, + ); + + if ( + outlineSegments.length > 0 && + doesSelectionContainElementOutline(outlineSegments, selectionBounds) + ) { + elementsInSelection.push(element); + } + } + + return finalizeElementsInSelection( + elementsInSelection, + excludeElementsInFrames, + elementsMap, + ); }; export const getVisibleAndNonSelectedElements = ( diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index e51865b2ea..c8a87b63d6 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -193,6 +193,7 @@ const APP_STATE_STORAGE_CONF = (< gridModeEnabled: { browser: true, export: true, server: true }, height: { browser: false, export: false, server: false }, isBindingEnabled: { browser: true, export: false, server: false }, + boxSelectionMode: { browser: true, export: false, server: false }, bindingPreference: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false }, defaultSidebarDockedPreference: { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5561594cb0..767a751e25 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -10267,6 +10267,7 @@ class App extends React.Component { this.state.selectionElement, this.scene.getNonDeletedElementsMap(), false, + this.state.boxSelectionMode, ) : []; diff --git a/packages/excalidraw/components/RadioGroup.scss b/packages/excalidraw/components/RadioGroup.scss index 28ddc8889b..d550d95b3b 100644 --- a/packages/excalidraw/components/RadioGroup.scss +++ b/packages/excalidraw/components/RadioGroup.scss @@ -26,13 +26,16 @@ background: var(--RadioGroup-background); border: 1px solid var(--RadioGroup-border); + gap: 2px; + &__choice { position: relative; display: flex; align-items: center; justify-content: center; - width: 32px; + min-width: 20px; height: 24px; + padding: 0 0.375rem; color: var(--RadioGroup-choice-color-off); background: var(--RadioGroup-choice-background-off); diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index e207203d60..3080314250 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -2,7 +2,7 @@ .excalidraw { .dropdown-menu { - max-width: 16rem; + max-width: 20rem; z-index: 1; &--placement-top { diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index 4f2986c30e..63646745fb 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -1,4 +1,5 @@ import { useEditorInterface } from "../App"; +import { Ellipsify } from "../Ellipsify"; import { RadioGroup } from "../RadioGroup"; type Props = { @@ -12,6 +13,7 @@ type Props = { onChange: (value: T) => void; children: React.ReactNode; name: string; + icon?: React.ReactNode; }; const DropdownMenuItemContentRadio = ({ @@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = ({ choices, children, name, + icon, }: Props) => { const editorInterface = useEditorInterface(); return ( <>
- + {icon &&
{icon}
} + { ); }; +const PreferencesBoxSelectionModeItem = () => { + const { t } = useI18n(); + const appState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + const boxSelectionMode = appState.boxSelectionMode ?? "contain"; + + return ( + + name="boxSelectionMode" + icon={emptyIcon} + value={boxSelectionMode} + onChange={(value) => { + setAppState({ + boxSelectionMode: value === "contain" ? undefined : value, + }); + }} + choices={[ + { + value: "contain", + label: t("labels.boxSelectionContain"), + ariaLabel: t("labels.boxSelectionContain"), + }, + { + value: "overlap", + label: t("labels.boxSelectionOverlap"), + ariaLabel: t("labels.boxSelectionOverlap"), + }, + ]} + > + {t("labels.boxSelectionMode")} + + ); +}; + const PreferencesToggleSnapModeItem = () => { const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); @@ -568,6 +608,7 @@ export const Preferences = ({ {children || ( <> + @@ -585,6 +626,7 @@ export const Preferences = ({ }; Preferences.ToggleToolLock = PreferencesToggleToolLockItem; +Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 1ed068dc1c..7a8b9e586c 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -936,6 +936,12 @@ export const restoreAppState = ( : defaultValue; } + const boxSelectionMode = + appState.boxSelectionMode ?? localAppState?.boxSelectionMode; + if (boxSelectionMode !== undefined) { + nextAppState.boxSelectionMode = boxSelectionMode; + } + return { ...nextAppState, cursorButton: localAppState?.cursorButton || "up", diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 42e87bdf82..05d3bf702e 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -185,6 +185,9 @@ "shapeSwitch": "Switch shape", "preferences": "Preferences", "preferences_toolLock": "Tool lock", + "boxSelectionMode": "Select on", + "boxSelectionContain": "Wrap", + "boxSelectionOverlap": "Overlap", "arrowBinding": "Arrow binding", "midpointSnapping": "Snap to midpoints" }, diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 15458fa366..d178cc64ab 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -4358,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 760410951, + "versionNonce": 1006504105, "width": 20, "x": -10, "y": 0, @@ -4383,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 1006504105, + "versionNonce": 289600103, "width": 20, "x": 20, "y": 30, @@ -6864,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 747212839, + "versionNonce": 1723083209, "width": 10, "x": -10, "y": 0, @@ -6891,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1723083209, + "versionNonce": 760410951, "width": 10, "x": 12, "y": 0, diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 64204f1a00..449f6c13dc 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -1,7 +1,9 @@ import React from "react"; import { vi } from "vitest"; -import { KEYS, reseed } from "@excalidraw/common"; +import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common"; +import { getElementBounds, getElementLineSegments } from "@excalidraw/element"; +import { pointFrom, type LocalPoint } from "@excalidraw/math"; import { SHAPES } from "../components/shapes"; @@ -12,6 +14,7 @@ import * as StaticScene from "../renderer/staticScene"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { + act, render, fireEvent, mockBoundingClientRect, @@ -39,6 +42,19 @@ const { h } = window; const mouse = new Pointer("mouse"); +const getOutlineBounds = (element: ReturnType) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const points = getElementLineSegments(sceneElement, elementsMap).flat(); + + return [ + Math.min(...points.map((point) => point[0])), + Math.min(...points.map((point) => point[1])), + Math.max(...points.map((point) => point[0])), + Math.max(...points.map((point) => point[1])), + ] as const; +}; + describe("box-selection", () => { beforeEach(async () => { await render(); @@ -108,6 +124,368 @@ describe("box-selection", () => { assertSelectedElements([]); }); + + it("should not select an element when the selection box only partially overlaps it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + mouse.downAt(25, -20); + mouse.move(-1000, -1000); + mouse.moveTo(75, 70); + mouse.up(); + + assertSelectedElements([]); + }); +}); + +describe("lasso reselection", () => { + beforeEach(async () => { + await render(); + }); + + it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => { + const rectA = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "red", + fillStyle: "solid", + }); + const rectB = API.createElement({ + type: "rectangle", + x: 220, + y: 0, + width: 100, + height: 100, + backgroundColor: "blue", + fillStyle: "solid", + }); + + API.setElements([rectA, rectB]); + mouse.select([rectA, rectB]); + act(() => { + h.app.setActiveTool({ type: "lasso" }); + }); + + Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => { + mouse.downAt(110, 50); + mouse.moveTo(50, -20); + + expect(h.app.lassoTrail.hasCurrentTrail).toBe(true); + + mouse.moveTo(-20, 50); + mouse.moveTo(50, 120); + mouse.moveTo(110, 50); + mouse.up(); + }); + + assertSelectedElements([rectA.id]); + }); +}); + +describe("box-selection overlap mode", () => { + const boxSelect = ( + startX: number, + startY: number, + endX: number, + endY: number, + ) => { + mouse.downAt(startX, startY); + mouse.move(-1000, -1000); + mouse.moveTo(endX, endY); + mouse.up(); + }; + + beforeEach(async () => { + await render( + , + ); + }); + + it("should select an element when the selection box partially overlaps it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, -20, 75, 70); + + assertSelectedElements([rect1.id]); + }); + + it("should not select a transparent rectangle when the selection box stays inside it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, 25, 75, 75); + + assertSelectedElements([]); + }); + + it("should select a transparent rectangle when the selection box crosses its outline", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, 25, 125, 75); + + assertSelectedElements([rect1.id]); + }); + + it("should not select a rotated transparent rectangle when the selection box stays inside it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(40, 40, 60, 60); + + assertSelectedElements([]); + }); + + it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 180, + angle: Math.PI / 6, + backgroundColor: "transparent", + fillStyle: "solid", + roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS }, + roughness: 0, + }); + + API.setElements([rect]); + + const sceneRect = API.getElement(rect); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds( + sceneRect, + elementsMap, + ); + const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect); + + expect(outlineX1).toBeGreaterThan(boundsX1); + expect(outlineY1).toBeGreaterThan(boundsY1); + expect(outlineX2).toBeLessThan(boundsX2); + expect(outlineY2).toBeLessThan(boundsY2); + + boxSelect( + outlineX1 - (outlineX1 - boundsX1) / 2, + outlineY1 - (outlineY1 - boundsY1) / 2, + outlineX2 + (boundsX2 - outlineX2) / 2, + outlineY2 + (boundsY2 - outlineY2) / 2, + ); + + assertSelectedElements([rect.id]); + }); + + it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => { + const text = API.createElement({ + type: "text", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + text: "test", + }); + + API.setElements([text]); + + boxSelect(-18, -18, -8, -8); + + assertSelectedElements([]); + }); + + it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => { + const image = API.createElement({ + type: "image", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + fileId: "file_A", + status: "saved", + }); + + API.setElements([image]); + + boxSelect(-18, -18, -8, -8); + + assertSelectedElements([]); + }); + + it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect]); + + mouse.clickAt(50, 50); + assertSelectedElements([rect.id]); + + const sceneRect = API.getElement(rect); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneRect, elementsMap); + + mouse.clickAt(x1 + 2, y1 + 2); + + assertSelectedElements([]); + }); + + it("should not select a line when the selection box only overlaps its bounds", () => { + const line = API.createElement({ + type: "line", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + points: [pointFrom(0, 0), pointFrom(100, 100)], + }); + + API.setElements([line]); + + boxSelect(20, 50, 30, 60); + + assertSelectedElements([]); + }); + + it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => { + const freedraw = API.createElement({ + type: "freedraw", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "transparent", + points: [ + pointFrom(0, 0), + pointFrom(100, 0), + pointFrom(100, 100), + pointFrom(0, 100), + pointFrom(0, 0), + ], + }); + + API.setElements([freedraw]); + + const sceneFreedraw = API.getElement(freedraw); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap); + + mouse.clickAt(x1 + 2, y1 + 2); + + assertSelectedElements([]); + }); + + it("should not select a freedraw when the selection box only overlaps its bounds", () => { + const freedraw = API.createElement({ + type: "freedraw", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + points: [ + pointFrom(0, 0), + pointFrom(50, 50), + pointFrom(100, 100), + ], + }); + + API.setElements([freedraw]); + + boxSelect(20, 50, 30, 60); + + assertSelectedElements([]); + }); + + it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => { + const frame = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + const rect1 = API.createElement({ + type: "rectangle", + x: 50, + y: 10, + width: 100, + height: 80, + frameId: frame.id, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([frame, rect1]); + + boxSelect(60, 20, 90, 60); + + assertSelectedElements([]); + }); }); describe("inner box-selection", () => { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index cae4e7b93f..79c35dd1c8 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -269,6 +269,8 @@ export type ObservedElementsAppState = { activeLockedId: AppState["activeLockedId"]; }; +export type BoxSelectionMode = "contain" | "overlap"; + export interface AppState { contextMenu: { items: ContextMenuItems; @@ -307,6 +309,8 @@ export interface AppState { * `bindingPreference` and keyboard modifiers (ctrl/alt) */ isBindingEnabled: boolean; + /** user box selection preference; defaults to "contain" when unset */ + boxSelectionMode?: BoxSelectionMode; /** user arrow binding preference */ bindingPreference: "enabled" | "disabled"; /** user preference whether arrow snap to midpoints while binding */