Skip to content

Commit 6aedf36

Browse files
fix(session-ui): copy Markdown selections without theme colours (#59151)
Co-authored-by: Brendonovich <[email protected]>
1 parent 54dcd34 commit 6aedf36

4 files changed

Lines changed: 276 additions & 0 deletions

File tree

‎packages/session-ui/component-tests/markdown.spec.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,3 +983,63 @@ story("loads file URLs and Windows paths, and leaves unreadable images as alt te
983983
await harness.getByLabel("Markdown text").fill("Still usable")
984984
await expect(harness.locator('[data-component="markdown"]')).toHaveText("Still usable")
985985
})
986+
987+
async function copySelection(page: Page, select: "markdown" | "harness") {
988+
await page.evaluate((select) => {
989+
const harness = document.querySelector('[data-testid="markdown-fixture"]')!
990+
const range = document.createRange()
991+
range.selectNodeContents(select === "markdown" ? harness.querySelector('[data-component="markdown"]')! : harness)
992+
getSelection()!.removeAllRanges()
993+
getSelection()!.addRange(range)
994+
document.addEventListener(
995+
"copy",
996+
(event) => {
997+
document.body.dataset.copiedText = event.clipboardData?.getData("text/plain")
998+
document.body.dataset.copiedHtml = event.clipboardData?.getData("text/html")
999+
},
1000+
{ once: true },
1001+
)
1002+
}, select)
1003+
await page.keyboard.press("ControlOrMeta+C")
1004+
1005+
return page.evaluate(() => ({ text: document.body.dataset.copiedText, html: document.body.dataset.copiedHtml }))
1006+
}
1007+
1008+
story("copies selected Markdown as semantic HTML without theme or highlight colours", async ({ page }) => {
1009+
await page.evaluate(async (fixture) => {
1010+
document.body.style.color = "rgb(237, 237, 237)"
1011+
const { mountMarkdown } = await import(fixture)
1012+
await mountMarkdown({
1013+
text: "## Answer\n\nSome **bold** and [docs](https://opencode.ai/docs).\n\n- one\n- two\n\n```ts\nconst value = 42\n```",
1014+
})
1015+
}, fixture)
1016+
const harness = page.getByTestId("markdown-fixture")
1017+
await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
1018+
await expect(harness.locator("pre code span[style]").first()).toBeAttached()
1019+
1020+
const copied = await copySelection(page, "markdown")
1021+
1022+
expect(copied.text).toContain("Some bold and docs.")
1023+
expect(copied.text).toContain("const value = 42")
1024+
expect(copied.html).toContain("<h2>Answer</h2>")
1025+
expect(copied.html).toContain('<p>Some <strong>bold</strong> and <a href="https://opencode.ai/docs">docs</a>.</p>')
1026+
expect(copied.html).toContain("<li>one</li>")
1027+
expect(copied.html).toMatch(/<pre><code>(<span>[^<]*<\/span>)+<\/code><\/pre>/)
1028+
expect(copied.html).not.toMatch(/style=|class=|<button|<svg/)
1029+
})
1030+
1031+
story("keeps the browser copy for selections that leave the Markdown", async ({ page }) => {
1032+
await page.evaluate(async (fixture) => {
1033+
const { mountMarkdown } = await import(fixture)
1034+
await mountMarkdown({ text: "Some **bold** text" })
1035+
}, fixture)
1036+
await expect(page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')).toHaveAttribute(
1037+
"data-markdown-ready",
1038+
"",
1039+
)
1040+
1041+
const copied = await copySelection(page, "harness")
1042+
1043+
expect(copied.html).toBe("")
1044+
expect(copied.text).toBe("")
1045+
})
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { GlobalRegistrator } from "@happy-dom/global-registrator"
2+
import { afterAll, beforeAll, expect, test } from "bun:test"
3+
import { markdownClipboardHtml } from "./markdown-clipboard"
4+
5+
beforeAll(() => GlobalRegistrator.register())
6+
7+
afterAll(() => GlobalRegistrator.unregister())
8+
9+
function markdownRoot(html: string) {
10+
const root = document.createElement("div")
11+
root.setAttribute("data-component", "markdown")
12+
root.innerHTML = html
13+
document.body.replaceChildren(root)
14+
15+
return root
16+
}
17+
18+
function rangeOver(node: Node) {
19+
const range = document.createRange()
20+
range.selectNodeContents(node)
21+
22+
return range
23+
}
24+
25+
test("keeps semantic structure without colours, classes, or layout attributes", () => {
26+
const root = markdownRoot(
27+
[
28+
'<div data-markdown-block="" style="display: contents">',
29+
'<h2 class="heading" style="color: rgb(237, 237, 237)">Title</h2>',
30+
'<p style="color: white">Some <strong>bold</strong>, <em>italic</em> and <code data-inline-code-kind="path">src/app.ts</code>.</p>',
31+
'<ol start="3"><li>three</li><li>four</li></ol>',
32+
'<table><thead><tr><th style="text-align: left">A</th></tr></thead><tbody><tr><td colspan="2">1</td></tr></tbody></table>',
33+
"<blockquote><p>quoted</p></blockquote>",
34+
"</div>",
35+
].join(""),
36+
)
37+
38+
expect(markdownClipboardHtml(root, rangeOver(root))).toBe(
39+
[
40+
"<div>",
41+
"<h2>Title</h2>",
42+
"<p>Some <strong>bold</strong>, <em>italic</em> and <code>src/app.ts</code>.</p>",
43+
'<ol start="3"><li>three</li><li>four</li></ol>',
44+
'<table><thead><tr><th>A</th></tr></thead><tbody><tr><td colspan="2">1</td></tr></tbody></table>',
45+
"<blockquote><p>quoted</p></blockquote>",
46+
"</div>",
47+
].join(""),
48+
)
49+
})
50+
51+
test("strips syntax highlight colours and the copy button from code blocks", () => {
52+
const root = markdownRoot(
53+
[
54+
'<div data-component="markdown-code" data-language="ts">',
55+
'<pre class="highlights"><code class="language-ts">',
56+
'<span style="color: #c678dd">const</span><span style="color: #e5c07b"> a</span>',
57+
"</code></pre>",
58+
'<div data-slot="markdown-copy-button"><button aria-label="Copy"><svg></svg></button></div>',
59+
"</div>",
60+
].join(""),
61+
)
62+
63+
expect(markdownClipboardHtml(root, rangeOver(root))).toBe(
64+
"<div><pre><code><span>const</span><span> a</span></code></pre></div>",
65+
)
66+
})
67+
68+
test("wraps a partial selection in its semantic ancestors", () => {
69+
const root = markdownRoot(
70+
'<ul><li>item <strong class="x">very bold</strong></li></ul><pre><code><span style="color: red">let x</span></code></pre>',
71+
)
72+
73+
const bold = root.querySelector("strong")!.firstChild!
74+
const token = root.querySelector("pre span")!.firstChild!
75+
const boldRange = document.createRange()
76+
boldRange.setStart(bold, 5)
77+
boldRange.setEnd(bold, 9)
78+
const codeRange = document.createRange()
79+
codeRange.setStart(token, 0)
80+
codeRange.setEnd(token, 3)
81+
82+
expect(markdownClipboardHtml(root, boldRange)).toBe("<ul><li><strong>bold</strong></li></ul>")
83+
expect(markdownClipboardHtml(root, codeRange)).toBe("<pre><code>let</code></pre>")
84+
})
85+
86+
test("keeps web links and drops local paths, scripts, and decorative icons", () => {
87+
const root = markdownRoot(
88+
[
89+
'<p><a class="external-link" target="_blank" href="https://opencode.ai/docs">',
90+
'<span class="markdown-link-favicon" aria-hidden="true"><img src="https://example.com/icon.png"></span>docs</a> ',
91+
'<a href="javascript:alert(1)">bad</a> <a href="mailto:[email protected]">mail</a> ',
92+
'<a data-local-link="src/app.ts" href="src/app.ts">app</a> ',
93+
'<img src="https://example.com/chart.png" alt="chart" width="20"> <img src="blob:local" alt="local">',
94+
'<span class="sr-only">hidden</span></p>',
95+
].join(""),
96+
)
97+
98+
expect(markdownClipboardHtml(root, rangeOver(root))).toBe(
99+
[
100+
'<p><a href="https://opencode.ai/docs">docs</a> ',
101+
'<a>bad</a> <a href="mailto:[email protected]">mail</a> ',
102+
"<a>app</a> ",
103+
'<img src="https://example.com/chart.png" alt="chart"> <img alt="local">',
104+
"</p>",
105+
].join(""),
106+
)
107+
})
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Chromium's default copy inlines computed styles, so text copied from a dark theme pastes as light text on the
2+
// destination's white page. Copied HTML keeps only semantic markup and lets the destination style it.
3+
4+
const removedSelector = [
5+
"button",
6+
"input",
7+
"textarea",
8+
"select",
9+
"script",
10+
"style",
11+
"template",
12+
"svg",
13+
'[aria-hidden="true"]',
14+
".sr-only",
15+
'[data-slot="markdown-copy-button"]',
16+
].join(", ")
17+
18+
// Ancestors that give a partial selection its meaning, such as a phrase inside bold text or a line inside a code block.
19+
const semanticTags = new Set([
20+
"A",
21+
"B",
22+
"BLOCKQUOTE",
23+
"CODE",
24+
"DEL",
25+
"EM",
26+
"H1",
27+
"H2",
28+
"H3",
29+
"H4",
30+
"H5",
31+
"H6",
32+
"I",
33+
"KBD",
34+
"LI",
35+
"OL",
36+
"P",
37+
"PRE",
38+
"S",
39+
"STRONG",
40+
"TABLE",
41+
"TBODY",
42+
"TD",
43+
"TH",
44+
"THEAD",
45+
"TR",
46+
"UL",
47+
])
48+
49+
const keptAttributes = new Set(["alt", "colspan", "rowspan", "start"])
50+
51+
const linkProtocols = new Set(["http:", "https:", "mailto:"])
52+
53+
const webProtocols = new Set(["http:", "https:"])
54+
55+
function sanitizeAttributes(element: Element) {
56+
for (const attribute of Array.from(element.attributes)) {
57+
if (keptAttributes.has(attribute.name)) continue
58+
59+
if (attribute.name === "href" && element.tagName === "A" && hasProtocol(attribute.value, linkProtocols)) continue
60+
61+
if (attribute.name === "src" && element.tagName === "IMG" && hasProtocol(attribute.value, webProtocols)) continue
62+
element.removeAttribute(attribute.name)
63+
}
64+
}
65+
66+
function hasProtocol(value: string, protocols: Set<string>) {
67+
return URL.canParse(value) && protocols.has(new URL(value).protocol)
68+
}
69+
70+
export function markdownClipboardHtml(root: Element, range: Range) {
71+
const ancestor = range.commonAncestorContainer
72+
let content: Node = range.cloneContents()
73+
74+
for (
75+
let element = ancestor instanceof Element ? ancestor : ancestor.parentElement;
76+
element && element !== root && root.contains(element);
77+
element = element.parentElement
78+
) {
79+
if (!semanticTags.has(element.tagName)) continue
80+
const wrapper = element.cloneNode(false)
81+
wrapper.appendChild(content)
82+
content = wrapper
83+
}
84+
85+
const container = document.createElement("div")
86+
container.appendChild(content)
87+
container.querySelectorAll(removedSelector).forEach((element) => element.remove())
88+
container.querySelectorAll("*").forEach(sanitizeAttributes)
89+
90+
return container.innerHTML
91+
}

