diff --git a/.changeset/smart-r3f-interaction-ranking.md b/.changeset/smart-r3f-interaction-ranking.md new file mode 100644 index 000000000..2de9b92df --- /dev/null +++ b/.changeset/smart-r3f-interaction-ranking.md @@ -0,0 +1,5 @@ +--- +"react-grab": patch +--- + +Prioritize interactive React Three Fiber objects during canvas selection so passive particles and helper geometry do not steal hits, while preserving passive-object fallback behavior. diff --git a/apps/e2e-app-vite/package.json b/apps/e2e-app-vite/package.json index 43d2da03b..a1267d81c 100644 --- a/apps/e2e-app-vite/package.json +++ b/apps/e2e-app-vite/package.json @@ -11,18 +11,21 @@ }, "dependencies": { "@pierre/diffs": "^1.2.12", + "@react-three/fiber": "^9.6.1", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.14.5", "react": "19.2.6", "react-dom": "19.2.6", "react-grab": "workspace:*", - "recharts": "^3.9.2" + "recharts": "^3.9.2", + "three": "^0.185.1" }, "devDependencies": { "@react-grab/e2e-development": "workspace:*", "@tailwindcss/vite": "^4.3.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.185.1", "@vitejs/plugin-react": "^6.0.1", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", diff --git a/apps/e2e-app-vite/src/App.tsx b/apps/e2e-app-vite/src/App.tsx index 09c31ecf7..230e8a4e1 100644 --- a/apps/e2e-app-vite/src/App.tsx +++ b/apps/e2e-app-vite/src/App.tsx @@ -5,6 +5,8 @@ import { IframeFixture } from "./iframe-fixture"; import { OwnerStackCases } from "./owner-stack-cases"; import { PierreDiffFixture, PierreDiffPreview } from "./pierre-diff-fixture"; import { ShadowDomEdgeFixture } from "./shadow-dom-edge-fixture"; +import { ThreeFiberFixture } from "./three-fiber-fixture"; +import { ThreeJsFixture } from "./three-js-fixture"; declare global { interface Window { @@ -880,6 +882,10 @@ export default function App() { + + + + diff --git a/apps/e2e-app-vite/src/three-fiber-fixture.tsx b/apps/e2e-app-vite/src/three-fiber-fixture.tsx new file mode 100644 index 000000000..8051b6a90 --- /dev/null +++ b/apps/e2e-app-vite/src/three-fiber-fixture.tsx @@ -0,0 +1,64 @@ +import { Canvas } from "@react-three/fiber"; +import { useMemo } from "react"; +import { + THREE_AMBIENT_LIGHT_INTENSITY, + THREE_BOX_SIZE_UNITS, + THREE_CAMERA_FOV_DEGREES, + THREE_CAMERA_POSITION_Z_UNITS, + THREE_DEVICE_PIXEL_RATIO, + THREE_DIRECTIONAL_LIGHT_INTENSITY, + THREE_DIRECTIONAL_LIGHT_POSITION, + THREE_LEFT_BOX_POSITION, + THREE_RIGHT_BOX_POSITION, +} from "./three-fixture-constants"; + +interface ThreeGrabBoxProps { + color: string; + name: string; + position: [number, number, number]; +} + +const ThreeGrabBox = (props: ThreeGrabBoxProps): React.JSX.Element => ( + undefined}> + + + +); + +const DecorativePoints = (): React.JSX.Element => { + const positions = useMemo( + () => new Float32Array([...THREE_LEFT_BOX_POSITION, ...THREE_RIGHT_BOX_POSITION]), + [], + ); + + return ( + + + + + + + ); +}; + +export const ThreeFiberFixture = (): React.JSX.Element => ( +
+

React Three Fiber Scene

+
+ + + + + + + +
+
+); diff --git a/apps/e2e-app-vite/src/three-fixture-constants.ts b/apps/e2e-app-vite/src/three-fixture-constants.ts new file mode 100644 index 000000000..6deafce3d --- /dev/null +++ b/apps/e2e-app-vite/src/three-fixture-constants.ts @@ -0,0 +1,9 @@ +export const THREE_AMBIENT_LIGHT_INTENSITY = 1.5; +export const THREE_BOX_SIZE_UNITS = 1.4; +export const THREE_CAMERA_FOV_DEGREES = 45; +export const THREE_CAMERA_POSITION_Z_UNITS = 5; +export const THREE_DEVICE_PIXEL_RATIO = 1; +export const THREE_DIRECTIONAL_LIGHT_INTENSITY = 2; +export const THREE_DIRECTIONAL_LIGHT_POSITION = [3, 4, 5] satisfies [number, number, number]; +export const THREE_LEFT_BOX_POSITION = [-1.1, 0, 0] satisfies [number, number, number]; +export const THREE_RIGHT_BOX_POSITION = [1.1, 0, 0] satisfies [number, number, number]; diff --git a/apps/e2e-app-vite/src/three-js-fixture.tsx b/apps/e2e-app-vite/src/three-js-fixture.tsx new file mode 100644 index 000000000..2534ea832 --- /dev/null +++ b/apps/e2e-app-vite/src/three-js-fixture.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef } from "react"; +import * as ReactGrabPrimitives from "react-grab/primitives"; +import { + AmbientLight, + BoxGeometry, + Color, + DirectionalLight, + Mesh, + MeshStandardMaterial, + PerspectiveCamera, + Raycaster, + Scene, + Vector2, + WebGLRenderer, +} from "three"; +import { + THREE_AMBIENT_LIGHT_INTENSITY, + THREE_BOX_SIZE_UNITS, + THREE_CAMERA_FOV_DEGREES, + THREE_CAMERA_POSITION_Z_UNITS, + THREE_DIRECTIONAL_LIGHT_INTENSITY, + THREE_DIRECTIONAL_LIGHT_POSITION, + THREE_LEFT_BOX_POSITION, + THREE_RIGHT_BOX_POSITION, +} from "./three-fixture-constants"; + +const createTestBox = ( + name: string, + color: Color, + position: [number, number, number], +): Mesh => { + const geometry = new BoxGeometry( + THREE_BOX_SIZE_UNITS, + THREE_BOX_SIZE_UNITS, + THREE_BOX_SIZE_UNITS, + ); + const material = new MeshStandardMaterial({ color }); + const boxMesh = new Mesh(geometry, material); + boxMesh.name = name; + boxMesh.position.set(...position); + return boxMesh; +}; + +export const ThreeJsFixture = (): React.JSX.Element => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || typeof ReactGrabPrimitives.registerThreeScene !== "function") return; + + const renderer = new WebGLRenderer({ antialias: true, canvas }); + const scene = new Scene(); + scene.background = new Color("#020617"); + const camera = new PerspectiveCamera( + THREE_CAMERA_FOV_DEGREES, + canvas.clientWidth / canvas.clientHeight, + ); + camera.position.z = THREE_CAMERA_POSITION_Z_UNITS; + const raycaster = new Raycaster(); + const pointer = new Vector2(); + const leftBox = createTestBox( + "three-js-left-cube", + new Color("#a3e635"), + THREE_LEFT_BOX_POSITION, + ); + const rightBox = createTestBox( + "three-js-right-cube", + new Color("#fb923c"), + THREE_RIGHT_BOX_POSITION, + ); + const directionalLight = new DirectionalLight("#ffffff", THREE_DIRECTIONAL_LIGHT_INTENSITY); + directionalLight.position.set(...THREE_DIRECTIONAL_LIGHT_POSITION); + scene.add( + new AmbientLight("#ffffff", THREE_AMBIENT_LIGHT_INTENSITY), + directionalLight, + leftBox, + rightBox, + ); + + const renderScene = (): void => { + const width = canvas.clientWidth; + const height = canvas.clientHeight; + if (width === 0 || height === 0) return; + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.render(scene, camera); + }; + const resizeObserver = new ResizeObserver(renderScene); + resizeObserver.observe(canvas); + renderScene(); + const unregisterScene = ReactGrabPrimitives.registerThreeScene({ + camera, + pointer, + raycaster, + renderer, + scene, + }); + + return () => { + unregisterScene(); + resizeObserver.disconnect(); + leftBox.geometry.dispose(); + leftBox.material.dispose(); + rightBox.geometry.dispose(); + rightBox.material.dispose(); + renderer.dispose(); + }; + }, []); + + return ( +
+

Three.js Scene

+
+ +
+
+ ); +}; diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 730b6a5d4..7a9fb0fa3 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,7 @@ # @react-grab/cli +## 0.1.49 + ## 0.1.48 ## 0.1.47 diff --git a/packages/cli/package.json b/packages/cli/package.json index c18531b61..1234b38c5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@react-grab/cli", - "version": "0.1.48", + "version": "0.1.49", "repository": { "type": "git", "url": "git+https://github.com/aidenybai/react-grab.git" diff --git a/packages/grab/CHANGELOG.md b/packages/grab/CHANGELOG.md index fbce007de..a08c95edf 100644 --- a/packages/grab/CHANGELOG.md +++ b/packages/grab/CHANGELOG.md @@ -1,5 +1,19 @@ # grab +## 0.1.49 + +### Patch Changes + +- 9a1c4f0: Ship the accumulated selection, copy, customization, and reliability improvements since 0.1.48: + + - Grab elements inside open Shadow DOM roots and same-origin iframes, including nested and transformed frames, while preserving source context, overlays, drag selection, editor navigation, and cleanup behavior. + - Select Three.js and React Three Fiber objects directly from canvas renderers, with component metadata, source context, bounds, CSS extraction, and editing support. + - Add public element-picker primitives for filtered or container-scoped hit testing, safe bounds snapshots, transactional page freezing, and editor navigation. The `grab` alias now exposes its documented `primitives` and stylesheet subpaths too. + - Keep held selections attached to their React fibers across DOM replacements and make copy failures recoverable with Retry and Ok controls. Cancel stale or in-flight copy work, reject empty transformed output, restore hovered copy labels, and isolate plugin, action, and subscriber failures. + - Harden activation, teardown, and host-page recovery. Invalid custom activation keys no longer crash initialization; repeated or failed disposal completes safely; toolbar state survives body replacement; Style previews, animations, pseudo states, pointer behavior, and iframe resources are restored reliably. + - Improve component-name and Solid source resolution, immediate theme updates, dark-mode label contrast, auto-scroll boundary handling, toolbar snapping, and selection rendering performance. + - @react-grab/cli@0.1.49 + ## 0.1.48 ### Patch Changes diff --git a/packages/grab/package.json b/packages/grab/package.json index 81ccacc0a..c8076847f 100644 --- a/packages/grab/package.json +++ b/packages/grab/package.json @@ -1,6 +1,6 @@ { "name": "grab", - "version": "0.1.48", + "version": "0.1.49", "description": "Select context for coding agents directly from your website", "keywords": [ "agent", diff --git a/packages/react-grab/CHANGELOG.md b/packages/react-grab/CHANGELOG.md index 9544aa613..1a374b286 100644 --- a/packages/react-grab/CHANGELOG.md +++ b/packages/react-grab/CHANGELOG.md @@ -1,5 +1,19 @@ # react-grab +## 0.1.49 + +### Patch Changes + +- 9a1c4f0: Ship the accumulated selection, copy, customization, and reliability improvements since 0.1.48: + + - Grab elements inside open Shadow DOM roots and same-origin iframes, including nested and transformed frames, while preserving source context, overlays, drag selection, editor navigation, and cleanup behavior. + - Select Three.js and React Three Fiber objects directly from canvas renderers, with component metadata, source context, bounds, CSS extraction, and editing support. + - Add public element-picker primitives for filtered or container-scoped hit testing, safe bounds snapshots, transactional page freezing, and editor navigation. The `grab` alias now exposes its documented `primitives` and stylesheet subpaths too. + - Keep held selections attached to their React fibers across DOM replacements and make copy failures recoverable with Retry and Ok controls. Cancel stale or in-flight copy work, reject empty transformed output, restore hovered copy labels, and isolate plugin, action, and subscriber failures. + - Harden activation, teardown, and host-page recovery. Invalid custom activation keys no longer crash initialization; repeated or failed disposal completes safely; toolbar state survives body replacement; Style previews, animations, pseudo states, pointer behavior, and iframe resources are restored reliably. + - Improve component-name and Solid source resolution, immediate theme updates, dark-mode label contrast, auto-scroll boundary handling, toolbar snapping, and selection rendering performance. + - @react-grab/cli@0.1.49 + ## 0.1.48 ### Patch Changes @@ -7,6 +21,7 @@ - bc3a591: Fix the grab hanging on "Grabbing…" when the app saturates the dev server's connection pool. Source resolution (bundle and source-map fetches via bippy, plus Next.js server-frame symbolication) now runs through a concurrency-capped, abortable queue with a timeout, so it no longer queues indefinitely behind the app's own requests. Requires bippy ≥0.5.42 so an aborted source-map fetch no longer poisons bippy's cache and later grabs recover. Also fixes: + - A click immediately after keyboard navigation selecting a stale element instead of the one under the pointer. - The page jumping when focus is restored after a grab (focus now restores with `preventScroll`). - Being unable to select page content while a modal sets `body { pointer-events: none }` (e.g. Radix), via a hit-test override. @@ -29,6 +44,7 @@ - 5407d4e: Surface deeper copy context for wrapper-heavy elements. App-owned shared-UI / design-system frames (files under `components/ui/`, `packages/ui/`, `design-system(s)/`, or `primitives/`, e.g. shadcn's `components/ui` or a monorepo `packages/ui`) are now treated like `node_modules` frames: still shown, but exempt from the compact line budget, so a grabbed wrapper digs through its UI primitives to the meaningful feature source by default. Adds a `maxContextLines` option (also settable via the script `data-options` attribute) to raise the budget further for large apps and agent/edit prompts — restoring the option the CLI already writes. Also hardens the trace: a non-finite/negative `maxContextLines` no longer disables the hard line cap (it falls back to the default), and consecutive duplicate trace lines from shared-UI frames are collapsed so the output stays readable. + - @react-grab/cli@0.1.47 ## 0.1.46 diff --git a/packages/react-grab/e2e/constants.ts b/packages/react-grab/e2e/constants.ts index 2e64dc2df..cc682f4d7 100644 --- a/packages/react-grab/e2e/constants.ts +++ b/packages/react-grab/e2e/constants.ts @@ -23,3 +23,8 @@ export const NON_UNIFORM_SCALED_IFRAME_EXPECTED_BORDER_RADIUS = "6px 9px / 8px 1 export const SHADOW_HOVER_BACKGROUND_COLOR = "rgb(14, 165, 233)"; export const SHADOW_FRAME_FOCUS_OUTLINE_COLOR = "rgb(168, 85, 247)"; export const FRAMEWORK_COPY_RETRY_TIMEOUT_MS = 20_000; +export const THREE_CANVAS_VERTICAL_CENTER_RATIO = 0.5; +export const THREE_LEFT_OBJECT_HORIZONTAL_RATIO = 0.39; +export const THREE_OBJECT_POINTER_NUDGE_PX = 1; +export const THREE_RIGHT_OBJECT_HORIZONTAL_RATIO = 0.61; +export const THREE_SELECTION_MAX_CANVAS_WIDTH_RATIO = 0.5; diff --git a/packages/react-grab/e2e/framework/recovery.spec.ts b/packages/react-grab/e2e/framework/recovery.spec.ts index 02f82700b..c2a41df2f 100644 --- a/packages/react-grab/e2e/framework/recovery.spec.ts +++ b/packages/react-grab/e2e/framework/recovery.spec.ts @@ -68,6 +68,7 @@ test.describe("framework recovery boundaries", () => { ); await reactGrab.page.goto("/fixture-error", { waitUntil: "domcontentloaded" }); + await waitForReactGrabReady(reactGrab.page); await expect(reactGrab.page.getByTestId("fixture-error-fallback")).toBeVisible(); await reactGrab.page.getByTestId("fixture-error-recovery-link").click(); await expect(reactGrab.page.getByTestId("page-title")).toBeVisible(); diff --git a/packages/react-grab/e2e/malformed-events.spec.ts b/packages/react-grab/e2e/malformed-events.spec.ts index c24a5469b..d7372e82e 100644 --- a/packages/react-grab/e2e/malformed-events.spec.ts +++ b/packages/react-grab/e2e/malformed-events.spec.ts @@ -206,6 +206,36 @@ test.describe("Malformed Events", () => { expect(errors).toHaveLength(0); }); + + test("should not crash custom activation matchers on keydown or keyup", async ({ + reactGrab, + }) => { + const errors = collectPageErrors(reactGrab.page); + + await reactGrab.page.evaluate(() => { + window.__REACT_GRAB__?.setOptions({ + activationKey: (event) => event.key.toLowerCase() === "c", + }); + }); + + await dispatchMalformedEvent( + reactGrab.page, + "keydown", + "KeyboardEvent", + { ctrlKey: true }, + { key: undefined }, + ); + await dispatchMalformedEvent( + reactGrab.page, + "keyup", + "KeyboardEvent", + { ctrlKey: true }, + { key: undefined }, + ); + await reactGrab.page.waitForTimeout(50); + + expect(errors).toHaveLength(0); + }); }); test.describe("Keyboard: while activated", () => { diff --git a/packages/react-grab/e2e/move-to-three-object.ts b/packages/react-grab/e2e/move-to-three-object.ts new file mode 100644 index 000000000..52134733f --- /dev/null +++ b/packages/react-grab/e2e/move-to-three-object.ts @@ -0,0 +1,32 @@ +import { expect, type Page } from "@playwright/test"; +import { THREE_CANVAS_VERTICAL_CENTER_RATIO, THREE_OBJECT_POINTER_NUDGE_PX } from "./constants.js"; + +export interface CanvasPointerPosition { + x: number; + y: number; +} + +export const moveToThreeObject = async ( + page: Page, + canvasTestId: string, + horizontalRatio: number, +): Promise => { + const canvas = page.getByTestId(canvasTestId); + await canvas.scrollIntoViewIfNeeded(); + const canvasBounds = await canvas.boundingBox(); + if (!canvasBounds) throw new Error(`${canvasTestId} has no bounds`); + const pointerPosition = { + x: canvasBounds.x + canvasBounds.width * horizontalRatio, + y: canvasBounds.y + canvasBounds.height * THREE_CANVAS_VERTICAL_CENTER_RATIO, + }; + await expect + .poll(async () => { + await page.mouse.move(pointerPosition.x - THREE_OBJECT_POINTER_NUDGE_PX, pointerPosition.y); + await page.mouse.move(pointerPosition.x, pointerPosition.y); + return page.evaluate( + () => window.__REACT_GRAB__?.getState().targetElement?.tagName.toLowerCase() ?? null, + ); + }) + .toBe("mesh"); + return pointerPosition; +}; diff --git a/packages/react-grab/e2e/selection.spec.ts b/packages/react-grab/e2e/selection.spec.ts index 2a4de2d91..dee258e28 100644 --- a/packages/react-grab/e2e/selection.spec.ts +++ b/packages/react-grab/e2e/selection.spec.ts @@ -80,13 +80,11 @@ test.describe("Element Selection", () => { }; } ).__REACT_GRAB__; - api?.unregisterPlugin("slow-copy-hook"); + api?.unregisterPlugin("pending-copy-hook"); api?.registerPlugin({ - name: "slow-copy-hook", + name: "pending-copy-hook", hooks: { - onBeforeCopy: async () => { - await new Promise((resolve) => setTimeout(resolve, 600)); - }, + onBeforeCopy: () => new Promise(() => {}), }, }); }); diff --git a/packages/react-grab/e2e/three-fiber-selection.spec.ts b/packages/react-grab/e2e/three-fiber-selection.spec.ts new file mode 100644 index 000000000..8eb1f10d0 --- /dev/null +++ b/packages/react-grab/e2e/three-fiber-selection.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "./fixtures.js"; +import { moveToThreeObject } from "./move-to-three-object.js"; +import { + THREE_LEFT_OBJECT_HORIZONTAL_RATIO, + THREE_RIGHT_OBJECT_HORIZONTAL_RATIO, + THREE_SELECTION_MAX_CANVAS_WIDTH_RATIO, +} from "./constants.js"; + +test.describe("React Three Fiber selection", () => { + test("grabs an individual mesh with projected bounds and source context", async ({ + reactGrab, + page, + }) => { + await reactGrab.activate(); + const pointerPosition = await moveToThreeObject( + page, + "three-fiber-canvas", + THREE_LEFT_OBJECT_HORIZONTAL_RATIO, + ); + await reactGrab.waitForSelectionBox(); + + const selectionLabel = await reactGrab.getSelectionLabelInfo(); + expect(selectionLabel.tagName).toBe("mesh"); + await reactGrab.waitForSelectionSource(); + + const canvasBounds = await page.locator('[data-testid="three-fiber-canvas"]').boundingBox(); + const selectionBounds = await reactGrab.getSelectionBoxBounds(); + if (!canvasBounds || !selectionBounds) throw new Error("Selection bounds were not rendered"); + expect(selectionBounds.width).toBeLessThan( + canvasBounds.width * THREE_SELECTION_MAX_CANVAS_WIDTH_RATIO, + ); + expect(selectionBounds.height).toBeLessThan(canvasBounds.height); + + await page.mouse.click(pointerPosition.x, pointerPosition.y); + await expect.poll(() => reactGrab.getClipboardContent()).toContain(' { + await reactGrab.activate(); + const pointerPosition = await moveToThreeObject( + page, + "three-fiber-canvas", + THREE_RIGHT_OBJECT_HORIZONTAL_RATIO, + ); + await reactGrab.waitForSelectionBox(); + await reactGrab.rightClickAtPosition(pointerPosition.x, pointerPosition.y); + + const contextMenuInfo = await reactGrab.getContextMenuInfo(); + expect(contextMenuInfo.tagBadgeText).toBe("ThreeGrabBox.mesh"); + expect(await reactGrab.isContextMenuItemEnabled("Style")).toBe(false); + + await reactGrab.clickContextMenuItem("Copy"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain(' { + test("grabs an individual mesh with projected bounds", async ({ reactGrab, page }) => { + await reactGrab.activate(); + const pointerPosition = await moveToThreeObject( + page, + "three-js-canvas", + THREE_LEFT_OBJECT_HORIZONTAL_RATIO, + ); + await reactGrab.waitForSelectionBox(); + + const selectionLabel = await reactGrab.getSelectionLabelInfo(); + expect(selectionLabel.tagName).toBe("mesh"); + + const canvasBounds = await page.getByTestId("three-js-canvas").boundingBox(); + const selectionBounds = await reactGrab.getSelectionBoxBounds(); + if (!canvasBounds || !selectionBounds) throw new Error("Selection bounds were not rendered"); + expect(selectionBounds.width).toBeLessThan( + canvasBounds.width * THREE_SELECTION_MAX_CANVAS_WIDTH_RATIO, + ); + expect(selectionBounds.height).toBeLessThan(canvasBounds.height); + + await page.mouse.click(pointerPosition.x, pointerPosition.y); + await expect + .poll(() => reactGrab.getClipboardContent()) + .toContain(' { + await reactGrab.activate(); + const pointerPosition = await moveToThreeObject( + page, + "three-js-canvas", + THREE_RIGHT_OBJECT_HORIZONTAL_RATIO, + ); + await reactGrab.waitForSelectionBox(); + await reactGrab.rightClickAtPosition(pointerPosition.x, pointerPosition.y); + + const contextMenuInfo = await reactGrab.getContextMenuInfo(); + expect(contextMenuInfo.tagBadgeText).toBe("mesh"); + expect(await reactGrab.isContextMenuItemEnabled("Style")).toBe(false); + + await reactGrab.clickContextMenuItem("Copy"); + await expect + .poll(() => reactGrab.getClipboardContent()) + .toContain(' { if (name.length <= 1) return false; @@ -48,7 +48,7 @@ const findNearestFiberElement = (element: Element): Element => { if (!isInstrumentationActive()) return element; let current: Element | null = element; while (current?.ownerDocument === element.ownerDocument) { - if (getFiberFromHostInstance(current)) return current; + if (getReactFiberForElement(current)) return current; if (current.parentElement) { current = current.parentElement; continue; @@ -93,7 +93,7 @@ const findNearestListItemKey = (startingFiber: Fiber | null): string | null => { const getNearestListItemKey = (element: Element): string | null => { if (!isInstrumentationActive()) return null; - return findNearestListItemKey(getFiberFromHostInstance(findNearestFiberElement(element))); + return findNearestListItemKey(getReactFiberForElement(findNearestFiberElement(element))); }; const stackCache = new WeakMap>(); @@ -115,7 +115,7 @@ const createSourceFetch = const fetchStackForElement = (element: Element): Promise => runQueuedSourceFetch(async (signal) => { try { - const fiber = getFiberFromHostInstance(element); + const fiber = getReactFiberForElement(element); if (!fiber) return null; const frames = await getOwnerStack(fiber, true, createSourceFetch(signal)); @@ -183,7 +183,7 @@ const getSourceComponentName = (fiber: Fiber | undefined): string | null => { // both compete for the same connection pool and neither has its own timeout. const getFiberSource = (element: Element): Promise => runQueuedSourceFetch(async (signal) => { - const fiber = getFiberFromHostInstance(findNearestFiberElement(element)); + const fiber = getReactFiberForElement(findNearestFiberElement(element)); if (!fiber) return null; try { @@ -296,7 +296,7 @@ const getComponentNamesFromFiber = ( shouldIncludeName: (componentName: string) => boolean = () => true, ): string[] => { if (!isInstrumentationActive()) return []; - const fiber = getFiberFromHostInstance(element); + const fiber = getReactFiberForElement(element); if (!fiber) return []; const componentNames: string[] = []; diff --git a/packages/react-grab/src/core/element-adapter.ts b/packages/react-grab/src/core/element-adapter.ts new file mode 100644 index 000000000..9fac5e3be --- /dev/null +++ b/packages/react-grab/src/core/element-adapter.ts @@ -0,0 +1,25 @@ +import { getFiberFromHostInstance, type Fiber } from "bippy"; +import type { OverlayBounds } from "../types.js"; + +interface ElementAdapter { + hostElement: Element; + supportsDomEditing: boolean; + getBounds: () => OverlayBounds; + getFiber: () => Fiber | null; + getPreview: () => string; + getSelector: () => string; + getTagName: () => string; + isConnected: () => boolean; +} + +const elementAdapters = new WeakMap(); + +export const registerElementAdapter = (element: Element, adapter: ElementAdapter): void => { + elementAdapters.set(element, adapter); +}; + +export const getElementAdapter = (element: Element): ElementAdapter | null => + elementAdapters.get(element) ?? null; + +export const getReactFiberForElement = (element: Element): Fiber | null => + getElementAdapter(element)?.getFiber() ?? getFiberFromHostInstance(element); diff --git a/packages/react-grab/src/core/html-preview.ts b/packages/react-grab/src/core/html-preview.ts index 5a84b5ea0..8fe2c7fc0 100644 --- a/packages/react-grab/src/core/html-preview.ts +++ b/packages/react-grab/src/core/html-preview.ts @@ -11,6 +11,7 @@ import { isInternalAttribute } from "../utils/strip-internal-attributes.js"; import { getPreviewTextContent } from "../utils/get-preview-text-content.js"; import { isElementNode } from "../utils/is-element-node.js"; import { isHtmlElement } from "../utils/is-html-element.js"; +import { getElementAdapter } from "./element-adapter.js"; const truncateAttrValue = (attributeValue: string): string => truncateString(attributeValue, PREVIEW_ATTR_VALUE_MAX_LENGTH); @@ -63,6 +64,8 @@ const formatChildElements = (elements: Array): string => { }; export const getInlineHTMLPreview = (element: Element): string => { + const adapter = getElementAdapter(element); + if (adapter) return adapter.getPreview(); const tagName = getTagName(element); if (!isHtmlElement(element)) { @@ -80,6 +83,8 @@ export const getInlineHTMLPreview = (element: Element): string => { }; export const getHTMLPreview = (element: Element): string => { + const adapter = getElementAdapter(element); + if (adapter) return adapter.getPreview(); const tagName = getTagName(element); const attrsText = formatAttrsForPreview(element); const previewText = getPreviewTextContent(element, tagName); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index cd6cb76a6..a2b0965f6 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -2964,9 +2964,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { (requiredModifiers.altKey && !event.altKey); const isReleasingActivationKey = pluginRegistry.store.options.activationKey - ? typeof pluginRegistry.store.options.activationKey === "function" - ? pluginRegistry.store.options.activationKey(event) - : parseActivationKey(pluginRegistry.store.options.activationKey)(event) + ? parseActivationKey(pluginRegistry.store.options.activationKey)(event) : isCLikeKey(event.key, event.code); if (didJustCopy() || isCopyFeedbackCooldownActive) { diff --git a/packages/react-grab/src/core/plugins/edit.ts b/packages/react-grab/src/core/plugins/edit.ts index 301065215..2e5874dc0 100644 --- a/packages/react-grab/src/core/plugins/edit.ts +++ b/packages/react-grab/src/core/plugins/edit.ts @@ -1,4 +1,5 @@ import type { Plugin } from "../../types.js"; +import { getElementAdapter } from "../element-adapter.js"; export const editPlugin: Plugin = { name: "edit", @@ -9,6 +10,7 @@ export const editPlugin: Plugin = { shortcut: "S", shortcutModifier: false, showInToolbarMenu: true, + enabled: (context) => getElementAdapter(context.element)?.supportsDomEditing !== false, onAction: (context) => { context.enterEditMode?.(); }, diff --git a/packages/react-grab/src/core/three-selection.ts b/packages/react-grab/src/core/three-selection.ts new file mode 100644 index 000000000..dfdd31f3b --- /dev/null +++ b/packages/react-grab/src/core/three-selection.ts @@ -0,0 +1,521 @@ +import { + getFiberFromHostInstance, + getLatestFiber, + instrument, + type Fiber, + type FiberRoot, +} from "bippy"; +import type { OverlayBounds } from "../types.js"; +import { + THREE_PREVIEW_ARRAY_MAX_LENGTH, + THREE_SELECTION_FALLBACK_BOUNDS_PX, +} from "../constants.js"; +import { createElementBounds } from "../utils/create-element-bounds.js"; +import { registerElementAdapter } from "./element-adapter.js"; + +interface ThreeVectorLike { + x: number; + y: number; + z: number; + set: (x: number, y: number, z: number) => ThreeVectorLike; + clone: () => ThreeVectorLike; + applyMatrix4: (matrix: ThreeMatrixLike) => ThreeVectorLike; + project: (camera: ThreeCameraLike) => ThreeVectorLike; +} + +interface ThreeMatrixLike { + clone: () => ThreeMatrixLike; + premultiply: (matrix: ThreeMatrixLike) => ThreeMatrixLike; +} + +interface ThreeBoxLike { + min: ThreeVectorLike; + max: ThreeVectorLike; +} + +interface ThreeGeometryLike { + boundingBox: ThreeBoxLike | null; + computeBoundingBox: () => void; +} + +interface ThreeCameraLike { + isCamera: boolean; +} + +interface ReactThreeFiberInstanceLike { + type: string; + props: Record; + object: ThreeObjectLike; + eventCount?: number; +} + +interface ThreeObjectLike { + isObject3D: boolean; + isScene?: boolean; + uuid: string; + name: string; + type: string; + visible: boolean; + parent: ThreeObjectLike | null; + geometry?: ThreeGeometryLike; + matrixWorld: ThreeMatrixLike; + updateWorldMatrix: (updateParents: boolean, updateChildren: boolean) => void; + getMatrixAt?: (instanceId: number, matrix: ThreeMatrixLike) => void; + children?: ThreeObjectLike[]; + __r3f?: ReactThreeFiberInstanceLike; +} + +interface ThreeIntersectionLike { + object: ThreeObjectLike; + point?: ThreeVectorLike; + instanceId?: number; +} + +interface ThreePointerLike { + set: (x: number, y: number) => void; +} + +interface ThreeRaycasterLike { + setFromCamera: (pointer: ThreePointerLike, camera: ThreeCameraLike) => void; + intersectObjects: (objects: ThreeObjectLike[], recursive: boolean) => ThreeIntersectionLike[]; +} + +interface ThreeRendererLike { + domElement: HTMLCanvasElement; +} + +interface ThreeSceneLike extends ThreeObjectLike { + isScene: boolean; + children: ThreeObjectLike[]; +} + +interface ThreeRootState { + gl: ThreeRendererLike; + scene: ThreeSceneLike; + camera: ThreeCameraLike; + raycaster: ThreeRaycasterLike; + pointer: ThreePointerLike; +} + +export interface ThreeSceneRegistration { + camera: object; + pointer: object; + raycaster: object; + renderer: object; + scene: object; +} + +interface ThreeRoot { + isReactThreeFiber: boolean; + state: ThreeRootState; +} + +interface ThreeSelection { + canvas: HTMLCanvasElement; + element: Element; + fiber: Fiber | null; + instance: ReactThreeFiberInstanceLike | null; + instanceId: number | null; + intersectionPoint: ThreeVectorLike | null; + object: ThreeObjectLike; + rootState: ThreeRootState; + tagName: string; +} + +const selectionsByObject = new WeakMap>(); +const threeRootByCanvas = new WeakMap(); +const THREE_PREVIEW_PROP_NAMES = [ + "name", + "position", + "rotation", + "scale", + "color", + "visible", + "args", +]; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isObjectOrFunction = (value: unknown): value is object => + (typeof value === "object" && value !== null) || typeof value === "function"; + +const hasFunction = (value: Record, propertyName: string): boolean => + typeof value[propertyName] === "function"; + +const isThreeVector = (value: unknown): value is ThreeVectorLike => + isRecord(value) && + typeof value.x === "number" && + typeof value.y === "number" && + typeof value.z === "number" && + hasFunction(value, "set") && + hasFunction(value, "clone") && + hasFunction(value, "applyMatrix4") && + hasFunction(value, "project"); + +const isThreeMatrix = (value: unknown): value is ThreeMatrixLike => + isRecord(value) && hasFunction(value, "clone") && hasFunction(value, "premultiply"); + +const isThreeGeometry = (value: unknown): value is ThreeGeometryLike => + isRecord(value) && "boundingBox" in value && hasFunction(value, "computeBoundingBox"); + +const isReactThreeFiberInstance = (value: unknown): value is ReactThreeFiberInstanceLike => + isRecord(value) && + typeof value.type === "string" && + isRecord(value.props) && + isThreeObject(value.object); + +const isThreeObject = (value: unknown): value is ThreeObjectLike => + isRecord(value) && + value.isObject3D === true && + typeof value.uuid === "string" && + typeof value.name === "string" && + typeof value.type === "string" && + typeof value.visible === "boolean" && + isThreeMatrix(value.matrixWorld) && + hasFunction(value, "updateWorldMatrix"); + +const isThreeCamera = (value: unknown): value is ThreeCameraLike => + isRecord(value) && value.isCamera === true; + +const isThreeScene = (value: unknown): value is ThreeSceneLike => + isThreeObject(value) && value.isScene === true && Array.isArray(value.children); + +const isCanvasElement = (value: unknown): value is HTMLCanvasElement => + isRecord(value) && + typeof value.tagName === "string" && + value.tagName.toLowerCase() === "canvas" && + hasFunction(value, "getContext"); + +const isThreeRenderer = (value: unknown): value is ThreeRendererLike => + isRecord(value) && isCanvasElement(value.domElement); + +const isThreePointer = (value: unknown): value is ThreePointerLike => + isRecord(value) && hasFunction(value, "set"); + +const isThreeRaycaster = (value: unknown): value is ThreeRaycasterLike => + isRecord(value) && hasFunction(value, "setFromCamera") && hasFunction(value, "intersectObjects"); + +const isThreeRootState = (value: unknown): value is ThreeRootState => + isRecord(value) && + isThreeRenderer(value.gl) && + isThreeScene(value.scene) && + isThreeCamera(value.camera) && + isThreeRaycaster(value.raycaster) && + isThreePointer(value.pointer); + +const getReactThreeFiberInstance = (object: ThreeObjectLike): ReactThreeFiberInstanceLike | null => + isReactThreeFiberInstance(object.__r3f) ? object.__r3f : null; + +const getThreeRootState = (root: FiberRoot): ThreeRootState | null => { + const stateNode = root.current.stateNode; + if (!isRecord(stateNode) || !isObjectOrFunction(stateNode.containerInfo)) return null; + const getState = Reflect.get(stateNode.containerInfo, "getState"); + if (typeof getState !== "function") return null; + const state = getState(); + return isThreeRootState(state) ? state : null; +}; + +instrument({ + name: "react-grab-three-selection", + onCommitFiberRoot: (_rendererId, root) => { + const rootState = getThreeRootState(root); + if (!rootState) return; + threeRootByCanvas.set(rootState.gl.domElement, { + isReactThreeFiber: true, + state: rootState, + }); + }, +}); + +const findReactThreeFiberObject = (object: ThreeObjectLike): ThreeObjectLike | null => { + let currentObject: ThreeObjectLike | null = object; + while (currentObject) { + if (getReactThreeFiberInstance(currentObject)) return currentObject; + currentObject = currentObject.parent; + } + return null; +}; + +const hasReactThreeFiberInteractionIntent = (object: ThreeObjectLike): boolean => { + let currentObject: ThreeObjectLike | null = object; + while (currentObject) { + const instance = getReactThreeFiberInstance(currentObject); + if (instance && typeof instance.eventCount === "number" && instance.eventCount > 0) { + return true; + } + currentObject = currentObject.parent; + } + return false; +}; + +const isThreeObjectInScene = (object: ThreeObjectLike, scene: ThreeSceneLike): boolean => { + let currentObject: ThreeObjectLike | null = object; + while (currentObject) { + if (currentObject === scene) return true; + currentObject = currentObject.parent; + } + return false; +}; + +const getOrCreateSelectionElement = ( + rootState: ThreeRootState, + object: ThreeObjectLike, + intersection: ThreeIntersectionLike, +): Element | null => { + const instanceId = + typeof intersection.instanceId === "number" && Number.isInteger(intersection.instanceId) + ? intersection.instanceId + : null; + let selectionsByInstance = selectionsByObject.get(object); + if (!selectionsByInstance) { + selectionsByInstance = new Map(); + selectionsByObject.set(object, selectionsByInstance); + } + + const existingSelection = selectionsByInstance.get(instanceId); + if (existingSelection) { + existingSelection.canvas = rootState.gl.domElement; + existingSelection.intersectionPoint = isThreeVector(intersection.point) + ? intersection.point + : null; + existingSelection.object = object; + existingSelection.rootState = rootState; + return existingSelection.element; + } + + const instance = getReactThreeFiberInstance(object); + const fiber = instance ? getFiberFromHostInstance(instance) : null; + if (instance && !fiber) return null; + const tagName = (instance?.type || object.type || "object-3d").toLowerCase(); + const createdElement = rootState.gl.domElement.ownerDocument.createElement(tagName); + const selection: ThreeSelection = { + canvas: rootState.gl.domElement, + element: createdElement, + fiber, + instance, + instanceId, + intersectionPoint: isThreeVector(intersection.point) ? intersection.point : null, + object, + rootState, + tagName, + }; + const nativeGetBoundingClientRect = createdElement.getBoundingClientRect.bind(createdElement); + createdElement.getBoundingClientRect = () => { + const bounds = createThreeSelectionBounds(selection); + const domRectConstructor = createdElement.ownerDocument.defaultView?.DOMRect; + if (!domRectConstructor) return nativeGetBoundingClientRect(); + return new domRectConstructor(bounds.x, bounds.y, bounds.width, bounds.height); + }; + registerElementAdapter(createdElement, { + hostElement: selection.canvas, + supportsDomEditing: false, + getBounds: () => createThreeSelectionBounds(selection), + getFiber: () => (selection.fiber ? getLatestFiber(selection.fiber) : null), + getPreview: () => getThreeSelectionPreview(selection), + getSelector: () => createThreeSelectionSelector(selection), + getTagName: () => selection.tagName, + isConnected: () => + selection.canvas.isConnected && + isThreeObjectInScene(selection.object, selection.rootState.scene), + }); + selectionsByInstance.set(instanceId, selection); + return createdElement; +}; + +export const resolveThreeElementAtPoint = ( + candidateElement: Element, + clientX: number, + clientY: number, +): Element => { + if (!isCanvasElement(candidateElement)) return candidateElement; + const root = threeRootByCanvas.get(candidateElement); + if (!root) return candidateElement; + + const canvasBounds = createElementBounds(candidateElement); + if (canvasBounds.width <= 0 || canvasBounds.height <= 0) return candidateElement; + const pointerX = ((clientX - canvasBounds.x) / canvasBounds.width) * 2 - 1; + const pointerY = -((clientY - canvasBounds.y) / canvasBounds.height) * 2 + 1; + if (pointerX < -1 || pointerX > 1 || pointerY < -1 || pointerY > 1) { + return candidateElement; + } + + try { + root.state.pointer.set(pointerX, pointerY); + root.state.raycaster.setFromCamera(root.state.pointer, root.state.camera); + const intersections = root.state.raycaster.intersectObjects(root.state.scene.children, true); + if (root.isReactThreeFiber) { + for (const intersection of intersections) { + if (!hasReactThreeFiberInteractionIntent(intersection.object)) continue; + const object = findReactThreeFiberObject(intersection.object); + if (!object || object.visible === false) continue; + const element = getOrCreateSelectionElement(root.state, object, intersection); + if (element) return element; + } + } + for (const intersection of intersections) { + const object = root.isReactThreeFiber + ? findReactThreeFiberObject(intersection.object) + : intersection.object; + if (!object || object.visible === false) continue; + const element = getOrCreateSelectionElement(root.state, object, intersection); + if (element) return element; + } + } catch {} + return candidateElement; +}; + +export const registerThreeScene = (registration: ThreeSceneRegistration): (() => void) => { + const rootState = { + gl: registration.renderer, + scene: registration.scene, + camera: registration.camera, + raycaster: registration.raycaster, + pointer: registration.pointer, + }; + if (!isThreeRootState(rootState)) { + throw new TypeError("Invalid Three.js scene registration"); + } + const canvas = rootState.gl.domElement; + const rootRegistration = { isReactThreeFiber: false, state: rootState }; + threeRootByCanvas.set(canvas, rootRegistration); + return () => { + if (threeRootByCanvas.get(canvas) === rootRegistration) threeRootByCanvas.delete(canvas); + }; +}; + +const formatPropValue = (value: unknown): string | null => { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") return `{${String(value)}}`; + if ( + Array.isArray(value) && + value.length <= THREE_PREVIEW_ARRAY_MAX_LENGTH && + value.every((item) => typeof item === "number" || typeof item === "string") + ) { + return `{${JSON.stringify(value)}}`; + } + return null; +}; + +const getThreeSelectionPreview = (selection: ThreeSelection): string => { + const props = selection.instance?.props ?? {}; + const attributes: string[] = []; + + for (const propName of THREE_PREVIEW_PROP_NAMES) { + let propValue = props[propName]; + if (propName === "name" && propValue === undefined && selection.object.name) { + propValue = selection.object.name; + } + const formattedValue = formatPropValue(propValue); + if (formattedValue) attributes.push(`${propName}=${formattedValue}`); + } + if (selection.instanceId !== null) attributes.push(`instanceId={${selection.instanceId}}`); + return `<${selection.tagName}${attributes.length > 0 ? ` ${attributes.join(" ")}` : ""} />`; +}; + +const createThreeSelectionSelector = (selection: ThreeSelection): string => { + if (selection.object.name) { + return `${selection.tagName}[name=${JSON.stringify(selection.object.name)}]`; + } + const instanceSuffix = selection.instanceId === null ? "" : `:${selection.instanceId}`; + return `${selection.tagName}[uuid=${JSON.stringify(`${selection.object.uuid}${instanceSuffix}`)}]`; +}; + +const getObjectMatrix = (selection: ThreeSelection): ThreeMatrixLike => { + selection.object.updateWorldMatrix(true, false); + const worldMatrix = selection.object.matrixWorld.clone(); + if (selection.instanceId === null || !selection.object.getMatrixAt) return worldMatrix; + const instanceMatrix = selection.object.matrixWorld.clone(); + selection.object.getMatrixAt(selection.instanceId, instanceMatrix); + return instanceMatrix.premultiply(worldMatrix); +}; + +const getGeometryBoundingBox = (object: ThreeObjectLike): ThreeBoxLike | null => { + if (!isThreeGeometry(object.geometry)) return null; + if (!object.geometry.boundingBox) object.geometry.computeBoundingBox(); + const boundingBox = object.geometry.boundingBox; + if (!boundingBox || !isThreeVector(boundingBox.min) || !isThreeVector(boundingBox.max)) { + return null; + } + return boundingBox; +}; + +const createFallbackBounds = ( + selection: ThreeSelection, + canvasBounds: OverlayBounds, +): OverlayBounds => { + const fallbackSize = THREE_SELECTION_FALLBACK_BOUNDS_PX; + let centerX = canvasBounds.x + canvasBounds.width / 2; + let centerY = canvasBounds.y + canvasBounds.height / 2; + if (selection.intersectionPoint) { + const projectedPoint = selection.intersectionPoint.clone().project(selection.rootState.camera); + if (Number.isFinite(projectedPoint.x) && Number.isFinite(projectedPoint.y)) { + centerX = canvasBounds.x + ((projectedPoint.x + 1) / 2) * canvasBounds.width; + centerY = canvasBounds.y + ((1 - projectedPoint.y) / 2) * canvasBounds.height; + } + } + return { + x: centerX - fallbackSize / 2, + y: centerY - fallbackSize / 2, + width: fallbackSize, + height: fallbackSize, + borderRadius: "0px", + }; +}; + +const createThreeSelectionBounds = (selection: ThreeSelection): OverlayBounds => { + const canvasBounds = createElementBounds(selection.canvas); + const boundingBox = getGeometryBoundingBox(selection.object); + if (!boundingBox) return createFallbackBounds(selection, canvasBounds); + + const matrix = getObjectMatrix(selection); + const xValues = [boundingBox.min.x, boundingBox.max.x]; + const yValues = [boundingBox.min.y, boundingBox.max.y]; + const zValues = [boundingBox.min.z, boundingBox.max.z]; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + const projectedCorner = boundingBox.min.clone(); + + for (const xValue of xValues) { + for (const yValue of yValues) { + for (const zValue of zValues) { + projectedCorner + .set(xValue, yValue, zValue) + .applyMatrix4(matrix) + .project(selection.rootState.camera); + if (!Number.isFinite(projectedCorner.x) || !Number.isFinite(projectedCorner.y)) continue; + const cornerX = canvasBounds.x + ((projectedCorner.x + 1) / 2) * canvasBounds.width; + const cornerY = canvasBounds.y + ((1 - projectedCorner.y) / 2) * canvasBounds.height; + minX = Math.min(minX, cornerX); + minY = Math.min(minY, cornerY); + maxX = Math.max(maxX, cornerX); + maxY = Math.max(maxY, cornerY); + } + } + } + + if ( + !Number.isFinite(minX) || + !Number.isFinite(minY) || + !Number.isFinite(maxX) || + !Number.isFinite(maxY) + ) { + return createFallbackBounds(selection, canvasBounds); + } + const clampedMinX = Math.max(canvasBounds.x, minX); + const clampedMinY = Math.max(canvasBounds.y, minY); + const clampedMaxX = Math.min(canvasBounds.x + canvasBounds.width, maxX); + const clampedMaxY = Math.min(canvasBounds.y + canvasBounds.height, maxY); + if (clampedMaxX <= clampedMinX || clampedMaxY <= clampedMinY) { + return createFallbackBounds(selection, canvasBounds); + } + return { + x: clampedMinX, + y: clampedMinY, + width: clampedMaxX - clampedMinX, + height: clampedMaxY - clampedMinY, + borderRadius: "0px", + }; +}; diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts index d72183a57..033863fac 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -14,7 +14,7 @@ import { resolveSource, } from "./core/context.js"; import { getHTMLPreview } from "./core/html-preview.js"; -import { Fiber, getFiberFromHostInstance } from "bippy"; +import type { Fiber } from "bippy"; import type { StackFrame } from "bippy/source"; export type { StackFrame }; import { createElementSelector } from "./utils/create-element-selector.js"; @@ -26,6 +26,10 @@ import { createElementBounds } from "./utils/create-element-bounds.js"; import { getUnfilteredElementsAtPoint } from "./utils/get-unfiltered-elements-at-point.js"; import { matchesElementAtPointOptions } from "./utils/matches-element-at-point-options.js"; import type { ElementAtPointOptions, ElementBounds } from "./types.js"; +import { getReactFiberForElement } from "./core/element-adapter.js"; +import { resolveThreeElementAtPoint } from "./core/three-selection.js"; + +export { registerThreeScene, type ThreeSceneRegistration } from "./core/three-selection.js"; export type { ElementAtPointOptions, ElementBounds } from "./types.js"; @@ -89,7 +93,7 @@ export const getElementContext = async (element: Element): Promise { const elements = getUnfilteredElementsAtPoint(clientX, clientY); for (const element of elements) { - if (matchesElementAtPointOptions(element, options)) return element; + const resolvedElement = resolveThreeElementAtPoint(element, clientX, clientY); + if (matchesElementAtPointOptions(resolvedElement, options)) { + return resolvedElement; + } + if (resolvedElement !== element && matchesElementAtPointOptions(element, options)) + return element; } return null; }; @@ -148,7 +157,14 @@ export const getElementsAtPoint = ( const elements = getUnfilteredElementsAtPoint(clientX, clientY); const matchingElements: Element[] = []; for (const element of elements) { - if (matchesElementAtPointOptions(element, options)) matchingElements.push(element); + const resolvedElement = resolveThreeElementAtPoint(element, clientX, clientY); + if (matchesElementAtPointOptions(resolvedElement, options)) { + matchingElements.push(resolvedElement); + continue; + } + if (resolvedElement !== element && matchesElementAtPointOptions(element, options)) { + matchingElements.push(element); + } } return matchingElements; }; diff --git a/packages/react-grab/src/utils/create-element-bounds.ts b/packages/react-grab/src/utils/create-element-bounds.ts index 1ade8dff9..44be94460 100644 --- a/packages/react-grab/src/utils/create-element-bounds.ts +++ b/packages/react-grab/src/utils/create-element-bounds.ts @@ -3,6 +3,7 @@ import { BOUNDS_CACHE_TTL_MS, BORDER_RADIUS_CACHE_TTL_MS } from "../constants.js import { convertClientPositionToTopWindow } from "./convert-client-position-to-top-window.js"; import { getElementComputedStyle } from "./get-element-computed-style.js"; import { scaleBorderRadius } from "./scale-border-radius.js"; +import { getElementAdapter } from "../core/element-adapter.js"; interface CachedBounds { bounds: OverlayBounds; @@ -46,6 +47,13 @@ export const createElementBounds = (element: Element): OverlayBounds => { return cached.bounds; } + const adapter = getElementAdapter(element); + if (adapter) { + const bounds = adapter.getBounds(); + boundsCache.set(element, { bounds, timestamp: now }); + return bounds; + } + const rect = element.getBoundingClientRect(); const topWindowPosition = convertClientPositionToTopWindow( element.ownerDocument.defaultView, diff --git a/packages/react-grab/src/utils/create-element-selector.ts b/packages/react-grab/src/utils/create-element-selector.ts index 3b509122c..e3868e5d5 100644 --- a/packages/react-grab/src/utils/create-element-selector.ts +++ b/packages/react-grab/src/utils/create-element-selector.ts @@ -3,6 +3,7 @@ import { FINDER_TIMEOUT_MS, SELECTOR_ATTR_VALUE_MAX_LENGTH_CHARS } from "../cons import { getWindowFrameElement } from "./get-window-frame-element.js"; import { isShadowRoot } from "./is-shadow-root.js"; import { isElementNode } from "./is-element-node.js"; +import { getElementAdapter } from "../core/element-adapter.js"; const getFinderRoot = (element: Element): Element => element.ownerDocument.body ?? element.ownerDocument.documentElement; @@ -122,6 +123,8 @@ const createLocalElementSelector = (element: Element): string => { }; export const createElementSelector = (element: Element): string => { + const adapter = getElementAdapter(element); + if (adapter) return adapter.getSelector(); const localSelector = createLocalElementSelector(element); const rootNode = element.getRootNode(); if (isShadowRoot(rootNode)) { diff --git a/packages/react-grab/src/utils/extract-element-css.ts b/packages/react-grab/src/utils/extract-element-css.ts index bee86cda6..5fc603b93 100644 --- a/packages/react-grab/src/utils/extract-element-css.ts +++ b/packages/react-grab/src/utils/extract-element-css.ts @@ -1,4 +1,5 @@ import { RELEVANT_CSS_PROPERTIES } from "../constants.js"; +import { getElementAdapter } from "../core/element-adapter.js"; const BORDER_FILTER_SIDE_MAP = new Map( (["top", "right", "bottom", "left"] as const).flatMap((side) => [ @@ -58,6 +59,7 @@ const isBorderPropertyWithoutWidth = ( }; export const extractElementCss = (element: Element): string => { + if (getElementAdapter(element)?.supportsDomEditing === false) return ""; const tagName = element.tagName.toLowerCase(); const defaultStyles = getDefaultStylesForTag(tagName); const computedStyle = getComputedStyle(element); diff --git a/packages/react-grab/src/utils/freeze-updates.ts b/packages/react-grab/src/utils/freeze-updates.ts index ce42a42a8..4c3eea635 100644 --- a/packages/react-grab/src/utils/freeze-updates.ts +++ b/packages/react-grab/src/utils/freeze-updates.ts @@ -92,6 +92,9 @@ const renderersWithPatchedDispatcher = new WeakSet(); const typedFiberRoots = _fiberRoots as Set; const pausedFiberRoots = new Set(); +const isDomRenderer = (renderer: ReactRenderer): boolean => + renderer.rendererPackageName.startsWith("react-dom"); + const getFiberRoot = (fiber: Fiber): FiberRootLike | null => { let current: Fiber | null = fiber; while (current.return) { @@ -100,11 +103,26 @@ const getFiberRoot = (fiber: Fiber): FiberRootLike | null => { return (current.stateNode ?? null) as FiberRootLike | null; }; +const isDomFiberRoot = (fiberRoot: FiberRootLike): boolean => { + const stateNode = fiberRoot.current?.stateNode; + if (!stateNode || typeof stateNode !== "object") return false; + const containerInfo = Reflect.get(stateNode, "containerInfo"); + return Boolean( + containerInfo && + typeof containerInfo === "object" && + typeof Reflect.get(containerInfo, "nodeType") === "number", + ); +}; + // Collects React fiber roots, preferring bippy's tracked set but falling back // to a DOM walk when the app mounted before bippy instrumented the renderers. const collectFiberRoots = (): Set => { if (typedFiberRoots.size > 0) { - return typedFiberRoots; + const domFiberRoots = new Set(); + for (const fiberRoot of typedFiberRoots) { + if (isDomFiberRoot(fiberRoot)) domFiberRoots.add(fiberRoot); + } + return domFiberRoots; } const collectedRoots = new Set(); @@ -517,6 +535,7 @@ const scheduleReactUpdate = (fiberRoots: Set): void => { queueMicrotask(() => { try { for (const renderer of getRDTHook().renderers.values()) { + if (!isDomRenderer(renderer)) continue; if (typeof renderer.scheduleUpdate !== "function") continue; for (const fiberRoot of fiberRoots) { if (fiberRoot.current) { @@ -548,6 +567,7 @@ const invokeCallbacks = (callbacks: Array<() => void>): void => { const initializeFreezeSupport = (): void => { for (const renderer of getRDTHook().renderers.values()) { + if (!isDomRenderer(renderer)) continue; if (renderersWithPatchedDispatcher.has(renderer)) continue; installDispatcherPatching(renderer); renderersWithPatchedDispatcher.add(renderer); 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 431cc4ca0..a63021726 100644 --- a/packages/react-grab/src/utils/get-element-at-position.ts +++ b/packages/react-grab/src/utils/get-element-at-position.ts @@ -14,6 +14,7 @@ import { isIframeElement } from "./is-iframe-element.js"; import { isPointInsideRect } from "./is-point-inside-rect.js"; import { isValidGrabbableElement } from "./is-valid-grabbable-element.js"; import { resumePointerEventsFreeze, suspendPointerEventsFreeze } from "./pointer-events-freeze.js"; +import { resolveThreeElementAtPoint } from "../core/three-selection.js"; interface PositionCache { clientX: number; @@ -64,8 +65,12 @@ export const getElementsAtPoint = (clientX: number, clientY: number): Element[] suspendPointerEventsFreeze(); try { const elements = getDeepElementsAtPoint(clientX, clientY); - if (!getScopeContainer()) return elements; - return elements.filter(isWithinScope); + const scopedElements = getScopeContainer() ? elements.filter(isWithinScope) : elements; + const resolvedElements: Element[] = []; + for (const element of scopedElements) { + resolvedElements.push(resolveThreeElementAtPoint(element, clientX, clientY)); + } + return resolvedElements; } finally { schedulePointerEventsResume(); } @@ -130,8 +135,16 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element // overlapping the scoped container) we fall back to elementsFromPoint, which // returns the full z-ordered stack, and take the first grabbable in-scope one. const topElement = getDeepElementAtPoint(clientX, clientY); - if (topElement && isValidGrabbableElement(topElement) && isWithinScope(topElement)) { - result = topElement; + const resolvedElement = topElement + ? resolveThreeElementAtPoint(topElement, clientX, clientY) + : null; + if ( + topElement && + resolvedElement && + isValidGrabbableElement(resolvedElement) && + isWithinScope(topElement) + ) { + result = resolvedElement; } else { result = getDeepFallbackElementAtPoint(clientX, clientY); } diff --git a/packages/react-grab/src/utils/get-tag-name.ts b/packages/react-grab/src/utils/get-tag-name.ts index 428b45fb8..c77889f9b 100644 --- a/packages/react-grab/src/utils/get-tag-name.ts +++ b/packages/react-grab/src/utils/get-tag-name.ts @@ -1 +1,4 @@ -export const getTagName = (element: Element): string => (element.tagName || "").toLowerCase(); +import { getElementAdapter } from "../core/element-adapter.js"; + +export const getTagName = (element: Element): string => + getElementAdapter(element)?.getTagName() ?? (element.tagName || "").toLowerCase(); diff --git a/packages/react-grab/src/utils/is-element-connected.ts b/packages/react-grab/src/utils/is-element-connected.ts index 69eec1445..61ed2d568 100644 --- a/packages/react-grab/src/utils/is-element-connected.ts +++ b/packages/react-grab/src/utils/is-element-connected.ts @@ -1,2 +1,8 @@ -export const isElementConnected = (element: Element | null | undefined): element is Element => - Boolean(element?.isConnected ?? element?.ownerDocument?.contains(element)); +import { getElementAdapter } from "../core/element-adapter.js"; + +export const isElementConnected = (element: Element | null | undefined): element is Element => { + if (!element) return false; + const adapter = getElementAdapter(element); + if (adapter) return adapter.isConnected(); + return Boolean(element.isConnected || element.ownerDocument?.contains(element)); +}; diff --git a/packages/react-grab/src/utils/is-element-within-container.ts b/packages/react-grab/src/utils/is-element-within-container.ts index 8946c14d5..34e7a60e7 100644 --- a/packages/react-grab/src/utils/is-element-within-container.ts +++ b/packages/react-grab/src/utils/is-element-within-container.ts @@ -1,7 +1,8 @@ import { getComposedParentElement } from "./get-composed-parent-element.js"; +import { getElementAdapter } from "../core/element-adapter.js"; export const isElementWithinContainer = (element: Element, container: Element): boolean => { - let currentElement: Element | null = element; + let currentElement: Element | null = getElementAdapter(element)?.hostElement ?? element; while (currentElement) { if (currentElement === container) return true; currentElement = getComposedParentElement(currentElement); diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index 6a3a330e9..a78d635c7 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -9,6 +9,7 @@ import { isRootElement } from "./is-root-element.js"; import { getElementComputedStyle } from "./get-element-computed-style.js"; import { isReactGrabElement } from "./is-react-grab-element.js"; import { isUserIgnoredElement } from "./is-user-ignored-element.js"; +import { getElementAdapter } from "../core/element-adapter.js"; // Dev tools like react-scan create full-viewport canvas overlays with // pointer-events:none that elementsFromPoint still returns. Without this @@ -57,6 +58,8 @@ export const clearVisibilityCache = (): void => { }; export const isValidGrabbableElement = (element: Element): boolean => { + const adapter = getElementAdapter(element); + if (adapter) return adapter.isConnected(); if (isRootElement(element)) { return false; } diff --git a/packages/react-grab/src/utils/mount-root.ts b/packages/react-grab/src/utils/mount-root.ts index c3db5e326..f2f0de4a1 100644 --- a/packages/react-grab/src/utils/mount-root.ts +++ b/packages/react-grab/src/utils/mount-root.ts @@ -47,7 +47,16 @@ const scheduleHostRecheck = (host: HTMLElement): (() => void) => { const recheckTimeoutId = window.setTimeout(() => { attachHostToBody(host); }, MOUNT_ROOT_RECHECK_DELAY_MS); - return () => window.clearTimeout(recheckTimeoutId); + const bodyObserver = new MutationObserver(() => { + if (host.parentNode !== document.body) { + attachHostToBody(host); + } + }); + bodyObserver.observe(document.documentElement, { childList: true }); + return () => { + window.clearTimeout(recheckTimeoutId); + bodyObserver.disconnect(); + }; }; interface MountRootResult { diff --git a/packages/react-grab/src/utils/parse-activation-key.ts b/packages/react-grab/src/utils/parse-activation-key.ts index 98e162f00..f186d7567 100644 --- a/packages/react-grab/src/utils/parse-activation-key.ts +++ b/packages/react-grab/src/utils/parse-activation-key.ts @@ -50,7 +50,13 @@ export const parseActivationKey = ( activationKey: ActivationKey, ): ((event: KeyboardEvent) => boolean) => { if (typeof activationKey === "function") { - return activationKey; + return (event: KeyboardEvent): boolean => { + try { + return activationKey(event); + } catch { + return false; + } + }; } const parsed = parseString(activationKey); diff --git a/packages/react-grab/tests/parse-activation-key.test.ts b/packages/react-grab/tests/parse-activation-key.test.ts new file mode 100644 index 000000000..98e6fc91e --- /dev/null +++ b/packages/react-grab/tests/parse-activation-key.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseActivationKey } from "../src/utils/parse-activation-key.js"; + +const createKeyboardEvent = (key = ""): KeyboardEvent => + Object.create(null, { + key: { + value: key, + }, + }); + +describe("parseActivationKey", () => { + it("treats exceptions from custom matchers as non-matches", () => { + const matcher = parseActivationKey(() => { + throw new Error("Malformed keyboard event"); + }); + + expect(matcher(createKeyboardEvent())).toBe(false); + }); + + it("preserves custom matcher results", () => { + const matcher = parseActivationKey((event) => event.key === "c"); + + expect(matcher(createKeyboardEvent("c"))).toBe(true); + expect(matcher(createKeyboardEvent("g"))).toBe(false); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e96d7cc7..6835b71c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@pierre/diffs': specifier: ^1.2.12 version: 1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-three/fiber': + specifier: ^9.6.1 + version: 9.6.1(@types/react@19.2.14)(immer@11.1.11)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.185.1) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -133,6 +136,9 @@ importers: recharts: specifier: ^3.9.2 version: 3.9.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1) + three: + specifier: ^0.185.1 + version: 0.185.1 devDependencies: '@react-grab/e2e-development': specifier: workspace:* @@ -146,6 +152,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: ^0.185.1 + version: 0.185.1 '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.37.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.4))(babel-plugin-react-compiler@1.0.0) @@ -384,8 +393,8 @@ importers: specifier: workspace:* version: link:../cli bippy: - specifier: ^0.6.0 - version: 0.6.0(react@19.2.6) + specifier: ^0.6.1 + version: 0.6.1(react@19.2.6) react: specifier: 19.2.6 version: 19.2.6 @@ -703,6 +712,9 @@ packages: engines: {node: '>= 0.10.4'} hasBin: true + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dotenvx/dotenvx@1.65.0': resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==} hasBin: true @@ -2939,6 +2951,31 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-three/fiber@9.6.1': + resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: 19.2.6 + react-dom: 19.2.6 + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + '@reduxjs/toolkit@2.12.0': resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} peerDependencies: @@ -3531,6 +3568,9 @@ packages: cpu: [arm64] os: [win32] + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -3641,6 +3681,11 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -3653,9 +3698,15 @@ packages: '@types/set-cookie-parser@2.4.10': resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/three@0.185.1': + resolution: {integrity: sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==} + '@types/tinycolor2@1.4.6': resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} @@ -3674,6 +3725,9 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4047,6 +4101,7 @@ packages: '@zed-industries/codex-acp@0.10.0': resolution: {integrity: sha512-vzwAUSHR7TaJh62JoE+6UD/HVm8fJbmMGsMBBMcHrKBIL7MF8yevlPDWVdoaDaGOsgqVZYRv9KhdT8ari0I4mg==} + deprecated: This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates. hasBin: true abort-controller@3.0.0: @@ -4293,8 +4348,8 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - bippy@0.6.0: - resolution: {integrity: sha512-6rdykmrQPOxyylsctFS3I1veDBWP03c2TbdD3sOowQ1w/3Y1FUQE52MhuNOyFvFQTNOxZOofRvdA1a0L/3Aagw==} + bippy@0.6.1: + resolution: {integrity: sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ==} peerDependencies: react: 19.2.6 @@ -4343,6 +4398,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -5107,6 +5165,9 @@ packages: fetchdts@0.1.7: resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -5745,6 +5806,11 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: 19.2.6 + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -6052,6 +6118,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -6868,6 +6937,15 @@ packages: '@types/react': optional: true + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: 19.2.6 + react-dom: 19.2.6 + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -7396,6 +7474,11 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: 19.2.6 + system-architecture@0.1.0: resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} engines: {node: '>=18'} @@ -7459,6 +7542,9 @@ packages: thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -8014,6 +8100,24 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: 19.2.6 + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -8426,6 +8530,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@dimforge/rapier3d-compat@0.12.0': {} + '@dotenvx/dotenvx@1.65.0': dependencies: commander: 11.1.0 @@ -10671,6 +10777,26 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-three/fiber@9.6.1(@types/react@19.2.14)(immer@11.1.11)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.185.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-use-measure: 2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.6) + three: 0.185.1 + use-sync-external-store: 1.6.0(react@19.2.6) + zustand: 5.0.14(@types/react@19.2.14)(immer@11.1.11)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - immer + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)': dependencies: '@standard-schema/spec': 1.1.0 @@ -11240,6 +11366,8 @@ snapshots: '@turbo/windows-arm64@2.9.12': optional: true + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -11364,6 +11492,10 @@ snapshots: dependencies: '@types/react': 19.2.17 + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -11383,8 +11515,19 @@ snapshots: dependencies: '@types/node': 25.6.2 + '@types/stats.js@0.17.4': {} + '@types/statuses@2.0.6': {} + '@types/three@0.185.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/tinycolor2@1.4.6': {} '@types/tough-cookie@4.0.5': {} @@ -11397,6 +11540,8 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} + '@types/webxr@0.5.24': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.2 @@ -11857,7 +12002,7 @@ snapshots: bignumber.js@9.3.1: {} - bippy@0.6.0(react@19.2.6): + bippy@0.6.1(react@19.2.6): dependencies: react: 19.2.6 @@ -11930,6 +12075,11 @@ snapshots: ieee754: 1.2.1 optional: true + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -12702,6 +12852,8 @@ snapshots: fetchdts@0.1.7: {} + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -13170,8 +13322,7 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: - optional: true + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -13370,6 +13521,13 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.6): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.6 + transitivePeerDependencies: + - '@types/react' + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -13649,6 +13807,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -14595,6 +14755,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-use-measure@2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + react@19.2.6: {} read-package-up@11.0.0: @@ -15282,6 +15448,10 @@ snapshots: dependencies: has-flag: 4.0.0 + suspend-react@0.1.3(react@19.2.6): + dependencies: + react: 19.2.6 + system-architecture@0.1.0: {} systeminformation@5.31.6: {} @@ -15380,6 +15550,8 @@ snapshots: dependencies: real-require: 0.2.0 + three@0.185.1: {} + through@2.3.8: {} tiny-invariant@1.3.3: {} @@ -16037,4 +16209,11 @@ snapshots: zod@4.4.3: {} + zustand@5.0.14(@types/react@19.2.14)(immer@11.1.11)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): + optionalDependencies: + '@types/react': 19.2.14 + immer: 11.1.11 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + zwitch@2.0.4: {}