Skip to content

feat: Copy as Rich Text — the rendered document, ready to paste - #44

Merged
seamoss merged 2 commits into
seamoss:mainfrom
productengineered:feat/copy-rich-text
Sep 29, 2026
Merged

seamoss merged 2 commits into
seamoss:mainfrom
productengineered:feat/copy-rich-text

Conversation

@productengineered

Copy link
Copy Markdown
Contributor

Export writes a file that carries its own stylesheet. A paste carries nothing — every destination throws away <style> blocks and class names — so the formatting has to ride on the elements. ⌥⌘C (File menu, next to Export; also ⌘K) puts the document on the clipboard as HTML with the styles inlined, and the markdown itself as the text/plain flavor.

What travels, and what deliberately doesn't

A paste target already knows what a heading, a list, a link, and bold text are, and it will style them like the rest of its own document — which is what anyone pasting wants, so those carry nothing (headings keep the Fraunces stack and no size). What it cannot infer is inlined: table hairlines and header fill, columns proportioned the way a drag-to-resize left them (<colgroup> from columnDisplayWidth, the same function the preview sizes with), the fence's tinted mono, the quote's fill, the rule, and Shiki's token colors resolved through their palette indirection to literals.

Every value comes from tokens.css + themes/ledger.css — parsed, var()s resolved (balanced-paren scan, nested references followed), never a hardcoded hex. Always the light palette, for the reason print gives in export.css: paper is paper even from a dark-mode machine, and a paste lands on someone else's white page.

Three things a destination would otherwise lose:

  • Task checkboxes — <input type="checkbox" disabled> is inert markup most destinations drop, taking the done/not-done with it. It becomes ☐ / ☑, which is text and survives anything.
  • Images — a relative src means nothing once pasted. Local images travel as data URIs (Docs and most CMSes accept and upload them) under an 8 MB budget, falling back to absolute file: URLs past it.
  • Class names — stripped. They mean nothing without our stylesheet, and shiki or task-list-item landing in a CMS that styles those names would mean something wrong. Ids stay; footnote links are built on them.

Why it works on the rendered HTML rather than the tree: the highlighter's markup — the spans this has to recolor — exists only in the output string. A rehype plugin registered after rehypeShikiFromHighlighter still sees a bare <pre><code> (verified both ways, lazy on and off). So src/shared/markdown.ts is untouched, and the golden fixtures with it. The markup being rewritten is our own, from one generator, and the tag scan is quote-aware so a > in alt text can't end a tag early.

Tests: pnpm test 337 (28 new) / pnpm typecheck clean. Note the unit tests inject the palette read from the sheets on disk, because vitest stubs CSS imports away — a ?raw import arrives as '' there. That path is covered instead by a live check in an isolated instance: drive the palette entry, then read the clipboard's flavors back through the main process. 16 checks, all passing — both flavors present, the text flavor byte-identical to the source, no var( left anywhere (so the ?raw sheets did reach the packaged build), the colgroup proportioned 22.222/77.778 from the delimiter row, the fence recolored, checkboxes glyphed, the PNG embedded as a data URI. The driver saves the user's clipboard first and puts it back.

No package.json / version changes, per the usual.

productengineered and others added 2 commits September 9, 2026 22:05
Export writes a file that carries its own stylesheet. A paste carries
nothing: every destination throws away <style> blocks and class names, so
the formatting has to ride on the elements. ⌥⌘C (File menu, ⌘K) puts the
document on the clipboard as HTML with the styles inlined, and the
markdown itself as the plain-text flavor.

What travels is deliberately narrow — the things a paste target cannot
infer. It already knows what a heading, a list, a link, and bold text
are, and it will style them like the rest of its own document, which is
what anyone pasting wants. It does not know that a table has hairline
borders and columns proportioned the way a drag left them, that a fence
is a tinted block of mono, that a quote is filled, or what color a
keyword is. Those are inlined, from the same tokens the app styles with,
resolved through Shiki's indirection to literal colors. Always the light
palette, for the reason print gives: paper is paper even from a
dark-mode machine, and a paste lands on someone else's white page.

Three things a destination would otherwise lose: a disabled checkbox is
inert markup most of them drop, so it becomes ☐/☑, which is text and
survives anything; a relative image src means nothing once pasted, so
local images travel as data URIs under a budget and fall back to file
URLs past it; and class names are stripped, because `shiki` landing in a
CMS that styles that name would mean something wrong.

It works on the rendered HTML rather than the tree, because the
highlighter's markup exists only in the output string — a plugin added
after it still sees a bare <pre><code>. THE pipeline is untouched, and
the golden fixtures with it.
help.md: both the Copy as Rich Text bullet and main's updated Reveal in
Finder / path-menu bullet.
@seamoss
seamoss merged commit e9efaeb into seamoss:main Sep 29, 2026
1 check passed
seamoss added a commit that referenced this pull request Sep 29, 2026
⌥⌘C puts the rendered document on the clipboard with its styles inlined
and the markdown as the plain-text flavor — a rewrite of the one
pipeline's output, never a second parser; each tab now remembers whether
it was in preview or the editor, with no source flash on the way in;
and foolscap://open?path=… lets other apps open a document, guarded to
absolute paths of existing markdown files, with a cold-launch fix so a
linked file stays the active tab after the session restores
(#44–#46, Brandon Dohman @productengineered).
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.

2 participants