From 4c1d0d80f220df691492dbb0c7a7837464de69bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Mar 2026 02:54:59 +0000 Subject: [PATCH 1/4] feat: add 'Copy as Image' plugin that renders text content into a compact PNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders grabbed text content onto a canvas using a compact 12px monospace font and copies the resulting PNG image to the clipboard. The image is designed to be LLM-parseable while staying token-efficient (small dimensions, minimal padding). New files: - utils/render-text-to-image.ts: canvas text rendering → PNG blob - utils/copy-image-to-clipboard.ts: clipboard write via ClipboardItem API - core/plugins/copy-image.ts: plugin wiring (context menu action) Co-authored-by: Aiden Bai --- packages/react-grab/src/constants.ts | 8 +++ packages/react-grab/src/core/index.tsx | 2 + .../react-grab/src/core/plugins/copy-image.ts | 32 ++++++++++ .../src/utils/copy-image-to-clipboard.ts | 13 ++++ .../src/utils/render-text-to-image.ts | 59 +++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 packages/react-grab/src/core/plugins/copy-image.ts create mode 100644 packages/react-grab/src/utils/copy-image-to-clipboard.ts create mode 100644 packages/react-grab/src/utils/render-text-to-image.ts diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 605ffc353..dc47507ad 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -160,6 +160,14 @@ export const DROPDOWN_EDGE_TRANSFORM_ORIGIN = { bottom: "center bottom", }; +export const TEXT_IMAGE_FONT_SIZE_PX = 12; +export const TEXT_IMAGE_LINE_HEIGHT_PX = 15; +export const TEXT_IMAGE_PADDING_PX = 8; +export const TEXT_IMAGE_FONT_FAMILY = "monospace"; +export const TEXT_IMAGE_BACKGROUND_COLOR = "#ffffff"; +export const TEXT_IMAGE_TEXT_COLOR = "#000000"; +export const TEXT_IMAGE_TAB_SIZE_SPACES = 2; + export const NEXTJS_REVALIDATION_DELAY_MS = 1000; export const TEXTAREA_MAX_HEIGHT_PX = 95; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 1c08a5221..c13f5f4cc 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -138,6 +138,7 @@ import { commentPlugin } from "./plugins/comment.js"; import { openPlugin } from "./plugins/open.js"; import { copyHtmlPlugin } from "./plugins/copy-html.js"; import { copyStylesPlugin } from "./plugins/copy-styles.js"; +import { copyImagePlugin } from "./plugins/copy-image.js"; import { freezeAnimations, freezeAllAnimations, @@ -168,6 +169,7 @@ const builtInPlugins = [ commentPlugin, copyHtmlPlugin, copyStylesPlugin, + copyImagePlugin, openPlugin, ]; diff --git a/packages/react-grab/src/core/plugins/copy-image.ts b/packages/react-grab/src/core/plugins/copy-image.ts new file mode 100644 index 000000000..7824c5b9b --- /dev/null +++ b/packages/react-grab/src/core/plugins/copy-image.ts @@ -0,0 +1,32 @@ +import { appendStackContext } from "../../utils/append-stack-context.js"; +import { copyImageToClipboard } from "../../utils/copy-image-to-clipboard.js"; +import { generateSnippet } from "../../utils/generate-snippet.js"; +import { joinSnippets } from "../../utils/join-snippets.js"; +import { renderTextToImage } from "../../utils/render-text-to-image.js"; +import { createPendingSelectionPlugin } from "./create-pending-selection-plugin.js"; + +export const copyImagePlugin = createPendingSelectionPlugin({ + name: "copy-image", + contextMenuAction: (api) => ({ + id: "copy-image", + label: "Copy as Image", + showInToolbarMenu: true, + onAction: async (context) => { + await context.performWithFeedback(async () => { + const rawSnippets = await generateSnippet(context.elements); + const nonEmptySnippets = rawSnippets.filter((snippet) => + snippet.trim(), + ); + + if (nonEmptySnippets.length === 0) return false; + + const combinedContent = joinSnippets(nonEmptySnippets); + const stackContext = await api.getStackContext(context.element); + const fullContent = appendStackContext(combinedContent, stackContext); + const imageBlob = await renderTextToImage(fullContent); + + return copyImageToClipboard(imageBlob); + }); + }, + }), +}); diff --git a/packages/react-grab/src/utils/copy-image-to-clipboard.ts b/packages/react-grab/src/utils/copy-image-to-clipboard.ts new file mode 100644 index 000000000..462860fb8 --- /dev/null +++ b/packages/react-grab/src/utils/copy-image-to-clipboard.ts @@ -0,0 +1,13 @@ +export const copyImageToClipboard = async ( + imageBlob: Blob, +): Promise => { + try { + const clipboardItem = new ClipboardItem({ + [imageBlob.type]: imageBlob, + }); + await navigator.clipboard.write([clipboardItem]); + return true; + } catch { + return false; + } +}; diff --git a/packages/react-grab/src/utils/render-text-to-image.ts b/packages/react-grab/src/utils/render-text-to-image.ts new file mode 100644 index 000000000..b4400edc3 --- /dev/null +++ b/packages/react-grab/src/utils/render-text-to-image.ts @@ -0,0 +1,59 @@ +import { + TEXT_IMAGE_FONT_SIZE_PX, + TEXT_IMAGE_LINE_HEIGHT_PX, + TEXT_IMAGE_PADDING_PX, + TEXT_IMAGE_FONT_FAMILY, + TEXT_IMAGE_BACKGROUND_COLOR, + TEXT_IMAGE_TEXT_COLOR, + TEXT_IMAGE_TAB_SIZE_SPACES, +} from "../constants.js"; + +export const renderTextToImage = async (text: string): Promise => { + const lines = text + .replace(/\t/g, " ".repeat(TEXT_IMAGE_TAB_SIZE_SPACES)) + .split("\n"); + + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Canvas 2D context not available"); + } + + context.font = `${TEXT_IMAGE_FONT_SIZE_PX}px ${TEXT_IMAGE_FONT_FAMILY}`; + const characterWidth = context.measureText("M").width; + + const longestLineLength = lines.reduce( + (maxLength, line) => Math.max(maxLength, line.length), + 0, + ); + + const contentWidth = Math.ceil(longestLineLength * characterWidth); + const contentHeight = lines.length * TEXT_IMAGE_LINE_HEIGHT_PX; + + canvas.width = contentWidth + TEXT_IMAGE_PADDING_PX * 2; + canvas.height = contentHeight + TEXT_IMAGE_PADDING_PX * 2; + + context.fillStyle = TEXT_IMAGE_BACKGROUND_COLOR; + context.fillRect(0, 0, canvas.width, canvas.height); + + context.font = `${TEXT_IMAGE_FONT_SIZE_PX}px ${TEXT_IMAGE_FONT_FAMILY}`; + context.fillStyle = TEXT_IMAGE_TEXT_COLOR; + context.textBaseline = "top"; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const xPosition = TEXT_IMAGE_PADDING_PX; + const yPosition = + TEXT_IMAGE_PADDING_PX + lineIndex * TEXT_IMAGE_LINE_HEIGHT_PX; + context.fillText(lines[lineIndex], xPosition, yPosition); + } + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to create image blob from canvas")); + } + }, "image/png"); + }); +}; From 24a432eb0fba05b09fe4b6d328ce60d67a26e028 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Mar 2026 02:55:05 +0000 Subject: [PATCH 2/4] chore: apply oxfmt formatting Co-authored-by: Aiden Bai --- packages/react-grab/e2e/fixtures.ts | 4 +--- packages/react-grab/src/utils/comment-storage.ts | 9 +++++---- packages/shadcn-registry/r/react-grab.json | 4 +--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 8b0026a6c..a256d7232 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -1007,9 +1007,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const items = dropdown.querySelectorAll( "[data-react-grab-menu-item]", ); - return Array.from(items).map( - (item) => item.textContent?.trim() ?? "", - ); + return Array.from(items).map((item) => item.textContent?.trim() ?? ""); } return []; }, ATTRIBUTE_NAME); diff --git a/packages/react-grab/src/utils/comment-storage.ts b/packages/react-grab/src/utils/comment-storage.ts index 9209c02a5..345b42f4c 100644 --- a/packages/react-grab/src/utils/comment-storage.ts +++ b/packages/react-grab/src/utils/comment-storage.ts @@ -76,9 +76,7 @@ let didConfirmClear = readSessionFlag(CLEAR_CONFIRMED_KEY); export const loadComments = (): CommentItem[] => commentItems; -export const addCommentItem = ( - item: Omit, -): CommentItem[] => +export const addCommentItem = (item: Omit): CommentItem[] => persistCommentItems( [{ ...item, id: generateId("comment") }, ...commentItems].slice( 0, @@ -101,6 +99,9 @@ export const confirmClear = (): void => { sessionStorage.setItem(CLEAR_CONFIRMED_KEY, "1"); } catch (error) { // HACK: sessionStorage can throw in private browsing or when quota is exceeded - logRecoverableError("Failed to save clear preference to sessionStorage", error); + logRecoverableError( + "Failed to save clear preference to sessionStorage", + error, + ); } }; diff --git a/packages/shadcn-registry/r/react-grab.json b/packages/shadcn-registry/r/react-grab.json index d1da7b2fa..e959f2950 100644 --- a/packages/shadcn-registry/r/react-grab.json +++ b/packages/shadcn-registry/r/react-grab.json @@ -4,9 +4,7 @@ "type": "registry:component", "title": "React Grab", "description": "Loads React Grab as early as possible — select context for coding agents directly from your website.", - "dependencies": [ - "react-grab" - ], + "dependencies": ["react-grab"], "files": [ { "path": "components/react-grab.tsx", From db44dec79b1447ef06d91c5ff65f9b3452b5a05f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Mar 2026 03:21:13 +0000 Subject: [PATCH 3/4] feat: name clipboard image file using the component/element display name Co-authored-by: Aiden Bai --- packages/react-grab/src/core/plugins/copy-image.ts | 4 +++- packages/react-grab/src/utils/copy-image-to-clipboard.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-grab/src/core/plugins/copy-image.ts b/packages/react-grab/src/core/plugins/copy-image.ts index 7824c5b9b..0a6522c69 100644 --- a/packages/react-grab/src/core/plugins/copy-image.ts +++ b/packages/react-grab/src/core/plugins/copy-image.ts @@ -24,8 +24,10 @@ export const copyImagePlugin = createPendingSelectionPlugin({ const stackContext = await api.getStackContext(context.element); const fullContent = appendStackContext(combinedContent, stackContext); const imageBlob = await renderTextToImage(fullContent); + const displayName = + context.componentName ?? context.tagName ?? "element"; - return copyImageToClipboard(imageBlob); + return copyImageToClipboard(imageBlob, displayName); }); }, }), diff --git a/packages/react-grab/src/utils/copy-image-to-clipboard.ts b/packages/react-grab/src/utils/copy-image-to-clipboard.ts index 462860fb8..aab68e9d7 100644 --- a/packages/react-grab/src/utils/copy-image-to-clipboard.ts +++ b/packages/react-grab/src/utils/copy-image-to-clipboard.ts @@ -1,9 +1,13 @@ export const copyImageToClipboard = async ( imageBlob: Blob, + fileName: string, ): Promise => { try { + const namedFile = new File([imageBlob], `${fileName}.png`, { + type: imageBlob.type, + }); const clipboardItem = new ClipboardItem({ - [imageBlob.type]: imageBlob, + [namedFile.type]: namedFile, }); await navigator.clipboard.write([clipboardItem]); return true; From b238633bf62c7671a360c4507502284be088352c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 24 Mar 2026 05:27:04 -0700 Subject: [PATCH 4/4] fix --- packages/react-grab/e2e/fixtures.ts | 27 ++++++- packages/react-grab/e2e/selection.spec.ts | 16 ++-- packages/react-grab/src/constants.ts | 6 +- packages/react-grab/src/core/copy.ts | 2 +- packages/react-grab/src/core/index.tsx | 10 +-- .../react-grab/src/core/plugins/copy-html.ts | 2 +- .../react-grab/src/core/plugins/copy-image.ts | 34 --------- .../src/core/plugins/copy-styles.ts | 2 +- packages/react-grab/src/utils/copy-content.ts | 75 +++++++++++++------ .../src/utils/copy-image-to-clipboard.ts | 17 ----- pnpm-lock.yaml | 10 +-- 11 files changed, 104 insertions(+), 97 deletions(-) delete mode 100644 packages/react-grab/src/core/plugins/copy-image.ts delete mode 100644 packages/react-grab/src/utils/copy-image-to-clipboard.ts diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index a256d7232..05d5eea20 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -340,8 +340,10 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const captureNextClipboardWrites = async () => { return page.evaluate(() => { return new Promise>((resolve) => { - const originalSetData = DataTransfer.prototype.setData; const clipboardWrites: Record = {}; + let didCleanup = false; + + const originalSetData = DataTransfer.prototype.setData; DataTransfer.prototype.setData = function ( type: string, value: string, @@ -350,8 +352,31 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { return originalSetData.call(this, type, value); }; + const originalWrite = navigator.clipboard.write.bind( + navigator.clipboard, + ); + navigator.clipboard.write = async function (data: ClipboardItem[]) { + for (const item of data) { + for (const type of item.types) { + if (type.startsWith("text/")) { + const blob = await item.getType(type); + clipboardWrites[type] = await blob.text(); + } + } + } + try { + return await originalWrite(data); + } finally { + clearTimeout(safetyTimeout); + queueMicrotask(cleanup); + } + }; + const cleanup = () => { + if (didCleanup) return; + didCleanup = true; DataTransfer.prototype.setData = originalSetData; + navigator.clipboard.write = originalWrite; resolve(clipboardWrites); }; diff --git a/packages/react-grab/e2e/selection.spec.ts b/packages/react-grab/e2e/selection.spec.ts index 254df2581..ece2c7f93 100644 --- a/packages/react-grab/e2e/selection.spec.ts +++ b/packages/react-grab/e2e/selection.spec.ts @@ -54,15 +54,17 @@ test.describe("Element Selection", () => { const copyPayloadPromise = reactGrab.captureNextClipboardWrites(); await reactGrab.clickElement("[data-testid='todo-list'] h1"); const copyPayload = await copyPayloadPromise; + + expect(copyPayload["text/plain"]).toContain("Todo List"); + expect(copyPayload["text/html"]).toContain("Todo List"); + const clipboardMetadataText = copyPayload["application/x-react-grab"]; - if (!clipboardMetadataText) { - throw new Error("Missing React Grab clipboard metadata"); + if (clipboardMetadataText) { + const clipboardMetadata = JSON.parse(clipboardMetadataText); + expect(clipboardMetadata.content).toContain("Todo List"); + expect(clipboardMetadata.entries).toHaveLength(1); + expect(clipboardMetadata.entries[0].content).toContain("Todo List"); } - - const clipboardMetadata = JSON.parse(clipboardMetadataText); - expect(clipboardMetadata.content).toContain("Todo List"); - expect(clipboardMetadata.entries).toHaveLength(1); - expect(clipboardMetadata.entries[0].content).toContain("Todo List"); }); test("should highlight different elements when hovering", async ({ diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index dc47507ad..474180ef4 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -160,9 +160,9 @@ export const DROPDOWN_EDGE_TRANSFORM_ORIGIN = { bottom: "center bottom", }; -export const TEXT_IMAGE_FONT_SIZE_PX = 12; -export const TEXT_IMAGE_LINE_HEIGHT_PX = 15; -export const TEXT_IMAGE_PADDING_PX = 8; +export const TEXT_IMAGE_FONT_SIZE_PX = 8; +export const TEXT_IMAGE_LINE_HEIGHT_PX = 10; +export const TEXT_IMAGE_PADDING_PX = 4; export const TEXT_IMAGE_FONT_FAMILY = "monospace"; export const TEXT_IMAGE_BACKGROUND_COLOR = "#ffffff"; export const TEXT_IMAGE_TEXT_COLOR = "#000000"; diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index fc8415134..1ec94ae03 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -73,7 +73,7 @@ export const tryCopyWithFallback = async ( ? `${extraPrompt}\n\n${transformedContent}` : transformedContent; - didCopy = copyContent(copiedContent, { + didCopy = await copyContent(copiedContent, { componentName: options.componentName, entries, }); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index c13f5f4cc..87ea0cc4b 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -138,7 +138,6 @@ import { commentPlugin } from "./plugins/comment.js"; import { openPlugin } from "./plugins/open.js"; import { copyHtmlPlugin } from "./plugins/copy-html.js"; import { copyStylesPlugin } from "./plugins/copy-styles.js"; -import { copyImagePlugin } from "./plugins/copy-image.js"; import { freezeAnimations, freezeAllAnimations, @@ -169,7 +168,6 @@ const builtInPlugins = [ commentPlugin, copyHtmlPlugin, copyStylesPlugin, - copyImagePlugin, openPlugin, ]; @@ -3790,8 +3788,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } }; - const copyCommentItemContent = (item: CommentItem) => { - copyContent(item.content, { + const copyCommentItemContent = async (item: CommentItem) => { + await copyContent(item.content, { tagName: item.tagName, componentName: item.componentName ?? item.elementName, commentText: item.commentText, @@ -3834,7 +3832,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } }; - const handleCommentsCopyAll = () => { + const handleCommentsCopyAll = async () => { clearCommentsHoverPreviews(); const currentCommentItems = commentItems(); if (currentCommentItems.length === 0) return; @@ -3844,7 +3842,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); const firstItem = currentCommentItems[0]; - copyContent(combinedContent, { + await copyContent(combinedContent, { componentName: firstItem.componentName ?? firstItem.tagName, entries: currentCommentItems.map((commentItem) => ({ tagName: commentItem.tagName, diff --git a/packages/react-grab/src/core/plugins/copy-html.ts b/packages/react-grab/src/core/plugins/copy-html.ts index b16d956fa..760c4c237 100644 --- a/packages/react-grab/src/core/plugins/copy-html.ts +++ b/packages/react-grab/src/core/plugins/copy-html.ts @@ -22,7 +22,7 @@ export const copyHtmlPlugin = createPendingSelectionPlugin({ if (!transformedHtml) return false; const stackContext = await api.getStackContext(context.element); - return copyContent(appendStackContext(transformedHtml, stackContext), { + return await copyContent(appendStackContext(transformedHtml, stackContext), { componentName: context.componentName, tagName: context.tagName, }); diff --git a/packages/react-grab/src/core/plugins/copy-image.ts b/packages/react-grab/src/core/plugins/copy-image.ts deleted file mode 100644 index 0a6522c69..000000000 --- a/packages/react-grab/src/core/plugins/copy-image.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { appendStackContext } from "../../utils/append-stack-context.js"; -import { copyImageToClipboard } from "../../utils/copy-image-to-clipboard.js"; -import { generateSnippet } from "../../utils/generate-snippet.js"; -import { joinSnippets } from "../../utils/join-snippets.js"; -import { renderTextToImage } from "../../utils/render-text-to-image.js"; -import { createPendingSelectionPlugin } from "./create-pending-selection-plugin.js"; - -export const copyImagePlugin = createPendingSelectionPlugin({ - name: "copy-image", - contextMenuAction: (api) => ({ - id: "copy-image", - label: "Copy as Image", - showInToolbarMenu: true, - onAction: async (context) => { - await context.performWithFeedback(async () => { - const rawSnippets = await generateSnippet(context.elements); - const nonEmptySnippets = rawSnippets.filter((snippet) => - snippet.trim(), - ); - - if (nonEmptySnippets.length === 0) return false; - - const combinedContent = joinSnippets(nonEmptySnippets); - const stackContext = await api.getStackContext(context.element); - const fullContent = appendStackContext(combinedContent, stackContext); - const imageBlob = await renderTextToImage(fullContent); - const displayName = - context.componentName ?? context.tagName ?? "element"; - - return copyImageToClipboard(imageBlob, displayName); - }); - }, - }), -}); diff --git a/packages/react-grab/src/core/plugins/copy-styles.ts b/packages/react-grab/src/core/plugins/copy-styles.ts index 5d718565d..86daac85d 100644 --- a/packages/react-grab/src/core/plugins/copy-styles.ts +++ b/packages/react-grab/src/core/plugins/copy-styles.ts @@ -19,7 +19,7 @@ export const copyStylesPlugin = createPendingSelectionPlugin({ .join("\n\n"); const stackContext = await api.getStackContext(context.element); - return copyContent(appendStackContext(combinedCss, stackContext), { + return await copyContent(appendStackContext(combinedCss, stackContext), { componentName: context.componentName, tagName: context.tagName, }); diff --git a/packages/react-grab/src/utils/copy-content.ts b/packages/react-grab/src/utils/copy-content.ts index 90cdd336e..5da926b20 100644 --- a/packages/react-grab/src/utils/copy-content.ts +++ b/packages/react-grab/src/utils/copy-content.ts @@ -1,6 +1,5 @@ import { VERSION } from "../constants.js"; - -const REACT_GRAB_MIME_TYPE = "application/x-react-grab"; +import { renderTextToImage } from "./render-text-to-image.js"; export interface ReactGrabEntry { tagName?: string; @@ -31,10 +30,13 @@ const escapeHtml = (text: string): string => .replace(/>/g, ">") .replace(/"/g, """); -export const copyContent = ( +const buildHtmlPayload = (content: string): string => + `
${escapeHtml(content)}
`; + +const buildMetadata = ( content: string, options?: CopyContentOptions, -): boolean => { +): ReactGrabMetadata => { const elementName = options?.componentName ?? "div"; const entries = options?.entries ?? [ { @@ -44,23 +46,37 @@ export const copyContent = ( commentText: options?.commentText, }, ]; - const reactGrabMetadata: ReactGrabMetadata = { - version: VERSION, - content, - entries, - timestamp: Date.now(), - }; + return { version: VERSION, content, entries, timestamp: Date.now() }; +}; + +const isModernClipboardAvailable = (): boolean => + Boolean(navigator.clipboard?.write) && typeof ClipboardItem !== "undefined"; +/** + * Modern path: writes text/plain + text/html + image/png in a single ClipboardItem. + * Cannot carry custom MIME types like application/x-react-grab. + */ +const modernCopy = async (content: string): Promise => { + const item = new ClipboardItem({ + "text/plain": new Blob([content], { type: "text/plain" }), + "text/html": new Blob([buildHtmlPayload(content)], { type: "text/html" }), + "image/png": renderTextToImage(content), + }); + await navigator.clipboard.write([item]); +}; + +/** + * Legacy path: execCommand("copy") with text/plain + text/html + metadata. + * Must run synchronously within a user gesture call stack. + */ +const legacyCopy = (content: string, metadata: ReactGrabMetadata): boolean => { const copyHandler = (event: ClipboardEvent) => { event.preventDefault(); event.clipboardData?.setData("text/plain", content); + event.clipboardData?.setData("text/html", buildHtmlPayload(content)); event.clipboardData?.setData( - "text/html", - `
${escapeHtml(content)}
`, - ); - event.clipboardData?.setData( - REACT_GRAB_MIME_TYPE, - JSON.stringify(reactGrabMetadata), + "application/x-react-grab", + JSON.stringify(metadata), ); }; @@ -78,13 +94,30 @@ export const copyContent = ( if (typeof document.execCommand !== "function") { return false; } - const didCopySucceed = document.execCommand("copy"); - if (didCopySucceed) { - options?.onSuccess?.(); - } - return didCopySucceed; + return document.execCommand("copy"); } finally { document.removeEventListener("copy", copyHandler); textarea.remove(); } }; + +export const copyContent = async ( + content: string, + options?: CopyContentOptions, +): Promise => { + let didCopy: boolean; + + if (isModernClipboardAvailable()) { + try { + await modernCopy(content); + didCopy = true; + } catch { + didCopy = false; + } + } else { + didCopy = legacyCopy(content, buildMetadata(content, options)); + } + + if (didCopy) options?.onSuccess?.(); + return didCopy; +}; diff --git a/packages/react-grab/src/utils/copy-image-to-clipboard.ts b/packages/react-grab/src/utils/copy-image-to-clipboard.ts deleted file mode 100644 index aab68e9d7..000000000 --- a/packages/react-grab/src/utils/copy-image-to-clipboard.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const copyImageToClipboard = async ( - imageBlob: Blob, - fileName: string, -): Promise => { - try { - const namedFile = new File([imageBlob], `${fileName}.png`, { - type: imageBlob.type, - }); - const clipboardItem = new ClipboardItem({ - [namedFile.type]: namedFile, - }); - await navigator.clipboard.write([clipboardItem]); - return true; - } catch { - return false; - } -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd5f76b03..6472d6771 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2831,8 +2831,8 @@ packages: resolution: {integrity: sha512-y9/ltK2TY+0HD1H2Sz7MvU3zFh4SjER6eQVNQfBx/0gK9N7S0QwHW6cmhHLx3CP25zN190LKHXPieMGqsVvrOQ==} engines: {node: '>=18'} - '@sourcegraph/amp@0.0.1773994045-ge5da52': - resolution: {integrity: sha512-poUO2Imyi1poV2LUS+lWaYIIeVsILaOGzqLtt62szh8C3Jbwp4oSMsRp6wKaYN+kT4c6TbIdolnQBZJsx5Wt6g==} + '@sourcegraph/amp@0.0.1774341467-g86d7fe': + resolution: {integrity: sha512-/9bCV055ryBeDwBkX0tEzyMpDv62RKfSiBh32WVnA0sR7+C/k29TVUYpK0tZBTb7S/iPlFQ4w99lQcoy//GwNA==} engines: {node: '>=20'} hasBin: true @@ -8046,7 +8046,7 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 @@ -8926,10 +8926,10 @@ snapshots: '@sourcegraph/amp-sdk@0.1.0-20251210081226-g90e3892': dependencies: - '@sourcegraph/amp': 0.0.1773994045-ge5da52 + '@sourcegraph/amp': 0.0.1774341467-g86d7fe zod: 3.25.76 - '@sourcegraph/amp@0.0.1773994045-ge5da52': + '@sourcegraph/amp@0.0.1774341467-g86d7fe': dependencies: '@napi-rs/keyring': 1.1.9