Skip to content

Commit 7083bce

Browse files
authored
Keep block code plain when copying from rendered markdown (#4468)
1 parent f915320 commit 7083bce

2 files changed

Lines changed: 115 additions & 2 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+
import { serializeRenderedMarkdownFragment } from "./markdown-clipboard";
4+
5+
const TEXT_NODE = 3;
6+
const ELEMENT_NODE = 1;
7+
8+
class FakeText {
9+
readonly nodeType = TEXT_NODE;
10+
readonly childNodes: ReadonlyArray<never> = [];
11+
12+
constructor(readonly textContent: string) {}
13+
}
14+
15+
class 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+
49+
function asNode(element: FakeElement): Node {
50+
return element as unknown as Node;
51+
}
52+
53+
function shikiCodeLine(text: string): FakeElement {
54+
const token = new FakeElement("SPAN").append(new FakeText(text));
55+
return new FakeElement("SPAN", ["line"]).append(token);
56+
}
57+
58+
describe("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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string {
3737
return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`;
3838
}
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+
*/
46+
function 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+
4056
function wrapInlineCode(code: string): string {
4157
const longestRun = [...(code.match(/`+/g) ?? [])].reduce(
4258
(max, run) => Math.max(max, run.length),
@@ -201,8 +217,10 @@ function serializeNode(node: Node): string {
201217
return `${serializeChildren(element).trim()}\n\n`;
202218
case "PRE":
203219
return serializeCodeBlock(element);
204-
case "CODE":
205-
return wrapInlineCode(element.textContent ?? "");
220+
case "CODE": {
221+
const content = element.textContent ?? "";
222+
return isBlockCodeElement(element, content) ? content : wrapInlineCode(content);
223+
}
206224
case "STRONG":
207225
case "B":
208226
return wrapInlineMarker(serializeChildren(element), "**");

0 commit comments

Comments
 (0)