Skip to content
ilinxa/pro-ui

Markdown Editor

alphav0.1.4

CodeMirror 6 markdown editor with GFM, wikilink autocomplete, a slot-able toolbar, and edit, split, and preview modes.

Category: FormsUpdated: 2026-08-11Created: 2026-04-29Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/markdown-editor

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/markdown-editor-fixtures

Preview

Controlled value / onChange. Try **bold**, *italic*, or ⌘B.

Length: 142 chars

Demo source

demo.tsxtsx

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 value and listens to onChange. 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 onSave handler 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 receives label. 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 ![alt](src).
  • 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 onSave handler is wired. Otherwise the browser dialog fires. Payload is the live CM6 doc, not the React value prop (avoids stale-by-React-batching).
  • Extension precedence: user-supplied extensions are appended LAST in the CM6 stack — earlier entries have HIGHER default precedence. To override our keymap, wrap with Prec.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)

Tags

markdown-editorcodemirrorwikilinksgraph-systemeditor

Dependencies

shadcn primitives: badge, button, tabs, tooltip
npm peer deps: @codemirror/state@^6.6.0, @codemirror/view@^6.41.1, @codemirror/commands@^6.10.3, @codemirror/language@^6.12.3, @codemirror/lang-markdown@^6.5.0, @codemirror/autocomplete@^6.20.1, @codemirror/search@^6.7.0, @lezer/markdown@^1.6.3, @lezer/highlight@^1.2.3, marked@^18.0.2, lucide-react@^1.11.0