History

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

Aditya Garud committed 2 months ago

Commit 7083bce1 parent f9153202 files · +115 −2Browse files

Unified · Split

2 files
  1. apps/web/src/markdown-clipboard.test.ts +95 −0
  2. apps/web/src/markdown-clipboard.ts +20 −2
apps/web/src/markdown-clipboard.test.ts+95−0
@@ -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+20−2
@@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string { · 36 unmodified lines
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 { · 158 unmodified lines
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), "**");