From b1a08ecc88240b89b3fd9f6f0ee4211710d63da3 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Mon, 3 Aug 2026 17:13:24 -0700 Subject: [PATCH] Improve drag selection fallback heuristic (#613) --- packages/react-grab/src/core/index.tsx | 11 +- .../src/utils/get-elements-in-drag.ts | 127 +++++++----- .../tests/get-elements-in-drag.test.ts | 190 ++++++++++++++++++ 3 files changed, 269 insertions(+), 59 deletions(-) create mode 100644 packages/react-grab/tests/get-elements-in-drag.test.ts diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 1c00bb577..033067c42 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -1352,10 +1352,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (!pointer) return []; const drag = calculateDragRectangle(pointer.x, pointer.y); - const elements = getElementsInDrag(drag, isValidGrabbableElement); - return elements.length > 0 - ? elements - : getElementsInDrag(drag, isValidGrabbableElement, false); + return getElementsInDrag(drag, isValidGrabbableElement); }); const dragPreviewBounds = createMemo((): OverlayBounds[] => { @@ -2103,11 +2100,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { hasModifierKeyHeld: boolean, isShiftHeld: boolean, ) => { - const elements = getElementsInDrag(dragSelectionRect, isValidGrabbableElement); - const selectedElements = - elements.length > 0 - ? elements - : getElementsInDrag(dragSelectionRect, isValidGrabbableElement, false); + const selectedElements = getElementsInDrag(dragSelectionRect, isValidGrabbableElement); if (selectedElements.length === 0) return; diff --git a/packages/react-grab/src/utils/get-elements-in-drag.ts b/packages/react-grab/src/utils/get-elements-in-drag.ts index 042b0629d..e2c3ee0bd 100644 --- a/packages/react-grab/src/utils/get-elements-in-drag.ts +++ b/packages/react-grab/src/utils/get-elements-in-drag.ts @@ -1,4 +1,4 @@ -import type { DragRect, Rect } from "../types.js"; +import type { DragRect } from "../types.js"; import { suspendPointerEventsFreeze, resumePointerEventsFreeze } from "./pointer-events-freeze.js"; import { DRAG_SELECTION_COVERAGE_THRESHOLD, @@ -7,6 +7,7 @@ import { DRAG_SELECTION_MAX_SAMPLES_PER_AXIS, DRAG_SELECTION_MAX_TOTAL_SAMPLE_POINTS, DRAG_SELECTION_EDGE_INSET_PX, + VIEWPORT_COVERAGE_THRESHOLD, } from "../constants.js"; import { isRootElement } from "./is-root-element.js"; import { isWithinScope } from "./runtime-mode.js"; @@ -19,27 +20,6 @@ import { getAccessibleIframeDocument } from "./get-accessible-iframe-document.js import { isIframeElement } from "./is-iframe-element.js"; import { isShadowRoot } from "./is-shadow-root.js"; -const calculateIntersectionArea = (rect1: Rect, rect2: Rect): number => { - const intersectionLeft = Math.max(rect1.left, rect2.left); - const intersectionTop = Math.max(rect1.top, rect2.top); - const intersectionRight = Math.min(rect1.right, rect2.right); - const intersectionBottom = Math.min(rect1.bottom, rect2.bottom); - - const intersectionWidth = Math.max(0, intersectionRight - intersectionLeft); - const intersectionHeight = Math.max(0, intersectionBottom - intersectionTop); - - return intersectionWidth * intersectionHeight; -}; - -const hasIntersection = (rect1: Rect, rect2: Rect): boolean => { - return ( - rect1.left < rect2.right && - rect1.right > rect2.left && - rect1.top < rect2.bottom && - rect1.bottom > rect2.top - ); -}; - const sortByDocumentOrder = (elements: Element[]): Element[] => elements.sort(compareElementDocumentOrder); @@ -124,14 +104,11 @@ const createSamplePoints = (dragRect: DragRect): SamplePoint[] => { const filterElementsInDrag = ( dragRect: DragRect, isValidGrabbableElement: (element: Element) => boolean, - shouldCheckCoverage: boolean, ): Element[] => { - const dragBounds: Rect = { - left: dragRect.x, - top: dragRect.y, - right: dragRect.x + dragRect.width, - bottom: dragRect.y + dragRect.height, - }; + const dragLeft = dragRect.x; + const dragTop = dragRect.y; + const dragRight = dragRect.x + dragRect.width; + const dragBottom = dragRect.y + dragRect.height; const candidates = new Set(); const samplePoints = createSamplePoints(dragRect); @@ -149,37 +126,88 @@ const filterElementsInDrag = ( } const matchingElements: Element[] = []; + let nearestFallbackElement: Element | null = null; + let nearestFallbackDistanceSquared = Number.POSITIVE_INFINITY; + let nearestFallbackArea = Number.POSITIVE_INFINITY; + const dragCenterX = dragRect.x + dragRect.width / 2; + const dragCenterY = dragRect.y + dragRect.height / 2; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const hasMeasurableViewport = viewportWidth > 0 && viewportHeight > 0; + const viewportCoverWidth = viewportWidth * VIEWPORT_COVERAGE_THRESHOLD; + const viewportCoverHeight = viewportHeight * VIEWPORT_COVERAGE_THRESHOLD; for (const candidateElement of candidates) { if (isIframeElement(candidateElement) && getAccessibleIframeDocument(candidateElement)) { continue; } - if (!shouldCheckCoverage && isRootElement(candidateElement)) continue; + if (isRootElement(candidateElement)) continue; if (!isWithinScope(candidateElement)) continue; if (!isValidGrabbableElement(candidateElement)) continue; const candidateBounds = createElementBounds(candidateElement); - if (candidateBounds.width <= 0 || candidateBounds.height <= 0) continue; - const bounds: Rect = { - left: candidateBounds.x, - top: candidateBounds.y, - right: candidateBounds.x + candidateBounds.width, - bottom: candidateBounds.y + candidateBounds.height, - }; - if (shouldCheckCoverage) { - const intersectionArea = calculateIntersectionArea(dragBounds, bounds); - const candidateArea = candidateBounds.width * candidateBounds.height; - const hasMajorityCoverage = - intersectionArea / candidateArea >= DRAG_SELECTION_COVERAGE_THRESHOLD; - - if (hasMajorityCoverage) { - matchingElements.push(candidateElement); - } - } else if (hasIntersection(bounds, dragBounds)) { + if ( + !Number.isFinite(candidateBounds.x) || + !Number.isFinite(candidateBounds.y) || + !Number.isFinite(candidateBounds.width) || + !Number.isFinite(candidateBounds.height) || + candidateBounds.width <= 0 || + candidateBounds.height <= 0 + ) { + continue; + } + + const candidateLeft = candidateBounds.x; + const candidateTop = candidateBounds.y; + const candidateRight = candidateLeft + candidateBounds.width; + const candidateBottom = candidateTop + candidateBounds.height; + const coversViewport = + hasMeasurableViewport && + candidateBounds.width >= viewportCoverWidth && + candidateBounds.height >= viewportCoverHeight && + Math.min(viewportWidth, candidateRight) - Math.max(0, candidateLeft) >= viewportCoverWidth && + Math.min(viewportHeight, candidateBottom) - Math.max(0, candidateTop) >= viewportCoverHeight; + if (coversViewport) continue; + + const intersectionWidth = Math.max( + 0, + Math.min(dragRight, candidateRight) - Math.max(dragLeft, candidateLeft), + ); + const intersectionHeight = Math.max( + 0, + Math.min(dragBottom, candidateBottom) - Math.max(dragTop, candidateTop), + ); + const intersectionArea = intersectionWidth * intersectionHeight; + if (intersectionArea <= 0) continue; + + const candidateArea = candidateBounds.width * candidateBounds.height; + if (intersectionArea / candidateArea >= DRAG_SELECTION_COVERAGE_THRESHOLD) { matchingElements.push(candidateElement); + continue; + } + + const candidateCenterX = candidateLeft + candidateBounds.width / 2; + const candidateCenterY = candidateTop + candidateBounds.height / 2; + const centerDistanceX = candidateCenterX - dragCenterX; + const centerDistanceY = candidateCenterY - dragCenterY; + const centerDistanceSquared = + centerDistanceX * centerDistanceX + centerDistanceY * centerDistanceY; + const isNearerFallback = centerDistanceSquared < nearestFallbackDistanceSquared; + const isSmallerEquidistantFallback = + centerDistanceSquared === nearestFallbackDistanceSquared && + candidateArea < nearestFallbackArea; + + if (isNearerFallback || isSmallerEquidistantFallback) { + nearestFallbackElement = candidateElement; + nearestFallbackDistanceSquared = centerDistanceSquared; + nearestFallbackArea = candidateArea; } } - return sortByDocumentOrder(matchingElements); + return matchingElements.length > 0 + ? sortByDocumentOrder(matchingElements) + : nearestFallbackElement + ? [nearestFallbackElement] + : []; }; const removeNestedElements = (elements: Element[]): Element[] => { @@ -221,8 +249,7 @@ const removeNestedElements = (elements: Element[]): Element[] => { export const getElementsInDrag = ( dragRect: DragRect, isValidGrabbableElement: (element: Element) => boolean, - shouldCheckCoverage = true, ): Element[] => { - const elements = filterElementsInDrag(dragRect, isValidGrabbableElement, shouldCheckCoverage); + const elements = filterElementsInDrag(dragRect, isValidGrabbableElement); return removeNestedElements(elements); }; diff --git a/packages/react-grab/tests/get-elements-in-drag.test.ts b/packages/react-grab/tests/get-elements-in-drag.test.ts new file mode 100644 index 000000000..2d1412a5a --- /dev/null +++ b/packages/react-grab/tests/get-elements-in-drag.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ElementBounds } from "../src/types.js"; +import { getElementsInDrag } from "../src/utils/get-elements-in-drag.js"; +import { createElementBounds } from "../src/utils/create-element-bounds.js"; +import { getDeepElementsAtPoint } from "../src/utils/get-deep-elements-at-point.js"; + +vi.mock("../src/utils/compare-element-document-order.js", () => ({ + compareElementDocumentOrder: vi.fn(() => 0), +})); + +vi.mock("../src/utils/create-element-bounds.js", () => ({ + createElementBounds: vi.fn(), +})); + +vi.mock("../src/utils/get-accessible-iframe-document.js", () => ({ + getAccessibleIframeDocument: vi.fn(() => null), +})); + +vi.mock("../src/utils/get-composed-parent-element.js", () => ({ + getComposedParentElement: vi.fn(() => null), +})); + +vi.mock("../src/utils/get-deep-elements-at-point.js", () => ({ + getDeepElementsAtPoint: vi.fn(), +})); + +vi.mock("../src/utils/is-iframe-element.js", () => ({ + isIframeElement: vi.fn(() => false), +})); + +vi.mock("../src/utils/is-root-element.js", () => ({ + isRootElement: vi.fn(() => false), +})); + +vi.mock("../src/utils/is-shadow-root.js", () => ({ + isShadowRoot: vi.fn(() => false), +})); + +vi.mock("../src/utils/pointer-events-freeze.js", () => ({ + resumePointerEventsFreeze: vi.fn(), + suspendPointerEventsFreeze: vi.fn(), +})); + +vi.mock("../src/utils/runtime-mode.js", () => ({ + isWithinScope: vi.fn(() => true), +})); + +const createElement = (): Element => Object.create(null); + +const setElementBounds = (boundsByElement: Map) => { + vi.mocked(createElementBounds).mockImplementation((element) => { + const bounds = boundsByElement.get(element); + if (!bounds) throw new Error("Missing element bounds"); + return bounds; + }); +}; + +beforeEach(() => { + vi.stubGlobal("window", { innerHeight: 300, innerWidth: 300 }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("getElementsInDrag", () => { + it("selects the nearest candidate even when another candidate has more coverage", () => { + const nearestElement = createElement(); + const higherCoverageElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([nearestElement, higherCoverageElement]); + setElementBounds( + new Map([ + [nearestElement, { x: 60, y: 60, width: 180, height: 180, borderRadius: "0px" }], + [higherCoverageElement, { x: 170, y: 100, width: 80, height: 100, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + + expect(elements).toEqual([nearestElement]); + }); + + it("ignores viewport-covering candidates", () => { + const viewportElement = createElement(); + const nearbyElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([viewportElement, nearbyElement]); + setElementBounds( + new Map([ + [viewportElement, { x: 0, y: 0, width: 300, height: 300, borderRadius: "0px" }], + [nearbyElement, { x: 75, y: 75, width: 200, height: 200, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + + expect(elements).toEqual([nearbyElement]); + }); + + it("ignores viewport-covering candidates that meet the coverage threshold", () => { + const viewportElement = createElement(); + const enclosedElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([viewportElement, enclosedElement]); + setElementBounds( + new Map([ + [viewportElement, { x: 0, y: 0, width: 300, height: 300, borderRadius: "0px" }], + [enclosedElement, { x: 120, y: 120, width: 40, height: 40, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag({ x: 10, y: 10, width: 280, height: 280 }, () => true); + + expect(elements).toEqual([enclosedElement]); + }); + + it("keeps viewport-sized candidates that are mostly offscreen", () => { + const offscreenElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([offscreenElement]); + setElementBounds( + new Map([ + [offscreenElement, { x: -200, y: -200, width: 300, height: 300, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag({ x: 50, y: 50, width: 50, height: 50 }, () => true); + + expect(elements).toEqual([offscreenElement]); + }); + + it("prefers covered candidates over a nearer fallback", () => { + const nearerFallbackElement = createElement(); + const coveredElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([nearerFallbackElement, coveredElement]); + setElementBounds( + new Map([ + [nearerFallbackElement, { x: 60, y: 60, width: 180, height: 180, borderRadius: "0px" }], + [coveredElement, { x: 190, y: 145, width: 10, height: 10, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + + expect(elements).toEqual([coveredElement]); + }); + + it("prefers the smaller candidate when fallback centers are equal", () => { + const wrapperElement = createElement(); + const nestedElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([wrapperElement, nestedElement]); + setElementBounds( + new Map([ + [wrapperElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }], + [nestedElement, { x: 90, y: 90, width: 120, height: 120, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + + expect(elements).toEqual([nestedElement]); + }); + + it("does not treat every candidate as viewport-covering while the viewport is zero-sized", () => { + const candidateElement = createElement(); + vi.stubGlobal("window", { innerHeight: 0, innerWidth: 0 }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([candidateElement]); + setElementBounds( + new Map([ + [candidateElement, { x: 100, y: 100, width: 100, height: 100, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag({ x: 125, y: 125, width: 50, height: 50 }, () => true); + + expect(elements).toEqual([candidateElement]); + }); + + it("skips candidates with non-finite geometry", () => { + const invalidElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([invalidElement]); + setElementBounds( + new Map([ + [invalidElement, { x: Number.NaN, y: 100, width: 100, height: 100, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + + expect(elements).toEqual([]); + }); +});