@fedoup/markdown-editor
v0.1.0
Published
Obsidian-style Live Preview markdown editor for React, built on CodeMirror 6. One editor instance — rendered markdown by default, click any line to edit the raw source with no buffer swap.
Maintainers
Readme
@fedoup/markdown-editor
Obsidian-style Live Preview markdown editor for React, built on CodeMirror 6.
One editor instance throughout. Rendered markdown by default. Click any line to edit the raw source for that line, with the cursor at your click position. Move away, the rendering returns. There is no preview/editor swap, no buffer, no lag — the editor is one CodeMirror view; toggling between "rendered" and "source" is a single decoration update.
npm install @fedoup/markdown-editor \
@codemirror/commands @codemirror/lang-markdown @codemirror/language \
@codemirror/state @codemirror/view @lezer/commonimport { MarkdownEditor } from "@fedoup/markdown-editor";
import "@fedoup/markdown-editor/styles.css";
export function MyNote() {
const [doc, setDoc] = useState("# Hello\n\nClick any line.");
return <MarkdownEditor initialValue={doc} onChange={setDoc} />;
}What you get
- Headings h1–h6 with proportional sizing and stable line-height (no twitch on cursor-on/off).
- Bold (
**x**), italic (*x*/_x_), inline code (`x`) — syntax tokens hide off-line, reappear on-line. - Links —
[label](url)shows the styled label only; brackets and URL hide off-line. - Images —
renders as an inline<img>widget off-line, with a host-suppliedimageResolverfor path translation. - Fenced code — opening
```langand closing```lines hide entirely when the cursor isn't in the block; reveal the moment it enters. - Lists — bullet and ordered list markers stay visible but muted.
- Source mode — pass
sourceMode={true}to disable Live Preview entirely (Obsidian's escape hatch). - State preservation — selection, scroll, undo history all survive alt-tab because the editor instance never unmounts. No "preview snaps back to editor" lag, no lost cursor.
- Tiny — ~9 kB raw, ~3 kB gzipped (excludes CodeMirror, which you bring as peer deps).
How it works
This is not a WYSIWYG editor. The document model is plain markdown text — the editor literally holds **bold** as characters. What changes is what you see: a CodeMirror 6 ViewPlugin walks the Lezer markdown syntax tree on every doc, viewport, or selection change, and emits two kinds of decorations:
Decoration.markover the content (e.g. the wordbold), adding CSS classes that style it.Decoration.replaceover the syntax tokens (the**s), hiding them — but only on lines the cursor isn't currently on.
That single conditional is what makes the experience feel like Obsidian's Live Preview: the line the cursor lives on is always shown as raw source; every other line is shown as if it were rendered. Click moves the cursor, the decoration set rebuilds in the same frame, and the line you clicked unfolds. No component swap, no race.
The pattern is documented in the canonical discuss.codemirror.net thread. Obsidian's own implementation is closed source, but uses CodeMirror 6 and the same fundamental approach (their CM5→CM6 migration shipped Live Preview as its headline feature). kenforthewin/atomic-editor is a related open-source clone in the same family.
API
interface MarkdownEditorProps {
initialValue: string;
onChange?: (next: string) => void;
sourceMode?: boolean; // disable Live Preview decorations
extraExtensions?: Extension[]; // add validation, paste handlers, line numbers, etc.
placeholder?: string;
className?: string;
/**
* Rewrite an image's `src` before the widget loads it. Useful for vault-
* relative paths or auth-protected sources. Return `null`/`undefined` to
* skip the widget for that image (alt + raw markdown fall back).
*/
imageResolver?: (src: string) => string | null | undefined;
}
interface MarkdownEditorHandle {
insertAtCursor: (text: string) => void;
focus: () => void;
getValue: () => string;
view: EditorView | null; // escape hatch for custom transactions
}initialValue is read once. The editor is uncontrolled afterwards — listen via onChange and write back via the imperative handle if needed.
Custom extensions
import { MarkdownEditor } from "@fedoup/markdown-editor";
import { lineNumbers } from "@codemirror/view";
import { closeBrackets } from "@codemirror/autocomplete";
<MarkdownEditor
initialValue={doc}
onChange={setDoc}
extraExtensions={[lineNumbers(), closeBrackets()]}
/>Line numbers are not built in — they're an opt-in extension. Same goes for autocomplete, keymaps, paste handlers.
Theming
The editor reads design-token CSS custom properties. Any of these will be picked up:
| Token | Default fallback | Purpose |
|-------|------------------|---------|
| --me-fg | var(--foreground, currentColor) | text + caret |
| --me-fg-muted | var(--muted-foreground, #888) | gutter, h6, list markers |
| --me-border | var(--border, #ddd) | gutter divider |
| --me-link | var(--primary, #2563eb) | link labels |
| --me-font-mono | system monospace stack | inline + fenced code |
| --me-font-prose | system sans stack | body |
| --me-font-size | 14px | base size |
If you already use shadcn/ui or Tailwind tokens, you get the editor themed for free — the --foreground / --border / --muted-foreground / --primary fallbacks pull straight from the same tokens.
Try it locally
git clone https://github.com/fedoup/markdown-editor
cd markdown-editor
npm install
cd examples && npm install && npm run devOpen the URL Vite prints. Click around. Alt-tab. Pop the Source mode checkbox.
Roadmap
- v0.2 — sub-line activation (only hide tokens not directly under the cursor, instead of activating the whole line — matches Obsidian's behavior on long lines).
- v0.2 — paste-as-markdown helper (incoming HTML → markdown, drop-anywhere image upload via host callback).
- v0.2 — task-list checkbox widget (
- [ ]/- [x]clickable).
What this isn't
- Not a WYSIWYG editor. The model is markdown text. Round-tripping is byte-for-byte. If you want a model where the source is HTML/JSON and markdown is lossy I/O, look at Tiptap or Milkdown.
- Not Obsidian. No graph view, no plugins, no vault layer. This is just the editing experience as a React component.
License
MIT
