Markdown Editor
alphav0.1.5CodeMirror 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 initpnpm dlx shadcn@latest add @ilinxa/markdown-editorAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/markdown-editor-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Controlled value / onChange. Try **bold**, *italic*, or ⌘B.
Length: 142 chars
Demo source
"use client"; import { useRef, useState } from "react";import { Save, Sparkles } from "lucide-react";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { MarkdownEditor } from "./markdown-editor";import { defaultMarkdownToolbar } from "./default-toolbar";import { GRAPH_NODES, NODE_KINDS, READ_ONLY_DOC, SAMPLE_DOC, SHORT_DOC,} from "./dummy-data";import type { MarkdownEditorHandle, ToolbarItem } from "./types"; function DemoFrame({ children }: { children: React.ReactNode }) { return ( <div className="rounded-md border border-border bg-card p-5">{children}</div> );} function BasicDemo() { const [value, setValue] = useState(SHORT_DOC); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Controlled <code>value</code> / <code>onChange</code>. Try <code>**bold**</code>, {" "}<code>*italic*</code>, or <kbd className="rounded bg-muted px-1 py-0.5 text-[10px]">⌘B</kbd>. </p> <MarkdownEditor value={value} onChange={setValue} ariaLabel="Basic markdown editor" minHeight="14rem" /> <p className="text-xs text-muted-foreground"> Length: <span className="font-mono">{value.length}</span> chars </p> </div> </DemoFrame> );} function ViewModesDemo() { const [value, setValue] = useState(SAMPLE_DOC); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Toggle between <code>edit</code>, <code>split</code>, and <code>preview</code> via the tabs. Split stacks vertically when the container is narrower than 480px. </p> <MarkdownEditor value={value} onChange={setValue} initialView="split" minHeight="22rem" maxHeight="28rem" ariaLabel="View-mode demo" /> </div> </DemoFrame> );} function WikilinksDemo() { const [value, setValue] = useState(SAMPLE_DOC); const [lastClicked, setLastClicked] = useState<string | null>(null); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Type <code>[[</code> in the editor to open the wikilink picker. Resolved targets get accent styling; unresolved (e.g. <code>[[unknown reference]]</code>) flip to dashed-underline broken styling. Click a wikilink in the preview to fire{" "} <code>onWikilinkClick</code>. </p> <MarkdownEditor value={value} onChange={setValue} wikilinkCandidates={GRAPH_NODES} kinds={NODE_KINDS} onWikilinkClick={(target) => setLastClicked(target)} initialView="split" minHeight="22rem" maxHeight="28rem" ariaLabel="Wikilink demo" /> <p className="text-xs text-muted-foreground"> Last clicked target:{" "} <span className="font-mono">{lastClicked ?? "—"}</span> </p> </div> </DemoFrame> );} function CustomToolbarDemo() { const [value, setValue] = useState(SHORT_DOC); const customToolbar: ReadonlyArray<ToolbarItem> = [ ...defaultMarkdownToolbar, { id: "sep-2", label: "" }, { id: "callout", label: "Insert callout", icon: <Sparkles />, run: (ctx) => { ctx.insertText("\n> [!note]\n> "); }, }, ]; return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Spread <code>defaultMarkdownToolbar</code> and append a custom item. The custom item receives <code>ToolbarCtx</code> with the live <code>EditorView</code>. </p> <MarkdownEditor value={value} onChange={setValue} toolbar={customToolbar} ariaLabel="Custom toolbar demo" minHeight="14rem" /> </div> </DemoFrame> );} function ReadOnlyDemo() { const [value, setValue] = useState(READ_ONLY_DOC); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> <code>readOnly=true</code> — toolbar disabled, keymaps inert, no <code>onChange</code> fires. Syntax highlighting still active. </p> <MarkdownEditor value={value} onChange={setValue} readOnly wikilinkCandidates={GRAPH_NODES} kinds={NODE_KINDS} initialView="split" minHeight="18rem" ariaLabel="Read-only demo" /> </div> </DemoFrame> );} function OnSaveDemo() { const [value, setValue] = useState(SHORT_DOC); const [savedAt, setSavedAt] = useState<string | null>(null); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Press <kbd className="rounded bg-muted px-1 py-0.5 text-[10px]">⌘S</kbd> /{" "} <kbd className="rounded bg-muted px-1 py-0.5 text-[10px]">Ctrl+S</kbd> to fire{" "} <code>onSave</code>. The browser's native save is suppressed only when{" "} <code>onSave</code> is supplied. </p> <MarkdownEditor value={value} onChange={setValue} onSave={(v) => { setSavedAt(new Date().toLocaleTimeString()); setValue(v); }} ariaLabel="onSave demo" minHeight="14rem" /> <p className="text-xs text-muted-foreground"> {savedAt ? ( <> <Save aria-hidden="true" className="inline size-3" /> Saved at{" "} <span className="font-mono">{savedAt}</span> </> ) : ( <>Nothing saved yet — try ⌘S.</> )} </p> </div> </DemoFrame> );} function NoToolbarDemo() { const [value, setValue] = useState(SHORT_DOC); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> <code>toolbar=false</code> hides the toolbar pane. The view-toggle still renders. Hosts can pass <code>showPreviewToggle=false</code> to lock the editor to a single view. </p> <MarkdownEditor value={value} onChange={setValue} toolbar={false} ariaLabel="No-toolbar demo" minHeight="12rem" /> </div> </DemoFrame> );} function HandleDemo() { const [value, setValue] = useState("Use the buttons below to drive the editor from outside.\n\n"); const ref = useRef<MarkdownEditorHandle>(null); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Imperative handle — <code>focus()</code>, <code>insertText()</code>,{" "} <code>undo()</code>, <code>redo()</code>, <code>getSelection()</code>. </p> <MarkdownEditor ref={ref} value={value} onChange={setValue} ariaLabel="Imperative handle demo" minHeight="14rem" /> <div className="flex flex-wrap gap-2"> <Button type="button" size="sm" variant="outline" onClick={() => ref.current?.focus()}> focus() </Button> <Button type="button" size="sm" variant="outline" onClick={() => ref.current?.insertText("✨ ")} > insertText("✨ ") </Button> <Button type="button" size="sm" variant="outline" onClick={() => ref.current?.undo()} > undo() </Button> <Button type="button" size="sm" variant="outline" onClick={() => ref.current?.redo()} > redo() </Button> <Button type="button" size="sm" variant="outline" onClick={() => { const sel = ref.current?.getSelection(); if (sel) { alert( `selection [${sel.from}, ${sel.to}]: ${sel.text || "(empty)"}`, ); } }} > getSelection() </Button> </div> <Badge variant="secondary" className="font-mono text-[10px]"> {value.length} chars </Badge> </div> </DemoFrame> );} export default function MarkdownEditorDemo() { return ( <Tabs defaultValue="basic"> <SwipeTabsList> <TabsTrigger value="basic">Basic</TabsTrigger> <TabsTrigger value="views">View modes</TabsTrigger> <TabsTrigger value="wikilinks">Wikilinks</TabsTrigger> <TabsTrigger value="custom-toolbar">Custom toolbar</TabsTrigger> <TabsTrigger value="read-only">Read-only</TabsTrigger> <TabsTrigger value="save">onSave</TabsTrigger> <TabsTrigger value="no-toolbar">No toolbar</TabsTrigger> <TabsTrigger value="handle">Imperative handle</TabsTrigger> </SwipeTabsList> <TabsContent value="basic" className="mt-4"> <BasicDemo /> </TabsContent> <TabsContent value="views" className="mt-4"> <ViewModesDemo /> </TabsContent> <TabsContent value="wikilinks" className="mt-4"> <WikilinksDemo /> </TabsContent> <TabsContent value="custom-toolbar" className="mt-4"> <CustomToolbarDemo /> </TabsContent> <TabsContent value="read-only" className="mt-4"> <ReadOnlyDemo /> </TabsContent> <TabsContent value="save" className="mt-4"> <OnSaveDemo /> </TabsContent> <TabsContent value="no-toolbar" className="mt-4"> <NoToolbarDemo /> </TabsContent> <TabsContent value="handle" className="mt-4"> <HandleDemo /> </TabsContent> </Tabs> );} 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: "" },
{
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
- v0.1.5 — `ToolbarItem.run` is optional: a separator is declarative (empty-string label) instead of carrying a no-op `run`
- 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)