diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a73a7d28bb..a072b81a90 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = ( return [minX, minY, maxX, maxY]; }; -export const getBoundsFromPoints = ( - points: ExcalidrawFreeDrawElement["points"], +export const getBoundsFromPoints =

( + 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 =

( ): 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: 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(innerBounds[0], innerBounds[1]), + pointFrom(innerBounds[0], innerBounds[3]), + pointFrom(innerBounds[2], innerBounds[1]), + pointFrom(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((x1 + x2) / 2, (y1 + y2) / 2); diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index b17d563dbe..c260ae5267 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -154,14 +154,11 @@ export const hitElementItself = ({ // Hit test against the extended, rotated bounding box of the element first const bounds = getElementBounds(element, elementsMap, true); - const hitBounds = isPointWithinBounds( - pointFrom(bounds[0] - threshold, bounds[1] - threshold), - pointRotateRads( - point, - getCenterForBounds(bounds), - -element.angle as Radians, - ), - pointFrom(bounds[2] + threshold, bounds[3] + threshold), + const hitBounds = isPointInRotatedBounds( + point, + bounds, + element.angle, + threshold, ); // PERF: Bail out early if the point is not even in the @@ -192,18 +189,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); diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 3c82099546..787c2692f9 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -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), + ), ); }; diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index ea7fdb1b77..8e9c4e8086 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -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 = ( + selectedElements: readonly T[], + framesInSelection: Set, +) => { + 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( + pointFrom(selectionX1, selectionY1), + pointFrom(selectionX2, selectionY1), + ), + lineSegment( + pointFrom(selectionX2, selectionY1), + pointFrom(selectionX2, selectionY2), + ), + lineSegment( + pointFrom(selectionX2, selectionY2), + pointFrom(selectionX1, selectionY2), + ), + lineSegment( + pointFrom(selectionX1, selectionY2), + pointFrom(selectionX1, selectionY1), + ), + ]; - let elementsInSelection = elements.filter((element) => { - let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( - element, - elementsMap, - ); + const framesInSelection = excludeElementsInFrames + ? new Set() + : 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(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( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[1], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + nonRotatedElementBounds[2], + (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[3], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + 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) => { diff --git a/packages/element/tests/collision.test.tsx b/packages/element/tests/collision.test.tsx index 4061a16cb6..a44f1f7bb0 100644 --- a/packages/element/tests/collision.test.tsx +++ b/packages/element/tests/collision.test.tsx @@ -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(); }); @@ -56,6 +57,7 @@ describe("hitElementItself cache", () => { }); localStorage.clear(); + reseed(7); await render(); }); diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index e51865b2ea..93fe770286 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -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: { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5561594cb0..4db823c2d4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { 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 { 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 { 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 { 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 { this.state.selectionElement, this.scene.getNonDeletedElementsMap(), false, + this.state.boxSelectionMode, ) : []; diff --git a/packages/excalidraw/components/RadioGroup.scss b/packages/excalidraw/components/RadioGroup.scss index 28ddc8889b..d550d95b3b 100644 --- a/packages/excalidraw/components/RadioGroup.scss +++ b/packages/excalidraw/components/RadioGroup.scss @@ -26,13 +26,16 @@ background: var(--RadioGroup-background); border: 1px solid var(--RadioGroup-border); + gap: 2px; + &__choice { position: relative; display: flex; align-items: center; justify-content: center; - width: 32px; + min-width: 20px; height: 24px; + padding: 0 0.375rem; color: var(--RadioGroup-choice-color-off); background: var(--RadioGroup-choice-background-off); diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 283bdb40d3..24d739afa9 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -750,7 +750,7 @@ describe("frame resizing behavior", () => { x: 0, y: 0, width: 100, - height: 100, + height: 103, }); // Create a rectangle outside the frame diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index e207203d60..3080314250 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -2,7 +2,7 @@ .excalidraw { .dropdown-menu { - max-width: 16rem; + max-width: 20rem; z-index: 1; &--placement-top { diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index 4f2986c30e..63646745fb 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -1,4 +1,5 @@ import { useEditorInterface } from "../App"; +import { Ellipsify } from "../Ellipsify"; import { RadioGroup } from "../RadioGroup"; type Props = { @@ -12,6 +13,7 @@ type Props = { onChange: (value: T) => void; children: React.ReactNode; name: string; + icon?: React.ReactNode; }; const DropdownMenuItemContentRadio = ({ @@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = ({ choices, children, name, + icon, }: Props) => { const editorInterface = useEditorInterface(); return ( <>

- + {icon &&
{icon}
} + { ); }; +const PreferencesBoxSelectionModeItem = () => { + const { t } = useI18n(); + const appState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + + return ( + + 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")} + + ); +}; + const PreferencesToggleSnapModeItem = () => { const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); @@ -568,6 +607,7 @@ export const Preferences = ({ {children || ( <> + @@ -585,6 +625,7 @@ export const Preferences = ({ }; Preferences.ToggleToolLock = PreferencesToggleToolLockItem; +Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index 1083a47897..d31ab931fd 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -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, diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 1ed068dc1c..7a8b9e586c 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -936,6 +936,12 @@ export const restoreAppState = ( : defaultValue; } + const boxSelectionMode = + appState.boxSelectionMode ?? localAppState?.boxSelectionMode; + if (boxSelectionMode !== undefined) { + nextAppState.boxSelectionMode = boxSelectionMode; + } + return { ...nextAppState, cursorButton: localAppState?.cursorButton || "up", diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 42e87bdf82..05d3bf702e 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -185,6 +185,9 @@ "shapeSwitch": "Switch shape", "preferences": "Preferences", "preferences_toolLock": "Tool lock", + "boxSelectionMode": "Select on", + "boxSelectionContain": "Wrap", + "boxSelectionOverlap": "Overlap", "arrowBinding": "Arrow binding", "midpointSnapping": "Snap to midpoints" }, diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 7cf111f6ce..8d8c405e82 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -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, diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 15458fa366..cb548f5b62 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -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": [ diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 3a400a0128..2923e9b321 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -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, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index e97991d96f..e502925b25 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -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, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index f6706a0f99..78b431b773 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -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); diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index d12a845d63..986c828f26 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -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 }, () => { diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 64204f1a00..ef0bd46b2c 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -1,7 +1,9 @@ import React from "react"; import { vi } from "vitest"; -import { KEYS, reseed } from "@excalidraw/common"; +import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common"; +import { getElementBounds, getElementLineSegments } from "@excalidraw/element"; +import { pointFrom, 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) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const points = getElementLineSegments(sceneElement, elementsMap).flat(); + + return [ + Math.min(...points.map((point) => point[0])), + Math.min(...points.map((point) => point[1])), + Math.max(...points.map((point) => point[0])), + Math.max(...points.map((point) => point[1])), + ] as const; +}; + describe("box-selection", () => { beforeEach(async () => { await render(); @@ -108,6 +124,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(); + }); + + 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, + ) => { + 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, + ) => { + 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, + ) => { + 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( + , + ); + }); + + 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(0, 0), pointFrom(100, 100)], + }); + + API.setElements([line]); + + boxSelect(20, 50, 30, 60); + + assertSelectedElements([]); + }); + + it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => { + const freedraw = API.createElement({ + type: "freedraw", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "transparent", + points: [ + pointFrom(0, 0), + pointFrom(100, 0), + pointFrom(100, 100), + pointFrom(0, 100), + pointFrom(0, 0), + ], + }); + + API.setElements([freedraw]); + + const sceneFreedraw = API.getElement(freedraw); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap); + + mouse.clickAt(x1 + 2, y1 + 2); + + assertSelectedElements([]); + }); + + it("should not select a freedraw when the selection box only overlaps its bounds", () => { + const freedraw = API.createElement({ + type: "freedraw", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + points: [ + pointFrom(0, 0), + pointFrom(50, 50), + pointFrom(100, 100), + ], + }); + + API.setElements([freedraw]); + + boxSelect(20, 50, 30, 60); + + assertSelectedElements([]); + }); + + it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => { + const frame = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + const rect1 = API.createElement({ + type: "rectangle", + x: 50, + y: 10, + width: 100, + height: 80, + frameId: frame.id, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([frame, rect1]); + + boxSelect(60, 20, 90, 60); + + assertSelectedElements([]); + }); }); describe("inner box-selection", () => { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index cae4e7b93f..a41512fe64 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -269,6 +269,8 @@ export type ObservedElementsAppState = { activeLockedId: AppState["activeLockedId"]; }; +export type BoxSelectionMode = "contain" | "overlap"; + export interface AppState { contextMenu: { items: ContextMenuItems; @@ -307,6 +309,8 @@ export interface AppState { * `bindingPreference` and keyboard modifiers (ctrl/alt) */ isBindingEnabled: boolean; + /** user box selection preference; defaults to "contain" when unset */ + boxSelectionMode: BoxSelectionMode; /** user arrow binding preference */ bindingPreference: "enabled" | "disabled"; /** user preference whether arrow snap to midpoints while binding */ diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index 32f537f434..8e53727f84 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -137,8 +137,17 @@ const calculate = ( [t0, s0]: [number, number], l: LineSegment, c: Curve, + 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 = ( */ export function curveIntersectLineSegment< Point extends GlobalPoint | LocalPoint, ->(c: Curve, l: LineSegment): Point[] { - let solution = calculate(initial_guesses[0], l, c); +>( + c: Curve, + l: LineSegment, + 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]; } diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index affae46199..f59d0a9e84 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, diff --git a/scripts/woff2/woff2-vite-plugins.js b/scripts/woff2/woff2-vite-plugins.js index b62a76cffb..3870bbcdfc 100644 --- a/scripts/woff2/woff2-vite-plugins.js +++ b/scripts/woff2/woff2-vite-plugins.js @@ -90,6 +90,13 @@ module.exports.woff2BrowserPlugin = () => { type="font/woff2" crossorigin="anonymous" /> +