‎packages/session-ui/src/components/markdown.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
import { createMarkdownImages } from "./markdown-image"
4848
import { createImagePreview } from "./image-preview"
4949
import { markSessionLinks, setupSessionLinks } from "./markdown-session-links"
50+
import { markdownClipboardHtml } from "./markdown-clipboard"
5051

5152
type RenderedBlock =
5253
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -556,6 +557,22 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
556557
}
557558
}
558559

560+
// Selections that leave this message keep the browser's copy, since surrounding UI may hold hidden content.
561+
function copySelection(event: ClipboardEvent & { currentTarget: HTMLDivElement }) {
562+
const selection = window.getSelection()
563+
564+
if (event.defaultPrevented || !event.clipboardData || !selection || selection.isCollapsed) return
565+
const ranges = Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index))
566+
567+
if (!ranges.every((range) => event.currentTarget.contains(range.commonAncestorContainer))) return
568+
event.preventDefault()
569+
event.clipboardData.setData("text/plain", selection.toString())
570+
event.clipboardData.setData(
571+
"text/html",
572+
`<meta charset="utf-8">${ranges.map((range) => markdownClipboardHtml(event.currentTarget, range)).join("")}`,
573+
)
574+
}
575+
559576
function initialResult(
560577
text: string,
561578
key: string | undefined,
@@ -929,6 +946,7 @@ export function Markdown(
929946
[local.class ?? ""]: !!local.class,
930947
}}
931948
ref={setRoot}
949+
onCopy={copySelection}
932950
{...others}
933951
/>
934952
)

0 commit comments

Comments
 (0)