Markdown Editor
alphav0.1.4CodeMirror 6 markdown editor with GFM, wikilink autocomplete, a slot-able toolbar, and edit, split, and preview modes.
Context
Heaviest Tier 1 pro-component for the graph-system. CodeMirror 6 substrate (decision #19; ~150KB acceptance per #26) with a per-instance `marked` for preview parsing (Q-P1 — avoids global mutation). Wikilink candidates flow through CM6 StateField + StateEffect so host-side updates (e.g., new graph nodes) re-decorate without remount (Q-P5). Generic over the candidate type via `<MarkdownEditor<TCandidate extends WikilinkCandidate>>` for kind-typed candidates. Composed inside force-graph from v0.5 onward (doc nodes + wikilink reconciliation per decision #36) and inside detail-panel showcases. The editor's contract is `onSave(value)` only — reconciliation lives in force-graph, not here.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/markdown-editorAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/markdown-editor-fixturesPreview
Controlled value / onChange. Try **bold**, *italic*, or ⌘B.
Length: 142 chars
Demo source
Usage
When to use
Reach for MarkdownEditor when a host needs a controlled markdown surface with wikilink autocomplete + decoration, a slot-able toolbar, and an edit / split / preview toggle. CodeMirror 6 is the substrate (decision #19); the editor is graph-aware via wikilinkCandidates but functions standalone.
- Controlled-only — host owns
valueand listens toonChange. Pure CM6 internals; React just mirrors. - GFM by default — tables, strikethrough, task lists (non-interactive in v0.1), autolink.
- Save via ⌘S / Ctrl+S when an
onSavehandler is wired; browser default save fires otherwise.
Quick start
Minimum viable: controlled value/onChange, default toolbar, edit-only view.
import { useState } from "react";
import { MarkdownEditor } from "@/components/markdown-editor";
export function NoteEditor() {
const [value, setValue] = useState("# Hello\n\nStart typing…");
return <MarkdownEditor value={value} onChange={setValue} />;
}With wikilinks
Pass a stable wikilinkCandidates array (memoized or module-scope per §11.1.1 reference-stability footgun). Resolved labels render with accent styling; unresolved render dashed/destructive. Wire onWikilinkClick to navigate.
import { useMemo, useState } from "react";
import { MarkdownEditor } from "@/components/markdown-editor";
import type { WikilinkCandidate, KindMeta } from "@/components/markdown-editor";
const KINDS: Record<string, KindMeta> = {
person: { label: "Person", color: "oklch(0.62 0.18 250)" },
doc: { label: "Doc", color: "oklch(0.62 0.18 60)" },
};
export function GraphAwareEditor({ graphNodes }: { graphNodes: WikilinkCandidate[] }) {
const [value, setValue] = useState("");
const candidates = useMemo(() => graphNodes, [graphNodes]);
return (
<MarkdownEditor
value={value}
onChange={setValue}
wikilinkCandidates={candidates}
kinds={KINDS}
onWikilinkClick={(target) => {
// resolve target → graph node id; navigate or open detail-panel
}}
initialView="split"
/>
);
}force-graph v0.5 integration recipe
When force-graph v0.5 ships, doc-node selection mounts the editor inside DetailPanel.Body. The editor's onSave fires force-graph's reconcileWikilinks action (decision #36 — reconciliation lives in force-graph, not here). Scaffold for the eventual cascade:
<DetailPanel selection={selectedDoc}>
<DetailPanel.Header>{selectedDoc.title}</DetailPanel.Header>
<DetailPanel.Body>
<MarkdownEditor
value={selectedDoc.body}
onChange={(v) => actions.updateNode(selectedDoc.id, { body: v })}
onSave={(v) => actions.reconcileWikilinks(selectedDoc.id, v)}
wikilinkCandidates={graph.allDocsAndPeople}
kinds={KINDS}
initialView="split"
/>
</DetailPanel.Body>
</DetailPanel>DetailPanel re-keys on selection change, remounting the editor cleanly between documents — no stale state. See detail-panel §4 for the re-key contract.
Custom toolbar
Spread defaultMarkdownToolbar and append items. Each item receives ToolbarCtx with the live EditorView, current value, and 3 dispatch helpers. To insert a vertical separator between groups, push a ToolbarItem with an empty-string label — the renderer detects label === '' and draws a divider instead of a button.
import { Sparkles } from "lucide-react";
import {
MarkdownEditor,
defaultMarkdownToolbar,
type ToolbarItem,
} from "@/components/markdown-editor";
const toolbar: ReadonlyArray<ToolbarItem> = [
...defaultMarkdownToolbar,
{ id: "sep-2", label: "", run: () => {} },
{
id: "callout",
label: "Insert callout",
icon: <Sparkles />,
run: (ctx) => ctx.insertText("\n> [!note]\n> "),
},
];
<MarkdownEditor value={value} onChange={setValue} toolbar={toolbar} />Reference stability
Same footgun as filter-panel categories and entity-picker items. React Compiler memoizes JSX-literal arrays in-repo, but NPM consumers without it must memoize manually.
// ✓ Module-scope (preferred for static lists)
const CANDIDATES = [/* ... */] satisfies WikilinkCandidate[];
<MarkdownEditor wikilinkCandidates={CANDIDATES} ... />
// ✓ useMemo (for derived lists)
const candidates = useMemo(() => deriveFromGraph(graph), [graph]);
<MarkdownEditor wikilinkCandidates={candidates} ... />
// ✗ Inline literal (NPM consumer without React Compiler — extra dispatch per render)
<MarkdownEditor wikilinkCandidates={[...]} ... />Imperative handle
React 19 ref-as-prop. The handle proxies CM6 history (undo/redo), inserts text at the caret, and exposes the underlying EditorView via getView() for escape-hatch use.
const ref = useRef<MarkdownEditorHandle>(null);
<MarkdownEditor ref={ref} value={value} onChange={setValue} />
// Drive from outside:
ref.current?.focus();
ref.current?.insertText("✨ ");
ref.current?.undo();
const { from, to, text } = ref.current?.getSelection() ?? {};
const view = ref.current?.getView(); // escape hatch (substrate-leak risk acknowledged)Notes
Things that are non-obvious from the prop types.
- Wikilink target is the part BEFORE the pipe: in
[[label|alias]], the click handler receiveslabel. Aliased rendering shows alias; resolution uses label (case-insensitive + trimmed). - Image embeds (
![[image.png]]) are NOT parsed in v0.1 — they render as literal text. Real images use. - Wikilink anchors (
[[label#anchor]]) are NOT supported in v0.1. The anchor portion is treated as part of the label; v0.2 adds anchor parsing. - Cmd+S only suppresses the browser's native save when an
onSavehandler is wired. Otherwise the browser dialog fires. Payload is the live CM6 doc, not the Reactvalueprop (avoids stale-by-React-batching). - Extension precedence: user-supplied
extensionsare appended LAST in the CM6 stack — earlier entries have HIGHER default precedence. To override our keymap, wrap withPrec.high(...)from@codemirror/state. - GFM task lists render as static disabled checkboxes. Toggling requires write-back to source — deferred to v0.2.
Features
- Pure controlled value/onChange — host owns the markdown string
- Three view modes — edit / split / preview, controlled or uncontrolled
- Default toolbar — 8 built-in items (bold, italic, code, link, lists, blockquote, heading-cycle); extend by spreading defaultMarkdownToolbar
- [[wikilink]] autocomplete with kind badges, capped at 50 results with sentinel overflow row
- Wikilink decoration in edit mode — broken-link styling reacts to runtime candidates updates via CM6 StateField (no remount)
- Wikilink rendering in preview — clickable, keyboard-accessible (role=link + tabindex when interactive); broken-link styling for unresolved
- Symmetric wikilink grammar — single source of truth shared by CM6 MatchDecorator, autocomplete trigger, and `marked` extension
- GitHub-Flavored Markdown — tables, strikethrough, task lists (static in v0.1), autolink
- Cmd/Ctrl+S → onSave(currentDoc) — preventDefault only when handler is supplied (browser save fires otherwise)
- Standard markdown keymap — Cmd+B/I/E/K + Cmd+Shift+. for blockquote
- Theme via CSS variables — dark/light flips with no remount
- extensions prop — user CM6 extensions appended LAST; OUR defaults win conflicts (escalate via Prec.high)
- Imperative handle — focus / undo / redo / insertText / getSelection / getValue / getView (escape hatch)
- React 19 ref-as-prop preserves generic inference
- Echo-guarded value-prop sync — no infinite loop if host calls setValue from inside onChange
- Bundle ≤180KB total (CM6 ~150KB + `marked` ~14KB + our code ~16KB)