Skip to content

Clean clipboard: copy as rich text (RTF + webarchive), paste HTML as Markdown#90

Merged
luca-chen198 merged 20 commits into
mainfrom
feature/clean-copy-webarchive
Jul 11, 2026
Merged

Clean clipboard: copy as rich text (RTF + webarchive), paste HTML as Markdown#90
luca-chen198 merged 20 commits into
mainfrom
feature/clean-copy-webarchive

Conversation

@luca-chen198

Copy link
Copy Markdown
Member

Makes the editor's clipboard round-trip faithful in both directions. Previously copy serialized the in-place-styled raw-markdown storage (leaked syntax markers, first line raw, --- produced nothing in RTF) and paste dropped all rich structure to plain text.

Copy (outgoing) — 33add33, 505fac5

The editor storage is raw markdown styled in place, so it isn't a real rich document. On copy we now render the selected markdown to a clean representation and put several flavors on the pasteboard:

  • MarkdownHTMLRenderer walks DocumentAST.parse → clean HTML (----<hr>, headings, bold/italic, inline+fenced code, links, ul/ol/task lists, blockquote with > stripped, GFM tables, all escaped).
  • MarkdownPasteboardWriter builds an NSAttributedString from that HTML and writes raw markdown (.string), RTF, com.apple.webarchive, and HTML.
  • NativeTextView.copy(_:) override: empty selection falls back to super; otherwise the selected raw markdown goes to the writer.

Result: rich targets (Notes / Mail / TextEdit-rich / Pages) get a faithful rendering — no raw first line, ---- becomes a real rule — while plain-text targets still get the raw markdown.

Paste (incoming) — 1262592, 84f65c0

Obsidian-style smart paste. Pasting rich content (Claude, browser, Word, Notion) now lands as Markdown:

  • HTMLToMarkdownConverter (lenient tag scanner + nesting stack): <ul><li>- , <ol>1./2., nested lists indented, GFM checkboxes, headings, bold/italic, links, inline+fenced code, blockquote, <hr>----; unknown/style tags unwrapped; non-HTML input returns nil.
  • Wired into paste(_:): HTML with structure converts to markdown (through the existing sanitizePastedText + blockquote-continuation path); otherwise falls through to the unchanged plain-text handling. Image-embed and file-URL branches keep priority.
  • ⌘⇧V (Paste and Match Style) stays raw — it uses NSTextView's default pasteAsPlainText: and never routes through smart paste.

Tests

30 new tests (17 renderer + 13 converter), all exact-output; swift test = 195 tests / 29 suites green. Both directions were also exercised directly (pasteboard flavors on copy; a real Claude <ul> snippet → - bullets on paste).

Notes / scope

  • v1 simplifications: LaTeX rendered as raw $…$, wiki-links as their name, embedded images as <img src=localTarget> (may not survive cross-app paste). Follow-ups if wanted: syntax colors in copied code, richer image/LaTeX embedding.
  • The old htmlTableToMarkdown helper is now unused (tables go through the general converter); left in place as internal, harmless.

🤖 Generated with Claude Code

luca-chen198 and others added 20 commits July 9, 2026 17:29
Walk DocumentAST and emit a clean HTML fragment for the editor's rich-copy
path, so selected raw markdown copies as proper rich text instead of the
in-place-styled NSTextStorage junk (leaked syntax markers, raw caret line,
`---` producing nothing). Test-first: 17 tests pin the output for headings,
emphasis, code, links, lists, task lists, blockquotes, thematic breaks (the
headline bug → <hr>), GFM tables, and HTML escaping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wn as plain text

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…), keep ⌘⇧V raw

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…breaks, text escaping, li blocks, href

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on real structure, drop dead table code

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align task-list content with bullet content (GitHub/Obsidian): the `[ ] `
chars collapse to the hidden-marker font so the hanging indent measures only
`- ` and the box glyph is drawn in the bullet slot, right-aligned to the
content. Shared TaskCheckboxGeometry keeps the fragment draw and click
hit-test in sync; extend renderingSurfaceBounds so the box (left of the first
glyph) isn't clipped; size from baseFont not NSTextView.font (whose getter
returns the first char's font — 1px boxes in heading-first docs).

Cursor: show the arrow over a checkbox box (skip super so it doesn't fight the
I-beam and flicker), and drop the isEditable gate on the exclusion zone so a
full-window overlay (search / transfer dialogs) fully owns the cursor instead
of the suppressed read-only editor re-asserting one beneath it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… prose

Chromium serializes a within-list selection as naked <li> elements without
the ul/ol wrapper (Claude/ChatGPT copies), so the structure guard never fired
and formatted lists pasted as plain text. Bare <li> now counts as structure
and consecutive top-level items render as one tight bullet list (the ol
numbering is lost with the wrapper — Obsidian parity).

The guard also accepts formatted prose: two or more real <p> paragraphs with
inline formatting convert, while a lone styled word/sentence and VS Code's
div/span code stay on the plain-text path. Tag matching now checks a real
boundary so prefixes can't false-match (<p vs <pre, <b vs <br>).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collapse the one-liner guard tests into two table-driven tests and merge the
converter's granular fix tests into grouped ones (30 fewer test cases, ~150
fewer lines, every input still asserted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ckbox stand-ins

Both rich flavors were derived from NSAttributedString(html:), whose importer
silently drops <hr> and <input> — so the horizontal rule vanished from every
rich paste target (the original review complaint) and task checkboxes decayed
to naked bullets. The web archive now wraps our rendered html verbatim, and
the RTF path substitutes visible stand-ins (─ rule, ☐/☑ glyphs) before
converting. Blockquotes keep the plain rich-text convention (indentation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e RTF rule stand-in

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t unit

The converter emitted two spaces per nesting level (GitHub source style).
The editor renders indentation from the literal whitespace advance and its
Tab key inserts \t (= one full 27.5pt indent step), so pasted sublists sat
only ~7pt right of their parents. One tab per level makes a pasted nested
list byte-identical to a typed one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on paste

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e anchor and full-document HTML paste; task items copy as plain bullets

- Tests condensed 541→~360 added lines (25 wrappers merged/deleted; every
  user-validated behavior keeps exactly one pin). Dead CheckboxStyle config,
  an unreachable li case, and all temporary paste/table diagnostics removed;
  oversized comments shortened.
- Fix: an edit touching any row of a table now restyles the WHOLE block —
  the image anchor sits on the block's first paragraph, and a scoped restyle
  clipped it away (pasted-adjacent tables stayed blank until a file switch).
- Fix: full-document clipboard HTML (<html><head><body> wrappers, e.g. GPT)
  fell into the inline unwrap and glued all block text into one run; the
  wrappers are now transparent containers and head/style/script never leak.
- Task items copy as plain list items in the rich flavors (user's call) —
  drops the checkbox-glyph rewrite machinery from the RTF path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… rich targets

Dropping the checkbox from ALL flavors lost the task state when pasting into
markdown apps (Obsidian converts the html flavor back to markdown). Split by
audience: the .html flavor keeps <input type=checkbox> so - [ ]/- [x] survive
into markdown targets; web archive and RTF strip it to the plain bullet the
user prefers in rich targets. In-app paste was never affected (private raw
flavor wins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chat UIs (Claude) render task lists as literal "[ ] text" in plain
<li>s; markdown escaping turned that into "\[ \] " which never
re-renders as a checkbox. After an unordered item's head is final,
reclaim the escaped prefix as a real "- [ ]" / "- [x]" marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@luca-chen198 luca-chen198 merged commit f90a801 into main Jul 11, 2026
1 check passed
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.

1 participant