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}`; + } + 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; }