diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index c1c1ab40609..8c120b3ad77 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -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("

Answer

") + expect(copied.html).toContain('

Some bold and docs.

') + expect(copied.html).toContain("
  • one
  • ") + expect(copied.html).toMatch(/
    ([^<]*<\/span>)+<\/code><\/pre>/)
    +  expect(copied.html).not.toMatch(/style=|class=| {
    +  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("")
    +})
    diff --git a/packages/session-ui/src/components/markdown-clipboard.test.ts b/packages/session-ui/src/components/markdown-clipboard.test.ts
    new file mode 100644
    index 00000000000..31d10ec63c0
    --- /dev/null
    +++ b/packages/session-ui/src/components/markdown-clipboard.test.ts
    @@ -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(
    +    [
    +      '
    ', + '

    Title

    ', + '

    Some bold, italic and src/app.ts.

    ', + '
    1. three
    2. four
    ', + '
    A
    1
    ', + "

    quoted

    ", + "
    ", + ].join(""), + ) + + expect(markdownClipboardHtml(root, rangeOver(root))).toBe( + [ + "
    ", + "

    Title

    ", + "

    Some bold, italic and src/app.ts.

    ", + '
    1. three
    2. four
    ', + '
    A
    1
    ', + "

    quoted

    ", + "
    ", + ].join(""), + ) +}) + +test("strips syntax highlight colours and the copy button from code blocks", () => { + const root = markdownRoot( + [ + '
    ', + '
    ',
    +      'const a',
    +      "
    ", + '
    ', + "
    ", + ].join(""), + ) + + expect(markdownClipboardHtml(root, rangeOver(root))).toBe( + "
    const a
    ", + ) +}) + +test("wraps a partial selection in its semantic ancestors", () => { + const root = markdownRoot( + '
    • item very bold
    let x
    ', + ) + + 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("
    • bold
    ") + expect(markdownClipboardHtml(root, codeRange)).toBe("
    let
    ") +}) + +test("keeps web links and drops local paths, scripts, and decorative icons", () => { + const root = markdownRoot( + [ + '

    ', + 'docs ', + 'bad mail ', + 'app ', + 'chart local', + 'hidden

    ', + ].join(""), + ) + + expect(markdownClipboardHtml(root, rangeOver(root))).toBe( + [ + '

    docs ', + 'bad mail ', + "app ", + 'chart local', + "

    ", + ].join(""), + ) +}) diff --git a/packages/session-ui/src/components/markdown-clipboard.ts b/packages/session-ui/src/components/markdown-clipboard.ts new file mode 100644 index 00000000000..b0cd457d707 --- /dev/null +++ b/packages/session-ui/src/components/markdown-clipboard.ts @@ -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) { + 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 +} diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index ee0aea67423..af2010a83a8 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -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 }) @@ -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", + `${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} /> )