fix(session-ui): copy Markdown selections without theme colours (#59151)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-27 15:11:22 +08:00
committed by GitHub
Unverified
parent 54dcd34566
commit 6aedf36ca3
4 changed files with 276 additions and 0 deletions
@@ -983,3 +983,63 @@ story("loads file URLs and Windows paths, and leaves unreadable images as alt te
await harness.getByLabel("Markdown text").fill("Still usable")
await expect(harness.locator('[data-component="markdown"]')).toHaveText("Still usable")
})
async function copySelection(page: Page, select: "markdown" | "harness") {
await page.evaluate((select) => {
const harness = document.querySelector('[data-testid="markdown-fixture"]')!
const range = document.createRange()
range.selectNodeContents(select === "markdown" ? harness.querySelector('[data-component="markdown"]')! : harness)
getSelection()!.removeAllRanges()
getSelection()!.addRange(range)
document.addEventListener(
"copy",
(event) => {
document.body.dataset.copiedText = event.clipboardData?.getData("text/plain")
document.body.dataset.copiedHtml = event.clipboardData?.getData("text/html")
},
{ once: true },
)
}, select)
await page.keyboard.press("ControlOrMeta+C")
return page.evaluate(() => ({ text: document.body.dataset.copiedText, html: document.body.dataset.copiedHtml }))
}
story("copies selected Markdown as semantic HTML without theme or highlight colours", async ({ page }) => {
await page.evaluate(async (fixture) => {
document.body.style.color = "rgb(237, 237, 237)"
const { mountMarkdown } = await import(fixture)
await mountMarkdown({
text: "## Answer\n\nSome **bold** and [docs](https://opencode.ai/docs).\n\n- one\n- two\n\n```ts\nconst value = 42\n```",
})
}, fixture)
const harness = page.getByTestId("markdown-fixture")
await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(harness.locator("pre code span[style]").first()).toBeAttached()
const copied = await copySelection(page, "markdown")
expect(copied.text).toContain("Some bold and docs.")
expect(copied.text).toContain("const value = 42")
expect(copied.html).toContain("<h2>Answer</h2>")
expect(copied.html).toContain('<p>Some <strong>bold</strong> and <a href="https://opencode.ai/docs">docs</a>.</p>')
expect(copied.html).toContain("<li>one</li>")
expect(copied.html).toMatch(/<pre><code>(<span>[^<]*<\/span>)+<\/code><\/pre>/)
expect(copied.html).not.toMatch(/style=|class=|<button|<svg/)
})
story("keeps the browser copy for selections that leave the Markdown", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({ text: "Some **bold** text" })
}, fixture)
await expect(page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')).toHaveAttribute(
"data-markdown-ready",
"",
)
const copied = await copySelection(page, "harness")
expect(copied.html).toBe("")
expect(copied.text).toBe("")
})
@@ -0,0 +1,107 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator"
import { afterAll, beforeAll, expect, test } from "bun:test"
import { markdownClipboardHtml } from "./markdown-clipboard"
beforeAll(() => GlobalRegistrator.register())
afterAll(() => GlobalRegistrator.unregister())
function markdownRoot(html: string) {
const root = document.createElement("div")
root.setAttribute("data-component", "markdown")
root.innerHTML = html
document.body.replaceChildren(root)
return root
}
function rangeOver(node: Node) {
const range = document.createRange()
range.selectNodeContents(node)
return range
}
test("keeps semantic structure without colours, classes, or layout attributes", () => {
const root = markdownRoot(
[
'<div data-markdown-block="" style="display: contents">',
'<h2 class="heading" style="color: rgb(237, 237, 237)">Title</h2>',
'<p style="color: white">Some <strong>bold</strong>, <em>italic</em> and <code data-inline-code-kind="path">src/app.ts</code>.</p>',
'<ol start="3"><li>three</li><li>four</li></ol>',
'<table><thead><tr><th style="text-align: left">A</th></tr></thead><tbody><tr><td colspan="2">1</td></tr></tbody></table>',
"<blockquote><p>quoted</p></blockquote>",
"</div>",
].join(""),
)
expect(markdownClipboardHtml(root, rangeOver(root))).toBe(
[
"<div>",
"<h2>Title</h2>",
"<p>Some <strong>bold</strong>, <em>italic</em> and <code>src/app.ts</code>.</p>",
'<ol start="3"><li>three</li><li>four</li></ol>',
'<table><thead><tr><th>A</th></tr></thead><tbody><tr><td colspan="2">1</td></tr></tbody></table>',
"<blockquote><p>quoted</p></blockquote>",
"</div>",
].join(""),
)
})
test("strips syntax highlight colours and the copy button from code blocks", () => {
const root = markdownRoot(
[
'<div data-component="markdown-code" data-language="ts">',
'<pre class="highlights"><code class="language-ts">',
'<span style="color: #c678dd">const</span><span style="color: #e5c07b"> a</span>',
"</code></pre>",
'<div data-slot="markdown-copy-button"><button aria-label="Copy"><svg></svg></button></div>',
"</div>",
].join(""),
)
expect(markdownClipboardHtml(root, rangeOver(root))).toBe(
"<div><pre><code><span>const</span><span> a</span></code></pre></div>",
)
})
test("wraps a partial selection in its semantic ancestors", () => {
const root = markdownRoot(
'<ul><li>item <strong class="x">very bold</strong></li></ul><pre><code><span style="color: red">let x</span></code></pre>',
)
const bold = root.querySelector("strong")!.firstChild!
const token = root.querySelector("pre span")!.firstChild!
const boldRange = document.createRange()
boldRange.setStart(bold, 5)
boldRange.setEnd(bold, 9)
const codeRange = document.createRange()
codeRange.setStart(token, 0)
codeRange.setEnd(token, 3)
expect(markdownClipboardHtml(root, boldRange)).toBe("<ul><li><strong>bold</strong></li></ul>")
expect(markdownClipboardHtml(root, codeRange)).toBe("<pre><code>let</code></pre>")
})
test("keeps web links and drops local paths, scripts, and decorative icons", () => {
const root = markdownRoot(
[
'<p><a class="external-link" target="_blank" href="https://opencode.ai/docs">',
'<span class="markdown-link-favicon" aria-hidden="true"><img src="https://example.com/icon.png"></span>docs</a> ',
'<a href="javascript:alert(1)">bad</a> <a href="mailto:help@example.com">mail</a> ',
'<a data-local-link="src/app.ts" href="src/app.ts">app</a> ',
'<img src="https://example.com/chart.png" alt="chart" width="20"> <img src="blob:local" alt="local">',
'<span class="sr-only">hidden</span></p>',
].join(""),
)
expect(markdownClipboardHtml(root, rangeOver(root))).toBe(
[
'<p><a href="https://opencode.ai/docs">docs</a> ',
'<a>bad</a> <a href="mailto:help@example.com">mail</a> ',
"<a>app</a> ",
'<img src="https://example.com/chart.png" alt="chart"> <img alt="local">',
"</p>",
].join(""),
)
})
@@ -0,0 +1,91 @@
// Chromium's default copy inlines computed styles, so text copied from a dark theme pastes as light text on the
// destination's white page. Copied HTML keeps only semantic markup and lets the destination style it.
const removedSelector = [
"button",
"input",
"textarea",
"select",
"script",
"style",
"template",
"svg",
'[aria-hidden="true"]',
".sr-only",
'[data-slot="markdown-copy-button"]',
].join(", ")
// Ancestors that give a partial selection its meaning, such as a phrase inside bold text or a line inside a code block.
const semanticTags = new Set([
"A",
"B",
"BLOCKQUOTE",
"CODE",
"DEL",
"EM",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"I",
"KBD",
"LI",
"OL",
"P",
"PRE",
"S",
"STRONG",
"TABLE",
"TBODY",
"TD",
"TH",
"THEAD",
"TR",
"UL",
])
const keptAttributes = new Set(["alt", "colspan", "rowspan", "start"])
const linkProtocols = new Set(["http:", "https:", "mailto:"])
const webProtocols = new Set(["http:", "https:"])
function sanitizeAttributes(element: Element) {
for (const attribute of Array.from(element.attributes)) {
if (keptAttributes.has(attribute.name)) continue
if (attribute.name === "href" && element.tagName === "A" && hasProtocol(attribute.value, linkProtocols)) continue
if (attribute.name === "src" && element.tagName === "IMG" && hasProtocol(attribute.value, webProtocols)) continue
element.removeAttribute(attribute.name)
}
}
function hasProtocol(value: string, protocols: Set<string>) {
return URL.canParse(value) && protocols.has(new URL(value).protocol)
}
export function markdownClipboardHtml(root: Element, range: Range) {
const ancestor = range.commonAncestorContainer
let content: Node = range.cloneContents()
for (
let element = ancestor instanceof Element ? ancestor : ancestor.parentElement;
element && element !== root && root.contains(element);
element = element.parentElement
) {
if (!semanticTags.has(element.tagName)) continue
const wrapper = element.cloneNode(false)
wrapper.appendChild(content)
content = wrapper
}
const container = document.createElement("div")
container.appendChild(content)
container.querySelectorAll(removedSelector).forEach((element) => element.remove())
container.querySelectorAll("*").forEach(sanitizeAttributes)
return container.innerHTML
}
@@ -47,6 +47,7 @@ import {
import { createMarkdownImages } from "./markdown-image"
import { createImagePreview } from "./image-preview"
import { markSessionLinks, setupSessionLinks } from "./markdown-session-links"
import { markdownClipboardHtml } from "./markdown-clipboard"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -556,6 +557,22 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
}
}
// Selections that leave this message keep the browser's copy, since surrounding UI may hold hidden content.
function copySelection(event: ClipboardEvent & { currentTarget: HTMLDivElement }) {
const selection = window.getSelection()
if (event.defaultPrevented || !event.clipboardData || !selection || selection.isCollapsed) return
const ranges = Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index))
if (!ranges.every((range) => event.currentTarget.contains(range.commonAncestorContainer))) return
event.preventDefault()
event.clipboardData.setData("text/plain", selection.toString())
event.clipboardData.setData(
"text/html",
`<meta charset="utf-8">${ranges.map((range) => markdownClipboardHtml(event.currentTarget, range)).join("")}`,
)
}
function initialResult(
text: string,
key: string | undefined,
@@ -929,6 +946,7 @@ export function Markdown(
[local.class ?? ""]: !!local.class,
}}
ref={setRoot}
onCopy={copySelection}
{...others}
/>
)