{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "markdown-editor",
  "type": "registry:ui",
  "title": "Markdown editor",
  "description": "A markdown editor with a toolbar that wraps the selection, Cmd/Ctrl+B and I shortcuts, write, split and preview modes, a live preview rendered without innerHTML, and a word count.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ui.minidev.pro/r/segmented-control.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/markdown-editor.tsx",
      "type": "registry:ui",
      "target": "components/ui/markdown-editor.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { BoldIcon, CodeIcon, Columns2Icon, EyeIcon, HeadingIcon, ItalicIcon, LinkIcon, ListIcon, PencilIcon, TextQuoteIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { SegmentedControl } from \"@/components/ui/segmented-control\"\n\n/** A markdown editor with a toolbar that wraps the selection, Cmd/Ctrl+B and I shortcuts, write, split and preview modes, a live preview rendered without innerHTML, and a word count. */\ntype MarkdownEditorMode = \"write\" | \"split\" | \"preview\"\n\ntype MarkdownEditorProps = {\n  value?: string\n  defaultValue?: string\n  onChange?: (value: string) => void\n  mode?: MarkdownEditorMode\n  defaultMode?: MarkdownEditorMode\n  onModeChange?: (mode: MarkdownEditorMode) => void\n  label?: string\n  placeholder?: string\n  /** Editor and preview height in px before the textarea is resized. */\n  minHeight?: number\n  disabled?: boolean\n  className?: string\n}\n\ntype Block =\n  | { type: \"h\"; level: number; text: string }\n  | { type: \"p\"; text: string }\n  | { type: \"code\"; lang: string; code: string }\n  | { type: \"quote\"; blocks: Block[] }\n  | { type: \"ul\"; items: string[] }\n  | { type: \"ol\"; items: string[]; start: number }\n  | { type: \"hr\" }\n\nconst SAMPLE = `## Lumen 2.4: Scheduled payouts\n\nPayouts can now run on a schedule. Pick **daily**, **weekly** or **monthly** under *Settings, Payouts* and Lumen batches everything that cleared since the last run.\n\n### What's new\n\n- Scheduled payouts with a preview of the next batch\n- Invoice reminders now respect the customer's time zone\n- \\`GET /v1/payouts\\` returns the schedule that produced each payout\n\n### Fixes\n\n1. Rounding on multi-currency refunds\n2. Exports above 50,000 rows no longer time out\n\n> Heads up: the legacy \\`payout.created\\` webhook is deprecated and stops firing on 1 December.\n\nRead the [full changelog](https://lumen.app/changelog) for details.`\n\nconst FENCE = /^```\\s*([\\w-]*)\\s*$/\nconst HEADING = /^(#{1,6})\\s+(.*?)\\s*#*\\s*$/\nconst RULE = /^(?:-{3,}|\\*{3,}|_{3,})\\s*$/\nconst QUOTE = /^>\\s?/\nconst BULLET = /^\\s*[-*+]\\s+/\nconst NUMBER = /^\\s*(\\d+)[.)]\\s+/\n\nfunction parseBlocks(src: string): Block[] {\n  const lines = src.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\")\n  const out: Block[] = []\n  let i = 0\n  while (i < lines.length) {\n    const line = lines[i]\n    if (!line.trim()) {\n      i++\n      continue\n    }\n    const fence = line.match(FENCE)\n    if (fence) {\n      const code: string[] = []\n      i++\n      while (i < lines.length && !/^```\\s*$/.test(lines[i])) code.push(lines[i++])\n      i++\n      out.push({ type: \"code\", lang: fence[1], code: code.join(\"\\n\") })\n      continue\n    }\n    const h = line.match(HEADING)\n    if (h) {\n      out.push({ type: \"h\", level: h[1].length, text: h[2] })\n      i++\n      continue\n    }\n    if (RULE.test(line)) {\n      out.push({ type: \"hr\" })\n      i++\n      continue\n    }\n    if (QUOTE.test(line)) {\n      const inner: string[] = []\n      while (i < lines.length && QUOTE.test(lines[i])) inner.push(lines[i++].replace(QUOTE, \"\"))\n      out.push({ type: \"quote\", blocks: parseBlocks(inner.join(\"\\n\")) })\n      continue\n    }\n    if (BULLET.test(line)) {\n      const items: string[] = []\n      while (i < lines.length && BULLET.test(lines[i])) items.push(lines[i++].replace(BULLET, \"\"))\n      out.push({ type: \"ul\", items })\n      continue\n    }\n    const num = line.match(NUMBER)\n    if (num) {\n      const items: string[] = []\n      while (i < lines.length && NUMBER.test(lines[i])) items.push(lines[i++].replace(NUMBER, \"\"))\n      out.push({ type: \"ol\", items, start: parseInt(num[1], 10) || 1 })\n      continue\n    }\n    const para: string[] = []\n    while (i < lines.length && lines[i].trim() && !FENCE.test(lines[i]) && !HEADING.test(lines[i]) && !QUOTE.test(lines[i]) && !BULLET.test(lines[i]) && !NUMBER.test(lines[i]) && !RULE.test(lines[i])) {\n      para.push(lines[i++].trim())\n    }\n    out.push({ type: \"p\", text: para.join(\" \") })\n  }\n  return out\n}\n\nfunction safeHref(raw: string) {\n  const u = raw.trim()\n  return /^(https?:\\/\\/|mailto:|\\/|#|\\.{1,2}\\/)/i.test(u) ? u : null\n}\n\nconst wordy = (c: string | undefined) => !!c && /[\\p{L}\\p{N}]/u.test(c)\n\n/** Inline markdown to React nodes. Everything is a text node or an element we create, never HTML. */\nfunction inline(text: string): React.ReactNode[] {\n  const out: React.ReactNode[] = []\n  let buf = \"\"\n  let i = 0\n  let k = 0\n  const flush = () => {\n    if (buf) out.push(buf)\n    buf = \"\"\n  }\n  const closing = (mark: string, from: number) => {\n    let j = text.indexOf(mark, from)\n    while (j !== -1) {\n      const after = text[j + mark.length]\n      const before = text[j - 1]\n      if (before !== \" \" && (mark !== \"_\" || !wordy(after)) && after !== mark[0]) return j\n      j = text.indexOf(mark, j + 1)\n    }\n    return -1\n  }\n  while (i < text.length) {\n    const ch = text[i]\n    if (ch === \"\\\\\" && i + 1 < text.length && /[\\\\`*_~[\\]()#>+!-]/.test(text[i + 1])) {\n      buf += text[i + 1]\n      i += 2\n      continue\n    }\n    if (ch === \"`\") {\n      const end = text.indexOf(\"`\", i + 1)\n      if (end > i + 1) {\n        flush()\n        out.push(\n          <code key={k++} className=\"rounded-[5px] border border-border bg-sunken px-1 py-px font-mono text-[0.85em] text-fg\">\n            {text.slice(i + 1, end)}\n          </code>\n        )\n        i = end + 1\n        continue\n      }\n    }\n    let matched = false\n    for (const [mark, Tag, cls] of [\n      [\"**\", \"strong\", \"font-semibold text-fg\"],\n      [\"__\", \"strong\", \"font-semibold text-fg\"],\n      [\"~~\", \"s\", \"text-fg-muted\"],\n    ] as const) {\n      if (text.startsWith(mark, i) && text[i + 2] !== \" \") {\n        const end = closing(mark, i + 2)\n        if (end > i + 2) {\n          flush()\n          out.push(\n            <Tag key={k++} className={cls}>\n              {inline(text.slice(i + 2, end))}\n            </Tag>\n          )\n          i = end + 2\n          matched = true\n          break\n        }\n      }\n    }\n    if (matched) continue\n    if ((ch === \"*\" || ch === \"_\") && text[i + 1] !== \" \" && text[i + 1] !== ch && (ch === \"*\" || !wordy(text[i - 1]))) {\n      const end = closing(ch, i + 1)\n      if (end > i + 1) {\n        flush()\n        out.push(\n          <em key={k++} className=\"italic\">\n            {inline(text.slice(i + 1, end))}\n          </em>\n        )\n        i = end + 1\n        continue\n      }\n    }\n    if (ch === \"[\") {\n      const mid = text.indexOf(\"](\", i + 1)\n      const end = mid > 0 ? text.indexOf(\")\", mid + 2) : -1\n      if (mid > i && end > mid) {\n        const label = text.slice(i + 1, mid)\n        const href = safeHref(text.slice(mid + 2, end))\n        flush()\n        out.push(\n          href ? (\n            <a\n              key={k++}\n              href={href}\n              target={/^https?:/i.test(href) ? \"_blank\" : undefined}\n              rel={/^https?:/i.test(href) ? \"noopener noreferrer\" : undefined}\n              className=\"rounded-sm text-accent-fg underline decoration-accent-line underline-offset-4 outline-none hover:decoration-accent focus-visible:ring-2 focus-visible:ring-accent\"\n            >\n              {inline(label)}\n            </a>\n          ) : (\n            <span key={k++}>{inline(label)}</span>\n          )\n        )\n        i = end + 1\n        continue\n      }\n    }\n    buf += ch\n    i++\n  }\n  flush()\n  return out\n}\n\nconst HEADING_CLASS: Record<number, string> = {\n  1: \"text-2xl font-medium tracking-[-0.02em] text-fg\",\n  2: \"text-xl font-medium tracking-[-0.014em] text-fg\",\n  3: \"text-base font-medium text-fg\",\n  4: \"text-sm font-medium text-fg\",\n  5: \"text-sm font-medium text-fg-muted\",\n  6: \"text-xs font-medium tracking-[0.04em] text-fg-muted uppercase\",\n}\n\nfunction Blocks({ blocks }: { blocks: Block[] }) {\n  return (\n    <>\n      {blocks.map((b, i) => {\n        switch (b.type) {\n          case \"h\": {\n            const Tag = `h${Math.min(6, b.level)}` as \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\"\n            return (\n              <Tag key={i} className={cn(HEADING_CLASS[b.level], \"text-balance\", i > 0 && \"mt-5\")}>\n                {inline(b.text)}\n              </Tag>\n            )\n          }\n          case \"p\":\n            return (\n              <p key={i} className=\"text-pretty text-sm leading-[1.65] text-fg\">\n                {inline(b.text)}\n              </p>\n            )\n          case \"code\":\n            return (\n              <pre key={i} className=\"overflow-x-auto rounded-lg border border-border bg-sunken p-3 font-mono text-xs leading-[1.6] text-fg\" data-lang={b.lang || undefined}>\n                <code>{b.code}</code>\n              </pre>\n            )\n          case \"quote\":\n            return (\n              <blockquote key={i} className=\"space-y-2 border-l-2 border-border-strong pl-3 text-fg-muted [&_p]:text-fg-muted\">\n                <Blocks blocks={b.blocks} />\n              </blockquote>\n            )\n          case \"ul\":\n            return (\n              <ul key={i} className=\"list-disc space-y-1 pl-5 text-sm leading-[1.6] text-fg marker:text-fg-subtle\">\n                {b.items.map((it, j) => (\n                  <li key={j}>{inline(it)}</li>\n                ))}\n              </ul>\n            )\n          case \"ol\":\n            return (\n              <ol key={i} start={b.start} className=\"list-decimal space-y-1 pl-5 text-sm leading-[1.6] text-fg marker:text-fg-subtle marker:tabular-nums\">\n                {b.items.map((it, j) => (\n                  <li key={j}>{inline(it)}</li>\n                ))}\n              </ol>\n            )\n          case \"hr\":\n            return <hr key={i} className=\"border-border\" />\n        }\n      })}\n    </>\n  )\n}\n\n/** Renders markdown as React elements: headings, paragraphs, emphasis, links, lists, code and quotes. */\nfunction MarkdownPreview({ source, className }: { source: string; className?: string }) {\n  const blocks = React.useMemo(() => parseBlocks(source), [source])\n  return (\n    <div data-slot=\"markdown-preview\" className={cn(\"space-y-3\", className)}>\n      {blocks.length ? <Blocks blocks={blocks} /> : <p className=\"text-sm text-fg-subtle\">Nothing to preview yet.</p>}\n    </div>\n  )\n}\n\ntype Tool = { id: string; label: string; icon: React.ReactNode; keys?: string }\n\nconst TOOLS: Tool[] = [\n  { id: \"bold\", label: \"Bold\", icon: <BoldIcon />, keys: \"B\" },\n  { id: \"italic\", label: \"Italic\", icon: <ItalicIcon />, keys: \"I\" },\n  { id: \"code\", label: \"Code\", icon: <CodeIcon />, keys: \"E\" },\n  { id: \"link\", label: \"Link\", icon: <LinkIcon />, keys: \"K\" },\n  { id: \"heading\", label: \"Heading\", icon: <HeadingIcon /> },\n  { id: \"list\", label: \"Bulleted list\", icon: <ListIcon /> },\n  { id: \"quote\", label: \"Quote\", icon: <TextQuoteIcon /> },\n]\n\nconst MODES = [\n  { value: \"write\", label: \"Write\", icon: <PencilIcon /> },\n  { value: \"split\", label: \"Split\", icon: <Columns2Icon /> },\n  { value: \"preview\", label: \"Preview\", icon: <EyeIcon /> },\n]\n\n/**\n * A markdown editor: a textarea with a formatting toolbar that wraps or\n * unwraps the selection, keyboard shortcuts, and a live preview built from\n * React elements rather than HTML strings. Write, split and preview modes\n * share the same text, and the footer keeps a word count.\n */\nfunction MarkdownEditor({\n  value: valueProp,\n  defaultValue = SAMPLE,\n  onChange,\n  mode: modeProp,\n  defaultMode = \"split\",\n  onModeChange,\n  label = \"Release notes\",\n  placeholder = \"Write in markdown…\",\n  minHeight = 300,\n  disabled,\n  className,\n}: MarkdownEditorProps) {\n  const [inner, setInner] = React.useState(defaultValue)\n  const value = valueProp ?? inner\n  const [innerMode, setInnerMode] = React.useState<MarkdownEditorMode>(defaultMode)\n  const mode = modeProp ?? innerMode\n  const [sel, setSel] = React.useState<[number, number]>([0, 0])\n  const [mac, setMac] = React.useState(false)\n  const ta = React.useRef<HTMLTextAreaElement>(null)\n  const pendingSel = React.useRef<[number, number] | null>(null)\n  const id = React.useId()\n\n  React.useEffect(() => {\n    setMac(/Mac|iPhone|iPad/.test(navigator.platform ?? \"\") || /Mac/.test(navigator.userAgent))\n  }, [])\n\n  React.useLayoutEffect(() => {\n    const p = pendingSel.current\n    const el = ta.current\n    if (!p || !el) return\n    pendingSel.current = null\n    el.focus()\n    el.setSelectionRange(p[0], p[1])\n    setSel(p)\n  }, [value])\n\n  const setMode = (m: MarkdownEditorMode) => {\n    if (modeProp === undefined) setInnerMode(m)\n    onModeChange?.(m)\n  }\n\n  const update = (next: string, s: number, e: number) => {\n    pendingSel.current = [s, e]\n    if (valueProp === undefined) setInner(next)\n    onChange?.(next)\n  }\n\n  const current = () => {\n    const el = ta.current\n    return el ? ([el.selectionStart, el.selectionEnd] as [number, number]) : sel\n  }\n\n  const wrap = (before: string, after: string, fallback: string) => {\n    const [s, e] = current()\n    const chosen = value.slice(s, e)\n    if (chosen.startsWith(before) && chosen.endsWith(after) && chosen.length >= before.length + after.length) {\n      const text = chosen.slice(before.length, chosen.length - after.length)\n      update(value.slice(0, s) + text + value.slice(e), s, s + text.length)\n      return\n    }\n    if (value.slice(s - before.length, s) === before && value.slice(e, e + after.length) === after) {\n      update(value.slice(0, s - before.length) + chosen + value.slice(e + after.length), s - before.length, e - before.length)\n      return\n    }\n    const text = chosen || fallback\n    update(value.slice(0, s) + before + text + after + value.slice(e), s + before.length, s + before.length + text.length)\n  }\n\n  const prefixLines = (test: RegExp, add: string) => {\n    const [s, e] = current()\n    const ls = value.lastIndexOf(\"\\n\", s - 1) + 1\n    const leRaw = value.indexOf(\"\\n\", Math.max(e, ls))\n    const le = leRaw === -1 ? value.length : leRaw\n    const lines = value.slice(ls, le).split(\"\\n\")\n    const all = lines.every((l) => test.test(l))\n    const next = lines.map((l) => (all ? l.replace(test, \"\") : add + l.replace(test, \"\"))).join(\"\\n\")\n    update(value.slice(0, ls) + next + value.slice(le), ls, ls + next.length)\n  }\n\n  const link = () => {\n    const [s, e] = current()\n    const chosen = value.slice(s, e)\n    const isUrl = /^https?:\\/\\/\\S+$/i.test(chosen)\n    const text = isUrl ? \"link text\" : chosen || \"link text\"\n    const url = isUrl ? chosen : \"https://\"\n    const ins = `[${text}](${url})`\n    const from = isUrl ? s + 1 : s + 1 + text.length + 2\n    const to = isUrl ? s + 1 + text.length : from + url.length\n    update(value.slice(0, s) + ins + value.slice(e), from, to)\n  }\n\n  const run = (tool: string) => {\n    if (disabled) return\n    if (tool === \"bold\") wrap(\"**\", \"**\", \"bold text\")\n    else if (tool === \"italic\") wrap(\"_\", \"_\", \"italic text\")\n    else if (tool === \"code\") wrap(\"`\", \"`\", \"code\")\n    else if (tool === \"link\") link()\n    else if (tool === \"heading\") prefixLines(/^#{1,6}\\s+/, \"## \")\n    else if (tool === \"list\") prefixLines(/^\\s*[-*+]\\s+/, \"- \")\n    else if (tool === \"quote\") prefixLines(/^>\\s?/, \"> \")\n  }\n\n  const pressed = (tool: string) => {\n    const [s, e] = sel\n    const m = tool === \"bold\" ? \"**\" : tool === \"italic\" ? \"_\" : tool === \"code\" ? \"`\" : null\n    if (!m) return undefined\n    const chosen = value.slice(s, e)\n    return (chosen.length > m.length * 2 && chosen.startsWith(m) && chosen.endsWith(m)) || (value.slice(s - m.length, s) === m && value.slice(e, e + m.length) === m)\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (!(e.metaKey || e.ctrlKey) || e.altKey) return\n    const key = e.key.toLowerCase()\n    const tool = key === \"b\" ? \"bold\" : key === \"i\" ? \"italic\" : key === \"k\" ? \"link\" : key === \"e\" ? \"code\" : null\n    if (!tool) return\n    e.preventDefault()\n    // The editor owns these while focused, so a global command palette on Cmd+K does not open.\n    e.stopPropagation()\n    run(tool)\n  }\n\n  const words = value.trim() ? value.trim().split(/\\s+/).length : 0\n  const chars = value.length\n  const combo = (k: string) => (mac ? `⌘${k}` : `Ctrl+${k}`)\n  const showEditor = mode !== \"preview\"\n  const showPreview = mode !== \"write\"\n\n  return (\n    <div data-slot=\"markdown-editor\" className={cn(\"w-full max-w-3xl rounded-xl border border-border bg-surface shadow-raised\", disabled && \"opacity-60\", className)}>\n      <div className=\"flex flex-wrap items-center justify-between gap-2 border-b border-border px-2 py-2\">\n        <div role=\"toolbar\" aria-label=\"Formatting\" aria-controls={`${id}-text`} className=\"flex items-center gap-0.5\">\n          <span className=\"sr-only\">{label}</span>\n          {TOOLS.map((t, i) => (\n            <React.Fragment key={t.id}>\n              {i === 4 ? <span aria-hidden className=\"mx-1 h-4 w-px bg-border\" /> : null}\n              <button\n                type=\"button\"\n                aria-label={t.keys ? `${t.label} (${combo(t.keys)})` : t.label}\n                title={t.keys ? `${t.label} (${combo(t.keys)})` : t.label}\n                aria-pressed={pressed(t.id)}\n                disabled={disabled || !showEditor}\n                onMouseDown={(e) => e.preventDefault()}\n                onClick={() => run(t.id)}\n                className={cn(\n                  \"grid size-8 place-items-center rounded-md text-fg-muted outline-none transition-[background-color,color] duration-[70ms] ease-hairline\",\n                  \"hover:bg-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-40\",\n                  \"aria-pressed:bg-accent-soft aria-pressed:text-accent-fg [&_svg]:size-4\"\n                )}\n              >\n                {t.icon}\n              </button>\n            </React.Fragment>\n          ))}\n        </div>\n        <SegmentedControl size=\"sm\" aria-label=\"Editor mode\" options={MODES} value={mode} onChange={(v) => setMode(v as MarkdownEditorMode)} />\n      </div>\n\n      <div className={cn(\"grid gap-0\", showEditor && showPreview && \"sm:grid-cols-2\")}>\n        {showEditor ? (\n          <div className={cn(\"flex flex-col p-2\", showPreview && \"border-b border-border sm:border-r sm:border-b-0\")}>\n            <label htmlFor={`${id}-text`} className=\"sr-only\">\n              {label}\n            </label>\n            <textarea\n              ref={ta}\n              id={`${id}-text`}\n              value={value}\n              placeholder={placeholder}\n              disabled={disabled}\n              spellCheck\n              onChange={(e) => {\n                if (valueProp === undefined) setInner(e.target.value)\n                onChange?.(e.target.value)\n              }}\n              onSelect={(e) => setSel([e.currentTarget.selectionStart, e.currentTarget.selectionEnd])}\n              onKeyDown={onKeyDown}\n              className={cn(\n                \"block w-full flex-1 resize-y rounded-lg border border-transparent bg-surface px-3 py-2.5 font-mono text-[0.8125rem] leading-[1.65] text-fg outline-none placeholder:text-fg-subtle\",\n                \"transition-[border-color,box-shadow] duration-[140ms] ease-hairline focus-visible:border-accent focus-visible:shadow-[0_0_0_3px_var(--accent-soft)]\",\n                \"disabled:cursor-not-allowed\"\n              )}\n              style={{ minHeight }}\n            />\n          </div>\n        ) : null}\n        {showPreview ? (\n          <div className=\"overflow-auto p-5\" style={{ minHeight: minHeight + 16 }} aria-live=\"polite\" aria-label=\"Preview\">\n            <MarkdownPreview source={value} />\n          </div>\n        ) : null}\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-2 border-t border-border bg-bg px-3 py-1.5 text-[11px] text-fg-subtle\">\n        <span className=\"tabular-nums\">\n          {words.toLocaleString(\"en-US\")} {words === 1 ? \"word\" : \"words\"} · {chars.toLocaleString(\"en-US\")} characters\n        </span>\n        <span className=\"hidden items-center gap-1.5 sm:inline-flex\">\n          Markdown\n          <kbd className=\"inline-flex h-4 items-center rounded-[4px] border border-border bg-surface px-1 font-sans text-[10px] font-medium text-fg-muted shadow-key\">{combo(\"B\")}</kbd>\n          <kbd className=\"inline-flex h-4 items-center rounded-[4px] border border-border bg-surface px-1 font-sans text-[10px] font-medium text-fg-muted shadow-key\">{combo(\"I\")}</kbd>\n          <kbd className=\"inline-flex h-4 items-center rounded-[4px] border border-border bg-surface px-1 font-sans text-[10px] font-medium text-fg-muted shadow-key\">{combo(\"K\")}</kbd>\n        </span>\n      </div>\n    </div>\n  )\n}\n\nexport { MarkdownEditor, MarkdownPreview }\nexport type { MarkdownEditorProps, MarkdownEditorMode }\n"
    }
  ]
}
