diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 7265e8b6043..9f1219baeaf 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -22,6 +22,7 @@ class FakeElement { constructor( readonly tagName: string, private readonly classNames: ReadonlyArray = [], + private readonly attributes: Readonly> = {}, ) {} get localName(): string { @@ -32,17 +33,49 @@ class FakeElement { return this.childNodes.map((child) => child.textContent).join(""); } + get children(): ReadonlyArray { + return this.childNodes.filter((child): child is FakeElement => child instanceof FakeElement); + } + append(...children: Array): this { this.childNodes.push(...children); return this; } - getAttribute(): string | null { + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; + } + + hasAttribute(name: string): boolean { + return name in this.attributes; + } + + closest(): FakeElement | null { return null; } - hasAttribute(): boolean { - return false; + /** Supports only the selectors markdown-clipboard actually asks for. */ + querySelector(selector: string): FakeElement | null { + const childOnly = selector.startsWith(":scope > "); + const target = childOnly ? selector.slice(":scope > ".length) : selector; + const matches = (element: FakeElement): boolean => { + if (target === 'input[type="checkbox"]') { + return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox"; + } + return element.tagName === target.toUpperCase(); + }; + const search = (parent: FakeElement): FakeElement | null => { + for (const child of parent.childNodes) { + if (!(child instanceof FakeElement)) continue; + if (matches(child)) return child; + if (!childOnly) { + const nested = search(child); + if (nested) return nested; + } + } + return null; + }; + return search(this); } } @@ -55,6 +88,21 @@ function shikiCodeLine(text: string): FakeElement { return new FakeElement("SPAN", ["line"]).append(token); } +/** Mirrors a rendered code block: select-none header chrome plus a shiki pre. */ +function renderedCodeBlock(lines: ReadonlyArray): FakeElement { + const code = new FakeElement("CODE"); + lines.forEach((line, index) => { + if (index > 0) code.append(new FakeText("\n")); + code.append(shikiCodeLine(line)); + }); + return new FakeElement("DIV", ["chat-markdown-codeblock"]).append( + new FakeElement("DIV", ["chat-markdown-codeblock-header", "select-none"]).append( + new FakeText("sh"), + ), + new FakeElement("DIV", ["chat-markdown-shiki"]).append(new FakeElement("PRE").append(code)), + ); +} + describe("serializeRenderedMarkdownFragment", () => { beforeEach(() => { vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); @@ -92,4 +140,80 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + + it("keeps fences when a bare list item sits alongside the code block", () => { + // serializeListItem emits "- " for an item with no text, so the item is + // content the plain-code path would drop. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append(new FakeElement("LI")), + renderedCodeBlock(["pnpm test"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```"); + }); + + it("keeps fences when a checkbox-only task item sits alongside the code block", () => { + // The checkbox is a skipped tag, so the item renders no text of its own, but + // it still carries the task state. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append( + new FakeElement("LI").append(new FakeElement("INPUT", [], { type: "checkbox" })), + ), + renderedCodeBlock(["pnpm test"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "- [ ]\n\n```\npnpm test\n```", + ); + }); + + it("still drops fences for a code block that is the whole list item", () => { + // The item only wraps the block, so a selection that never left the pre + // would drop the marker too. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append(new FakeElement("LI").append(renderedCodeBlock(["pnpm test"]))), + new FakeText("\n"), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("pnpm test"); + }); + + it("keeps fences when a file chip sits alongside the code block", () => { + // The chip renders as a button, a skipped tag, but its data-markdown-copy + // still contributes markdown, so the block is not the only visible content. + const container = new FakeElement("DIV").append( + renderedCodeBlock(["pnpm test"]), + new FakeText("\n"), + new FakeElement("BUTTON", [], { "data-markdown-copy": "`src/foo.ts`" }), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "```\npnpm test\n```\n\n`src/foo.ts`", + ); + }); + + it("omits fences when a selection past the last line drags in the whole code block", () => { + // Dragging over the final newline ends the range after the pre, so the + // fragment carries the block plus the empty head of the next paragraph. + const container = new FakeElement("DIV").append( + renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]), + new FakeText("\n"), + new FakeElement("P").append(new FakeText("")), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN", + ); + }); + + it("still fences a code block copied alongside prose", () => { + const container = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Run this:")), + renderedCodeBlock(["gh workflow run Deploy --ref main"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "Run this:\n\n```\ngh workflow run Deploy --ref main\n```", + ); + }); }); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 069d161a188..4a96c8b31d1 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -256,6 +256,73 @@ function serializeNode(node: Node): string { } } +/** + * Tracks whether a fragment carries exactly one code block and nothing else a + * reader would see. + */ +interface SoleCodeBlockScan { + pre: Element | null; + other: boolean; +} + +function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void { + for (const child of node.childNodes) { + if (scan.other) return; + if (child.nodeType === Node.TEXT_NODE) { + if ((child.textContent ?? "").trim().length > 0) scan.other = true; + continue; + } + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const element = child as Element; + // Mirrors serializeNode's order: an element carrying markdown of its own + // still contributes it even when its tag is otherwise skipped, as a file + // chip rendered as a button does. + if (element.hasAttribute("data-markdown-details")) { + scan.other = true; + continue; + } + const markdownCopy = element.getAttribute("data-markdown-copy"); + if (markdownCopy !== null) { + if (markdownCopy.trim().length > 0) scan.other = true; + continue; + } + if (isSkippedElement(element)) continue; + if (element.tagName === "PRE") { + if (scan.pre) scan.other = true; + else scan.pre = element; + continue; + } + if (element.tagName === "IMG" || element.tagName === "HR") { + scan.other = true; + continue; + } + if (element.tagName === "LI") { + // serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item, + // so an item that does not hold the block carries content of its own even + // when it renders no text. An item that wraps the block is just the + // structure around it, and a pre-only selection would drop the marker too. + const preBeforeItem = scan.pre; + scanForSoleCodeBlock(element, scan); + if (!scan.other && scan.pre === preBeforeItem) scan.other = true; + continue; + } + scanForSoleCodeBlock(element, scan); + } +} + +/** + * A drag that ends on a block's final newline pulls the closing `pre` into the + * range, so the fragment holds the whole block even though the user only + * highlighted code. Re-fencing that pastes stray backticks, so a fragment whose + * only visible content is one code block copies as plain code, matching a + * selection that never left the `pre`. + */ +function soleCodeBlock(container: Node): Element | null { + const scan: SoleCodeBlockScan = { pre: null, other: false }; + scanForSoleCodeBlock(container, scan); + return scan.other ? null : scan.pre; +} + /** Collapses serializer spacing artifacts without touching fenced code content. */ function tidyMarkdown(markdown: string): string { return markdown @@ -268,6 +335,8 @@ function tidyMarkdown(markdown: string): string { } export function serializeRenderedMarkdownFragment(container: Node): string { + const codeBlock = soleCodeBlock(container); + if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, ""); return tidyMarkdown(serializeChildren(container)); }