diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 6567a456d..4abf3b7f1 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -2,6 +2,7 @@ import { Command } from "commander";
import { add } from "./commands/add.js";
import { configure } from "./commands/configure.js";
import { init } from "./commands/init.js";
+import { peek } from "./commands/peek.js";
import { pull } from "./commands/pull.js";
import { remove } from "./commands/remove.js";
import { stop } from "./commands/stop.js";
@@ -32,6 +33,7 @@ program.addCommand(remove);
program.addCommand(configure);
program.addCommand(upgrade);
program.addCommand(pull);
+program.addCommand(peek);
program.addCommand(stop);
program.addCommand(watch, { hidden: true });
diff --git a/packages/cli/src/commands/peek.ts b/packages/cli/src/commands/peek.ts
new file mode 100644
index 000000000..48cec1942
--- /dev/null
+++ b/packages/cli/src/commands/peek.ts
@@ -0,0 +1,96 @@
+import path from "node:path";
+import { Command } from "commander";
+import {
+ createReader,
+ extractPrompt,
+ isGrabText,
+ NO_READER_MESSAGE,
+ prepareWorkDir,
+} from "../utils/clipboard.js";
+import { DEFAULT_WATCH_DIR } from "../utils/constants.js";
+
+interface PeekOptions {
+ dir: string;
+ textOnly?: boolean;
+}
+
+interface GrabPayload {
+ timestamp?: number;
+ version?: string;
+ content?: unknown;
+ entries?: unknown;
+ implicit?: boolean;
+}
+
+const fail = (message: string): never => {
+ process.stderr.write(`react-grab peek: ${message}\n`);
+ process.exit(1);
+};
+
+export const peek = new Command()
+ .name("peek")
+ .description(
+ "read the current clipboard once and print it if it contains a React Grab grab (no daemon needed)",
+ )
+ .option("-d, --dir
", "work dir for the compiled native binary", DEFAULT_WATCH_DIR)
+ .option("--text-only", "skip the native reader and use the plain-text fallback")
+ .action((options: PeekOptions) => {
+ const dir = path.resolve(options.dir);
+
+ try {
+ prepareWorkDir(dir);
+ } catch (error) {
+ fail(String((error as Error)?.message ?? error));
+ }
+
+ const reader = createReader({ textOnly: Boolean(options.textOnly), workDir: dir });
+ if (!reader) {
+ fail(NO_READER_MESSAGE);
+ }
+
+ const snapshot = reader.read();
+ if (!snapshot) {
+ process.exit(0);
+ }
+
+ let parsedGrab: GrabPayload | null = null;
+ if (snapshot.grab) {
+ try {
+ parsedGrab = JSON.parse(snapshot.grab) as GrabPayload;
+ } catch {}
+ }
+
+ if (parsedGrab && typeof parsedGrab.timestamp === "number") {
+ const record: Record = {
+ source: parsedGrab.implicit ? "viewport" : "custom",
+ timestamp: parsedGrab.timestamp,
+ version: typeof parsedGrab.version === "string" ? parsedGrab.version : undefined,
+ content: typeof parsedGrab.content === "string" ? parsedGrab.content : "",
+ entries: Array.isArray(parsedGrab.entries) ? parsedGrab.entries : [],
+ implicit: Boolean(parsedGrab.implicit),
+ id: `peek-${parsedGrab.timestamp}`,
+ receivedAt: Date.now(),
+ };
+ const prompt = extractPrompt(record);
+ if (prompt) record.prompt = prompt;
+ process.stdout.write(`${JSON.stringify(record)}\n`, () => process.exit(0));
+ return;
+ }
+
+ if (snapshot.text && isGrabText(snapshot.text)) {
+ const now = Date.now();
+ const record = {
+ source: "text",
+ timestamp: now,
+ content: snapshot.text,
+ entries: [],
+ implicit: false,
+ id: `peek-${now}`,
+ receivedAt: now,
+ };
+ process.stdout.write(`${JSON.stringify(record)}\n`, () => process.exit(0));
+ return;
+ }
+
+ process.exit(0);
+ });
diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts
index efaf6384d..b8e1e2942 100644
--- a/packages/react-grab/src/constants.ts
+++ b/packages/react-grab/src/constants.ts
@@ -338,3 +338,9 @@ export const RELEVANT_CSS_PROPERTIES = new Set([
"object-fit",
"object-position",
]);
+
+export const VIEWPORT_CLIPBOARD_DEBOUNCE_MS = 3000;
+export const VIEWPORT_SAMPLE_COLUMNS = 6;
+export const VIEWPORT_SAMPLE_ROWS = 6;
+export const VIEWPORT_SAMPLE_EDGE_INSET_PX = 8;
+export const VIEWPORT_MAX_ELEMENTS = 30;
diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx
index 83bab4f16..fe251b814 100644
--- a/packages/react-grab/src/core/index.tsx
+++ b/packages/react-grab/src/core/index.tsx
@@ -138,6 +138,7 @@ import { generateId } from "../utils/generate-id.js";
import { logRecoverableError } from "../utils/log-recoverable-error.js";
import { getNearestEdge } from "../utils/get-nearest-edge.js";
import { findShortcutAction } from "../utils/action-shortcuts.js";
+import { createViewportClipboard } from "./viewport-clipboard.js";
const builtInPlugins = [copyPlugin, editPlugin, commentPlugin, openPlugin];
@@ -1996,6 +1997,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
};
const eventListenerManager = createEventListenerManager();
+ const viewportClipboard = createViewportClipboard();
const keyboardClaimer = setupKeyboardEventClaimer();
@@ -2570,6 +2572,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
if (!event.isPrimary) return;
const isTouchPointer = event.pointerType === "touch";
actions.setTouchMode(isTouchPointer);
+ if (!isActivated() && !isHoldingKeys() && !isCopying() && isEnabled()) {
+ viewportClipboard.scheduleWrite();
+ }
if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return;
if (isModalPopoverOpen()) return;
if (isSelectionInteractionLocked()) return;
@@ -2594,6 +2599,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
{ passive: true },
);
+ eventListenerManager.addWindowListener(
+ "scroll",
+ () => {
+ if (!isActivated() && !isHoldingKeys() && !isCopying() && isEnabled()) {
+ viewportClipboard.scheduleWrite();
+ }
+ },
+ { passive: true },
+ );
+
eventListenerManager.addWindowListener(
"pointerdown",
(event: PointerEvent) => {
@@ -3665,6 +3680,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
dispose: () => {
disposed = true;
hasInited = false;
+ viewportClipboard.cancel();
disposeRenderer?.();
stopToolbarMenuTracking?.();
stopToolbarMenuTracking = null;
diff --git a/packages/react-grab/src/core/viewport-clipboard.ts b/packages/react-grab/src/core/viewport-clipboard.ts
new file mode 100644
index 000000000..acc93c295
--- /dev/null
+++ b/packages/react-grab/src/core/viewport-clipboard.ts
@@ -0,0 +1,197 @@
+import {
+ getFiberFromHostInstance,
+ isInstrumentationActive,
+ getDisplayName,
+ isCompositeFiber,
+ type Fiber,
+} from "bippy";
+import { getSource, isSourceFile } from "bippy/source";
+import { VERSION, VIEWPORT_CLIPBOARD_DEBOUNCE_MS } from "../constants.js";
+import { collectViewportElements } from "../utils/collect-viewport-elements.js";
+import { getTagName } from "../utils/get-tag-name.js";
+import { normalizeFilePath } from "../utils/normalize-file-path.js";
+import { isUsefulComponentName } from "../utils/is-useful-component-name.js";
+
+const REACT_GRAB_MIME_TYPE = "application/x-react-grab";
+const WEB_CUSTOM_MIME_TYPE = `web ${REACT_GRAB_MIME_TYPE}`;
+
+interface ViewportEntry {
+ tagName: string;
+ componentName?: string;
+ filePath?: string;
+ lineNumber?: number;
+ columnNumber?: number;
+}
+
+interface ViewportClipboardController {
+ scheduleWrite: () => void;
+ cancel: () => void;
+}
+
+const getComponentNameFromFiber = (fiber: Fiber): string | undefined => {
+ let currentFiber: Fiber | null = fiber.return ?? null;
+ while (currentFiber) {
+ if (isCompositeFiber(currentFiber)) {
+ const name = getDisplayName(currentFiber.type);
+ if (name && isUsefulComponentName(name)) {
+ return name;
+ }
+ }
+ currentFiber = currentFiber.return ?? null;
+ }
+ return undefined;
+};
+
+const resolveElementEntry = async (element: Element): Promise => {
+ const fiber = getFiberFromHostInstance(element);
+ if (!fiber) return null;
+
+ const tagName = getTagName(element);
+ const componentName = getComponentNameFromFiber(fiber);
+
+ let filePath: string | undefined;
+ let lineNumber: number | undefined;
+ let columnNumber: number | undefined;
+
+ try {
+ const source = await getSource(fiber);
+ if (source?.fileName && isSourceFile(source.fileName)) {
+ filePath = normalizeFilePath(source.fileName);
+ lineNumber = source.lineNumber ?? undefined;
+ columnNumber = source.columnNumber ?? undefined;
+ }
+ } catch {}
+
+ return { tagName, componentName, filePath, lineNumber, columnNumber };
+};
+
+const formatEntryReference = (entry: ViewportEntry): string => {
+ const tag = entry.componentName
+ ? `<${entry.tagName}>`
+ : `<${entry.tagName} />`;
+
+ const locationParts: string[] = [];
+ if (entry.componentName) {
+ locationParts.push(`in ${entry.componentName}`);
+ }
+ if (entry.filePath) {
+ const locationSuffix =
+ entry.lineNumber != null && entry.columnNumber != null
+ ? `:${entry.lineNumber}:${entry.columnNumber}`
+ : entry.lineNumber != null
+ ? `:${entry.lineNumber}`
+ : "";
+ locationParts.push(`(at ${entry.filePath}${locationSuffix})`);
+ }
+
+ const location = locationParts.length > 0 ? ` ${locationParts.join(" ")}` : "";
+ return `[${tag}${location}]`;
+};
+
+const writeToClipboard = async (content: string, metadata: string): Promise => {
+ if (!navigator.clipboard) return false;
+
+ try {
+ if (typeof ClipboardItem !== "undefined") {
+ const textBlob = new Blob([content], { type: "text/plain" });
+ const customBlob = new Blob([metadata], { type: REACT_GRAB_MIME_TYPE });
+ const item = new ClipboardItem({
+ "text/plain": textBlob,
+ [WEB_CUSTOM_MIME_TYPE]: customBlob,
+ });
+ await navigator.clipboard.write([item]);
+ return true;
+ }
+ } catch {}
+
+ try {
+ await navigator.clipboard.writeText(content);
+ return true;
+ } catch {}
+
+ return false;
+};
+
+export const createViewportClipboard = (): ViewportClipboardController => {
+ let timerId: ReturnType | null = null;
+ let lastContentHash = "";
+ let isWriting = false;
+
+ const performWrite = async (): Promise => {
+ if (isWriting) return;
+ if (!isInstrumentationActive()) return;
+
+ isWriting = true;
+ try {
+ const elements = collectViewportElements();
+ if (elements.length === 0) return;
+
+ const entryResults = await Promise.allSettled(
+ elements.map(resolveElementEntry),
+ );
+
+ const entries: ViewportEntry[] = [];
+ for (const result of entryResults) {
+ if (result.status === "fulfilled" && result.value) {
+ entries.push(result.value);
+ }
+ }
+
+ if (entries.length === 0) return;
+
+ const deduplicatedEntries: ViewportEntry[] = [];
+ const seenKeys = new Set();
+ for (const entry of entries) {
+ const deduplicationKey = entry.componentName && entry.filePath
+ ? `${entry.componentName}:${entry.filePath}:${entry.lineNumber ?? ""}`
+ : `${entry.tagName}:${entry.filePath ?? ""}:${entry.lineNumber ?? ""}`;
+ if (seenKeys.has(deduplicationKey)) continue;
+ seenKeys.add(deduplicationKey);
+ deduplicatedEntries.push(entry);
+ }
+
+ const references = deduplicatedEntries.map(formatEntryReference);
+ const content = references.join("\n");
+
+ if (content === lastContentHash) return;
+
+ const metadata = JSON.stringify({
+ version: VERSION,
+ content,
+ entries: deduplicatedEntries.map((entry) => ({
+ tagName: entry.tagName,
+ componentName: entry.componentName,
+ content: formatEntryReference(entry),
+ })),
+ timestamp: Date.now(),
+ implicit: true,
+ });
+
+ const didWrite = await writeToClipboard(content, metadata);
+ if (didWrite) {
+ lastContentHash = content;
+ }
+ } catch {} finally {
+ isWriting = false;
+ }
+ };
+
+ const scheduleWrite = (): void => {
+ if (timerId !== null) {
+ clearTimeout(timerId);
+ }
+ timerId = setTimeout(() => {
+ timerId = null;
+ void performWrite();
+ }, VIEWPORT_CLIPBOARD_DEBOUNCE_MS);
+ };
+
+ const cancel = (): void => {
+ if (timerId !== null) {
+ clearTimeout(timerId);
+ timerId = null;
+ }
+ };
+
+ return { scheduleWrite, cancel };
+};
diff --git a/packages/react-grab/src/utils/collect-viewport-elements.ts b/packages/react-grab/src/utils/collect-viewport-elements.ts
new file mode 100644
index 000000000..59cae4b94
--- /dev/null
+++ b/packages/react-grab/src/utils/collect-viewport-elements.ts
@@ -0,0 +1,41 @@
+import {
+ VIEWPORT_SAMPLE_COLUMNS,
+ VIEWPORT_SAMPLE_ROWS,
+ VIEWPORT_SAMPLE_EDGE_INSET_PX,
+ VIEWPORT_MAX_ELEMENTS,
+} from "../constants.js";
+import { isValidGrabbableElement } from "./is-valid-grabbable-element.js";
+import { isRootElement } from "./is-root-element.js";
+
+export const collectViewportElements = (): Element[] => {
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ const uniqueElements = new Set();
+
+ for (let columnIndex = 0; columnIndex < VIEWPORT_SAMPLE_COLUMNS; columnIndex += 1) {
+ const sampleX =
+ VIEWPORT_SAMPLE_EDGE_INSET_PX +
+ ((columnIndex + 0.5) / VIEWPORT_SAMPLE_COLUMNS) *
+ (viewportWidth - VIEWPORT_SAMPLE_EDGE_INSET_PX * 2);
+
+ for (let rowIndex = 0; rowIndex < VIEWPORT_SAMPLE_ROWS; rowIndex += 1) {
+ if (uniqueElements.size >= VIEWPORT_MAX_ELEMENTS) break;
+
+ const sampleY =
+ VIEWPORT_SAMPLE_EDGE_INSET_PX +
+ ((rowIndex + 0.5) / VIEWPORT_SAMPLE_ROWS) *
+ (viewportHeight - VIEWPORT_SAMPLE_EDGE_INSET_PX * 2);
+
+ const elementsAtPoint = document.elementsFromPoint(sampleX, sampleY);
+ for (const element of elementsAtPoint) {
+ if (uniqueElements.size >= VIEWPORT_MAX_ELEMENTS) break;
+ if (isRootElement(element)) continue;
+ if (!isValidGrabbableElement(element)) continue;
+ uniqueElements.add(element);
+ }
+ }
+ }
+
+ return Array.from(uniqueElements);
+};