diff --git a/packages/react-grab/e2e/api-methods.spec.ts b/packages/react-grab/e2e/api-methods.spec.ts index df62c90c3..a40012a99 100644 --- a/packages/react-grab/e2e/api-methods.spec.ts +++ b/packages/react-grab/e2e/api-methods.spec.ts @@ -212,7 +212,7 @@ test.describe("API Methods", () => { }) => { await reactGrab.updateOptions({ theme: { hue: 45 } }); await reactGrab.updateOptions({ - theme: { crosshair: { enabled: false } }, + theme: { elementLabel: { enabled: false } }, }); await reactGrab.activate(); diff --git a/packages/react-grab/e2e/event-callbacks.spec.ts b/packages/react-grab/e2e/event-callbacks.spec.ts index b50a6871e..1951e49b2 100644 --- a/packages/react-grab/e2e/event-callbacks.spec.ts +++ b/packages/react-grab/e2e/event-callbacks.spec.ts @@ -361,23 +361,6 @@ test.describe("Event Callbacks", () => { expect(dragBoxCalls.length).toBeGreaterThan(0); }); - test("onCrosshair should fire when crosshair moves", async ({ - reactGrab, - }) => { - await reactGrab.activate(); - await reactGrab.clearCallbackHistory(); - - await reactGrab.page.mouse.move(200, 200); - await reactGrab.page.waitForTimeout(50); - await reactGrab.page.mouse.move(300, 300); - await reactGrab.page.waitForTimeout(100); - - const history = await reactGrab.getCallbackHistory(); - const crosshairCalls = history.filter((c) => c.name === "onCrosshair"); - - expect(crosshairCalls.length).toBeGreaterThan(0); - }); - test("onGrabbedBox should fire when element is grabbed", async ({ reactGrab, }) => { diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 10a9f0bca..ac81a5136 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -70,11 +70,6 @@ interface ReactGrabState { labelInstances: LabelInstanceInfo[]; } -interface CrosshairInfo { - isVisible: boolean; - position: { x: number; y: number } | null; -} - interface GrabbedBoxInfo { count: number; boxes: Array<{ @@ -184,8 +179,6 @@ export interface ReactGrabPageObject { waitForSelectionLabel: () => Promise; getLabelStatusText: () => Promise; - getCrosshairInfo: () => Promise; - isCrosshairVisible: () => Promise; getGrabbedBoxInfo: () => Promise; getLabelInstancesInfo: () => Promise; isGrabbedBoxVisible: () => Promise; @@ -1531,57 +1524,6 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { }, ATTRIBUTE_NAME); }; - const getCrosshairInfo = async (): Promise => { - return page.evaluate((attrName) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) return { isVisible: false, position: null }; - const root = shadowRoot.querySelector(`[${attrName}]`); - if (!root) return { isVisible: false, position: null }; - - const crosshairElements = Array.from( - root.querySelectorAll("div[style*='pointer-events: none']"), - ); - for (let i = 0; i < crosshairElements.length; i++) { - const element = crosshairElements[i] as HTMLElement; - const style = element.style; - if ( - style.position === "fixed" && - (style.width === "1px" || - style.height === "1px" || - style.width === "100%" || - style.height === "100%") - ) { - const transform = style.transform; - const match = transform?.match(/translate\(([^,]+)px,\s*([^)]+)px\)/); - if (match) { - return { - isVisible: true, - position: { x: parseFloat(match[1]), y: parseFloat(match[2]) }, - }; - } - } - } - return { isVisible: false, position: null }; - }, ATTRIBUTE_NAME); - }; - - const isCrosshairVisible = async (): Promise => { - return page.evaluate(() => { - const api = ( - window as { - __REACT_GRAB__?: { - getState: () => { - isCrosshairVisible: boolean; - }; - }; - } - ).__REACT_GRAB__; - - return api?.getState()?.isCrosshairVisible ?? false; - }); - }; - const getGrabbedBoxInfo = async (): Promise => { return page.evaluate(() => { const api = ( @@ -1826,7 +1768,6 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { "onPromptModeChange", "onSelectionBox", "onDragBox", - "onCrosshair", "onGrabbedBox", "onContextMenu", "onOpenFile", @@ -2353,7 +2294,6 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { onPromptModeChange: trackCallback("onPromptModeChange"), onSelectionBox: trackCallback("onSelectionBox"), onDragBox: trackCallback("onDragBox"), - onCrosshair: trackCallback("onCrosshair"), onGrabbedBox: trackCallback("onGrabbedBox"), onContextMenu: trackCallback("onContextMenu"), onOpenFile: trackCallback("onOpenFile"), @@ -2496,8 +2436,6 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { waitForSelectionLabel, getLabelStatusText, - getCrosshairInfo, - isCrosshairVisible, getGrabbedBoxInfo, getLabelInstancesInfo, isGrabbedBoxVisible, diff --git a/packages/react-grab/e2e/focus-trap.spec.ts b/packages/react-grab/e2e/focus-trap.spec.ts new file mode 100644 index 000000000..18853e31b --- /dev/null +++ b/packages/react-grab/e2e/focus-trap.spec.ts @@ -0,0 +1,340 @@ +import { test, expect } from "./fixtures.js"; + +const FOCUS_TRAP_CONTAINER_ID = "focus-trap-test-container"; + +const injectFocusTrap = async (page: import("@playwright/test").Page) => { + await page.evaluate((containerId) => { + const container = document.createElement("div"); + container.id = containerId; + container.innerHTML = ` +
+

Focus-trapped Modal

+ + + +
+
+ `; + document.body.appendChild(container); + + const modal = document.getElementById("focus-trap-modal")!; + const focusableSelector = + 'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'; + + const getFocusableElements = () => + Array.from(modal.querySelectorAll(focusableSelector)) as HTMLElement[]; + + const focusInHandler = (event: FocusEvent) => { + const target = event.target as Node; + if (!modal.contains(target)) { + event.stopImmediatePropagation(); + const focusable = getFocusableElements(); + if (focusable.length > 0) { + focusable[0].focus(); + } + } + }; + document.addEventListener("focusin", focusInHandler, true); + + const keydownHandler = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + + const focusable = getFocusableElements(); + if (focusable.length === 0) return; + + const firstElement = focusable[0]; + const lastElement = focusable[focusable.length - 1]; + + if (event.shiftKey) { + if (document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } else { + if (document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + }; + document.addEventListener("keydown", keydownHandler, true); + + (window as { __FOCUS_TRAP_CLEANUP__?: () => void }).__FOCUS_TRAP_CLEANUP__ = + () => { + document.removeEventListener("focusin", focusInHandler, true); + document.removeEventListener("keydown", keydownHandler, true); + }; + + const firstInput = document.getElementById("trap-input-1"); + firstInput?.focus(); + }, FOCUS_TRAP_CONTAINER_ID); +}; + +const removeFocusTrap = async (page: import("@playwright/test").Page) => { + await page.evaluate((containerId) => { + ( + window as { __FOCUS_TRAP_CLEANUP__?: () => void } + ).__FOCUS_TRAP_CLEANUP__?.(); + document.getElementById(containerId)?.remove(); + }, FOCUS_TRAP_CONTAINER_ID); +}; + +test.describe("Focus Trap Resistance", () => { + test.afterEach(async ({ reactGrab }) => { + await removeFocusTrap(reactGrab.page); + }); + + test.describe("Activation", () => { + test("should activate via API while focus trap is active", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + + const isActive = await reactGrab.isOverlayVisible(); + expect(isActive).toBe(true); + }); + + test("should deactivate with Escape while focus trap is active", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + await reactGrab.deactivate(); + + const isActive = await reactGrab.isOverlayVisible(); + expect(isActive).toBe(false); + }); + }); + + test.describe("Element Selection", () => { + test("should hover and select elements behind focus trap backdrop", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + await injectFocusTrap(reactGrab.page); + + await reactGrab.hoverElement("li:first-child"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + + test("should select elements inside the focus-trapped modal", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + + await reactGrab.hoverElement("#trap-button"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + + test("should update selection when hovering different elements", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + + await reactGrab.hoverElement("#trap-input-1"); + await reactGrab.waitForSelectionBox(); + const bounds1 = await reactGrab.getSelectionBoxBounds(); + + await reactGrab.hoverElement("#trap-button"); + await reactGrab.waitForSelectionBox(); + const bounds2 = await reactGrab.getSelectionBoxBounds(); + + if (bounds1 && bounds2) { + const didSelectionChange = + bounds1.y !== bounds2.y || bounds1.height !== bounds2.height; + expect(didSelectionChange).toBe(true); + } + }); + }); + + test.describe("Copy", () => { + test("should copy element while focus trap is active", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + + await reactGrab.hoverElement("#trap-button"); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement("#trap-button"); + + await expect + .poll(() => reactGrab.getClipboardContent(), { timeout: 2000 }) + .toBeTruthy(); + }); + + test("should copy element outside modal while focus trap is active", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + await injectFocusTrap(reactGrab.page); + + await reactGrab.hoverElement("h1"); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement("h1"); + + await expect + .poll(() => reactGrab.getClipboardContent(), { timeout: 2000 }) + .toBeTruthy(); + }); + }); + + test.describe("Prompt Mode", () => { + test("should enter prompt mode while focus trap is active", async ({ + reactGrab, + }) => { + await reactGrab.setupMockAgent(); + await injectFocusTrap(reactGrab.page); + + await reactGrab.enterPromptMode("li:first-child"); + + const isPromptMode = await reactGrab.isPromptModeActive(); + expect(isPromptMode).toBe(true); + }); + + test("textarea should receive typed input despite focus trap", async ({ + reactGrab, + }) => { + await reactGrab.setupMockAgent(); + await injectFocusTrap(reactGrab.page); + + await reactGrab.enterPromptMode("li:first-child"); + await reactGrab.typeInInput("Hello from inside focus trap"); + + const inputValue = await reactGrab.getInputValue(); + expect(inputValue).toBe("Hello from inside focus trap"); + }); + + test("should submit prompt while focus trap is active", async ({ + reactGrab, + }) => { + await reactGrab.setupMockAgent({ delay: 100 }); + await injectFocusTrap(reactGrab.page); + + await reactGrab.enterPromptMode("li:first-child"); + await reactGrab.typeInInput("Test prompt"); + await reactGrab.submitInput(); + + await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); + }); + + test("Escape should dismiss prompt mode despite focus trap", async ({ + reactGrab, + }) => { + await reactGrab.setupMockAgent(); + await injectFocusTrap(reactGrab.page); + + await reactGrab.enterPromptMode("li:first-child"); + await reactGrab.pressEscape(); + await reactGrab.pressEscape(); + + await expect + .poll(() => reactGrab.isOverlayVisible(), { timeout: 5000 }) + .toBe(false); + }); + }); + + test.describe("Context Menu", () => { + test("should open context menu while focus trap is active", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + + await reactGrab.hoverElement("#trap-button"); + await reactGrab.waitForSelectionBox(); + await reactGrab.rightClickElement("#trap-button"); + + const isVisible = await reactGrab.isContextMenuVisible(); + expect(isVisible).toBe(true); + }); + }); + + test.describe("Keyboard Navigation", () => { + test("arrow key navigation should work while focus trap is active", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + + await reactGrab.hoverElement("li:first-child"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.pressArrowDown(); + await reactGrab.waitForSelectionBox(); + + const isActive = await reactGrab.isOverlayVisible(); + const isSelectionVisible = await reactGrab.isSelectionBoxVisible(); + expect(isActive).toBe(true); + expect(isSelectionVisible).toBe(true); + }); + + test("Escape should deactivate from selection while focus trap is active", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + + await reactGrab.hoverElement("li:first-child"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.deactivate(); + + const isActive = await reactGrab.isOverlayVisible(); + expect(isActive).toBe(false); + }); + }); + + test.describe("Focus Trap Lifecycle", () => { + test("should continue working after focus trap is removed", async ({ + reactGrab, + }) => { + await injectFocusTrap(reactGrab.page); + await reactGrab.activate(); + + await reactGrab.hoverElement("#trap-button"); + await reactGrab.waitForSelectionBox(); + + await removeFocusTrap(reactGrab.page); + await reactGrab.page.waitForTimeout(100); + + await reactGrab.hoverElement("li:first-child"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + + test("should work when focus trap appears after activation", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("li:first-child"); + await reactGrab.waitForSelectionBox(); + + await injectFocusTrap(reactGrab.page); + await reactGrab.page.waitForTimeout(100); + + await reactGrab.hoverElement("h1"); + await reactGrab.waitForSelectionBox(); + + const isVisible = await reactGrab.isSelectionBoxVisible(); + expect(isVisible).toBe(true); + }); + }); +}); diff --git a/packages/react-grab/e2e/input-mode.spec.ts b/packages/react-grab/e2e/input-mode.spec.ts index 7dd323526..aaeafdfe5 100644 --- a/packages/react-grab/e2e/input-mode.spec.ts +++ b/packages/react-grab/e2e/input-mode.spec.ts @@ -245,6 +245,7 @@ test.describe("Input Mode", () => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); + await reactGrab.page.mouse.click(10, 10); await reactGrab.page.mouse.click(10, 10); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); diff --git a/packages/react-grab/e2e/prompt-mode.spec.ts b/packages/react-grab/e2e/prompt-mode.spec.ts index f20349fa3..91fdb119b 100644 --- a/packages/react-grab/e2e/prompt-mode.spec.ts +++ b/packages/react-grab/e2e/prompt-mode.spec.ts @@ -261,6 +261,7 @@ test.describe("Prompt Mode", () => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); + await reactGrab.page.mouse.click(10, 10); await reactGrab.page.mouse.click(10, 10); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); diff --git a/packages/react-grab/e2e/theme-customization.spec.ts b/packages/react-grab/e2e/theme-customization.spec.ts index f1fcfa0f0..fbc3400d1 100644 --- a/packages/react-grab/e2e/theme-customization.spec.ts +++ b/packages/react-grab/e2e/theme-customization.spec.ts @@ -160,29 +160,6 @@ test.describe("Theme Customization", () => { }); }); - test.describe("Crosshair", () => { - test("should show crosshair by default", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.page.mouse.move(400, 400); - await reactGrab.page.waitForTimeout(100); - - const isVisible = await reactGrab.isCrosshairVisible(); - expect(isVisible).toBe(true); - }); - - test("should hide crosshair when disabled", async ({ reactGrab }) => { - await reactGrab.updateOptions({ - theme: { crosshair: { enabled: false } }, - }); - await reactGrab.activate(); - await reactGrab.page.mouse.move(400, 400); - await reactGrab.page.waitForTimeout(100); - - const isVisible = await reactGrab.isCrosshairVisible(); - expect(isVisible).toBe(false); - }); - }); - test.describe("Toolbar", () => { test("should show toolbar by default", async ({ reactGrab }) => { await reactGrab.page.waitForTimeout(600); @@ -215,56 +192,6 @@ test.describe("Theme Customization", () => { }); }); - test.describe("Multiple Feature Toggles", () => { - test("should apply multiple theme settings", async ({ reactGrab }) => { - await reactGrab.updateOptions({ - theme: { - hue: 45, - crosshair: { enabled: false }, - elementLabel: { enabled: false }, - }, - }); - - await reactGrab.activate(); - await reactGrab.page.mouse.move(400, 400); - await reactGrab.page.waitForTimeout(100); - - const hasFilter = await reactGrab.page.evaluate(() => { - const host = document.querySelector("[data-react-grab]"); - const shadowRoot = host?.shadowRoot; - const root = shadowRoot?.querySelector( - "[data-react-grab]", - ) as HTMLElement; - return root?.style.filter?.includes("hue-rotate(45deg)") ?? false; - }); - expect(hasFilter).toBe(true); - - const isCrosshairVisible = await reactGrab.isCrosshairVisible(); - expect(isCrosshairVisible).toBe(false); - }); - - test("should allow re-enabling disabled features", async ({ - reactGrab, - }) => { - await reactGrab.updateOptions({ - theme: { crosshair: { enabled: false } }, - }); - await reactGrab.activate(); - - const isDisabled = await reactGrab.isCrosshairVisible(); - expect(isDisabled).toBe(false); - - await reactGrab.updateOptions({ - theme: { crosshair: { enabled: true } }, - }); - await reactGrab.page.mouse.move(400, 400); - await reactGrab.page.waitForTimeout(100); - - const isEnabled = await reactGrab.isCrosshairVisible(); - expect(isEnabled).toBe(true); - }); - }); - test.describe("Theme Persistence", () => { test("theme should persist across activation cycles", async ({ reactGrab, @@ -285,21 +212,5 @@ test.describe("Theme Customization", () => { }); expect(hasFilter).toBe(true); }); - - test("theme updates should be immediate", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.page.mouse.move(400, 400); - await reactGrab.page.waitForTimeout(100); - - const isVisibleBefore = await reactGrab.isCrosshairVisible(); - expect(isVisibleBefore).toBe(true); - - await reactGrab.updateOptions({ - theme: { crosshair: { enabled: false } }, - }); - - const isVisibleAfter = await reactGrab.isCrosshairVisible(); - expect(isVisibleAfter).toBe(false); - }); }); }); diff --git a/packages/react-grab/e2e/toolbar-selection-hover.spec.ts b/packages/react-grab/e2e/toolbar-selection-hover.spec.ts index d307182b8..b43b4b542 100644 --- a/packages/react-grab/e2e/toolbar-selection-hover.spec.ts +++ b/packages/react-grab/e2e/toolbar-selection-hover.spec.ts @@ -88,24 +88,6 @@ test.describe("Toolbar Selection Hover", () => { .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); }); - - test("should hide crosshair when hovering toolbar", async ({ - reactGrab, - }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("li"); - await reactGrab.waitForSelectionBox(); - - await expect - .poll(() => reactGrab.isCrosshairVisible(), { timeout: 2000 }) - .toBe(true); - - await hoverToolbar(reactGrab.page); - - await expect - .poll(() => reactGrab.isCrosshairVisible(), { timeout: 2000 }) - .toBe(false); - }); }); test.describe("Frozen Mode", () => { diff --git a/packages/react-grab/e2e/touch-mode.spec.ts b/packages/react-grab/e2e/touch-mode.spec.ts index deab8bacf..d7a06b310 100644 --- a/packages/react-grab/e2e/touch-mode.spec.ts +++ b/packages/react-grab/e2e/touch-mode.spec.ts @@ -55,26 +55,6 @@ test.describe("Touch Mode", () => { }); test.describe("Touch Mode Behavior", () => { - test("crosshair should be hidden in touch mode", async ({ reactGrab }) => { - await reactGrab.updateOptions({ - theme: { crosshair: { enabled: true } }, - }); - await reactGrab.activate(); - - const listItem = reactGrab.page.locator("li").first(); - const box = await listItem.boundingBox(); - if (!box) throw new Error("Could not get bounding box"); - - await reactGrab.page.touchscreen.tap( - box.x + box.width / 2, - box.y + box.height / 2, - ); - await reactGrab.page.waitForTimeout(100); - - const isCrosshairVisible = await reactGrab.isCrosshairVisible(); - expect(isCrosshairVisible).toBe(false); - }); - test("touch events should update pointer position", async ({ reactGrab, }) => { diff --git a/packages/react-grab/e2e/visual-feedback.spec.ts b/packages/react-grab/e2e/visual-feedback.spec.ts index 080c26dba..5fadc8f0b 100644 --- a/packages/react-grab/e2e/visual-feedback.spec.ts +++ b/packages/react-grab/e2e/visual-feedback.spec.ts @@ -154,51 +154,6 @@ test.describe("Visual Feedback", () => { }); }); - test.describe("Crosshair", () => { - test("crosshair should be visible when active", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.page.mouse.move(400, 400); - await reactGrab.page.waitForTimeout(100); - - const isVisible = await reactGrab.isCrosshairVisible(); - expect(isVisible).toBe(true); - }); - - test("crosshair should follow cursor", async ({ reactGrab }) => { - await reactGrab.activate(); - - await reactGrab.page.mouse.move(200, 200); - await reactGrab.page.waitForTimeout(100); - const info1 = await reactGrab.getCrosshairInfo(); - - await reactGrab.page.mouse.move(500, 400); - await reactGrab.page.waitForTimeout(100); - const info2 = await reactGrab.getCrosshairInfo(); - - if (info1.position && info2.position) { - expect(info1.position.x).not.toBe(info2.position.x); - expect(info1.position.y).not.toBe(info2.position.y); - } - }); - - test("crosshair should be hidden during drag", async ({ reactGrab }) => { - await reactGrab.activate(); - - const listItem = reactGrab.page.locator("li").first(); - const box = await listItem.boundingBox(); - if (!box) throw new Error("Could not get bounding box"); - - await reactGrab.page.mouse.move(box.x, box.y); - await reactGrab.page.mouse.down(); - await reactGrab.page.mouse.move(box.x + 100, box.y + 100, { steps: 5 }); - - const state = await reactGrab.getState(); - expect(state.isDragging).toBe(true); - - await reactGrab.page.mouse.up(); - }); - }); - test.describe("Selection Label", () => { test("label should show tag name", async ({ reactGrab }) => { await reactGrab.activate(); diff --git a/packages/react-grab/src/components/overlay-canvas.tsx b/packages/react-grab/src/components/overlay-canvas.tsx index 537fcb8b8..c8c8a0987 100644 --- a/packages/react-grab/src/components/overlay-canvas.tsx +++ b/packages/react-grab/src/components/overlay-canvas.tsx @@ -14,7 +14,6 @@ import { FADE_OUT_BUFFER_MS, MIN_DEVICE_PIXEL_RATIO, Z_INDEX_OVERLAY_CANVAS, - OVERLAY_CROSSHAIR_COLOR, OVERLAY_BORDER_COLOR_DRAG, OVERLAY_FILL_COLOR_DRAG, OVERLAY_BORDER_COLOR_DEFAULT, @@ -24,6 +23,7 @@ import { nativeCancelAnimationFrame, nativeRequestAnimationFrame, } from "../utils/native-raf.js"; +import { supportsDisplayP3 } from "../utils/supports-display-p3.js"; const LAYER_STYLES = { drag: { @@ -48,7 +48,7 @@ const LAYER_STYLES = { }, } as const; -type LayerName = "crosshair" | "drag" | "selection" | "grabbed" | "processing"; +type LayerName = "drag" | "selection" | "grabbed" | "processing"; interface OffscreenLayer { canvas: OffscreenCanvas | null; @@ -66,14 +66,7 @@ interface AnimatedBounds { isInitialized: boolean; } -interface Position { - x: number; - y: number; -} - export interface OverlayCanvasProps { - crosshairVisible?: boolean; - selectionVisible?: boolean; selectionBounds?: OverlayBounds; selectionBoundsMultiple?: OverlayBounds[]; @@ -103,20 +96,21 @@ export const OverlayCanvas: Component = (props) => { let animationFrameId: number | null = null; const layers: Record = { - crosshair: { canvas: null, context: null }, drag: { canvas: null, context: null }, selection: { canvas: null, context: null }, grabbed: { canvas: null, context: null }, processing: { canvas: null, context: null }, }; - const crosshairCurrentPosition: Position = { x: 0, y: 0 }; - let selectionAnimations: AnimatedBounds[] = []; let dragAnimation: AnimatedBounds | null = null; let grabbedAnimations: AnimatedBounds[] = []; let processingAnimations: AnimatedBounds[] = []; + const canvasColorSpace: PredefinedColorSpace = supportsDisplayP3() + ? "display-p3" + : "srgb"; + const createOffscreenLayer = ( layerWidth: number, layerHeight: number, @@ -126,7 +120,7 @@ export const OverlayCanvas: Component = (props) => { layerWidth * scaleFactor, layerHeight * scaleFactor, ); - const context = canvas.getContext("2d"); + const context = canvas.getContext("2d", { colorSpace: canvasColorSpace }); if (context) { context.scale(scaleFactor, scaleFactor); } @@ -148,7 +142,7 @@ export const OverlayCanvas: Component = (props) => { canvasRef.style.width = `${canvasWidth}px`; canvasRef.style.height = `${canvasHeight}px`; - mainContext = canvasRef.getContext("2d"); + mainContext = canvasRef.getContext("2d", { colorSpace: canvasColorSpace }); if (mainContext) { mainContext.scale(devicePixelRatio, devicePixelRatio); } @@ -251,26 +245,6 @@ export const OverlayCanvas: Component = (props) => { context.globalAlpha = 1; }; - const renderCrosshairLayer = () => { - const layer = layers.crosshair; - if (!layer.context) return; - - const context = layer.context; - context.clearRect(0, 0, canvasWidth, canvasHeight); - - if (!props.crosshairVisible) return; - - context.strokeStyle = OVERLAY_CROSSHAIR_COLOR; - context.lineWidth = 1; - - context.beginPath(); - context.moveTo(crosshairCurrentPosition.x, 0); - context.lineTo(crosshairCurrentPosition.x, canvasHeight); - context.moveTo(0, crosshairCurrentPosition.y); - context.lineTo(canvasWidth, crosshairCurrentPosition.y); - context.stroke(); - }; - const renderDragLayer = () => { const layer = layers.drag; if (!layer.context) return; @@ -354,14 +328,12 @@ export const OverlayCanvas: Component = (props) => { mainContext.clearRect(0, 0, canvasRef.width, canvasRef.height); mainContext.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); - renderCrosshairLayer(); renderDragLayer(); renderSelectionLayer(); renderBoundsLayer("grabbed", grabbedAnimations); renderBoundsLayer("processing", processingAnimations); const layerRenderOrder: LayerName[] = [ - "crosshair", "drag", "selection", "grabbed", @@ -517,15 +489,6 @@ export const OverlayCanvas: Component = (props) => { scheduleAnimationFrame(); }; - createEffect( - on( - () => props.crosshairVisible, - () => { - scheduleAnimationFrame(); - }, - ), - ); - createEffect( on( () => @@ -726,16 +689,6 @@ export const OverlayCanvas: Component = (props) => { initializeCanvas(); scheduleAnimationFrame(); - const handlePointerMove = (event: PointerEvent) => { - if (!event.isPrimary) return; - crosshairCurrentPosition.x = event.clientX; - crosshairCurrentPosition.y = event.clientY; - scheduleAnimationFrame(); - }; - - window.addEventListener("pointermove", handlePointerMove, { - passive: true, - }); window.addEventListener("resize", handleWindowResize); let currentDprMediaQuery: MediaQueryList | null = null; @@ -770,7 +723,6 @@ export const OverlayCanvas: Component = (props) => { setupDprMediaQuery(); onCleanup(() => { - window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("resize", handleWindowResize); if (currentDprMediaQuery) { currentDprMediaQuery.removeEventListener( diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 738cec8c4..73186e441 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -20,7 +20,6 @@ export const ReactGrabRenderer: Component = (props) => { return ( <> = (props) => { onCancel={props.onInputCancel} onToggleExpand={props.onToggleExpand} isPendingDismiss={props.isPendingDismiss} + selectionLabelShakeCount={props.selectionLabelShakeCount} onConfirmDismiss={props.onConfirmDismiss} onCancelDismiss={props.onCancelDismiss} onOpen={() => { diff --git a/packages/react-grab/src/components/selection-label/index.tsx b/packages/react-grab/src/components/selection-label/index.tsx index 84f7aeb9a..6ec347fec 100644 --- a/packages/react-grab/src/components/selection-label/index.tsx +++ b/packages/react-grab/src/components/selection-label/index.tsx @@ -4,6 +4,7 @@ import { createSignal, createEffect, createMemo, + on, onMount, onCleanup, } from "solid-js"; @@ -67,6 +68,7 @@ export const SelectionLabel: Component = (props) => { const [viewportVersion, setViewportVersion] = createSignal(0); const [hadValidBounds, setHadValidBounds] = createSignal(false); const [isInternalFading, setIsInternalFading] = createSignal(false); + const [isShaking, setIsShaking] = createSignal(false); const canInteract = () => props.status !== "copying" && @@ -149,6 +151,8 @@ export const SelectionLabel: Component = (props) => { } window.addEventListener("scroll", handleViewportChange, true); window.addEventListener("resize", handleViewportChange); + window.visualViewport?.addEventListener("resize", handleViewportChange); + window.visualViewport?.addEventListener("scroll", handleViewportChange); window.addEventListener("keydown", handleGlobalKeyDown, { capture: true }); }); @@ -156,6 +160,8 @@ export const SelectionLabel: Component = (props) => { resizeObserver?.disconnect(); window.removeEventListener("scroll", handleViewportChange, true); window.removeEventListener("resize", handleViewportChange); + window.visualViewport?.removeEventListener("resize", handleViewportChange); + window.visualViewport?.removeEventListener("scroll", handleViewportChange); window.removeEventListener("keydown", handleGlobalKeyDown, { capture: true, }); @@ -200,14 +206,19 @@ export const SelectionLabel: Component = (props) => { }; } - const viewportWidth = window.visualViewport?.width ?? window.innerWidth; - const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + const visualViewport = window.visualViewport; + const viewportLeft = visualViewport?.offsetLeft ?? 0; + const viewportTop = visualViewport?.offsetTop ?? 0; + const viewportRight = + viewportLeft + (visualViewport?.width ?? window.innerWidth); + const viewportBottom = + viewportTop + (visualViewport?.height ?? window.innerHeight); const isSelectionVisibleInViewport = - bounds.x + bounds.width > 0 && - bounds.x < viewportWidth && - bounds.y + bounds.height > 0 && - bounds.y < viewportHeight; + bounds.x + bounds.width > viewportLeft && + bounds.x < viewportRight && + bounds.y + bounds.height > viewportTop && + bounds.y < viewportBottom; if (!isSelectionVisibleInViewport) { return { @@ -233,24 +244,24 @@ export const SelectionLabel: Component = (props) => { const labelLeft = anchorX - labelWidth / 2; const labelRight = anchorX + labelWidth / 2; - if (labelRight > viewportWidth - VIEWPORT_MARGIN_PX) { - edgeOffsetX = viewportWidth - VIEWPORT_MARGIN_PX - labelRight; + if (labelRight > viewportRight - VIEWPORT_MARGIN_PX) { + edgeOffsetX = viewportRight - VIEWPORT_MARGIN_PX - labelRight; } - if (labelLeft + edgeOffsetX < VIEWPORT_MARGIN_PX) { - edgeOffsetX = VIEWPORT_MARGIN_PX - labelLeft; + if (labelLeft + edgeOffsetX < viewportLeft + VIEWPORT_MARGIN_PX) { + edgeOffsetX = viewportLeft + VIEWPORT_MARGIN_PX - labelLeft; } } const totalHeightNeeded = labelHeight + actualArrowHeight + LABEL_GAP_PX; const fitsBelow = - positionTop + labelHeight <= viewportHeight - VIEWPORT_MARGIN_PX; + positionTop + labelHeight <= viewportBottom - VIEWPORT_MARGIN_PX; if (!fitsBelow) { positionTop = selectionTop - totalHeightNeeded; } - if (positionTop < VIEWPORT_MARGIN_PX) { - positionTop = VIEWPORT_MARGIN_PX; + if (positionTop < viewportTop + VIEWPORT_MARGIN_PX) { + positionTop = viewportTop + VIEWPORT_MARGIN_PX; } const arrowLeftPercent = ARROW_CENTER_PERCENT; @@ -290,6 +301,14 @@ export const SelectionLabel: Component = (props) => { } }); + createEffect( + on( + () => props.selectionLabelShakeCount, + () => setIsShaking(true), + { defer: true }, + ), + ); + const handleKeyDown = (event: KeyboardEvent) => { if (event.isComposing || event.keyCode === IME_COMPOSING_KEY_CODE) { return; @@ -418,10 +437,12 @@ export const SelectionLabel: Component = (props) => { class={cn( "contain-layout flex items-center gap-[5px] rounded-[10px] antialiased w-fit h-fit p-0 [font-synthesis:none] [corner-shape:superellipse(1.25)]", PANEL_STYLES, + isShaking() && "animate-shake", )} style={{ display: isCompletedStatus() && !props.error ? "none" : undefined, }} + onAnimationEnd={() => setIsShaking(false)} >
= (props) => { { + props.onCancelDismiss?.(); + inputRef?.focus(); + }} /> diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 32f4ff8d5..3edb14295 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -1605,11 +1605,18 @@ export const Toolbar: Component = (props) => { > - + 0 + } + > + class="absolute -top-1 -right-1 min-w-2.5 h-2.5 px-0.5 flex items-center justify-center rounded-full bg-black text-white text-[8px] font-semibold leading-none" + > + {props.historyItemCount} + diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 224665b82..f119dfd18 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -1,3 +1,5 @@ +import { overlayColor } from "./utils/overlay-color.js"; + export const VERSION = process.env.VERSION as string; export const VIEWPORT_MARGIN_PX = 8; @@ -45,13 +47,11 @@ export const LERP_CONVERGENCE_THRESHOLD_PX = 0.5; export const FADE_OUT_BUFFER_MS = 100; export const MIN_DEVICE_PIXEL_RATIO = 2; -const GRAB_PURPLE_RGB = "210, 57, 192"; -export const OVERLAY_CROSSHAIR_COLOR = `rgba(${GRAB_PURPLE_RGB}, 1)`; -export const OVERLAY_BORDER_COLOR_DRAG = `rgba(${GRAB_PURPLE_RGB}, 0.4)`; -export const OVERLAY_FILL_COLOR_DRAG = `rgba(${GRAB_PURPLE_RGB}, 0.05)`; -export const OVERLAY_BORDER_COLOR_DEFAULT = `rgba(${GRAB_PURPLE_RGB}, 0.5)`; -export const OVERLAY_FILL_COLOR_DEFAULT = `rgba(${GRAB_PURPLE_RGB}, 0.08)`; -export const FROZEN_GLOW_COLOR = `rgba(${GRAB_PURPLE_RGB}, 0.15)`; +export const OVERLAY_BORDER_COLOR_DRAG = overlayColor(0.4); +export const OVERLAY_FILL_COLOR_DRAG = overlayColor(0.05); +export const OVERLAY_BORDER_COLOR_DEFAULT = overlayColor(0.5); +export const OVERLAY_FILL_COLOR_DEFAULT = overlayColor(0.08); +export const FROZEN_GLOW_COLOR = overlayColor(0.15); export const FROZEN_GLOW_EDGE_PX = 50; export const ARROW_HEIGHT_PX = 8; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 32967b3d6..ac341c693 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -273,6 +273,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { savedToolbarState?.enabled ?? true, ); const [toolbarShakeCount, setToolbarShakeCount] = createSignal(0); + const [selectionLabelShakeCount, setSelectionLabelShakeCount] = + createSignal(0); const [currentToolbarState, setCurrentToolbarState] = createSignal(savedToolbarState); const [isToolbarSelectHovered, setIsToolbarSelectHovered] = @@ -532,19 +534,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const isRendererActive = createMemo(() => isActivated() && !isCopying()); - const crosshairVisible = createMemo( - () => - pluginRegistry.store.theme.enabled && - pluginRegistry.store.theme.crosshair.enabled && - isRendererActive() && - !isDragging() && - !store.isTouchMode && - !isToggleFrozen() && - !isPromptMode() && - !isToolbarSelectHovered() && - store.contextMenuPosition === null, - ); - const grabbedBoxTimeouts = new Map(); const showTemporaryGrabbedBox = ( @@ -1273,10 +1262,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { clearSource(); return; } - actions.setSelectionSource( - source.filePath, - source.lineNumber, - ); + actions.setSelectionSource(source.filePath, source.lineNumber); }) .catch(() => { if (selectionSourceRequestVersion === currentVersion) { @@ -1317,7 +1303,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const dragging = isDragging(); const copying = isCopying(); const inputMode = isPromptMode(); - const crosshairState = crosshairVisible(); const target = targetElement(); const drag = dragBounds(); const themeEnabled = pluginRegistry.store.theme.enabled; @@ -1350,7 +1335,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { isDragging: dragging, isCopying: copying, isPromptMode: inputMode, - isCrosshairVisible: crosshairState ?? false, isSelectionBoxVisible, isDragBoxVisible, targetElement: target, @@ -1411,15 +1395,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ), ); - createEffect( - on( - () => [crosshairVisible(), store.pointer.x, store.pointer.y] as const, - ([visible, x, y]) => { - pluginRegistry.hooks.onCrosshair(Boolean(visible), { x, y }); - }, - ), - ); - createEffect( on( () => @@ -1464,10 +1439,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { createEffect( on( () => [isActivated(), isCopying(), isPromptMode()] as const, - ([activated, copying, inputMode]) => { + ([activated, copying, promptMode]) => { if (copying) { setCursorOverride("progress"); - } else if (activated && !inputMode) { + } else if (activated && !promptMode) { setCursorOverride("crosshair"); } else { setCursorOverride(null); @@ -1643,15 +1618,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.clearLastCopied(); if (!isPromptMode()) return; - const currentInput = store.inputText.trim(); - if (currentInput && !isPendingDismiss()) { - actions.setPendingDismiss(true); + if (isPendingDismiss()) { + actions.clearInputText(); + actions.clearReplySessionId(); + deactivateRenderer(); return; } - actions.clearInputText(); - actions.clearReplySessionId(); - deactivateRenderer(); + actions.setPendingDismiss(true); + setSelectionLabelShakeCount((count) => count + 1); }; const handleConfirmDismiss = () => { @@ -2943,6 +2918,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { lastWindowFocusTimestamp = Date.now(); }); + eventListenerManager.addWindowListener( + "focusin", + (event: FocusEvent) => { + if (isEventFromOverlay(event, "data-react-grab")) { + event.stopPropagation(); + } + }, + { capture: true }, + ); + const redetectElementUnderPointer = () => { if (store.isTouchMode && !isHoldingKeys() && !isActivated()) return; if ( @@ -4022,7 +4007,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { store.frozenElements.length > 1 ? undefined : cursorPosition().x } mouseY={cursorPosition().y} - crosshairVisible={crosshairVisible()} isFrozen={ isToggleFrozen() || isActivated() || isToolbarSelectHovered() } @@ -4047,6 +4031,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { onInputCancel={handleInputCancel} onToggleExpand={handleToggleExpand} isPendingDismiss={isPendingDismiss()} + selectionLabelShakeCount={selectionLabelShakeCount()} onConfirmDismiss={handleConfirmDismiss} onCancelDismiss={handleCancelDismiss} pendingAbortSessionId={pendingAbortSessionId()} @@ -4260,7 +4245,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { isDragging: isDragging(), isCopying: isCopying(), isPromptMode: isPromptMode(), - isCrosshairVisible: crosshairVisible() ?? false, isSelectionBoxVisible: selectionVisible() ?? false, isDragBoxVisible: dragVisible() ?? false, targetElement: targetElement(), diff --git a/packages/react-grab/src/core/noop-api.ts b/packages/react-grab/src/core/noop-api.ts index c4eeba10f..0e25c3fe2 100644 --- a/packages/react-grab/src/core/noop-api.ts +++ b/packages/react-grab/src/core/noop-api.ts @@ -7,7 +7,6 @@ export const createNoopApi = (): ReactGrabAPI => { isDragging: false, isCopying: false, isPromptMode: false, - isCrosshairVisible: false, isSelectionBoxVisible: false, isDragBoxVisible: false, targetElement: null, diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index 4f0e9dd15..0060cf2f4 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -14,7 +14,6 @@ import type { DragRect, ElementLabelVariant, ElementLabelContext, - CrosshairContext, ActivationMode, ActivationKey, SettableOptions, @@ -333,8 +332,6 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { variant: ElementLabelVariant, context: ElementLabelContext, ) => callHook("onElementLabel", visible, variant, context), - onCrosshair: (visible: boolean, context: CrosshairContext) => - callHook("onCrosshair", visible, context), onContextMenu: (element: Element, position: { x: number; y: number }) => callHook("onContextMenu", element, position), cancelPendingToolbarActions: () => callHook("cancelPendingToolbarActions"), diff --git a/packages/react-grab/src/core/theme.ts b/packages/react-grab/src/core/theme.ts index ac4d33cf9..df1fffc9f 100644 --- a/packages/react-grab/src/core/theme.ts +++ b/packages/react-grab/src/core/theme.ts @@ -15,9 +15,6 @@ export const DEFAULT_THEME: Required = { elementLabel: { enabled: true, }, - crosshair: { - enabled: true, - }, toolbar: { enabled: true, }, @@ -44,9 +41,6 @@ export const deepMergeTheme = ( enabled: partialTheme.elementLabel?.enabled ?? baseTheme.elementLabel.enabled, }, - crosshair: { - enabled: partialTheme.crosshair?.enabled ?? baseTheme.crosshair.enabled, - }, toolbar: { enabled: partialTheme.toolbar?.enabled ?? baseTheme.toolbar.enabled, }, diff --git a/packages/react-grab/src/index.ts b/packages/react-grab/src/index.ts index 03066ad27..401a51ef4 100644 --- a/packages/react-grab/src/index.ts +++ b/packages/react-grab/src/index.ts @@ -22,7 +22,6 @@ export type { DeepPartial, ElementLabelVariant, PromptModeContext, - CrosshairContext, ElementLabelContext, AgentContext, AgentSession, diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 44135b9e6..bdb434d01 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -57,16 +57,6 @@ export interface Theme { */ enabled?: boolean; }; - /** - * The crosshair cursor overlay that helps with precise element targeting - */ - crosshair?: { - /** - * Whether to show the crosshair - * @default true - */ - enabled?: boolean; - }; /** * The floating toolbar that allows toggling React Grab activation */ @@ -84,7 +74,6 @@ export interface ReactGrabState { isDragging: boolean; isCopying: boolean; isPromptMode: boolean; - isCrosshairVisible: boolean; isSelectionBoxVisible: boolean; isDragBoxVisible: boolean; targetElement: Element | null; @@ -117,11 +106,6 @@ export interface PromptModeContext { targetElement: Element | null; } -export interface CrosshairContext { - x: number; - y: number; -} - export interface ElementLabelContext { x: number; y: number; @@ -308,7 +292,6 @@ export interface PluginHooks { variant: ElementLabelVariant, context: ElementLabelContext, ) => void; - onCrosshair?: (visible: boolean, context: CrosshairContext) => void; onContextMenu?: ( element: Element, position: { x: number; y: number }, @@ -503,7 +486,6 @@ export interface ReactGrabRendererProps { labelZIndex?: number; mouseX?: number; mouseY?: number; - crosshairVisible?: boolean; isFrozen?: boolean; inputValue?: string; isPromptMode?: boolean; @@ -529,6 +511,7 @@ export interface ReactGrabRendererProps { onInputCancel?: () => void; onToggleExpand?: () => void; isPendingDismiss?: boolean; + selectionLabelShakeCount?: number; onConfirmDismiss?: () => void; onCancelDismiss?: () => void; pendingAbortSessionId?: string | null; @@ -688,6 +671,7 @@ export interface SelectionLabelProps { onUndo?: () => void; onFollowUpSubmit?: (prompt: string) => void; isPendingDismiss?: boolean; + selectionLabelShakeCount?: number; onConfirmDismiss?: () => void; onCancelDismiss?: () => void; isPendingAbort?: boolean; diff --git a/packages/react-grab/src/utils/overlay-color.ts b/packages/react-grab/src/utils/overlay-color.ts new file mode 100644 index 000000000..9041b593d --- /dev/null +++ b/packages/react-grab/src/utils/overlay-color.ts @@ -0,0 +1,10 @@ +import { supportsDisplayP3 } from "./supports-display-p3.js"; + +const isWideGamut = supportsDisplayP3(); +const SRGB_COMPONENTS = "210, 57, 192"; +const P3_COMPONENTS = "0.84 0.19 0.78"; + +export const overlayColor = (alpha: number): string => + isWideGamut + ? `color(display-p3 ${P3_COMPONENTS} / ${alpha})` + : `rgba(${SRGB_COMPONENTS}, ${alpha})`; diff --git a/packages/react-grab/src/utils/supports-display-p3.ts b/packages/react-grab/src/utils/supports-display-p3.ts new file mode 100644 index 000000000..3ab625d59 --- /dev/null +++ b/packages/react-grab/src/utils/supports-display-p3.ts @@ -0,0 +1,13 @@ +let cachedResult: boolean | null = null; + +export const supportsDisplayP3 = (): boolean => { + if (cachedResult !== null) return cachedResult; + + try { + cachedResult = window.matchMedia("(color-gamut: p3)").matches; + } catch { + cachedResult = false; + } + + return cachedResult; +};