feat(editor): implement overlap box selection (#11053)

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
David Luzar
2026-04-01 18:41:11 +02:00
committed by GitHub
parent 4be4cc0ed0
commit d9e8a33aa4
26 changed files with 1141 additions and 104 deletions
+24 -4
View File
@@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
export const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"],
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
points: readonly P[],
padding: number = 0,
): Bounds => {
let minX = Infinity;
let minY = Infinity;
@@ -695,7 +696,7 @@ export const getBoundsFromPoints = (
maxY = Math.max(maxY, y);
}
return [minX, minY, maxX, maxY];
return [minX - padding, minY - padding, maxX + padding, maxY + padding];
};
const getFreeDrawElementAbsoluteCoords = (
@@ -1261,6 +1262,17 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
// TODO make pointInsideBounds inclusive and remove this function once we
// test nothing is breaking
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] >= bounds[0] &&
p[0] <= bounds[2] &&
p[1] >= bounds[1] &&
p[1] <= bounds[3];
export const doBoundsIntersect = (
bounds1: Bounds | null,
bounds2: Bounds | null,
@@ -1275,13 +1287,21 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
};
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
[
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
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<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+28 -15
View File
@@ -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,32 @@ export const hitElementItself = ({
return result;
};
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 hitElementBoundingBoxOnly = (
@@ -573,7 +584,9 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue;
}
const hits = curveIntersectLineSegment(c, segment);
const hits = curveIntersectLineSegment(c, segment, {
iterLimit: 10,
});
if (hits.length > 0) {
intersections.push(...hits);
+11 -9
View File
@@ -1,7 +1,6 @@
import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type {
AppClassProperties,
@@ -18,6 +17,8 @@ import {
getElementLineSegments,
getCommonBounds,
getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
@@ -920,16 +921,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return (
elementsOverlappingBBox({
elements,
bounds: frame,
type: "overlap",
})
// removes elements who are overlapping, but are in a different frame,
return elements.filter(
(el) =>
// exclude elements which are overlapping, but are in a different frame,
// and thus invisible in target frame
.filter((el) => !el.frameId || el.frameId === frame.id)
(!el.frameId || el.frameId === frame.id) &&
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
);
};
+262 -38
View File
@@ -1,15 +1,32 @@
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import type {
AppState,
BoxSelectionMode,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import {
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { isElementInViewport } from "./sizeHelpers";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
@@ -17,19 +34,38 @@ import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
isElementIntersectingFrame,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./types";
const shouldIgnoreElementFromSelection = (
element: NonDeletedExcalidrawElement,
) => element.locked || isBoundToContainer(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
) => {
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
/**
* 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
@@ -49,55 +85,243 @@ export const excludeElementsInFramesFromSelection = <
}
});
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
return excludeElementsFromFrames(selectedElements, framesInSelection);
};
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
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;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
element,
elementsMap,
);
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
elementsMap,
);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
for (const element of elements) {
if (shouldIgnoreElementFromSelection(element)) {
continue;
}
return (
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
});
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
isElementIntersectingFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
} else {
elementsInSelection.push(element);
continue;
}
}
// 2. Handle the case where the label is overlapped by the selection box
if (
boxSelectionMode === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.push(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.push(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
elementsInSelection = framesInSelection
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
+3 -1
View File
@@ -1,4 +1,4 @@
import { arrayToMap } from "@excalidraw/common";
import { arrayToMap, reseed } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -12,6 +12,7 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
@@ -56,6 +57,7 @@ describe("hitElementItself cache", () => {
});
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
+2
View File
@@ -128,6 +128,7 @@ export const getDefaultAppState = (): Omit<
lockedMultiSelections: {},
activeLockedId: null,
bindMode: "orbit",
boxSelectionMode: "contain",
};
};
@@ -193,6 +194,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: {
+34 -14
View File
@@ -27,6 +27,7 @@ import {
KEYS,
APP_NAME,
CURSOR_TYPE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -2524,6 +2525,7 @@ class App extends React.Component<AppProps, AppState> {
const magicFrameChildren = getElementsOverlappingFrame(
this.scene.getNonDeletedElements(),
magicFrame,
this.scene.getNonDeletedElementsMap(),
).filter((el) => !isMagicFrameElement(el));
if (!magicFrameChildren.length) {
@@ -7239,6 +7241,14 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
);
} else if (
!event[KEYS.CTRL_OR_CMD] &&
this.isHittingCommonBoundingBoxOfSelectedElements(
scenePointer,
selectedElements,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} else if (this.state.viewModeEnabled) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.openDialog?.name === "elementLinkSelector") {
@@ -7730,17 +7740,24 @@ class App extends React.Component<AppProps, AppState> {
const hitSelectedElement =
pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element);
const shouldForceLassoReselect =
event.altKey &&
event[KEYS.CTRL_OR_CMD] &&
!pointerDownState.resize.handleType;
const shouldStartLassoSelection =
shouldForceLassoReselect ||
(!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement);
if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement
) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
if (shouldStartLassoSelection) {
if (!this.lassoTrail.hasCurrentTrail) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
}
// block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately)
@@ -8729,12 +8746,14 @@ class App extends React.Component<AppProps, AppState> {
DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
1,
);
const boundsPadding =
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value;
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return (
point.x > x1 - threshold &&
point.x < x2 + threshold &&
point.y > y1 - threshold &&
point.y < y2 + threshold
point.x > x1 - boundsPadding - threshold &&
point.x < x2 + boundsPadding + threshold &&
point.y > y1 - boundsPadding - threshold &&
point.y < y2 + boundsPadding + threshold
);
}
@@ -10267,6 +10286,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement,
this.scene.getNonDeletedElementsMap(),
false,
this.state.boxSelectionMode,
)
: [];
@@ -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);
@@ -750,7 +750,7 @@ describe("frame resizing behavior", () => {
x: 0,
y: 0,
width: 100,
height: 100,
height: 103,
});
// Create a rectangle outside the frame
@@ -2,7 +2,7 @@
.excalidraw {
.dropdown-menu {
max-width: 16rem;
max-width: 20rem;
z-index: 1;
&--placement-top {
@@ -1,4 +1,5 @@
import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup";
type Props<T> = {
@@ -12,6 +13,7 @@ type Props<T> = {
onChange: (value: T) => void;
children: React.ReactNode;
name: string;
icon?: React.ReactNode;
};
const DropdownMenuItemContentRadio = <T,>({
@@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = <T,>({
choices,
children,
name,
icon,
}: Props<T>) => {
const editorInterface = useEditorInterface();
return (
<>
<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
name={name}
value={value}
@@ -39,7 +39,13 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import {
boltIcon,
DeviceDesktopIcon,
@@ -427,6 +433,39 @@ const PreferencesToggleToolLockItem = () => {
);
};
const PreferencesBoxSelectionModeItem = () => {
const { t } = useI18n();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItemContentRadio<"contain" | "overlap">
name="boxSelectionMode"
icon={emptyIcon}
value={appState.boxSelectionMode}
onChange={(value) => {
setAppState({
boxSelectionMode: 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 { t } = useI18n();
const actionManager = useExcalidrawActionManager();
@@ -568,6 +607,7 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || (
<>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem />
@@ -585,6 +625,7 @@ export const Preferences = ({
};
Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
+7 -1
View File
@@ -6,6 +6,7 @@ import {
MIME_TYPES,
cloneJSON,
SVG_DOCUMENT_PREAMBLE,
arrayToMap,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
@@ -49,6 +50,7 @@ export const prepareElementsForExport = (
exportSelectionOnly: boolean,
) => {
elements = getNonDeletedElements(elements);
const elementsMap = arrayToMap(elements);
const isExportingSelection =
exportSelectionOnly &&
@@ -71,7 +73,11 @@ export const prepareElementsForExport = (
isFrameLikeElement(exportedElements[0])
) {
exportingFrame = exportedElements[0];
exportedElements = getElementsOverlappingFrame(elements, exportingFrame);
exportedElements = getElementsOverlappingFrame(
elements,
exportingFrame,
elementsMap,
);
} else if (exportedElements.length > 1) {
exportedElements = getSelectedElements(
elements,
+6
View File
@@ -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",
+3
View File
@@ -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"
},
+5 -1
View File
@@ -157,7 +157,11 @@ const prepareElementsForRender = ({
let nextElements: readonly ExcalidrawElement[];
if (exportingFrame) {
nextElements = getElementsOverlappingFrame(elements, exportingFrame);
nextElements = getElementsOverlappingFrame(
elements,
exportingFrame,
arrayToMap(elements),
);
} else if (frameRendering.enabled && frameRendering.name) {
nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode,
@@ -13,6 +13,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -1086,6 +1087,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1300,6 +1302,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1631,6 +1634,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1962,6 +1966,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2176,6 +2181,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2417,6 +2423,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2715,6 +2722,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3087,6 +3095,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3580,6 +3589,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3903,6 +3913,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4226,6 +4237,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4358,7 +4370,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 +4395,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,
@@ -4637,6 +4649,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -5854,6 +5867,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -6864,7 +6878,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 +6905,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,
@@ -7122,6 +7136,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -7811,6 +7826,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -8802,6 +8818,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -13,6 +13,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -646,6 +647,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1207,6 +1209,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1567,6 +1570,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1929,6 +1933,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2192,6 +2197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2645,6 +2651,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2948,6 +2955,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3267,6 +3275,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3561,6 +3570,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3847,6 +3857,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4082,6 +4093,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4339,6 +4351,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4610,6 +4623,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4839,6 +4853,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5068,6 +5083,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5315,6 +5331,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5571,6 +5588,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5829,6 +5847,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6158,6 +6177,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6585,6 +6605,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6959,6 +6980,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7271,6 +7293,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7563,6 +7586,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7793,6 +7817,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8145,6 +8170,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8497,6 +8523,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8903,6 +8930,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9182,6 +9210,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9446,6 +9475,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9711,6 +9741,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9943,6 +9974,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10240,6 +10272,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10557,6 +10590,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10793,6 +10827,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11718,6 +11753,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11978,6 +12014,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12213,6 +12250,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12450,6 +12488,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12841,6 +12880,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13051,6 +13091,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13258,6 +13299,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13559,6 +13601,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13857,6 +13900,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14102,6 +14146,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14339,6 +14384,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14576,6 +14622,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14823,6 +14870,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -15154,6 +15202,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -15324,6 +15373,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -15608,6 +15658,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -15871,6 +15922,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -16024,6 +16076,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -16306,6 +16359,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -16468,6 +16522,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -17216,6 +17271,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -17862,6 +17918,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -18508,6 +18565,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -19257,6 +19315,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -20025,6 +20084,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -20505,6 +20565,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -21016,6 +21077,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -21475,6 +21537,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13,6 +13,7 @@ exports[`given element A and group of elements B and given both are selected whe
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -439,6 +440,7 @@ exports[`given element A and group of elements B and given both are selected whe
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -855,6 +857,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1421,6 +1424,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1628,6 +1632,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2012,6 +2017,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2257,6 +2263,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2437,6 +2444,7 @@ exports[`regression tests > can drag element that covers another element, while
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2762,6 +2770,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3017,6 +3026,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3258,6 +3268,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3494,6 +3505,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3752,6 +3764,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4066,6 +4079,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4502,6 +4516,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4785,6 +4800,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5061,6 +5077,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5269,6 +5286,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5469,6 +5487,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5862,6 +5881,7 @@ exports[`regression tests > drags selected elements from point inside common bou
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6159,6 +6179,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6947,6 +6968,7 @@ exports[`regression tests > given a group of selected elements with an element t
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7281,6 +7303,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7560,6 +7583,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7795,6 +7819,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8035,6 +8060,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8215,6 +8241,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8395,6 +8422,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8575,6 +8603,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8807,6 +8836,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9037,6 +9067,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9229,6 +9260,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9461,6 +9493,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9641,6 +9674,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9871,6 +9905,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10051,6 +10086,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10243,6 +10279,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10423,6 +10460,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10954,6 +10992,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11234,6 +11273,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11357,6 +11397,7 @@ exports[`regression tests > shift click on selected element should deselect it o
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11557,6 +11598,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11876,6 +11918,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12305,6 +12348,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12945,6 +12989,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13071,6 +13116,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13702,6 +13748,7 @@ exports[`regression tests > switches from group of selected elements to another
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14041,6 +14088,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14305,6 +14353,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14428,6 +14477,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14792,6 +14842,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14915,6 +14966,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
+1 -1
View File
@@ -314,7 +314,7 @@ describe("history", () => {
expect.objectContaining({ id: rect2.id, isDeleted: true }),
]);
mouse.downAt(0, 0);
mouse.downAt(-10, -10);
mouse.moveTo(25, 25);
mouse.moveTo(50, 50);
mouse.upAt(50, 50);
@@ -468,7 +468,7 @@ describe("regression tests", () => {
mouse.reset();
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.restorePosition(end[0] + 3, end[1] + 3);
mouse.up();
expect(h.elements.length).toBe(3);
@@ -519,7 +519,7 @@ describe("regression tests", () => {
mouse.reset();
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.restorePosition(end[0] + 3, end[1] + 3);
mouse.up();
for (const element of h.elements) {
@@ -537,7 +537,7 @@ describe("regression tests", () => {
mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.restorePosition(end[0] + 3, end[1] + 3);
mouse.up();
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+508 -1
View File
@@ -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, pointRotateRads, 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<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", () => {
beforeEach(async () => {
await render(<Excalidraw />);
@@ -108,6 +124,497 @@ 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(<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();
};
const boxSelectTopLeftAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneElement, elementsMap);
boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12);
};
const boxSelectTopRightAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [, y1, x2] = getElementBounds(sceneElement, elementsMap);
boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12);
};
const boxSelectTopLeftRotatedLocalBoundsCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true);
const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2);
const [cornerX, cornerY] = pointRotateRads(
pointFrom(x1, y1),
center,
sceneElement.angle,
);
boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4);
};
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 - 1);
expect(outlineY1).toBeGreaterThan(boundsY1 - 1);
expect(outlineX2).toBeLessThan(boundsX2 + 1);
expect(outlineY2).toBeLessThan(boundsY2 + 1);
boxSelect(
outlineX1 - (outlineX1 - boundsX1) / 2,
outlineY1 - (outlineY1 - boundsY1) / 2,
outlineX2 + (boundsX2 - outlineX2) / 2,
outlineY2 + (boundsY2 - outlineY2) / 2,
);
assertSelectedElements([rect.id]);
});
it("should not select a filled rotated rectangle when the selection box only overlaps 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]);
boxSelectTopLeftAabbCorner(rect);
assertSelectedElements([]);
});
it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopRightAabbCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopRightAabbCorner(diamond);
assertSelectedElements([]);
});
it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopLeftRotatedLocalBoundsCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopLeftRotatedLocalBoundsCorner(diamond);
assertSelectedElements([]);
});
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", () => {
+4
View File
@@ -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 */
+39 -5
View File
@@ -137,8 +137,17 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
[t0, s0]: [number, number],
l: LineSegment<Point>,
c: Curve<Point>,
tolerance: number = 1e-2,
iterLimit: number = 4,
) => {
const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 4);
const solution = solveWithAnalyticalJacobian(
c,
l,
t0,
s0,
tolerance,
iterLimit,
);
if (!solution) {
return null;
@@ -158,18 +167,43 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
*/
export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint,
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
let solution = calculate(initial_guesses[0], l, c);
>(
c: Curve<Point>,
l: LineSegment<Point>,
opts?: {
tolerance?: number;
iterLimit?: number;
},
): Point[] {
let solution = calculate(
initial_guesses[0],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) {
return [solution];
}
solution = calculate(initial_guesses[1], l, c);
solution = calculate(
initial_guesses[1],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) {
return [solution];
}
solution = calculate(initial_guesses[2], l, c);
solution = calculate(
initial_guesses[2],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) {
return [solution];
}
@@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = `
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
+7
View File
@@ -90,6 +90,13 @@ module.exports.woff2BrowserPlugin = () => {
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="${OSS_FONTS_CDN}fonts/Assistant/Assistant-SemiBold.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="${OSS_FONTS_CDN}fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2"