mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
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:
committed by
GitHub
Unverified
parent
54dcd34566
commit
6aedf36ca3
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user