Skip to content
Open
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
91 changes: 91 additions & 0 deletions packages/react-grab/e2e/copy-url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { test, expect } from "./fixtures.js";

test.describe("Copy URL Inclusion", () => {
test("should include page URL in copied clipboard content", async ({
reactGrab,
}) => {
await reactGrab.activate();
await reactGrab.hoverElement("[data-testid='main-title']");
await reactGrab.waitForSelectionBox();
await reactGrab.clickElement("[data-testid='main-title']");

const pageUrl = reactGrab.page.url();
await expect
.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
.toContain(`URL: ${pageUrl}`);
});

test("should place URL after element snippet content", async ({
reactGrab,
}) => {
await reactGrab.activate();
await reactGrab.hoverElement("[data-testid='main-title']");
await reactGrab.waitForSelectionBox();
await reactGrab.clickElement("[data-testid='main-title']");

await expect
.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
.toContain("React Grab");

const content = await reactGrab.getClipboardContent();
const urlIndex = content.indexOf("URL:");
const snippetIndex = content.indexOf("React Grab");
expect(urlIndex).toBeGreaterThan(snippetIndex);
});

test("should include URL in clipboard metadata but not in metadata content", async ({
reactGrab,
}) => {
await reactGrab.activate();
await reactGrab.hoverElement("[data-testid='todo-list'] h1");
await reactGrab.waitForSelectionBox();

const copyPayloadPromise = reactGrab.captureNextClipboardWrites();
await reactGrab.clickElement("[data-testid='todo-list'] h1");
const copyPayload = await copyPayloadPromise;
const metadataText = copyPayload["application/x-react-grab"];
if (!metadataText) {
throw new Error("Missing React Grab clipboard metadata");
}

const pageUrl = reactGrab.page.url();
const metadata = JSON.parse(metadataText);
expect(metadata.url).toBe(pageUrl);
expect(metadata.content).toContain("Todo List");
expect(metadata.content).not.toContain("URL:");
});

test("should include URL when copying with comment", async ({
reactGrab,
}) => {
await reactGrab.registerCommentAction();
await reactGrab.enterPromptMode("li:first-child");
await reactGrab.typeInInput("Fix this item");
await reactGrab.submitInput();

const pageUrl = reactGrab.page.url();
await expect
.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
.toContain("Fix this item");

const content = await reactGrab.getClipboardContent();
expect(content).toContain(`URL: ${pageUrl}`);

// Comment should come before snippet, URL should come last
const commentIndex = content.indexOf("Fix this item");
const urlIndex = content.indexOf("URL:");
expect(commentIndex).toBeLessThan(urlIndex);
});

test("should include URL when copying multiple elements via drag", async ({
reactGrab,
}) => {
await reactGrab.activate();
await reactGrab.dragSelect("li:first-child", "li:nth-child(3)");

const pageUrl = reactGrab.page.url();
await expect
.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
.toContain(`URL: ${pageUrl}`);
});
});
3 changes: 2 additions & 1 deletion packages/react-grab/e2e/element-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ test.describe("Element Context Fallback", () => {

const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toContain("long-dom-element");
expect(clipboard.length).toBeLessThanOrEqual(510);
const snippetPortion = clipboard.split("\n\nURL:")[0];
expect(snippetPortion.length).toBeLessThanOrEqual(510);
});
});
});
22 changes: 19 additions & 3 deletions packages/react-grab/src/core/copy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { copyContent, type ReactGrabEntry } from "../utils/copy-content.js";
import {
buildClipboardText,
copyContent,
type ReactGrabEntry,
} from "../utils/copy-content.js";
import { generateSnippet } from "../utils/generate-snippet.js";
import { joinSnippets } from "../utils/join-snippets.js";
import { normalizeError } from "../utils/normalize-error.js";
Expand All @@ -17,7 +21,11 @@ interface CopyHooks {
elements: Element[],
) => Promise<string>;
onAfterCopy: (elements: Element[], success: boolean) => void;
onCopySuccess: (elements: Element[], content: string) => void;
onCopySuccess: (
elements: Element[],
content: string,
clipboardText: string,
) => void;
onCopyError: (error: Error) => void;
}

