This document covers the design philosophy and implementation details
of @atomic-editor/editor. It's not an inventory of every function —
it's the set of decisions worth holding in your head before changing
anything, because the surface area is small but each piece is
load-bearing.
Most WYSIWYG markdown editors are built on ProseMirror (Milkdown, Tiptap, etc.). They produce polished editing surfaces but can't virtualize — ProseMirror's state model requires the entire document tree in memory and mounted as DOM on every render. For long documents that's a non-starter: open time grows linearly with size, scrolling jitters under layout churn, memory pressure becomes real, and iOS kinetic scroll halts on big height changes.
CodeMirror 6 virtualizes natively. It renders only the viewport, the
parser (@lezer/markdown) is incremental, and the whole system is
built around decorations that compose cleanly. The tradeoff is that
CM6 is a text editor at heart — "WYSIWYG" in CM6 means carefully
choreographed decorations layered over a raw text buffer, not a rich-
content model like ProseMirror's.
The document text in state.doc is always plain markdown. Every
decoration is view-only. This is the single rule the whole design
follows from, and it's worth stating twice:
- What you see on screen may differ from the raw text (hidden syntax tokens, rendered bullet characters, checkbox widgets, rendered images, WYSIWYG tables, etc.).
- What you copy, what gets saved, what another editor would parse is always the underlying markdown.
This invariant is why cross-block selection "just works" — the browser selection maps to doc positions, and copy reads raw markdown from those positions. It's why collaborative editing and diffing can be bolted on without rethinking the view layer. And it's why what the user sees matches what's persisted.
An earlier iteration briefly shipped a "block" preview mode that replaced each block with a rendered HTML widget. Every cursor move between blocks caused a height change as the clicked block unfolded to raw and the leaving block refolded. Measured at ~0.1 CLS per 10 cursor moves; in practice it felt like the UI was vibrating under the user.
The current mode ("inline live preview") avoids layout shifts by
making line heights depend only on CSS class, not on whether
syntax tokens are visible. A heading line styled .cm-atomic-h1 is
~1.35em whether the # prefix is currently hidden or revealed.
Active / inactive states toggle token visibility via
Decoration.replace({}), which removes characters from flow without
changing the enclosing line's measured height.
Measured CLS for the same 10 cursor-moves test in inline mode: ~0.003 — essentially all of it is the cursor caret redrawing. The structure doesn't shift.
src/
index.ts public API (AtomicCodeMirrorEditor + types)
AtomicCodeMirrorEditor.tsx React shell + imperative handle
inline-preview.ts main decoration engine (ViewPlugin)
image-blocks.ts block image widgets (StateField)
table-widget.ts WYSIWYG tables (StateField)
edit-helpers.ts bracket / emphasis auto-pairing
atomic-theme.ts theme + syntax highlighting
code-languages.ts curated fenced-code grammar registry
styles/inline-preview.css all editor CSS in one file
Every CodeMirror module is a peer dependency so the consumer's
bundler resolves a single copy. Two copies of @codemirror/state in
the same bundle silently break state-field identity checks; peer-deps
are what prevent that.
A React wrapper around a single EditorView. Teardown on unmount;
document identity (documentId ?? markdownSource) keys the view so
cursor / undo state from one document can't bleed into the next.
The component exposes an imperative handle via editorHandleRef:
focus, undo, redo, openSearch(query?), closeSearch,
isSearchOpen, getMarkdown, getContentDOM.
Notable props:
markdownSource— initial content; the editor owns the doc after mount.onMarkdownChange— fires for every doc mutation, including internal ones (checkbox toggles, tight-list continuations).initialSearchText— opens the search panel pre-filled, useful for landing users on a search hit.onLinkClick— called when the user taps the external-link icon rendered next to a link. Defaults towindow.open; override for platform-specific shells (Tauri, Capacitor, Electron).codeLanguages— grammars for fenced code blocks; defaults to[]. See the README for usage.
Three pieces, each with a specific reason to exist.
A boolean StateField tracking whether decoration rebuilds are
paused. Toggled via a setFrozen effect from the freeze plugin.
A ViewPlugin with a capture-phase pointerdown listener on
view.dom and a pointerup listener on window. On pointerdown
inside the content DOM, it dispatches setFrozen(true). On pointerup,
after a ~100ms tail, it dispatches setFrozen(false).
The freeze exists because clicking a heading used to reveal its #
prefix immediately — which shifted the heading text rightward under
the user's cursor mid-click, sometimes promoting the click into a
micro-drag selection. Now the reveal waits until the click has fully
resolved.
Capture-phase matters: @codemirror/lang-markdown's own
pointerdown handler runs bubble-phase and dispatches selection
changes. Without capture, CM6 can rebuild decorations before we
freeze, and the reveal fires anyway. The content-DOM filter matters
too: without it, a scrollbar drag engages the freeze and stops
decoration rebuilds for the whole drag — deep content stays raw until
mouseup.
A ViewPlugin whose decorations facet drives the display. Rebuilds
on doc change, selection change, or focus change, subject to the
freeze flag. Not on viewport change — scrolling alone must not
rebuild decorations, because on iOS that halts kinetic momentum
whenever the rebuild produces new decorations for lines at the top of
a scroll-up viewport (CM6 anchor conflict with the scroll animation).
The build function gets its tree from decorationTree in
tree-progress.ts, the one place that decides how far the parser is
forced before a walk. Tables and image blocks use the same helper, so
the three builders always agree on coverage. At mount (and on cursor
or focus rebuilds) it only guarantees a bounded prefix inside a 20 ms
budget: the two StateFields (tables, image blocks) have no view and
use the fixed 16 KB window; the inline-preview ViewPlugin passes
view.viewport.to, so it covers the window or the current viewport,
whichever is larger. That is what keeps opening a large document from
paying a synchronous whole-document Lezer parse inside the first
paint. A doc change forces the whole document with the historical
200 ms budget, so editing behaves exactly as before. A tree-growth
rebuild never forces at all.
Content past the parsed prefix would otherwise render as raw
##/** forever, since decorations don't rebuild on scroll. The
treeProgressPlugin closes that gap. Each idle tick (a
requestIdleCallback with a 400 ms timeout, so a busy host cannot
starve it) asks the parser for one bounded segment, from the last
observed tree length to that plus an adaptive threshold (8 KB,
doubling after each rebuild up to 64 KB, reset on every doc change),
or to the viewport end when the user has scrolled past the parsed
region, within a 30 ms budget. The parse context only publishes a new tree
when a parse completes, so the bounded target is what makes growth
progressive: every tick that finishes its segment lands a longer tree
and dispatches one treeGrowthEffect, and all three builders rebuild
on it. A 300 KB document is decorated in about eight steps rather
than in one late rebuild at the end. scripts/bench-editor-entry.mjs
asserts the mount reach stays inside the window and reports the
number of growth rebuilds.
What gets hidden, styled, or replaced
-
Line classes (applied unconditionally based on block type):
cm-atomic-h1..h6,cm-atomic-blockquote,cm-atomic-fenced-code,cm-atomic-hr,cm-atomic-task-done. These set font size / weight / decoration. No height changes between active and inactive states because the class doesn't care about cursor position. -
Inline content marks (applied unconditionally to content between syntax tokens):
cm-atomic-strong,cm-atomic-em,cm-atomic-inline-code,cm-atomic-strike,cm-atomic-link. The link mark also renders an "open externally" icon via a::afterpseudo-element; only the icon's hit region is clickable, since the link text itself is editable prose. -
Hide decorations (applied only on inactive lines):
HeaderMark,EmphasisMark,CodeMark,CodeInfo,LinkMark,URL,LinkTitle,StrikethroughMark,QuoteMark, andEscape. Header and quote marks swallow a trailing space so the hidden-state line doesn't read indented.Escapehides only the leading backslash — content ingested from RSS or other sources full of\.and\,reads clean until focused. -
Widgets (always-on replacements):
•for bulletListMark, a checkbox forTaskMarker, horizontal-rule rendering via a CSS::afterrule on the line, rendered images below each image source line (seeimage-blocks.ts), and full WYSIWYG tables (seetable-widget.ts).
Images can't be emitted from a ViewPlugin because CM6 requires block
decorations to come from a StateField or a mandatory facet. The
image state field lives alongside the inline preview plugin; CM6
composes the two decoration sets at render time.
For each Image node, the field emits a block widget with side: 1
at line.to, so the image renders immediately below its source line.
Images inside tables are skipped — the table widget renders them
inline in the cells.
Size invariants: the <img> uses display: block; max-width: 100%; height: auto so it fits the reading column without upscaling beyond
natural size. Small images render at their own size, left-aligned.
Narrow invalidation: on every transaction, changeAffectsImages
checks whether the change overlaps an existing image decoration OR
whether the changed lines contain , a preview strip rendering the image below.
The widget's eq() is structure-only (row × column count), so CM6
keeps the existing DOM across per-keystroke dispatches and the caret
survives edits. Cell input re-serializes the whole table and replaces
the current source range — the range is resolved fresh via `posAtDOM
- tree walk` every time, because earlier edits shift the bounds.
Wide tables get their own horizontal scroll inside a wrapper
(overflow-x: auto), so the editor's content column isn't forced
wider than the viewport when a 10-column table enters view. This was
the root cause of a mobile overflow bug and is worth preserving.
Interaction contract:
- Tab / Shift-Tab move between cells. Tab past the last cell appends a new row and lands on its first cell.
- Right-click opens a menu with Insert row / Delete row / Insert column / Delete column. The last column is floored so lezer can still parse the remnant as a Table.
- Inside an image cell, the raw
hides when focus leaves the cell — only the image shows at rest, matching the block-image invariant outside tables. - Backspace at the line immediately after a table selects the whole table as an atomic unit instead of merging content into the last row.
@codemirror/lang-markdown ships insertNewlineContinueMarkup as its
default Enter handler. It inspects the syntax tree to decide whether
the list it's continuing is "loose" (CommonMark: blank lines between
items) and, if so, inserts a blank line into the continuation to
preserve the loose style.
In inline live-preview mode loose and tight lists look identical, so the distinction doesn't earn its weight. Worse, lezer often classifies a newly-typed list item as loose when it sits near an existing list — users end up with spurious blank lines between their items.
insertTightListItem in inline-preview.ts overrides Enter at
Prec.highest. Bound behavior:
- Inside a
BulletList, always emit\n<indent><marker>(tight). - Inside a task item, emit
\n<indent><marker> [ ]— fresh tasks start unchecked, even if you pressed Enter on a checked item. - On an empty continuation (
-with nothing after, or- [ ]with nothing after), replace the line with just its indent, which exits the list the way users expect.
CommonMark's flanking rules say **foo ** is not emphasis because
the closing ** is preceded by whitespace. Lezer agrees and doesn't
emit StrongEmphasis. Result: while the user types a sentence inside
**...**, the bold styling flickers on and off every time they hit
the spacebar.
supplementMidTypingEmphasis patches the UX: on the focused line,
scan for matched delimiter pairs (**, __, ~~, *, _) the
cursor sits between and emit the mark ourselves regardless of flanking.
Once the cursor leaves, lezer's opinion wins and the visual reverts to
what will actually persist when the line serializes.
closeBrackets() pairs (, [, {, ", ', *, _, ` by
default; we extend the markdown language's data facet to include the
markdown-specific symmetric delimiters. extendEmphasisPair in
edit-helpers.ts adds one special case: typing * inside an empty
*|* (or _|_) promotes the pair to **|** — the Obsidian ergonomic
for typing bold quickly without thinking about doubled keystrokes.
Two CM6 extensions:
- An
EditorView.theme()with visual / selection / scrollbar styling tied to--atomic-editor-*custom properties. See the README for the full variable list. - A
HighlightStyle+syntaxHighlightingpairing that colors both markdown tokens and the tokens emitted by grammars nested inside fenced code blocks. Code-language colors use a Material Palenight palette by default and flip to a GitHub-style light palette when[data-theme="light"]is set.
The curated fenced-code language registry. Each language's load()
is a dynamic import so the bundler splits each grammar into its own
chunk and users only download grammars they open.
The registry is exposed at the /code-languages sub-path so
consumers opt in explicitly; the main entry bundle has no lang-*
dependencies.
The editor wires @codemirror/search with a custom minimal panel:
input + match counter + prev/next/close icon buttons. No replace, no
case/regex/word toggles — reader-first, not editor-first. Keyboard
users get the same behavior CM6's searchKeymap ships with
(Cmd/Ctrl+G = next, Shift+same = previous, Escape = close).
External code can detect "is search open?" via the imperative
handle's isSearchOpen() method, which delegates to CM6's
searchPanelOpen(state).