authorgravatar for 153842990+yashranaway@users.noreply.github.comAditya Garud <153842990+yashranaway@users.noreply.github.com> 2026-08-15 17:42:15+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:12:15+00:00
log7083bce26aa89fedfc482ad44cf61c5508a58db7
tree986a610168796e1bb5736a0e7456f77191a45c17
parentf915320914d1bc446e60cbcfe4cd7d75bad4dc2a
signature Signed by PGP key B5690EEEBB952194

Keep block code plain when copying from rendered markdown (#4468)


2 files changed, 115 insertions(+), 2 deletions(-)

apps/web/src/markdown-clipboard.test.ts created+95
...@@ -0,0 +1,95 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { serializeRenderedMarkdownFragment } from "./markdown-clipboard";
4
5const TEXT_NODE = 3;
6const ELEMENT_NODE = 1;
7
8class FakeText {
9 readonly nodeType = TEXT_NODE;
10 readonly childNodes: ReadonlyArray<never> = [];
11
12 constructor(readonly textContent: string) {}
13}
14
15class FakeElement {
16 readonly nodeType = ELEMENT_NODE;
17 readonly childNodes: Array<FakeElement | FakeText> = [];
18 readonly classList = {
19 contains: (name: string) => this.classNames.includes(name),
20 };
21
22 constructor(
23 readonly tagName: string,
24 private readonly classNames: ReadonlyArray<string> = [],
25 ) {}
26
27 get localName(): string {
28 return this.tagName.toLowerCase();
29 }
30
31 get textContent(): string {
32 return this.childNodes.map((child) => child.textContent).join("");
33 }
34
35 append(...children: Array<FakeElement | FakeText>): this {
36 this.childNodes.push(...children);
37 return this;
38 }
39
40 getAttribute(): string | null {
41 return null;
42 }
43
44 hasAttribute(): boolean {
45 return false;
46 }
47}
48
49function asNode(element: FakeElement): Node {
50 return element as unknown as Node;
51}
52
53function shikiCodeLine(text: string): FakeElement {
54 const token = new FakeElement("SPAN").append(new FakeText(text));
55 return new FakeElement("SPAN", ["line"]).append(token);
56}
57
58describe("serializeRenderedMarkdownFragment", () => {
59 beforeEach(() => {
60 vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE });
61 });
62
63 afterEach(() => {
64 vi.unstubAllGlobals();
65 });
66
67 it("wraps inline code in backticks", () => {
68 const paragraph = new FakeElement("P").append(
69 new FakeText("run "),
70 new FakeElement("CODE").append(new FakeText("git status")),
71 new FakeText(" first"),
72 );
73 const container = new FakeElement("DIV").append(paragraph);
74
75 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first");
76 });
77
78 it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => {
79 const code = new FakeElement("CODE").append(
80 shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"),
81 );
82 const container = new FakeElement("DIV").append(code);
83
84 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
85 "git show-ref --verify refs/remotes/origin/opt/deploy/dev",
86 );
87 });
88
89 it("keeps a multi-line code selection plain instead of inline-wrapping it", () => {
90 const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line"));
91 const container = new FakeElement("DIV").append(code);
92
93 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line");
94 });
95});
apps/web/src/markdown-clipboard.ts+20-2
...@@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string {...@@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string {
37 return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`;37 return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`;
38}38}
3939
40/**
41 * A code element whose pre wrapper fell outside the copied range is still
42 * block code, recognizable by its highlighter line spans or embedded
43 * newlines. Wrapping it like inline code produces backtick-surrounded
44 * shell commands on paste.
45 */
46function isBlockCodeElement(element: Element, content: string): boolean {
47 if (content.includes("\n")) return true;
48 for (const child of element.childNodes) {
49 if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) {
50 return true;
51 }
52 }
53 return false;
54}
55
40function wrapInlineCode(code: string): string {56function wrapInlineCode(code: string): string {
41 const longestRun = [...(code.match(/`+/g) ?? [])].reduce(57 const longestRun = [...(code.match(/`+/g) ?? [])].reduce(
42 (max, run) => Math.max(max, run.length),58 (max, run) => Math.max(max, run.length),
...@@ -201,8 +217,10 @@ function serializeNode(node: Node): string {...@@ -201,8 +217,10 @@ function serializeNode(node: Node): string {
201 return `${serializeChildren(element).trim()}\n\n`;217 return `${serializeChildren(element).trim()}\n\n`;
202 case "PRE":218 case "PRE":
203 return serializeCodeBlock(element);219 return serializeCodeBlock(element);
204 case "CODE":220 case "CODE": {
205 return wrapInlineCode(element.textContent ?? "");221 const content = element.textContent ?? "";
222 return isBlockCodeElement(element, content) ? content : wrapInlineCode(content);
223 }
206 case "STRONG":224 case "STRONG":
207 case "B":225 case "B":
208 return wrapInlineMarker(serializeChildren(element), "**");226 return wrapInlineMarker(serializeChildren(element), "**");