diff --git a/.changeset/shield-gated-hit-testing.md b/.changeset/shield-gated-hit-testing.md new file mode 100644 index 000000000..f10ac6f5a --- /dev/null +++ b/.changeset/shield-gated-hit-testing.md @@ -0,0 +1,5 @@ +--- +"react-grab": patch +--- + +Fix hover lag on large pages by hit-testing behind a pointer shield instead of flipping `pointer-events` on the document root, coalescing scroll re-detection into a frame, and caching visual viewport reads. diff --git a/packages/react-grab/e2e/hit-test-shield.spec.ts b/packages/react-grab/e2e/hit-test-shield.spec.ts new file mode 100644 index 000000000..ef6e3a1af --- /dev/null +++ b/packages/react-grab/e2e/hit-test-shield.spec.ts @@ -0,0 +1,161 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./fixtures.js"; + +interface ShieldPanelRect { + left: number; + top: number; + right: number; + bottom: number; +} + +interface ScrollState { + container: number; + window: number; +} + +const readShieldPanelRects = (page: Page): Promise => + page.evaluate(() => { + const container = document.querySelector("[data-react-grab-hit-test-shield]"); + if (!container) return null; + return [...container.children].map((panel) => { + const rect = panel.getBoundingClientRect(); + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; + }); + }); + +const isPointCovered = (panelRects: ShieldPanelRect[], pointX: number, pointY: number): boolean => + panelRects.some( + (rect) => + pointX >= rect.left && pointX <= rect.right && pointY >= rect.top && pointY <= rect.bottom, + ); + +const readScrollState = (page: Page): Promise => + page.evaluate(() => ({ + container: document.querySelector('[data-testid="scroll-container"]')?.scrollTop ?? -1, + window: window.scrollY, + })); + +test.describe("Hit Test Shield", () => { + // The shield covers the viewport, so a wheel event targets the shield and the + // browser would scroll the page instead of the container under the pointer. + test("should scroll the container under the pointer, not the page", async ({ + reactGrab, + page, + }) => { + const container = page.getByTestId("scroll-container"); + await container.scrollIntoViewIfNeeded(); + const containerBox = await container.boundingBox(); + expect(containerBox).not.toBeNull(); + if (!containerBox) return; + + await reactGrab.activate(); + await page.mouse.move( + containerBox.x + containerBox.width / 2, + containerBox.y + containerBox.height / 2, + { steps: 3 }, + ); + + const beforeScroll = await readScrollState(page); + await page.mouse.wheel(0, 300); + await expect + .poll(async () => (await readScrollState(page)).container) + .toBeGreaterThan(beforeScroll.container); + expect((await readScrollState(page)).window).toBe(beforeScroll.window); + }); + + test("should leave page scrolling native when nothing under the pointer scrolls", async ({ + reactGrab, + page, + }) => { + const heading = page.getByRole("heading", { level: 1 }).first(); + await heading.scrollIntoViewIfNeeded(); + const headingBox = await heading.boundingBox(); + expect(headingBox).not.toBeNull(); + if (!headingBox) return; + + await reactGrab.activate(); + await page.mouse.move( + headingBox.x + headingBox.width / 2, + headingBox.y + headingBox.height / 2, + { steps: 3 }, + ); + + const beforeScroll = await readScrollState(page); + await page.mouse.wheel(0, 300); + await expect + .poll(async () => (await readScrollState(page)).window) + .toBeGreaterThan(beforeScroll.window); + }); + + test("should cut a hole for a same-origin frame that covers the viewport", async ({ + reactGrab, + }) => { + const page = reactGrab.page; + await page.evaluate(async () => { + const iframeElement = document.createElement("iframe"); + iframeElement.dataset.testid = "full-viewport-iframe"; + iframeElement.srcdoc = `full viewport frame`; + iframeElement.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;border:0"; + const didLoad = new Promise((resolve) => { + iframeElement.addEventListener("load", () => resolve(), { once: true }); + }); + document.body.append(iframeElement); + await didLoad; + }); + + await page.evaluate(() => window.freezeReactGrab()); + + const viewportSize = page.viewportSize(); + expect(viewportSize).not.toBeNull(); + if (!viewportSize) return; + + await expect + .poll(async () => { + const panelRects = await readShieldPanelRects(page); + if (!panelRects) return null; + return isPointCovered(panelRects, viewportSize.width / 2, viewportSize.height / 2); + }) + .toBe(false); + + await page.evaluate(() => { + window.unfreezeReactGrab(); + document.querySelector('[data-testid="full-viewport-iframe"]')?.remove(); + }); + }); + + test("should cut a hole for a same-origin frame inside a shadow root", async ({ reactGrab }) => { + const page = reactGrab.page; + const frameBox = await page.evaluate(async () => { + const hostElement = document.createElement("div"); + const shadowRoot = hostElement.attachShadow({ mode: "open" }); + const iframeElement = document.createElement("iframe"); + iframeElement.srcdoc = `shadow frame`; + iframeElement.style.cssText = + "position:fixed;left:40px;top:40px;width:200px;height:150px;border:0"; + const didLoad = new Promise((resolve) => { + iframeElement.addEventListener("load", () => resolve(), { once: true }); + }); + shadowRoot.append(iframeElement); + document.body.append(hostElement); + hostElement.dataset.testid = "shadow-frame-host"; + await didLoad; + const rect = iframeElement.getBoundingClientRect(); + return { centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2 }; + }); + + await page.evaluate(() => window.freezeReactGrab()); + + await expect + .poll(async () => { + const panelRects = await readShieldPanelRects(page); + if (!panelRects || panelRects.length === 0) return null; + return isPointCovered(panelRects, frameBox.centerX, frameBox.centerY); + }) + .toBe(false); + + await page.evaluate(() => { + window.unfreezeReactGrab(); + document.querySelector('[data-testid="shadow-frame-host"]')?.remove(); + }); + }); +}); diff --git a/packages/react-grab/e2e/style-invalidation.spec.ts b/packages/react-grab/e2e/style-invalidation.spec.ts new file mode 100644 index 000000000..723c8ce1e --- /dev/null +++ b/packages/react-grab/e2e/style-invalidation.spec.ts @@ -0,0 +1,94 @@ +// Regression guard for whole-document style invalidation during hover. +// +// The pointer-events freeze has to flip between "page frozen" and "hit-testable" +// on every element detection. Doing that by adding/removing a stylesheet (or +// toggling `HTMLStyleElement.disabled`) changes the document's active sheet set, +// which makes Blink re-collect matching rules for EVERY element: profiled at +// ~20-35ms per flip on an 8k-element page, twice per hover interruption, which +// is enough to drop 2-3 frames every time the pointer pauses and moves again. +// +// Counting restyled elements rather than milliseconds keeps this deterministic +// across machines: a scoped flip touches the root and inherits down, so no +// single recalc should approach the size of the document. +import { expect, goToHeavyView, test } from "./perf-fixtures.js"; +import { idleFrame } from "./perf-recorder.js"; + +interface StyleRecalcSample { + elementCount: number; + durationMs: number; +} + +const FULL_DOCUMENT_RESTYLE_RATIO = 0.5; + +// Playwright types every trace event field as a string, but UpdateLayoutTree +// carries the restyled element count as a number under `args`. +const readRestyledElementCount = (traceEvent: { args?: unknown }): number => { + if (typeof traceEvent.args !== "object" || traceEvent.args === null) return 0; + const elementCount = Reflect.get(traceEvent.args, "elementCount"); + return typeof elementCount === "number" ? elementCount : 0; +}; + +test.describe("style invalidation", () => { + test("hovering and scrolling never restyles the whole document", async ({ reactGrab, page }) => { + await goToHeavyView(page, "all"); + const documentElementCount = await page.evaluate(() => document.querySelectorAll("*").length); + expect(documentElementCount).toBeGreaterThan(1000); + + const client = await page.context().newCDPSession(page); + const recalcSamples: StyleRecalcSample[] = []; + client.on("Tracing.dataCollected", ({ value }) => { + for (const traceEvent of value) { + if (traceEvent.name !== "UpdateLayoutTree") continue; + recalcSamples.push({ + elementCount: readRestyledElementCount(traceEvent), + durationMs: (Number(traceEvent.dur) || 0) / 1000, + }); + } + }); + + await reactGrab.activate(); + await page.mouse.move(600, 400, { steps: 2 }); + await idleFrame(page, 2); + + await client.send("Tracing.start", { + transferMode: "ReportEvents", + traceConfig: { includedCategories: ["devtools.timeline"] }, + }); + + // Bursts with pauses between them: the pause lets the debounced freeze + // resume land, so the next burst has to flip back to hit-test mode. A + // continuous sweep would coalesce the flips and hide the regression. + for (let burstIndex = 0; burstIndex < 6; burstIndex++) { + for (let stepIndex = 0; stepIndex < 8; stepIndex++) { + await page.mouse.move( + 500 + ((stepIndex * 37) % 400), + 250 + ((burstIndex * 61 + stepIndex * 23) % 400), + { steps: 1 }, + ); + await page.mouse.wheel(0, 100); + } + await page.waitForTimeout(220); + } + await idleFrame(page, 2); + + const tracingComplete = new Promise((resolve) => { + client.once("Tracing.tracingComplete", () => resolve()); + }); + await client.send("Tracing.end"); + await tracingComplete; + await reactGrab.deactivate(); + + expect(recalcSamples.length).toBeGreaterThan(0); + const fullDocumentRestyles = recalcSamples.filter( + (sample) => sample.elementCount > documentElementCount * FULL_DOCUMENT_RESTYLE_RATIO, + ); + expect( + fullDocumentRestyles, + `${fullDocumentRestyles.length} recalc(s) restyled over ${Math.round( + documentElementCount * FULL_DOCUMENT_RESTYLE_RATIO, + )} of ${documentElementCount} elements: ${fullDocumentRestyles + .map((sample) => `${sample.elementCount} elements/${sample.durationMs.toFixed(1)}ms`) + .join(", ")}`, + ).toHaveLength(0); + }); +}); diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 8ae7c499d..3946316c2 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -85,6 +85,7 @@ 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 VISUAL_VIEWPORT_CACHE_TTL_MS = 16; export const BORDER_RADIUS_CACHE_TTL_MS = 200; export const BORDER_RADIUS_SCALE_PRECISION_DECIMAL_PLACES = 3; export const BOUNDS_RECALC_INTERVAL_MS = 100; @@ -95,6 +96,22 @@ export const AUTO_SCROLL_SPEED_PX = 10; export const Z_INDEX_OVERLAY = 2147483647; export const Z_INDEX_OVERLAY_CANVAS = 2147483645; +// Below react-grab's own overlays so the toolbar stays clickable, above page +// content so the shield absorbs the page's hover, focus, and click. Page content +// stacked inside react-grab's own range (2147483645 and up) paints over the +// shield and keeps receiving hover; staying below the overlays is the tradeoff +// that keeps the toolbar usable. +export const Z_INDEX_HIT_TEST_SHIELD = 2147483644; +// Subtracting N overlapping frame holes can partition the viewport into O(N^2) +// rectangles, so past this count the shield falls back to one hole spanning +// every frame rather than creating unbounded panels on each scroll frame. +export const HIT_TEST_SHIELD_MAX_PANELS = 12; +// deltaMode line/page wheel events (Firefox, some mice) carry notch counts +// instead of pixels; a line is worth roughly one line box. +export const WHEEL_LINE_DELTA_PX = 16; +// Subpixel scroll positions never reach the exact scrollable extent, so scroll +// room is measured with a tolerance before declaring an axis exhausted. +export const SCROLL_ROOM_EPSILON_PX = 1; export const DOCUMENT_NODE_TYPE = 9; export const DRAG_LERP_FACTOR = 0.7; @@ -180,6 +197,7 @@ export const ARROW_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRi export const FROZEN_ELEMENT_ATTRIBUTE = "data-react-grab-frozen"; export const SAME_ORIGIN_FRAME_ATTRIBUTE = "data-react-grab-same-origin-frame"; +export const HIT_TEST_SHIELD_ATTRIBUTE = "data-react-grab-hit-test-shield"; // Pausing animations individually via WAAPI avoids the full-document style // recalc that a universal `*` selector forces — profiled at ~62ms on a real @@ -267,7 +285,6 @@ export const HIERARCHY_INDENT_PX = 12; export const ELEMENT_POSITION_CACHE_DISTANCE_THRESHOLD_PX = 2; export const ELEMENT_POSITION_THROTTLE_MS = 16; -export const POINTER_EVENTS_RESUME_DEBOUNCE_MS = 100; export const VISIBILITY_CACHE_TTL_MS = 50; export const ZOOM_DETECTION_THRESHOLD = 0.01; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 90cc596e6..e7918d63f 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -58,6 +58,7 @@ import { getElementsInDrag } from "../utils/get-elements-in-drag.js"; import { getElementAnchorRatio } from "../utils/get-element-anchor-ratio.js"; import { createElementBounds } from "../utils/create-element-bounds.js"; import { invalidateInteractionCaches } from "../utils/invalidate-interaction-caches.js"; +import { refreshPointerEventsFreezeShields } from "../utils/pointer-events-freeze.js"; import { normalizeErrorMessage } from "../utils/normalize-error.js"; import { createBoundsFromDragRect, @@ -3278,15 +3279,34 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { let boundsRecalcIntervalId: number | null = null; let viewportChangeFrameId: number | null = null; + let scrollChangeFrameId: number | null = null; const handleViewportChange = () => { invalidateInteractionCaches(); + refreshPointerEventsFreezeShields(); redetectElementUnderPointer(); setScrollVersion((version) => version + 1); actions.incrementViewportVersion(); actions.updateContextMenuPosition(); }; + // A trackpad gesture emits scroll events far faster than the display + // refreshes, and every one of them would otherwise re-run a hit test plus a + // full reactive bounds pass. Coalescing into a frame keeps the overlay in + // step with the scrolled paint (rAF runs before paint) while collapsing the + // burst into a single update. + const scheduleViewportChange = () => { + // Cache invalidation stays synchronous: it is a handful of map clears, and + // deferring it would let a pointer or context-menu hit test in the same + // frame resolve geometry from before the scroll. + invalidateInteractionCaches(); + if (scrollChangeFrameId !== null) return; + scrollChangeFrameId = nativeRequestAnimationFrame(() => { + scrollChangeFrameId = null; + handleViewportChange(); + }); + }; + // Unlike scroll, resize can flip visibility synchronously (media and // container queries), so the visibility cache's TTL is not a safe // staleness bound here. Resize is rare enough that the extra @@ -3296,8 +3316,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { handleViewportChange(); }; - eventListenerManager.addWindowListener("scroll", handleViewportChange, { + eventListenerManager.addWindowListener("scroll", scheduleViewportChange, { capture: true, + passive: true, }); let previousViewportWidth = window.innerWidth; @@ -3333,7 +3354,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { visualViewport.addEventListener("resize", handleViewportResize, { signal, }); - visualViewport.addEventListener("scroll", handleViewportChange, { + visualViewport.addEventListener("scroll", scheduleViewportChange, { signal, }); } @@ -3383,6 +3404,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (viewportChangeFrameId !== null) { nativeCancelAnimationFrame(viewportChangeFrameId); } + if (scrollChangeFrameId !== null) { + nativeCancelAnimationFrame(scrollChangeFrameId); + } }); eventListenerManager.addDocumentListener( diff --git a/packages/react-grab/src/utils/create-hit-test-shield.ts b/packages/react-grab/src/utils/create-hit-test-shield.ts new file mode 100644 index 000000000..bab7ed87e --- /dev/null +++ b/packages/react-grab/src/utils/create-hit-test-shield.ts @@ -0,0 +1,200 @@ +import type { Rect } from "../types.js"; +import { + HIT_TEST_SHIELD_ATTRIBUTE, + HIT_TEST_SHIELD_MAX_PANELS, + WHEEL_LINE_DELTA_PX, + Z_INDEX_HIT_TEST_SHIELD, +} from "../constants.js"; +import { findScrollableAncestor } from "./find-scrollable-ancestor.js"; +import { getDeepElementInDocumentAtPoint } from "./get-deep-element-at-point.js"; +import { hideFromThirdParties } from "./hide-from-third-parties.js"; +import { subtractRect } from "./subtract-rect.js"; + +// A shield on top of the page absorbs hover, focus, and click instead of +// `html { pointer-events: none }`. Both suppress page interaction identically, +// but pointer-events is inherited, so flipping it on the root to run a hit test +// restyles every element in the document (profiled at 4.2ms on 5.6k elements and +// 31-35ms on 16k). The shield's panels are leaves, so gating pointer-events on +// them restyles at most a handful of elements and stays affordable to do +// synchronously around every hit test. +// +// The gate deliberately avoids `display`: the container is viewport-sized and +// position:fixed, so hiding it destroys and rebuilds its composited layer on +// every hit test. That is invisible on a GPU but showed up on CI's software +// rasterizer as p95 frame time climbing from 58ms to 292ms while deactivating +// with many frozen elements, plus GPU-process CPU regressions across scenarios. +// +// Same-origin iframes are cut out of the shield: they must keep receiving wheel +// events to scroll natively, and their own documents get their own shield. +export interface HitTestShield { + /** Lets document hit-testing reach page content again. */ + openForHitTest: () => void; + closeAfterHitTest: () => void; + refreshHoles: () => void; + remove: () => void; +} + +// Any value a computed signature cannot produce, including the empty string that +// a viewport-covering frame leaves behind once every panel is subtracted away. +const FULL_VIEWPORT_PANEL_SIGNATURE = "full-viewport"; + +const createPanel = (targetDocument: Document, pointerEvents: string): HTMLDivElement => { + const panel = targetDocument.createElement("div"); + // Marked individually so isReactGrabElement recognizes a panel on its own — + // hover walks read the browser's :hover chain, which lands on a panel rather + // than on the container. + panel.setAttribute(HIT_TEST_SHIELD_ATTRIBUTE, ""); + panel.style.position = "absolute"; + panel.style.pointerEvents = pointerEvents; + panel.style.background = "transparent"; + return panel; +}; + +const readHoleRects = (holeElements: Iterable): Rect[] => { + const holeRects: Rect[] = []; + for (const holeElement of holeElements) { + const holeRect = holeElement.getBoundingClientRect(); + if (holeRect.width <= 0 || holeRect.height <= 0) continue; + holeRects.push({ + left: holeRect.left, + top: holeRect.top, + right: holeRect.right, + bottom: holeRect.bottom, + }); + } + return holeRects; +}; + +const boundingRect = (rects: readonly Rect[]): Rect => { + const bounds: Rect = { ...rects[0] }; + for (const rect of rects) { + if (rect.left < bounds.left) bounds.left = rect.left; + if (rect.top < bounds.top) bounds.top = rect.top; + if (rect.right > bounds.right) bounds.right = rect.right; + if (rect.bottom > bounds.bottom) bounds.bottom = rect.bottom; + } + return bounds; +}; + +export const createHitTestShield = ( + targetDocument: Document, + collectHoleElements: () => Iterable, +): HitTestShield => { + const container = targetDocument.createElement("div"); + container.setAttribute(HIT_TEST_SHIELD_ATTRIBUTE, ""); + container.setAttribute("aria-hidden", "true"); + hideFromThirdParties(container); + container.style.cssText = + "position:fixed;inset:0;pointer-events:none;contain:strict;background:transparent;" + + `z-index:${Z_INDEX_HIT_TEST_SHIELD};`; + + let isOpenForHitTest = false; + const panelPointerEvents = (): string => (isOpenForHitTest ? "none" : "auto"); + + const fullViewportPanel = createPanel(targetDocument, panelPointerEvents()); + fullViewportPanel.style.inset = "0"; + container.appendChild(fullViewportPanel); + (targetDocument.body ?? targetDocument.documentElement).appendChild(container); + + let panels: HTMLDivElement[] = [fullViewportPanel]; + let appliedPanelSignature = FULL_VIEWPORT_PANEL_SIGNATURE; + + const setGateOpen = (isOpen: boolean): void => { + if (isOpenForHitTest === isOpen) return; + isOpenForHitTest = isOpen; + const pointerEvents = panelPointerEvents(); + for (const panel of panels) panel.style.pointerEvents = pointerEvents; + }; + + const readElementBeneathShield = (clientX: number, clientY: number): Element | null => { + const wasOpenForHitTest = isOpenForHitTest; + setGateOpen(true); + const element = getDeepElementInDocumentAtPoint(targetDocument, clientX, clientY); + setGateOpen(wasOpenForHitTest); + return element; + }; + + // A wheel event hit-tests to the shield, so the browser would scroll the + // shield's own chain — the page — instead of the container under the pointer. + // Re-applying the delta to the real scroll target keeps nested scrollers + // working; when the page is the only thing that can scroll we stay out of the + // way and let the native compositor scroll run. + const handleWheel = (event: WheelEvent): void => { + let deltaX = event.deltaX; + let deltaY = event.deltaY; + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { + deltaX *= WHEEL_LINE_DELTA_PX; + deltaY *= WHEEL_LINE_DELTA_PX; + } else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + deltaX *= targetDocument.documentElement.clientWidth; + deltaY *= targetDocument.documentElement.clientHeight; + } + if (deltaX === 0 && deltaY === 0) return; + + const elementBeneathShield = readElementBeneathShield(event.clientX, event.clientY); + if (!elementBeneathShield) return; + + const scrollTarget = findScrollableAncestor(elementBeneathShield, deltaX, deltaY); + if (!scrollTarget) return; + + event.preventDefault(); + scrollTarget.scrollBy({ left: deltaX, top: deltaY, behavior: "instant" }); + }; + + container.addEventListener("wheel", handleWheel, { passive: false }); + + return { + openForHitTest: () => { + setGateOpen(true); + }, + closeAfterHitTest: () => { + setGateOpen(false); + }, + refreshHoles: () => { + const holeRects = readHoleRects(collectHoleElements()); + if (holeRects.length === 0) { + if (appliedPanelSignature === FULL_VIEWPORT_PANEL_SIGNATURE) return; + appliedPanelSignature = FULL_VIEWPORT_PANEL_SIGNATURE; + fullViewportPanel.style.pointerEvents = panelPointerEvents(); + panels = [fullViewportPanel]; + container.replaceChildren(fullViewportPanel); + return; + } + + const viewportRect: Rect = { + left: 0, + top: 0, + right: targetDocument.documentElement.clientWidth, + bottom: targetDocument.documentElement.clientHeight, + }; + let panelRects = subtractRect([viewportRect], holeRects[0]); + for (let index = 1; index < holeRects.length; index++) { + panelRects = subtractRect(panelRects, holeRects[index]); + } + if (panelRects.length > HIT_TEST_SHIELD_MAX_PANELS) { + panelRects = subtractRect([viewportRect], boundingRect(holeRects)); + } + + const nextSignature = panelRects + .map((rect) => `${rect.left},${rect.top},${rect.right},${rect.bottom}`) + .join("|"); + if (nextSignature === appliedPanelSignature) return; + appliedPanelSignature = nextSignature; + + const pointerEvents = panelPointerEvents(); + panels = panelRects.map((rect) => { + const panel = createPanel(targetDocument, pointerEvents); + panel.style.left = `${rect.left}px`; + panel.style.top = `${rect.top}px`; + panel.style.width = `${rect.right - rect.left}px`; + panel.style.height = `${rect.bottom - rect.top}px`; + return panel; + }); + container.replaceChildren(...panels); + }, + remove: () => { + container.removeEventListener("wheel", handleWheel); + container.remove(); + }, + }; +}; diff --git a/packages/react-grab/src/utils/find-scrollable-ancestor.ts b/packages/react-grab/src/utils/find-scrollable-ancestor.ts new file mode 100644 index 000000000..895a8623a --- /dev/null +++ b/packages/react-grab/src/utils/find-scrollable-ancestor.ts @@ -0,0 +1,65 @@ +import { SCROLL_ROOM_EPSILON_PX } from "../constants.js"; +import { getComposedParentElement } from "./get-composed-parent-element.js"; + +const SCROLLABLE_OVERFLOW_VALUES = new Set(["auto", "scroll", "overlay"]); + +const canScrollAxis = ( + overflow: string, + scrollPosition: number, + clientSize: number, + scrollSize: number, + delta: number, +): boolean => { + if (!SCROLLABLE_OVERFLOW_VALUES.has(overflow)) return false; + if (delta < 0) return scrollPosition > SCROLL_ROOM_EPSILON_PX; + return scrollPosition + clientSize < scrollSize - SCROLL_ROOM_EPSILON_PX; +}; + +// Mirrors the browser's scroll chain for a wheel event that the hit-test shield +// intercepted: the nearest ancestor that scrolls the wheeled axis and still has +// room left in that direction. Returns null when nothing but the page itself can +// scroll, which lets the caller leave the native (compositor-driven) page scroll +// alone instead of emulating it on the main thread. +export const findScrollableAncestor = ( + element: Element, + deltaX: number, + deltaY: number, +): Element | null => { + let current: Element | null = element; + + while (current) { + const hasVerticalOverflow = deltaY !== 0 && current.scrollHeight > current.clientHeight; + const hasHorizontalOverflow = deltaX !== 0 && current.scrollWidth > current.clientWidth; + // getComputedStyle is the expensive half, so overflowing size gates it. + if (hasVerticalOverflow || hasHorizontalOverflow) { + const style = getComputedStyle(current); + if ( + hasVerticalOverflow && + canScrollAxis( + style.overflowY, + current.scrollTop, + current.clientHeight, + current.scrollHeight, + deltaY, + ) + ) { + return current; + } + if ( + hasHorizontalOverflow && + canScrollAxis( + style.overflowX, + current.scrollLeft, + current.clientWidth, + current.scrollWidth, + deltaX, + ) + ) { + return current; + } + } + current = getComposedParentElement(current); + } + + return null; +}; diff --git a/packages/react-grab/src/utils/get-deep-element-at-point.ts b/packages/react-grab/src/utils/get-deep-element-at-point.ts index fdcfdd0cc..eb4d80c39 100644 --- a/packages/react-grab/src/utils/get-deep-element-at-point.ts +++ b/packages/react-grab/src/utils/get-deep-element-at-point.ts @@ -2,7 +2,7 @@ import { convertParentPositionToIframe } from "./convert-parent-position-to-ifra import { getAccessibleIframeDocument } from "./get-accessible-iframe-document.js"; import { isIframeElement } from "./is-iframe-element.js"; -const getDeepElementInDocumentAtPoint = ( +export const getDeepElementInDocumentAtPoint = ( targetDocument: Document, clientX: number, clientY: number, 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 e013031c2..8ffd63b3f 100644 --- a/packages/react-grab/src/utils/get-element-at-position.ts +++ b/packages/react-grab/src/utils/get-element-at-position.ts @@ -2,7 +2,6 @@ import type { Rect } from "../types.js"; import { ELEMENT_POSITION_CACHE_DISTANCE_THRESHOLD_PX, ELEMENT_POSITION_THROTTLE_MS, - POINTER_EVENTS_RESUME_DEBOUNCE_MS, } from "../constants.js"; import { createElementBounds } from "./create-element-bounds.js"; import { getAccessibleIframeDocument } from "./get-accessible-iframe-document.js"; @@ -38,24 +37,6 @@ interface InaccessibleIframePositionCache { let positionCache: PositionCache | null = null; let inaccessibleIframePositionCache: InaccessibleIframePositionCache | null = null; -let pointerEventsResumeTimerId: ReturnType | null = null; - -const schedulePointerEventsResume = (): void => { - if (pointerEventsResumeTimerId !== null) { - clearTimeout(pointerEventsResumeTimerId); - } - pointerEventsResumeTimerId = setTimeout(() => { - pointerEventsResumeTimerId = null; - resumePointerEventsFreeze(); - }, POINTER_EVENTS_RESUME_DEBOUNCE_MS); -}; - -const cancelScheduledPointerEventsResume = (): void => { - if (pointerEventsResumeTimerId !== null) { - clearTimeout(pointerEventsResumeTimerId); - pointerEventsResumeTimerId = null; - } -}; const isWithinThreshold = (x1: number, y1: number, x2: number, y2: number): boolean => { const deltaX = Math.abs(x1 - x2); @@ -81,7 +62,6 @@ const resolveValidElementAtPoint = ( export const getElementsAtPoint = (clientX: number, clientY: number): Element[] => { if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return []; - cancelScheduledPointerEventsResume(); suspendPointerEventsFreeze(); try { const elements = getDeepElementsAtPoint(clientX, clientY); @@ -134,83 +114,78 @@ export const getElementsAtPoint = (clientX: number, clientY: number): Element[] } return resolvedElements; } finally { - schedulePointerEventsResume(); + resumePointerEventsFreeze(); } }; export const getElementAtPosition = (clientX: number, clientY: number): Element | null => { if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; const now = performance.now(); - - // Inaccessible iframes can only resolve to the iframe element itself. Reusing - // its bounds avoids repeating the pointer-events toggle and full hit test on - // every move. Accessibility is checked again so a later same-origin navigation - // immediately leaves this fast path and resumes deep element detection. - if (inaccessibleIframePositionCache) { - const cachedIframe = inaccessibleIframePositionCache.element; - const isCacheFresh = - now - inaccessibleIframePositionCache.timestamp < ELEMENT_POSITION_THROTTLE_MS; - if ( - cachedIframe.isConnected && - isCacheFresh && - isPointInsideRect(clientX, clientY, inaccessibleIframePositionCache.bounds) && - !getAccessibleIframeDocument(cachedIframe) - ) { - return cachedIframe; + // Hit testing needs the page interactive, so the shield comes down for the + // whole detection: the cached fast paths below run caretPositionFromPoint, + // which would otherwise resolve to the shield instead of page text. Gating it + // synchronously (rather than on a debounce, as the old root pointer-events + // flip required) is affordable because hiding the shield restyles one leaf + // element instead of the whole document. + // Alternatives explored and rejected: + // - IntersectionObserver pre-population: adds 1-frame latency to every poll + // - generic bounds-check cache: ignores z-index/stacking, causing hover + // detection misses; the cache below is limited to inaccessible iframes + suspendPointerEventsFreeze(); + try { + // Inaccessible iframes can only resolve to the iframe element itself. Reusing + // its bounds avoids repeating the full hit test on every move. + // Accessibility is checked again so a later same-origin navigation + // immediately leaves this fast path and resumes deep element detection. + if (inaccessibleIframePositionCache) { + const cachedIframe = inaccessibleIframePositionCache.element; + const isCacheFresh = + now - inaccessibleIframePositionCache.timestamp < ELEMENT_POSITION_THROTTLE_MS; + if ( + cachedIframe.isConnected && + isCacheFresh && + isPointInsideRect(clientX, clientY, inaccessibleIframePositionCache.bounds) && + !getAccessibleIframeDocument(cachedIframe) + ) { + return cachedIframe; + } + inaccessibleIframePositionCache = null; + if (positionCache?.element === cachedIframe) positionCache = null; } - inaccessibleIframePositionCache = null; - if (positionCache?.element === cachedIframe) positionCache = null; - } - if (positionCache) { - const isPositionClose = isWithinThreshold( - clientX, - clientY, - positionCache.clientX, - positionCache.clientY, - ); - const isWithinThrottle = now - positionCache.timestamp < ELEMENT_POSITION_THROTTLE_MS; - - if (isPositionClose && isWithinThrottle) { - if (!positionCache.preciseHitElement) return positionCache.element; - - const localContentElement = getLocalContentElementAtPoint( - positionCache.preciseHitElement, + if (positionCache) { + const isPositionClose = isWithinThreshold( clientX, clientY, + positionCache.clientX, + positionCache.clientY, ); - if (localContentElement) { - const localContentResult = resolveValidElementAtPoint( - localContentElement, + const isWithinThrottle = now - positionCache.timestamp < ELEMENT_POSITION_THROTTLE_MS; + + if (isPositionClose && isWithinThrottle) { + if (!positionCache.preciseHitElement) return positionCache.element; + + const localContentElement = getLocalContentElementAtPoint( + positionCache.preciseHitElement, clientX, clientY, ); - if (localContentResult) return localContentResult; + 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) + ); } - if (!positionCache.usesTextHitTesting) return positionCache.fallbackElement; - return ( - resolveValidElementAtPoint(positionCache.preciseHitElement, clientX, clientY) ?? - getDeepFallbackElementAtPoint(clientX, clientY) - ); } - } - // 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 (schedulePointerEventsResume) 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 - // - generic bounds-check cache: ignores z-index/stacking, causing hover - // detection misses; the cache below is limited to inaccessible iframes - // - transparent overlay instead of pointer-events: none: leaks CSS-only :hover - // dropdowns/tooltips during the hit-test toggle - cancelScheduledPointerEventsResume(); - suspendPointerEventsFreeze(); - try { let result: Element | null = null; // elementFromPoint returns the topmost element, but if it's not grabbable @@ -260,13 +235,11 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element }; return result; } finally { - schedulePointerEventsResume(); + resumePointerEventsFreeze(); } }; export const clearElementPositionCache = (): void => { - cancelScheduledPointerEventsResume(); - resumePointerEventsFreeze(); positionCache = null; inaccessibleIframePositionCache = null; }; diff --git a/packages/react-grab/src/utils/get-visual-viewport.ts b/packages/react-grab/src/utils/get-visual-viewport.ts index 7c0357349..f596a8523 100644 --- a/packages/react-grab/src/utils/get-visual-viewport.ts +++ b/packages/react-grab/src/utils/get-visual-viewport.ts @@ -1,3 +1,4 @@ +import { VISUAL_VIEWPORT_CACHE_TTL_MS } from "../constants.js"; import { getScopeContainer } from "./runtime-mode.js"; interface VisualViewportInfo { @@ -7,31 +8,59 @@ interface VisualViewportInfo { offsetTop: number; } -export const getVisualViewport = (): VisualViewportInfo => { - const scopeContainer = getScopeContainer(); +// Reading window.visualViewport (or the scope container's rect) flushes pending +// style and layout, and the toolbar/label position memos call this on every +// pointer move and scroll frame — profiled at ~25ms of self time across a 3s +// hover-and-scroll session. Only the measurement is cached; callers still get +// their own object, so retaining one can never observe a later viewport. +const cachedViewport: VisualViewportInfo = { + width: 0, + height: 0, + offsetLeft: 0, + offsetTop: 0, +}; +let cachedScopeContainer: Element | null = null; +let cacheTimestamp = Number.NEGATIVE_INFINITY; + +export const invalidateVisualViewportCache = (): void => { + cacheTimestamp = Number.NEGATIVE_INFINITY; +}; + +const measureViewport = (scopeContainer: Element | null): void => { if (scopeContainer) { const rect = scopeContainer.getBoundingClientRect(); - return { - width: rect.width, - height: rect.height, - offsetLeft: rect.left, - offsetTop: rect.top, - }; + cachedViewport.width = rect.width; + cachedViewport.height = rect.height; + cachedViewport.offsetLeft = rect.left; + cachedViewport.offsetTop = rect.top; + return; } const visualViewport = window.visualViewport; - if (visualViewport) { - return { - width: visualViewport.width, - height: visualViewport.height, - offsetLeft: visualViewport.offsetLeft, - offsetTop: visualViewport.offsetTop, - }; + cachedViewport.width = visualViewport?.width ?? window.innerWidth; + cachedViewport.height = visualViewport?.height ?? window.innerHeight; + cachedViewport.offsetLeft = visualViewport?.offsetLeft ?? 0; + cachedViewport.offsetTop = visualViewport?.offsetTop ?? 0; +}; + +export const getVisualViewport = (): VisualViewportInfo => { + const scopeContainer = getScopeContainer(); + const now = performance.now(); + // Keyed by scope because a dispose and re-init within the TTL would otherwise + // position against the previous scope's dimensions. + if ( + scopeContainer !== cachedScopeContainer || + now - cacheTimestamp >= VISUAL_VIEWPORT_CACHE_TTL_MS + ) { + cachedScopeContainer = scopeContainer; + cacheTimestamp = now; + measureViewport(scopeContainer); } + return { - width: window.innerWidth, - height: window.innerHeight, - offsetLeft: 0, - offsetTop: 0, + width: cachedViewport.width, + height: cachedViewport.height, + offsetLeft: cachedViewport.offsetLeft, + offsetTop: cachedViewport.offsetTop, }; }; diff --git a/packages/react-grab/src/utils/invalidate-interaction-caches.ts b/packages/react-grab/src/utils/invalidate-interaction-caches.ts index fd898cdc9..35202f8de 100644 --- a/packages/react-grab/src/utils/invalidate-interaction-caches.ts +++ b/packages/react-grab/src/utils/invalidate-interaction-caches.ts @@ -1,6 +1,7 @@ import { invalidateBoundsCache } from "./create-element-bounds.js"; import { invalidateElementTextBoundsCache } from "./get-element-text-bounds.js"; import { clearElementPositionCache } from "./get-element-at-position.js"; +import { invalidateVisualViewportCache } from "./get-visual-viewport.js"; // The visibility cache is intentionally NOT cleared here: this runs on every // scroll/resize event, and clearing it per event forced a getComputedStyle @@ -13,4 +14,5 @@ export const invalidateInteractionCaches = (): void => { invalidateBoundsCache(); invalidateElementTextBoundsCache(); clearElementPositionCache(); + invalidateVisualViewportCache(); }; diff --git a/packages/react-grab/src/utils/is-react-grab-element.ts b/packages/react-grab/src/utils/is-react-grab-element.ts index 759f7563c..1c322fbca 100644 --- a/packages/react-grab/src/utils/is-react-grab-element.ts +++ b/packages/react-grab/src/utils/is-react-grab-element.ts @@ -1,8 +1,10 @@ +import { HIT_TEST_SHIELD_ATTRIBUTE } from "../constants.js"; import { isReactGrabHost } from "./is-react-grab-host.js"; import { isShadowRoot } from "./is-shadow-root.js"; export const isReactGrabElement = (element: Element): boolean => { if (isReactGrabHost(element)) return true; + if (element.hasAttribute(HIT_TEST_SHIELD_ATTRIBUTE)) return true; const rootNode = element.getRootNode(); return isShadowRoot(rootNode) && isReactGrabHost(rootNode.host); diff --git a/packages/react-grab/src/utils/pointer-events-freeze.ts b/packages/react-grab/src/utils/pointer-events-freeze.ts index 453cc9839..6f376cbd6 100644 --- a/packages/react-grab/src/utils/pointer-events-freeze.ts +++ b/packages/react-grab/src/utils/pointer-events-freeze.ts @@ -1,110 +1,132 @@ import { SAME_ORIGIN_FRAME_ATTRIBUTE } from "../constants.js"; +import { createHitTestShield, type HitTestShield } from "./create-hit-test-shield.js"; import { createStyleElement } from "./create-style-element.js"; +import { getWindowFrameElement } from "./get-window-frame-element.js"; -// We apply pointer-events:none on `html` rather than `*` because pointer-events -// is inherited, so toggling it on a single root element is O(1) style invalidation -// instead of O(N) for every DOM node, which caused visible lag on dense DOMs -// like GitHub diff viewers with 10k+ nodes. -// Same-origin iframe elements stay interactive for native viewport scrolling, -// while each accessible frame document receives this root freeze too. Their -// forwarded input can still drive selection without page descendants reacting. -// @see https://github.com/aidenybai/react-grab/pull/209 -const POINTER_EVENTS_STYLES = `html { pointer-events: none !important; } -iframe[${SAME_ORIGIN_FRAME_ATTRIBUTE}] { pointer-events: auto !important; }`; - -// Enabled only during a hit-test (between suspend/resume). It must override -// pointer-events:none that the PAGE applied, not just ours — e.g. Radix (and -// other modal layers) set `body { pointer-events: none }` while a dropdown/ -// dialog is open so only the popover is interactive. Without this, our -// elementsFromPoint returns nothing outside the popover and react-grab can only -// select elements inside the open dropdown. +// A per-document shield (see create-hit-test-shield) blocks page interaction, +// which leaves this sheet responsible for the opposite problem: the page +// neutralizing its own content. Radix (and other modal layers) set +// `body { pointer-events: none }` while a dropdown or dialog is open so only the +// popover is interactive, which would leave our hit test unable to see anything +// outside it. Forcing the page hit-testable is safe because the shield, not +// pointer-events, is what keeps the page from reacting. // // Scoped to html/body (not `*`) on purpose: elements that set their OWN // pointer-events:none — the click-through dev-tool overlays we deliberately skip -// in isValidGrabbableElement — must keep reading as "none". Overriding only the -// inherited root value restores hit-testability for normal page content while -// leaving those self-set overlays untouched. `!important` beats Radix's -// inline `body.style.pointerEvents = "none"` (inline without !important loses to -// an !important rule), and a later-inserted sheet wins our own freeze. -const HIT_TEST_OVERRIDE_STYLES = "html, body { pointer-events: auto !important; }"; +// in isValidGrabbableElement — must keep reading as "none". `!important` beats +// Radix's inline `body.style.pointerEvents = "none"` (inline without !important +// loses to an !important rule). +// +// Same-origin iframes stay forced interactive so they keep scrolling natively +// through the shield's cut-outs. +// @see https://github.com/aidenybai/react-grab/pull/209 +const POINTER_EVENTS_STYLES = `html, body { pointer-events: auto !important; } +iframe[${SAME_ORIGIN_FRAME_ATTRIBUTE}] { pointer-events: auto !important; }`; -interface PointerEventsFreezeStyles { - pointerEventsStyle: HTMLStyleElement; - hitTestOverrideStyle: HTMLStyleElement; +interface PointerEventsFreezeLayer { + style: HTMLStyleElement; + shield: HitTestShield; } const registeredDocuments = new Set(); -const stylesByDocument = new Map(); +const layersByDocument = new Map(); let isInstalled = false; +// Counted rather than a boolean because hit tests nest: a drag scan holds the +// gate open while the helpers it calls open and close it themselves, and an +// inner close would leave the outer scan hit-testing the shield. +let hitTestDepth = 0; -const installDocumentStyles = (targetDocument: Document): void => { - if (stylesByDocument.has(targetDocument)) return; +const collectSameOriginFrames = (targetDocument: Document): Iterable => { + const frames = new Set( + targetDocument.querySelectorAll(`iframe[${SAME_ORIGIN_FRAME_ATTRIBUTE}]`), + ); + // querySelectorAll cannot reach into shadow roots, so every registered frame + // document also contributes the element hosting it — otherwise a frame inside + // a shadow root keeps the shield over it and loses native scrolling. + for (const registeredDocument of registeredDocuments) { + const frameElement = getWindowFrameElement(registeredDocument.defaultView); + if (frameElement?.ownerDocument === targetDocument) frames.add(frameElement); + } + return frames; +}; - const pointerEventsStyle = createStyleElement( +const installDocumentLayer = (targetDocument: Document): void => { + if (layersByDocument.has(targetDocument)) return; + + const style = createStyleElement( "data-react-grab-frozen-pseudo", POINTER_EVENTS_STYLES, targetDocument, ); - const hitTestOverrideStyle = createStyleElement( - "data-react-grab-hittest-override", - HIT_TEST_OVERRIDE_STYLES, - targetDocument, - ); - hitTestOverrideStyle.disabled = true; - stylesByDocument.set(targetDocument, { pointerEventsStyle, hitTestOverrideStyle }); + const shield = createHitTestShield(targetDocument, () => collectSameOriginFrames(targetDocument)); + // Reads iframe rects, so it forces a style flush — but only on documents that + // actually contain same-origin frames, which keeps the common case free of the + // extra recalc that freezeGlobalInteractions batches its writes to avoid. + shield.refreshHoles(); + if (hitTestDepth > 0) shield.openForHitTest(); + layersByDocument.set(targetDocument, { style, shield }); }; -const uninstallDocumentStyles = (targetDocument: Document): void => { - const styles = stylesByDocument.get(targetDocument); - if (!styles) return; - styles.pointerEventsStyle.remove(); - styles.hitTestOverrideStyle.remove(); - stylesByDocument.delete(targetDocument); +const uninstallDocumentLayer = (targetDocument: Document): void => { + const layer = layersByDocument.get(targetDocument); + if (!layer) return; + layer.style.remove(); + layer.shield.remove(); + layersByDocument.delete(targetDocument); }; export const isPointerEventsFreezeInstalled = (): boolean => isInstalled; export const registerPointerEventsFreezeDocument = (targetDocument: Document): (() => void) => { registeredDocuments.add(targetDocument); - if (isInstalled) installDocumentStyles(targetDocument); + if (isInstalled) { + installDocumentLayer(targetDocument); + // A frame appearing or leaving changes where the parent shield needs its + // cut-outs, not just which documents carry a shield. + refreshPointerEventsFreezeShields(); + } return () => { registeredDocuments.delete(targetDocument); - uninstallDocumentStyles(targetDocument); + uninstallDocumentLayer(targetDocument); + refreshPointerEventsFreezeShields(); }; }; export const installPointerEventsFreeze = (): void => { if (isInstalled) return; isInstalled = true; + hitTestDepth = 0; registeredDocuments.add(document); - for (const targetDocument of registeredDocuments) installDocumentStyles(targetDocument); + for (const targetDocument of registeredDocuments) installDocumentLayer(targetDocument); }; export const uninstallPointerEventsFreeze = (): void => { if (!isInstalled) return; isInstalled = false; - for (const targetDocument of [...stylesByDocument.keys()]) { - uninstallDocumentStyles(targetDocument); + hitTestDepth = 0; + for (const targetDocument of [...layersByDocument.keys()]) { + uninstallDocumentLayer(targetDocument); } }; -// Writing `.disabled` on a CSSStyleSheet element invalidates the affected -// selector tree even when the new value matches the old one in some engines, -// so we early-out when the desired state is already in effect. Continuous -// pointermove hits this hundreds of times per second. export const suspendPointerEventsFreeze = (): void => { if (!isInstalled) return; - for (const styles of stylesByDocument.values()) { - if (!styles.pointerEventsStyle.disabled) styles.pointerEventsStyle.disabled = true; - if (styles.hitTestOverrideStyle.disabled) styles.hitTestOverrideStyle.disabled = false; - } + hitTestDepth++; + if (hitTestDepth > 1) return; + for (const layer of layersByDocument.values()) layer.shield.openForHitTest(); }; export const resumePointerEventsFreeze = (): void => { + if (!isInstalled || hitTestDepth === 0) return; + hitTestDepth--; + if (hitTestDepth > 0) return; + for (const layer of layersByDocument.values()) layer.shield.closeAfterHitTest(); +}; + +// The shield's iframe cut-outs are viewport-relative, so scrolling or resizing +// moves the frames out from under their holes. +export const refreshPointerEventsFreezeShields = (): void => { if (!isInstalled) return; - for (const styles of stylesByDocument.values()) { - if (styles.pointerEventsStyle.disabled) styles.pointerEventsStyle.disabled = false; - if (!styles.hitTestOverrideStyle.disabled) styles.hitTestOverrideStyle.disabled = true; - } + for (const layer of layersByDocument.values()) layer.shield.refreshHoles(); }; diff --git a/packages/react-grab/src/utils/subtract-rect.ts b/packages/react-grab/src/utils/subtract-rect.ts new file mode 100644 index 000000000..ca9bce8f6 --- /dev/null +++ b/packages/react-grab/src/utils/subtract-rect.ts @@ -0,0 +1,55 @@ +import type { Rect } from "../types.js"; + +// Splits every rect around the hole, producing up to four pieces per input +// (above, below, left, right of the overlap). Used to build a pointer shield +// that covers the viewport except where an element must stay interactive. +export const subtractRect = (rects: readonly Rect[], hole: Rect): Rect[] => { + const remainingRects: Rect[] = []; + + for (const rect of rects) { + const overlapLeft = Math.max(rect.left, hole.left); + const overlapTop = Math.max(rect.top, hole.top); + const overlapRight = Math.min(rect.right, hole.right); + const overlapBottom = Math.min(rect.bottom, hole.bottom); + + if (overlapRight <= overlapLeft || overlapBottom <= overlapTop) { + remainingRects.push(rect); + continue; + } + + if (overlapTop > rect.top) { + remainingRects.push({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: overlapTop, + }); + } + if (overlapBottom < rect.bottom) { + remainingRects.push({ + left: rect.left, + top: overlapBottom, + right: rect.right, + bottom: rect.bottom, + }); + } + if (overlapLeft > rect.left) { + remainingRects.push({ + left: rect.left, + top: overlapTop, + right: overlapLeft, + bottom: overlapBottom, + }); + } + if (overlapRight < rect.right) { + remainingRects.push({ + left: overlapRight, + top: overlapTop, + right: rect.right, + bottom: overlapBottom, + }); + } + } + + return remainingRects; +}; diff --git a/packages/react-grab/tests/get-element-at-position.test.ts b/packages/react-grab/tests/get-element-at-position.test.ts index 4ae5c2d5d..8f7378e48 100644 --- a/packages/react-grab/tests/get-element-at-position.test.ts +++ b/packages/react-grab/tests/get-element-at-position.test.ts @@ -290,15 +290,12 @@ describe("getElementAtPosition", () => { }); 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(); }); @@ -564,14 +561,12 @@ describe("getElementsAtPoint", () => { expect(getLocalContentElementAtPoint).toHaveBeenCalledWith(insideElement, 10, 10); }); - it("schedules freeze restoration when deep stack collection throws", () => { - vi.useFakeTimers(); + it("restores freezing when deep stack collection throws", () => { 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/subtract-rect.test.ts b/packages/react-grab/tests/subtract-rect.test.ts new file mode 100644 index 000000000..4331dcfa4 --- /dev/null +++ b/packages/react-grab/tests/subtract-rect.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { Rect } from "../src/types.js"; +import { subtractRect } from "../src/utils/subtract-rect.js"; + +const viewportRect: Rect = { left: 0, top: 0, right: 100, bottom: 100 }; + +const totalArea = (rects: readonly Rect[]): number => + rects.reduce((area, rect) => area + (rect.right - rect.left) * (rect.bottom - rect.top), 0); + +const containsPoint = (rects: readonly Rect[], x: number, y: number): boolean => + rects.some((rect) => x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom); + +describe("subtractRect", () => { + it("keeps rects that do not overlap the hole", () => { + expect(subtractRect([viewportRect], { left: 200, top: 200, right: 300, bottom: 300 })).toEqual([ + viewportRect, + ]); + }); + + it("keeps rects that only touch the hole edge", () => { + expect(subtractRect([viewportRect], { left: 100, top: 0, right: 200, bottom: 100 })).toEqual([ + viewportRect, + ]); + }); + + it("splits a fully enclosed hole into four pieces", () => { + const remainingRects = subtractRect([viewportRect], { + left: 40, + top: 40, + right: 60, + bottom: 60, + }); + + expect(remainingRects).toHaveLength(4); + expect(totalArea(remainingRects)).toBe(100 * 100 - 20 * 20); + expect(containsPoint(remainingRects, 50, 50)).toBe(false); + expect(containsPoint(remainingRects, 39, 50)).toBe(true); + expect(containsPoint(remainingRects, 50, 61)).toBe(true); + }); + + it("drops a rect the hole covers entirely", () => { + expect(subtractRect([viewportRect], { left: -10, top: -10, right: 110, bottom: 110 })).toEqual( + [], + ); + }); + + it("cuts every hole out when applied in sequence", () => { + const firstHole: Rect = { left: 0, top: 0, right: 20, bottom: 20 }; + const secondHole: Rect = { left: 80, top: 80, right: 100, bottom: 100 }; + const remainingRects = subtractRect(subtractRect([viewportRect], firstHole), secondHole); + + expect(totalArea(remainingRects)).toBe(100 * 100 - 20 * 20 - 20 * 20); + expect(containsPoint(remainingRects, 10, 10)).toBe(false); + expect(containsPoint(remainingRects, 90, 90)).toBe(false); + expect(containsPoint(remainingRects, 50, 50)).toBe(true); + }); +});