@atomic-editor/editor
v0.3.0
Published
CodeMirror 6 markdown editor with Obsidian-style inline live preview.
Maintainers
Readme
Atomic Editor
A CodeMirror 6 markdown editor with Obsidian-style inline live preview. Renders headings, bold, italic, links, images, and tables WYSIWYG while keeping the raw markdown as the source of truth — so copy, save, and interop with any other markdown tool Just Works.
Originally built for Atomic, a personal knowledge base, now standalone.
- Virtualized: CM6 renders only the viewport. Open a 500-page atom instantly; scrolling stays smooth even on iOS.
- Layout-stable: no reflow when you click into a heading or move the cursor. Inline decorations rather than block-level widget swaps.
- WYSIWYG tables: click into a cell to edit in place; wide tables scroll horizontally inside a contained wrapper.
- Smart lists: Enter continues tight bullets and task checkboxes,
Enter on an empty item dedents,
- [ ]becomes a real checkbox. - Fenced code highlighting for 20+ languages, lazy-loaded per fence so unused grammars never hit the wire.
- Theme with CSS variables — dark by default, light via a single
data-theme="light"attribute. - Minimal search panel (Ctrl/Cmd+F) styled to match the editor.
Install
npm install @atomic-editor/editor \
@codemirror/state @codemirror/view @codemirror/commands \
@codemirror/autocomplete @codemirror/language @codemirror/search \
@codemirror/lang-markdown \
@lezer/common @lezer/highlight \
react react-domThe CodeMirror and React packages are declared as peer dependencies
rather than regular deps. You install them alongside the editor so
your bundler resolves a single shared copy — two copies of
@codemirror/state in one bundle would silently break the editor's
state-field identity checks.
Fenced-code language grammars (@codemirror/lang-javascript,
@codemirror/lang-python, etc.) are optional peers — install only
the ones you want highlighted. See
Syntax highlighting below.
Use
import { AtomicCodeMirrorEditor } from '@atomic-editor/editor';
import '@atomic-editor/editor/styles.css';
function App() {
return (
<AtomicCodeMirrorEditor
markdownSource={'# Hello\n\nA paragraph.'}
onMarkdownChange={(md) => console.log(md)}
onLinkClick={(url) => window.open(url, '_blank', 'noopener,noreferrer')}
/>
);
}The editor fills its parent — wrap it in a height-bounded flex or grid container.
Imperative handle
Pass a ref if you need to drive the editor from outside — e.g. wire your own toolbar buttons, or open the search panel from a global keybinding:
import { useRef } from 'react';
import {
AtomicCodeMirrorEditor,
type AtomicCodeMirrorEditorHandle,
} from '@atomic-editor/editor';
function App() {
const editor = useRef<AtomicCodeMirrorEditorHandle | null>(null);
return (
<>
<button onClick={() => editor.current?.openSearch()}>Search</button>
<AtomicCodeMirrorEditor
markdownSource={'…'}
editorHandleRef={editor}
/>
</>
);
}Methods: focus, undo, redo, openSearch(query?), closeSearch,
revealText(query), isSearchOpen, getMarkdown, getContentDOM.
Arriving from a search result
Two props drop the user near a relevant paragraph on mount:
initialSearchTextopens the search panel pre-filled with the query. Full navigation surface — arrow keys to step through matches, close to dismiss. Good when the user explicitly invoked find.initialRevealTextdoes a less intrusive scroll-into-view with a 3.2 s fade-out highlight on the first match — no panel, no cursor move. Good for "I clicked a search result, take me to the paragraph it came from".
Both accept string | null. The reveal matcher falls back
progressively — exact, whitespace-collapsed, individual lines, then
truncated prefixes (140 and 80 chars) — so hits still resolve when
the query came from an LLM-massaged snippet that doesn't match the
source byte-for-byte. For post-mount reveals, call
editorHandle.revealText(query) via the imperative handle.
The fade highlight uses CSS variables
--atomic-editor-initial-reveal-bg and
--atomic-editor-initial-reveal-bg-strong; override to theme the
peak and settled colors independently of the main search-match
palette.
Syntax highlighting
Fenced code blocks are plain monospace by default. To enable
highlighting, pass a codeLanguages array. @codemirror/lang-markdown
dynamically imports each grammar the first time a fence uses it, so
large lists don't bloat the initial bundle.
Option 1: use the curated list (~20 languages)
# Install the lang-* peers you want highlighted.
npm install \
@codemirror/lang-javascript @codemirror/lang-python \
@codemirror/lang-rust @codemirror/lang-go @codemirror/lang-html \
@codemirror/lang-css @codemirror/lang-json @codemirror/lang-yaml \
@codemirror/legacy-modes # ruby/swift/shell/toml/dockerfileimport { AtomicCodeMirrorEditor } from '@atomic-editor/editor';
import { ATOMIC_CODE_LANGUAGES } from '@atomic-editor/editor/code-languages';
<AtomicCodeMirrorEditor
markdownSource={'…'}
codeLanguages={ATOMIC_CODE_LANGUAGES}
/>See src/code-languages.ts for the full
list (JavaScript, TypeScript, Python, Go, Rust, Ruby, Java, C, C++,
PHP, Swift, Shell, SQL, HTML, CSS, XML, JSON, YAML, TOML, Dockerfile,
Markdown).
Option 2: bring your own
import { LanguageDescription } from '@codemirror/language';
import { python } from '@codemirror/lang-python';
const codeLanguages = [
LanguageDescription.of({
name: 'Python',
alias: ['py'],
extensions: ['py'],
load: () => Promise.resolve(python()),
}),
];
<AtomicCodeMirrorEditor markdownSource={'…'} codeLanguages={codeLanguages} />Theming
Every color, font, and size reads from a CSS custom property with an inline fallback. Override on any ancestor of the editor.
The package ships a light variant that activates whenever
data-theme="light" is set on an ancestor — including <html> or
<body>. The dark defaults remain unchanged; the light block just
re-maps the same variables.
<html data-theme="light">…</html>| Variable | Dark default (auto-light on [data-theme="light"]) |
| ------------------------------------- | --------------------------------------------------- |
| --atomic-editor-font | system sans |
| --atomic-editor-font-mono | system mono |
| --atomic-editor-body-size | 1.0625rem |
| --atomic-editor-body-leading | 1.7 |
| --atomic-editor-measure | 70ch |
| --atomic-editor-fg | #dcddde |
| --atomic-editor-fg-muted | #888 |
| --atomic-editor-fg-faint | #666 |
| --atomic-editor-bg | #1e1e1e |
| --atomic-editor-bg-panel | #252525 |
| --atomic-editor-bg-surface | #2d2d2d |
| --atomic-editor-border | #3d3d3d |
| --atomic-editor-accent | #7c3aed |
| --atomic-editor-accent-bright | #a78bfa |
| --atomic-editor-link | #60a5fa |
| --atomic-editor-link-hover | #93c5fd |
| --atomic-editor-code-bg | subtle dark panel |
| --atomic-editor-selection-bg | accent-tinted 28% |
| --atomic-editor-search-bg | accent-tinted 28% |
| --atomic-editor-search-bg-active | accent-tinted 60% |
| Code-token colors (Palenight) | |
| --atomic-editor-hl-keyword | #c792ea |
| --atomic-editor-hl-string | #c3e88d |
| --atomic-editor-hl-number | #f78c6c |
| --atomic-editor-hl-comment | #6a7a82 |
| --atomic-editor-hl-type | #ffcb6b |
| --atomic-editor-hl-function | #82aaff |
| --atomic-editor-hl-property | #82aaff |
| --atomic-editor-hl-regexp | #f07178 |
| --atomic-editor-hl-escape | #89ddff |
| --atomic-editor-hl-tag | #f07178 |
| --atomic-editor-hl-variable | #eeffff |
| --atomic-editor-hl-operator | #89ddff |
| --atomic-editor-hl-invalid | #ff5370 |
Extending with plugins
CodeMirror 6 is extension-based, and so is this package. Pass any
number of CM6 extensions via the extensions prop to layer in
autocomplete sources, custom decorations, domain-specific keymaps,
collaboration (yjs), vim mode, or anything else:
import { autocompletion, type CompletionContext } from '@codemirror/autocomplete';
const wikiLinks = autocompletion({
override: [(ctx: CompletionContext) => {
const match = ctx.matchBefore(/\[\[\w*$/);
if (!match) return null;
return {
from: match.from + 2,
options: myAtomStore.list().map((a) => ({ label: a.title })),
};
}],
});
<AtomicCodeMirrorEditor
markdownSource={'…'}
extensions={[wikiLinks]}
/>Consumer extensions are appended after the built-ins, so wrap a custom
keymap in Prec.high (from @codemirror/state) if it needs to beat
the default bindings. The array is captured at mount — pass a stable
reference unless you want a remount.
Low-level composition
If the React wrapper's extension set is too opinionated, every piece is exported individually so you can assemble a fully custom editor:
import {
inlinePreview, // live preview decorations
imageBlocks, // rendered image widgets
tables, // WYSIWYG table widget
atomicEditorTheme,
atomicMarkdownSyntax,
extendEmphasisPair,
} from '@atomic-editor/editor';You could build an editor that includes inlinePreview() + tables()
but skips atomicEditorTheme for your own EditorView.theme({...}),
or swap atomicMarkdownSyntax for a custom
syntaxHighlighting(HighlightStyle.define([...])). At that point
you're outside the React wrapper and in plain CM6 territory.
Design notes
See docs/architecture.md for the full design rationale. Short version:
- Raw markdown is the source of truth. All decorations are view-only — copy, save, and round-trip to any markdown parser are identical to what you'd expect from a plain textarea.
- No layout shifts. Every line has a stable height regardless of cursor position. Inline decorations hide syntax tokens on inactive lines without changing line heights.
- Narrow invalidation. Decoration rebuilds only touch lines whose content (or surrounding trigger characters) changed, so editing a paragraph in a 50KB doc costs O(change size), not O(doc).
- Mouse-freeze guard. Clicks don't trigger a decoration rebuild mid-interaction — eliminates a class of cursor-drift bugs.
- iOS-aware. Momentum scroll halts were investigated and fixed;
the
MinimalCodeMirrorEditorandNoPreviewCodeMirrorEditorflavors in the demo exist as bisection tools for any future scroll issues.
Contributing
git clone https://github.com/kenforthewin/atomic-editor
cd atomic-editor
npm install
npm run dev # demo dev server at http://localhost:5173
npm test # vitest unit tests
npm run build # tsc emit to dist/
npm run test:e2e # Playwright probe suite against the demoThe Playwright suite (scripts/test-editor.mjs) is the primary
regression-catching tool — ~33 probes covering CLS during idle /
scroll / typing, click-freeze timing, block-type decorations
(headings, lists, tasks, tables, images, fences, HRs), copy-as-raw-
markdown, tight-list continuation, escape handling, and late-doc
rendering via the parser-progress mechanic. Run after any change to
the editor's extensions.
Issues and PRs welcome.
License
MIT. See LICENSE.
