feat(editor): implement overlap box selection

This commit is contained in:
dwelle
2026-03-27 13:41:53 +01:00
parent 4a5c9e990c
commit 379dba47aa
15 changed files with 873 additions and 64 deletions
+1 -1
View File
@@ -1281,7 +1281,7 @@ export const elementCenterPoint = (
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
if (isLinearElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+91 -14
View File
@@ -154,14 +154,11 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first // Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true); const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointWithinBounds( const hitBounds = isPointInRotatedBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold), point,
pointRotateRads( bounds,
point, element.angle,
getCenterForBounds(bounds), threshold,
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
); );
// PERF: Bail out early if the point is not even in the // PERF: Bail out early if the point is not even in the
@@ -192,18 +189,98 @@ export const hitElementItself = ({
return result; return result;
}; };
const getBoundsCorners = (bounds: Bounds, angle: Radians = 0 as Radians) => {
const [x1, y1, x2, y2] = bounds;
const center = getCenterForBounds(bounds);
const corners = [
pointFrom<GlobalPoint>(x1, y1),
pointFrom<GlobalPoint>(x2, y1),
pointFrom<GlobalPoint>(x2, y2),
pointFrom<GlobalPoint>(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 = ( export const hitElementBoundingBox = (
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
) => { ) => {
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const bounds = getElementBounds(element, elementsMap, true);
x1 -= tolerance; return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
y1 -= tolerance; };
x2 += tolerance;
y2 += tolerance; export const doBoundsIntersectElementBoundingBox = (
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2)); intersectorBounds: Bounds,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
const [x1, y1, x2, y2] = intersectorBounds;
const intersectorCorners = [
pointFrom<GlobalPoint>(x1, y1),
pointFrom<GlobalPoint>(x2, y1),
pointFrom<GlobalPoint>(x2, y2),
pointFrom<GlobalPoint>(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 = ( export const hitElementBoundingBoxOnly = (
+2 -2
View File
@@ -99,7 +99,7 @@ export const isElementContainingFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ): boolean => {
return getElementsWithinSelection([frame], element, elementsMap).some( return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
); );
@@ -140,7 +140,7 @@ export const elementOverlapsWithFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ): boolean => {
return ( return (
elementsAreInFrameBounds([element], frame, elementsMap) || elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) || isElementIntersectingFrame(element, frame, elementsMap) ||
+324 -36
View File
@@ -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 { import type {
AppState, AppState,
BoxSelectionMode,
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types"; } 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 { isElementInViewport } from "./sizeHelpers";
import { import {
isBoundToContainer, isBoundToContainer,
@@ -30,6 +46,180 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } 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<GlobalPoint>,
bounds: Bounds,
): LineSegment<GlobalPoint> | 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<GlobalPoint>(x1 + tMin * deltaX, y1 + tMin * deltaY),
pointFrom<GlobalPoint>(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<GlobalPoint>[] => {
const selectionTopLeft = pointFrom<GlobalPoint>(
selectionBounds[0],
selectionBounds[1],
);
const selectionBottomRight = pointFrom<GlobalPoint>(
selectionBounds[2],
selectionBounds[3],
);
return [
lineSegment(
selectionTopLeft,
pointFrom<GlobalPoint>(selectionBounds[2], selectionBounds[1]),
),
lineSegment(
pointFrom<GlobalPoint>(selectionBounds[2], selectionBounds[1]),
selectionBottomRight,
),
lineSegment(
selectionBottomRight,
pointFrom<GlobalPoint>(selectionBounds[0], selectionBounds[3]),
),
lineSegment(
pointFrom<GlobalPoint>(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<GlobalPoint>[],
elementsMap: ElementsMap,
) =>
selectionEdges.some((selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
0,
true,
).some((point) => !frameBounds || isPointWithinAabb(point, frameBounds)),
);
const doesSelectionContainElementOutline = (
outlineSegments: readonly LineSegment<GlobalPoint>[],
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. * 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 * Given an array of selected elements, if there are frames and their containing elements
@@ -62,55 +252,153 @@ export const getElementsWithinSelection = (
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
excludeElementsInFrames: boolean = true, excludeElementsInFrames: boolean = true,
) => { boxSelectionMode: BoxSelectionMode = "contain",
const [selectionX1, selectionY1, selectionX2, selectionY2] = ): NonDeletedExcalidrawElement[] => {
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap); 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) => { if (boxSelectionMode !== "overlap") {
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( const elementsInSelection: NonDeletedExcalidrawElement[] = [];
element,
elementsMap,
);
const containingFrame = getContainingFrame(element, elementsMap); for (const element of elements) {
if (containingFrame) { if (shouldSkipElementFromSelection(element)) {
const [fx1, fy1, fx2, fy2] = getElementBounds( continue;
containingFrame, }
elementsMap,
);
elementX1 = Math.max(fx1, elementX1); const elementBounds = getElementBounds(element, elementsMap) as Bounds;
elementY1 = Math.max(fy1, elementY1); const frameBounds = getFrameBoundsForSelection(element, elementsMap);
elementX2 = Math.min(fx2, elementX2); let elementX1 = elementBounds[0];
elementY2 = Math.min(fy2, elementY2); 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 ( return finalizeElementsInSelection(
element.locked === false && elementsInSelection,
element.type !== "selection" && excludeElementsInFrames,
!isBoundToContainer(element) && 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 && selectionX1 <= elementX1 &&
selectionY1 <= elementY1 && selectionY1 <= elementY1 &&
selectionX2 >= elementX2 && selectionX2 >= elementX2 &&
selectionY2 >= elementY2 selectionY2 >= elementY2;
);
});
elementsInSelection = excludeElementsInFrames const isSelectionOverlappingElementAabb =
? excludeElementsInFramesFromSelection(elementsInSelection) selectionX1 <= elementX2 &&
: elementsInSelection; selectionY1 <= elementY2 &&
selectionX2 >= elementX1 &&
selectionY2 >= elementY1;
const isSelectionOverlappingElement = shouldUseRotatedOverlapBroadPhase(
element,
)
? isSelectionOverlappingElementAabb &&
doBoundsIntersectElementBoundingBox(
selectionBounds,
element,
elementsMap,
)
: isSelectionOverlappingElementAabb;
elementsInSelection = elementsInSelection.filter((element) => { const shouldSelectFromInside = shouldTestInside(element);
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) { if (shouldSelectFromInside) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap); 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 = ( export const getVisibleAndNonSelectedElements = (
+1
View File
@@ -193,6 +193,7 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true }, gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, 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 }, bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: { defaultSidebarDockedPreference: {
+1
View File
@@ -10267,6 +10267,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement, this.state.selectionElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
false, false,
this.state.boxSelectionMode,
) )
: []; : [];
@@ -26,13 +26,16 @@
background: var(--RadioGroup-background); background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border); border: 1px solid var(--RadioGroup-border);
gap: 2px;
&__choice { &__choice {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; min-width: 20px;
height: 24px; height: 24px;
padding: 0 0.375rem;
color: var(--RadioGroup-choice-color-off); color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off); background: var(--RadioGroup-choice-background-off);
@@ -2,7 +2,7 @@
.excalidraw { .excalidraw {
.dropdown-menu { .dropdown-menu {
max-width: 16rem; max-width: 20rem;
z-index: 1; z-index: 1;
&--placement-top { &--placement-top {
@@ -1,4 +1,5 @@
import { useEditorInterface } from "../App"; import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup"; import { RadioGroup } from "../RadioGroup";
type Props<T> = { type Props<T> = {
@@ -12,6 +13,7 @@ type Props<T> = {
onChange: (value: T) => void; onChange: (value: T) => void;
children: React.ReactNode; children: React.ReactNode;
name: string; name: string;
icon?: React.ReactNode;
}; };
const DropdownMenuItemContentRadio = <T,>({ const DropdownMenuItemContentRadio = <T,>({
@@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = <T,>({
choices, choices,
children, children,
name, name,
icon,
}: Props<T>) => { }: Props<T>) => {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
return ( return (
<> <>
<div className="dropdown-menu-item-base dropdown-menu-item-bare"> <div className="dropdown-menu-item-base dropdown-menu-item-bare">
<label className="dropdown-menu-item__text">{children}</label> {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<RadioGroup <RadioGroup
name={name} name={name}
value={value} value={value}
@@ -39,7 +39,13 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons"; import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
@@ -427,6 +433,40 @@ const PreferencesToggleToolLockItem = () => {
); );
}; };
const PreferencesBoxSelectionModeItem = () => {
const { t } = useI18n();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const boxSelectionMode = appState.boxSelectionMode ?? "contain";
return (
<DropdownMenuItemContentRadio<"contain" | "overlap">
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")}
</DropdownMenuItemContentRadio>
);
};
const PreferencesToggleSnapModeItem = () => { const PreferencesToggleSnapModeItem = () => {
const { t } = useI18n(); const { t } = useI18n();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@@ -568,6 +608,7 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu"> <DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || ( {children || (
<> <>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem /> <PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem /> <PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem /> <PreferencesToggleGridModeItem />
@@ -585,6 +626,7 @@ export const Preferences = ({
}; };
Preferences.ToggleToolLock = PreferencesToggleToolLockItem; Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
+6
View File
@@ -936,6 +936,12 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
const boxSelectionMode =
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
if (boxSelectionMode !== undefined) {
nextAppState.boxSelectionMode = boxSelectionMode;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
+3
View File
@@ -185,6 +185,9 @@
"shapeSwitch": "Switch shape", "shapeSwitch": "Switch shape",
"preferences": "Preferences", "preferences": "Preferences",
"preferences_toolLock": "Tool lock", "preferences_toolLock": "Tool lock",
"boxSelectionMode": "Select on",
"boxSelectionContain": "Wrap",
"boxSelectionOverlap": "Overlap",
"arrowBinding": "Arrow binding", "arrowBinding": "Arrow binding",
"midpointSnapping": "Snap to midpoints" "midpointSnapping": "Snap to midpoints"
}, },
@@ -4358,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 760410951, "versionNonce": 1006504105,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4383,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 238820263, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 1006504105, "versionNonce": 289600103,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -6864,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 747212839, "versionNonce": 1723083209,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -6891,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 238820263, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1723083209, "versionNonce": 760410951,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
+379 -1
View File
@@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; 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"; import { SHAPES } from "../components/shapes";
@@ -12,6 +14,7 @@ import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { import {
act,
render, render,
fireEvent, fireEvent,
mockBoundingClientRect, mockBoundingClientRect,
@@ -39,6 +42,19 @@ const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const getOutlineBounds = (element: ReturnType<typeof API.createElement>) => {
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", () => { describe("box-selection", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@@ -108,6 +124,368 @@ describe("box-selection", () => {
assertSelectedElements([]); 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(<Excalidraw />);
});
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(
<Excalidraw
initialData={{ appState: { boxSelectionMode: "overlap" } }}
/>,
);
});
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<LocalPoint>(0, 0), pointFrom<LocalPoint>(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<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 0),
pointFrom<LocalPoint>(100, 100),
pointFrom<LocalPoint>(0, 100),
pointFrom<LocalPoint>(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<LocalPoint>(0, 0),
pointFrom<LocalPoint>(50, 50),
pointFrom<LocalPoint>(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", () => { describe("inner box-selection", () => {
+4
View File
@@ -269,6 +269,8 @@ export type ObservedElementsAppState = {
activeLockedId: AppState["activeLockedId"]; activeLockedId: AppState["activeLockedId"];
}; };
export type BoxSelectionMode = "contain" | "overlap";
export interface AppState { export interface AppState {
contextMenu: { contextMenu: {
items: ContextMenuItems; items: ContextMenuItems;
@@ -307,6 +309,8 @@ export interface AppState {
* `bindingPreference` and keyboard modifiers (ctrl/alt) * `bindingPreference` and keyboard modifiers (ctrl/alt)
*/ */
isBindingEnabled: boolean; isBindingEnabled: boolean;
/** user box selection preference; defaults to "contain" when unset */
boxSelectionMode?: BoxSelectionMode;
/** user arrow binding preference */ /** user arrow binding preference */
bindingPreference: "enabled" | "disabled"; bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */ /** user preference whether arrow snap to midpoints while binding */