diff --git a/packages/react-grab/e2e/constants.ts b/packages/react-grab/e2e/constants.ts index daea55830..c06365508 100644 --- a/packages/react-grab/e2e/constants.ts +++ b/packages/react-grab/e2e/constants.ts @@ -32,6 +32,7 @@ export const SHADOW_FRAME_FOCUS_OUTLINE_COLOR = "rgb(168, 85, 247)"; export const FRAMEWORK_COPY_RETRY_TIMEOUT_MS = 20_000; export const UI_STATE_TIMEOUT_MS = 10_000; export const THREE_CANVAS_VERTICAL_CENTER_RATIO = 0.5; +export const THREE_DRAG_SELECTION_OUTSET_PX = 5; export const THREE_JS_FRAME_COUNT_WINDOW_PROPERTY = "__REACT_GRAB_THREE_JS_FRAME_COUNT__"; export const THREE_FRAME_COUNT_WINDOW_PROPERTY = "__REACT_GRAB_THREE_FRAME_COUNT__"; export const THREE_ELAPSED_TIME_WINDOW_PROPERTY = "__REACT_GRAB_THREE_ELAPSED_TIME__"; diff --git a/packages/react-grab/e2e/context-menu.spec.ts b/packages/react-grab/e2e/context-menu.spec.ts index 9852151fe..6595ce7af 100644 --- a/packages/react-grab/e2e/context-menu.spec.ts +++ b/packages/react-grab/e2e/context-menu.spec.ts @@ -255,9 +255,9 @@ test.describe("Context Menu", () => { test.describe("Keyboard Navigation Integration", () => { test("should show context menu after keyboard navigation", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("li:first-child"); + await reactGrab.hoverUntilSelected("li:first-child span"); - await reactGrab.pressArrowDown(); + await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:nth-child(2)"); @@ -270,13 +270,14 @@ test.describe("Context Menu", () => { reactGrab, }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("li:first-child"); + await reactGrab.hoverUntilSelected("li:first-child span"); - await reactGrab.pressArrowDown(); - await reactGrab.page.waitForTimeout(100); + await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); + await reactGrab.page.waitForTimeout(100); - await reactGrab.rightClickElement("li:nth-child(2)"); + await reactGrab.page.keyboard.press("Shift+F10"); + await expect.poll(() => reactGrab.isContextMenuVisible()).toBe(true); await reactGrab.clickContextMenuItem("Copy"); await reactGrab.page.waitForTimeout(500); @@ -706,7 +707,7 @@ test.describe("Context Menu", () => { enabled: (context: { element: Element }) => { (window as { __enabledTagName?: string }).__enabledTagName = context.element.tagName; - return context.element.tagName.toLowerCase() === "li"; + return context.element.tagName.toLowerCase() === "button"; }, onAction: () => {}, }, @@ -715,13 +716,13 @@ test.describe("Context Menu", () => { }); await reactGrab.activate(); - await reactGrab.hoverUntilSelected("li:first-child"); - await reactGrab.rightClickElement("li:first-child"); + await reactGrab.hoverUntilSelected("[data-testid='plain-button']"); + await reactGrab.rightClickElement("[data-testid='plain-button']"); const enabledTagName = await reactGrab.page.evaluate( () => (window as { __enabledTagName?: string }).__enabledTagName, ); - expect(enabledTagName).toBe("LI"); + expect(enabledTagName).toBe("BUTTON"); const menuInfo = await reactGrab.getContextMenuInfo(); const lowerMenuItems = menuInfo.menuItems.map((item: string) => item.toLowerCase()); diff --git a/packages/react-grab/e2e/disabled-elements.spec.ts b/packages/react-grab/e2e/disabled-elements.spec.ts index bf16af3f4..ddc0f1d9e 100644 --- a/packages/react-grab/e2e/disabled-elements.spec.ts +++ b/packages/react-grab/e2e/disabled-elements.spec.ts @@ -125,7 +125,7 @@ test.describe("Disabled Element Selection", () => { await reactGrab.waitForSelectionBox(); await reactGrab.page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("pointer-events-none"); }); test("should select nested disabled element inside enabled parent", async ({ reactGrab }) => { @@ -198,10 +198,10 @@ test.describe("Pointer Events None - Arrow Navigation", () => { test("should support ArrowUp from pointer-events none element", async ({ reactGrab }) => { await reactGrab.page.evaluate((containerId) => { const container = document.getElementById(containerId); - const parent = document.createElement("div"); + const parent = document.createElement("article"); parent.setAttribute("data-testid", "arrow-up-parent"); parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;"; - const child = document.createElement("div"); + const child = document.createElement("span"); child.setAttribute("data-testid", "arrow-up-child"); child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;"; child.textContent = "Pointer Events None Child"; @@ -215,20 +215,22 @@ test.describe("Pointer Events None - Arrow Navigation", () => { if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect.poll(reactGrab.getTargetTestId).toBe("arrow-up-child"); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect + .poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName) + .toBe("article"); }); test("should support ArrowDown back to pointer-events none element", async ({ reactGrab }) => { await reactGrab.page.evaluate((containerId) => { const container = document.getElementById(containerId); - const parent = document.createElement("div"); + const parent = document.createElement("article"); parent.setAttribute("data-testid", "arrow-down-parent"); parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;"; - const child = document.createElement("div"); + const child = document.createElement("span"); child.setAttribute("data-testid", "arrow-down-child"); child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;"; child.textContent = "Pointer Events None Child"; @@ -245,19 +247,22 @@ test.describe("Pointer Events None - Arrow Navigation", () => { await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); + await expect + .poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName) + .toBe("article"); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("span"); }); test("should support round-trip navigation", async ({ reactGrab }) => { await reactGrab.page.evaluate((containerId) => { const container = document.getElementById(containerId); - const parent = document.createElement("div"); + const parent = document.createElement("article"); parent.setAttribute("data-testid", "round-trip-parent"); parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;"; - const child = document.createElement("div"); + const child = document.createElement("span"); child.setAttribute("data-testid", "round-trip-child"); child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;"; child.textContent = "Pointer Events None Child"; @@ -271,30 +276,35 @@ test.describe("Pointer Events None - Arrow Navigation", () => { if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await reactGrab.waitForSelectionBox(); + await expect.poll(reactGrab.getTargetTestId).toBe("round-trip-child"); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect + .poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName) + .toBe("article"); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("span"); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect + .poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName) + .toBe("article"); }); test("should navigate through nested pointer-events none elements", async ({ reactGrab }) => { await reactGrab.page.evaluate((containerId) => { const container = document.getElementById(containerId); - const grandparent = document.createElement("div"); + const grandparent = document.createElement("section"); grandparent.setAttribute("data-testid", "nested-grandparent"); grandparent.style.cssText = "padding: 60px; background: #c0c0c0; margin-top: 10px;"; - const parent = document.createElement("div"); + const parent = document.createElement("article"); parent.setAttribute("data-testid", "nested-parent"); parent.style.cssText = "pointer-events: none; padding: 40px; background: #d0d0d0;"; - const child = document.createElement("div"); + const child = document.createElement("span"); child.setAttribute("data-testid", "nested-child"); child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;"; child.textContent = "Deeply Nested Pointer Events None"; @@ -309,14 +319,18 @@ test.describe("Pointer Events None - Arrow Navigation", () => { if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect.poll(reactGrab.getTargetTestId).toBe("nested-child"); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect + .poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName) + .toBe("article"); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); - expect(await reactGrab.isSelectionBoxVisible()).toBe(true); + await expect + .poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName) + .toBe("section"); }); }); diff --git a/packages/react-grab/e2e/drag-selection.spec.ts b/packages/react-grab/e2e/drag-selection.spec.ts index 9f2084153..4b92969fc 100644 --- a/packages/react-grab/e2e/drag-selection.spec.ts +++ b/packages/react-grab/e2e/drag-selection.spec.ts @@ -1,7 +1,78 @@ import { test, expect } from "./fixtures.js"; +import type { ReactGrabPageObject } from "./fixtures.js"; const TODO_LIST_ITEM_SELECTOR = "[data-testid='todo-list'] li"; +declare global { + interface Window { + __DID_WIDE_TEXT_DRAG_END__?: boolean; + __WIDE_TEXT_DRAG_TARGET_IDS__?: string[]; + } +} + +const configureWideTextTarget = async ( + reactGrab: ReactGrabPageObject, + shouldAddEmptySpaceSentinel = false, +): Promise => { + await reactGrab.page.evaluate((shouldAddSentinel) => { + for (const pageElement of document.body.querySelectorAll("*")) { + if (pageElement instanceof HTMLElement && !pageElement.closest("[data-react-grab]")) { + pageElement.style.visibility = "hidden"; + } + } + const cardElement = document.createElement("div"); + cardElement.id = "wide-text-drag-card"; + cardElement.style.cssText = + "position:fixed;left:20px;top:80px;width:500px;height:80px;background:#222;visibility:visible;z-index:10000"; + const paragraphElement = document.createElement("p"); + paragraphElement.id = "wide-text-drag-target"; + paragraphElement.textContent = "Short label"; + Object.assign(paragraphElement.style, { + font: "20px sans-serif", + height: "40px", + left: "20px", + lineHeight: "40px", + margin: "0", + position: "fixed", + top: "80px", + visibility: "visible", + width: "500px", + }); + cardElement.append(paragraphElement); + document.body.append(cardElement); + if (shouldAddSentinel) { + const sentinelElement = document.createElement("button"); + sentinelElement.id = "wide-text-drag-sentinel"; + sentinelElement.textContent = "Sentinel"; + sentinelElement.style.cssText = + "position:fixed;left:370px;top:90px;width:40px;height:20px;z-index:10001"; + document.body.append(sentinelElement); + } + + window.__DID_WIDE_TEXT_DRAG_END__ = false; + window.__WIDE_TEXT_DRAG_TARGET_IDS__ = []; + const api = window.__REACT_GRAB__; + api?.unregisterPlugin("wide-text-drag-tracking"); + api?.registerPlugin({ + name: "wide-text-drag-tracking", + hooks: { + onDragEnd: (selectedElements: Element[]) => { + window.__DID_WIDE_TEXT_DRAG_END__ = true; + window.__WIDE_TEXT_DRAG_TARGET_IDS__ = selectedElements.map( + (selectedElement) => selectedElement.id, + ); + }, + }, + }); + }, shouldAddEmptySpaceSentinel); +}; + +const getWideTextDragTargetIds = async (reactGrab: ReactGrabPageObject): Promise => + reactGrab.page.evaluate(() => window.__WIDE_TEXT_DRAG_TARGET_IDS__ ?? []); + +const didWideTextDragEnd = async (reactGrab: ReactGrabPageObject): Promise => + reactGrab.page.evaluate(() => window.__DID_WIDE_TEXT_DRAG_END__ ?? false); + test.describe("Drag Selection", () => { test("should keep drag active when releasing Space in hold mode with Space activation key", async ({ reactGrab, @@ -128,6 +199,80 @@ test.describe("Drag Selection", () => { expect(clipboardContent.length).toBeGreaterThan(0); }); + test("should ignore empty space inside a wide text layout box", async ({ reactGrab }) => { + await configureWideTextTarget(reactGrab, true); + await reactGrab.activate(); + + const paragraphBounds = await reactGrab.page.locator("#wide-text-drag-target").boundingBox(); + if (!paragraphBounds) throw new Error("Could not get wide text bounds"); + + await reactGrab.page.mouse.move(paragraphBounds.x + 300, paragraphBounds.y + 5); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(paragraphBounds.x + 450, paragraphBounds.y + 35, { + steps: 5, + }); + await reactGrab.page.mouse.up(); + + expect(await didWideTextDragEnd(reactGrab)).toBe(true); + const selectedTargetIds = await getWideTextDragTargetIds(reactGrab); + expect(selectedTargetIds).toContain("wide-text-drag-sentinel"); + expect(selectedTargetIds).not.toContain("wide-text-drag-target"); + }); + + test("should drag-select the painted part of a wide text element", async ({ reactGrab }) => { + await configureWideTextTarget(reactGrab); + await reactGrab.activate(); + + const textBounds = await reactGrab.page + .locator("#wide-text-drag-target") + .evaluate((element) => { + const textNode = element.firstChild; + if (!textNode) return null; + const range = document.createRange(); + range.selectNodeContents(textNode); + const bounds = range.getBoundingClientRect(); + return { bottom: bounds.bottom, left: bounds.left, right: bounds.right, top: bounds.top }; + }); + if (!textBounds) throw new Error("Could not get painted text bounds"); + + await reactGrab.page.mouse.move(textBounds.left - 10, textBounds.top - 5); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(textBounds.right + 10, textBounds.bottom + 5, { steps: 5 }); + await reactGrab.page.mouse.up(); + + expect(await getWideTextDragTargetIds(reactGrab)).toContain("wide-text-drag-target"); + }); + + test("should hover the card through empty width beside its text", async ({ reactGrab }) => { + await configureWideTextTarget(reactGrab); + await reactGrab.activate(); + + const paragraphBounds = await reactGrab.page.locator("#wide-text-drag-target").boundingBox(); + if (!paragraphBounds) throw new Error("Could not get wide text bounds"); + + await reactGrab.page.mouse.move(paragraphBounds.x + 20, paragraphBounds.y + 20); + await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("p"); + + await reactGrab.page.mouse.move(paragraphBounds.x + 300, paragraphBounds.y + 20); + await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("div"); + }); + + test("should keep the full box targetable when a wide text element has paint", async ({ + reactGrab, + }) => { + await configureWideTextTarget(reactGrab); + await reactGrab.page.locator("#wide-text-drag-target").evaluate((paragraphElement) => { + paragraphElement.style.background = "rgb(34, 34, 34)"; + }); + await reactGrab.activate(); + + const paragraphBounds = await reactGrab.page.locator("#wide-text-drag-target").boundingBox(); + if (!paragraphBounds) throw new Error("Could not get wide text bounds"); + + await reactGrab.page.mouse.move(paragraphBounds.x + 300, paragraphBounds.y + 20); + await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("p"); + }); + test("should copy all selected elements to clipboard", async ({ reactGrab }) => { await reactGrab.activate(); diff --git a/packages/react-grab/e2e/drag-targeting-regressions.spec.ts b/packages/react-grab/e2e/drag-targeting-regressions.spec.ts new file mode 100644 index 000000000..6f0016ca3 --- /dev/null +++ b/packages/react-grab/e2e/drag-targeting-regressions.spec.ts @@ -0,0 +1,223 @@ +import { expect, test } from "./fixtures.js"; + +test.describe("drag targeting regressions", () => { + test("keeps the candidate preview visible during continuous movement", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const targetElement = document.createElement("button"); + targetElement.textContent = "Continuous drag target"; + targetElement.style.cssText = + "position:fixed;left:100px;top:100px;width:160px;height:120px;z-index:2147480000"; + document.body.append(targetElement); + }); + + await reactGrab.activate(); + await reactGrab.page.mouse.move(80, 80); + await reactGrab.page.mouse.down(); + + for (let stepIndex = 1; stepIndex <= 15; stepIndex += 1) { + await reactGrab.page.mouse.move(80 + stepIndex * 8, 80 + stepIndex * 6); + await reactGrab.page.waitForTimeout(10); + } + + expect( + await reactGrab.page.evaluate( + () => window.__REACT_GRAB__?.getState().isSelectionBoxVisible ?? false, + ), + ).toBe(true); + + await reactGrab.page.mouse.up(); + }); + + test("uses release direction to choose between equal partial candidates", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate(() => { + const leftElement = document.createElement("div"); + leftElement.dataset.testid = "direction-left"; + leftElement.style.cssText = + "position:fixed;left:50px;top:80px;width:120px;height:140px;background:#ef4444;z-index:2147480000"; + + const rightElement = document.createElement("div"); + rightElement.dataset.testid = "direction-right"; + rightElement.style.cssText = + "position:fixed;left:130px;top:80px;width:120px;height:140px;background:#3b82f6;z-index:2147480000"; + + document.body.append(leftElement, rightElement); + window.__REACT_GRAB__?.registerPlugin({ + name: "drag-direction-regression", + hooks: { + onDragEnd: (elements) => { + document.documentElement.dataset.dragDirectionTargets = elements + .map((element) => element.getAttribute("data-testid") ?? element.tagName) + .join(","); + }, + }, + }); + }); + + await reactGrab.activate(); + await reactGrab.page.mouse.move(100, 100); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(200, 200, { steps: 10 }); + await reactGrab.page.mouse.up(); + await expect + .poll(() => + reactGrab.page.evaluate(() => document.documentElement.dataset.dragDirectionTargets ?? ""), + ) + .toBe("direction-right"); + + await reactGrab.page.evaluate(() => { + delete document.documentElement.dataset.dragDirectionTargets; + }); + await reactGrab.activate(); + await reactGrab.page.mouse.move(200, 200); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(100, 100, { steps: 10 }); + await reactGrab.page.mouse.up(); + await expect + .poll(() => + reactGrab.page.evaluate(() => document.documentElement.dataset.dragDirectionTargets ?? ""), + ) + .toBe("direction-left"); + }); + + test("selects pointer-events-none text at the release point", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const containerElement = document.createElement("div"); + containerElement.dataset.testid = "pointer-none-container"; + containerElement.style.cssText = + "position:fixed;left:40px;top:80px;width:220px;height:80px;background:#111827;z-index:2147480000"; + + const labelElement = document.createElement("span"); + labelElement.dataset.testid = "pointer-none-release-label"; + labelElement.textContent = "composer-2.5"; + labelElement.style.cssText = + "position:absolute;left:110px;top:30px;width:120px;height:30px;pointer-events:none;font-size:20px;line-height:30px;color:white"; + containerElement.append(labelElement); + document.body.append(containerElement); + + window.__REACT_GRAB__?.registerPlugin({ + name: "pointer-none-release-regression", + hooks: { + onDragEnd: (elements) => { + document.documentElement.dataset.pointerNoneDragTargets = elements + .map((element) => element.getAttribute("data-testid") ?? element.tagName) + .join(","); + }, + }, + }); + }); + + await reactGrab.activate(); + await reactGrab.page.mouse.move(100, 90); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(190, 125, { steps: 10 }); + await reactGrab.page.mouse.up(); + + await expect + .poll(() => + reactGrab.page.evaluate( + () => document.documentElement.dataset.pointerNoneDragTargets ?? "", + ), + ) + .toBe("pointer-none-release-label"); + }); + + test("falls through an ignored top layer during drag selection", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const targetElement = document.createElement("button"); + targetElement.dataset.testid = "under-overlay-drag-target"; + targetElement.textContent = "Target"; + targetElement.style.cssText = + "position:fixed;left:100px;top:100px;width:100px;height:100px;z-index:2147480000"; + + const overlayElement = document.createElement("div"); + overlayElement.dataset.testid = "ignored-drag-overlay"; + overlayElement.setAttribute("data-react-grab-ignore", ""); + overlayElement.style.cssText = + "position:fixed;left:100px;top:100px;width:100px;height:100px;background:transparent;z-index:2147480001"; + document.body.append(targetElement, overlayElement); + + window.__REACT_GRAB__?.registerPlugin({ + name: "ignored-overlay-drag-regression", + hooks: { + onDragEnd: (elements) => { + document.documentElement.dataset.overlayDragTargets = elements + .map((element) => element.getAttribute("data-testid") ?? element.tagName) + .join(","); + }, + }, + }); + }); + + await reactGrab.activate(); + await reactGrab.page.mouse.move(90, 90); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move(190, 190, { steps: 10 }); + await reactGrab.page.mouse.up(); + + await expect + .poll(() => + reactGrab.page.evaluate(() => document.documentElement.dataset.overlayDragTargets ?? ""), + ) + .toBe("under-overlay-drag-target"); + }); + + test("fills unsampled table rows without selecting the table shell", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const tableElement = document.createElement("table"); + tableElement.style.cssText = + "position:fixed;left:100px;top:100px;width:120px;height:200px;border-collapse:collapse;font-size:0;line-height:0;z-index:2147480000"; + const tableBodyElement = document.createElement("tbody"); + for (let rowIndex = 0; rowIndex < 20; rowIndex += 1) { + const rowElement = document.createElement("tr"); + rowElement.dataset.testid = `sparse-row-${rowIndex + 1}`; + rowElement.style.height = "10px"; + const cellElement = document.createElement("td"); + cellElement.textContent = String(rowIndex + 1); + cellElement.style.cssText = "height:10px;padding:0"; + rowElement.append(cellElement); + tableBodyElement.append(rowElement); + } + tableElement.append(tableBodyElement); + document.body.append(tableElement); + + window.__REACT_GRAB__?.registerPlugin({ + name: "sparse-table-drag-regression", + hooks: { + onDragEnd: (elements) => { + document.documentElement.dataset.sparseTableDragTargets = elements + .map((element) => element.getAttribute("data-testid") ?? element.tagName) + .join(","); + }, + }, + }); + }); + + const firstRowBounds = await reactGrab.page + .locator("[data-testid='sparse-row-1']") + .boundingBox(); + const tenthRowBounds = await reactGrab.page + .locator("[data-testid='sparse-row-10']") + .boundingBox(); + if (!firstRowBounds || !tenthRowBounds) throw new Error("Could not measure sparse table rows"); + + await reactGrab.activate(); + await reactGrab.page.mouse.move(firstRowBounds.x + 1, firstRowBounds.y + 1); + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.move( + tenthRowBounds.x + tenthRowBounds.width - 1, + tenthRowBounds.y + tenthRowBounds.height - 1, + { steps: 10 }, + ); + await reactGrab.page.mouse.up(); + + await expect + .poll(() => + reactGrab.page.evaluate( + () => document.documentElement.dataset.sparseTableDragTargets?.split(",") ?? [], + ), + ) + .toEqual(Array.from({ length: 10 }, (_, rowIndex) => `sparse-row-${rowIndex + 1}`)); + }); +}); diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 46f95f603..e78357393 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -189,6 +189,76 @@ test.describe("Element Context Fallback", () => { expect(clipboard).toContain('Quarterly revenue'); }); + test("should hover and copy pointer-events-none SVG text instead of its chart root", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate(() => { + const wrapperElement = document.createElement("div"); + Object.assign(wrapperElement.style, { + left: "200px", + position: "fixed", + top: "200px", + zIndex: "999", + }); + const svgElement = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgElement.setAttribute("height", "100"); + svgElement.setAttribute("width", "400"); + const textElement = document.createElementNS("http://www.w3.org/2000/svg", "text"); + textElement.id = "pointer-events-none-svg-label"; + textElement.setAttribute("font-size", "24"); + textElement.setAttribute("x", "20"); + textElement.setAttribute("y", "50"); + textElement.style.pointerEvents = "none"; + textElement.textContent = "composer-2.5"; + svgElement.appendChild(textElement); + wrapperElement.appendChild(svgElement); + document.body.appendChild(wrapperElement); + }); + + await reactGrab.activate(); + await reactGrab.hoverUntilTargetSelected("#pointer-events-none-svg-label"); + await reactGrab.clickElement("#pointer-events-none-svg-label"); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain('composer-2.5'); + + await reactGrab.activate(); + await reactGrab.hoverUntilTargetSelected("#pointer-events-none-svg-label"); + await reactGrab.pressArrowUp(); + await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("svg"); + await reactGrab.pressArrowDown(); + await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("text"); + }); + + test("should refine a native container hit to pointer-events-none HTML text", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate(() => { + const buttonElement = document.createElement("button"); + Object.assign(buttonElement.style, { + fontSize: "24px", + left: "200px", + padding: "20px", + position: "fixed", + top: "200px", + zIndex: "999", + }); + const labelElement = document.createElement("span"); + labelElement.id = "pointer-events-none-html-label"; + labelElement.style.pointerEvents = "none"; + labelElement.textContent = "Nested label"; + buttonElement.appendChild(labelElement); + document.body.appendChild(buttonElement); + }); + + await reactGrab.activate(); + await reactGrab.hoverUntilTargetSelected("#pointer-events-none-html-label"); + await reactGrab.clickElement("#pointer-events-none-html-label"); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain('Nested label'); + }); + test("should use a semantic link selector for a source-less SVG path", async ({ reactGrab, }) => { diff --git a/packages/react-grab/e2e/event-callbacks.spec.ts b/packages/react-grab/e2e/event-callbacks.spec.ts index cf5e4829f..b3717be97 100644 --- a/packages/react-grab/e2e/event-callbacks.spec.ts +++ b/packages/react-grab/e2e/event-callbacks.spec.ts @@ -107,7 +107,7 @@ test.describe("Event Callbacks", () => { await reactGrab.page.waitForTimeout(100); await reactGrab.hoverElement("li:first-child"); await reactGrab.page.waitForTimeout(100); - await reactGrab.hoverElement("ul"); + await reactGrab.hoverElement("[data-testid='test-input']"); await reactGrab.page.waitForTimeout(100); const history = await reactGrab.getCallbackHistory(); diff --git a/packages/react-grab/e2e/fiber-relink.spec.ts b/packages/react-grab/e2e/fiber-relink.spec.ts index 0a7f7b1c7..30c708a2b 100644 --- a/packages/react-grab/e2e/fiber-relink.spec.ts +++ b/packages/react-grab/e2e/fiber-relink.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "./fixtures.js"; import type { Page } from "@playwright/test"; +import { getTextInteractionPosition } from "./get-text-interaction-position.js"; interface FiberSwapWindow { __triggerFiberSwap?: () => void; @@ -12,6 +13,8 @@ interface FiberSwapWindow { const SELECTION_TEXT_ATTEMPTS = 4; const HOVER_MOVE_STEPS = 5; const SELECTION_TEXT_POLL_TIMEOUT_MS = 1_000; +const SELECTION_SETTLE_DELAY_MS = 50; +const DISCONNECTED_HOVER_REDETECTION_TIMEOUT_MS = 130; const getSelectionTargetText = () => (window as unknown as FiberSwapWindow).__REACT_GRAB__ @@ -29,10 +32,13 @@ const hoverUntilSelectionTextIs = async (page: Page, selector: string, expectedT for (let attempt = 1; attempt <= SELECTION_TEXT_ATTEMPTS; attempt++) { const bounds = await target.boundingBox(); if (bounds) { + const textPosition = await getTextInteractionPosition(target); await page.mouse.move(bounds.x - 5, bounds.y - 5); - await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, { - steps: HOVER_MOVE_STEPS, - }); + await page.mouse.move( + bounds.x + (textPosition?.x ?? bounds.width / 2), + bounds.y + (textPosition?.y ?? bounds.height / 2), + { steps: HOVER_MOVE_STEPS }, + ); } try { await expect @@ -40,7 +46,8 @@ const hoverUntilSelectionTextIs = async (page: Page, selector: string, expectedT timeout: SELECTION_TEXT_POLL_TIMEOUT_MS, }) .toBe(expectedText); - return; + await page.waitForTimeout(SELECTION_SETTLE_DELAY_MS); + if ((await page.evaluate(getSelectionTargetText)) === expectedText) return; } catch (error) { if (attempt === SELECTION_TEXT_ATTEMPTS) throw error; } @@ -48,6 +55,50 @@ const hoverUntilSelectionTextIs = async (page: Page, selector: string, expectedT }; test.describe("Fiber-latched selection", () => { + test("redetects under the pointer when the hovered element disconnects", async ({ + reactGrab, + page, + }) => { + await page.evaluate(() => { + const disconnectedElement = document.createElement("button"); + disconnectedElement.dataset.testid = "disconnected-hover-target"; + disconnectedElement.textContent = "Disconnecting hover target"; + disconnectedElement.style.cssText = + "position:fixed;left:20px;top:80px;width:180px;height:40px;z-index:2147480000"; + + const replacementElement = document.createElement("button"); + replacementElement.dataset.testid = "replacement-hover-target"; + replacementElement.textContent = "Replacement hover target"; + replacementElement.style.cssText = + "position:fixed;left:220px;top:80px;width:180px;height:40px;z-index:2147480000"; + document.body.append(disconnectedElement, replacementElement); + }); + + await reactGrab.activate(); + await hoverUntilSelectionTextIs( + page, + "[data-testid='disconnected-hover-target']", + "Disconnecting hover target", + ); + + await page.locator("[data-testid='disconnected-hover-target']").evaluate((element) => { + element.remove(); + }); + const replacementElement = page.locator("[data-testid='replacement-hover-target']"); + const replacementBounds = await replacementElement.boundingBox(); + if (!replacementBounds) throw new Error("Could not get replacement hover target bounds"); + await page.mouse.move( + replacementBounds.x + replacementBounds.width / 2, + replacementBounds.y + replacementBounds.height / 2, + ); + + await expect + .poll(() => page.evaluate(getSelectionTargetText), { + timeout: DISCONNECTED_HOVER_REDETECTION_TIMEOUT_MS, + }) + .toBe("Replacement hover target"); + }); + // When React swaps the DOM node backing a held selection (a keyed remount // here), the originally captured Element detaches. Without fiber latching the // selection drops; with it, react-grab re-resolves the live node from the diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index c1e63bc28..3f79b047f 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -5,6 +5,7 @@ import { UI_STATE_TIMEOUT_MS, } from "./constants.js"; import { COVERAGE_RAW_DIR } from "./coverage-config.js"; +import { getTextInteractionPosition } from "./get-text-interaction-position.js"; const COVERAGE_ENABLED = Boolean(process.env.COVERAGE); @@ -320,13 +321,15 @@ const createReactGrabPageObject = ( const hoverElement = async (selector: string) => { const element = page.locator(selector).first(); - await element.hover({ force: true }); + const position = await getTextInteractionPosition(element); + await element.hover({ force: true, position }); await page.waitForTimeout(350); }; const clickElement = async (selector: string) => { const element = page.locator(selector).first(); - await element.click({ force: true }); + const position = await getTextInteractionPosition(element); + await element.click({ force: true, position }); }; const dragSelect = async (startSelector: string, endSelector: string) => { @@ -522,7 +525,8 @@ const createReactGrabPageObject = ( const rightClickElement = async (selector: string) => { const wasActive = await isOverlayVisible(); const element = page.locator(selector).first(); - await element.click({ button: "right", force: true }); + const position = await getTextInteractionPosition(element); + await element.click({ button: "right", force: true, position }); if (wasActive) { await waitForContextMenu(true); } @@ -1544,7 +1548,11 @@ const createReactGrabPageObject = ( const element = page.locator(selector).first(); const box = await element.boundingBox(); if (box) { - await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + const position = await getTextInteractionPosition(element); + await page.touchscreen.tap( + box.x + (position?.x ?? box.width / 2), + box.y + (position?.y ?? box.height / 2), + ); } }; diff --git a/packages/react-grab/e2e/get-text-interaction-position.ts b/packages/react-grab/e2e/get-text-interaction-position.ts new file mode 100644 index 000000000..233bcaf14 --- /dev/null +++ b/packages/react-grab/e2e/get-text-interaction-position.ts @@ -0,0 +1,30 @@ +import type { Locator } from "@playwright/test"; + +export const getTextInteractionPosition = async (element: Locator) => + element.evaluate((targetElement) => { + if (targetElement.firstElementChild) return undefined; + + const targetBounds = targetElement.getBoundingClientRect(); + const textNodeWalker = document.createTreeWalker(targetElement, NodeFilter.SHOW_TEXT); + let textNode = textNodeWalker.nextNode(); + while (textNode) { + if (textNode.textContent?.trim()) { + const textRange = document.createRange(); + textRange.selectNodeContents(textNode); + for (const textBounds of textRange.getClientRects()) { + const clippedLeft = Math.max(textBounds.left, targetBounds.left); + const clippedTop = Math.max(textBounds.top, targetBounds.top); + const clippedRight = Math.min(textBounds.right, targetBounds.right); + const clippedBottom = Math.min(textBounds.bottom, targetBounds.bottom); + if (clippedRight > clippedLeft && clippedBottom > clippedTop) { + return { + x: clippedLeft - targetBounds.left + (clippedRight - clippedLeft) / 2, + y: clippedTop - targetBounds.top + (clippedBottom - clippedTop) / 2, + }; + } + } + } + textNode = textNodeWalker.nextNode(); + } + return undefined; + }); diff --git a/packages/react-grab/e2e/keyboard-navigation.spec.ts b/packages/react-grab/e2e/keyboard-navigation.spec.ts index b05e56a55..8488430c5 100644 --- a/packages/react-grab/e2e/keyboard-navigation.spec.ts +++ b/packages/react-grab/e2e/keyboard-navigation.spec.ts @@ -58,6 +58,12 @@ const showKeyboardSelectionDiscardPrompt = async (reactGrab: ReactGrabPageObject await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); }; +const selectTodoListItem = async (reactGrab: ReactGrabPageObject, selector: string) => { + await reactGrab.hoverUntilSelected(`${selector} span`); + await reactGrab.page.keyboard.press("ArrowUp"); + await reactGrab.waitForSelectionBox(); +}; + test.describe("Keyboard Navigation", () => { test("should navigate to next element with ArrowDown", async ({ reactGrab }) => { await reactGrab.activate(); @@ -83,7 +89,7 @@ test.describe("Keyboard Navigation", () => { test("should navigate to a sibling with ArrowLeft", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:nth-child(2)"); + await selectTodoListItem(reactGrab, "[data-testid='todo-list'] li:nth-child(2)"); await reactGrab.page.keyboard.press("ArrowLeft"); await reactGrab.waitForSelectionBox(); @@ -96,7 +102,7 @@ test.describe("Keyboard Navigation", () => { test("should navigate to a sibling with ArrowRight", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:first-child"); + await selectTodoListItem(reactGrab, "[data-testid='todo-list'] li:first-child"); await reactGrab.page.keyboard.press("ArrowRight"); await reactGrab.waitForSelectionBox(); @@ -110,7 +116,7 @@ test.describe("Keyboard Navigation", () => { test("Tab should navigate to the next sibling like ArrowRight", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:first-child"); + await selectTodoListItem(reactGrab, "[data-testid='todo-list'] li:first-child"); await reactGrab.page.keyboard.press("Tab"); await reactGrab.waitForSelectionBox(); @@ -124,7 +130,7 @@ test.describe("Keyboard Navigation", () => { reactGrab, }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:nth-child(2)"); + await selectTodoListItem(reactGrab, "[data-testid='todo-list'] li:nth-child(2)"); await reactGrab.page.keyboard.press("Shift+Tab"); await reactGrab.waitForSelectionBox(); @@ -526,7 +532,7 @@ test.describe("Navigation History and Wrapping", () => { test.describe("ArrowUp Vertical Traversal", () => { test("ArrowUp should reach parent element from child", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:first-child"); + await selectTodoListItem(reactGrab, "[data-testid='todo-list'] li:first-child"); const initialLabel = await reactGrab.getSelectionLabelInfo(); @@ -542,7 +548,7 @@ test.describe("ArrowUp Vertical Traversal", () => { test("repeated ArrowUp should not oscillate between elements", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:first-child"); + await selectTodoListItem(reactGrab, "[data-testid='todo-list'] li:first-child"); const visitedTags: string[] = []; for (let step = 0; step < 8; step++) { @@ -567,7 +573,7 @@ test.describe("ArrowUp Vertical Traversal", () => { test("ArrowUp bounds should never shrink", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:first-child"); + await selectTodoListItem(reactGrab, "[data-testid='todo-list'] li:first-child"); let previousBounds = await reactGrab.getSelectionBoxBounds(); expect(previousBounds).not.toBeNull(); @@ -593,7 +599,7 @@ test.describe("ArrowUp Vertical Traversal", () => { test("ArrowDown should reverse ArrowUp and maintain selection", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:first-child"); + await selectTodoListItem(reactGrab, "[data-testid='todo-list'] li:first-child"); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); diff --git a/packages/react-grab/e2e/keyboard-shortcuts.spec.ts b/packages/react-grab/e2e/keyboard-shortcuts.spec.ts index 80ffdc24f..1a72559a7 100644 --- a/packages/react-grab/e2e/keyboard-shortcuts.spec.ts +++ b/packages/react-grab/e2e/keyboard-shortcuts.spec.ts @@ -34,9 +34,9 @@ test.describe("Keyboard Shortcuts", () => { test("should copy list item when clicked", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:nth-child(2)"); + await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:nth-child(2) span"); - await reactGrab.clickElement("[data-testid='todo-list'] li:nth-child(2)"); + await reactGrab.clickElement("[data-testid='todo-list'] li:nth-child(2) span"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); diff --git a/packages/react-grab/e2e/open-file.spec.ts b/packages/react-grab/e2e/open-file.spec.ts index 91d6ad28d..dc93be7fe 100644 --- a/packages/react-grab/e2e/open-file.spec.ts +++ b/packages/react-grab/e2e/open-file.spec.ts @@ -274,7 +274,7 @@ test.describe("Open File", () => { }); await reactGrab.activate(); - await reactGrab.hoverUntilSelected("li:first-child"); + await reactGrab.hoverUntilSelected("li:first-child span"); await reactGrab.waitForSelectionSource(); await reactGrab.page.evaluate((attrName) => { diff --git a/packages/react-grab/e2e/overlay-filtering.spec.ts b/packages/react-grab/e2e/overlay-filtering.spec.ts index 22c5fbf7b..dc87a5947 100644 --- a/packages/react-grab/e2e/overlay-filtering.spec.ts +++ b/packages/react-grab/e2e/overlay-filtering.spec.ts @@ -49,7 +49,7 @@ test.describe("Overlay Filtering", () => { test("should select page elements through react-grab overlay", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("li:first-child"); + await reactGrab.hoverUntilSelected("li:first-child span"); const tagName = await reactGrab.page.evaluate(() => { const api = ( @@ -63,7 +63,7 @@ test.describe("Overlay Filtering", () => { return state?.targetElement?.tagName?.toLowerCase() ?? null; }); - expect(tagName).toBe("li"); + expect(tagName).toBe("span"); }); }); diff --git a/packages/react-grab/e2e/prompt-mode.spec.ts b/packages/react-grab/e2e/prompt-mode.spec.ts index 33115e65e..5f5a28b6a 100644 --- a/packages/react-grab/e2e/prompt-mode.spec.ts +++ b/packages/react-grab/e2e/prompt-mode.spec.ts @@ -192,7 +192,7 @@ test.describe("Prompt Mode", () => { test.describe("Keyboard Shortcuts in Prompt Mode", () => { test("arrow keys should not navigate elements in prompt mode", async ({ reactGrab }) => { await reactGrab.registerCommentAction(); - await reactGrab.enterPromptMode("li:first-child"); + await reactGrab.enterPromptMode("li:first-child span"); await reactGrab.typeInInput("Line 1\nLine 2"); await reactGrab.pressArrowDown(); @@ -201,7 +201,7 @@ test.describe("Prompt Mode", () => { expect(isPromptMode).toBe(true); expect(await reactGrab.getInputValue()).toBe("Line 1\nLine 2"); const isFirstItemStillSelected = await reactGrab.page.evaluate(() => { - const firstItem = document.querySelector("li:first-child"); + const firstItem = document.querySelector("li:first-child span"); return window.__REACT_GRAB__?.getState().targetElement === firstItem; }); expect(isFirstItemStillSelected).toBe(true); diff --git a/packages/react-grab/e2e/shift-multi-select.spec.ts b/packages/react-grab/e2e/shift-multi-select.spec.ts index 2607bbff6..db4933a0f 100644 --- a/packages/react-grab/e2e/shift-multi-select.spec.ts +++ b/packages/react-grab/e2e/shift-multi-select.spec.ts @@ -19,8 +19,8 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const lastItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(6); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const lastItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(6); const firstBox = await firstItem.boundingBox(); const lastBox = await lastItem.boundingBox(); @@ -50,8 +50,8 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -93,8 +93,8 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -158,7 +158,7 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); const firstBox = await firstItem.boundingBox(); if (!firstBox) throw new Error("Could not get bounding box"); @@ -176,7 +176,7 @@ test.describe("Shift Multi-Select", () => { const label = shadowRoot?.querySelector("[data-react-grab-selection-label]"); return label?.textContent?.replace(/\s+/g, " ").trim() ?? ""; }); - expect(firstLabelText).toContain("TodoItem.li"); + expect(firstLabelText).toContain("TodoItem.span"); const initialLabelBounds = await reactGrab.getSelectionLabelBounds(); if (!initialLabelBounds) throw new Error("Could not get initial label bounds"); @@ -208,8 +208,8 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").first(); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").first(); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); if (!firstBox || !secondBox) throw new Error("Could not get bounding boxes"); @@ -277,8 +277,8 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -334,8 +334,8 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -380,8 +380,8 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -456,9 +456,9 @@ test.describe("Shift Multi-Select", () => { test("should preserve drag preview while shift multi-selecting", async ({ reactGrab }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); - const thirdItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(2); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); + const thirdItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(2); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -492,8 +492,8 @@ test.describe("Shift Multi-Select", () => { test("should toggle element off when shift+clicking it twice", async ({ reactGrab }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -528,7 +528,7 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.evaluate(() => navigator.clipboard.writeText("baseline")); await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); const firstBox = await firstItem.boundingBox(); if (!firstBox) throw new Error("Could not get bounding box"); @@ -555,9 +555,9 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); - const lastItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(6); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); + const lastItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(6); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -593,8 +593,8 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.evaluate(() => navigator.clipboard.writeText("baseline")); await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -630,8 +630,8 @@ test.describe("Shift Multi-Select", () => { test("should render a tag label under each accumulated element", async ({ reactGrab }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -662,7 +662,7 @@ test.describe("Shift Multi-Select", () => { expect(labelTexts.length).toBeGreaterThanOrEqual(2); const concatenatedLabelText = labelTexts.join(" "); expect(concatenatedLabelText).not.toContain("elements"); - expect(concatenatedLabelText).toContain("li"); + expect(concatenatedLabelText).toContain("span"); await reactGrab.page.keyboard.up("Shift"); }); @@ -672,9 +672,9 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); - const lastItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(6); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); + const lastItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(6); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -713,8 +713,8 @@ test.describe("Shift Multi-Select", () => { test("should clear shift multi-select state when window loses focus", async ({ reactGrab }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -756,8 +756,8 @@ test.describe("Shift Multi-Select", () => { }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); @@ -791,9 +791,9 @@ test.describe("Shift Multi-Select", () => { test("should extend existing drag selection with shift+click", async ({ reactGrab }) => { await reactGrab.activate(); - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); - const lastItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(6); + const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(0); + const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(1); + const lastItem = reactGrab.page.locator("[data-testid='todo-list'] li span").nth(6); const firstBox = await firstItem.boundingBox(); const secondBox = await secondItem.boundingBox(); diff --git a/packages/react-grab/e2e/three-fiber-selection.spec.ts b/packages/react-grab/e2e/three-fiber-selection.spec.ts index 010fe6469..774626f04 100644 --- a/packages/react-grab/e2e/three-fiber-selection.spec.ts +++ b/packages/react-grab/e2e/three-fiber-selection.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./fixtures.js"; import { moveToThreeObject } from "./move-to-three-object.js"; import { + THREE_DRAG_SELECTION_OUTSET_PX, THREE_ELAPSED_TIME_WINDOW_PROPERTY, THREE_FRAME_COUNT_WINDOW_PROPERTY, THREE_LEFT_OBJECT_HORIZONTAL_RATIO, @@ -109,4 +110,39 @@ test.describe("React Three Fiber selection", () => { const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).not.toContain(' { + await reactGrab.activate(); + await moveToThreeObject(page, "three-fiber-canvas", THREE_LEFT_OBJECT_HORIZONTAL_RATIO); + await reactGrab.waitForSelectionBox(); + const leftSelectionBounds = await reactGrab.getSelectionBoxBounds(); + if (!leftSelectionBounds) throw new Error("Left Three.js selection bounds were not rendered"); + + await moveToThreeObject(page, "three-fiber-canvas", THREE_RIGHT_OBJECT_HORIZONTAL_RATIO); + await expect + .poll(async () => (await reactGrab.getSelectionBoxBounds())?.x ?? leftSelectionBounds.x) + .toBeGreaterThan(leftSelectionBounds.x); + const rightSelectionBounds = await reactGrab.getSelectionBoxBounds(); + if (!rightSelectionBounds) throw new Error("Right Three.js selection bounds were not rendered"); + + const dragStartX = leftSelectionBounds.x - THREE_DRAG_SELECTION_OUTSET_PX; + const dragStartY = + Math.min(leftSelectionBounds.y, rightSelectionBounds.y) - THREE_DRAG_SELECTION_OUTSET_PX; + const dragEndX = + rightSelectionBounds.x + rightSelectionBounds.width + THREE_DRAG_SELECTION_OUTSET_PX; + const dragEndY = + Math.max( + leftSelectionBounds.y + leftSelectionBounds.height, + rightSelectionBounds.y + rightSelectionBounds.height, + ) + THREE_DRAG_SELECTION_OUTSET_PX; + + await page.mouse.move(dragStartX, dragStartY); + await page.mouse.down(); + await page.mouse.move(dragEndX, dragEndY, { steps: 10 }); + await page.mouse.up(); + + await expect.poll(() => reactGrab.getClipboardContent()).toContain(' { await reactGrab.activate(); await reactGrab.hoverUntilSelected("[data-testid='todo-list'] h1"); - - const element = reactGrab.page.locator("[data-testid='todo-list'] h1"); - const box = await element.boundingBox(); - if (!box) throw new Error("Could not get bounding box"); - - await reactGrab.page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + await reactGrab.touchTap("[data-testid='todo-list'] h1"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) diff --git a/packages/react-grab/e2e/visual-feedback.spec.ts b/packages/react-grab/e2e/visual-feedback.spec.ts index bf6525ae9..518c3c6e8 100644 --- a/packages/react-grab/e2e/visual-feedback.spec.ts +++ b/packages/react-grab/e2e/visual-feedback.spec.ts @@ -4,9 +4,9 @@ test.describe("Visual Feedback", () => { test.describe("Selection Box", () => { test("selection box should match element bounds", async ({ reactGrab }) => { await reactGrab.activate(); - await reactGrab.hoverUntilSelected("li:first-child"); + await reactGrab.hoverUntilSelected("li:first-child span"); - const elementBounds = await reactGrab.getElementBounds("li:first-child"); + const elementBounds = await reactGrab.getElementBounds("li:first-child span"); const selectionBounds = await reactGrab.getSelectionBoxBounds(); if (elementBounds && selectionBounds) { diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index d627e1bda..8ae7c499d 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -78,9 +78,12 @@ export const ELEMENT_DETECTION_THROTTLE_MS = 32; export const PENDING_DETECTION_STALENESS_MS = 200; export const COMPONENT_NAME_DEBOUNCE_MS = 100; export const DRAG_PREVIEW_DEBOUNCE_MS = 32; +export const DRAG_PREVIEW_MAX_WAIT_MS = 100; +export const DRAG_PREVIEW_FRAME_BUDGET_MS = 16; export const BOUNDS_CACHE_TTL_MS = 16; export const THREE_PREVIEW_ARRAY_MAX_LENGTH = 4; export const THREE_SELECTION_FALLBACK_BOUNDS_PX = 16; +export const THREE_DRAG_SELECTION_MAX_INDIVIDUAL_INSTANCES = 512; export const IFRAME_LAYOUT_METRICS_CACHE_TTL_MS = 16; export const BORDER_RADIUS_CACHE_TTL_MS = 200; export const BORDER_RADIUS_SCALE_PRECISION_DECIMAL_PLACES = 3; @@ -245,6 +248,13 @@ export const DRAG_SELECTION_MIN_SAMPLES_PER_AXIS = 3; export const DRAG_SELECTION_MAX_SAMPLES_PER_AXIS = 20; export const DRAG_SELECTION_MAX_TOTAL_SAMPLE_POINTS = 100; export const DRAG_SELECTION_EDGE_INSET_PX = 1; +export const DRAG_SELECTION_SAMPLE_COORDINATE_VALUES = 2; +export const DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS = 64; +export const DRAG_SELECTION_MAX_LOCAL_COLLECTION_ELEMENTS = 32; +export const DRAG_SELECTION_MAX_TEXT_FLOW_NODES = 64; +export const DRAG_SELECTION_MAX_TEXT_NODES = 32; +export const DRAG_SELECTION_MAX_TEXT_RECTS = 64; +export const MIN_HIT_TEST_VIEWPORT_DIMENSION_PX = 1; export const MAX_ARROW_NAVIGATION_HISTORY = 50; export const MIN_HORIZONTAL_NAV_SIZE_PX = 16; diff --git a/packages/react-grab/src/core/arrow-navigation.ts b/packages/react-grab/src/core/arrow-navigation.ts index 81d3dd0ac..cd8e79bc5 100644 --- a/packages/react-grab/src/core/arrow-navigation.ts +++ b/packages/react-grab/src/core/arrow-navigation.ts @@ -1,6 +1,7 @@ import { MAX_ARROW_NAVIGATION_HISTORY } from "../constants.js"; import type { ElementPredicate, OverlayBounds } from "../types.js"; import { getElementsAtPoint } from "../utils/get-element-at-position.js"; +import { getElementTextBounds } from "../utils/get-element-text-bounds.js"; import { getVisibleBoundsCenter } from "../utils/get-visible-bounds-center.js"; import { isElementConnected } from "../utils/is-element-connected.js"; @@ -21,8 +22,22 @@ export const createArrowNavigator = ( let navigationHistory: Element[] = []; const findVerticalNext = (currentElement: Element, direction: 1 | -1): Element | null => { - const bounds = createElementBounds(currentElement); - const probePoint = getVisibleBoundsCenter(bounds); + const textBounds = getElementTextBounds(currentElement); + let probeBounds = createElementBounds(currentElement); + if (textBounds) { + for (const textFragmentBounds of textBounds) { + if ( + textFragmentBounds.x < window.innerWidth && + textFragmentBounds.x + textFragmentBounds.width > 0 && + textFragmentBounds.y < window.innerHeight && + textFragmentBounds.y + textFragmentBounds.height > 0 + ) { + probeBounds = textFragmentBounds; + break; + } + } + } + const probePoint = getVisibleBoundsCenter(probeBounds); const elementsAtPoint = getElementsAtPoint(probePoint.x, probePoint.y).filter( isValidGrabbableElement, ); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 033067c42..90cc596e6 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -77,6 +77,8 @@ import { PENDING_DETECTION_STALENESS_MS, COMPONENT_NAME_DEBOUNCE_MS, DRAG_PREVIEW_DEBOUNCE_MS, + DRAG_PREVIEW_MAX_WAIT_MS, + DRAG_PREVIEW_FRAME_BUDGET_MS, MODIFIER_KEYS, BLUR_DEACTIVATION_THRESHOLD_MS, BOUNDS_RECALC_INTERVAL_MS, @@ -129,6 +131,7 @@ import type { Plugin, ToolbarState, DropdownAnchor, + DragRect, ElementLabelVariant, } from "../types.js"; import { createPluginRegistry } from "./plugin-registry.js"; @@ -487,28 +490,71 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { latestPointerX: 0, latestPointerY: 0, }; - let dragPreviewDebounceTimerId: number | null = null; - const [debouncedDragPointer, setDebouncedDragPointer] = createSignal<{ - x: number; - y: number; - } | null>(null); + let dragPreviewUpdateTimerId: number | null = null; + const latestDragPreviewPointer: Position = { x: 0, y: 0 }; + let lastDragPreviewUpdateTimestampMs = 0; + let lastDragPreviewComputationDurationMs = 0; + const [dragPreviewPointer, setDragPreviewPointer] = createSignal(null); const [scrollVersion, setScrollVersion] = createSignal(0); + const cancelScheduledDragPreviewUpdate = () => { + if (dragPreviewUpdateTimerId === null) return; + clearTimeout(dragPreviewUpdateTimerId); + dragPreviewUpdateTimerId = null; + }; + const publishDragPreviewPointer = (timestampMs: number) => { + setDragPreviewPointer({ + x: latestDragPreviewPointer.x, + y: latestDragPreviewPointer.y, + }); + lastDragPreviewUpdateTimestampMs = timestampMs; + }; const scheduleDragPreviewUpdate = (clientX: number, clientY: number) => { - if (dragPreviewDebounceTimerId !== null) { - clearTimeout(dragPreviewDebounceTimerId); + if (!isDraggingBeyondThreshold()) return; + + latestDragPreviewPointer.x = clientX; + latestDragPreviewPointer.y = clientY; + const timestampMs = performance.now(); + const timeSinceLastUpdateMs = timestampMs - lastDragPreviewUpdateTimestampMs; + const isPreviewComputationExpensive = + lastDragPreviewComputationDurationMs >= DRAG_PREVIEW_FRAME_BUDGET_MS; + + if ( + lastDragPreviewUpdateTimestampMs === 0 || + (!isPreviewComputationExpensive && timeSinceLastUpdateMs >= DRAG_PREVIEW_MAX_WAIT_MS) + ) { + cancelScheduledDragPreviewUpdate(); + publishDragPreviewPointer(timestampMs); + return; } - setDebouncedDragPointer(null); - dragPreviewDebounceTimerId = window.setTimeout(() => { - setDebouncedDragPointer({ x: clientX, y: clientY }); - dragPreviewDebounceTimerId = null; - }, DRAG_PREVIEW_DEBOUNCE_MS); + + cancelScheduledDragPreviewUpdate(); + dragPreviewUpdateTimerId = window.setTimeout( + () => { + if (isDraggingBeyondThreshold()) publishDragPreviewPointer(performance.now()); + dragPreviewUpdateTimerId = null; + }, + isPreviewComputationExpensive + ? DRAG_PREVIEW_DEBOUNCE_MS + : Math.min(DRAG_PREVIEW_DEBOUNCE_MS, DRAG_PREVIEW_MAX_WAIT_MS - timeSinceLastUpdateMs), + ); }; - const releaseDragPreview = () => { - if (dragPreviewDebounceTimerId !== null) { - clearTimeout(dragPreviewDebounceTimerId); - dragPreviewDebounceTimerId = null; - } - setDebouncedDragPointer(null); + const resolveDragSelectionAtRelease = ( + dragSelectionRect: DragRect, + clientX: number, + clientY: number, + ): Element[] => { + cancelScheduledDragPreviewUpdate(); + return getElementsInDrag( + dragSelectionRect, + { x: clientX, y: clientY }, + isValidGrabbableElement, + ); + }; + const clearDragPreview = () => { + cancelScheduledDragPreviewUpdate(); + lastDragPreviewUpdateTimestampMs = 0; + lastDragPreviewComputationDurationMs = 0; + setDragPreviewPointer(null); // Memos hold their last value until re-read. Once the drag is over // nothing may render the preview again, which would pin the captured // Element[] (and any since-detached subtrees) in memory — reading the @@ -1348,11 +1394,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (!isDraggingBeyondThreshold()) return []; - const pointer = debouncedDragPointer(); + const pointer = dragPreviewPointer(); if (!pointer) return []; const drag = calculateDragRectangle(pointer.x, pointer.y); - return getElementsInDrag(drag, isValidGrabbableElement); + const computationStartTimestampMs = performance.now(); + const elements = getElementsInDrag(drag, pointer, isValidGrabbableElement); + lastDragPreviewComputationDurationMs = performance.now() - computationStartTimestampMs; + return elements; }); const dragPreviewBounds = createMemo((): OverlayBounds[] => { @@ -1686,7 +1735,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { setPendingToolbarActionId(null); if (wasDragging) { restoreHostBodyStyle("userSelect"); - releaseDragPreview(); + clearDragPreview(); } if (keydownSpamTimerId) window.clearTimeout(keydownSpamTimerId); autoScroller.stop(); @@ -1943,18 +1992,22 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { // discards the result anyway, and each hit-test costs a full // elementFromPoint pass (~20ms on 100k-node DOMs). cancelActiveDrag // redetects on cancel; a committed drag enters the frozen phase. - if ( - !isDraggingBeyondThreshold() && - now - elementDetectionState.lastDetectionTimestamp >= ELEMENT_DETECTION_THROTTLE_MS && - !isDetectionPending - ) { - elementDetectionState.lastDetectionTimestamp = now; + if (!isDraggingBeyondThreshold() && !isDetectionPending) { elementDetectionState.pendingDetectionScheduledAt = now; + const detectionDelay = Math.max( + 0, + ELEMENT_DETECTION_THROTTLE_MS - (now - elementDetectionState.lastDetectionTimestamp), + ); setTimeout(() => { - if (isElementDetectionBlocked() || isDraggingBeyondThreshold()) { + if (isElementDetectionBlocked() || isFrozenPhase() || isDraggingBeyondThreshold()) { elementDetectionState.pendingDetectionScheduledAt = 0; return; } + if (store.detectedElement && !isElementConnected(store.detectedElement)) { + actions.relinkLiveElements(); + clearElementPositionCache(); + } + elementDetectionState.lastDetectionTimestamp = performance.now(); const candidate = getElementAtPosition( elementDetectionState.latestPointerX, elementDetectionState.latestPointerY, @@ -1963,7 +2016,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.setDetectedElement(candidate); } elementDetectionState.pendingDetectionScheduledAt = 0; - }); + }, detectionDelay); } if (isDragging()) { @@ -2003,8 +2056,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.setPointer({ x: clientX, y: clientY }); setHostBodyStyle("userSelect", "none"); - scheduleDragPreviewUpdate(clientX, clientY); - pluginRegistry.hooks.onDragStart(clientX + window.scrollX, clientY + window.scrollY); return true; @@ -2097,11 +2148,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const handleDragSelection = ( dragSelectionRect: ReturnType, + selectedElements: Element[], hasModifierKeyHeld: boolean, isShiftHeld: boolean, ) => { - const selectedElements = getElementsInDrag(dragSelectionRect, isValidGrabbableElement); - if (selectedElements.length === 0) return; const isShiftAccumulating = @@ -2279,7 +2329,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const cancelActiveDrag = () => { if (!isDragging()) return; stopSpaceDragRepositioning(); - releaseDragPreview(); + clearDragPreview(); actions.cancelDrag(); autoScroller.stop(); restoreHostBodyStyle("userSelect"); @@ -2296,8 +2346,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ) => { if (!isDragging()) return; - releaseDragPreview(); - const dragDistance = calculateDragDistance(clientX, clientY); const wasDragGesture = dragDistance.x > DRAG_THRESHOLD_PX || dragDistance.y > DRAG_THRESHOLD_PX; @@ -2305,6 +2353,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { // The rectangle needs to be calculated before endDrag() because endDrag // resets dragStart in the store, which would zero out the rectangle. const dragSelectionRect = wasDragGesture ? calculateDragRectangle(clientX, clientY) : null; + const dragSelectionElements = dragSelectionRect + ? resolveDragSelectionAtRelease(dragSelectionRect, clientX, clientY) + : []; + + clearDragPreview(); if (wasDragGesture) { actions.endDrag(); @@ -2316,7 +2369,12 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { restoreHostBodyStyle("userSelect"); if (dragSelectionRect) { - handleDragSelection(dragSelectionRect, hasModifierKeyHeld, isShiftHeld); + handleDragSelection( + dragSelectionRect, + dragSelectionElements, + hasModifierKeyHeld, + isShiftHeld, + ); } else { handleSingleClick(clientX, clientY, hasModifierKeyHeld, isShiftHeld); } @@ -3344,9 +3402,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const cleanupErrors: unknown[] = []; collectCleanupError(stopForwardingSameOriginFrameEvents, cleanupErrors); collectCleanupError(() => eventListenerManager.abort(), cleanupErrors); - if (dragPreviewDebounceTimerId !== null) { - window.clearTimeout(dragPreviewDebounceTimerId); - } + cancelScheduledDragPreviewUpdate(); if (keydownSpamTimerId) window.clearTimeout(keydownSpamTimerId); collectCleanupError(clearCopyFeedbackCooldown, cleanupErrors); if (stopToolbarMenuTracking) { diff --git a/packages/react-grab/src/core/three-selection.ts b/packages/react-grab/src/core/three-selection.ts index 7794c0256..9f7023709 100644 --- a/packages/react-grab/src/core/three-selection.ts +++ b/packages/react-grab/src/core/three-selection.ts @@ -1,6 +1,7 @@ import { getFiberFromHostInstance, getLatestFiber, instrument, type Fiber } from "bippy"; import type { OverlayBounds } from "../types.js"; import { + THREE_DRAG_SELECTION_MAX_INDIVIDUAL_INSTANCES, THREE_PREVIEW_ARRAY_MAX_LENGTH, THREE_SELECTION_FALLBACK_BOUNDS_PX, } from "../constants.js"; @@ -47,14 +48,19 @@ interface ReactThreeFiberInstanceLike { interface ThreeObjectLike { isObject3D: boolean; isScene?: boolean; + isInstancedMesh?: boolean; uuid: string; name: string; type: string; visible: boolean; parent: ThreeObjectLike | null; + boundingBox?: ThreeBoxLike | null; + computeBoundingBox?: () => void; geometry?: ThreeGeometryLike; + position?: ThreeVectorLike; matrixWorld: ThreeMatrixLike; updateWorldMatrix: (updateParents: boolean, updateChildren: boolean) => void; + count?: number; getMatrixAt?: (instanceId: number, matrix: ThreeMatrixLike) => void; children?: ThreeObjectLike[]; __r3f?: ReactThreeFiberInstanceLike; @@ -101,6 +107,7 @@ interface ThreeRootState { interface ThreeRoot { getState: () => ThreeRootState; + selectableObjects: ThreeObjectLike[] | null; } interface ThreeFiberRootLike { @@ -128,6 +135,7 @@ interface ThreeSelection { } const selectionsByObject = new WeakMap>(); +const selectionByElement = new WeakMap(); const threeRootByCanvas = new WeakMap(); const rendererFreezeCleanupByCanvas = new WeakMap void>(); const registrationByReactThreeFiberRoot = new WeakMap< @@ -312,11 +320,13 @@ export const handleReactThreeFiberRootCommit = (root: ThreeFiberRootLike): void canvas, root: { getState: getRootState, + selectableObjects: null, }, }; registrationByReactThreeFiberRoot.set(root, registration); } else { registration.root.getState = getRootState; + registration.root.selectableObjects = null; } threeRootByCanvas.set(canvas, registration.root); registerThreeRendererFreeze(canvas); @@ -357,6 +367,46 @@ const isThreeObjectInScene = (object: ThreeObjectLike, scene: ThreeSceneLike): b return false; }; +const isThreeObjectVisible = (object: ThreeObjectLike): boolean => { + let currentObject: ThreeObjectLike | null = object; + while (currentObject) { + if (!currentObject.visible) return false; + currentObject = currentObject.parent; + } + return true; +}; + +const isThreeDragSelectableObject = (object: ThreeObjectLike): boolean => + Boolean(getReactThreeFiberInstance(object)) && + (isThreeGeometry(object.geometry) || object.type.toLowerCase() === "sprite"); + +const getThreeObjectInstanceCount = (object: ThreeObjectLike): number => { + if ( + object.isInstancedMesh !== true || + typeof object.count !== "number" || + !Number.isInteger(object.count) || + object.count <= 0 || + !object.getMatrixAt + ) { + return 0; + } + return object.count; +}; + +const appendThreeSelectionElement = ( + elements: Element[], + rootState: ThreeRootState, + object: ThreeObjectLike, + instanceId?: number, +): void => { + try { + const intersection: ThreeIntersectionLike = { object }; + if (instanceId !== undefined) intersection.instanceId = instanceId; + const element = getOrCreateSelectionElement(rootState, object, intersection); + if (element) elements.push(element); + } catch {} +}; + const getOrCreateSelectionElement = ( rootState: ThreeRootState, object: ThreeObjectLike, @@ -419,6 +469,7 @@ const getOrCreateSelectionElement = ( isThreeObjectInScene(selection.object, selection.rootState.scene), }); selectionsByInstance.set(instanceId, selection); + selectionByElement.set(createdElement, selection); return createdElement; }; @@ -430,17 +481,17 @@ export const resolveThreeElementAtPoint = ( if (!isCanvasElement(candidateElement)) return candidateElement; const root = threeRootByCanvas.get(candidateElement); if (!root) return candidateElement; - const rootState = root.getState(); - - const canvasBounds = createElementBounds(candidateElement); - if (canvasBounds.width <= 0 || canvasBounds.height <= 0) return candidateElement; - const pointerX = ((clientX - canvasBounds.x) / canvasBounds.width) * 2 - 1; - const pointerY = -((clientY - canvasBounds.y) / canvasBounds.height) * 2 + 1; - if (pointerX < -1 || pointerX > 1 || pointerY < -1 || pointerY > 1) { - return candidateElement; - } try { + const rootState = root.getState(); + const canvasBounds = createElementBounds(candidateElement); + if (canvasBounds.width <= 0 || canvasBounds.height <= 0) return candidateElement; + const pointerX = ((clientX - canvasBounds.x) / canvasBounds.width) * 2 - 1; + const pointerY = -((clientY - canvasBounds.y) / canvasBounds.height) * 2 + 1; + if (pointerX < -1 || pointerX > 1 || pointerY < -1 || pointerY > 1) { + return candidateElement; + } + rootState.pointer.set(pointerX, pointerY); rootState.raycaster.setFromCamera(rootState.pointer, rootState.camera); const intersections = rootState.raycaster.intersectObjects(rootState.scene.children, true); @@ -461,6 +512,58 @@ export const resolveThreeElementAtPoint = ( return candidateElement; }; +export const getThreeSelectionElements = ( + candidateElement: Element, + endpointElement?: Element, +): Element[] => { + if (!isCanvasElement(candidateElement)) return []; + const root = threeRootByCanvas.get(candidateElement); + if (!root) return []; + try { + const rootState = root.getState(); + const endpointSelection = endpointElement ? selectionByElement.get(endpointElement) : null; + + if (!root.selectableObjects) { + const selectableObjects: ThreeObjectLike[] = []; + const objectQueue = [...rootState.scene.children]; + for (let objectIndex = 0; objectIndex < objectQueue.length; objectIndex += 1) { + const object = objectQueue[objectIndex]; + if (object.children) { + for (const childObject of object.children) objectQueue.push(childObject); + } + if (!isThreeDragSelectableObject(object)) continue; + selectableObjects.push(object); + } + root.selectableObjects = selectableObjects; + } + + const elements: Element[] = []; + for (const object of root.selectableObjects) { + if (!isThreeObjectVisible(object) || !isThreeObjectInScene(object, rootState.scene)) continue; + if (object.isInstancedMesh === true && object.count === 0) continue; + const instanceCount = getThreeObjectInstanceCount(object); + if (instanceCount > 0 && instanceCount <= THREE_DRAG_SELECTION_MAX_INDIVIDUAL_INSTANCES) { + for (let instanceId = 0; instanceId < instanceCount; instanceId += 1) { + appendThreeSelectionElement(elements, rootState, object, instanceId); + } + continue; + } + if ( + instanceCount > THREE_DRAG_SELECTION_MAX_INDIVIDUAL_INSTANCES && + endpointSelection?.object === object && + endpointSelection.instanceId !== null + ) { + elements.push(endpointSelection.element); + continue; + } + appendThreeSelectionElement(elements, rootState, object); + } + return elements; + } catch { + return []; + } +}; + const formatPropValue = (value: unknown): string | null => { if (typeof value === "string") return JSON.stringify(value); if (typeof value === "number" || typeof value === "boolean") return `{${String(value)}}`; @@ -509,7 +612,13 @@ const getObjectMatrix = (selection: ThreeSelection): ThreeMatrixLike => { const getGeometryBoundingBox = (object: ThreeObjectLike): ThreeBoxLike | null => { if (!isThreeGeometry(object.geometry)) return null; - if (!object.geometry.boundingBox) object.geometry.computeBoundingBox(); + if (!object.geometry.boundingBox) { + try { + object.geometry.computeBoundingBox(); + } catch { + return null; + } + } const boundingBox = object.geometry.boundingBox; if (!boundingBox || !isThreeVector(boundingBox.min) || !isThreeVector(boundingBox.max)) { return null; @@ -517,6 +626,28 @@ const getGeometryBoundingBox = (object: ThreeObjectLike): ThreeBoxLike | null => return boundingBox; }; +const getInstancedObjectBoundingBox = (object: ThreeObjectLike): ThreeBoxLike | null => { + if (object.isInstancedMesh !== true || typeof object.computeBoundingBox !== "function") + return null; + if (!object.boundingBox) { + try { + object.computeBoundingBox(); + } catch { + return null; + } + } + const boundingBox = object.boundingBox; + if (!boundingBox || !isThreeVector(boundingBox.min) || !isThreeVector(boundingBox.max)) { + return null; + } + return boundingBox; +}; + +const getSelectionBoundingBox = (selection: ThreeSelection): ThreeBoxLike | null => + selection.instanceId === null + ? (getInstancedObjectBoundingBox(selection.object) ?? getGeometryBoundingBox(selection.object)) + : getGeometryBoundingBox(selection.object); + const createFallbackBounds = ( selection: ThreeSelection, canvasBounds: OverlayBounds, @@ -524,13 +655,20 @@ const createFallbackBounds = ( const fallbackSize = THREE_SELECTION_FALLBACK_BOUNDS_PX; let centerX = canvasBounds.x + canvasBounds.width / 2; let centerY = canvasBounds.y + canvasBounds.height / 2; - if (selection.intersectionPoint) { - const projectedPoint = selection.intersectionPoint.clone().project(selection.rootState.camera); - if (Number.isFinite(projectedPoint.x) && Number.isFinite(projectedPoint.y)) { - centerX = canvasBounds.x + ((projectedPoint.x + 1) / 2) * canvasBounds.width; - centerY = canvasBounds.y + ((1 - projectedPoint.y) / 2) * canvasBounds.height; + try { + const projectedPoint = selection.intersectionPoint + ? selection.intersectionPoint.clone() + : isThreeVector(selection.object.position) + ? selection.object.position.clone().set(0, 0, 0).applyMatrix4(getObjectMatrix(selection)) + : null; + if (projectedPoint) { + projectedPoint.project(selection.rootState.camera); + if (Number.isFinite(projectedPoint.x) && Number.isFinite(projectedPoint.y)) { + centerX = canvasBounds.x + ((projectedPoint.x + 1) / 2) * canvasBounds.width; + centerY = canvasBounds.y + ((1 - projectedPoint.y) / 2) * canvasBounds.height; + } } - } + } catch {} return { x: centerX - fallbackSize / 2, y: centerY - fallbackSize / 2, @@ -542,57 +680,61 @@ const createFallbackBounds = ( const createThreeSelectionBounds = (selection: ThreeSelection): OverlayBounds => { const canvasBounds = createElementBounds(selection.canvas); - const boundingBox = getGeometryBoundingBox(selection.object); - if (!boundingBox) return createFallbackBounds(selection, canvasBounds); - - const matrix = getObjectMatrix(selection); - const xValues = [boundingBox.min.x, boundingBox.max.x]; - const yValues = [boundingBox.min.y, boundingBox.max.y]; - const zValues = [boundingBox.min.z, boundingBox.max.z]; - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - const projectedCorner = boundingBox.min.clone(); - - for (const xValue of xValues) { - for (const yValue of yValues) { - for (const zValue of zValues) { - projectedCorner - .set(xValue, yValue, zValue) - .applyMatrix4(matrix) - .project(selection.rootState.camera); - if (!Number.isFinite(projectedCorner.x) || !Number.isFinite(projectedCorner.y)) continue; - const cornerX = canvasBounds.x + ((projectedCorner.x + 1) / 2) * canvasBounds.width; - const cornerY = canvasBounds.y + ((1 - projectedCorner.y) / 2) * canvasBounds.height; - minX = Math.min(minX, cornerX); - minY = Math.min(minY, cornerY); - maxX = Math.max(maxX, cornerX); - maxY = Math.max(maxY, cornerY); + try { + const boundingBox = getSelectionBoundingBox(selection); + if (!boundingBox) return createFallbackBounds(selection, canvasBounds); + + const matrix = getObjectMatrix(selection); + const xValues = [boundingBox.min.x, boundingBox.max.x]; + const yValues = [boundingBox.min.y, boundingBox.max.y]; + const zValues = [boundingBox.min.z, boundingBox.max.z]; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + const projectedCorner = boundingBox.min.clone(); + + for (const xValue of xValues) { + for (const yValue of yValues) { + for (const zValue of zValues) { + projectedCorner + .set(xValue, yValue, zValue) + .applyMatrix4(matrix) + .project(selection.rootState.camera); + if (!Number.isFinite(projectedCorner.x) || !Number.isFinite(projectedCorner.y)) continue; + const cornerX = canvasBounds.x + ((projectedCorner.x + 1) / 2) * canvasBounds.width; + const cornerY = canvasBounds.y + ((1 - projectedCorner.y) / 2) * canvasBounds.height; + minX = Math.min(minX, cornerX); + minY = Math.min(minY, cornerY); + maxX = Math.max(maxX, cornerX); + maxY = Math.max(maxY, cornerY); + } } } - } - if ( - !Number.isFinite(minX) || - !Number.isFinite(minY) || - !Number.isFinite(maxX) || - !Number.isFinite(maxY) - ) { - return createFallbackBounds(selection, canvasBounds); - } - const clampedMinX = Math.max(canvasBounds.x, minX); - const clampedMinY = Math.max(canvasBounds.y, minY); - const clampedMaxX = Math.min(canvasBounds.x + canvasBounds.width, maxX); - const clampedMaxY = Math.min(canvasBounds.y + canvasBounds.height, maxY); - if (clampedMaxX <= clampedMinX || clampedMaxY <= clampedMinY) { + if ( + !Number.isFinite(minX) || + !Number.isFinite(minY) || + !Number.isFinite(maxX) || + !Number.isFinite(maxY) + ) { + return createFallbackBounds(selection, canvasBounds); + } + const clampedMinX = Math.max(canvasBounds.x, minX); + const clampedMinY = Math.max(canvasBounds.y, minY); + const clampedMaxX = Math.min(canvasBounds.x + canvasBounds.width, maxX); + const clampedMaxY = Math.min(canvasBounds.y + canvasBounds.height, maxY); + if (clampedMaxX <= clampedMinX || clampedMaxY <= clampedMinY) { + return createFallbackBounds(selection, canvasBounds); + } + return { + x: clampedMinX, + y: clampedMinY, + width: clampedMaxX - clampedMinX, + height: clampedMaxY - clampedMinY, + borderRadius: "0px", + }; + } catch { return createFallbackBounds(selection, canvasBounds); } - return { - x: clampedMinX, - y: clampedMinY, - width: clampedMaxX - clampedMinX, - height: clampedMaxY - clampedMinY, - borderRadius: "0px", - }; }; diff --git a/packages/react-grab/src/utils/convert-top-window-position-to-client.ts b/packages/react-grab/src/utils/convert-top-window-position-to-client.ts new file mode 100644 index 000000000..63e9e4325 --- /dev/null +++ b/packages/react-grab/src/utils/convert-top-window-position-to-client.ts @@ -0,0 +1,34 @@ +import type { Position } from "../types.js"; +import { convertParentPositionToIframe } from "./convert-parent-position-to-iframe.js"; +import { getWindowFrameElement } from "./get-window-frame-element.js"; +import { isIframeElement } from "./is-iframe-element.js"; + +export const convertTopWindowPositionToClient = ( + ownerWindow: Window | null, + clientX: number, + clientY: number, +): Position => { + const frameElements: HTMLIFrameElement[] = []; + let currentWindow = ownerWindow; + + while (currentWindow && currentWindow !== window) { + const frameElement = getWindowFrameElement(currentWindow); + if (!frameElement || !isIframeElement(frameElement)) break; + frameElements.push(frameElement); + currentWindow = frameElement.ownerDocument.defaultView; + } + + let convertedX = clientX; + let convertedY = clientY; + for (let frameIndex = frameElements.length - 1; frameIndex >= 0; frameIndex -= 1) { + const convertedPosition = convertParentPositionToIframe( + frameElements[frameIndex], + convertedX, + convertedY, + ); + convertedX = convertedPosition.x; + convertedY = convertedPosition.y; + } + + return { x: convertedX, y: convertedY }; +}; diff --git a/packages/react-grab/src/utils/get-deep-fallback-element-at-point.ts b/packages/react-grab/src/utils/get-deep-fallback-element-at-point.ts index 4014a33ea..2a2d8f9d4 100644 --- a/packages/react-grab/src/utils/get-deep-fallback-element-at-point.ts +++ b/packages/react-grab/src/utils/get-deep-fallback-element-at-point.ts @@ -3,12 +3,15 @@ import { getAccessibleIframeDocument } from "./get-accessible-iframe-document.js import { isIframeElement } from "./is-iframe-element.js"; import { isUserIgnoredElement } from "./is-user-ignored-element.js"; import { isValidGrabbableElement } from "./is-valid-grabbable-element.js"; +import { isElementPaintedAtPosition } from "./is-element-painted-at-position.js"; import { isWithinScope } from "./runtime-mode.js"; const findDeepFallbackElementAtPoint = ( root: Document | ShadowRoot, clientX: number, clientY: number, + topClientX: number, + topClientY: number, ): Element | null => { let canDescendIntoNestedRoots = true; @@ -18,7 +21,13 @@ const findDeepFallbackElementAtPoint = ( const shadowRoot = candidateElement.shadowRoot; if (canDescendIntoNestedRoots && shadowRoot && shadowRoot !== root) { - const shadowTarget = findDeepFallbackElementAtPoint(shadowRoot, clientX, clientY); + const shadowTarget = findDeepFallbackElementAtPoint( + shadowRoot, + clientX, + clientY, + topClientX, + topClientY, + ); if (shadowTarget) return shadowTarget; } @@ -30,12 +39,15 @@ const findDeepFallbackElementAtPoint = ( iframeDocument, iframePosition.x, iframePosition.y, + topClientX, + topClientY, ); if (iframeTarget) return iframeTarget; } } if (!isValidGrabbableElement(candidateElement)) continue; + if (!isElementPaintedAtPosition(candidateElement, topClientX, topClientY)) continue; return candidateElement; } @@ -43,4 +55,4 @@ const findDeepFallbackElementAtPoint = ( }; export const getDeepFallbackElementAtPoint = (clientX: number, clientY: number): Element | null => - findDeepFallbackElementAtPoint(document, clientX, clientY); + findDeepFallbackElementAtPoint(document, clientX, clientY, clientX, clientY); diff --git a/packages/react-grab/src/utils/get-element-at-position.ts b/packages/react-grab/src/utils/get-element-at-position.ts index a63021726..e013031c2 100644 --- a/packages/react-grab/src/utils/get-element-at-position.ts +++ b/packages/react-grab/src/utils/get-element-at-position.ts @@ -9,9 +9,13 @@ import { getAccessibleIframeDocument } from "./get-accessible-iframe-document.js import { getDeepElementAtPoint } from "./get-deep-element-at-point.js"; import { getDeepFallbackElementAtPoint } from "./get-deep-fallback-element-at-point.js"; import { getDeepElementsAtPoint } from "./get-deep-elements-at-point.js"; +import { getComposedParentElement } from "./get-composed-parent-element.js"; +import { getElementTextBounds } from "./get-element-text-bounds.js"; +import { getLocalContentElementAtPoint } from "./get-local-content-element-at-point.js"; import { getScopeContainer, isWithinScope } from "./runtime-mode.js"; import { isIframeElement } from "./is-iframe-element.js"; import { isPointInsideRect } from "./is-point-inside-rect.js"; +import { isElementPaintedAtPosition } from "./is-element-painted-at-position.js"; import { isValidGrabbableElement } from "./is-valid-grabbable-element.js"; import { resumePointerEventsFreeze, suspendPointerEventsFreeze } from "./pointer-events-freeze.js"; import { resolveThreeElementAtPoint } from "../core/three-selection.js"; @@ -20,6 +24,9 @@ interface PositionCache { clientX: number; clientY: number; element: Element | null; + fallbackElement: Element | null; + preciseHitElement: Element | null; + usesTextHitTesting: boolean; timestamp: number; } @@ -59,6 +66,19 @@ const isWithinThreshold = (x1: number, y1: number, x2: number, y2: number): bool ); }; +const resolveValidElementAtPoint = ( + element: Element, + clientX: number, + clientY: number, +): Element | null => { + const resolvedElement = resolveThreeElementAtPoint(element, clientX, clientY); + return isValidGrabbableElement(resolvedElement) && + isWithinScope(element) && + isElementPaintedAtPosition(element, clientX, clientY) + ? resolvedElement + : null; +}; + export const getElementsAtPoint = (clientX: number, clientY: number): Element[] => { if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return []; cancelScheduledPointerEventsResume(); @@ -67,8 +87,50 @@ export const getElementsAtPoint = (clientX: number, clientY: number): Element[] const elements = getDeepElementsAtPoint(clientX, clientY); const scopedElements = getScopeContainer() ? elements.filter(isWithinScope) : elements; const resolvedElements: Element[] = []; + const includedElements = new Set(); + let didResolveLocalContent = false; for (const element of scopedElements) { - resolvedElements.push(resolveThreeElementAtPoint(element, clientX, clientY)); + let preciseElement = element; + let isPreciseElementPainted = isElementPaintedAtPosition(element, clientX, clientY); + if (!didResolveLocalContent) { + const localContentElement = getLocalContentElementAtPoint(element, clientX, clientY); + if ( + localContentElement && + isWithinScope(localContentElement) && + isValidGrabbableElement(localContentElement) && + isElementPaintedAtPosition(localContentElement, clientX, clientY) + ) { + preciseElement = localContentElement; + isPreciseElementPainted = true; + didResolveLocalContent = true; + } else if (isValidGrabbableElement(element) && isPreciseElementPainted) { + didResolveLocalContent = true; + } + } + const resolvedPreciseElement = resolveThreeElementAtPoint(preciseElement, clientX, clientY); + if (isPreciseElementPainted && !includedElements.has(resolvedPreciseElement)) { + includedElements.add(resolvedPreciseElement); + resolvedElements.push(resolvedPreciseElement); + } + if (preciseElement === element) continue; + + let ancestorElement = getComposedParentElement(preciseElement); + while (ancestorElement && isWithinScope(ancestorElement)) { + const resolvedAncestorElement = resolveThreeElementAtPoint( + ancestorElement, + clientX, + clientY, + ); + if ( + isElementPaintedAtPosition(ancestorElement, clientX, clientY) && + !includedElements.has(resolvedAncestorElement) + ) { + includedElements.add(resolvedAncestorElement); + resolvedElements.push(resolvedAncestorElement); + } + ancestorElement = getComposedParentElement(ancestorElement); + } + break; } return resolvedElements; } finally { @@ -109,7 +171,28 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element ); const isWithinThrottle = now - positionCache.timestamp < ELEMENT_POSITION_THROTTLE_MS; - if (isPositionClose || isWithinThrottle) return positionCache.element; + if (isPositionClose && isWithinThrottle) { + if (!positionCache.preciseHitElement) return positionCache.element; + + const localContentElement = getLocalContentElementAtPoint( + positionCache.preciseHitElement, + clientX, + clientY, + ); + if (localContentElement) { + const localContentResult = resolveValidElementAtPoint( + localContentElement, + clientX, + clientY, + ); + if (localContentResult) return localContentResult; + } + if (!positionCache.usesTextHitTesting) return positionCache.fallbackElement; + return ( + resolveValidElementAtPoint(positionCache.preciseHitElement, clientX, clientY) ?? + getDeepFallbackElementAtPoint(clientX, clientY) + ); + } } // PERF: suspendPointerEventsFreeze toggles the html { pointer-events: none } @@ -135,19 +218,16 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element // overlapping the scoped container) we fall back to elementsFromPoint, which // returns the full z-ordered stack, and take the first grabbable in-scope one. const topElement = getDeepElementAtPoint(clientX, clientY); - const resolvedElement = topElement - ? resolveThreeElementAtPoint(topElement, clientX, clientY) + const usesTextHitTesting = topElement ? getElementTextBounds(topElement) !== null : false; + const localContentElement = topElement + ? getLocalContentElementAtPoint(topElement, clientX, clientY) : null; - if ( - topElement && - resolvedElement && - isValidGrabbableElement(resolvedElement) && - isWithinScope(topElement) - ) { - result = resolvedElement; - } else { - result = getDeepFallbackElementAtPoint(clientX, clientY); - } + const localContentResult = localContentElement + ? resolveValidElementAtPoint(localContentElement, clientX, clientY) + : null; + const topResult = topElement ? resolveValidElementAtPoint(topElement, clientX, clientY) : null; + const fallbackResult = topResult ?? getDeepFallbackElementAtPoint(clientX, clientY); + result = localContentResult ?? fallbackResult; if (result && isIframeElement(result) && !getAccessibleIframeDocument(result)) { const iframeBounds = createElementBounds(result); @@ -164,7 +244,20 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element } else { inaccessibleIframePositionCache = null; } - positionCache = { clientX, clientY, element: result, timestamp: now }; + positionCache = { + clientX, + clientY, + element: result, + fallbackElement: fallbackResult, + preciseHitElement: + topElement?.namespaceURI === "http://www.w3.org/2000/svg" || + localContentElement || + usesTextHitTesting + ? topElement + : null, + usesTextHitTesting, + timestamp: now, + }; return result; } finally { schedulePointerEventsResume(); diff --git a/packages/react-grab/src/utils/get-element-text-bounds.ts b/packages/react-grab/src/utils/get-element-text-bounds.ts new file mode 100644 index 000000000..c5081cf06 --- /dev/null +++ b/packages/react-grab/src/utils/get-element-text-bounds.ts @@ -0,0 +1,205 @@ +import type { ElementBounds } from "../types.js"; +import { + BOUNDS_CACHE_TTL_MS, + DRAG_SELECTION_MAX_TEXT_FLOW_NODES, + DRAG_SELECTION_MAX_TEXT_NODES, + DRAG_SELECTION_MAX_TEXT_RECTS, +} from "../constants.js"; +import { convertClientPositionToTopWindow } from "./convert-client-position-to-top-window.js"; +import { hasElementBoxPaint } from "./has-element-box-paint.js"; +import { isElementNode } from "./is-element-node.js"; +import { isHtmlElement } from "./is-html-element.js"; + +const BOX_SELECTION_ROOT_TAG_NAMES = new Set([ + "A", + "AUDIO", + "BUTTON", + "CANVAS", + "DETAILS", + "EMBED", + "IFRAME", + "IMG", + "INPUT", + "METER", + "OBJECT", + "OPTION", + "PROGRESS", + "SELECT", + "SUMMARY", + "SVG", + "TEXTAREA", + "VIDEO", +]); + +const BOX_SELECTION_ROLES = new Set([ + "button", + "checkbox", + "combobox", + "gridcell", + "link", + "listbox", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "option", + "radio", + "scrollbar", + "searchbox", + "slider", + "spinbutton", + "switch", + "tab", + "textbox", + "treeitem", +]); + +const INLINE_TEXT_TAG_NAMES = new Set([ + "A", + "ABBR", + "B", + "BDI", + "BDO", + "BR", + "CITE", + "CODE", + "DATA", + "DEL", + "DFN", + "EM", + "I", + "INS", + "KBD", + "MARK", + "Q", + "S", + "SAMP", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TIME", + "U", + "VAR", + "WBR", +]); + +let textBoundsCache = new WeakMap(); +let textBoundsTimestampCache = new WeakMap(); + +export const invalidateElementTextBoundsCache = (): void => { + textBoundsCache = new WeakMap(); + textBoundsTimestampCache = new WeakMap(); +}; + +const cacheTextBounds = ( + element: Element, + bounds: ElementBounds[] | null, + timestamp: number, +): ElementBounds[] | null => { + textBoundsCache.set(element, bounds); + textBoundsTimestampCache.set(element, timestamp); + return bounds; +}; + +const usesBoxSelection = (element: Element): boolean => + BOX_SELECTION_ROOT_TAG_NAMES.has(element.tagName) || + BOX_SELECTION_ROLES.has(element.getAttribute("role") ?? "") || + (isHtmlElement(element) && element.isContentEditable); + +export const getElementTextBounds = (element: Element): ElementBounds[] | null => { + const now = performance.now(); + const cachedTimestamp = textBoundsTimestampCache.get(element); + if (cachedTimestamp !== undefined && now - cachedTimestamp < BOUNDS_CACHE_TTL_MS) { + return textBoundsCache.get(element) ?? null; + } + + if (usesBoxSelection(element)) return cacheTextBounds(element, null, now); + + if (element.childNodes.length > DRAG_SELECTION_MAX_TEXT_FLOW_NODES) { + return cacheTextBounds(element, null, now); + } + + const textNodes: Node[] = []; + const pendingNodes: Node[] = []; + let inspectedNodeCount = 0; + for (let childIndex = element.childNodes.length - 1; childIndex >= 0; childIndex -= 1) { + pendingNodes.push(element.childNodes[childIndex]); + } + + while (pendingNodes.length > 0) { + inspectedNodeCount += 1; + if (inspectedNodeCount > DRAG_SELECTION_MAX_TEXT_FLOW_NODES) { + return cacheTextBounds(element, null, now); + } + + const currentNode = pendingNodes.pop(); + if (!currentNode) continue; + if (currentNode.nodeType === Node.TEXT_NODE) { + if (currentNode.textContent?.trim()) { + textNodes.push(currentNode); + if (textNodes.length > DRAG_SELECTION_MAX_TEXT_NODES) { + return cacheTextBounds(element, null, now); + } + } + continue; + } + if (!isElementNode(currentNode) || !INLINE_TEXT_TAG_NAMES.has(currentNode.tagName)) { + return cacheTextBounds(element, null, now); + } + if ( + inspectedNodeCount + pendingNodes.length + currentNode.childNodes.length > + DRAG_SELECTION_MAX_TEXT_FLOW_NODES + ) { + return cacheTextBounds(element, null, now); + } + for (let childIndex = currentNode.childNodes.length - 1; childIndex >= 0; childIndex -= 1) { + pendingNodes.push(currentNode.childNodes[childIndex]); + } + } + + if (textNodes.length === 0) return cacheTextBounds(element, null, now); + if (hasElementBoxPaint(element)) return cacheTextBounds(element, null, now); + + try { + const range = element.ownerDocument.createRange(); + const topWindowOrigin = convertClientPositionToTopWindow( + element.ownerDocument.defaultView, + 0, + 0, + ); + const textBounds: ElementBounds[] = []; + for (const textNode of textNodes) { + range.selectNodeContents(textNode); + const clientRects = range.getClientRects(); + if (textBounds.length + clientRects.length > DRAG_SELECTION_MAX_TEXT_RECTS) { + return cacheTextBounds(element, null, now); + } + for (let rectIndex = 0; rectIndex < clientRects.length; rectIndex += 1) { + const clientRect = clientRects[rectIndex]; + const width = clientRect.width * topWindowOrigin.scaleX; + const height = clientRect.height * topWindowOrigin.scaleY; + if ( + !Number.isFinite(clientRect.left) || + !Number.isFinite(clientRect.top) || + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + continue; + } + textBounds.push({ + borderRadius: "0px", + height, + width, + x: topWindowOrigin.x + clientRect.left * topWindowOrigin.scaleX, + y: topWindowOrigin.y + clientRect.top * topWindowOrigin.scaleY, + }); + } + } + return cacheTextBounds(element, textBounds.length > 0 ? textBounds : null, now); + } catch { + return cacheTextBounds(element, null, now); + } +}; 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 e2c3ee0bd..a6ad2ca55 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 } from "../types.js"; +import type { DragRect, ElementBounds, Position } from "../types.js"; import { suspendPointerEventsFreeze, resumePointerEventsFreeze } from "./pointer-events-freeze.js"; import { DRAG_SELECTION_COVERAGE_THRESHOLD, @@ -7,32 +7,144 @@ import { DRAG_SELECTION_MAX_SAMPLES_PER_AXIS, DRAG_SELECTION_MAX_TOTAL_SAMPLE_POINTS, DRAG_SELECTION_EDGE_INSET_PX, + DRAG_SELECTION_SAMPLE_COORDINATE_VALUES, + DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS, + DRAG_SELECTION_MAX_LOCAL_COLLECTION_ELEMENTS, + MIN_HIT_TEST_VIEWPORT_DIMENSION_PX, VIEWPORT_COVERAGE_THRESHOLD, } from "../constants.js"; import { isRootElement } from "./is-root-element.js"; import { isWithinScope } from "./runtime-mode.js"; import { clampToRange } from "./clamp-to-range.js"; import { getDeepElementsAtPoint } from "./get-deep-elements-at-point.js"; +import { getLocalContentElementAtPoint } from "./get-local-content-element-at-point.js"; import { createElementBounds } from "./create-element-bounds.js"; +import { getElementTextBounds } from "./get-element-text-bounds.js"; import { getComposedParentElement } from "./get-composed-parent-element.js"; import { compareElementDocumentOrder } from "./compare-element-document-order.js"; import { getAccessibleIframeDocument } from "./get-accessible-iframe-document.js"; import { isIframeElement } from "./is-iframe-element.js"; import { isShadowRoot } from "./is-shadow-root.js"; +import { getThreeSelectionElements, resolveThreeElementAtPoint } from "../core/three-selection.js"; const sortByDocumentOrder = (elements: Element[]): Element[] => elements.sort(compareElementDocumentOrder); -interface SamplePoint { - x: number; - y: number; -} +const hasValidBounds = (bounds: ElementBounds): boolean => + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) && + bounds.width > 0 && + bounds.height > 0; -const createSamplePoints = (dragRect: DragRect): SamplePoint[] => { +const boundsIntersectDrag = (bounds: ElementBounds, dragRect: DragRect): boolean => + bounds.x < dragRect.x + dragRect.width && + bounds.x + bounds.width > dragRect.x && + bounds.y < dragRect.y + dragRect.height && + bounds.y + bounds.height > dragRect.y; + +const addIntersectingNeighbors = ( + candidates: Set, + dragRect: DragRect, + candidateBoundsByElement: Map, + excludedElements: Set, +): void => { + const candidateQueue = [...candidates].filter( + (candidateElement) => !excludedElements.has(candidateElement), + ); + const tableRowQueue = candidateQueue.filter( + (candidateElement) => candidateElement.tagName === "TR", + ); + let inspectedNeighborCount = 0; + + for ( + let candidateIndex = 0; + candidateIndex < candidateQueue.length && + inspectedNeighborCount < DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS; + candidateIndex += 1 + ) { + const parentElement = getComposedParentElement(candidateQueue[candidateIndex]); + if (!parentElement || parentElement.tagName !== "TR" || candidates.has(parentElement)) continue; + inspectedNeighborCount += 1; + candidates.add(parentElement); + candidateQueue.push(parentElement); + tableRowQueue.push(parentElement); + } + + const addCandidate = (candidateElement: Element | null): void => { + if ( + !candidateElement || + candidates.has(candidateElement) || + excludedElements.has(candidateElement) || + inspectedNeighborCount >= DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS + ) { + return; + } + inspectedNeighborCount += 1; + + let candidateBounds = candidateBoundsByElement.get(candidateElement); + if (!candidateBounds) { + candidateBounds = createElementBounds(candidateElement); + candidateBoundsByElement.set(candidateElement, candidateBounds); + } + if (!hasValidBounds(candidateBounds) || !boundsIntersectDrag(candidateBounds, dragRect)) return; + + candidates.add(candidateElement); + candidateQueue.push(candidateElement); + if (candidateElement.tagName === "TR") tableRowQueue.push(candidateElement); + }; + + const addChildren = (childCollection: HTMLCollection): void => { + if (childCollection.length > DRAG_SELECTION_MAX_LOCAL_COLLECTION_ELEMENTS) return; + for (const childElement of childCollection) { + if (inspectedNeighborCount >= DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS) return; + addCandidate(childElement); + } + }; + + for ( + let tableRowIndex = 0; + tableRowIndex < tableRowQueue.length && + inspectedNeighborCount < DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS; + tableRowIndex += 1 + ) { + const tableRowElement = tableRowQueue[tableRowIndex]; + addCandidate(tableRowElement.previousElementSibling); + addCandidate(tableRowElement.nextElementSibling); + } + + for ( + let candidateIndex = 0; + candidateIndex < candidateQueue.length && + inspectedNeighborCount < DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS; + candidateIndex += 1 + ) { + const candidateElement = candidateQueue[candidateIndex]; + if (isRootElement(candidateElement)) continue; + + const siblingCount = candidateElement.parentElement?.children.length ?? 0; + if ( + candidateElement.tagName === "TR" || + siblingCount <= DRAG_SELECTION_MAX_LOCAL_COLLECTION_ELEMENTS + ) { + addCandidate(candidateElement.previousElementSibling); + addCandidate(candidateElement.nextElementSibling); + } + + addChildren(candidateElement.children); + if (candidateElement.shadowRoot) addChildren(candidateElement.shadowRoot.children); + } +}; + +const createSampleCoordinates = (dragRect: DragRect, intentPoint: Position): number[] => { if (dragRect.width <= 0 || dragRect.height <= 0) return []; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; + const viewportWidth = Math.max(MIN_HIT_TEST_VIEWPORT_DIMENSION_PX, Math.round(window.innerWidth)); + const viewportHeight = Math.max( + MIN_HIT_TEST_VIEWPORT_DIMENSION_PX, + Math.round(window.innerHeight), + ); const left = dragRect.x; const top = dragRect.y; @@ -68,18 +180,19 @@ const createSamplePoints = (dragRect: DragRect): SamplePoint[] => { DRAG_SELECTION_MAX_SAMPLES_PER_AXIS, ); - const pointKeys = new Set(); - const points: SamplePoint[] = []; + const pointKeys = new Set(); + const sampleCoordinates: number[] = []; const addPoint = (x: number, y: number) => { const clampedX = clampToRange(Math.round(x), 0, viewportWidth - 1); const clampedY = clampToRange(Math.round(y), 0, viewportHeight - 1); - const key = `${clampedX}:${clampedY}`; + const key = clampedY * viewportWidth + clampedX; if (pointKeys.has(key)) return; pointKeys.add(key); - points.push({ x: clampedX, y: clampedY }); + sampleCoordinates.push(clampedX, clampedY); }; + addPoint(intentPoint.x, intentPoint.y); addPoint(left + DRAG_SELECTION_EDGE_INSET_PX, top + DRAG_SELECTION_EDGE_INSET_PX); addPoint(right - DRAG_SELECTION_EDGE_INSET_PX, top + DRAG_SELECTION_EDGE_INSET_PX); addPoint(left + DRAG_SELECTION_EDGE_INSET_PX, bottom - DRAG_SELECTION_EDGE_INSET_PX); @@ -98,11 +211,12 @@ const createSamplePoints = (dragRect: DragRect): SamplePoint[] => { } } - return points; + return sampleCoordinates; }; const filterElementsInDrag = ( dragRect: DragRect, + intentPoint: Position, isValidGrabbableElement: (element: Element) => boolean, ): Element[] => { const dragLeft = dragRect.x; @@ -111,26 +225,103 @@ const filterElementsInDrag = ( const dragBottom = dragRect.y + dragRect.height; const candidates = new Set(); - const samplePoints = createSamplePoints(dragRect); + const candidateBoundsByElement = new Map(); + const candidateValidityByElement = new Map(); + const inspectedThreeCanvasElements = new Set(); + const resolvedThreeCanvasElements = new Set(); + const coveredCandidates = new Set(); + const sampleCoordinates = createSampleCoordinates(dragRect, intentPoint); + const isCandidateValid = (candidateElement: Element): boolean => { + const cachedValidity = candidateValidityByElement.get(candidateElement); + if (cachedValidity !== undefined) return cachedValidity; + const isValid = isValidGrabbableElement(candidateElement); + candidateValidityByElement.set(candidateElement, isValid); + return isValid; + }; suspendPointerEventsFreeze(); try { - for (const point of samplePoints) { - const elementsAtPoint = getDeepElementsAtPoint(point.x, point.y); + for ( + let coordinateIndex = 0; + coordinateIndex < sampleCoordinates.length; + coordinateIndex += DRAG_SELECTION_SAMPLE_COORDINATE_VALUES + ) { + const elementsAtPoint = getDeepElementsAtPoint( + sampleCoordinates[coordinateIndex], + sampleCoordinates[coordinateIndex + 1], + ); for (const candidateElement of elementsAtPoint) { + if (candidateElement.tagName === "CANVAS") { + if (resolvedThreeCanvasElements.has(candidateElement)) continue; + if (inspectedThreeCanvasElements.has(candidateElement)) { + candidates.add(candidateElement); + continue; + } + inspectedThreeCanvasElements.add(candidateElement); + let endpointThreeElement = candidateElement; + if (coordinateIndex === 0) { + try { + endpointThreeElement = resolveThreeElementAtPoint( + candidateElement, + sampleCoordinates[coordinateIndex], + sampleCoordinates[coordinateIndex + 1], + ); + } catch {} + } + const threeElements = getThreeSelectionElements(candidateElement, endpointThreeElement); + if (threeElements.length > 0 || endpointThreeElement !== candidateElement) { + resolvedThreeCanvasElements.add(candidateElement); + for (const threeElement of threeElements) { + candidates.add(threeElement); + } + if (endpointThreeElement !== candidateElement) { + candidates.add(endpointThreeElement); + } + continue; + } + } candidates.add(candidateElement); } + + let didFindFrontmostCandidate = false; + for (const hitElement of elementsAtPoint) { + let candidateElement = hitElement; + if (coordinateIndex === 0 && !didFindFrontmostCandidate) { + const localContentElement = getLocalContentElementAtPoint( + hitElement, + sampleCoordinates[coordinateIndex], + sampleCoordinates[coordinateIndex + 1], + ); + if (localContentElement && isCandidateValid(localContentElement)) { + candidates.add(localContentElement); + if (localContentElement !== hitElement) coveredCandidates.add(hitElement); + candidateElement = localContentElement; + } + } + if (!isCandidateValid(candidateElement)) continue; + if (!didFindFrontmostCandidate) { + didFindFrontmostCandidate = true; + continue; + } + coveredCandidates.add(candidateElement); + } } } finally { resumePointerEventsFreeze(); } + for (const canvasElement of resolvedThreeCanvasElements) candidates.delete(canvasElement); + addIntersectingNeighbors( + candidates, + dragRect, + candidateBoundsByElement, + resolvedThreeCanvasElements, + ); + 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; @@ -142,19 +333,11 @@ const filterElementsInDrag = ( } if (isRootElement(candidateElement)) continue; if (!isWithinScope(candidateElement)) continue; - if (!isValidGrabbableElement(candidateElement)) continue; + if (!isCandidateValid(candidateElement)) continue; - const candidateBounds = createElementBounds(candidateElement); - 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 candidateBounds = + candidateBoundsByElement.get(candidateElement) ?? createElementBounds(candidateElement); + if (!hasValidBounds(candidateBounds)) continue; const candidateLeft = candidateBounds.x; const candidateTop = candidateBounds.y; @@ -168,37 +351,85 @@ const filterElementsInDrag = ( Math.min(viewportHeight, candidateBottom) - Math.max(0, candidateTop) >= viewportCoverHeight; if (coversViewport) continue; - const intersectionWidth = Math.max( + const candidateIntersectionWidth = Math.max( 0, Math.min(dragRight, candidateRight) - Math.max(dragLeft, candidateLeft), ); - const intersectionHeight = Math.max( + const candidateIntersectionHeight = Math.max( 0, Math.min(dragBottom, candidateBottom) - Math.max(dragTop, candidateTop), ); - const intersectionArea = intersectionWidth * intersectionHeight; + const candidateArea = candidateBounds.width * candidateBounds.height; + if (candidateIntersectionWidth <= 0 || candidateIntersectionHeight <= 0 || candidateArea <= 0) { + continue; + } + + const textBounds = getElementTextBounds(candidateElement); + let intersectionArea = 0; + let textArea = 0; + let intentDistanceSquared = Number.POSITIVE_INFINITY; + if (textBounds) { + for (const textFragmentBounds of textBounds) { + const fragmentLeft = textFragmentBounds.x; + const fragmentTop = textFragmentBounds.y; + const fragmentRight = fragmentLeft + textFragmentBounds.width; + const fragmentBottom = fragmentTop + textFragmentBounds.height; + const intersectionWidth = Math.max( + 0, + Math.min(dragRight, fragmentRight) - Math.max(dragLeft, fragmentLeft), + ); + const intersectionHeight = Math.max( + 0, + Math.min(dragBottom, fragmentBottom) - Math.max(dragTop, fragmentTop), + ); + intersectionArea += intersectionWidth * intersectionHeight; + textArea += textFragmentBounds.width * textFragmentBounds.height; + + const intentDistanceX = Math.max( + fragmentLeft - intentPoint.x, + 0, + intentPoint.x - fragmentRight, + ); + const intentDistanceY = Math.max( + fragmentTop - intentPoint.y, + 0, + intentPoint.y - fragmentBottom, + ); + const fragmentIntentDistanceSquared = + intentDistanceX * intentDistanceX + intentDistanceY * intentDistanceY; + intentDistanceSquared = Math.min(intentDistanceSquared, fragmentIntentDistanceSquared); + } + } else { + intersectionArea = candidateIntersectionWidth * candidateIntersectionHeight; + + const intentDistanceX = Math.max( + candidateLeft - intentPoint.x, + 0, + intentPoint.x - candidateRight, + ); + const intentDistanceY = Math.max( + candidateTop - intentPoint.y, + 0, + intentPoint.y - candidateBottom, + ); + intentDistanceSquared = intentDistanceX * intentDistanceX + intentDistanceY * intentDistanceY; + } if (intersectionArea <= 0) continue; - const candidateArea = candidateBounds.width * candidateBounds.height; - if (intersectionArea / candidateArea >= DRAG_SELECTION_COVERAGE_THRESHOLD) { + const coverageArea = + textBounds && !coveredCandidates.has(candidateElement) ? textArea : candidateArea; + if (intersectionArea / coverageArea >= 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 isNearerFallback = intentDistanceSquared < nearestFallbackDistanceSquared; const isSmallerEquidistantFallback = - centerDistanceSquared === nearestFallbackDistanceSquared && + intentDistanceSquared === nearestFallbackDistanceSquared && candidateArea < nearestFallbackArea; if (isNearerFallback || isSmallerEquidistantFallback) { nearestFallbackElement = candidateElement; - nearestFallbackDistanceSquared = centerDistanceSquared; + nearestFallbackDistanceSquared = intentDistanceSquared; nearestFallbackArea = candidateArea; } } @@ -248,8 +479,9 @@ const removeNestedElements = (elements: Element[]): Element[] => { export const getElementsInDrag = ( dragRect: DragRect, + intentPoint: Position, isValidGrabbableElement: (element: Element) => boolean, ): Element[] => { - const elements = filterElementsInDrag(dragRect, isValidGrabbableElement); + const elements = filterElementsInDrag(dragRect, intentPoint, isValidGrabbableElement); return removeNestedElements(elements); }; diff --git a/packages/react-grab/src/utils/get-local-content-element-at-point.ts b/packages/react-grab/src/utils/get-local-content-element-at-point.ts new file mode 100644 index 000000000..3f3ed9ac6 --- /dev/null +++ b/packages/react-grab/src/utils/get-local-content-element-at-point.ts @@ -0,0 +1,67 @@ +import { convertTopWindowPositionToClient } from "./convert-top-window-position-to-client.js"; +import { getElementComputedStyle } from "./get-element-computed-style.js"; +import { isElementNode } from "./is-element-node.js"; +import { isRootElement } from "./is-root-element.js"; +import { isShadowRoot } from "./is-shadow-root.js"; + +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + +const getNearestSvgRoot = (element: Element): Element => { + if (element.localName === "svg") return element; + let svgRoot = element; + let parentElement = element.parentElement; + while (parentElement?.namespaceURI === SVG_NAMESPACE) { + svgRoot = parentElement; + if (parentElement.localName === "svg") break; + parentElement = parentElement.parentElement; + } + return svgRoot; +}; + +const getCaretNode = ( + targetDocument: Document, + clientX: number, + clientY: number, + shadowRoot: ShadowRoot | null, +): Node | null => { + if (typeof targetDocument.caretPositionFromPoint === "function") { + const caretPosition = targetDocument.caretPositionFromPoint(clientX, clientY, { + shadowRoots: shadowRoot ? [shadowRoot] : [], + }); + if (caretPosition) return caretPosition.offsetNode; + } + return typeof targetDocument.caretRangeFromPoint === "function" + ? (targetDocument.caretRangeFromPoint(clientX, clientY)?.startContainer ?? null) + : null; +}; + +export const getLocalContentElementAtPoint = ( + hitElement: Element, + clientX: number, + clientY: number, +): Element | null => { + if (isRootElement(hitElement)) return null; + + const targetDocument = hitElement.ownerDocument; + const ownerWindow = targetDocument.defaultView; + if (!ownerWindow) return null; + + const localPosition = convertTopWindowPositionToClient(ownerWindow, clientX, clientY); + const hitRoot = hitElement.getRootNode(); + const caretNode = getCaretNode( + targetDocument, + localPosition.x, + localPosition.y, + isShadowRoot(hitRoot) ? hitRoot : null, + ); + if (!caretNode) return null; + + const contentElement = isElementNode(caretNode) ? caretNode : caretNode.parentElement; + if (!contentElement || contentElement === hitElement) return null; + + const isSvgHit = hitElement.namespaceURI === SVG_NAMESPACE; + const localRoot = isSvgHit ? getNearestSvgRoot(hitElement) : hitElement; + if (!localRoot.contains(contentElement)) return null; + if (!isSvgHit && getElementComputedStyle(contentElement).pointerEvents !== "none") return null; + return contentElement; +}; diff --git a/packages/react-grab/src/utils/has-element-box-paint.ts b/packages/react-grab/src/utils/has-element-box-paint.ts new file mode 100644 index 000000000..02f86d9a4 --- /dev/null +++ b/packages/react-grab/src/utils/has-element-box-paint.ts @@ -0,0 +1,15 @@ +import { isCssColorTransparent } from "./is-css-color-transparent.js"; + +export const hasElementBoxPaint = (element: Element): boolean => { + const style = element.ownerDocument.defaultView?.getComputedStyle?.(element); + if (!style) return false; + const hasBackground = + style.backgroundClip !== "text" && + (style.backgroundImage !== "none" || !isCssColorTransparent(style.backgroundColor)); + const hasBorder = + (style.borderTopStyle !== "none" && style.borderTopWidth !== "0px") || + (style.borderRightStyle !== "none" && style.borderRightWidth !== "0px") || + (style.borderBottomStyle !== "none" && style.borderBottomWidth !== "0px") || + (style.borderLeftStyle !== "none" && style.borderLeftWidth !== "0px"); + return hasBackground || hasBorder || style.boxShadow !== "none" || style.outlineStyle !== "none"; +}; diff --git a/packages/react-grab/src/utils/invalidate-interaction-caches.ts b/packages/react-grab/src/utils/invalidate-interaction-caches.ts index 109acbb0c..fd898cdc9 100644 --- a/packages/react-grab/src/utils/invalidate-interaction-caches.ts +++ b/packages/react-grab/src/utils/invalidate-interaction-caches.ts @@ -1,4 +1,5 @@ import { invalidateBoundsCache } from "./create-element-bounds.js"; +import { invalidateElementTextBoundsCache } from "./get-element-text-bounds.js"; import { clearElementPositionCache } from "./get-element-at-position.js"; // The visibility cache is intentionally NOT cleared here: this runs on every @@ -10,5 +11,6 @@ import { clearElementPositionCache } from "./get-element-at-position.js"; // mutations already have when they fire outside a scroll event. export const invalidateInteractionCaches = (): void => { invalidateBoundsCache(); + invalidateElementTextBoundsCache(); clearElementPositionCache(); }; diff --git a/packages/react-grab/src/utils/is-css-color-transparent.ts b/packages/react-grab/src/utils/is-css-color-transparent.ts new file mode 100644 index 000000000..b2fbf184d --- /dev/null +++ b/packages/react-grab/src/utils/is-css-color-transparent.ts @@ -0,0 +1,15 @@ +export const isCssColorTransparent = (color: string): boolean => { + if (color === "transparent") return true; + + const alphaSeparatorIndex = color.lastIndexOf("/"); + if (alphaSeparatorIndex >= 0) { + return Number.parseFloat(color.slice(alphaSeparatorIndex + 1)) === 0; + } + + if (!color.startsWith("rgba(")) return false; + const legacyAlphaSeparatorIndex = color.lastIndexOf(","); + return ( + legacyAlphaSeparatorIndex >= 0 && + Number.parseFloat(color.slice(legacyAlphaSeparatorIndex + 1)) === 0 + ); +}; diff --git a/packages/react-grab/src/utils/is-element-painted-at-position.ts b/packages/react-grab/src/utils/is-element-painted-at-position.ts new file mode 100644 index 000000000..1d5b2fec0 --- /dev/null +++ b/packages/react-grab/src/utils/is-element-painted-at-position.ts @@ -0,0 +1,22 @@ +import { getElementTextBounds } from "./get-element-text-bounds.js"; + +export const isElementPaintedAtPosition = ( + element: Element, + clientX: number, + clientY: number, +): boolean => { + const textBounds = getElementTextBounds(element); + if (!textBounds) return true; + + for (const textFragmentBounds of textBounds) { + if ( + clientX >= textFragmentBounds.x && + clientX <= textFragmentBounds.x + textFragmentBounds.width && + clientY >= textFragmentBounds.y && + clientY <= textFragmentBounds.y + textFragmentBounds.height + ) { + return true; + } + } + return false; +}; diff --git a/packages/react-grab/src/utils/runtime-mode.ts b/packages/react-grab/src/utils/runtime-mode.ts index 68b4f6867..3c0db4138 100644 --- a/packages/react-grab/src/utils/runtime-mode.ts +++ b/packages/react-grab/src/utils/runtime-mode.ts @@ -1,4 +1,4 @@ -import { getComposedParentElement } from "./get-composed-parent-element.js"; +import { isElementWithinContainer } from "./is-element-within-container.js"; // Scope and mode for the active React Grab instance, held as a singleton so // utilities outside the init closure (hit-testing, viewport math) can read them @@ -28,12 +28,7 @@ export const getScopeContainer = (): HTMLElement | null => scopeContainer; export const isWithinScope = (element: Element | null): boolean => { if (!scopeContainer) return true; - let currentElement = element; - while (currentElement) { - if (currentElement === scopeContainer) return true; - currentElement = getComposedParentElement(currentElement); - } - return false; + return element ? isElementWithinContainer(element, scopeContainer) : false; }; // A build-time constant, not a function: the bundler replaces diff --git a/packages/react-grab/tests/convert-top-window-position-to-client.test.ts b/packages/react-grab/tests/convert-top-window-position-to-client.test.ts new file mode 100644 index 000000000..0bb3f9492 --- /dev/null +++ b/packages/react-grab/tests/convert-top-window-position-to-client.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { convertParentPositionToIframe } from "../src/utils/convert-parent-position-to-iframe.js"; +import { convertTopWindowPositionToClient } from "../src/utils/convert-top-window-position-to-client.js"; +import { getWindowFrameElement } from "../src/utils/get-window-frame-element.js"; + +vi.mock("../src/utils/convert-parent-position-to-iframe.js", () => ({ + convertParentPositionToIframe: vi.fn((_frameElement, clientX, clientY) => ({ + x: clientX - 10, + y: clientY - 20, + })), +})); + +vi.mock("../src/utils/get-window-frame-element.js", () => ({ + getWindowFrameElement: vi.fn(), +})); + +vi.mock("../src/utils/is-iframe-element.js", () => ({ + isIframeElement: vi.fn(() => true), +})); + +const topWindow: Window = Object.assign(Object.create(null), {}); + +beforeEach(() => { + vi.stubGlobal("window", topWindow); + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("convertTopWindowPositionToClient", () => { + it("converts from the outermost frame into nested iframe coordinates", () => { + const outerFrame: HTMLIFrameElement = Object.assign(Object.create(null), { + ownerDocument: { defaultView: topWindow }, + }); + const outerFrameWindow: Window = Object.assign(Object.create(null), {}); + const innerFrame: HTMLIFrameElement = Object.assign(Object.create(null), { + ownerDocument: { defaultView: outerFrameWindow }, + }); + const innerFrameWindow: Window = Object.assign(Object.create(null), {}); + vi.mocked(getWindowFrameElement).mockImplementation((targetWindow) => { + if (targetWindow === innerFrameWindow) return innerFrame; + if (targetWindow === outerFrameWindow) return outerFrame; + return null; + }); + + expect(convertTopWindowPositionToClient(innerFrameWindow, 100, 120)).toEqual({ + x: 80, + y: 80, + }); + expect(convertParentPositionToIframe).toHaveBeenNthCalledWith(1, outerFrame, 100, 120); + expect(convertParentPositionToIframe).toHaveBeenNthCalledWith(2, innerFrame, 90, 100); + }); + + it("leaves top-window coordinates unchanged", () => { + expect(convertTopWindowPositionToClient(topWindow, 100, 120)).toEqual({ x: 100, y: 120 }); + expect(convertParentPositionToIframe).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-grab/tests/get-element-at-position.test.ts b/packages/react-grab/tests/get-element-at-position.test.ts new file mode 100644 index 000000000..4ae5c2d5d --- /dev/null +++ b/packages/react-grab/tests/get-element-at-position.test.ts @@ -0,0 +1,577 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + clearElementPositionCache, + getElementAtPosition, + getElementsAtPoint, +} from "../src/utils/get-element-at-position.js"; +import { resolveThreeElementAtPoint } from "../src/core/three-selection.js"; +import { createElementBounds } from "../src/utils/create-element-bounds.js"; +import { getAccessibleIframeDocument } from "../src/utils/get-accessible-iframe-document.js"; +import { getDeepElementAtPoint } from "../src/utils/get-deep-element-at-point.js"; +import { getDeepElementsAtPoint } from "../src/utils/get-deep-elements-at-point.js"; +import { getDeepFallbackElementAtPoint } from "../src/utils/get-deep-fallback-element-at-point.js"; +import { getElementTextBounds } from "../src/utils/get-element-text-bounds.js"; +import { getLocalContentElementAtPoint } from "../src/utils/get-local-content-element-at-point.js"; +import { isIframeElement } from "../src/utils/is-iframe-element.js"; +import { isValidGrabbableElement } from "../src/utils/is-valid-grabbable-element.js"; +import { + resumePointerEventsFreeze, + suspendPointerEventsFreeze, +} from "../src/utils/pointer-events-freeze.js"; +import { getScopeContainer, isWithinScope } from "../src/utils/runtime-mode.js"; +import { ELEMENT_POSITION_THROTTLE_MS } from "../src/constants.js"; + +vi.mock("../src/core/three-selection.js", () => ({ + resolveThreeElementAtPoint: vi.fn((element) => element), +})); + +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-deep-element-at-point.js", () => ({ + getDeepElementAtPoint: vi.fn(), +})); + +vi.mock("../src/utils/get-deep-elements-at-point.js", () => ({ + getDeepElementsAtPoint: vi.fn(() => []), +})); + +vi.mock("../src/utils/get-deep-fallback-element-at-point.js", () => ({ + getDeepFallbackElementAtPoint: vi.fn(() => null), +})); + +vi.mock("../src/utils/get-element-text-bounds.js", () => ({ + getElementTextBounds: vi.fn(() => null), +})); + +vi.mock("../src/utils/get-local-content-element-at-point.js", () => ({ + getLocalContentElementAtPoint: vi.fn(() => null), +})); + +vi.mock("../src/utils/is-iframe-element.js", () => ({ + isIframeElement: vi.fn(() => false), +})); + +vi.mock("../src/utils/is-valid-grabbable-element.js", () => ({ + isValidGrabbableElement: vi.fn(() => true), +})); + +vi.mock("../src/utils/pointer-events-freeze.js", () => ({ + resumePointerEventsFreeze: vi.fn(), + suspendPointerEventsFreeze: vi.fn(), +})); + +vi.mock("../src/utils/runtime-mode.js", () => ({ + getScopeContainer: vi.fn(() => null), + isWithinScope: vi.fn(() => true), +})); + +const createSvgElement = (localName: string): Element => + Object.assign(Object.create(null), { + getRootNode: () => Object.create(null), + localName, + namespaceURI: "http://www.w3.org/2000/svg", + parentElement: null, + }); + +const createHtmlElement = (localName: string): Element => + Object.assign(Object.create(null), { + getRootNode: () => Object.create(null), + localName, + namespaceURI: "http://www.w3.org/1999/xhtml", + parentElement: null, + }); + +beforeEach(() => { + clearElementPositionCache(); + vi.resetAllMocks(); + vi.stubGlobal("performance", { now: vi.fn(() => 0) }); + vi.mocked(createElementBounds).mockReset(); + vi.mocked(getAccessibleIframeDocument).mockReturnValue(null); + vi.mocked(getDeepElementAtPoint).mockReturnValue(null); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([]); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(null); + vi.mocked(getElementTextBounds).mockReturnValue(null); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(null); + vi.mocked(getScopeContainer).mockReturnValue(null); + vi.mocked(isIframeElement).mockReturnValue(false); + vi.mocked(isValidGrabbableElement).mockReturnValue(true); + vi.mocked(isWithinScope).mockReturnValue(true); + vi.mocked(resolveThreeElementAtPoint).mockImplementation((element) => element); +}); + +afterEach(() => { + clearElementPositionCache(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("getElementAtPosition", () => { + it("rejects non-finite coordinates without hit testing", () => { + expect(getElementAtPosition(Number.NaN, 10)).toBeNull(); + expect(getElementAtPosition(10, Number.POSITIVE_INFINITY)).toBeNull(); + expect(getDeepElementAtPoint).not.toHaveBeenCalled(); + expect(suspendPointerEventsFreeze).not.toHaveBeenCalled(); + }); + + it("does not refine cached HTML hits without a local content match", () => { + const htmlElement = createHtmlElement("li"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(htmlElement); + + expect(getElementAtPosition(10, 10)).toBe(htmlElement); + expect(getElementAtPosition(11, 11)).toBe(htmlElement); + expect(getLocalContentElementAtPoint).toHaveBeenCalledOnce(); + }); + + it("refreshes the hit target after a fast pointer jump", () => { + const firstElement = createHtmlElement("li"); + const secondElement = createHtmlElement("button"); + vi.mocked(getDeepElementAtPoint) + .mockReturnValueOnce(firstElement) + .mockReturnValueOnce(secondElement); + + expect(getElementAtPosition(10, 10)).toBe(firstElement); + vi.mocked(performance.now).mockReturnValue(1); + expect(getElementAtPosition(100, 100)).toBe(secondElement); + expect(getDeepElementAtPoint).toHaveBeenCalledTimes(2); + }); + + it("refreshes a nearby hit after the cache throttle expires", () => { + const firstElement = createHtmlElement("li"); + const secondElement = createHtmlElement("button"); + vi.mocked(getDeepElementAtPoint) + .mockReturnValueOnce(firstElement) + .mockReturnValueOnce(secondElement); + + expect(getElementAtPosition(10, 10)).toBe(firstElement); + vi.mocked(performance.now).mockReturnValue(ELEMENT_POSITION_THROTTLE_MS + 1); + expect(getElementAtPosition(11, 11)).toBe(secondElement); + expect(getDeepElementAtPoint).toHaveBeenCalledTimes(2); + }); + + it("uses the deep fallback when the top paint layer is invalid", () => { + const overlayElement = createHtmlElement("div"); + const targetElement = createHtmlElement("button"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(overlayElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(targetElement); + vi.mocked(isValidGrabbableElement).mockImplementation((element) => element !== overlayElement); + + expect(getElementAtPosition(10, 10)).toBe(targetElement); + expect(getDeepFallbackElementAtPoint).toHaveBeenCalledWith(10, 10); + }); + + it("uses the lower layer in empty width beside painted text", () => { + const textElement = createHtmlElement("p"); + const cardElement = createHtmlElement("div"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(textElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(cardElement); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === textElement + ? [{ x: 10, y: 10, width: 80, height: 20, borderRadius: "0px" }] + : null, + ); + + expect(getElementAtPosition(150, 20)).toBe(cardElement); + }); + + it("keeps a text layer when the point is inside its painted fragment", () => { + const textElement = createHtmlElement("p"); + const cardElement = createHtmlElement("div"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(textElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(cardElement); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === textElement + ? [{ x: 10, y: 10, width: 80, height: 20, borderRadius: "0px" }] + : null, + ); + + expect(getElementAtPosition(50, 20)).toBe(textElement); + expect(getDeepFallbackElementAtPoint).not.toHaveBeenCalled(); + }); + + it("leaves painted text when a cached pointer crosses into its empty width", () => { + const textElement = createHtmlElement("p"); + const cardElement = createHtmlElement("div"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(textElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(cardElement); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === textElement ? [{ x: 0, y: 0, width: 10, height: 20, borderRadius: "0px" }] : null, + ); + + expect(getElementAtPosition(10, 10)).toBe(textElement); + vi.mocked(performance.now).mockReturnValue(1); + expect(getElementAtPosition(11, 10)).toBe(cardElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + }); + + it("re-enters painted text from cached empty width", () => { + const textElement = createHtmlElement("p"); + const cardElement = createHtmlElement("div"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(textElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(cardElement); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === textElement ? [{ x: 10, y: 0, width: 1, height: 20, borderRadius: "0px" }] : null, + ); + + expect(getElementAtPosition(12, 10)).toBe(cardElement); + vi.mocked(performance.now).mockReturnValue(1); + expect(getElementAtPosition(11, 10)).toBe(textElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + }); + + it("falls through cached text when local content is outside its painted fragment", () => { + const textElement = createHtmlElement("p"); + const localTextElement = createHtmlElement("span"); + const cardElement = createHtmlElement("div"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(textElement); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(localTextElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(cardElement); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === textElement || element === localTextElement + ? [{ x: 0, y: 0, width: 10, height: 20, borderRadius: "0px" }] + : null, + ); + + expect(getElementAtPosition(10, 10)).toBe(localTextElement); + vi.mocked(performance.now).mockReturnValue(1); + expect(getElementAtPosition(11, 10)).toBe(cardElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + expect(getDeepFallbackElementAtPoint).toHaveBeenCalledOnce(); + }); + + it("keeps a cached native text hit when refined local content is unpainted", () => { + const textElement = createHtmlElement("p"); + const localTextElement = createHtmlElement("span"); + const cardElement = createHtmlElement("div"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(textElement); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(localTextElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(cardElement); + vi.mocked(getElementTextBounds).mockImplementation((element) => { + if (element === textElement) { + return [{ x: 0, y: 0, width: 20, height: 20, borderRadius: "0px" }]; + } + return element === localTextElement + ? [{ x: 0, y: 0, width: 10, height: 20, borderRadius: "0px" }] + : null; + }); + + expect(getElementAtPosition(10, 10)).toBe(localTextElement); + vi.mocked(performance.now).mockReturnValue(1); + expect(getElementAtPosition(11, 10)).toBe(textElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + expect(getDeepFallbackElementAtPoint).not.toHaveBeenCalled(); + }); + + it("falls back to the native hit when refined local content is invalid", () => { + const nativeElement = createHtmlElement("button"); + const localContentElement = createHtmlElement("span"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(nativeElement); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(localContentElement); + vi.mocked(isValidGrabbableElement).mockImplementation( + (element) => element !== localContentElement, + ); + + expect(getElementAtPosition(10, 10)).toBe(nativeElement); + }); + + it("falls back to the native hit when refined local content is out of scope", () => { + const nativeElement = createHtmlElement("button"); + const localContentElement = createHtmlElement("span"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(nativeElement); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(localContentElement); + vi.mocked(isWithinScope).mockImplementation((element) => element !== localContentElement); + + expect(getElementAtPosition(10, 10)).toBe(nativeElement); + }); + + it("restores pointer-event freezing after a failed deep hit test", () => { + vi.useFakeTimers(); + vi.mocked(getDeepElementAtPoint).mockImplementation(() => { + throw new Error("hit test failed"); + }); + + expect(() => getElementAtPosition(10, 10)).toThrow("hit test failed"); + expect(suspendPointerEventsFreeze).toHaveBeenCalledOnce(); + expect(resumePointerEventsFreeze).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(resumePointerEventsFreeze).toHaveBeenCalledOnce(); + }); + + it("reuses an inaccessible iframe only while the point remains inside fresh bounds", () => { + const iframeElement = Object.assign(createHtmlElement("iframe"), { isConnected: true }); + const outsideElement = createHtmlElement("button"); + vi.mocked(getDeepElementAtPoint) + .mockReturnValueOnce(iframeElement) + .mockReturnValueOnce(outsideElement); + vi.mocked(isIframeElement).mockImplementation((element) => element === iframeElement); + vi.mocked(createElementBounds).mockReturnValue({ + x: 0, + y: 0, + width: 100, + height: 100, + borderRadius: "0px", + }); + + expect(getElementAtPosition(10, 10)).toBe(iframeElement); + vi.mocked(performance.now).mockReturnValue(1); + expect(getElementAtPosition(20, 20)).toBe(iframeElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + + expect(getElementAtPosition(120, 120)).toBe(outsideElement); + expect(getDeepElementAtPoint).toHaveBeenCalledTimes(2); + }); + + it("invalidates an iframe cache when the frame becomes accessible", () => { + const iframeElement = Object.assign(createHtmlElement("iframe"), { isConnected: true }); + const frameContentElement = createHtmlElement("button"); + let accessibleDocument: Document | null = null; + vi.mocked(getDeepElementAtPoint) + .mockReturnValueOnce(iframeElement) + .mockReturnValueOnce(frameContentElement); + vi.mocked(isIframeElement).mockImplementation((element) => element === iframeElement); + vi.mocked(getAccessibleIframeDocument).mockImplementation(() => accessibleDocument); + vi.mocked(createElementBounds).mockReturnValue({ + x: 0, + y: 0, + width: 100, + height: 100, + borderRadius: "0px", + }); + + expect(getElementAtPosition(10, 10)).toBe(iframeElement); + accessibleDocument = Object.create(null); + vi.mocked(performance.now).mockReturnValue(1); + expect(getElementAtPosition(20, 20)).toBe(frameContentElement); + expect(getDeepElementAtPoint).toHaveBeenCalledTimes(2); + }); + + it("refines cached SVG hits when the pointer enters a text label", () => { + const svgElement = createSvgElement("svg"); + const textElement = createSvgElement("text"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(svgElement); + vi.mocked(getLocalContentElementAtPoint) + .mockReturnValueOnce(null) + .mockReturnValueOnce(textElement); + + expect(getElementAtPosition(10, 10)).toBe(svgElement); + expect(getElementAtPosition(11, 11)).toBe(textElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + }); + + it("restores the cached native SVG hit when the pointer leaves a text label", () => { + const svgElement = createSvgElement("svg"); + const textElement = createSvgElement("text"); + vi.mocked(getDeepElementAtPoint).mockReturnValue(svgElement); + vi.mocked(getLocalContentElementAtPoint) + .mockReturnValueOnce(textElement) + .mockReturnValueOnce(null); + + expect(getElementAtPosition(10, 10)).toBe(textElement); + expect(getElementAtPosition(11, 11)).toBe(svgElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + }); + + it("preserves a cached deep fallback when the native SVG hit is invalid", () => { + const svgElement = createSvgElement("svg"); + const fallbackElement: Element = Object.create(null); + vi.mocked(getDeepElementAtPoint).mockReturnValue(svgElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(fallbackElement); + vi.mocked(isValidGrabbableElement).mockImplementation((element) => element !== svgElement); + + expect(getElementAtPosition(10, 10)).toBe(fallbackElement); + expect(getElementAtPosition(11, 11)).toBe(fallbackElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + expect(getDeepFallbackElementAtPoint).toHaveBeenCalledOnce(); + }); + + it("restores the deep fallback after leaving refined SVG text", () => { + const svgElement = createSvgElement("svg"); + const textElement = createSvgElement("text"); + const fallbackElement: Element = Object.create(null); + vi.mocked(getDeepElementAtPoint).mockReturnValue(svgElement); + vi.mocked(getDeepFallbackElementAtPoint).mockReturnValue(fallbackElement); + vi.mocked(getLocalContentElementAtPoint) + .mockReturnValueOnce(textElement) + .mockReturnValueOnce(null); + vi.mocked(isValidGrabbableElement).mockImplementation((element) => element !== svgElement); + + expect(getElementAtPosition(10, 10)).toBe(textElement); + expect(getElementAtPosition(11, 11)).toBe(fallbackElement); + expect(getDeepElementAtPoint).toHaveBeenCalledOnce(); + expect(getDeepFallbackElementAtPoint).toHaveBeenCalledOnce(); + }); +}); + +describe("getElementsAtPoint", () => { + it("skips text-flow layers where the point is outside painted text", () => { + const textElement = createHtmlElement("p"); + const cardElement = createHtmlElement("div"); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([textElement, cardElement]); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === textElement ? [{ x: 0, y: 0, width: 10, height: 20, borderRadius: "0px" }] : null, + ); + + expect(getElementsAtPoint(50, 10)).toEqual([cardElement]); + }); + + it("keeps text-flow layers where the point is inside painted text", () => { + const textElement = createHtmlElement("span"); + const cardElement = createHtmlElement("div"); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([textElement, cardElement]); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === textElement ? [{ x: 0, y: 0, width: 40, height: 20, borderRadius: "0px" }] : null, + ); + + expect(getElementsAtPoint(20, 10)).toEqual([textElement, cardElement]); + }); + + it("replaces lower native layers with the refined hierarchy", () => { + const ignoredOverlayElement = createHtmlElement("div"); + const localContentElement = createHtmlElement("span"); + const containerElement = Object.assign(createHtmlElement("button"), { + contains: (element: Element) => element === localContentElement, + }); + Object.assign(localContentElement, { parentElement: containerElement }); + const lowerElement = createHtmlElement("section"); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([ + ignoredOverlayElement, + containerElement, + lowerElement, + ]); + vi.mocked(getLocalContentElementAtPoint).mockImplementation((element) => + element === containerElement ? localContentElement : null, + ); + vi.mocked(isValidGrabbableElement).mockImplementation( + (element) => element !== ignoredOverlayElement, + ); + + expect(getElementsAtPoint(10, 10)).toEqual([ + ignoredOverlayElement, + localContentElement, + containerElement, + ]); + expect(getLocalContentElementAtPoint).toHaveBeenCalledTimes(2); + expect(getLocalContentElementAtPoint).not.toHaveBeenCalledWith(lowerElement, 10, 10); + }); + + it("preserves the native layer when local content is invalid", () => { + const ignoredContentElement = createHtmlElement("span"); + const containerElement = Object.assign(createHtmlElement("button"), { + contains: (element: Element) => element === ignoredContentElement, + }); + const lowerElement = createHtmlElement("section"); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([containerElement, lowerElement]); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(ignoredContentElement); + vi.mocked(isValidGrabbableElement).mockImplementation( + (element) => element !== ignoredContentElement, + ); + + expect(getElementsAtPoint(10, 10)).toEqual([containerElement, lowerElement]); + }); + + it("inserts pointer-disabled ancestors before the native layer", () => { + const containerElement = createHtmlElement("button"); + const parentElement = Object.assign(createHtmlElement("span"), { + parentElement: containerElement, + }); + const localContentElement = Object.assign(createHtmlElement("strong"), { + parentElement, + }); + Object.assign(containerElement, { + contains: (element: Element) => element === parentElement || element === localContentElement, + }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([containerElement]); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(localContentElement); + + expect(getElementsAtPoint(10, 10)).toEqual([ + localContentElement, + parentElement, + containerElement, + ]); + }); + + it("skips a native SVG sibling behind refined text", () => { + const svgElement = createSvgElement("svg"); + const shapeElement = createSvgElement("rect"); + const labelGroupElement = Object.assign(createSvgElement("g"), { + parentElement: svgElement, + }); + const localTextElement = Object.assign(createSvgElement("text"), { + parentElement: labelGroupElement, + }); + const lowerElement = createHtmlElement("section"); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([shapeElement, svgElement, lowerElement]); + vi.mocked(getLocalContentElementAtPoint).mockImplementation((element) => + element === shapeElement ? localTextElement : null, + ); + + expect(getElementsAtPoint(10, 10)).toEqual([localTextElement, labelGroupElement, svgElement]); + }); + + it("does not repeat an inserted ancestor that is also in the native stack", () => { + const containerElement = createHtmlElement("button"); + const parentElement = Object.assign(createHtmlElement("span"), { + parentElement: containerElement, + }); + const localContentElement = Object.assign(createHtmlElement("strong"), { + parentElement, + }); + Object.assign(containerElement, { + contains: (element: Element) => element === parentElement || element === localContentElement, + }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([containerElement, parentElement]); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(localContentElement); + + expect(getElementsAtPoint(10, 10)).toEqual([ + localContentElement, + parentElement, + containerElement, + ]); + }); + + it("stops the refined hierarchy at the scope boundary", () => { + const outsideElement = createHtmlElement("main"); + const scopedElement = Object.assign(createHtmlElement("section"), { + parentElement: outsideElement, + }); + const localContentElement = Object.assign(createHtmlElement("span"), { + parentElement: scopedElement, + }); + const scopeContainer: HTMLElement = Object.create(null); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([scopedElement]); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(localContentElement); + vi.mocked(getScopeContainer).mockReturnValue(scopeContainer); + vi.mocked(isWithinScope).mockImplementation((element) => element !== outsideElement); + + expect(getElementsAtPoint(10, 10)).toEqual([localContentElement, scopedElement]); + }); + + it("filters out-of-scope stack layers before local refinement", () => { + const outsideElement = createHtmlElement("div"); + const insideElement = createHtmlElement("button"); + const scopeElement: HTMLElement = Object.create(null); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([outsideElement, insideElement]); + vi.mocked(getScopeContainer).mockReturnValue(scopeElement); + vi.mocked(isWithinScope).mockImplementation((element) => element !== outsideElement); + + expect(getElementsAtPoint(10, 10)).toEqual([insideElement]); + expect(getLocalContentElementAtPoint).toHaveBeenCalledOnce(); + expect(getLocalContentElementAtPoint).toHaveBeenCalledWith(insideElement, 10, 10); + }); + + it("schedules freeze restoration when deep stack collection throws", () => { + vi.useFakeTimers(); + vi.mocked(getDeepElementsAtPoint).mockImplementation(() => { + throw new Error("stack failed"); + }); + + expect(() => getElementsAtPoint(10, 10)).toThrow("stack failed"); + vi.runAllTimers(); + expect(resumePointerEventsFreeze).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/react-grab/tests/get-element-text-bounds.test.ts b/packages/react-grab/tests/get-element-text-bounds.test.ts new file mode 100644 index 000000000..0b1573c70 --- /dev/null +++ b/packages/react-grab/tests/get-element-text-bounds.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + DRAG_SELECTION_MAX_TEXT_FLOW_NODES, + DRAG_SELECTION_MAX_TEXT_RECTS, +} from "../src/constants.js"; +import { + getElementTextBounds, + invalidateElementTextBoundsCache, +} from "../src/utils/get-element-text-bounds.js"; +import { isElementPaintedAtPosition } from "../src/utils/is-element-painted-at-position.js"; +import { convertClientPositionToTopWindow } from "../src/utils/convert-client-position-to-top-window.js"; + +vi.mock("../src/utils/convert-client-position-to-top-window.js", () => ({ + convertClientPositionToTopWindow: vi.fn(() => ({ x: 0, y: 0, scaleX: 1, scaleY: 1 })), +})); + +const createTextNode = (textContent: string): Node => + Object.assign(Object.create(null), { childNodes: [], nodeType: 3, textContent }); + +const createElement = ( + tagName: string, + childNodes: Node[], + createRange: () => unknown, + role: string | null = null, + hasBoxPaint = false, +): Element => + Object.assign(Object.create(null), { + childNodes, + getAttribute: (attributeName: string) => (attributeName === "role" ? role : null), + isContentEditable: false, + namespaceURI: "http://www.w3.org/1999/xhtml", + nodeType: 1, + ownerDocument: { + createRange, + defaultView: { + getComputedStyle: () => ({ + backgroundClip: "border-box", + backgroundColor: hasBoxPaint ? "rgb(240, 240, 240)" : "transparent", + backgroundImage: "none", + borderBottomStyle: "none", + borderBottomWidth: "0px", + borderLeftStyle: "none", + borderLeftWidth: "0px", + borderRightStyle: "none", + borderRightWidth: "0px", + borderTopStyle: "none", + borderTopWidth: "0px", + boxShadow: "none", + outlineStyle: "none", + }), + }, + }, + tagName, + }); + +const createRangeHarness = (rectsByNode: Map) => { + let selectedNode: Node | null = null; + const getClientRects = vi.fn(() => (selectedNode ? (rectsByNode.get(selectedNode) ?? []) : [])); + const selectNodeContents = vi.fn((node: Node) => { + selectedNode = node; + }); + const createRange = vi.fn(() => ({ getClientRects, selectNodeContents })); + return { createRange, getClientRects, selectNodeContents }; +}; + +const createRect = (left: number, top: number, width: number, height: number): DOMRect => + Object.assign(Object.create(null), { height, left, top, width }); + +beforeEach(() => { + vi.stubGlobal("Node", { ELEMENT_NODE: 1, TEXT_NODE: 3 }); + vi.stubGlobal("performance", { now: vi.fn(() => 100) }); + invalidateElementTextBoundsCache(); + vi.mocked(convertClientPositionToTopWindow).mockReturnValue({ + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("getElementTextBounds", () => { + it.each(["DIV", "P", "SPAN"])( + "measures the painted fragment instead of the %s container box", + (tagName) => { + const textNode = createTextNode("Syncing workspace…"); + const rangeHarness = createRangeHarness(new Map([[textNode, [createRect(20, 10, 140, 24)]]])); + const element = createElement(tagName, [textNode], rangeHarness.createRange); + + expect(getElementTextBounds(element)).toEqual([ + { x: 20, y: 10, width: 140, height: 24, borderRadius: "0px" }, + ]); + expect(rangeHarness.selectNodeContents).toHaveBeenCalledWith(textNode); + expect(isElementPaintedAtPosition(element, 40, 20)).toBe(true); + expect(isElementPaintedAtPosition(element, 200, 20)).toBe(false); + }, + ); + + it("keeps a painted text container on its element box", () => { + const textNode = createTextNode("Painted label"); + const rangeHarness = createRangeHarness(new Map([[textNode, [createRect(10, 10, 40, 20)]]])); + const element = createElement("DIV", [textNode], rangeHarness.createRange, null, true); + + expect(getElementTextBounds(element)).toBeNull(); + expect(rangeHarness.createRange).not.toHaveBeenCalled(); + }); + + it("keeps wrapped fragments separate through inline formatting", () => { + const firstTextNode = createTextNode("first line"); + const secondTextNode = createTextNode("short line"); + const rangeHarness = createRangeHarness( + new Map([ + [firstTextNode, [createRect(10, 10, 120, 20)]], + [secondTextNode, [createRect(10, 30, 60, 20)]], + ]), + ); + const inlineElement = createElement("STRONG", [secondTextNode], rangeHarness.createRange); + const element = createElement("P", [firstTextNode, inlineElement], rangeHarness.createRange); + + expect(getElementTextBounds(element)).toEqual([ + { x: 10, y: 10, width: 120, height: 20, borderRadius: "0px" }, + { x: 10, y: 30, width: 60, height: 20, borderRadius: "0px" }, + ]); + expect(isElementPaintedAtPosition(element, 100, 20)).toBe(true); + expect(isElementPaintedAtPosition(element, 100, 40)).toBe(false); + expect(isElementPaintedAtPosition(element, 50, 40)).toBe(true); + }); + + it("converts iframe fragments to top-window coordinates once per element", () => { + const textNode = createTextNode("scaled text"); + const rangeHarness = createRangeHarness( + new Map([[textNode, [createRect(10, 5, 40, 10), createRect(10, 15, 20, 10)]]]), + ); + const element = createElement("SPAN", [textNode], rangeHarness.createRange); + vi.mocked(convertClientPositionToTopWindow).mockReturnValue({ + x: 100, + y: 200, + scaleX: 2, + scaleY: 3, + }); + + expect(getElementTextBounds(element)).toEqual([ + { x: 120, y: 215, width: 80, height: 30, borderRadius: "0px" }, + { x: 120, y: 245, width: 40, height: 30, borderRadius: "0px" }, + ]); + expect(convertClientPositionToTopWindow).toHaveBeenCalledOnce(); + }); + + it.each(["A", "BUTTON", "INPUT", "CANVAS", "SVG"])( + "keeps %s geometry on its element box", + (tagName) => { + const textNode = createTextNode("control text"); + const rangeHarness = createRangeHarness(new Map([[textNode, [createRect(10, 10, 40, 20)]]])); + const element = createElement(tagName, [textNode], rangeHarness.createRange); + + expect(getElementTextBounds(element)).toBeNull(); + expect(rangeHarness.createRange).not.toHaveBeenCalled(); + }, + ); + + it("keeps interactive roles on their element box", () => { + const textNode = createTextNode("custom control"); + const rangeHarness = createRangeHarness(new Map([[textNode, [createRect(10, 10, 40, 20)]]])); + const element = createElement("DIV", [textNode], rangeHarness.createRange, "button"); + + expect(getElementTextBounds(element)).toBeNull(); + expect(rangeHarness.createRange).not.toHaveBeenCalled(); + }); + + it("falls back to the element box when the container has block content", () => { + const textNode = createTextNode("card content"); + const rangeHarness = createRangeHarness(new Map([[textNode, [createRect(10, 10, 40, 20)]]])); + const blockElement = createElement("P", [textNode], rangeHarness.createRange); + const element = createElement("DIV", [blockElement], rangeHarness.createRange); + + expect(getElementTextBounds(element)).toBeNull(); + expect(rangeHarness.createRange).not.toHaveBeenCalled(); + }); + + it("bounds text-flow traversal work", () => { + const childNodes = Array.from({ length: DRAG_SELECTION_MAX_TEXT_FLOW_NODES + 1 }, () => + createTextNode(" "), + ); + const rangeHarness = createRangeHarness(new Map()); + const element = createElement("DIV", childNodes, rangeHarness.createRange); + + expect(getElementTextBounds(element)).toBeNull(); + expect(rangeHarness.createRange).not.toHaveBeenCalled(); + }); + + it("bounds queued descendants before traversing nested inline content", () => { + const nestedTextNodes = Array.from({ length: DRAG_SELECTION_MAX_TEXT_FLOW_NODES }, () => + createTextNode(" "), + ); + const rangeHarness = createRangeHarness(new Map()); + const inlineElement = createElement("SPAN", nestedTextNodes, rangeHarness.createRange); + const element = createElement("P", [inlineElement], rangeHarness.createRange); + + expect(getElementTextBounds(element)).toBeNull(); + expect(rangeHarness.createRange).not.toHaveBeenCalled(); + }); + + it("falls back instead of partially measuring too many wrapped fragments", () => { + const textNode = createTextNode("many wrapped lines"); + const rects = Array.from({ length: DRAG_SELECTION_MAX_TEXT_RECTS + 1 }, (_, rectIndex) => + createRect(0, rectIndex * 10, 40, 10), + ); + const rangeHarness = createRangeHarness(new Map([[textNode, rects]])); + const element = createElement("P", [textNode], rangeHarness.createRange); + + expect(getElementTextBounds(element)).toBeNull(); + }); + + it("falls back when range measurement fails", () => { + const textNode = createTextNode("unmeasurable text"); + const createRange = vi.fn(() => { + throw new Error("range unavailable"); + }); + const element = createElement("P", [textNode], createRange); + + expect(getElementTextBounds(element)).toBeNull(); + }); + + it("reuses text geometry within the bounds cache window", () => { + const textNode = createTextNode("cached text"); + const rangeHarness = createRangeHarness(new Map([[textNode, [createRect(10, 10, 40, 20)]]])); + const element = createElement("P", [textNode], rangeHarness.createRange); + + const firstBounds = getElementTextBounds(element); + const secondBounds = getElementTextBounds(element); + + expect(secondBounds).toBe(firstBounds); + expect(rangeHarness.createRange).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/react-grab/tests/get-elements-in-drag.test.ts b/packages/react-grab/tests/get-elements-in-drag.test.ts index 2d1412a5a..a7ae4e026 100644 --- a/packages/react-grab/tests/get-elements-in-drag.test.ts +++ b/packages/react-grab/tests/get-elements-in-drag.test.ts @@ -1,17 +1,48 @@ 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 { compareElementDocumentOrder } from "../src/utils/compare-element-document-order.js"; import { createElementBounds } from "../src/utils/create-element-bounds.js"; +import { getElementTextBounds } from "../src/utils/get-element-text-bounds.js"; +import { getAccessibleIframeDocument } from "../src/utils/get-accessible-iframe-document.js"; +import { getComposedParentElement } from "../src/utils/get-composed-parent-element.js"; import { getDeepElementsAtPoint } from "../src/utils/get-deep-elements-at-point.js"; +import { getLocalContentElementAtPoint } from "../src/utils/get-local-content-element-at-point.js"; +import { isIframeElement } from "../src/utils/is-iframe-element.js"; +import { isRootElement } from "../src/utils/is-root-element.js"; +import { isShadowRoot } from "../src/utils/is-shadow-root.js"; +import { + getThreeSelectionElements, + resolveThreeElementAtPoint, +} from "../src/core/three-selection.js"; +import { + resumePointerEventsFreeze, + suspendPointerEventsFreeze, +} from "../src/utils/pointer-events-freeze.js"; +import { isWithinScope } from "../src/utils/runtime-mode.js"; +import { + DRAG_SELECTION_MAX_LOCAL_COLLECTION_ELEMENTS, + DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS, + DRAG_SELECTION_MAX_TOTAL_SAMPLE_POINTS, +} from "../src/constants.js"; vi.mock("../src/utils/compare-element-document-order.js", () => ({ compareElementDocumentOrder: vi.fn(() => 0), })); +vi.mock("../src/core/three-selection.js", () => ({ + getThreeSelectionElements: vi.fn(() => []), + resolveThreeElementAtPoint: vi.fn((element) => element), +})); + vi.mock("../src/utils/create-element-bounds.js", () => ({ createElementBounds: vi.fn(), })); +vi.mock("../src/utils/get-element-text-bounds.js", () => ({ + getElementTextBounds: vi.fn(() => null), +})); + vi.mock("../src/utils/get-accessible-iframe-document.js", () => ({ getAccessibleIframeDocument: vi.fn(() => null), })); @@ -24,6 +55,10 @@ vi.mock("../src/utils/get-deep-elements-at-point.js", () => ({ getDeepElementsAtPoint: vi.fn(), })); +vi.mock("../src/utils/get-local-content-element-at-point.js", () => ({ + getLocalContentElementAtPoint: vi.fn(() => null), +})); + vi.mock("../src/utils/is-iframe-element.js", () => ({ isIframeElement: vi.fn(() => false), })); @@ -45,7 +80,25 @@ vi.mock("../src/utils/runtime-mode.js", () => ({ isWithinScope: vi.fn(() => true), })); -const createElement = (): Element => Object.create(null); +const createElement = (children: Element[] = []): Element => { + const element = Object.assign(Object.create(null), { + children, + getRootNode: () => null, + nextElementSibling: null, + parentElement: null, + previousElementSibling: null, + shadowRoot: null, + tagName: "DIV", + }); + for (let childIndex = 0; childIndex < children.length; childIndex += 1) { + Object.assign(children[childIndex], { + nextElementSibling: children[childIndex + 1] ?? null, + parentElement: element, + previousElementSibling: children[childIndex - 1] ?? null, + }); + } + return element; +}; const setElementBounds = (boundsByElement: Map) => { vi.mocked(createElementBounds).mockImplementation((element) => { @@ -57,6 +110,19 @@ const setElementBounds = (boundsByElement: Map) => { beforeEach(() => { vi.stubGlobal("window", { innerHeight: 300, innerWidth: 300 }); + vi.mocked(compareElementDocumentOrder).mockReturnValue(0); + vi.mocked(createElementBounds).mockReset(); + vi.mocked(getAccessibleIframeDocument).mockReturnValue(null); + vi.mocked(getElementTextBounds).mockReturnValue(null); + vi.mocked(getComposedParentElement).mockReturnValue(null); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([]); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(null); + vi.mocked(getThreeSelectionElements).mockReturnValue([]); + vi.mocked(resolveThreeElementAtPoint).mockImplementation((element) => element); + vi.mocked(isIframeElement).mockReturnValue(false); + vi.mocked(isRootElement).mockReturnValue(false); + vi.mocked(isShadowRoot).mockReturnValue(false); + vi.mocked(isWithinScope).mockReturnValue(true); }); afterEach(() => { @@ -76,11 +142,406 @@ describe("getElementsInDrag", () => { ]), ); - const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + const elements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 150, y: 150 }, + () => true, + ); expect(elements).toEqual([nearestElement]); }); + it("prefers the candidate under the drag endpoint over one with a closer center", () => { + const endpointElement = createElement(); + const centeredElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([centeredElement, endpointElement]); + setElementBounds( + new Map([ + [endpointElement, { x: 100, y: 80, width: 150, height: 140, borderRadius: "0px" }], + [centeredElement, { x: 140, y: 0, width: 20, height: 300, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 195, y: 150 }, + () => true, + ); + + expect(elements).toEqual([endpointElement]); + expect(getDeepElementsAtPoint).toHaveBeenNthCalledWith(1, 195, 150); + }); + + it("includes local pointer-none content at the drag endpoint", () => { + const svgElement = createElement(); + const textElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([svgElement]); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(textElement); + setElementBounds( + new Map([ + [svgElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }], + [textElement, { x: 125, y: 125, width: 50, height: 20, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 125, y: 125, width: 50, height: 20 }, + { x: 150, y: 135 }, + () => true, + ); + + expect(elements).toEqual([textElement]); + expect(getLocalContentElementAtPoint).toHaveBeenCalledOnce(); + }); + + it("only refines local content at the drag endpoint", () => { + const candidateElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([candidateElement]); + setElementBounds( + new Map([ + [candidateElement, { x: 100, y: 100, width: 100, height: 100, borderRadius: "0px" }], + ]), + ); + + getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, { x: 195, y: 195 }, () => true); + + expect(vi.mocked(getDeepElementsAtPoint).mock.calls.length).toBeGreaterThan(1); + expect(getLocalContentElementAtPoint).toHaveBeenCalledOnce(); + expect(getLocalContentElementAtPoint).toHaveBeenCalledWith(candidateElement, 195, 195); + }); + + it("selects projected Three.js objects instead of their shared canvas", () => { + const canvasElement = createElement(); + const leftMeshElement = createElement(); + const rightMeshElement = createElement(); + const canvasContainerElement = createElement([canvasElement]); + Object.assign(canvasElement, { tagName: "CANVAS" }); + Object.assign(leftMeshElement, { tagName: "MESH" }); + Object.assign(rightMeshElement, { tagName: "MESH" }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([canvasElement, canvasContainerElement]); + vi.mocked(getThreeSelectionElements).mockReturnValue([leftMeshElement, rightMeshElement]); + setElementBounds( + new Map([ + [canvasElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }], + [canvasContainerElement, { x: 0, y: 0, width: 300, height: 300, borderRadius: "0px" }], + [leftMeshElement, { x: 80, y: 100, width: 40, height: 40, borderRadius: "0px" }], + [rightMeshElement, { x: 180, y: 100, width: 40, height: 40, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 50, y: 50, width: 200, height: 200 }, + { x: 245, y: 245 }, + () => true, + ); + + expect(elements).toEqual([leftMeshElement, rightMeshElement]); + expect(getThreeSelectionElements).toHaveBeenCalledOnce(); + expect(getThreeSelectionElements).toHaveBeenCalledWith(canvasElement, canvasElement); + }); + + it("passes the endpoint instance into Three.js drag enumeration", () => { + const canvasElement = createElement(); + const instanceElement = createElement(); + Object.assign(canvasElement, { tagName: "CANVAS" }); + Object.assign(instanceElement, { tagName: "INSTANCEDMESH" }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([canvasElement]); + vi.mocked(resolveThreeElementAtPoint).mockReturnValue(instanceElement); + vi.mocked(getThreeSelectionElements).mockReturnValue([instanceElement]); + setElementBounds( + new Map([ + [canvasElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }], + [instanceElement, { x: 180, y: 180, width: 30, height: 30, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 175, y: 175, width: 40, height: 40 }, + { x: 200, y: 200 }, + () => true, + ); + + expect(elements).toEqual([instanceElement]); + expect(resolveThreeElementAtPoint).toHaveBeenCalledOnce(); + expect(resolveThreeElementAtPoint).toHaveBeenCalledWith(canvasElement, 200, 200); + expect(getThreeSelectionElements).toHaveBeenCalledWith(canvasElement, instanceElement); + }); + + it("keeps the canvas when Three.js endpoint resolution fails", () => { + const canvasElement = createElement(); + Object.assign(canvasElement, { tagName: "CANVAS" }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([canvasElement]); + vi.mocked(resolveThreeElementAtPoint).mockImplementation(() => { + throw new Error("renderer state is unreadable"); + }); + setElementBounds( + new Map([[canvasElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 50, y: 50, width: 200, height: 200 }, + { x: 245, y: 245 }, + () => true, + ); + + expect(elements).toEqual([canvasElement]); + }); + + it("keeps an ordinary canvas when it has no Three.js targets", () => { + const canvasElement = createElement(); + Object.assign(canvasElement, { tagName: "CANVAS" }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([canvasElement]); + setElementBounds( + new Map([[canvasElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 50, y: 50, width: 200, height: 200 }, + { x: 245, y: 245 }, + () => true, + ); + + expect(elements).toEqual([canvasElement]); + }); + + it("looks through invalid stack layers for local content at the endpoint", () => { + const ignoredOverlayElement = createElement(); + const contentContainerElement = createElement(); + const localContentElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([ + ignoredOverlayElement, + contentContainerElement, + ]); + vi.mocked(getLocalContentElementAtPoint).mockImplementation((element) => + element === contentContainerElement ? localContentElement : null, + ); + setElementBounds( + new Map([ + [ignoredOverlayElement, { x: 0, y: 0, width: 300, height: 300, borderRadius: "0px" }], + [contentContainerElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }], + [localContentElement, { x: 140, y: 140, width: 20, height: 20, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 140, y: 140, width: 20, height: 20 }, + { x: 150, y: 150 }, + (element) => element !== ignoredOverlayElement, + ); + + expect(elements).toEqual([localContentElement]); + expect(getLocalContentElementAtPoint).toHaveBeenNthCalledWith( + 1, + ignoredOverlayElement, + 150, + 150, + ); + expect(getLocalContentElementAtPoint).toHaveBeenNthCalledWith( + 2, + contentContainerElement, + 150, + 150, + ); + }); + + it("stops local refinement at the first valid paint layer", () => { + const topElement = createElement(); + const lowerElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([topElement, lowerElement]); + setElementBounds( + new Map([ + [topElement, { x: 100, y: 100, width: 100, height: 100, borderRadius: "0px" }], + [lowerElement, { x: 100, y: 100, width: 100, height: 100, borderRadius: "0px" }], + ]), + ); + + getElementsInDrag({ x: 125, y: 125, width: 50, height: 50 }, { x: 150, y: 150 }, () => true); + + expect(getLocalContentElementAtPoint).toHaveBeenCalledOnce(); + expect(getLocalContentElementAtPoint).toHaveBeenCalledWith(topElement, 150, 150); + }); + + it("uses drag direction to resolve otherwise equal fallback candidates", () => { + const leftElement = createElement(); + const rightElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([leftElement, rightElement]); + setElementBounds( + new Map([ + [leftElement, { x: 50, y: 80, width: 120, height: 140, borderRadius: "0px" }], + [rightElement, { x: 130, y: 80, width: 120, height: 140, borderRadius: "0px" }], + ]), + ); + + const leftToRightElements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 195, y: 150 }, + () => true, + ); + const rightToLeftElements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 105, y: 150 }, + () => true, + ); + + expect(leftToRightElements).toEqual([rightElement]); + expect(rightToLeftElements).toEqual([leftElement]); + }); + + it("prefers the smaller candidate when overlapping candidates contain the endpoint", () => { + const containerElement = createElement(); + const targetElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([containerElement, targetElement]); + setElementBounds( + new Map([ + [containerElement, { x: 0, y: 0, width: 200, height: 200, borderRadius: "0px" }], + [targetElement, { x: 0, y: 0, width: 100, height: 100, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 50, height: 50 }, + { x: 25, y: 25 }, + () => true, + ); + + expect(elements).toEqual([targetElement]); + }); + + it("fills unsampled table rows around a sampled cell", () => { + const firstCell = createElement(); + const secondCell = createElement(); + const thirdCell = createElement(); + const firstRow = createElement([firstCell]); + const secondRow = createElement([secondCell]); + const thirdRow = createElement([thirdCell]); + const tableBody = createElement([firstRow, secondRow, thirdRow]); + Object.assign(firstRow, { tagName: "TR" }); + Object.assign(secondRow, { tagName: "TR" }); + Object.assign(thirdRow, { tagName: "TR" }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([firstCell, tableBody]); + vi.mocked(getComposedParentElement).mockImplementation((element) => { + if (element === firstCell) return firstRow; + if (element === secondCell) return secondRow; + if (element === thirdCell) return thirdRow; + if (element === firstRow || element === secondRow || element === thirdRow) return tableBody; + return null; + }); + setElementBounds( + new Map([ + [tableBody, { x: 0, y: 0, width: 300, height: 500, borderRadius: "0px" }], + [firstRow, { x: 0, y: 0, width: 300, height: 100, borderRadius: "0px" }], + [secondRow, { x: 0, y: 100, width: 300, height: 100, borderRadius: "0px" }], + [thirdRow, { x: 0, y: 200, width: 300, height: 100, borderRadius: "0px" }], + [firstCell, { x: 0, y: 0, width: 100, height: 100, borderRadius: "0px" }], + [secondCell, { x: 0, y: 100, width: 100, height: 100, borderRadius: "0px" }], + [thirdCell, { x: 0, y: 200, width: 100, height: 100, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 300, height: 300 }, + { x: 299, y: 299 }, + () => true, + ); + + expect(elements).toEqual([firstRow, secondRow, thirdRow]); + }); + + it("bounds candidate neighborhood inspections on dense containers", () => { + const siblingElements = Array.from( + { length: DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS + 10 }, + () => createElement(), + ); + for (const siblingElement of siblingElements) { + Object.assign(siblingElement, { tagName: "TR" }); + } + createElement(siblingElements); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([siblingElements[0]]); + const boundsByElement = new Map(); + for (const siblingElement of siblingElements) { + boundsByElement.set(siblingElement, { + x: 10, + y: 10, + width: 10, + height: 10, + borderRadius: "0px", + }); + } + setElementBounds(boundsByElement); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 300, height: 300 }, + { x: 299, y: 299 }, + () => true, + ); + + expect(elements).toHaveLength(DRAG_SELECTION_MAX_NEIGHBOR_SCAN_ELEMENTS + 1); + }); + + it("does not scan children from an unbounded local collection", () => { + const childElements = Array.from( + { length: DRAG_SELECTION_MAX_LOCAL_COLLECTION_ELEMENTS + 1 }, + () => createElement(), + ); + const containerElement = createElement(childElements); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([containerElement]); + setElementBounds( + new Map([[containerElement, { x: 0, y: 0, width: 200, height: 200, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 25, y: 25, width: 50, height: 50 }, + { x: 50, y: 50 }, + () => true, + ); + + expect(elements).toEqual([containerElement]); + expect(createElementBounds).toHaveBeenCalledOnce(); + }); + + it("bounds sampling work for enormous drag rectangles", () => { + getElementsInDrag( + { x: -1_000_000, y: -1_000_000, width: 2_000_000, height: 2_000_000 }, + { x: 150, y: 150 }, + () => true, + ); + + expect(vi.mocked(getDeepElementsAtPoint).mock.calls.length).toBeLessThanOrEqual( + DRAG_SELECTION_MAX_TOTAL_SAMPLE_POINTS + 10, + ); + for (const [clientX, clientY] of vi.mocked(getDeepElementsAtPoint).mock.calls) { + expect(clientX).toBeGreaterThanOrEqual(0); + expect(clientX).toBeLessThan(300); + expect(clientY).toBeGreaterThanOrEqual(0); + expect(clientY).toBeLessThan(300); + } + }); + + it("does not hit test an empty drag rectangle", () => { + expect( + getElementsInDrag({ x: 100, y: 100, width: 0, height: 100 }, { x: 100, y: 150 }, () => true), + ).toEqual([]); + expect(getDeepElementsAtPoint).not.toHaveBeenCalled(); + expect(suspendPointerEventsFreeze).toHaveBeenCalledOnce(); + expect(resumePointerEventsFreeze).toHaveBeenCalledOnce(); + }); + + it("restores pointer-event freezing when a sampled hit test throws", () => { + vi.mocked(getDeepElementsAtPoint).mockImplementation(() => { + throw new Error("hit test failed"); + }); + + expect(() => + getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 150, y: 150 }, + () => true, + ), + ).toThrow("hit test failed"); + expect(suspendPointerEventsFreeze).toHaveBeenCalledOnce(); + expect(resumePointerEventsFreeze).toHaveBeenCalledOnce(); + }); + it("ignores viewport-covering candidates", () => { const viewportElement = createElement(); const nearbyElement = createElement(); @@ -92,7 +553,11 @@ describe("getElementsInDrag", () => { ]), ); - const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + const elements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 150, y: 150 }, + () => true, + ); expect(elements).toEqual([nearbyElement]); }); @@ -108,7 +573,11 @@ describe("getElementsInDrag", () => { ]), ); - const elements = getElementsInDrag({ x: 10, y: 10, width: 280, height: 280 }, () => true); + const elements = getElementsInDrag( + { x: 10, y: 10, width: 280, height: 280 }, + { x: 150, y: 150 }, + () => true, + ); expect(elements).toEqual([enclosedElement]); }); @@ -122,7 +591,11 @@ describe("getElementsInDrag", () => { ]), ); - const elements = getElementsInDrag({ x: 50, y: 50, width: 50, height: 50 }, () => true); + const elements = getElementsInDrag( + { x: 50, y: 50, width: 50, height: 50 }, + { x: 75, y: 75 }, + () => true, + ); expect(elements).toEqual([offscreenElement]); }); @@ -138,15 +611,379 @@ describe("getElementsInDrag", () => { ]), ); - const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + const elements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 150, y: 150 }, + () => true, + ); expect(elements).toEqual([coveredElement]); }); - it("prefers the smaller candidate when fallback centers are equal", () => { + it("includes a candidate at the exact coverage threshold", () => { + const candidateElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([candidateElement]); + setElementBounds( + new Map([[candidateElement, { x: 0, y: 0, width: 30, height: 40, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 30, height: 30 }, + { x: 29, y: 29 }, + () => true, + ); + + expect(elements).toEqual([candidateElement]); + }); + + it("does not select empty space inside a wide text element", () => { + const textElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([textElement]); + vi.mocked(getElementTextBounds).mockReturnValue([ + { x: 10, y: 10, width: 100, height: 20, borderRadius: "0px" }, + ]); + setElementBounds( + new Map([[textElement, { x: 10, y: 10, width: 280, height: 20, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 220, y: 10, width: 60, height: 20 }, + { x: 275, y: 20 }, + () => true, + ); + + expect(elements).toEqual([]); + }); + + it("selects a wide text element when the drag covers its painted text", () => { + const textElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([textElement]); + vi.mocked(getElementTextBounds).mockReturnValue([ + { x: 10, y: 10, width: 100, height: 20, borderRadius: "0px" }, + ]); + setElementBounds( + new Map([[textElement, { x: 10, y: 10, width: 280, height: 20, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 10, y: 10, width: 100, height: 20 }, + { x: 105, y: 20 }, + () => true, + ); + + expect(elements).toEqual([textElement]); + }); + + it("selects painted text from multiple exposed wide elements", () => { + const firstTextElement = createElement(); + const secondTextElement = createElement(); + vi.mocked(compareElementDocumentOrder).mockImplementation((firstElement, secondElement) => { + if (firstElement === secondElement) return 0; + return firstElement === firstTextElement ? -1 : 1; + }); + vi.mocked(getDeepElementsAtPoint).mockImplementation((_clientX, clientY) => + clientY < 50 ? [firstTextElement] : [secondTextElement], + ); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === firstTextElement + ? [{ x: 0, y: 10, width: 100, height: 20, borderRadius: "0px" }] + : [{ x: 0, y: 70, width: 100, height: 20, borderRadius: "0px" }], + ); + setElementBounds( + new Map([ + [firstTextElement, { x: 0, y: 0, width: 280, height: 40, borderRadius: "0px" }], + [secondTextElement, { x: 0, y: 60, width: 280, height: 40, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 95, y: 95 }, + () => true, + ); + + expect(elements).toEqual([firstTextElement, secondTextElement]); + }); + + it("keeps wrapped text line gaps out of drag geometry", () => { + const textElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([textElement]); + vi.mocked(getElementTextBounds).mockReturnValue([ + { x: 10, y: 10, width: 100, height: 20, borderRadius: "0px" }, + { x: 10, y: 30, width: 40, height: 20, borderRadius: "0px" }, + ]); + setElementBounds( + new Map([[textElement, { x: 10, y: 10, width: 280, height: 40, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 60, y: 30, width: 40, height: 20 }, + { x: 95, y: 40 }, + () => true, + ); + + expect(elements).toEqual([]); + }); + + it("does not promote small text behind a covered foreground target", () => { + const backgroundTextElement = createElement(); + const foregroundElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([foregroundElement, backgroundTextElement]); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === backgroundTextElement + ? [{ x: 20, y: 20, width: 60, height: 20, borderRadius: "0px" }] + : null, + ); + setElementBounds( + new Map([ + [backgroundTextElement, { x: 0, y: 0, width: 300, height: 100, borderRadius: "0px" }], + [foregroundElement, { x: 40, y: 40, width: 20, height: 20, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 50, y: 50 }, + () => true, + ); + + expect(elements).toEqual([foregroundElement]); + }); + + it("does not promote wide text that is exposed at one sample but covered at another", () => { + const backgroundTextElement = createElement(); + const foregroundElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockImplementation((clientX) => + clientX < 50 ? [foregroundElement, backgroundTextElement] : [backgroundTextElement], + ); + vi.mocked(getElementTextBounds).mockImplementation((element) => + element === backgroundTextElement + ? [{ x: 0, y: 0, width: 100, height: 100, borderRadius: "0px" }] + : null, + ); + setElementBounds( + new Map([ + [backgroundTextElement, { x: 0, y: 0, width: 300, height: 100, borderRadius: "0px" }], + [foregroundElement, { x: 0, y: 0, width: 50, height: 100, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 95, y: 50 }, + () => true, + ); + + expect(elements).toEqual([foregroundElement]); + }); + + it("validates a sampled candidate once across the drag", () => { + const candidateElement = createElement(); + const isValidGrabbableElement = vi.fn(() => true); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([candidateElement]); + setElementBounds( + new Map([[candidateElement, { x: 0, y: 0, width: 100, height: 100, borderRadius: "0px" }]]), + ); + + getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 95, y: 95 }, + isValidGrabbableElement, + ); + + expect(isValidGrabbableElement).toHaveBeenCalledOnce(); + }); + + it("prefers a text child over its text-flow parent for a partial drag", () => { + const labelElement = createElement(); + const containerElement = createElement([labelElement]); + const sharedTextBounds = [{ x: 150, y: 110, width: 110, height: 30, borderRadius: "0px" }]; + vi.mocked(getDeepElementsAtPoint).mockReturnValue([containerElement]); + vi.mocked(getLocalContentElementAtPoint).mockReturnValue(labelElement); + vi.mocked(getElementTextBounds).mockReturnValue(sharedTextBounds); + setElementBounds( + new Map([ + [containerElement, { x: 40, y: 80, width: 220, height: 80, borderRadius: "0px" }], + [labelElement, { x: 150, y: 110, width: 120, height: 30, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 100, y: 90, width: 90, height: 35 }, + { x: 190, y: 125 }, + () => true, + ); + + expect(elements).toEqual([labelElement]); + }); + + it("excludes a candidate that only touches the drag edge", () => { + const candidateElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([candidateElement]); + setElementBounds( + new Map([[candidateElement, { x: 100, y: 0, width: 50, height: 100, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 99, y: 50 }, + () => true, + ); + + expect(elements).toEqual([]); + expect(getElementTextBounds).not.toHaveBeenCalled(); + }); + + it("returns all covered candidates in document order", () => { + const laterElement = createElement(); + const earlierElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([laterElement, earlierElement]); + vi.mocked(compareElementDocumentOrder).mockImplementation((leftElement, rightElement) => + leftElement === earlierElement && rightElement === laterElement ? -1 : 1, + ); + setElementBounds( + new Map([ + [laterElement, { x: 60, y: 60, width: 20, height: 20, borderRadius: "0px" }], + [earlierElement, { x: 20, y: 20, width: 20, height: 20, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 75, y: 75 }, + () => true, + ); + + expect(elements).toEqual([earlierElement, laterElement]); + }); + + it("keeps the ancestor when ordinary nested candidates both qualify", () => { + const childElement = createElement(); + const parentElement = createElement([childElement]); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([childElement, parentElement]); + vi.mocked(getComposedParentElement).mockImplementation((element) => + element === childElement ? parentElement : null, + ); + setElementBounds( + new Map([ + [parentElement, { x: 0, y: 0, width: 100, height: 100, borderRadius: "0px" }], + [childElement, { x: 10, y: 10, width: 20, height: 20, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 20, y: 20 }, + () => true, + ); + + expect(elements).toEqual([parentElement]); + }); + + it("keeps the inner candidate instead of its open shadow host", () => { + const shadowElement = createElement(); + const shadowHostElement = createElement(); + const shadowRoot = Object.assign(Object.create(null), { host: shadowHostElement }); + Object.assign(shadowElement, { getRootNode: () => shadowRoot }); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([shadowElement, shadowHostElement]); + vi.mocked(compareElementDocumentOrder).mockImplementation((leftElement, rightElement) => + leftElement === shadowHostElement && rightElement === shadowElement ? -1 : 1, + ); + vi.mocked(getComposedParentElement).mockImplementation((element) => + element === shadowElement ? shadowHostElement : null, + ); + vi.mocked(isShadowRoot).mockImplementation((rootNode) => rootNode === shadowRoot); + setElementBounds( + new Map([ + [shadowHostElement, { x: 0, y: 0, width: 100, height: 100, borderRadius: "0px" }], + [shadowElement, { x: 10, y: 10, width: 20, height: 20, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 20, y: 20 }, + () => true, + ); + + expect(elements).toEqual([shadowElement]); + }); + + it("skips accessible iframe shells while keeping their deep content", () => { + const iframeElement = createElement(); + const frameContentElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([frameContentElement, iframeElement]); + vi.mocked(isIframeElement).mockImplementation((element) => element === iframeElement); + vi.mocked(getAccessibleIframeDocument).mockImplementation((element) => + element === iframeElement ? Object.create(null) : null, + ); + setElementBounds( + new Map([ + [iframeElement, { x: 0, y: 0, width: 100, height: 100, borderRadius: "0px" }], + [frameContentElement, { x: 10, y: 10, width: 20, height: 20, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 20, y: 20 }, + () => true, + ); + + expect(elements).toEqual([frameContentElement]); + }); + + it("keeps an inaccessible iframe as a selectable fallback", () => { + const iframeElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([iframeElement]); + vi.mocked(isIframeElement).mockReturnValue(true); + setElementBounds( + new Map([[iframeElement, { x: 0, y: 0, width: 100, height: 100, borderRadius: "0px" }]]), + ); + + const elements = getElementsInDrag( + { x: 25, y: 25, width: 50, height: 50 }, + { x: 50, y: 50 }, + () => true, + ); + + expect(elements).toEqual([iframeElement]); + }); + + it("filters roots, out-of-scope layers, and invalid overlays before fallback ranking", () => { + const rootElement = createElement(); + const outOfScopeElement = createElement(); + const invalidOverlayElement = createElement(); + const targetElement = createElement(); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([ + rootElement, + outOfScopeElement, + invalidOverlayElement, + targetElement, + ]); + vi.mocked(isRootElement).mockImplementation((element) => element === rootElement); + vi.mocked(isWithinScope).mockImplementation((element) => element !== outOfScopeElement); + setElementBounds( + new Map([ + [invalidOverlayElement, { x: 100, y: 100, width: 10, height: 10, borderRadius: "0px" }], + [targetElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }], + ]), + ); + + const elements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 150, y: 150 }, + (element) => element !== invalidOverlayElement, + ); + + expect(elements).toEqual([targetElement]); + expect(createElementBounds).toHaveBeenCalledOnce(); + }); + + it("prefers the topmost candidate when multiple candidates contain the drag endpoint", () => { const wrapperElement = createElement(); const nestedElement = createElement(); - vi.mocked(getDeepElementsAtPoint).mockReturnValue([wrapperElement, nestedElement]); + vi.mocked(getDeepElementsAtPoint).mockReturnValue([nestedElement, wrapperElement]); setElementBounds( new Map([ [wrapperElement, { x: 50, y: 50, width: 200, height: 200, borderRadius: "0px" }], @@ -154,7 +991,11 @@ describe("getElementsInDrag", () => { ]), ); - const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + const elements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 150, y: 150 }, + () => true, + ); expect(elements).toEqual([nestedElement]); }); @@ -169,7 +1010,11 @@ describe("getElementsInDrag", () => { ]), ); - const elements = getElementsInDrag({ x: 125, y: 125, width: 50, height: 50 }, () => true); + const elements = getElementsInDrag( + { x: 125, y: 125, width: 50, height: 50 }, + { x: 150, y: 150 }, + () => true, + ); expect(elements).toEqual([candidateElement]); }); @@ -183,7 +1028,11 @@ describe("getElementsInDrag", () => { ]), ); - const elements = getElementsInDrag({ x: 100, y: 100, width: 100, height: 100 }, () => true); + const elements = getElementsInDrag( + { x: 100, y: 100, width: 100, height: 100 }, + { x: 150, y: 150 }, + () => true, + ); expect(elements).toEqual([]); }); diff --git a/packages/react-grab/tests/get-local-content-element-at-point.test.ts b/packages/react-grab/tests/get-local-content-element-at-point.test.ts new file mode 100644 index 000000000..b5a9981cb --- /dev/null +++ b/packages/react-grab/tests/get-local-content-element-at-point.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { convertTopWindowPositionToClient } from "../src/utils/convert-top-window-position-to-client.js"; +import { getLocalContentElementAtPoint } from "../src/utils/get-local-content-element-at-point.js"; +import { isShadowRoot } from "../src/utils/is-shadow-root.js"; + +vi.mock("../src/utils/convert-top-window-position-to-client.js", () => ({ + convertTopWindowPositionToClient: vi.fn((_ownerWindow, clientX, clientY) => ({ + x: clientX, + y: clientY, + })), +})); + +vi.mock("../src/utils/is-shadow-root.js", () => ({ + isShadowRoot: vi.fn(() => false), +})); + +const topWindow: Window = Object.assign(Object.create(null), { + getComputedStyle: vi.fn(() => ({ pointerEvents: "none" })), +}); + +const createContentHit = ( + hitLocalName: string, + hitNamespace: string, + contentLocalName: string, + contentNamespace: string, +): { + contentElement: Element; + hitElement: Element; + targetDocument: Document; +} => { + const contentElement: Element = Object.assign(Object.create(null), { + localName: contentLocalName, + namespaceURI: contentNamespace, + }); + const caretNode: Node = Object.assign(Object.create(null), { + nodeType: 3, + parentElement: contentElement, + }); + const targetDocument: Document = Object.assign(Object.create(null), { + defaultView: topWindow, + caretPositionFromPoint: vi.fn(() => + Object.assign(Object.create(null), { offsetNode: caretNode }), + ), + caretRangeFromPoint: vi.fn(() => null), + }); + Object.assign(contentElement, { ownerDocument: targetDocument }); + const hitElement: Element = Object.assign(Object.create(null), { + contains: vi.fn((element) => element === contentElement), + getRootNode: vi.fn(() => targetDocument), + localName: hitLocalName, + namespaceURI: hitNamespace, + ownerDocument: targetDocument, + parentElement: null, + tagName: hitLocalName.toUpperCase(), + }); + return { contentElement, hitElement, targetDocument }; +}; + +beforeEach(() => { + vi.stubGlobal("Node", { ELEMENT_NODE: 1 }); + vi.stubGlobal("window", topWindow); + vi.clearAllMocks(); + vi.mocked(isShadowRoot).mockReturnValue(false); + vi.mocked(topWindow.getComputedStyle).mockReturnValue( + Object.assign(Object.create(null), { pointerEvents: "none" }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("getLocalContentElementAtPoint", () => { + it("refines a native container hit to its text-bearing descendant", () => { + const { contentElement, hitElement } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBe(contentElement); + expect(convertTopWindowPositionToClient).toHaveBeenCalledWith(topWindow, 15, 20); + }); + + it("does not replace an HTML hit with interactive nested text", () => { + const { hitElement } = createContentHit( + "li", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + vi.mocked(topWindow.getComputedStyle).mockReturnValue( + Object.assign(Object.create(null), { pointerEvents: "auto" }), + ); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBeNull(); + }); + + it("accepts a caret API element node directly", () => { + const { contentElement, hitElement, targetDocument } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + Object.assign(contentElement, { nodeType: Node.ELEMENT_NODE }); + vi.mocked(targetDocument.caretPositionFromPoint).mockReturnValue( + Object.assign(Object.create(null), { offsetNode: contentElement }), + ); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBe(contentElement); + }); + + it("returns no refinement when the owner document has no window", () => { + const { hitElement, targetDocument } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + Object.assign(targetDocument, { defaultView: null }); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBeNull(); + expect(convertTopWindowPositionToClient).not.toHaveBeenCalled(); + expect(targetDocument.caretPositionFromPoint).not.toHaveBeenCalled(); + }); + + it("returns no refinement when neither caret API is available", () => { + const { hitElement, targetDocument } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + Object.assign(targetDocument, { + caretPositionFromPoint: undefined, + caretRangeFromPoint: undefined, + }); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBeNull(); + }); + + it("falls through to a caret range when a caret position misses", () => { + const { contentElement, hitElement, targetDocument } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + const caretNode: Node = Object.assign(Object.create(null), { + nodeType: 3, + parentElement: contentElement, + }); + vi.mocked(targetDocument.caretPositionFromPoint).mockReturnValue(null); + vi.mocked(targetDocument.caretRangeFromPoint).mockReturnValue( + Object.assign(Object.create(null), { startContainer: caretNode }), + ); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBe(contentElement); + }); + + it("does not refine when the caret resolves to the native hit itself", () => { + const { hitElement, targetDocument } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + Object.assign(hitElement, { nodeType: Node.ELEMENT_NODE }); + vi.mocked(targetDocument.caretPositionFromPoint).mockReturnValue( + Object.assign(Object.create(null), { offsetNode: hitElement }), + ); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBeNull(); + }); + + it("does not refine an orphaned caret text node", () => { + const { hitElement, targetDocument } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + const orphanedTextNode: Node = Object.assign(Object.create(null), { + nodeType: 3, + parentElement: null, + }); + vi.mocked(targetDocument.caretPositionFromPoint).mockReturnValue( + Object.assign(Object.create(null), { offsetNode: orphanedTextNode }), + ); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBeNull(); + }); + + it("allows SVG text elsewhere in the same SVG render island", () => { + const { contentElement, hitElement } = createContentHit( + "rect", + "http://www.w3.org/2000/svg", + "text", + "http://www.w3.org/2000/svg", + ); + const svgElement: Element = Object.assign(Object.create(null), { + contains: vi.fn((element) => element === contentElement), + localName: "svg", + namespaceURI: "http://www.w3.org/2000/svg", + parentElement: null, + }); + Object.assign(hitElement, { parentElement: svgElement }); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBe(contentElement); + }); + + it("does not leave a nested SVG root", () => { + const { contentElement, hitElement } = createContentHit( + "svg", + "http://www.w3.org/2000/svg", + "text", + "http://www.w3.org/2000/svg", + ); + const outerSvgElement: Element = Object.assign(Object.create(null), { + contains: vi.fn(() => false), + localName: "svg", + namespaceURI: "http://www.w3.org/2000/svg", + parentElement: null, + }); + Object.assign(hitElement, { parentElement: outerSvgElement }); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBe(contentElement); + expect(outerSvgElement.contains).not.toHaveBeenCalled(); + }); + + it("does not leave a nested SVG root when the native hit is its descendant", () => { + const { hitElement } = createContentHit( + "rect", + "http://www.w3.org/2000/svg", + "text", + "http://www.w3.org/2000/svg", + ); + const nestedSvgElement: Element = Object.assign(Object.create(null), { + contains: vi.fn(() => false), + localName: "svg", + namespaceURI: "http://www.w3.org/2000/svg", + parentElement: null, + }); + const outerSvgElement: Element = Object.assign(Object.create(null), { + contains: vi.fn(() => true), + localName: "svg", + namespaceURI: "http://www.w3.org/2000/svg", + parentElement: null, + }); + Object.assign(nestedSvgElement, { parentElement: outerSvgElement }); + Object.assign(hitElement, { parentElement: nestedSvgElement }); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBeNull(); + expect(nestedSvgElement.contains).toHaveBeenCalledOnce(); + expect(outerSvgElement.contains).not.toHaveBeenCalled(); + }); + + it("does not compute pointer events for SVG content", () => { + const { contentElement, hitElement } = createContentHit( + "svg", + "http://www.w3.org/2000/svg", + "text", + "http://www.w3.org/2000/svg", + ); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBe(contentElement); + expect(topWindow.getComputedStyle).not.toHaveBeenCalled(); + }); + + it("passes the local shadow root to the standard caret API", () => { + const { contentElement, hitElement, targetDocument } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + const shadowRoot: ShadowRoot = Object.assign(Object.create(null), {}); + Object.assign(hitElement, { getRootNode: () => shadowRoot }); + vi.mocked(isShadowRoot).mockImplementation((rootNode) => rootNode === shadowRoot); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBe(contentElement); + expect(targetDocument.caretPositionFromPoint).toHaveBeenCalledWith(15, 20, { + shadowRoots: [shadowRoot], + }); + }); + + it("does not refine to unrelated content outside the local hit", () => { + const { hitElement } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + vi.mocked(hitElement.contains).mockReturnValue(false); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBeNull(); + }); + + it("does not use document roots as unbounded refinement islands", () => { + const { hitElement, targetDocument } = createContentHit( + "body", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBeNull(); + expect(targetDocument.caretPositionFromPoint).not.toHaveBeenCalled(); + }); + + it("falls back to WebKit caret ranges", () => { + const { contentElement, hitElement, targetDocument } = createContentHit( + "button", + "http://www.w3.org/1999/xhtml", + "span", + "http://www.w3.org/1999/xhtml", + ); + const caretNode: Node = Object.assign(Object.create(null), { + nodeType: 3, + parentElement: contentElement, + }); + Object.assign(targetDocument, { + caretPositionFromPoint: undefined, + caretRangeFromPoint: vi.fn(() => + Object.assign(Object.create(null), { startContainer: caretNode }), + ), + }); + + expect(getLocalContentElementAtPoint(hitElement, 15, 20)).toBe(contentElement); + }); +}); diff --git a/packages/react-grab/tests/has-element-box-paint.test.ts b/packages/react-grab/tests/has-element-box-paint.test.ts new file mode 100644 index 000000000..76e2351c7 --- /dev/null +++ b/packages/react-grab/tests/has-element-box-paint.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; +import { hasElementBoxPaint } from "../src/utils/has-element-box-paint.js"; + +const createComputedStyle = (styleOverrides: object = {}) => + Object.assign( + { + backgroundClip: "border-box", + backgroundColor: "transparent", + backgroundImage: "none", + borderBottomStyle: "none", + borderBottomWidth: "0px", + borderLeftStyle: "none", + borderLeftWidth: "0px", + borderRightStyle: "none", + borderRightWidth: "0px", + borderTopStyle: "none", + borderTopWidth: "0px", + boxShadow: "none", + outlineStyle: "none", + }, + styleOverrides, + ); + +const createElement = (styleOverrides: object = {}): Element => + Object.assign(Object.create(null), { + ownerDocument: { + defaultView: { + getComputedStyle: () => createComputedStyle(styleOverrides), + }, + }, + }); + +describe("hasElementBoxPaint", () => { + it.each([ + "transparent", + "rgba(0, 0, 0, 0)", + "rgba(255, 0, 0, 0.000)", + "rgb(255 0 0 / 0)", + "color(display-p3 1 0 0 / 0%)", + "oklch(62% 0.2 20 / 0)", + ])("treats a %s background as unpainted", (backgroundColor) => { + expect(hasElementBoxPaint(createElement({ backgroundColor }))).toBe(false); + }); + + it.each([ + { backgroundColor: "rgb(34, 34, 34)" }, + { backgroundColor: "rgba(34, 34, 34, 0.01)" }, + { backgroundImage: "linear-gradient(red, blue)" }, + { borderTopStyle: "solid", borderTopWidth: "1px" }, + { boxShadow: "rgb(0, 0, 0) 0px 1px 2px" }, + { outlineStyle: "solid" }, + ])("treats box paint as full-box geometry", (styleOverrides) => { + expect(hasElementBoxPaint(createElement(styleOverrides))).toBe(true); + }); + + it("does not expand a background clipped to glyphs", () => { + const element = createElement({ + backgroundClip: "text", + backgroundColor: "rgb(34, 34, 34)", + backgroundImage: "linear-gradient(red, blue)", + }); + + expect(hasElementBoxPaint(element)).toBe(false); + }); +}); diff --git a/packages/react-grab/tests/runtime-mode.test.ts b/packages/react-grab/tests/runtime-mode.test.ts new file mode 100644 index 000000000..dfb85fb87 --- /dev/null +++ b/packages/react-grab/tests/runtime-mode.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { registerElementAdapter } from "../src/core/element-adapter.js"; +import { isWithinScope, setScopeContainer } from "../src/utils/runtime-mode.js"; + +const createElement = (parentElement: Element | null = null): HTMLElement => + Object.assign(Object.create(null), { + assignedSlot: null, + getRootNode: () => null, + parentElement, + }); + +afterEach(() => setScopeContainer(null)); + +describe("runtime scope", () => { + it("uses an adapted element's host when checking scope", () => { + const scopeContainer = createElement(); + const canvasElement = createElement(scopeContainer); + const syntheticElement = createElement(); + registerElementAdapter(syntheticElement, { + getBounds: () => ({ borderRadius: "0px", height: 1, width: 1, x: 0, y: 0 }), + getFiber: () => null, + getPreview: () => "", + getSelector: () => "", + getTagName: () => "mesh", + hostElement: canvasElement, + isConnected: () => true, + supportsDomEditing: false, + }); + setScopeContainer(scopeContainer); + + expect(isWithinScope(syntheticElement)).toBe(true); + }); +}); diff --git a/packages/react-grab/tests/three-selection.test.ts b/packages/react-grab/tests/three-selection.test.ts new file mode 100644 index 000000000..7bbaf4c2f --- /dev/null +++ b/packages/react-grab/tests/three-selection.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + getThreeSelectionElements, + handleReactThreeFiberRootCommit, + resolveThreeElementAtPoint, +} from "../src/core/three-selection.js"; + +vi.mock("bippy", () => ({ + getFiberFromHostInstance: vi.fn(() => Object.create(null)), + getLatestFiber: vi.fn((fiber) => fiber), + instrument: vi.fn(), +})); + +const createMatrix = () => { + const matrix = Object.create(null); + Object.assign(matrix, { + clone: () => createMatrix(), + premultiply: () => matrix, + }); + return matrix; +}; + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Three.js drag selection", () => { + it("replaces a large instanced mesh aggregate with the endpoint instance", () => { + const ownerWindow = Object.assign(Object.create(null), { + getComputedStyle: () => ({ borderRadius: "0px" }), + }); + const ownerDocument = Object.assign(Object.create(null), { + defaultView: ownerWindow, + createElement: (tagName: string) => + Object.assign(Object.create(null), { + getBoundingClientRect: () => ({ height: 0, left: 0, top: 0, width: 0 }), + ownerDocument, + tagName: tagName.toUpperCase(), + }), + }); + const canvasElement = Object.assign(Object.create(null), { + getBoundingClientRect: () => ({ height: 100, left: 0, top: 0, width: 100 }), + getContext: () => null, + isConnected: true, + ownerDocument, + tagName: "CANVAS", + }); + vi.stubGlobal("window", ownerWindow); + + const scene = Object.assign(Object.create(null), { + children: [], + isObject3D: true, + isScene: true, + matrixWorld: createMatrix(), + name: "", + parent: null, + type: "Scene", + updateWorldMatrix: () => undefined, + uuid: "scene", + visible: true, + }); + const instancedMesh = Object.assign(Object.create(null), { + count: 600, + geometry: { boundingBox: null, computeBoundingBox: () => undefined }, + getMatrixAt: () => undefined, + isInstancedMesh: true, + isObject3D: true, + matrixWorld: createMatrix(), + name: "instances", + parent: scene, + type: "InstancedMesh", + updateWorldMatrix: () => undefined, + uuid: "instances", + visible: true, + }); + Object.assign(instancedMesh, { + __r3f: { + eventCount: 1, + object: instancedMesh, + props: {}, + type: "instancedMesh", + }, + }); + scene.children.push(instancedMesh); + + const rootState = { + camera: { isCamera: true }, + gl: { domElement: canvasElement }, + pointer: { set: () => undefined }, + raycaster: { + intersectObjects: () => [{ instanceId: 513, object: instancedMesh }], + setFromCamera: () => undefined, + }, + scene, + }; + const root = { + current: { + child: Object.create(null), + stateNode: { + containerInfo: { + getState: () => rootState, + }, + }, + }, + }; + + handleReactThreeFiberRootCommit(root); + const endpointElement = resolveThreeElementAtPoint(canvasElement, 50, 50); + const aggregateElements = getThreeSelectionElements(canvasElement); + const endpointElements = getThreeSelectionElements(canvasElement, endpointElement); + + expect(endpointElement).not.toBe(canvasElement); + expect(aggregateElements).toHaveLength(1); + expect(aggregateElements[0]).not.toBe(endpointElement); + expect(endpointElements).toEqual([endpointElement]); + + Reflect.set(root.current, "child", null); + handleReactThreeFiberRootCommit(root); + }); +});