Expand All @@ -29,6 +37,7 @@ export const tryCopyWithFallback = async (
): Promise<boolean> => {
let didCopy = false;
let copiedContent = "";
let url = "";

await hooks.onBeforeCopy(elements);

Expand Down Expand Up @@ -69,21 +78,28 @@ export const tryCopyWithFallback = async (
elements,
);

url = window.location.href;

copiedContent = extraPrompt
? `${extraPrompt}\n\n${transformedContent}`
: transformedContent;

didCopy = copyContent(copiedContent, {
componentName: options.componentName,
entries,
url,
});
}
} catch (error) {
hooks.onCopyError(normalizeError(error));
}

if (didCopy) {
hooks.onCopySuccess(elements, copiedContent);
hooks.onCopySuccess(
elements,
copiedContent,
buildClipboardText(copiedContent, url),
);
}
hooks.onAfterCopy(elements, didCopy);

Expand Down
11 changes: 9 additions & 2 deletions packages/react-grab/src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
const handleCopySuccessWithComments = (options: {
copiedElements: Element[];
content: string;
clipboardText: string;
extraPrompt: string | undefined;
elementName: string | undefined;
tagName: string | null;
Expand All @@ -835,12 +836,13 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
const {
copiedElements,
content,
clipboardText,
extraPrompt,
elementName,
tagName,
componentName,
} = options;
pluginRegistry.hooks.onCopySuccess(copiedElements, content);
pluginRegistry.hooks.onCopySuccess(copiedElements, clipboardText);

if (!extraPrompt) return;

Expand Down Expand Up @@ -928,10 +930,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
transformSnippet: pluginRegistry.hooks.transformSnippet,
transformCopyContent: pluginRegistry.hooks.transformCopyContent,
onAfterCopy: pluginRegistry.hooks.onAfterCopy,
onCopySuccess: (copiedElements: Element[], content: string) => {
onCopySuccess: (
copiedElements: Element[],
content: string,
clipboardText: string,
) => {
handleCopySuccessWithComments({
copiedElements,
content,
clipboardText,
extraPrompt,
elementName,
tagName,
Expand Down
16 changes: 13 additions & 3 deletions packages/react-grab/src/utils/copy-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ interface CopyContentOptions {
tagName?: string;
commentText?: string;
entries?: ReactGrabEntry[];
url?: string;
}

interface ReactGrabMetadata {
version: string;
url: string;
content: string;
entries: ReactGrabEntry[];
timestamp: number;
Expand All @@ -31,6 +33,11 @@ const escapeHtml = (text: string): string =>
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");

export const buildClipboardText = (
content: string,
url?: string,
): string => (url != null ? `${content}\n\nURL: ${url}` : content);

export const copyContent = (
content: string,
options?: CopyContentOptions,
Expand All @@ -44,19 +51,22 @@ export const copyContent = (
commentText: options?.commentText,
},
];
const url = options?.url ?? window.location.href;
const clipboardText = buildClipboardText(content, options?.url != null ? url : undefined);
const reactGrabMetadata: ReactGrabMetadata = {
version: VERSION,
url,
content,
entries,
timestamp: Date.now(),
};

const copyHandler = (event: ClipboardEvent) => {
event.preventDefault();
event.clipboardData?.setData("text/plain", content);
event.clipboardData?.setData("text/plain", clipboardText);
event.clipboardData?.setData(
"text/html",
`<meta charset='utf-8'><pre><code>${escapeHtml(content)}</code></pre>`,
`<meta charset='utf-8'><pre><code>${escapeHtml(clipboardText)}</code></pre>`,
);
event.clipboardData?.setData(
REACT_GRAB_MIME_TYPE,
Expand All @@ -67,7 +77,7 @@ export const copyContent = (
document.addEventListener("copy", copyHandler);

const textarea = document.createElement("textarea");
textarea.value = content;
textarea.value = clipboardText;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.ariaHidden = "true";
Expand Down