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 (
+
+ );
+};
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: {}