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