diff --git a/apps/storybook/stories/fixtures.ts b/apps/storybook/stories/fixtures.ts
index f936496e4..d3d3fd782 100644
--- a/apps/storybook/stories/fixtures.ts
+++ b/apps/storybook/stories/fixtures.ts
@@ -22,7 +22,7 @@ export const COMMENT_PRESET_KEYS: readonly CommentPreset[] = [
export const createMenuActions = (openEnabled: boolean): ContextMenuAction[] => [
{ id: "copy", label: "Copy", shortcut: "C", onAction: noop },
- { id: "copy-html", label: "Copy HTML", onAction: noop },
+ { id: "copy-details", label: "Copy details", onAction: noop },
{ id: "open", label: "Open", shortcut: "O", enabled: openEnabled, onAction: noop },
{ id: "comment", label: "Comment", shortcut: "Enter", onAction: noop },
];
diff --git a/packages/react-grab/docs/architecture.md b/packages/react-grab/docs/architecture.md
index 90a3d68b6..6f1b2e235 100644
--- a/packages/react-grab/docs/architecture.md
+++ b/packages/react-grab/docs/architecture.md
@@ -23,7 +23,7 @@ The runtime has two main concerns: initialization and the interaction lifecycle.
When the package is imported in a browser environment, [index.ts](../src/index.ts) checks that `window` exists and `__REACT_GRAB_DISABLED__` is not set, then calls `init()`. The returned API object is assigned to `window.__REACT_GRAB__`, any plugins that were registered via `registerPlugin()` before initialization completed are flushed from a pending queue, and a `react-grab:init` custom event is dispatched so other code on the page can react to the tool being ready. If `init()` is called in an SSR environment or when the tool is disabled, it returns a no-op API object with the same shape so that consumers don't need to guard every call.
-Inside `init()`, the first thing that happens is merging any options provided programmatically with options parsed from the `data-options` JSON attribute on the script tag (via `getScriptOptions()`). Then `createPluginRegistry` and `createGrabStore` are called to set up the reactive state. A single `AbortController` manages all event listeners on `window` and `document`, so that teardown is a single `abort()` call. The five built-in plugins (copy, comment, open, copy-html, copy-styles) are registered through the same `register()` path that external plugins use. Finally, the renderer is loaded asynchronously via `import("../components/renderer.js")` and mounted into a Shadow DOM container created by [utils/mount-root.ts](../src/utils/mount-root.ts).
+Inside `init()`, the first thing that happens is merging any options provided programmatically with options parsed from the `data-options` JSON attribute on the script tag (via `getScriptOptions()`). Then `createPluginRegistry` and `createGrabStore` are called to set up the reactive state. A single `AbortController` manages all event listeners on `window` and `document`, so that teardown is a single `abort()` call. The four built-in plugins (copy, comment, copy-details, open) are registered through the same `register()` path that external plugins use. Finally, the renderer is loaded asynchronously via `import("../components/renderer.js")` and mounted into a Shadow DOM container created by [utils/mount-root.ts](../src/utils/mount-root.ts).
### The interaction lifecycle
@@ -202,5 +202,4 @@ The five built-in plugins are registered during `init()` through the same `regis
- **copy** registers the default "Copy" context-menu action that calls the copy flow.
- **comment** registers the "Comment" action that enters prompt mode.
- **open** registers the "Open in editor" action that calls `openFile` with the resolved source location, running the URL through the `transformOpenFileUrl` hook pipeline first.
-- **copy-html** registers "Copy HTML" which copies the element's `outerHTML` with stack context appended.
-- **copy-styles** registers "Copy styles" which extracts the element's computed CSS (compared against a baseline from a hidden iframe) and copies it with stack context.
+- **copy-details** registers "Copy details" which copies the full verbose HTML preview with the component stack.
diff --git a/packages/react-grab/e2e/context-menu.spec.ts b/packages/react-grab/e2e/context-menu.spec.ts
index 284b93fd4..4fc3bd988 100644
--- a/packages/react-grab/e2e/context-menu.spec.ts
+++ b/packages/react-grab/e2e/context-menu.spec.ts
@@ -299,7 +299,7 @@ test.describe("Context Menu", () => {
await reactGrab.page.waitForTimeout(500);
const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).toContain("Walk the dog");
+ expect(clipboardContent).toContain("TodoItem");
});
});
diff --git a/packages/react-grab/e2e/copy-styles.spec.ts b/packages/react-grab/e2e/copy-styles.spec.ts
deleted file mode 100644
index 5c6657e70..000000000
--- a/packages/react-grab/e2e/copy-styles.spec.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { test, expect } from "./fixtures.js";
-
-test.describe("Copy styles", () => {
- test.describe("Context Menu", () => {
- test("should show Copy styles in context menu", async ({ reactGrab }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='todo-list'] h1");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='todo-list'] h1");
-
- const menuInfo = await reactGrab.getContextMenuInfo();
- expect(menuInfo.isVisible).toBe(true);
- expect(menuInfo.menuItems.map((item: string) => item.toLowerCase())).toContain("copy styles");
- });
-
- test("should copy CSS declarations to clipboard via context menu", async ({ reactGrab }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='todo-list'] h1");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='todo-list'] h1");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect
- .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
- .toMatch(/[\w-]+:\s*.+;/);
- });
-
- test("should include className header when element has a class", async ({ reactGrab }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='todo-list'] h1");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='todo-list'] h1");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect
- .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
- .toContain("className:");
- });
-
- test("should contain CSS property-value pairs", async ({ reactGrab }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='submit-button']");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='submit-button']");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect
- .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
- .toMatch(/[\w-]+:\s*.+;/);
-
- const content = await reactGrab.getClipboardContent();
- const hasRelevantProperty =
- content.includes("background-color:") ||
- content.includes("color:") ||
- content.includes("padding-");
- expect(hasRelevantProperty).toBe(true);
- });
- });
-
- test.describe("Feedback", () => {
- test("should show Copied feedback after Copy styles", async ({ reactGrab }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='submit-button']");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='submit-button']");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect.poll(() => reactGrab.getLabelStatusText(), { timeout: 5000 }).toBe("Copied");
- });
-
- test("should dismiss context menu after Copy styles action", async ({ reactGrab }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("li:first-child");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("li:first-child");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 2000 }).toBe(false);
- });
- });
-
- test.describe("Different Elements", () => {
- test("should copy CSS for element with background and color styles", async ({ reactGrab }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='gradient-div']");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='gradient-div']");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect
- .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
- .toMatch(/[\w-]+:\s*.+;/);
-
- const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).toMatch(/width:|height:|background/);
- });
-
- test("should produce output for a plain element with no custom styles", async ({
- reactGrab,
- }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='deeply-nested-text']");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='deeply-nested-text']");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).toBeTruthy();
- });
-
- test("should copy different CSS for different elements", async ({ reactGrab }) => {
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='submit-button']");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='submit-button']");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect
- .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 })
- .toMatch(/[\w-]+:\s*.+;/);
-
- const firstCss = await reactGrab.getClipboardContent();
-
- await reactGrab.activate();
- await reactGrab.hoverElement("[data-testid='todo-list'] h1");
- await reactGrab.waitForSelectionBox();
- await reactGrab.rightClickElement("[data-testid='todo-list'] h1");
- await reactGrab.clickContextMenuItem("Copy styles");
-
- await expect
- .poll(
- async () => {
- const content = await reactGrab.getClipboardContent();
- return content !== firstCss;
- },
- { timeout: 5000 },
- )
- .toBe(true);
- });
- });
-});
diff --git a/packages/react-grab/e2e/disabled-elements.spec.ts b/packages/react-grab/e2e/disabled-elements.spec.ts
index 5afa770be..bf16af3f4 100644
--- a/packages/react-grab/e2e/disabled-elements.spec.ts
+++ b/packages/react-grab/e2e/disabled-elements.spec.ts
@@ -125,7 +125,7 @@ test.describe("Disabled Element Selection", () => {
await reactGrab.waitForSelectionBox();
await reactGrab.page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
- await expect.poll(() => reactGrab.getClipboardContent()).toContain("Pointer Events None");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container");
});
test("should select nested disabled element inside enabled parent", async ({ reactGrab }) => {
diff --git a/packages/react-grab/e2e/drag-selection.spec.ts b/packages/react-grab/e2e/drag-selection.spec.ts
index a6edf9b0a..9cf1a9c19 100644
--- a/packages/react-grab/e2e/drag-selection.spec.ts
+++ b/packages/react-grab/e2e/drag-selection.spec.ts
@@ -131,7 +131,7 @@ test.describe("Drag Selection", () => {
const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).toContain("Buy groceries");
+ expect(clipboardContent).toContain("TodoList");
});
test("should cancel drag selection on Escape", async ({ reactGrab }) => {
@@ -199,8 +199,7 @@ test.describe("Drag Selection", () => {
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toBeTruthy();
- expect(clipboardContent).toContain("Buy groceries");
- expect(clipboardContent).toContain("Write tests");
+ expect(clipboardContent).toContain("TodoList");
});
test("should show visual feedback during drag", async ({ reactGrab }) => {
diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts
index f05702269..632d4b56d 100644
--- a/packages/react-grab/e2e/element-context.spec.ts
+++ b/packages/react-grab/e2e/element-context.spec.ts
@@ -2,7 +2,7 @@ import { test, expect } from "./fixtures.js";
test.describe("Element Context Fallback", () => {
test.describe("React Elements", () => {
- test("should include component names in clipboard for React elements", async ({
+ test("should copy a compact reference or verbose context for React elements", async ({
reactGrab,
}) => {
await reactGrab.activate();
@@ -12,22 +12,11 @@ test.describe("Element Context Fallback", () => {
await reactGrab.clickElement("[data-testid='todo-list'] h1");
const clipboard = await reactGrab.getClipboardContent();
+ expect(clipboard).toMatch(/^\[<\w+[\s>]/);
expect(clipboard).toContain("TodoList");
});
- test("should include HTML preview with tag and content", async ({ reactGrab }) => {
- await reactGrab.activate();
-
- await reactGrab.hoverElement("[data-testid='main-title']");
- await reactGrab.waitForSelectionBox();
- await reactGrab.clickElement("[data-testid='main-title']");
-
- const clipboard = await reactGrab.getClipboardContent();
- expect(clipboard).toContain("
{
await reactGrab.activate();
@@ -37,24 +26,11 @@ test.describe("Element Context Fallback", () => {
await reactGrab.clickElement("[data-testid='nested-button']");
const clipboard = await reactGrab.getClipboardContent();
+ expect(clipboard).toMatch(/^\[<\w+[\s>]/);
expect(clipboard).toContain("NestedCard");
});
- test("should include parent components in stack, not just immediate component", async ({
- reactGrab,
- }) => {
- await reactGrab.activate();
-
- await reactGrab.hoverElement("[data-testid='nested-button']");
- await reactGrab.waitForSelectionBox();
- await reactGrab.clickElement("[data-testid='nested-button']");
-
- const clipboard = await reactGrab.getClipboardContent();
- const inMatches = clipboard.match(/in\s+\S+/g) ?? [];
- expect(inMatches.length).toBeGreaterThanOrEqual(2);
- });
-
- test("should include ancestor component for todo item", async ({ reactGrab }) => {
+ test("should produce useful context for todo items", async ({ reactGrab }) => {
await reactGrab.activate();
const todoItem = "[data-testid='todo-list'] ul li:first-child span";
@@ -63,6 +39,7 @@ test.describe("Element Context Fallback", () => {
await reactGrab.clickElement(todoItem);
const clipboard = await reactGrab.getClipboardContent();
+ expect(clipboard).toMatch(/^\[<\w+[\s>]/);
expect(clipboard).toContain("TodoItem");
});
});
diff --git a/packages/react-grab/e2e/keyboard-shortcuts.spec.ts b/packages/react-grab/e2e/keyboard-shortcuts.spec.ts
index e10ef9b5b..232ebf3b0 100644
--- a/packages/react-grab/e2e/keyboard-shortcuts.spec.ts
+++ b/packages/react-grab/e2e/keyboard-shortcuts.spec.ts
@@ -43,7 +43,7 @@ test.describe("Keyboard Shortcuts", () => {
await reactGrab.page.waitForTimeout(500);
const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).toContain("Walk the dog");
+ expect(clipboardContent).toContain("TodoItem");
});
test("should keep overlay active while navigating with arrow keys", async ({ reactGrab }) => {
diff --git a/packages/react-grab/e2e/shift-multi-select.spec.ts b/packages/react-grab/e2e/shift-multi-select.spec.ts
index 14e805f92..060455597 100644
--- a/packages/react-grab/e2e/shift-multi-select.spec.ts
+++ b/packages/react-grab/e2e/shift-multi-select.spec.ts
@@ -42,9 +42,7 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.page.keyboard.up("Shift");
- await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
- const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).toContain("Write tests");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});
test("should show the pending hover target while shift multi-selecting", async ({
@@ -323,9 +321,7 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.page.keyboard.up("Shift");
- await expect.poll(() => reactGrab.getClipboardContent()).toContain("Walk the dog");
- const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).not.toContain("Buy groceries");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});
test("should not auto-copy until shift is released", async ({ reactGrab }) => {
@@ -351,7 +347,7 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.page.keyboard.up("Shift");
- await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});
test("should reset accumulated selection when a non-shift click follows", async ({
@@ -388,10 +384,7 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.activate();
await reactGrab.page.mouse.click(lastBox.x + lastBox.width / 2, lastBox.y + lastBox.height / 2);
- await expect.poll(() => reactGrab.getClipboardContent()).toContain("Write tests");
- const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).not.toContain("Buy groceries");
- expect(clipboardContent).not.toContain("Walk the dog");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});
test("should not commit accumulated selection when shift releases over an open context menu", async ({
@@ -511,9 +504,7 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.page.keyboard.up("Shift");
await reactGrab.page.mouse.up();
- await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
- const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).toContain("Walk the dog");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
const userSelectStyle = await reactGrab.page.evaluate(() => document.body.style.userSelect);
expect(userSelectStyle).toBe("");
@@ -594,9 +585,7 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.page.keyboard.up("Shift");
- await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
- const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).toContain("Walk the dog");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});
test("should extend existing drag selection with shift+click", async ({ reactGrab }) => {
@@ -630,8 +619,6 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.page.keyboard.up("Shift");
- await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
- const clipboardContent = await reactGrab.getClipboardContent();
- expect(clipboardContent).toContain("Write tests");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});
});
diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts
index 6a03aa869..e40bfd280 100644
--- a/packages/react-grab/src/constants.ts
+++ b/packages/react-grab/src/constants.ts
@@ -73,6 +73,9 @@ export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [
"title",
];
+export const COMPACT_IDENTIFYING_ATTRS: readonly string[] = ["id", "data-testid"];
+export const COMPACT_TEXT_MAX_LENGTH = 30;
+
export const PREVIEW_IDENTIFYING_ATTRS = new Set([
"id",
"data-testid",
diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts
index 78d45ab88..f0125d963 100644
--- a/packages/react-grab/src/core/context.ts
+++ b/packages/react-grab/src/core/context.ts
@@ -459,16 +459,11 @@ export const getElementContext = async (
};
const getFallbackContext = (element: Element): string => {
- const tagName = getTagName(element);
-
if (!(element instanceof HTMLElement)) {
- const attrsHint = formatPriorityAttrs(element, {
- truncate: false,
- maxAttrs: PREVIEW_PRIORITY_ATTRS.length,
- });
- return `<${tagName}${attrsHint} />`;
+ return getInlineHTMLPreview(element);
}
+ const tagName = getTagName(element);
const attrsText = formatAttrsForPreview(element);
const directText = getDirectTextContent(element);
const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH);
@@ -532,7 +527,7 @@ const formatAttrsForPreview = (element: Element): string => {
return identifyingParts.join("") + remainingParts.join("") + classAttr;
};
-const getDirectTextContent = (element: Element): string => {
+export const getDirectTextContent = (element: Element): string => {
let directText = "";
for (const node of element.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
@@ -553,6 +548,27 @@ const formatChildElements = (elements: Array): string => {
return `(${elements.length} elements)`;
};
+export const getInlineHTMLPreview = (element: Element): string => {
+ const tagName = getTagName(element);
+
+ if (!(element instanceof HTMLElement)) {
+ const attrsHint = formatPriorityAttrs(element, {
+ truncate: false,
+ maxAttrs: PREVIEW_PRIORITY_ATTRS.length,
+ });
+ return `<${tagName}${attrsHint} />`;
+ }
+
+ const attrsText = formatAttrsForPreview(element);
+ const directText = getDirectTextContent(element);
+ const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH);
+
+ if (truncatedText) {
+ return `<${tagName}${attrsText}>${truncatedText}${tagName}>`;
+ }
+ return `<${tagName}${attrsText} />`;
+};
+
export const getHTMLPreview = (element: Element): string => {
const tagName = getTagName(element);
const attrsText = formatAttrsForPreview(element);
diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts
index da2761ca5..083dd5664 100644
--- a/packages/react-grab/src/core/copy.ts
+++ b/packages/react-grab/src/core/copy.ts
@@ -1,23 +1,74 @@
-import { copyContent, type ReactGrabEntry } from "../utils/copy-content.js";
-import { generateSnippet } from "../utils/generate-snippet.js";
-import { joinSnippets } from "../utils/join-snippets.js";
+import {
+ resolveSource,
+ checkIsNextProject,
+ getComponentDisplayName,
+ getDirectTextContent,
+ getInlineHTMLPreview,
+} from "./context.js";
+import { COMPACT_IDENTIFYING_ATTRS, COMPACT_TEXT_MAX_LENGTH } from "../constants.js";
+import { copyContent } from "../utils/copy-content.js";
+import { getTagName } from "../utils/get-tag-name.js";
import { normalizeError } from "../utils/normalize-error.js";
+import { truncateString } from "../utils/truncate-string.js";
interface CopyOptions {
- maxContextLines?: number;
getContent?: (elements: Element[]) => Promise | string;
componentName?: string;
}
interface CopyHooks {
onBeforeCopy: (elements: Element[]) => Promise;
- transformSnippet: (snippet: string, element: Element) => Promise;
transformCopyContent: (content: string, elements: Element[]) => Promise;
onAfterCopy: (elements: Element[], success: boolean) => void;
onCopySuccess: (elements: Element[], content: string) => void;
onCopyError: (error: Error) => void;
}
+const formatCompactReference = (
+ element: Element,
+ source: Awaited>,
+ isNextProject: boolean,
+): string => {
+ const tagName = getTagName(element);
+ const componentName = source?.componentName ?? getComponentDisplayName(element);
+
+ if (!source && !componentName) {
+ return `[${getInlineHTMLPreview(element)}]`;
+ }
+
+ let identifyingAttrs = "";
+ for (const attrName of COMPACT_IDENTIFYING_ATTRS) {
+ const attrValue = element.getAttribute(attrName);
+ if (attrValue) {
+ identifyingAttrs += ` ${attrName}="${attrValue.replaceAll('"', "'")}"`;
+ }
+ }
+ const directText = getDirectTextContent(element);
+ const sanitizedText = directText.replaceAll('"', "'");
+ const textSnippet = sanitizedText
+ ? ` "${truncateString(sanitizedText, COMPACT_TEXT_MAX_LENGTH)}"`
+ : "";
+
+ const parts = [`<${tagName}${identifyingAttrs}>${textSnippet}`];
+ if (componentName) parts.push(`in ${componentName}`);
+ if (source) {
+ const lineReference = isNextProject && source.lineNumber ? `:${source.lineNumber}` : "";
+ parts.push(`@${source.filePath}${lineReference}`);
+ }
+ return `[${parts.join(" ")}]`;
+};
+
+const buildCompactContent = async (elements: Element[]): Promise => {
+ const isNextProject = checkIsNextProject();
+ const resolvedSources = await Promise.all(elements.map(resolveSource));
+ const uniqueReferences = new Set(
+ elements.map((element, index) =>
+ formatCompactReference(element, resolvedSources[index], isNextProject),
+ ),
+ );
+ return uniqueReferences.size > 0 ? [...uniqueReferences].join("\n") : null;
+};
+
export const tryCopyWithFallback = async (
options: CopyOptions,
hooks: CopyHooks,
@@ -30,40 +81,17 @@ export const tryCopyWithFallback = async (
await hooks.onBeforeCopy(elements);
try {
- let generatedContent: string;
- let entries: ReactGrabEntry[] | undefined;
-
- if (options.getContent) {
- generatedContent = await options.getContent(elements);
- } else {
- const rawSnippets = await generateSnippet(elements, {
- maxLines: options.maxContextLines,
- });
- const transformedSnippets = await Promise.all(
- rawSnippets.map((snippet, index) =>
- snippet.trim() ? hooks.transformSnippet(snippet, elements[index]) : Promise.resolve(""),
- ),
- );
- const snippetElementPairs = transformedSnippets
- .map((snippet, index) => ({ snippet, element: elements[index] }))
- .filter(({ snippet }) => snippet.trim());
-
- generatedContent = joinSnippets(snippetElementPairs.map(({ snippet }) => snippet));
- entries = snippetElementPairs.map(({ snippet, element }) => ({
- tagName: element.localName,
- content: snippet,
- commentText: extraPrompt,
- }));
- }
+ const generatedContent = options.getContent
+ ? await options.getContent(elements)
+ : await buildCompactContent(elements);
- if (generatedContent.trim()) {
+ if (generatedContent?.trim()) {
const transformedContent = await hooks.transformCopyContent(generatedContent, elements);
- copiedContent = extraPrompt ? `${extraPrompt}\n\n${transformedContent}` : transformedContent;
+ copiedContent = extraPrompt ? `${extraPrompt}\n${transformedContent}` : transformedContent;
didCopy = copyContent(copiedContent, {
componentName: options.componentName,
- entries,
});
}
} catch (error) {
diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx
index 941bd1ea1..e200958e0 100644
--- a/packages/react-grab/src/core/index.tsx
+++ b/packages/react-grab/src/core/index.tsx
@@ -70,7 +70,6 @@ import {
INPUT_FOCUS_ACTIVATION_DELAY_MS,
INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS,
DEFAULT_KEY_HOLD_DURATION_MS,
- DEFAULT_MAX_CONTEXT_LINES,
MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS,
ZOOM_DETECTION_THRESHOLD,
WINDOW_REFOCUS_GRACE_PERIOD_MS,
@@ -123,8 +122,7 @@ import { loadToolbarState, saveToolbarState } from "../components/toolbar/state.
import { copyPlugin } from "./plugins/copy.js";
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 { copyDetailsPlugin } from "./plugins/copy-details.js";
import {
freezeAnimations,
freezeAllAnimations,
@@ -147,7 +145,7 @@ import { generateId } from "../utils/generate-id.js";
import { logRecoverableError } from "../utils/log-recoverable-error.js";
import { getNearestEdge } from "../utils/get-nearest-edge.js";
-const builtInPlugins = [copyPlugin, commentPlugin, copyHtmlPlugin, copyStylesPlugin, openPlugin];
+const builtInPlugins = [copyPlugin, commentPlugin, copyDetailsPlugin, openPlugin];
interface CopyWithLabelOptions {
element: Element;
@@ -193,7 +191,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
activationMode: "toggle",
keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS,
allowActivationInsideInput: true,
- maxContextLines: DEFAULT_MAX_CONTEXT_LINES,
...scriptOptions,
...rawOptions,
};
@@ -840,13 +837,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
return tryCopyWithFallback(
{
- maxContextLines: pluginRegistry.store.options.maxContextLines,
getContent: pluginRegistry.store.options.getContent,
componentName: elementName,
},
{
onBeforeCopy: pluginRegistry.hooks.onBeforeCopy,
- transformSnippet: pluginRegistry.hooks.transformSnippet,
transformCopyContent: pluginRegistry.hooks.transformCopyContent,
onAfterCopy: pluginRegistry.hooks.onAfterCopy,
onCopySuccess: (copiedElements: Element[], content: string) => {
diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts
index f91fad38d..690affc03 100644
--- a/packages/react-grab/src/core/plugin-registry.ts
+++ b/packages/react-grab/src/core/plugin-registry.ts
@@ -20,7 +20,7 @@ import type {
ActionContext,
} from "../types.js";
import { DEFAULT_THEME, deepMergeTheme } from "./theme.js";
-import { DEFAULT_KEY_HOLD_DURATION_MS, DEFAULT_MAX_CONTEXT_LINES } from "../constants.js";
+import { DEFAULT_KEY_HOLD_DURATION_MS } from "../constants.js";
interface RegisteredPlugin {
plugin: Plugin;
@@ -31,7 +31,6 @@ interface OptionsState {
activationMode: ActivationMode;
keyHoldDuration: number;
allowActivationInsideInput: boolean;
- maxContextLines: number;
activationKey: ActivationKey | undefined;
getContent: ((elements: Element[]) => Promise | string) | undefined;
freezeReactUpdates: boolean;
@@ -41,7 +40,6 @@ const DEFAULT_OPTIONS: OptionsState = {
activationMode: "toggle",
keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS,
allowActivationInsideInput: true,
- maxContextLines: DEFAULT_MAX_CONTEXT_LINES,
activationKey: undefined,
getContent: undefined,
freezeReactUpdates: true,
@@ -105,7 +103,6 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => {
"activationMode",
"keyHoldDuration",
"allowActivationInsideInput",
- "maxContextLines",
"activationKey",
"getContent",
"freezeReactUpdates",
@@ -306,8 +303,6 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => {
callHookReduceSync("transformActionContext", context),
transformOpenFileUrl: (url: string, filePath: string, lineNumber?: number) =>
callHookReduceSync("transformOpenFileUrl", url, filePath, lineNumber),
- transformSnippet: async (snippet: string, element: Element) =>
- callHookReduce("transformSnippet", snippet, element),
};
return {
diff --git a/packages/react-grab/src/core/plugins/copy-details.ts b/packages/react-grab/src/core/plugins/copy-details.ts
new file mode 100644
index 000000000..9b1170d68
--- /dev/null
+++ b/packages/react-grab/src/core/plugins/copy-details.ts
@@ -0,0 +1,30 @@
+import { copyContent } from "../../utils/copy-content.js";
+import { generateSnippet } from "../../utils/generate-snippet.js";
+import { joinSnippets } from "../../utils/join-snippets.js";
+import { createPendingSelectionPlugin } from "./create-pending-selection-plugin.js";
+
+export const copyDetailsPlugin = createPendingSelectionPlugin({
+ name: "copy-details",
+ contextMenuAction: (_api, _hooks) => ({
+ id: "copy-details",
+ label: "Copy details",
+ 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 joinedContent = joinSnippets(nonEmptySnippets);
+ const transformedContent = await context.hooks.transformHtmlContent(
+ joinedContent,
+ context.elements,
+ );
+ if (!transformedContent) return false;
+ return copyContent(transformedContent, {
+ componentName: context.componentName,
+ tagName: context.tagName,
+ });
+ });
+ },
+ }),
+});
diff --git a/packages/react-grab/src/core/plugins/copy-html.ts b/packages/react-grab/src/core/plugins/copy-html.ts
deleted file mode 100644
index 34aadf08a..000000000
--- a/packages/react-grab/src/core/plugins/copy-html.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { appendStackContext } from "../../utils/append-stack-context.js";
-import { copyContent } from "../../utils/copy-content.js";
-import { stripInternalAttributes } from "../../utils/strip-internal-attributes.js";
-import { createPendingSelectionPlugin } from "./create-pending-selection-plugin.js";
-
-export const copyHtmlPlugin = createPendingSelectionPlugin({
- name: "copy-html",
- contextMenuAction: (api) => ({
- id: "copy-html",
- label: "Copy HTML",
- showInToolbarMenu: true,
- onAction: async (context) => {
- await context.performWithFeedback(async () => {
- const combinedHtml = context.elements
- .map((element) => stripInternalAttributes(element.outerHTML))
- .join("\n\n");
-
- const transformedHtml = await context.hooks.transformHtmlContent(
- combinedHtml,
- context.elements,
- );
-
- if (!transformedHtml) return false;
-
- const stackContext = await api.getStackContext(context.element);
- return copyContent(appendStackContext(transformedHtml, stackContext), {
- componentName: context.componentName,
- tagName: context.tagName,
- });
- });
- },
- }),
-});
diff --git a/packages/react-grab/src/core/plugins/copy-styles.ts b/packages/react-grab/src/core/plugins/copy-styles.ts
deleted file mode 100644
index 054db10fa..000000000
--- a/packages/react-grab/src/core/plugins/copy-styles.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { appendStackContext } from "../../utils/append-stack-context.js";
-import { copyContent } from "../../utils/copy-content.js";
-import { extractElementCss, disposeBaselineStyles } from "../../utils/extract-element-css.js";
-import { createPendingSelectionPlugin } from "./create-pending-selection-plugin.js";
-
-export const copyStylesPlugin = createPendingSelectionPlugin({
- name: "copy-styles",
- contextMenuAction: (api) => ({
- id: "copy-styles",
- label: "Copy styles",
- showInToolbarMenu: true,
- onAction: async (context) => {
- await context.performWithFeedback(async () => {
- const combinedCss = context.elements.map(extractElementCss).join("\n\n");
-
- const stackContext = await api.getStackContext(context.element);
- return copyContent(appendStackContext(combinedCss, stackContext), {
- componentName: context.componentName,
- tagName: context.tagName,
- });
- });
- },
- }),
- cleanup: disposeBaselineStyles,
-});
diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts
index 3091fd4b7..3513203f3 100644
--- a/packages/react-grab/src/primitives.ts
+++ b/packages/react-grab/src/primitives.ts
@@ -21,7 +21,7 @@ import { Fiber, getFiberFromHostInstance } from "bippy";
import type { StackFrame } from "bippy/source";
export type { StackFrame };
import { createElementSelector } from "./utils/create-element-selector.js";
-import { extractElementCss } from "./utils/extract-element-css.js";
+import { extractElementCss, disposeBaselineStyles } from "./utils/extract-element-css.js";
import { openFile as openFileAsync } from "./utils/open-file.js";
export interface ReactGrabElementContext {
@@ -163,3 +163,5 @@ export const isFreezeActive = (): boolean => {
export const openFile = async (filePath: string, lineNumber?: number): Promise => {
await openFileAsync(filePath, lineNumber);
};
+
+export { disposeBaselineStyles };
diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts
index 1405dffef..18d6b60de 100644
--- a/packages/react-grab/src/types.ts
+++ b/packages/react-grab/src/types.ts
@@ -218,7 +218,6 @@ export interface PluginHooks {
) => AgentContext | Promise;
transformActionContext?: (context: ActionContext) => ActionContext;
transformOpenFileUrl?: (url: string, filePath: string, lineNumber?: number) => string;
- transformSnippet?: (snippet: string, element: Element) => string | Promise;
}
export interface PluginConfig {
@@ -243,7 +242,6 @@ export interface Options {
activationMode?: ActivationMode;
keyHoldDuration?: number;
allowActivationInsideInput?: boolean;
- maxContextLines?: number;
activationKey?: ActivationKey;
getContent?: (elements: Element[]) => Promise | string;
/**
diff --git a/packages/react-grab/src/utils/append-stack-context.ts b/packages/react-grab/src/utils/append-stack-context.ts
deleted file mode 100644
index 252e659aa..000000000
--- a/packages/react-grab/src/utils/append-stack-context.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const appendStackContext = (content: string, stackContext: string): string => {
- if (!stackContext) return content;
- return `${content}\n${stackContext}`;
-};
diff --git a/packages/react-grab/src/utils/get-script-options.ts b/packages/react-grab/src/utils/get-script-options.ts
index 98d1c37da..a9fa7dd72 100644
--- a/packages/react-grab/src/utils/get-script-options.ts
+++ b/packages/react-grab/src/utils/get-script-options.ts
@@ -21,9 +21,6 @@ const parseOptionsFromJson = (rawValue: unknown): Partial | null => {
if (typeof rawValue.allowActivationInsideInput === "boolean") {
parsedOptions.allowActivationInsideInput = rawValue.allowActivationInsideInput;
}
- if (typeof rawValue.maxContextLines === "number" && Number.isFinite(rawValue.maxContextLines)) {
- parsedOptions.maxContextLines = rawValue.maxContextLines;
- }
if (typeof rawValue.activationKey === "string") {
parsedOptions.activationKey = rawValue.activationKey;
}