feat(copy): copying from the preview keeps its formatting (#674) - #680
Merged
Conversation
⌘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
force-pushed
the
feat/674-rich-text-copy
branch
from
August 18, 2026 15:08
986a12e to
623bc6e
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/htmlbesidetext/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::copyreaches 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:
@media printalready removes them for the same reason.srcis anasset://URL (http://asset.localhoston Windows), a scheme that exists only inside this app. It becomes afile:URL, via the sameresolveExportImagePaththe 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-texwas installing a secondcopylistener. 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 throughsetDataduring a real copy event, with the first version of this change in place: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 inutils/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$, notE=mc2).Measured end to end, dispatching a copy over a rendered document and reading the flavours back:
text/plain(text/htmltoo, but only when the selection had math)text/plain,text/html<strong>,<h2>in the HTMLasset://localhost/…file:///Users/me/notes/img/shot.png$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 acopyhandler has to write synchronously — that needsnavigator.clipboard.writewith aClipboardItempromise, 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-texdid, so no selection copies differently than it did before — and it is why the no-math path still readsSelection.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://andhttp://asset.localhostand document-relative sources all becoming the samefile:URL, spaces and#surviving as percent-escapes, remote anddata:sources untouched, TeX as the plain text for inline and display formulas.scripts/previewCopyPlainText.test.tsis renamedpreviewCopy.test.tsand 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 insrcinstalls acopylistener. That last one is what keepscopy-texfrom 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
The table above was measured in Chromium against the dev server, with
window.__TAURI_INTERNALS__stubbed so the frontend boots outside Tauri andrender_markdownreturns 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.