Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-r3f-interaction-ranking.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion apps/e2e-app-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/e2e-app-vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -880,6 +882,10 @@ export default function App() {

<ShadowDomEdgeFixture />

<ThreeFiberFixture />

<ThreeJsFixture />

<IframeFixture />

<HiddenToggleSection />
Expand Down
64 changes: 64 additions & 0 deletions apps/e2e-app-vite/src/three-fiber-fixture.tsx
Original file line number Diff line number Diff line change
@@ -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 => (
<mesh name={props.name} position={props.position} onClick={() => undefined}>
<boxGeometry args={[THREE_BOX_SIZE_UNITS, THREE_BOX_SIZE_UNITS, THREE_BOX_SIZE_UNITS]} />
<meshStandardMaterial color={props.color} />
</mesh>
);

const DecorativePoints = (): React.JSX.Element => {
const positions = useMemo(
() => new Float32Array([...THREE_LEFT_BOX_POSITION, ...THREE_RIGHT_BOX_POSITION]),
[],
);

return (
<points name="decorative-points" position={[0, 0, 1]}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial color="#ffffff" size={0.08} />
</points>
);
};

export const ThreeFiberFixture = (): React.JSX.Element => (
<section className="border rounded-lg p-4" data-testid="three-fiber-section">
<h2 className="text-lg font-bold mb-4">React Three Fiber Scene</h2>
<div className="h-80 overflow-hidden rounded-lg bg-slate-950">
<Canvas
camera={{ position: [0, 0, THREE_CAMERA_POSITION_Z_UNITS], fov: THREE_CAMERA_FOV_DEGREES }}
data-testid="three-fiber-canvas"
dpr={THREE_DEVICE_PIXEL_RATIO}
>
<ambientLight intensity={THREE_AMBIENT_LIGHT_INTENSITY} />
<directionalLight
position={THREE_DIRECTIONAL_LIGHT_POSITION}
intensity={THREE_DIRECTIONAL_LIGHT_INTENSITY}
/>
<DecorativePoints />
<ThreeGrabBox color="#38bdf8" name="left-cube" position={THREE_LEFT_BOX_POSITION} />
<ThreeGrabBox color="#f472b6" name="right-cube" position={THREE_RIGHT_BOX_POSITION} />
</Canvas>
</div>
</section>
);
9 changes: 9 additions & 0 deletions apps/e2e-app-vite/src/three-fixture-constants.ts
Original file line number Diff line number Diff line change
@@ -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];
119 changes: 119 additions & 0 deletions apps/e2e-app-vite/src/three-js-fixture.tsx
Original file line number Diff line number Diff line change
@@ -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<BoxGeometry, MeshStandardMaterial> => {
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<HTMLCanvasElement>(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 (
<section className="border rounded-lg p-4" data-testid="three-js-section">
<h2 className="text-lg font-bold mb-4">Three.js Scene</h2>
<div className="h-80 overflow-hidden rounded-lg bg-slate-950">
<canvas className="h-full w-full" data-testid="three-js-canvas" ref={canvasRef} />
</div>
</section>
);
};
2 changes: 2 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# @react-grab/cli

## 0.1.49

## 0.1.48

## 0.1.47
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
14 changes: 14 additions & 0 deletions packages/grab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/grab/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions packages/react-grab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
# 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

- 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.
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/react-grab/e2e/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions packages/react-grab/e2e/framework/recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
30 changes: 30 additions & 0 deletions packages/react-grab/e2e/malformed-events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading