diff --git a/packages/react-grab/e2e/copy-url.spec.ts b/packages/react-grab/e2e/copy-url.spec.ts new file mode 100644 index 000000000..8788f7c39 --- /dev/null +++ b/packages/react-grab/e2e/copy-url.spec.ts @@ -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}`); + }); +}); diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 93b594b9d..88ea29830 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -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); }); }); }); diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index fc8415134..f9d3e0d86 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -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"; @@ -17,7 +21,11 @@ interface CopyHooks { elements: Element[], ) => Promise; onAfterCopy: (elements: Element[], success: boolean) => void; - onCopySuccess: (elements: Element[], content: string) => void; + onCopySuccess: ( + elements: Element[], + content: string, + clipboardText: string, + ) => void; onCopyError: (error: Error) => void; } @@ -29,6 +37,7 @@ export const tryCopyWithFallback = async ( ): Promise => { let didCopy = false; let copiedContent = ""; + let url = ""; await hooks.onBeforeCopy(elements); @@ -69,6 +78,8 @@ export const tryCopyWithFallback = async ( elements, ); + url = window.location.href; + copiedContent = extraPrompt ? `${extraPrompt}\n\n${transformedContent}` : transformedContent; @@ -76,6 +87,7 @@ export const tryCopyWithFallback = async ( didCopy = copyContent(copiedContent, { componentName: options.componentName, entries, + url, }); } } catch (error) { @@ -83,7 +95,11 @@ export const tryCopyWithFallback = async ( } if (didCopy) { - hooks.onCopySuccess(elements, copiedContent); + hooks.onCopySuccess( + elements, + copiedContent, + buildClipboardText(copiedContent, url), + ); } hooks.onAfterCopy(elements, didCopy); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index e6c3f6efd..19330ff72 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -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; @@ -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; @@ -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, diff --git a/packages/react-grab/src/utils/copy-content.ts b/packages/react-grab/src/utils/copy-content.ts index 90cdd336e..5b1dfd2d2 100644 --- a/packages/react-grab/src/utils/copy-content.ts +++ b/packages/react-grab/src/utils/copy-content.ts @@ -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; @@ -31,6 +33,11 @@ const escapeHtml = (text: string): string => .replace(/>/g, ">") .replace(/"/g, """); +export const buildClipboardText = ( + content: string, + url?: string, +): string => (url != null ? `${content}\n\nURL: ${url}` : content); + export const copyContent = ( content: string, options?: CopyContentOptions, @@ -44,8 +51,11 @@ 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(), @@ -53,10 +63,10 @@ export const copyContent = ( const copyHandler = (event: ClipboardEvent) => { event.preventDefault(); - event.clipboardData?.setData("text/plain", content); + event.clipboardData?.setData("text/plain", clipboardText); event.clipboardData?.setData( "text/html", - `
${escapeHtml(content)}
`, + `
${escapeHtml(clipboardText)}
`, ); event.clipboardData?.setData( REACT_GRAB_MIME_TYPE, @@ -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";