diff --git a/apps/e2e-app/src/App.tsx b/apps/e2e-app/src/App.tsx index 95200cc2a..b5a37932c 100644 --- a/apps/e2e-app/src/App.tsx +++ b/apps/e2e-app/src/App.tsx @@ -585,6 +585,242 @@ const PointerUpModalSection = () => { ); }; +const OverflowClippingSection = () => { + return ( +
+

Overflow Clipping

+
+
+
+ Wide clipped content +
+
+ Visible child +
+
+ +
+
+ Scrollable inner content +
+
+ +
+
+
+ Nested clipped content +
+
+
+
+
+ ); +}; + +const ContainPaintSection = () => { + return ( +
+

CSS Containment

+
+
+
+ Paint-contained clipped +
+
+ +
+
+ Strict contained +
+
+ +
+
+ Content contained +
+
+
+
+ ); +}; + +const StackingOrderSection = () => { + return ( +
+

Stacking Order

+
+
+ Bottom (z-index: 1) +
+
+ Middle (z-index: 2) +
+
+ Top (z-index: 3) +
+
+ +
+
+ Large element behind +
+
+ Small in front +
+
+
+ ); +}; + +const DecorativeOverlaySection = () => { + return ( +
+

Decorative Overlays

+
+
+ This content should be selectable +
+
+
+ +
+

+ Text content under a positioned empty div +

+
+
+
+ ); +}; + +const InlineElementsSection = () => { + return ( +
+

Inline Elements

+

+ This paragraph has an inline span and{" "} + + an inline link + {" "} + and emphasized text and{" "} + strong text inside it. +

+

+ + inline code element + +

+
+ ); +}; + +const TransformStackingSection = () => { + return ( +
+

Transform & Opacity Stacking

+
+
+ Transformed element (creates stacking context) +
+ +
+ Opacity element (creates stacking context) +
+ +
+
+ Behind +
+
+ Front (transform) +
+
+
+
+ ); +}; + const HiddenToggleSection = () => { const [isVisible, setIsVisible] = useState(true); const elementRef = useRef(null); @@ -648,6 +884,18 @@ export default function App() { + + + + + + + + + + + +
await reactGrab.typeInInput("comment"); await reactGrab.submitInput(); await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).toBeTruthy(); - // HACK: Wait for copy feedback transition and comments item addition - await reactGrab.page.waitForTimeout(300); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true); }; test.describe("Toolbar Copy All Button", () => { @@ -24,18 +23,18 @@ test.describe("Toolbar Copy All Button", () => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickCommentsButton(); - await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(true); }); test("should hide when comments dropdown is closed", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickCommentsButton(); - await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(true); await reactGrab.clickCommentsButton(); - await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(false); }); }); @@ -60,7 +59,7 @@ test.describe("Toolbar Copy All Button", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); }); }); @@ -74,7 +73,7 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); }); @@ -84,7 +83,7 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickCommentsCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); }); }); @@ -98,13 +97,13 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.confirmClearCommentsPrompt(); await reactGrab.page.waitForTimeout(200); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false); }); test("should clear comments when confirmed via Enter key", async ({ reactGrab }) => { @@ -114,17 +113,17 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.pressEnter(); await reactGrab.page.waitForTimeout(200); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false); }); test("should dismiss the prompt after confirming", async ({ reactGrab }) => { @@ -134,13 +133,13 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.confirmClearCommentsPrompt(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); }); @@ -153,12 +152,12 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.confirmClearCommentsPrompt(); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false); await copyElement(reactGrab, "li:last-child"); @@ -166,10 +165,10 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false); }); }); @@ -181,17 +180,17 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.cancelClearCommentsPrompt(); await reactGrab.page.waitForTimeout(200); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true); }); test("should dismiss prompt when cancelled via Escape key", async ({ reactGrab }) => { @@ -201,17 +200,17 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(200); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true); }); }); @@ -223,7 +222,7 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.activate(); @@ -232,7 +231,7 @@ test.describe("Clear History Prompt", () => { await reactGrab.rightClickElement("li:first-child"); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); }); @@ -243,14 +242,14 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.clickToolbarEnabled(); await reactGrab.page.waitForTimeout(200); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); }); }); diff --git a/packages/react-grab/e2e/context-menu.spec.ts b/packages/react-grab/e2e/context-menu.spec.ts index 603595d1f..284b93fd4 100644 --- a/packages/react-grab/e2e/context-menu.spec.ts +++ b/packages/react-grab/e2e/context-menu.spec.ts @@ -46,7 +46,7 @@ test.describe("Context Menu", () => { const element = reactGrab.page.locator("li").first(); await element.click({ button: "right", force: true }); - await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(true); await reactGrab.page.keyboard.up("c"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); @@ -118,10 +118,8 @@ test.describe("Context Menu", () => { expect(isVisibleBefore).toBe(true); await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(200); - const isVisibleAfter = await reactGrab.isContextMenuVisible(); - expect(isVisibleAfter).toBe(false); + await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(false); }); test("should dismiss context menu when clicking outside", async ({ reactGrab }) => { @@ -134,10 +132,8 @@ test.describe("Context Menu", () => { expect(isVisibleBefore).toBe(true); await reactGrab.page.mouse.click(10, 10); - await reactGrab.page.waitForTimeout(200); - const isVisibleAfter = await reactGrab.isContextMenuVisible(); - expect(isVisibleAfter).toBe(false); + await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(false); }); test("should dismiss context menu after Copy action", async ({ reactGrab }) => { diff --git a/packages/react-grab/e2e/element-detection.spec.ts b/packages/react-grab/e2e/element-detection.spec.ts new file mode 100644 index 000000000..db657cf3b --- /dev/null +++ b/packages/react-grab/e2e/element-detection.spec.ts @@ -0,0 +1,939 @@ +import { test, expect } from "./fixtures.js"; + +const getTargetTestId = async (page: import("@playwright/test").Page): Promise => { + return page.evaluate(() => { + const api = ( + window as { + __REACT_GRAB__?: { + getState: () => { targetElement: Element | null }; + }; + } + ).__REACT_GRAB__; + return api?.getState()?.targetElement?.getAttribute("data-testid") ?? null; + }); +}; + +const getTargetTagName = async (page: import("@playwright/test").Page): Promise => { + return page.evaluate(() => { + const api = ( + window as { + __REACT_GRAB__?: { + getState: () => { targetElement: Element | null }; + }; + } + ).__REACT_GRAB__; + return api?.getState()?.targetElement?.tagName?.toLowerCase() ?? null; + }); +}; + +test.describe("Element Detection", () => { + test.describe("Overflow Clipping", () => { + test("should select visible child inside overflow:hidden container", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='overflow-clipping-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='overflow-visible-child']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("overflow-visible-child"); + }); + + test("should select clipped element when hovering its visible region", async ({ + reactGrab, + }) => { + const container = reactGrab.page.locator("[data-testid='overflow-hidden-container']"); + await container.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const containerBox = await container.boundingBox(); + if (!containerBox) throw new Error("Could not get container bounds"); + + await reactGrab.page.mouse.move(containerBox.x + 10, containerBox.y + 10); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + + test("should not select clipped element at position outside container", async ({ + reactGrab, + }) => { + const container = reactGrab.page.locator("[data-testid='overflow-hidden-container']"); + await container.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const containerBox = await container.boundingBox(); + if (!containerBox) throw new Error("Could not get container bounds"); + + await reactGrab.page.mouse.move(containerBox.x + containerBox.width + 20, containerBox.y + 5); + await reactGrab.page.waitForTimeout(200); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).not.toBe("overflow-clipped-wide"); + }); + + test("should handle overflow:auto scrollable containers", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='overflow-clipping-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='overflow-auto-inner']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("overflow-auto-inner"); + }); + + test("should handle nested overflow:hidden containers", async ({ reactGrab }) => { + const innerContainer = reactGrab.page.locator("[data-testid='overflow-nested-inner']"); + await innerContainer.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const innerBox = await innerContainer.boundingBox(); + if (!innerBox) throw new Error("Could not get inner container bounds"); + + await reactGrab.page.mouse.move(innerBox.x + 5, innerBox.y + 5); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + + test("should select elements inside scroll-container", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='scrollable-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='scroll-item-1']"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + + await reactGrab.clickElement("[data-testid='scroll-item-1']"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("Scrollable Item 1"); + }); + + test("should not select scroll items that are scrolled out of view", async ({ reactGrab }) => { + const container = reactGrab.page.locator("[data-testid='scroll-container']"); + await container.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const containerBox = await container.boundingBox(); + if (!containerBox) throw new Error("Could not get scroll container bounds"); + + await reactGrab.page.mouse.move(containerBox.x + containerBox.width / 2, containerBox.y + 5); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).not.toBe("scroll-item-50"); + }); + }); + + test.describe("CSS Containment", () => { + test("should select child inside contain:paint container at visible region", async ({ + reactGrab, + }) => { + const container = reactGrab.page.locator("[data-testid='contain-paint-container']"); + await container.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const containerBox = await container.boundingBox(); + if (!containerBox) throw new Error("Could not get container bounds"); + + await reactGrab.page.mouse.move(containerBox.x + 10, containerBox.y + 10); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + + test("should not select paint-contained element outside container bounds", async ({ + reactGrab, + }) => { + const container = reactGrab.page.locator("[data-testid='contain-paint-container']"); + await container.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const containerBox = await container.boundingBox(); + if (!containerBox) throw new Error("Could not get container bounds"); + + await reactGrab.page.mouse.move( + containerBox.x + containerBox.width + 30, + containerBox.y + 10, + ); + await reactGrab.page.waitForTimeout(200); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).not.toBe("contain-paint-clipped"); + }); + + test("should select child inside contain:strict container", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='contain-paint-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='contain-strict-child']"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + + test("should select child inside contain:content container", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='contain-paint-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='contain-content-child']"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + }); + + test.describe("Stacking Order", () => { + test("should select topmost z-indexed element at overlap point", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='stacking-order-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const topElement = reactGrab.page.locator("[data-testid='stacking-top']"); + const topBox = await topElement.boundingBox(); + if (!topBox) throw new Error("Could not get top element bounds"); + + await reactGrab.page.mouse.move(topBox.x + 10, topBox.y + 10); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("stacking-top"); + }); + + test("should select middle element where top element does not overlap", async ({ + reactGrab, + }) => { + const section = reactGrab.page.locator("[data-testid='stacking-order-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const middleElement = reactGrab.page.locator("[data-testid='stacking-middle']"); + const topElement = reactGrab.page.locator("[data-testid='stacking-top']"); + const middleBox = await middleElement.boundingBox(); + const topBox = await topElement.boundingBox(); + if (!middleBox || !topBox) throw new Error("Could not get element bounds"); + + await reactGrab.page.mouse.move(middleBox.x + 5, middleBox.y + 5); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("stacking-middle"); + }); + + test("should select bottom element in non-overlapping region", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='stacking-order-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const bottomElement = reactGrab.page.locator("[data-testid='stacking-bottom']"); + const bottomBox = await bottomElement.boundingBox(); + if (!bottomBox) throw new Error("Could not get bottom element bounds"); + + await reactGrab.page.mouse.move(bottomBox.x + 5, bottomBox.y + 5); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("stacking-bottom"); + }); + + test("should prefer smaller front element over larger background", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='stacking-order-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const frontElement = reactGrab.page.locator("[data-testid='stacking-small-front']"); + const frontBox = await frontElement.boundingBox(); + if (!frontBox) throw new Error("Could not get front element bounds"); + + await reactGrab.page.mouse.move( + frontBox.x + frontBox.width / 2, + frontBox.y + frontBox.height / 2, + ); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("stacking-small-front"); + }); + + test("should copy correct content from stacked elements", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='stacking-order-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='stacking-top']"); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement("[data-testid='stacking-top']"); + + await expect.poll(() => reactGrab.getClipboardContent()).toContain("Top"); + }); + }); + + test.describe("Inline Elements", () => { + test("should select inline span element", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='inline-elements-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='inline-span']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("inline-span"); + }); + + test("should select inline link element", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='inline-elements-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='inline-link']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("inline-link"); + }); + + test("should select inline em element", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='inline-elements-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='inline-em']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("inline-em"); + }); + + test("should select inline strong element", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='inline-elements-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='inline-strong']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("inline-strong"); + }); + + test("should select inline code element", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='inline-elements-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='inline-code']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("inline-code"); + }); + + test("should copy inline span content correctly", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='inline-elements-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='inline-span']"); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement("[data-testid='inline-span']"); + + await expect.poll(() => reactGrab.getClipboardContent()).toContain("inline span"); + }); + + test("should show selection box with correct bounds for inline elements", async ({ + reactGrab, + }) => { + const section = reactGrab.page.locator("[data-testid='inline-elements-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='inline-span']"); + await reactGrab.waitForSelectionBox(); + + const bounds = await reactGrab.getSelectionBoxBounds(); + expect(bounds).not.toBeNull(); + expect(bounds!.width).toBeGreaterThan(0); + expect(bounds!.height).toBeGreaterThan(0); + }); + }); + + test.describe("Decorative Overlays", () => { + test("should select content element, not the empty overlay above it", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='decorative-overlay-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='decorative-content']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("decorative-content"); + }); + + test("should select text content under positioned empty div", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='decorative-overlay-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='decorative-text-content']"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.clickElement("[data-testid='decorative-text-content']"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("Text content under"); + }); + + test("should copy content from element under decorative overlay", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='decorative-overlay-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='decorative-content']"); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement("[data-testid='decorative-content']"); + + await expect.poll(() => reactGrab.getClipboardContent()).toContain("should be selectable"); + }); + }); + + test.describe("Transform and Opacity Stacking", () => { + test("should select element with CSS transform", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='transform-stacking-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='transform-element']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("transform-element"); + }); + + test("should select element with sub-1 opacity", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='transform-stacking-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='opacity-element']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("opacity-element"); + }); + + test("should prefer front transformed element at overlap", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='transform-stacking-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const frontElement = reactGrab.page.locator("[data-testid='transform-front']"); + const frontBox = await frontElement.boundingBox(); + if (!frontBox) throw new Error("Could not get front element bounds"); + + await reactGrab.page.mouse.move( + frontBox.x + frontBox.width / 2, + frontBox.y + frontBox.height / 2, + ); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("transform-front"); + }); + }); + + test.describe("Fixed Position Elements", () => { + test("should select fixed-position corner element", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='edge-top-left']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("edge-top-left"); + }); + + test("should select different fixed-position corners", async ({ reactGrab }) => { + await reactGrab.activate(); + + await reactGrab.hoverElement("[data-testid='edge-top-right']"); + await reactGrab.waitForSelectionBox(); + const topRightId = await getTargetTestId(reactGrab.page); + expect(topRightId).toBe("edge-top-right"); + + await reactGrab.hoverElement("[data-testid='edge-bottom-left']"); + await reactGrab.waitForSelectionBox(); + const bottomLeftId = await getTargetTestId(reactGrab.page); + expect(bottomLeftId).toBe("edge-bottom-left"); + }); + + test("should copy fixed-position element content", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='edge-top-left']"); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement("[data-testid='edge-top-left']"); + + await expect.poll(() => reactGrab.getClipboardContent()).toContain("Top Left"); + }); + }); + + test.describe("Various Element Types", () => { + test("should select table cells", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='various-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='td-1-1']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("td-1-1"); + }); + + test("should select image element", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='various-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='img-element']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("img-element"); + }); + + test("should select article element", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='various-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='article-element']"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + + test("should select button element", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='various-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='plain-button']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("plain-button"); + }); + + test("should select gradient div", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='various-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='gradient-div']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("gradient-div"); + }); + }); + + test.describe("Element Removal After Index Build", () => { + test("should handle element removal after activation", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='dynamic-element-2']"); + await reactGrab.waitForSelectionBox(); + + const testIdBefore = await getTargetTestId(reactGrab.page); + expect(testIdBefore).toBe("dynamic-element-2"); + + await reactGrab.removeElement("[data-testid='dynamic-element-2']"); + await reactGrab.page.waitForTimeout(200); + + await reactGrab.hoverElement("[data-testid='dynamic-element-1']"); + await reactGrab.waitForSelectionBox(); + + const testIdAfter = await getTargetTestId(reactGrab.page); + expect(testIdAfter).toBe("dynamic-element-1"); + }); + + test("should handle dynamically added elements", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='dynamic-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + await reactGrab.page.evaluate(() => { + const section = document.querySelector("[data-testid='dynamic-section']"); + if (!section) return; + const newDiv = document.createElement("div"); + newDiv.setAttribute("data-testid", "runtime-added-element"); + newDiv.className = "p-4 bg-emerald-200 rounded mt-2"; + newDiv.textContent = "Dynamically added at runtime"; + section.appendChild(newDiv); + }); + await reactGrab.page.waitForTimeout(200); + + await reactGrab.hoverElement("[data-testid='runtime-added-element']"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + }); + + test.describe("Zero-Dimension and Invisible Elements", () => { + test("should not detect zero-size elements", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='zero-dimension-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const zeroEl = reactGrab.page.locator("[data-testid='zero-size-element']"); + const box = await zeroEl.boundingBox(); + + if (box && box.width > 0 && box.height > 0) { + await reactGrab.page.mouse.move(box.x, box.y); + } else { + await reactGrab.page.mouse.move(100, 100); + } + await reactGrab.page.waitForTimeout(200); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).not.toBe("zero-size-element"); + }); + + test("should skip invisible elements and select visible ones", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='zero-dimension-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='zero-dimension-section'] h2"); + await reactGrab.waitForSelectionBox(); + + const tagName = await getTargetTagName(reactGrab.page); + expect(tagName).toBe("h2"); + }); + }); + + test.describe("Modal and Overlay Interaction", () => { + test("should select modal content when modal is open", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='modal-dialog-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.page.click("[data-testid='modal-trigger']"); + await reactGrab.page.waitForSelector("[data-testid='modal-content']"); + + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='modal-inner-button']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("modal-inner-button"); + }); + + test("should copy modal content correctly", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='modal-dialog-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.page.click("[data-testid='modal-trigger']"); + await reactGrab.page.waitForSelector("[data-testid='modal-content']"); + + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='modal-inner-button']"); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement("[data-testid='modal-inner-button']"); + + await expect.poll(() => reactGrab.getClipboardContent()).toContain("Button Inside Modal"); + }); + }); + + test.describe("Deeply Nested Elements", () => { + test("should select deepest nested text element", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='deeply-nested-text']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("deeply-nested-text"); + }); + + test("should select nested button inside cards", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='nested-button']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("nested-button"); + }); + + test("should navigate through nested cards with arrows", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='nested-button']"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.pressArrowDown(); + await reactGrab.page.waitForTimeout(200); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + }); + + test.describe("Reactivation Rebuilds Index", () => { + test("should rebuild element index on each activation cycle", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='main-title']"); + await reactGrab.waitForSelectionBox(); + + const testId1 = await getTargetTestId(reactGrab.page); + expect(testId1).toBe("main-title"); + + await reactGrab.deactivate(); + await reactGrab.page.waitForTimeout(100); + + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='main-title']"); + await reactGrab.waitForSelectionBox(); + + const testId2 = await getTargetTestId(reactGrab.page); + expect(testId2).toBe("main-title"); + }); + + test("should detect elements added between activation cycles", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.deactivate(); + + await reactGrab.page.evaluate(() => { + const section = document.querySelector("[data-testid='dynamic-section']"); + if (!section) return; + const newDiv = document.createElement("div"); + newDiv.setAttribute("data-testid", "between-cycles-element"); + newDiv.className = "p-4 bg-fuchsia-200 rounded mt-2"; + newDiv.textContent = "Added between cycles"; + section.appendChild(newDiv); + }); + + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='between-cycles-element']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("between-cycles-element"); + }); + }); + + test.describe("Animated Elements", () => { + test("should select pulsing element after freeze", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='animated-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='animated-pulse']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("animated-pulse"); + }); + + test("should select spinning element after freeze", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='animated-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='animated-spin']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("animated-spin"); + }); + + test("should select bouncing element after freeze", async ({ reactGrab }) => { + const section = reactGrab.page.locator("[data-testid='animated-section']"); + await section.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='animated-bounce']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("animated-bounce"); + }); + }); + + test.describe("Injected Edge Cases", () => { + test("should skip absolutely positioned empty decorative div over content", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate(() => { + const container = document.createElement("div"); + container.id = "injected-decorative-test"; + container.style.cssText = "position: relative; margin: 16px;"; + container.innerHTML = ` +

+ Real content under decorative overlay +

+
+ `; + document.body.appendChild(container); + }); + + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='injected-real-content']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("injected-real-content"); + + await reactGrab.page.evaluate(() => { + document.getElementById("injected-decorative-test")?.remove(); + }); + }); + + test("should handle deeply nested overflow clipping chain", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const container = document.createElement("div"); + container.id = "injected-nested-clip-test"; + container.innerHTML = ` +
+
+
+
+ Visible +
+
+ This extends far beyond all containers +
+
+
+
+ `; + document.body.appendChild(container); + }); + + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='injected-deep-visible']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("injected-deep-visible"); + + await reactGrab.page.evaluate(() => { + document.getElementById("injected-nested-clip-test")?.remove(); + }); + }); + + test("should handle contain:paint with overflow interaction", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const container = document.createElement("div"); + container.id = "injected-contain-overflow-test"; + container.innerHTML = ` +
+
+ Visible +
+
+ Clipped by contain +
+
+ `; + document.body.appendChild(container); + }); + + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='injected-contain-visible']"); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("injected-contain-visible"); + + await reactGrab.page.evaluate(() => { + document.getElementById("injected-contain-overflow-test")?.remove(); + }); + }); + + test("should handle multiple stacking contexts with transforms", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const container = document.createElement("div"); + container.id = "injected-transform-stack-test"; + container.style.cssText = "position: relative; height: 100px; margin: 16px;"; + container.innerHTML = ` +
+ Background +
+
+ Foreground (transform) +
+ `; + document.body.appendChild(container); + }); + + const frontElement = reactGrab.page.locator("[data-testid='injected-transform-front']"); + await frontElement.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const frontBox = await frontElement.boundingBox(); + if (!frontBox) throw new Error("Could not get front element bounds"); + + await reactGrab.page.mouse.move( + frontBox.x + frontBox.width / 2, + frontBox.y + frontBox.height / 2, + ); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("injected-transform-front"); + + await reactGrab.page.evaluate(() => { + document.getElementById("injected-transform-stack-test")?.remove(); + }); + }); + + test("should handle flex item stacking with z-index", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const container = document.createElement("div"); + container.id = "injected-flex-stack-test"; + container.style.cssText = "display: flex; position: relative; height: 80px; margin: 16px;"; + container.innerHTML = ` +
+ Flex behind +
+
+ Flex front +
+ `; + document.body.appendChild(container); + }); + + const frontElement = reactGrab.page.locator("[data-testid='injected-flex-front']"); + await frontElement.scrollIntoViewIfNeeded(); + await reactGrab.activate(); + + const frontBox = await frontElement.boundingBox(); + if (!frontBox) throw new Error("Could not get front element bounds"); + + await reactGrab.page.mouse.move( + frontBox.x + frontBox.width / 2, + frontBox.y + frontBox.height / 2, + ); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("injected-flex-front"); + + await reactGrab.page.evaluate(() => { + document.getElementById("injected-flex-stack-test")?.remove(); + }); + }); + + test("should handle page with only fixed elements and no regular elements", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate(() => { + document.body.innerHTML = ""; + const fixedElement = document.createElement("div"); + fixedElement.id = "injected-fixed-only-test"; + fixedElement.setAttribute("data-testid", "fixed-only-element"); + fixedElement.style.cssText = + "position: fixed; top: 10px; left: 10px; width: 200px; height: 60px; background: #93c5fd; padding: 8px; z-index: 10;"; + fixedElement.textContent = "Only fixed element"; + document.body.appendChild(fixedElement); + }); + + await reactGrab.activate(); + + const fixedElement = reactGrab.page.locator("[data-testid='fixed-only-element']"); + const fixedBox = await fixedElement.boundingBox(); + if (!fixedBox) throw new Error("Could not get fixed element bounds"); + + await reactGrab.page.mouse.move( + fixedBox.x + fixedBox.width / 2, + fixedBox.y + fixedBox.height / 2, + ); + await reactGrab.waitForSelectionBox(); + + const testId = await getTargetTestId(reactGrab.page); + expect(testId).toBe("fixed-only-element"); + }); + }); +}); diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index c11fa5b3f..596bc9228 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -265,7 +265,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const hoverElement = async (selector: string) => { const element = page.locator(selector).first(); await element.hover({ force: true }); - await page.waitForTimeout(250); + await page.waitForTimeout(350); }; const clickElement = async (selector: string) => { @@ -422,23 +422,23 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { return expectedVisible ? menuItem !== null : menuItem === null; }, { attrName: ATTRIBUTE_NAME, expectedVisible: visible }, - { timeout: 2000 }, + { timeout: 5000 }, ); }; const rightClickElement = async (selector: string) => { + const wasActive = await isOverlayVisible(); const element = page.locator(selector).first(); await element.click({ button: "right", force: true }); - const isActive = await isOverlayVisible(); - if (isActive) { + if (wasActive) { await waitForContextMenu(true); } }; const rightClickAtPosition = async (x: number, y: number) => { + const wasActive = await isOverlayVisible(); await page.mouse.click(x, y, { button: "right" }); - const isActive = await isOverlayVisible(); - if (isActive) { + if (wasActive) { await waitForContextMenu(true); } }; @@ -576,7 +576,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { return api?.getState()?.isPromptMode === expected; }, active, - { timeout: 2000 }, + { timeout: 5000 }, ); }; @@ -600,7 +600,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { return state?.isSelectionBoxVisible || state?.targetElement !== null; }, undefined, - { timeout: 2000 }, + { timeout: 5000 }, ) .then(() => true) .catch(() => false); diff --git a/packages/react-grab/e2e/freeze-updates.spec.ts b/packages/react-grab/e2e/freeze-updates.spec.ts index 99a6205dc..3d2fcfa07 100644 --- a/packages/react-grab/e2e/freeze-updates.spec.ts +++ b/packages/react-grab/e2e/freeze-updates.spec.ts @@ -235,7 +235,7 @@ test.describe("Freeze Updates", () => { await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.deactivate(); // HACK: allow freeze cleanup to fully propagate before next iteration - await reactGrab.page.waitForTimeout(300); + await reactGrab.page.waitForTimeout(500); } const getElementCount = async () => { diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index 4ee57e2b1..6dfe305c7 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -102,7 +102,6 @@ "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-typescript": "^7.28.5", - "solid-js": "^1.9.10", "@playwright/test": "^1.40.0", "@tailwindcss/cli": "^4.1.17", "@types/babel__core": "^7.20.5", @@ -111,6 +110,7 @@ "babel-preset-solid": "^1.9.10", "concurrently": "^9.1.2", "expect-sdk": "0.0.0-canary-20260405095424", + "solid-js": "^1.9.10", "tailwindcss": "^4.1.0", "tsx": "^4.21.0" }, diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 5036c2fc6..7c007c577 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -248,14 +248,14 @@ export const Toolbar: Component = (props) => { setTooltipVisible: (visible: boolean) => void, options?: FreezeHandlersOptions, ) => ({ - onMouseEnter: () => { + onMouseEnter: (event: MouseEvent) => { if (drag.isDragging()) return; safePolygonTracker.stop(); setTooltipVisible(true); if (options?.shouldFreezeInteractions !== false && !unfreezeUpdatesCallback) { unfreezeUpdatesCallback = freezeUpdates(); freezeGlobalAnimations(); - freezePseudoStates(); + freezePseudoStates(event.clientX, event.clientY); } options?.onHoverChange?.(true); }, diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 9e4d92561..82f545005 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -130,6 +130,8 @@ export const ELEMENT_POSITION_THROTTLE_MS = 16; export const POINTER_EVENTS_RESUME_DEBOUNCE_MS = 100; export const VISIBILITY_CACHE_TTL_MS = 50; +export const ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX = 10_000; + export const ZOOM_DETECTION_THRESHOLD = 0.01; export const MOUNT_ROOT_RECHECK_DELAY_MS = 1000; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 504c30579..3a5057d8e 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -118,7 +118,6 @@ import { createArrowNavigator } from "./arrow-navigation.js"; import { getRequiredModifiers, setupKeyboardEventClaimer } from "./keyboard-handlers.js"; import { createAutoScroller, getAutoScrollDirection } from "./auto-scroll.js"; import { logIntro } from "./log-intro.js"; -import { onIdle } from "../utils/on-idle.js"; import { getScriptOptions } from "../utils/get-script-options.js"; import { isEnterCode } from "../utils/is-enter-code.js"; import { isMac } from "../utils/is-mac.js"; @@ -136,6 +135,10 @@ import { } from "../utils/freeze-animations.js"; import { freezePseudoStates, unfreezePseudoStates } from "../utils/freeze-pseudo-states.js"; import { freezeUpdates } from "../utils/freeze-updates.js"; +import { + buildElementAtPointIndex, + destroyElementAtPointIndex, +} from "../utils/element-at-point-index.js"; import { loadComments, addCommentItem, @@ -231,7 +234,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const isDragging = createMemo( () => store.current.state === "active" && - (store.current.phase === "dragging-select" || store.current.phase === "dragging-reposition"), + (store.current.phase === "dragging-select" || + store.current.phase === "dragging-reposition"), ); const isDragRepositioning = createMemo( () => store.current.state === "active" && store.current.phase === "dragging-reposition", @@ -257,13 +261,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { createEffect( on(isActivated, (activated, previousActivated) => { if (activated && !previousActivated) { - freezePseudoStates(); + freezePseudoStates(store.pointer.x, store.pointer.y); freezeGlobalAnimations(); + buildElementAtPointIndex(); document.body.style.touchAction = "none"; // iOS Safari auto-zooms on focused inputs with font-size < 16px, // which would disrupt the overlay positioning. unlockViewportZoom = lockViewportZoom(); } else if (!activated && previousActivated) { + destroyElementAtPointIndex(); unfreezePseudoStates(); unfreezeGlobalAnimations(); document.body.style.touchAction = ""; @@ -1624,7 +1630,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ) { elementDetectionState.lastDetectionTimestamp = now; elementDetectionState.pendingDetectionScheduledAt = now; - onIdle(() => { + // setTimeout defers the elementFromPoint hit-test into its own macrotask + // so the style recalculation it triggers doesn't eat the current frame's + // budget. Alternatives considered: + // - queueMicrotask: runs before paint, blocks the frame (ivi/Preact use + // microtasks for lightweight JS work, but elementFromPoint forces the + // browser's layout engine which is categorically heavier) + // - requestAnimationFrame: runs inside the render pipeline, same problem + // - scheduler.postTask("background"): starved for 350ms–5s on pages with + // continuous React renders or CSS animations + // setTimeout(0) fires in ~1ms as a separate task with no nesting-based + // clamping (each call originates from a distinct pointermove handler). + setTimeout(() => { const candidate = getElementAtPosition( elementDetectionState.latestPointerX, elementDetectionState.latestPointerY, @@ -2867,6 +2884,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { onCleanup(() => { eventListenerManager.abort(); + destroyElementAtPointIndex(); if (dragPreviewDebounceTimerId !== null) { window.clearTimeout(dragPreviewDebounceTimerId); } diff --git a/packages/react-grab/src/core/store.ts b/packages/react-grab/src/core/store.ts index 825c24f15..7121799ae 100644 --- a/packages/react-grab/src/core/store.ts +++ b/packages/react-grab/src/core/store.ts @@ -12,12 +12,7 @@ interface FrozenDragRect { height: number; } -type GrabPhase = - | "hovering" - | "frozen" - | "dragging-select" - | "dragging-reposition" - | "justDragged"; +type GrabPhase = "hovering" | "frozen" | "dragging-select" | "dragging-reposition" | "justDragged"; type GrabState = | { state: "idle" } diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts index 6a0320a6f..e9867922f 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -3,9 +3,8 @@ import { freezeGlobalAnimations, unfreezeGlobalAnimations, } from "./utils/freeze-animations.js"; -import { freezePseudoStates } from "./utils/freeze-pseudo-states.js"; +import { freezePseudoStates, unfreezePseudoStates } from "./utils/freeze-pseudo-states.js"; import { freezeUpdates } from "./utils/freeze-updates.js"; -import { unfreezePseudoStates } from "./utils/freeze-pseudo-states.js"; import { getComponentDisplayName, getHTMLPreview, diff --git a/packages/react-grab/src/utils/compare-stacking-order.ts b/packages/react-grab/src/utils/compare-stacking-order.ts new file mode 100644 index 000000000..347bd5119 --- /dev/null +++ b/packages/react-grab/src/utils/compare-stacking-order.ts @@ -0,0 +1,97 @@ +const STACKING_PROPS = + /\b(?:position|z-index|opacity|transform|mix-blend-mode|filter|isolation|clip-path|backdrop-filter|perspective|contain)\b/; + +const getParentElement = (node: Element): Element | null => { + const parentNode = node.parentNode; + const shadowHost = parentNode && "host" in parentNode ? (parentNode as ShadowRoot).host : null; + return shadowHost ?? node.parentElement; +}; + +const getAncestorChain = (node: Element): Element[] => { + const ancestors: Element[] = []; + let current: Element | null = node; + while (current) { + ancestors.push(current); + current = getParentElement(current); + } + return ancestors; +}; + +const isFlexOrGridItem = (node: Element): boolean => { + const parentElement = getParentElement(node); + if (!parentElement) return false; + const display = getComputedStyle(parentElement).display; + return ( + display === "flex" || + display === "inline-flex" || + display === "grid" || + display === "inline-grid" + ); +}; + +const hasPaintContainment = (containValue: string): boolean => { + if (containValue === "none" || containValue === "size" || containValue === "layout") return false; + return containValue === "paint" || containValue === "strict" || containValue === "content" + || containValue.includes("paint"); +}; + +const isStackingContext = (node: Element, style: CSSStyleDeclaration): boolean => { + if (style.position === "fixed" || style.position === "sticky") return true; + if (style.zIndex !== "auto" && (style.position !== "static" || isFlexOrGridItem(node))) + return true; + if (+style.opacity < 1) return true; + if (style.transform !== "none") return true; + if ("mixBlendMode" in style && style.mixBlendMode !== "normal") return true; + if (style.filter !== "none") return true; + if ("isolation" in style && style.getPropertyValue("isolation") === "isolate") return true; + if (hasPaintContainment(style.contain)) return true; + if ("backdropFilter" in style && style.getPropertyValue("backdrop-filter") !== "none") return true; + if (style.perspective !== "none") return true; + if ("clipPath" in style && style.getPropertyValue("clip-path") !== "none") return true; + if (STACKING_PROPS.test(style.willChange)) return true; + return false; +}; + +const getNearestStackingContextZIndex = (ancestors: Element[]): number => { + let ancestorIndex = ancestors.length; + while (ancestorIndex--) { + const style = getComputedStyle(ancestors[ancestorIndex]); + if (isStackingContext(ancestors[ancestorIndex], style)) { + return Number(style.zIndex) || 0; + } + } + return 0; +}; + +export const compareStackingOrder = (elementA: Element, elementB: Element): number => { + if (elementA === elementB) return 0; + + const ancestorsA = getAncestorChain(elementA); + const ancestorsB = getAncestorChain(elementB); + + let commonAncestor: Element | undefined; + + while (ancestorsA.at(-1) === ancestorsB.at(-1)) { + commonAncestor = ancestorsA.pop()!; + ancestorsB.pop(); + } + + const zIndexA = getNearestStackingContextZIndex(ancestorsA); + const zIndexB = getNearestStackingContextZIndex(ancestorsB); + + if (zIndexA === zIndexB) { + if (!commonAncestor) return 0; + const children = commonAncestor.childNodes; + const furthestAncestorA = ancestorsA.at(-1); + const furthestAncestorB = ancestorsB.at(-1); + + let childIndex = children.length; + while (childIndex--) { + const child = children[childIndex]; + if (child === furthestAncestorA) return 1; + if (child === furthestAncestorB) return -1; + } + } + + return Math.sign(zIndexA - zIndexB); +}; diff --git a/packages/react-grab/src/utils/element-at-point-index.ts b/packages/react-grab/src/utils/element-at-point-index.ts new file mode 100644 index 000000000..e37bd2dc2 --- /dev/null +++ b/packages/react-grab/src/utils/element-at-point-index.ts @@ -0,0 +1,252 @@ +import { HilbertRTree } from "./hilbert-r-tree.js"; +import { ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX } from "../constants.js"; +import { isValidGrabbableElement } from "./is-valid-grabbable-element.js"; +import { isDecorativeOverlay } from "./is-decorative-overlay.js"; +import { compareStackingOrder } from "./compare-stacking-order.js"; + +interface PageRect { + left: number; + top: number; + right: number; + bottom: number; +} + +interface CachedFixedElement { + element: Element; + viewportRect: PageRect; + zIndex: number; +} + +interface ElementAtPointIndexState { + tree: HilbertRTree | null; + elements: Element[]; + fixedElements: CachedFixedElement[]; +} + +const SKIP_TAGS = new Set([ + "SCRIPT", + "STYLE", + "HEAD", + "META", + "LINK", + "NOSCRIPT", + "BR", + "TEMPLATE", + "SLOT", +]); + +let currentIndex: ElementAtPointIndexState | null = null; +let pendingObserver: IntersectionObserver | null = null; + +export const buildElementAtPointIndex = (): void => { + destroyElementAtPointIndex(); + + let didObserveAnyElement = false; + + const accumulatedElements: Element[] = []; + const accumulatedRects: PageRect[] = []; + const accumulatedFixedElements: CachedFixedElement[] = []; + + const observer = new IntersectionObserver( + (entries) => { + const scrollX = window.scrollX; + const scrollY = window.scrollY; + + for (const entry of entries) { + observer.unobserve(entry.target); + + const targetElement = entry.target as HTMLElement; + const boundingRect = entry.boundingClientRect; + if (boundingRect.width === 0 || boundingRect.height === 0) continue; + if (!isValidGrabbableElement(targetElement)) continue; + const computedStyle = getComputedStyle(targetElement); + if (computedStyle.position === "fixed") { + accumulatedFixedElements.push({ + element: targetElement, + viewportRect: { + left: boundingRect.left, + top: boundingRect.top, + right: boundingRect.right, + bottom: boundingRect.bottom, + }, + zIndex: parseInt(computedStyle.zIndex, 10) || 0, + }); + continue; + } + if (isDecorativeOverlay(targetElement, computedStyle.position)) continue; + + accumulatedElements.push(targetElement); + + accumulatedRects.push({ + left: boundingRect.left + scrollX, + top: boundingRect.top + scrollY, + right: boundingRect.right + scrollX, + bottom: boundingRect.bottom + scrollY, + }); + } + + if (accumulatedElements.length === 0 && accumulatedFixedElements.length === 0) return; + + let tree: HilbertRTree | null = null; + if (accumulatedElements.length > 0) { + tree = new HilbertRTree(accumulatedElements.length); + for (const rect of accumulatedRects) { + tree.add(rect.left, rect.top, rect.right, rect.bottom); + } + tree.finish(); + } + + accumulatedFixedElements.sort((entryA, entryB) => entryA.zIndex - entryB.zIndex); + + currentIndex = { + tree, + elements: [...accumulatedElements], + fixedElements: [...accumulatedFixedElements], + }; + }, + { rootMargin: `${ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX}px` }, + ); + + pendingObserver = observer; + + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node) => + SKIP_TAGS.has((node as Element).tagName) + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_ACCEPT, + }); + + while (walker.nextNode()) { + const element = walker.currentNode as HTMLElement; + if (element.offsetWidth === 0 && element.offsetHeight === 0) continue; + observer.observe(element); + didObserveAnyElement = true; + } + + if (!didObserveAnyElement) { + observer.disconnect(); + pendingObserver = null; + } +}; + +const CLIPPING_OVERFLOW_VALUES = new Set(["hidden", "scroll", "auto", "clip"]); +const PAINT_CONTAIN_VALUES = new Set(["paint", "strict", "content"]); + +const CLIPS_X = 1; +const CLIPS_Y = 2; + +let clipStateCache = new WeakMap(); + +const hasPaintContainment = (containValue: string): boolean => { + for (const keyword of containValue.split(" ")) { + if (PAINT_CONTAIN_VALUES.has(keyword)) return true; + } + return false; +}; + +const getClipState = (ancestor: Element): number => { + const cached = clipStateCache.get(ancestor); + if (cached !== undefined) return cached; + + const style = getComputedStyle(ancestor); + const isPaintContained = hasPaintContainment(style.contain); + let state = 0; + if (CLIPPING_OVERFLOW_VALUES.has(style.overflowX) || isPaintContained) state |= CLIPS_X; + if (CLIPPING_OVERFLOW_VALUES.has(style.overflowY) || isPaintContained) state |= CLIPS_Y; + clipStateCache.set(ancestor, state); + return state; +}; + +const isVisibleAtPoint = (element: Element, clientX: number, clientY: number): boolean => { + let ancestor = element.parentElement; + while (ancestor && ancestor !== document.documentElement) { + const clipState = getClipState(ancestor); + + if (clipState !== 0) { + const ancestorRect = ancestor.getBoundingClientRect(); + if ( + (clipState & CLIPS_X) !== 0 && + (clientX < ancestorRect.left || clientX > ancestorRect.right) + ) + return false; + if ( + (clipState & CLIPS_Y) !== 0 && + (clientY < ancestorRect.top || clientY > ancestorRect.bottom) + ) + return false; + } + + ancestor = ancestor.parentElement; + } + return true; +}; + +const findTopmostFixedElement = ( + fixedElements: CachedFixedElement[], + clientX: number, + clientY: number, +): Element | null => { + let topmost: CachedFixedElement | null = null; + + for (const entry of fixedElements) { + if (!entry.element.isConnected) continue; + const rect = entry.viewportRect; + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + topmost = entry; + } + } + + return topmost?.element ?? null; +}; + +export const queryElementAtPointIndex = (clientX: number, clientY: number): Element | null => { + if (!currentIndex) return null; + + const pageX = clientX + window.scrollX; + const pageY = clientY + window.scrollY; + + const fixedHit = findTopmostFixedElement(currentIndex.fixedElements, clientX, clientY); + + const hitIndices = currentIndex.tree?.search(pageX, pageY, pageX, pageY) ?? []; + + const visibleCandidates: Element[] = []; + + for (const hitIndex of hitIndices) { + const candidate = currentIndex.elements[hitIndex]; + if (!candidate.isConnected) continue; + if (!isVisibleAtPoint(candidate, clientX, clientY)) continue; + visibleCandidates.push(candidate); + } + + if (fixedHit) { + if (visibleCandidates.length === 0) return fixedHit; + visibleCandidates.push(fixedHit); + } + + if (visibleCandidates.length === 0) return null; + if (visibleCandidates.length === 1) return visibleCandidates[0]; + + visibleCandidates.sort((elementA, elementB) => compareStackingOrder(elementB, elementA)); + + return visibleCandidates[0]; +}; + +export const isElementAtPointIndexReady = (): boolean => currentIndex !== null; + +export const destroyElementAtPointIndex = (): void => { + if (pendingObserver) { + pendingObserver.disconnect(); + pendingObserver = null; + } + if (currentIndex) { + currentIndex.elements.length = 0; + currentIndex.fixedElements.length = 0; + currentIndex = null; + } + clipStateCache = new WeakMap(); +}; diff --git a/packages/react-grab/src/utils/freeze-animations.ts b/packages/react-grab/src/utils/freeze-animations.ts index 74e9ba2cc..d141dc60b 100644 --- a/packages/react-grab/src/utils/freeze-animations.ts +++ b/packages/react-grab/src/utils/freeze-animations.ts @@ -201,20 +201,17 @@ export const unfreezeGlobalAnimations = (): void => { // so calling finish() on animations the freeze never paused would break // react-grab's own toolbar and label animations. // @see https://github.com/aidenybai/react-grab/issues/163 - const animations: Animation[] = []; + const animationsToFinish: Animation[] = []; for (const animation of document.getAnimations()) { if (animation.effect instanceof KeyframeEffect) { const target = animation.effect.target; - if (target instanceof Element) { - const rootNode = target.getRootNode(); - if (rootNode instanceof ShadowRoot) { - continue; - } + if (target instanceof Element && target.getRootNode() instanceof ShadowRoot) { + continue; } } - animations.push(animation); + animationsToFinish.push(animation); } - finishAnimations(animations); + finishAnimations(animationsToFinish); globalAnimationStyleElement.remove(); globalAnimationStyleElement = null; diff --git a/packages/react-grab/src/utils/freeze-pseudo-states.ts b/packages/react-grab/src/utils/freeze-pseudo-states.ts index 214b3ece2..9a5be3aee 100644 --- a/packages/react-grab/src/utils/freeze-pseudo-states.ts +++ b/packages/react-grab/src/utils/freeze-pseudo-states.ts @@ -89,32 +89,50 @@ const collectOriginalPropertyValues = ( return originalPropertyValues; }; -const collectPseudoStates = ( - selector: string, +const freezeElement = ( + element: HTMLElement, properties: readonly string[], alreadyFrozen?: Map>, -): FrozenPseudoState[] => { - const elementsToFreeze: FrozenPseudoState[] = []; - - for (const element of document.querySelectorAll(selector)) { - if (!(element instanceof HTMLElement)) continue; - if (alreadyFrozen?.has(element)) continue; +): FrozenPseudoState | null => { + if (alreadyFrozen?.has(element)) return null; - const computed = getComputedStyle(element); - let frozenStyles = element.style.cssText; - const originalPropertyValues = collectOriginalPropertyValues(element, properties); + const computed = getComputedStyle(element); + let frozenStyles = element.style.cssText; + const originalPropertyValues = collectOriginalPropertyValues(element, properties); - for (const prop of properties) { - const computedValue = computed.getPropertyValue(prop); - if (computedValue) { - frozenStyles += `${prop}: ${computedValue} !important; `; - } + for (const prop of properties) { + const computedValue = computed.getPropertyValue(prop); + if (computedValue) { + frozenStyles += `${prop}: ${computedValue} !important; `; } + } + + return { element, frozenStyles, originalPropertyValues }; +}; - elementsToFreeze.push({ element, frozenStyles, originalPropertyValues }); +const collectHoveredElements = (cursorX: number, cursorY: number): HTMLElement[] => { + const hoveredElements: HTMLElement[] = []; + let current = document.elementFromPoint(cursorX, cursorY); + while (current && current !== document.documentElement) { + if (current instanceof HTMLElement) { + hoveredElements.push(current); + } + current = current.parentElement; } + return hoveredElements; +}; - return elementsToFreeze; +const collectFocusedElements = (): HTMLElement[] => { + const focusedElements: HTMLElement[] = []; + let current: Element | null = document.activeElement; + while (current && current !== document.body) { + if (current instanceof HTMLElement) { + focusedElements.push(current); + } + const shadowRoot = current.shadowRoot; + current = shadowRoot?.activeElement ?? null; + } + return focusedElements; }; const applyFrozenStates = ( @@ -152,7 +170,7 @@ export const resumePointerEventsFreeze = (): void => { if (pointerEventsStyle) pointerEventsStyle.disabled = false; }; -export const freezePseudoStates = (): void => { +export const freezePseudoStates = (cursorX?: number, cursorY?: number): void => { if (pointerEventsStyle) return; for (const eventType of MOUSE_EVENTS_TO_BLOCK) { @@ -163,12 +181,29 @@ export const freezePseudoStates = (): void => { document.addEventListener(eventType, preventFocusChange, true); } - const hoverStates = collectPseudoStates(":hover", HOVER_STYLE_PROPERTIES); - const focusStates = collectPseudoStates( - ":focus, :focus-visible", - FOCUS_STYLE_PROPERTIES, - frozenFocusElements, - ); + const hoverStates: FrozenPseudoState[] = []; + const isCursorInViewport = + cursorX !== undefined && + cursorY !== undefined && + cursorX >= 0 && + cursorY >= 0 && + cursorX < window.innerWidth && + cursorY < window.innerHeight; + const hoveredElements = isCursorInViewport + ? collectHoveredElements(cursorX, cursorY) + : Array.from(document.querySelectorAll(":hover")).filter( + (element): element is HTMLElement => element instanceof HTMLElement, + ); + for (const element of hoveredElements) { + const state = freezeElement(element, HOVER_STYLE_PROPERTIES); + if (state) hoverStates.push(state); + } + + const focusStates: FrozenPseudoState[] = []; + for (const element of collectFocusedElements()) { + const state = freezeElement(element, FOCUS_STYLE_PROPERTIES, frozenFocusElements); + if (state) focusStates.push(state); + } applyFrozenStates(hoverStates, frozenHoverElements); applyFrozenStates(focusStates, frozenFocusElements); 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 0fcb34bf8..9701e1b71 100644 --- a/packages/react-grab/src/utils/get-element-at-position.ts +++ b/packages/react-grab/src/utils/get-element-at-position.ts @@ -5,6 +5,7 @@ import { POINTER_EVENTS_RESUME_DEBOUNCE_MS, } from "../constants.js"; import { suspendPointerEventsFreeze, resumePointerEventsFreeze } from "./freeze-pseudo-states.js"; +import { isElementAtPointIndexReady, queryElementAtPointIndex } from "./element-at-point-index.js"; interface PositionCache { clientX: number; @@ -42,6 +43,14 @@ const isWithinThreshold = (x1: number, y1: number, x2: number, y2: number): bool ); }; +const getElementArea = (element: Element): number => { + const blockWidth = element.clientWidth; + const blockHeight = element.clientHeight; + if (blockWidth > 0 && blockHeight > 0) return blockWidth * blockHeight; + const rect = element.getBoundingClientRect(); + return rect.width * rect.height; +}; + export const getElementsAtPoint = (clientX: number, clientY: number): Element[] => { if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return []; cancelScheduledResume(); @@ -60,40 +69,39 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element const isWithinThrottle = now - cache.timestamp < ELEMENT_POSITION_THROTTLE_MS; if (isPositionClose || isWithinThrottle) { - return cache.element; + if (cache.element === null || cache.element.isConnected) { + return cache.element; + } + cache = null; + } + } + + if (isElementAtPointIndexReady()) { + const spatialResult = queryElementAtPointIndex(clientX, clientY); + if ( + spatialResult && + isValidGrabbableElement(spatialResult) && + getElementArea(spatialResult) > 0 + ) { + cache = { clientX, clientY, element: spatialResult, timestamp: now }; + return spatialResult; } } - // PERF: suspendPointerEventsFreeze toggles the html { pointer-events: none } - // stylesheet, which dirties the entire style tree. elementFromPoint then forces - // a Recalculate Style. The 100ms debounced resume (scheduleResume) ensures the - // toggle is a no-op on rapid subsequent calls. The expensive recalc on those - // calls comes from host-page CSS animations dirtying styles between frames, - // which is unavoidable without removing pointer-events: none entirely. - // Alternatives explored and rejected: - // - IntersectionObserver pre-population: adds 1-frame latency to every poll - // - event.target fast path: always html/document due to pointer-events: none - // - bounds-check cache: ignores z-index/stacking, causes hover detection misses - // - transparent overlay instead of pointer-events: none: leaks CSS-only :hover - // dropdowns/tooltips during the hit-test toggle cancelScheduledResume(); suspendPointerEventsFreeze(); + const elementsAtPoint = document.elementsFromPoint(clientX, clientY); let result: Element | null = null; + let smallestArea = Infinity; - // elementFromPoint returns the topmost element, but if it's not grabbable - // (e.g. a transparent overlay) we fall back to elementsFromPoint which - // returns the full z-ordered stack at that coordinate. - const topElement = document.elementFromPoint(clientX, clientY); - if (topElement && isValidGrabbableElement(topElement)) { - result = topElement; - } else { - const elementsAtPoint = document.elementsFromPoint(clientX, clientY); - for (const candidateElement of elementsAtPoint) { - if (candidateElement !== topElement && isValidGrabbableElement(candidateElement)) { - result = candidateElement; - break; - } + for (const candidateElement of elementsAtPoint) { + if (!isValidGrabbableElement(candidateElement)) continue; + const area = getElementArea(candidateElement); + if (area === 0) continue; + if (area < smallestArea) { + smallestArea = area; + result = candidateElement; } } diff --git a/packages/react-grab/src/utils/hilbert-r-tree.ts b/packages/react-grab/src/utils/hilbert-r-tree.ts new file mode 100644 index 000000000..37a0e20bf --- /dev/null +++ b/packages/react-grab/src/utils/hilbert-r-tree.ts @@ -0,0 +1,299 @@ +const NODE_SIZE = 16; + +const upperBound = (value: number, levelBounds: number[]): number => { + let low = 0; + let high = levelBounds.length - 1; + while (low < high) { + const mid = (low + high) >> 1; + if (levelBounds[mid] > value) { + high = mid; + } else { + low = mid + 1; + } + } + return levelBounds[low]; +}; + +const swapItems = ( + values: Uint32Array, + boxes: Float64Array, + indices: Uint16Array | Uint32Array, + indexA: number, + indexB: number, +): void => { + const tempValue = values[indexA]; + values[indexA] = values[indexB]; + values[indexB] = tempValue; + + const boxOffsetA = 4 * indexA; + const boxOffsetB = 4 * indexB; + + const minXA = boxes[boxOffsetA]; + const minYA = boxes[boxOffsetA + 1]; + const maxXA = boxes[boxOffsetA + 2]; + const maxYA = boxes[boxOffsetA + 3]; + boxes[boxOffsetA] = boxes[boxOffsetB]; + boxes[boxOffsetA + 1] = boxes[boxOffsetB + 1]; + boxes[boxOffsetA + 2] = boxes[boxOffsetB + 2]; + boxes[boxOffsetA + 3] = boxes[boxOffsetB + 3]; + boxes[boxOffsetB] = minXA; + boxes[boxOffsetB + 1] = minYA; + boxes[boxOffsetB + 2] = maxXA; + boxes[boxOffsetB + 3] = maxYA; + + const tempIndex = indices[indexA]; + indices[indexA] = indices[indexB]; + indices[indexB] = tempIndex; +}; + +const sortByHilbert = ( + values: Uint32Array, + boxes: Float64Array, + indices: Uint16Array | Uint32Array, + left: number, + right: number, +): void => { + const stack = [left, right]; + + while (stack.length) { + const stackRight = stack.pop()!; + const stackLeft = stack.pop()!; + + if ( + stackRight - stackLeft <= NODE_SIZE && + Math.floor(stackLeft / NODE_SIZE) >= Math.floor(stackRight / NODE_SIZE) + ) + continue; + + const valueLeft = values[stackLeft]; + const valueMid = values[(stackLeft + stackRight) >> 1]; + const valueRight = values[stackRight]; + const pivot = + valueLeft > valueMid !== valueLeft > valueRight + ? valueLeft + : valueMid < valueLeft !== valueMid < valueRight + ? valueMid + : valueRight; + + let scanLeft = stackLeft - 1; + let scanRight = stackRight + 1; + + while (true) { + do scanLeft++; + while (values[scanLeft] < pivot); + do scanRight--; + while (values[scanRight] > pivot); + if (scanLeft >= scanRight) break; + swapItems(values, boxes, indices, scanLeft, scanRight); + } + + stack.push(stackLeft, scanRight, scanRight + 1, stackRight); + } +}; + +// Fast Hilbert curve algorithm by http://threadlocalmutex.com/ +// Ported from C++ https://github.com/rawrunprotected/hilbert_curves (public domain) +const hilbert = (coordX: number, coordY: number): number => { + let xorXY = coordX ^ coordY; + let invertedXor = 0xffff ^ xorXY; + let invertedOr = 0xffff ^ (coordX | coordY); + let maskedAnd = coordX & (coordY ^ 0xffff); + + let levelA = xorXY | (invertedXor >> 1); + let levelB = (xorXY >> 1) ^ xorXY; + let levelC = (invertedOr >> 1) ^ (invertedXor & (maskedAnd >> 1)) ^ invertedOr; + let levelD = (xorXY & (invertedOr >> 1)) ^ (maskedAnd >> 1) ^ maskedAnd; + + xorXY = levelA; + invertedXor = levelB; + invertedOr = levelC; + maskedAnd = levelD; + levelA = (xorXY & (xorXY >> 2)) ^ (invertedXor & (invertedXor >> 2)); + levelB = (xorXY & (invertedXor >> 2)) ^ (invertedXor & ((xorXY ^ invertedXor) >> 2)); + levelC ^= (xorXY & (invertedOr >> 2)) ^ (invertedXor & (maskedAnd >> 2)); + levelD ^= (invertedXor & (invertedOr >> 2)) ^ ((xorXY ^ invertedXor) & (maskedAnd >> 2)); + + xorXY = levelA; + invertedXor = levelB; + invertedOr = levelC; + maskedAnd = levelD; + levelA = (xorXY & (xorXY >> 4)) ^ (invertedXor & (invertedXor >> 4)); + levelB = (xorXY & (invertedXor >> 4)) ^ (invertedXor & ((xorXY ^ invertedXor) >> 4)); + levelC ^= (xorXY & (invertedOr >> 4)) ^ (invertedXor & (maskedAnd >> 4)); + levelD ^= (invertedXor & (invertedOr >> 4)) ^ ((xorXY ^ invertedXor) & (maskedAnd >> 4)); + + xorXY = levelA; + invertedXor = levelB; + levelC ^= (xorXY & (invertedOr >> 8)) ^ (invertedXor & (maskedAnd >> 8)); + levelD ^= (invertedXor & (invertedOr >> 8)) ^ ((xorXY ^ invertedXor) & (maskedAnd >> 8)); + + xorXY = levelC ^ (levelC >> 1); + invertedXor = levelD ^ (levelD >> 1); + + let interleaved0 = coordX ^ coordY; + let interleaved1 = invertedXor | (0xffff ^ (interleaved0 | xorXY)); + + interleaved0 = (interleaved0 | (interleaved0 << 8)) & 0x00ff00ff; + interleaved0 = (interleaved0 | (interleaved0 << 4)) & 0x0f0f0f0f; + interleaved0 = (interleaved0 | (interleaved0 << 2)) & 0x33333333; + interleaved0 = (interleaved0 | (interleaved0 << 1)) & 0x55555555; + + interleaved1 = (interleaved1 | (interleaved1 << 8)) & 0x00ff00ff; + interleaved1 = (interleaved1 | (interleaved1 << 4)) & 0x0f0f0f0f; + interleaved1 = (interleaved1 | (interleaved1 << 2)) & 0x33333333; + interleaved1 = (interleaved1 | (interleaved1 << 1)) & 0x55555555; + + return ((interleaved1 << 1) | interleaved0) >>> 0; +}; + +export class HilbertRTree { + private readonly numItems: number; + private readonly boxes: Float64Array; + private readonly indices: Uint16Array | Uint32Array; + private readonly levelBounds: number[]; + private position: number; + + minX = Infinity; + minY = Infinity; + maxX = -Infinity; + maxY = -Infinity; + + constructor(numItems: number) { + if (numItems <= 0) throw new Error(`Unexpected numItems value: ${numItems}.`); + + this.numItems = numItems; + + let nodeCount = numItems; + this.levelBounds = [nodeCount * 4]; + let remaining = numItems; + do { + remaining = Math.ceil(remaining / NODE_SIZE); + nodeCount += remaining; + this.levelBounds.push(nodeCount * 4); + } while (remaining !== 1); + + const IndexArrayType = nodeCount < 16384 ? Uint16Array : Uint32Array; + this.boxes = new Float64Array(nodeCount * 4); + this.indices = new IndexArrayType(nodeCount); + this.position = 0; + } + + add(minX: number, minY: number, maxX: number = minX, maxY: number = minY): number { + const itemIndex = this.position >> 2; + this.indices[itemIndex] = itemIndex; + this.boxes[this.position++] = minX; + this.boxes[this.position++] = minY; + this.boxes[this.position++] = maxX; + this.boxes[this.position++] = maxY; + + if (minX < this.minX) this.minX = minX; + if (minY < this.minY) this.minY = minY; + if (maxX > this.maxX) this.maxX = maxX; + if (maxY > this.maxY) this.maxY = maxY; + + return itemIndex; + } + + finish(): void { + if (this.position >> 2 !== this.numItems) { + throw new Error(`Added ${this.position >> 2} items when expected ${this.numItems}.`); + } + + const boxes = this.boxes; + + if (this.numItems <= NODE_SIZE) { + boxes[this.position++] = this.minX; + boxes[this.position++] = this.minY; + boxes[this.position++] = this.maxX; + boxes[this.position++] = this.maxY; + return; + } + + const width = this.maxX - this.minX || 1; + const height = this.maxY - this.minY || 1; + const hilbertValues = new Uint32Array(this.numItems); + const hilbertMax = (1 << 16) - 1; + + for (let itemIndex = 0, boxPosition = 0; itemIndex < this.numItems; itemIndex++) { + const rectMinX = boxes[boxPosition++]; + const rectMinY = boxes[boxPosition++]; + const rectMaxX = boxes[boxPosition++]; + const rectMaxY = boxes[boxPosition++]; + const normalizedX = Math.floor( + (hilbertMax * ((rectMinX + rectMaxX) / 2 - this.minX)) / width, + ); + const normalizedY = Math.floor( + (hilbertMax * ((rectMinY + rectMaxY) / 2 - this.minY)) / height, + ); + hilbertValues[itemIndex] = hilbert(normalizedX, normalizedY); + } + + sortByHilbert(hilbertValues, boxes, this.indices, 0, this.numItems - 1); + + for ( + let levelIndex = 0, boxPosition = 0; + levelIndex < this.levelBounds.length - 1; + levelIndex++ + ) { + const levelEnd = this.levelBounds[levelIndex]; + + while (boxPosition < levelEnd) { + const nodeStartIndex = boxPosition; + + let nodeMinX = boxes[boxPosition++]; + let nodeMinY = boxes[boxPosition++]; + let nodeMaxX = boxes[boxPosition++]; + let nodeMaxY = boxes[boxPosition++]; + for (let childIndex = 1; childIndex < NODE_SIZE && boxPosition < levelEnd; childIndex++) { + nodeMinX = Math.min(nodeMinX, boxes[boxPosition++]); + nodeMinY = Math.min(nodeMinY, boxes[boxPosition++]); + nodeMaxX = Math.max(nodeMaxX, boxes[boxPosition++]); + nodeMaxY = Math.max(nodeMaxY, boxes[boxPosition++]); + } + + this.indices[this.position >> 2] = nodeStartIndex; + boxes[this.position++] = nodeMinX; + boxes[this.position++] = nodeMinY; + boxes[this.position++] = nodeMaxX; + boxes[this.position++] = nodeMaxY; + } + } + } + + search(minX: number, minY: number, maxX: number, maxY: number): number[] { + if (this.position !== this.boxes.length) { + throw new Error("Data not yet indexed - call finish()."); + } + + let nodeIndex: number | undefined = this.boxes.length - 4; + const searchQueue: number[] = []; + const results: number[] = []; + + while (nodeIndex !== undefined) { + const nodeEnd = Math.min(nodeIndex + NODE_SIZE * 4, upperBound(nodeIndex, this.levelBounds)); + + for (let boxPosition = nodeIndex; boxPosition < nodeEnd; boxPosition += 4) { + const candidateMinX = this.boxes[boxPosition]; + if (maxX < candidateMinX) continue; + const candidateMinY = this.boxes[boxPosition + 1]; + if (maxY < candidateMinY) continue; + const candidateMaxX = this.boxes[boxPosition + 2]; + if (minX > candidateMaxX) continue; + const candidateMaxY = this.boxes[boxPosition + 3]; + if (minY > candidateMaxY) continue; + + const candidateIndex = this.indices[boxPosition >> 2] | 0; + + if (nodeIndex >= this.numItems * 4) { + searchQueue.push(candidateIndex); + } else { + results.push(candidateIndex); + } + } + + nodeIndex = searchQueue.pop(); + } + + return results; + } +} diff --git a/packages/react-grab/src/utils/is-decorative-overlay.ts b/packages/react-grab/src/utils/is-decorative-overlay.ts new file mode 100644 index 000000000..a6b08ca0d --- /dev/null +++ b/packages/react-grab/src/utils/is-decorative-overlay.ts @@ -0,0 +1,18 @@ +const REPLACED_TAGS = new Set([ + "IMG", + "VIDEO", + "CANVAS", + "SVG", + "IFRAME", + "INPUT", + "TEXTAREA", + "SELECT", + "OBJECT", + "EMBED", +]); + +export const isDecorativeOverlay = (element: Element, computedPosition: string): boolean => + (computedPosition === "absolute" || computedPosition === "sticky") && + !REPLACED_TAGS.has(element.tagName) && + element.childElementCount === 0 && + (element.textContent?.trim().length ?? 0) === 0; diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index f2876803f..c9459a50b 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -34,28 +34,16 @@ const isDevToolsOverlay = (computedStyle: CSSStyleDeclaration): boolean => { ); }; -const isFullViewportOverlay = (element: Element, computedStyle: CSSStyleDeclaration): boolean => { - const position = computedStyle.position; - if (position !== "fixed" && position !== "absolute") { - return false; - } - - const rect = element.getBoundingClientRect(); - const coversViewport = - rect.width / window.innerWidth >= VIEWPORT_COVERAGE_THRESHOLD && - rect.height / window.innerHeight >= VIEWPORT_COVERAGE_THRESHOLD; - - if (!coversViewport) { - return false; - } - +const hasTransparentBackground = (computedStyle: CSSStyleDeclaration): boolean => { const backgroundColor = computedStyle.backgroundColor; - const hasInvisibleBackground = - backgroundColor === "transparent" || - backgroundColor === "rgba(0, 0, 0, 0)" || - parseFloat(computedStyle.opacity) < 0.1; + return backgroundColor === "transparent" || backgroundColor === "rgba(0, 0, 0, 0)"; +}; + +const isFullViewportOverlay = (computedStyle: CSSStyleDeclaration): boolean => { + const position = computedStyle.position; + if (position !== "fixed" && position !== "absolute") return false; - if (hasInvisibleBackground) { + if (hasTransparentBackground(computedStyle) || parseFloat(computedStyle.opacity) < 0.1) { return true; } @@ -110,7 +98,7 @@ export const isValidGrabbableElement = (element: Element): boolean => { if (isDevToolsOverlay(computedStyle)) { return false; } - if (isFullViewportOverlay(element, computedStyle)) { + if (isFullViewportOverlay(computedStyle)) { return false; } } diff --git a/packages/react-grab/src/utils/on-idle.ts b/packages/react-grab/src/utils/on-idle.ts deleted file mode 100644 index 7437b19d9..000000000 --- a/packages/react-grab/src/utils/on-idle.ts +++ /dev/null @@ -1,36 +0,0 @@ -interface BackgroundTaskScheduler { - postTask: (callback: () => void, options: { priority: "background" }) => unknown; -} - -declare global { - interface Window { - scheduler?: BackgroundTaskScheduler; - } -} - -const isBackgroundTaskScheduler = (value: unknown): value is BackgroundTaskScheduler => { - if (typeof value !== "object" || value === null) return false; - if (!("postTask" in value)) return false; - return typeof value.postTask === "function"; -}; - -// Defers work via the best available idle-scheduling primitive: the Scheduler -// API with background priority (Chrome 94+), then requestIdleCallback, then a -// setTimeout(0) fallback. This is used for expensive hit-testing work that -// should never block user-visible rendering. -export const onIdle = (callback: () => void): void => { - if (typeof window !== "undefined") { - const schedulerCandidate = window.scheduler; - if (isBackgroundTaskScheduler(schedulerCandidate)) { - schedulerCandidate.postTask(callback, { - priority: "background", - }); - return; - } - if ("requestIdleCallback" in window) { - requestIdleCallback(callback); - return; - } - } - setTimeout(callback, 0); -};