Skip to content
Draft
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
31 changes: 27 additions & 4 deletions packages/react-grab/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,10 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
const captureNextClipboardWrites = async () => {
return page.evaluate(() => {
return new Promise<Record<string, string>>((resolve) => {
const originalSetData = DataTransfer.prototype.setData;
const clipboardWrites: Record<string, string> = {};
let didCleanup = false;

const originalSetData = DataTransfer.prototype.setData;
DataTransfer.prototype.setData = function (
type: string,
value: string,
Expand All @@ -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);
};

Expand Down Expand Up @@ -1007,9 +1032,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
const items = dropdown.querySelectorAll<HTMLButtonElement>(
"[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);
Expand Down
16 changes: 9 additions & 7 deletions packages/react-grab/e2e/selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down
8 changes: 8 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ export const DROPDOWN_EDGE_TRANSFORM_ORIGIN = {
bottom: "center bottom",
};

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";
export const TEXT_IMAGE_TAB_SIZE_SPACES = 2;

export const NEXTJS_REVALIDATION_DELAY_MS = 1000;

export const TEXTAREA_MAX_HEIGHT_PX = 95;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-grab/src/core/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const tryCopyWithFallback = async (
? `${extraPrompt}\n\n${transformedContent}`
: transformedContent;

didCopy = copyContent(copiedContent, {
didCopy = await copyContent(copiedContent, {
componentName: options.componentName,
entries,
});
Expand Down
8 changes: 4 additions & 4 deletions packages/react-grab/src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3788,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,
Expand Down Expand Up @@ -3832,7 +3832,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
}
};

const handleCommentsCopyAll = () => {
const handleCommentsCopyAll = async () => {
clearCommentsHoverPreviews();
const currentCommentItems = commentItems();
if (currentCommentItems.length === 0) return;
Expand All @@ -3842,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,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-grab/src/core/plugins/copy-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
2 changes: 1 addition & 1 deletion packages/react-grab/src/core/plugins/copy-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
9 changes: 5 additions & 4 deletions packages/react-grab/src/utils/comment-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ let didConfirmClear = readSessionFlag(CLEAR_CONFIRMED_KEY);

export const loadComments = (): CommentItem[] => commentItems;

export const addCommentItem = (
item: Omit<CommentItem, "id">,
): CommentItem[] =>
export const addCommentItem = (item: Omit<CommentItem, "id">): CommentItem[] =>
persistCommentItems(
[{ ...item, id: generateId("comment") }, ...commentItems].slice(
0,
Expand All @@ -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,
);
}
};
75 changes: 54 additions & 21 deletions packages/react-grab/src/utils/copy-content.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,10 +30,13 @@ const escapeHtml = (text: string): string =>
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");

export const copyContent = (
const buildHtmlPayload = (content: string): string =>
`<meta charset='utf-8'><pre><code>${escapeHtml(content)}</code></pre>`;

const buildMetadata = (
content: string,
options?: CopyContentOptions,
): boolean => {
): ReactGrabMetadata => {
const elementName = options?.componentName ?? "div";
const entries = options?.entries ?? [
{
Expand All @@ -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<void> => {
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",
`<meta charset='utf-8'><pre><code>${escapeHtml(content)}</code></pre>`,
);
event.clipboardData?.setData(
REACT_GRAB_MIME_TYPE,
JSON.stringify(reactGrabMetadata),
"application/x-react-grab",
JSON.stringify(metadata),
);
};

Expand All @@ -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<boolean> => {
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;
};
59 changes: 59 additions & 0 deletions packages/react-grab/src/utils/render-text-to-image.ts
Original file line number Diff line number Diff line change
@@ -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<Blob> => {
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");
});
};
4 changes: 1 addition & 3 deletions packages/shadcn-registry/r/react-grab.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading