diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts new file mode 100644 index 00000000000..7265e8b6043 --- /dev/null +++ b/apps/web/src/markdown-clipboard.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +class FakeText { + readonly nodeType = TEXT_NODE; + readonly childNodes: ReadonlyArray = []; + + constructor(readonly textContent: string) {} +} + +class FakeElement { + readonly nodeType = ELEMENT_NODE; + readonly childNodes: Array = []; + readonly classList = { + contains: (name: string) => this.classNames.includes(name), + }; + + constructor( + readonly tagName: string, + private readonly classNames: ReadonlyArray = [], + ) {} + + get localName(): string { + return this.tagName.toLowerCase(); + } + + get textContent(): string { + return this.childNodes.map((child) => child.textContent).join(""); + } + + append(...children: Array): this { + this.childNodes.push(...children); + return this; + } + + getAttribute(): string | null { + return null; + } + + hasAttribute(): boolean { + return false; + } +} + +function asNode(element: FakeElement): Node { + return element as unknown as Node; +} + +function shikiCodeLine(text: string): FakeElement { + const token = new FakeElement("SPAN").append(new FakeText(text)); + return new FakeElement("SPAN", ["line"]).append(token); +} + +describe("serializeRenderedMarkdownFragment", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("wraps inline code in backticks", () => { + const paragraph = new FakeElement("P").append( + new FakeText("run "), + new FakeElement("CODE").append(new FakeText("git status")), + new FakeText(" first"), + ); + const container = new FakeElement("DIV").append(paragraph); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); + }); + + it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => { + const code = new FakeElement("CODE").append( + shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"), + ); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "git show-ref --verify refs/remotes/origin/opt/deploy/dev", + ); + }); + + it("keeps a multi-line code selection plain instead of inline-wrapping it", () => { + const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line")); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 86965eebfa9..50a68562699 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string { return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`; } +/** + * A code element whose pre wrapper fell outside the copied range is still + * block code, recognizable by its highlighter line spans or embedded + * newlines. Wrapping it like inline code produces backtick-surrounded + * shell commands on paste. + */ +function isBlockCodeElement(element: Element, content: string): boolean { + if (content.includes("\n")) return true; + for (const child of element.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) { + return true; + } + } + return false; +} + function wrapInlineCode(code: string): string { const longestRun = [...(code.match(/`+/g) ?? [])].reduce( (max, run) => Math.max(max, run.length), @@ -201,8 +217,10 @@ function serializeNode(node: Node): string { return `${serializeChildren(element).trim()}\n\n`; case "PRE": return serializeCodeBlock(element); - case "CODE": - return wrapInlineCode(element.textContent ?? ""); + case "CODE": { + const content = element.textContent ?? ""; + return isBlockCodeElement(element, content) ? content : wrapInlineCode(content); + } case "STRONG": case "B": return wrapInlineMarker(serializeChildren(element), "**");