Skip to content
ilinxa/pro-ui

Markdown Editor

alphav0.1.5

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

Category: FormsUpdated: 2026-08-19Created: 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
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

CLI 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

demo.tsxtsx
"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&apos;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(&quot;✨ &quot;)          </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 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: "" },
  {
    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

  • 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)

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