Skip to content

Commit b238633

Browse files
committed
fix
1 parent db44dec commit b238633

File tree

11 files changed

+104
-97
lines changed

11 files changed

+104
-97
lines changed

packages/react-grab/e2e/fixtures.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,10 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
340340
const captureNextClipboardWrites = async () => {
341341
return page.evaluate(() => {
342342
return new Promise<Record<string, string>>((resolve) => {
343-
const originalSetData = DataTransfer.prototype.setData;
344343
const clipboardWrites: Record<string, string> = {};
344+
let didCleanup = false;
345+
346+
const originalSetData = DataTransfer.prototype.setData;
345347
DataTransfer.prototype.setData = function (
346348
type: string,
347349
value: string,
@@ -350,8 +352,31 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
350352
return originalSetData.call(this, type, value);
351353
};
352354

355+
const originalWrite = navigator.clipboard.write.bind(
356+
navigator.clipboard,
357+
);
358+
navigator.clipboard.write = async function (data: ClipboardItem[]) {
359+
for (const item of data) {
360+
for (const type of item.types) {
361+
if (type.startsWith("text/")) {
362+
const blob = await item.getType(type);
363+
clipboardWrites[type] = await blob.text();
364+
}
365+
}
366+
}
367+
try {
368+
return await originalWrite(data);
369+
} finally {
370+
clearTimeout(safetyTimeout);
371+
queueMicrotask(cleanup);
372+
}
373+
};
374+
353375
const cleanup = () => {
376+
if (didCleanup) return;
377+
didCleanup = true;
354378
DataTransfer.prototype.setData = originalSetData;
379+
navigator.clipboard.write = originalWrite;
355380
resolve(clipboardWrites);
356381
};
357382

packages/react-grab/e2e/selection.spec.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,17 @@ test.describe("Element Selection", () => {
5454
const copyPayloadPromise = reactGrab.captureNextClipboardWrites();
5555
await reactGrab.clickElement("[data-testid='todo-list'] h1");
5656
const copyPayload = await copyPayloadPromise;
57+
58+
expect(copyPayload["text/plain"]).toContain("Todo List");
59+
expect(copyPayload["text/html"]).toContain("Todo List");
60+
5761
const clipboardMetadataText = copyPayload["application/x-react-grab"];
58-
if (!clipboardMetadataText) {
59-
throw new Error("Missing React Grab clipboard metadata");
62+
if (clipboardMetadataText) {
63+
const clipboardMetadata = JSON.parse(clipboardMetadataText);
64+
expect(clipboardMetadata.content).toContain("Todo List");
65+
expect(clipboardMetadata.entries).toHaveLength(1);
66+
expect(clipboardMetadata.entries[0].content).toContain("Todo List");
6067
}
61-
62-
const clipboardMetadata = JSON.parse(clipboardMetadataText);
63-
expect(clipboardMetadata.content).toContain("Todo List");
64-
expect(clipboardMetadata.entries).toHaveLength(1);
65-
expect(clipboardMetadata.entries[0].content).toContain("Todo List");
6668
});
6769

6870
test("should highlight different elements when hovering", async ({

packages/react-grab/src/constants.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,9 @@ export const DROPDOWN_EDGE_TRANSFORM_ORIGIN = {
160160
bottom: "center bottom",
161161
};
162162

163-
export const TEXT_IMAGE_FONT_SIZE_PX = 12;
164-
export const TEXT_IMAGE_LINE_HEIGHT_PX = 15;
165-
export const TEXT_IMAGE_PADDING_PX = 8;
163+
export const TEXT_IMAGE_FONT_SIZE_PX = 8;
164+
export const TEXT_IMAGE_LINE_HEIGHT_PX = 10;
165+
export const TEXT_IMAGE_PADDING_PX = 4;
166166
export const TEXT_IMAGE_FONT_FAMILY = "monospace";
167167
export const TEXT_IMAGE_BACKGROUND_COLOR = "#ffffff";
168168
export const TEXT_IMAGE_TEXT_COLOR = "#000000";

packages/react-grab/src/core/copy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const tryCopyWithFallback = async (
7373
? `${extraPrompt}\n\n${transformedContent}`
7474
: transformedContent;
7575

76-
didCopy = copyContent(copiedContent, {
76+
didCopy = await copyContent(copiedContent, {
7777
componentName: options.componentName,
7878
entries,
7979
});

packages/react-grab/src/core/index.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ import { commentPlugin } from "./plugins/comment.js";
138138
import { openPlugin } from "./plugins/open.js";
139139
import { copyHtmlPlugin } from "./plugins/copy-html.js";
140140
import { copyStylesPlugin } from "./plugins/copy-styles.js";
141-
import { copyImagePlugin } from "./plugins/copy-image.js";
142141
import {
143142
freezeAnimations,
144143
freezeAllAnimations,
@@ -169,7 +168,6 @@ const builtInPlugins = [
169168
commentPlugin,
170169
copyHtmlPlugin,
171170
copyStylesPlugin,
172-
copyImagePlugin,
173171
openPlugin,
174172
];
175173

@@ -3790,8 +3788,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
37903788
}
37913789
};
37923790

3793-
const copyCommentItemContent = (item: CommentItem) => {
3794-
copyContent(item.content, {
3791+
const copyCommentItemContent = async (item: CommentItem) => {
3792+
await copyContent(item.content, {
37953793
tagName: item.tagName,
37963794
componentName: item.componentName ?? item.elementName,
37973795
commentText: item.commentText,
@@ -3834,7 +3832,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
38343832
}
38353833
};
38363834

3837-
const handleCommentsCopyAll = () => {
3835+
const handleCommentsCopyAll = async () => {
38383836
clearCommentsHoverPreviews();
38393837
const currentCommentItems = commentItems();
38403838
if (currentCommentItems.length === 0) return;
@@ -3844,7 +3842,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
38443842
);
38453843

38463844
const firstItem = currentCommentItems[0];
3847-
copyContent(combinedContent, {
3845+
await copyContent(combinedContent, {
38483846
componentName: firstItem.componentName ?? firstItem.tagName,
38493847
entries: currentCommentItems.map((commentItem) => ({
38503848
tagName: commentItem.tagName,

packages/react-grab/src/core/plugins/copy-html.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const copyHtmlPlugin = createPendingSelectionPlugin({
2222
if (!transformedHtml) return false;
2323

2424
const stackContext = await api.getStackContext(context.element);
25-
return copyContent(appendStackContext(transformedHtml, stackContext), {
25+
return await copyContent(appendStackContext(transformedHtml, stackContext), {
2626
componentName: context.componentName,
2727
tagName: context.tagName,
2828
});

packages/react-grab/src/core/plugins/copy-image.ts

Lines changed: 0 additions & 34 deletions
This file was deleted.

packages/react-grab/src/core/plugins/copy-styles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const copyStylesPlugin = createPendingSelectionPlugin({
1919
.join("\n\n");
2020

2121
const stackContext = await api.getStackContext(context.element);
22-
return copyContent(appendStackContext(combinedCss, stackContext), {
22+
return await copyContent(appendStackContext(combinedCss, stackContext), {
2323
componentName: context.componentName,
2424
tagName: context.tagName,
2525
});

packages/react-grab/src/utils/copy-content.ts

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { VERSION } from "../constants.js";
2-
3-
const REACT_GRAB_MIME_TYPE = "application/x-react-grab";
2+
import { renderTextToImage } from "./render-text-to-image.js";
43

54
export interface ReactGrabEntry {
65
tagName?: string;
@@ -31,10 +30,13 @@ const escapeHtml = (text: string): string =>
3130
.replace(/>/g, "&gt;")
3231
.replace(/"/g, "&quot;");
3332

34-
export const copyContent = (
33+
const buildHtmlPayload = (content: string): string =>
34+
`<meta charset='utf-8'><pre><code>${escapeHtml(content)}</code></pre>`;
35+
36+
const buildMetadata = (
3537
content: string,
3638
options?: CopyContentOptions,
37-
): boolean => {
39+
): ReactGrabMetadata => {
3840
const elementName = options?.componentName ?? "div";
3941
const entries = options?.entries ?? [
4042
{
@@ -44,23 +46,37 @@ export const copyContent = (
4446
commentText: options?.commentText,
4547
},
4648
];
47-
const reactGrabMetadata: ReactGrabMetadata = {
48-
version: VERSION,
49-
content,
50-
entries,
51-
timestamp: Date.now(),
52-
};
49+
return { version: VERSION, content, entries, timestamp: Date.now() };
50+
};
51+
52+
const isModernClipboardAvailable = (): boolean =>
53+
Boolean(navigator.clipboard?.write) && typeof ClipboardItem !== "undefined";
5354

55+
/**
56+
* Modern path: writes text/plain + text/html + image/png in a single ClipboardItem.
57+
* Cannot carry custom MIME types like application/x-react-grab.
58+
*/
59+
const modernCopy = async (content: string): Promise<void> => {
60+
const item = new ClipboardItem({
61+
"text/plain": new Blob([content], { type: "text/plain" }),
62+
"text/html": new Blob([buildHtmlPayload(content)], { type: "text/html" }),
63+
"image/png": renderTextToImage(content),
64+
});
65+
await navigator.clipboard.write([item]);
66+
};
67+
68+
/**
69+
* Legacy path: execCommand("copy") with text/plain + text/html + metadata.
70+
* Must run synchronously within a user gesture call stack.
71+
*/
72+
const legacyCopy = (content: string, metadata: ReactGrabMetadata): boolean => {
5473
const copyHandler = (event: ClipboardEvent) => {
5574
event.preventDefault();
5675
event.clipboardData?.setData("text/plain", content);
76+
event.clipboardData?.setData("text/html", buildHtmlPayload(content));
5777
event.clipboardData?.setData(
58-
"text/html",
59-
`<meta charset='utf-8'><pre><code>${escapeHtml(content)}</code></pre>`,
60-
);
61-
event.clipboardData?.setData(
62-
REACT_GRAB_MIME_TYPE,
63-
JSON.stringify(reactGrabMetadata),
78+
"application/x-react-grab",
79+
JSON.stringify(metadata),
6480
);
6581
};
6682

@@ -78,13 +94,30 @@ export const copyContent = (
7894
if (typeof document.execCommand !== "function") {
7995
return false;
8096
}
81-
const didCopySucceed = document.execCommand("copy");
82-
if (didCopySucceed) {
83-
options?.onSuccess?.();
84-
}
85-
return didCopySucceed;
97+
return document.execCommand("copy");
8698
} finally {
8799
document.removeEventListener("copy", copyHandler);
88100
textarea.remove();
89101
}
90102
};
103+
104+
export const copyContent = async (
105+
content: string,
106+
options?: CopyContentOptions,
107+
): Promise<boolean> => {
108+
let didCopy: boolean;
109+
110+
if (isModernClipboardAvailable()) {
111+
try {
112+
await modernCopy(content);
113+
didCopy = true;
114+
} catch {
115+
didCopy = false;
116+
}
117+
} else {
118+
didCopy = legacyCopy(content, buildMetadata(content, options));
119+
}
120+
121+
if (didCopy) options?.onSuccess?.();
122+
return didCopy;
123+
};

packages/react-grab/src/utils/copy-image-to-clipboard.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)