Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions packages/react-grab/src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] => {
Expand Down Expand Up @@ -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;

Expand Down
127 changes: 77 additions & 50 deletions packages/react-grab/src/utils/get-elements-in-drag.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -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);

Expand Down Expand Up @@ -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<Element>();
const samplePoints = createSamplePoints(dragRect);
Expand All @@ -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[] => {
Expand Down Expand Up @@ -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);
};
190 changes: 190 additions & 0 deletions packages/react-grab/tests/get-elements-in-drag.test.ts
Original file line number Diff line number Diff line change
@@ -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<Element, ElementBounds>) => {
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([]);
});
});
Loading