Repository navigation
How the Rich Markdown Editor Works
In the Rich Editor view (mode preview), a Markdown file is shown as a rich text editor: you type in the rendered page, like Typora. This page explains how edits reach the file without reformatting it. The rest of the Markdown support is in How the Markdown Editor Works; the user side is Markdown Editor.
Many people like to edit the page as it looks. But a Markdown file is shared, and a rich editor that rewrote the whole file in its own style would turn every edit into a huge diff. So the rules are: text you did not touch keeps its exact spelling, and a file the editor cannot keep exactly is not edited at all.
The editor is TipTap 3 (on ProseMirror) with @tiptap/markdown, which reads Markdown with marked. Its safety comes from an MIT-licensed core ported into src/lib/markdown/rich/ with its tests. RichMarkdownView.svelte imports richEditor.ts on first use. The CodeMirror text editor stays the real file: it is hidden but alive, so save, undo, the unsaved dot and diffs keep working.
sequenceDiagram
participant U as User
participant TT as TipTap (ProseMirror)
participant RE as RichMarkdownEditor
participant RC as rich/ (ported core)
participant CM as Text editor (CodeMirror)
U->>TT: types in the page
TT->>RE: document changed
Note over RE: wait 300 ms for a pause (SYNC_DELAY_MS)
RE->>RC: commitRichMarkdownSerialization
RC->>RC: diff the editor's Markdown before and after, patch the diff onto the file's own text
RC->>RC: re-parse the patched text; it must give the same Markdown
RC-->>RE: the new body (else the editor's own Markdown)
RE->>CM: onEdit(from, to, insert): one small edit, userEvent "input.rich"
load splits off front matter (splitFrontMatter in richSync.ts; it is kept as written and never shown), then asks getMarkdownRichModeUnsupportedMessage (rich/markdown-rich-mode.ts):
- Raw HTML, JSX or MDX is allowed only when a full round trip (
rich/markdown-round-trip.ts: parse into a throwaway editor, write Markdown out) keeps every tag.rich/raw-markdown-html.tsfirst wraps HTML in placeholder tokens, so the editor shows it as a block it does not change. - Reference-style links and footnotes are refused: the editor cannot keep them.
- More than 600 KB is refused (
rich/markdown-rich-size-limit.ts).
A refused file is shown read-only with the reason, for example "This file has footnotes, which the rich editor cannot keep exactly. Edit it in the text editor."
After loading, normalizeEmptyListItems runs (so an empty list item does not split a paragraph), and the save starts from two copies: the body's own text and the editor's Markdown for it.
commitRichMarkdownSerialization (rich/rich-markdown-serialization-commit.ts):
- No real change (the editor's Markdown equals the copy from loading): the original text is kept, byte for byte.
- Otherwise
reconcileSerializedMarkdown(rich/rich-markdown-source-reconcile.ts) diffs the two Markdown versions with diff-match-patch and applies that diff to the file's own text, so untouched text keeps its list markers, emphasis style, blank lines, CRLF line endings and trailing newline. - The patched text is parsed again by a throwaway editor with the same schema (
rich/rich-markdown-reconcile-serializer.ts); it must give the same Markdown, or the editor's own Markdown is used instead. Files over 50 000 characters skip the patch. - When the patch fell back,
rich/rich-markdown-block-source.tsstill keeps whole untouched blocks.
sourceEdit (richSync.ts) turns the new text into one edit (the stretch between the common start and end, never splitting a surrogate pair), and RichMarkdownView hands it to the text editor.
RichMarkdownView watches docVersion. When the text changes outside the rich editor (Revert, a reload, the hidden text editor), setSource loads it again, without adding to TipTap's history. flush() saves a pending edit at once: before Cmd+S, before switching views, on blur and when the view unmounts.
The toolbar's onRichFormat(action) runs a TipTap command (RichMarkdownEditor.run); Link (Cmd+K) sets or removes a link. Task boxes are TipTap's checkboxes. A click on a link places the caret; Cmd+click follows it (classifyLink).
The editor on screen passes node views to the shared schema (createRichMarkdownExtensions); node views never change the Markdown:
-
Images use
classifyImage, like the preview: local files load throughread_image_data_urlonce near the screen; remote ones are not loaded ("Remote images are not loaded"). -
Code blocks with the language
mermaidshow the diagram under the code, drawn near the screen only, redrawn 400 ms after typing stops, and sanitized withsanitizeSvg.
| File | What it does |
|---|---|
ui/web/src/lib/components/MarkdownEditor.svelte |
Loading, read-only, Cmd+click links |
ui/web/src/lib/markdown/richEditor.ts, richNodeViews.ts
|
TipTap, saving, actions, node views |
ui/web/src/lib/markdown/rich/ |
The ported core (MIT, LICENSE-ORCA): schema, round trip, eligibility, reconcile, HTML placeholders |
ui/web/src/lib/markdown/richSync.ts |
Front matter split, sourceEdit
|
src/lib/views/files/RichMarkdownView.svelte |
Link prompt, links, keys |
src/lib/views/files/FileView.svelte |
applyRichEdit, richScroll, mode switches |
vite.config.js |
TipTap entry points in optimizeDeps
|
Patch the file, prove it, else fall back. A diff of the editor's own Markdown keeps every untouched byte; the re-parse proof means a misplaced patch can never be written.
Refuse over a wrong rewrite. A file whose round trip loses anything is shown, not edited.
A ported core, not our own. Its round trip and reconcile code comes with hundreds of tests from a product that edits real files every day. Left out: math, editable <details> blocks (kept as raw HTML here), wiki links, superscript links, review notes and code colors.
HTML blocks whole. A CommonMark HTML block that starts with a block tag (<table>, <div>...) runs to the next blank line; it is kept as one raw block, as marked reads it (our addition to the ported code).
Only the Rich Editor view is editable, so the two sides of the split never fight over the cursor.
-
ui/web/src/lib/markdown/rich/*.test.ts: the ported tests for the round trip, eligibility, reconcile, HTML placeholders, tables, lists and paragraphs. -
ui/web/src/lib/markdown/richEditor.test.ts(happy-dom): every page ofdocs/wiki,README.md,CHANGELOG.mdand messy samples (mixed markers,__bold__, setext headings, padded tables,~~~fences, CRLF, front matter, HTML, emoji). Each loads and saves with no change, and one typed character changes only that character; reference links and footnotes are refused; a ticked task box changes only[ ]. -
ui/web/src/lib/markdown/richSync.test.ts:sourceEditandsplitFrontMatter.
- Update this page when the sync delay, the refusal rules or the ported core change; keep
LICENSE-ORCAwith the ported files. - Retake
markdown-rich-editor.pngwhen the look changes.
Newer ones: Rich Markdown Bugs.
Editing an existing file did not work well.
- The issue: typing in Preview Only (now Rich Editor) on a file could reformat other text, turn the page read-only, or reload it after an edit.
- Why it happened: the old editor (Milkdown) wrote back block by block, which needed remark's block ranges and Milkdown's blocks to line up one to one; often they did not (raw HTML, lists, nested blocks).
- The fix and why we chose it: TipTap with the ported core. It decides per file whether editing is safe, patches each edit onto the file's own text and proves the result by a re-parse. Tested on every Markdown file here.
A README with an HTML table could not be edited.
- The issue: our README (a screenshot table in HTML) opened read-only.
-
Why it happened: HTML was wrapped line by line, so the table's indented
<td>lines were read as indented code and the round trip lost them. -
The fix and why we chose it:
raw-markdown-html.tskeeps a CommonMark block-tag HTML block whole, to the next blank line, as marked does.
Rich editor diagrams were not sanitized (Milkdown version, kept): the code block view now passes mermaid's SVG through sanitizeSvg, as the preview does.
These pages are generated from docs/wiki. To correct something, open a pull request against the source file; edits made here are overwritten.
Using Nibtree
- Getting Started
- Welcome Screen
- Workspaces
- Files Panel
- File Operations
- File Icons
- Rounded Panels
- Editor and Tabs
- Pin Reorder and Wrap Tabs
- Single Tab Title
- Split Editor
- New File and Unsaved Changes
- Unload Hidden Tabs
- Editing Code
- Code Appearance
- Markdown Editor
- Image and PDF Preview
- Search Everywhere
- Recent Files
- Navigation Bar
- Find and Replace
- Changes and Commits
- Sign In Prompts
- Commit Box Layout
- Repository Actions
- Commit Options
- Ignoring Files
- Diffs
- Inline Diffs
- Staging View
- Revert a Change
- Resolving Conflicts
- Merge Tool
- Blame
- History and Log
- Merge Commits
- Branches and Tags
- Branches Popup
- Remotes
- Stashes
- Shelf
- Git Menu
- Git Dialogs
- Interactive Rebase
- Worktrees
- AI Agents
- AI Tasks
- AI Usage
- Keep Computer Awake
- Submodules
- Git LFS
- GitHub
- Git Console
- Terminal
- Terminal Features
- Scripts
- Navigation
- Menus
- Settings
- Settings Search
- Color Themes
- Settings Terminal and Automation
- Settings Files
- Updates
- Status Bar and Help
- Notifications
- Memory Use
- Clear Cache
- MCP and CLI
- Git Mergetool
- Keyboard Shortcuts
- Keyboard Shortcuts Editor
- Troubleshooting
- Troubleshooting Git
Developers
- Developer Guide
- Architecture
- Project Layout
- Backend
- How Folder Watching Works
- Folder Watching Bugs We Fixed
- How Workspace Files Work
- How the Preview Scheme Works
- Backend Services
- How Terminal Features Work
- Terminal Bugs We Fixed
- Frontend
- How the Rich Markdown Editor Works
- Rich Markdown Bugs We Fixed
- Git Menu Links
- Menu Keys and Routing
- Frontend Modules
- Blame Bugs We Fixed
- Project Color And Title Bar
- One Diff View
- Diff Bugs We Fixed
- Merge Tool Layouts
- Merge Tool Bugs We Fixed
- Color Theme Bugs We Fixed
- How the Connected Diff Works
- Rounded Panels Bugs We Fixed
- Update Bugs We Fixed
- Status Bar Bugs We Fixed
- MCP and CLI Bugs We Fixed
- Repository Actions Bugs We Fixed
- How the Changes Tab Works
- CodeMirror Patterns
- How the Cursor Works
- How Editor Features Work
- How the Path Bar Works
- Commands and Events
- Settings Reference
- Commands Workspace and Changes
- Commands Branches and Remotes
- Commands History and Extras
- Commands App and Tools
- Testing
- Test Suites
- Debugging
- Debugging with MCP
- Measuring Setting Memory
- How Terminal Memory Is Measured
- How Markdown Diagrams Work
- Releases and CI
- Publishing the Wiki
- Versioning and Changelog
- Browser Version Packages
- Browser Release Checks
- Docker Image
- Platforms and Signing
- Windows Support
- Windows Bugs We Fixed
- Windows MCP and CLI
- Docs and Screenshots
- UI Library
- UI Library MCP
- UI Library Bugs
- UI Library Migration
- Contributing
How features work
- Welcome screen
- Workspaces and folders
- Files panel
- File operations
- File icons
- Editor and tabs
- Pin, reorder and wrap tabs
- Single tab title
- Split editor
- New File and unsaved changes
- Unload hidden tabs
- Editing code
- Code appearance
- Markdown editor
- Image and PDF preview
- Search Everywhere
- Recent Files
- Navigation Bar
- Find and replace
- Changes and commits
- Sign-in prompts
- Commit box layout
- Repository actions
- Commit options
- Ignoring files
- Diffs
- Inline diffs
- Staging view
- Revert a change
- Resolving conflicts
- Merge tool
- Blame
- History and log
- Merge commits
- Commit tabs
- Branches and tags
- Branches popup
- Remotes
- Stashes
- Shelf
- Git menu
- Git dialogs
- Interactive rebase
- Worktrees
- AI agents
- AI usage
- Keep computer awake
- AI tasks
- Submodules
- Git LFS
- GitHub account
- Git Console
- Back and Forward
- Integrated terminal
- Scripts tool window
- Menu bar
- Settings
- Search in Settings
- Color themes
- Rounded panels
- Updates and release channels
- Status bar and help links
- Notifications
- Memory readout and memory log
- Clear Cache
- MCP server and command line tool
- git mergetool mode