Skip to content

feat(copy): copying from the preview keeps its formatting (#674) - #680

Merged
PathGao merged 1 commit into
masterfrom
feat/674-rich-text-copy
Aug 18, 2026
Merged

feat(copy): copying from the preview keeps its formatting (#674)#680
PathGao merged 1 commit into
masterfrom
feat/674-rich-text-copy

Conversation

@PathGao

@PathGao PathGao commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

What this is

Closes #674, reported by @yvesll: selecting in the preview and copying put only plain text on the clipboard, so a paste into Word or Mail lost every heading, bold run and link.

⌘C in the preview now writes text/html beside text/plain. A pasted selection keeps its headings, emphasis, lists, tables and links, resolved by the receiving application's own styles.

This is the product question #549 deliberately left open — it fixed which code writes the clipboard, and left "plain or rich" for later, with a test forbidding a rich flavour until someone decided. Decided: a preview is rendered content, and copying rendered content is expected to keep its formatting. It is what a browser does, and what the reporter cited VS Code's own Markdown preview for.

Mechanism

Writing the flavour ourselves is what preserves #549's result. The native copy is still cancelled before WebKit's Editor::copy reaches its subresource sweep, so the 19 MB WebArchive that measurement found is still never built; what lands on the clipboard is two strings this handler produced.

Three things in the preview's DOM are not structure and had to be resolved before the fragment leaves the app, because the clipboard carries no stylesheet:

  • KaTeX renders every formula twice — MathML for screen readers, positioned spans for the eye — and hides one in CSS. Pasted somewhere without that CSS, both are visible and the formula reads twice. The visual half stays.
  • The fold chevrons are controls. @media print already removes them for the same reason.
  • An image's src is an asset:// URL (http://asset.localhost on Windows), a scheme that exists only inside this app. It becomes a file: URL, via the same resolveExportImagePath the HTML export uses, so document-relative and absolute sources resolve identically in both.

The find that changed the shape of this PR: katex/contrib/copy-tex was installing a second copy listener. It fires only when the selection contains math, rewrites both flavours from a fragment of its own, and — being registered later, since it is a lazy import — runs last and wins. Traced through setData during a real copy event, with the first version of this change in place:

text/plain  <- handleCopy                    (54 chars)
text/html   <- handleCopy                    (545 chars, pruned)
text/html   <- katex copy-tex.js:89          (898 chars, unpruned)
text/plain  <- katex copy-tex.js:90          (50 chars)

So every pruning above would have been silently undone for any selection containing a formula, and its text/html — which has been reaching the clipboard all along, unnoticed, since only math selections trigger it — is the doubled-formula one. The extension is no longer loaded; its two behaviours worth keeping live in utils/previewCopy.ts: a selection that starts or ends inside a formula expands to the whole formula, and the plain text of a formula is its TeX source rather than the glyph soup ($E=mc^2$, not E=mc2).

Measured end to end, dispatching a copy over a rendered document and reading the flavours back:

before after
flavours written text/plain (text/html too, but only when the selection had math) text/plain, text/html
<strong>, <h2> in the HTML preserved
KaTeX MathML duplicate present removed
fold chevrons present removed
image src asset://localhost/… file:///Users/me/notes/img/shot.png
formula in the plain text $E=mc^2$ $E=mc^2$

Scope

file: rather than a data URI. Embedding the bytes means reading the file, which is a Tauri round-trip, and a copy handler has to write synchronously — that needs navigator.clipboard.write with a ClipboardItem promise, a different clipboard API with its own permission and user-gesture behaviour in each webview. file: costs nothing and works in the desktop applications this issue names; it will not work when pasting into a browser-based editor like Google Docs, which cannot open local files, and it will not survive being pasted on another machine. Worth doing properly if someone asks — as its own change.

Syntax highlighting does not survive either: highlight.js colours through CSS classes, and inlining those colours is the same "serialize a fragment as a standalone document" work. Code blocks keep their structure and monospace, not their palette.

Mermaid diagrams are left exactly as they render, inline SVG. What a receiving app does with that varies and is not something this change can decide.

The plain text of a selection containing math is built from the fragment's text content, which loses the blank lines between blocks. That is inherited exactly — it is what copy-tex did, so no selection copies differently than it did before — and it is why the no-math path still reads Selection.toString(), which is the only source that knows what is rendered.

Not touched: the editor's copy, which is Monaco's own path on Rust (#548), and Edit ▸ Copy in the menu bar, which lands in this same handler and therefore gains the same behaviour.

Tests

scripts/previewCopyHtml.spec.ts (vitest, needs a DOM) runs the real functions over fragments and selections: structure preserved, formula carried once, chevrons dropped, asset:// and http://asset.localhost and document-relative sources all becoming the same file: URL, spaces and # surviving as percent-escapes, remote and data: sources untouched, TeX as the plain text for inline and display formulas.

scripts/previewCopyPlainText.test.ts is renamed previewCopy.test.ts and its contract updated — the assertion that no rich flavour may exist was #549's placeholder for this decision, and it is replaced by the two that matter now: the HTML is the one this handler built, and nothing else in src installs a copy listener. That last one is what keeps copy-tex from being reintroduced and quietly restoring the fight.

Revert the flavour write and keep the tests: the spec goes red on every assertion about HTML, and the handler tests go red on the flavour and single-decision assertions.

Verification

npm audit             0 vulnerabilities
npm run check         804 files, 0 errors, 0 warnings
npm test              931 pass, 0 fail
npm run test:vitest   42 files, 376 pass
cargo test            148 pass

The table above was measured in Chromium against the dev server, with window.__TAURI_INTERNALS__ stubbed so the frontend boots outside Tauri and render_markdown returns a fixture — including real KaTeX output, since the annotation the TeX substitution reads only exists after KaTeX renders in the DOM.

What that does not cover: what any receiving application actually does with the HTML. Nobody has pasted this into Word, Mail or Google Docs — the flavours on the clipboard are verified, their interpretation is not. Nor is WKWebView or WebView2 behaviour: the handler is plain DOM and the same code runs everywhere, but the measurements are Chromium's.

⌘C in the preview now writes text/html beside the plain text, so a paste
into Word, Mail or Docs arrives with its headings, emphasis, lists,
tables and links — what #549 left as an open product question.

The fragment is pruned first: KaTeX's hidden MathML half would otherwise
paste the formula twice, the fold chevrons are controls rather than
content, and an image's asset:// src is a scheme only this app can
resolve, so it becomes a file: URL.

katex/contrib/copy-tex is no longer loaded. It installed a second copy
listener that fired only for selections containing math and rewrote both
flavours from its own fragment, undoing all of the above for exactly
those selections. Its two worthwhile behaviours — expanding a selection
to whole formulas, and TeX as the plain text — are implemented here, on
the one copy path.
@PathGao
PathGao force-pushed the feat/674-rich-text-copy branch from 986a12e to 623bc6e Compare August 18, 2026 15:08
@PathGao
PathGao merged commit ac1cece into master Aug 18, 2026
4 checks passed
@PathGao
PathGao deleted the feat/674-rich-text-copy branch August 18, 2026 15:26
PathGao added a commit that referenced this pull request Aug 25, 2026
Four things shipped since 2.7.4 that the file which documents what Markpad can
do never heard about. It carries the editing behaviour around each construct,
not only the spellings, so each one belongs to a section that already exists.

- Lists: `Tab` moved a line by tabSize and left the marker alone. #713 makes it
  a level change -- the parent's content column, and both numbered lists
  renumbered -- which is what the file already claimed and now describes
  accurately.
- Quotes: `Enter` continues a block quote (#705), so the section gets the
  paragraph Lists has had. Including that one keystroke clears an empty quoted
  line at any depth, which is the way out.
- Images: where a pasted or dropped image lands, and `${filename}` in that
  setting (#716). It expands to a folder name, not a path, and the note says so
  -- `./images/${filename}/` is not a thing you can write here.
- Not-syntax: copying from the preview keeps its formatting (#680), and the
  split panes can trade sides (#693).

Tests: 984 pass.
alecdotdev pushed a commit that referenced this pull request Aug 25, 2026
…own again (#719)

* chore: bump version to 2.7.5

* docs(syntax): bring the reference up to 2.7.5, in both languages

Four things shipped since 2.7.4 that the file which documents what Markpad can
do never heard about. It carries the editing behaviour around each construct,
not only the spellings, so each one belongs to a section that already exists.

- Lists: `Tab` moved a line by tabSize and left the marker alone. #713 makes it
  a level change -- the parent's content column, and both numbered lists
  renumbered -- which is what the file already claimed and now describes
  accurately.
- Quotes: `Enter` continues a block quote (#705), so the section gets the
  paragraph Lists has had. Including that one keystroke clears an empty quoted
  line at any depth, which is the way out.
- Images: where a pasted or dropped image lands, and `${filename}` in that
  setting (#716). It expands to a folder name, not a path, and the note says so
  -- `./images/${filename}/` is not a thing you can write here.
- Not-syntax: copying from the preview keeps its formatting (#680), and the
  split panes can trade sides (#693).

Tests: 984 pass.

* docs(release): the download table's two warnings describe 2.7.5, not 2.7.4

The table is composed in `build.yml` and printed on every release page, so both
notes ship with whatever master holds when the workflow is dispatched. Both are
about to be wrong.

macOS: the last paragraph told users the app grants file access per prompt and
that self-signing in Keychain Access is the way out, redone after every update.
#707 is what that paragraph asks for, so it would print the workaround on the
first release that no longer needs it. Replaced with what is now true, worded so
it stays true for 2.7.6: the grant survives an update, and only a user coming
from a release older than 2.7.5 is asked once more, because the signature
changes the identity the old grants belonged to. The Gatekeeper paragraph above
it is untouched -- signing is not notarization, and the first-launch dialog is
unaffected.

Windows: the "false positive Trojan" half is the stale one -- VirusTotal no
longer flags the portable `.exe`, which is what #334 and #466 were. The
SmartScreen half is not: it fires on an unsigned binary regardless of what any
scanner says, and stays true until an Authenticode certificate exists (#562).
Dropping the whole note would leave the release page silent about a dialog every
Windows user still meets. So the antivirus claim goes and the unrecognized-app
one stays, in one shorter sentence.

Tests: 984 pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support copying rendered/formatted text (rich text)

1 participant