{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "markdown-editor",
  "type": "registry:ui",
  "title": "Markdown Editor",
  "description": "A textarea Markdown editor with a roving-focus toolbar, undoable formatting, shortcuts and a safe Write / Preview.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/markdown-editor.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Safe Markdown subset → React elements                                     */\n/* -------------------------------------------------------------------------- */\n\nconst SAFE_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\"]);\n\n/** Returns a normalised href for http(s)/mailto URLs, or null for anything else (javascript:, data:, relative…). */\nexport function safeHref(raw: string): string | null {\n  try {\n    // The URL parser also strips the tabs/newlines used to disguise \"java\\tscript:\".\n    const url = new URL(raw.trim());\n    return SAFE_PROTOCOLS.has(url.protocol) ? url.href : null;\n  } catch {\n    return null;\n  }\n}\n\n// One pass over inline syntax. Order matters: code spans win, then escapes, links, bold, italic.\nconst INLINE =\n  /(`+)([^`]|[^`][\\s\\S]*?[^`])\\1(?!`)|\\\\([\\\\`*_[\\]()#>+\\-.!])|\\[([^\\]\\n]+)\\]\\(((?:[^()\\s]|\\([^()\\s]*\\))+)\\)|\\*\\*(?=\\S)([\\s\\S]*?\\S)\\*\\*|__(?=\\S)([\\s\\S]*?\\S)__|\\*(?=[^\\s*])([\\s\\S]*?[^\\s*])\\*|(?<![A-Za-z0-9])_(?=[^\\s_])([\\s\\S]*?[^\\s_])_(?![A-Za-z0-9])/g;\n\nfunction renderInline(text: string, key: string, depth = 0): React.ReactNode[] {\n  const out: React.ReactNode[] = [];\n  let last = 0;\n  let n = 0;\n  // Strings become React text nodes, so any HTML in the source is escaped, never parsed.\n  for (const m of text.matchAll(INLINE)) {\n    const at = m.index ?? 0;\n    if (at > last) out.push(text.slice(last, at));\n    const k = `${key}-${n++}`;\n    const nest = (s: string) => (depth < 4 ? renderInline(s, k, depth + 1) : [s]);\n    if (m[2] !== undefined) {\n      out.push(\n        <code key={k} className=\"rounded bg-zinc-900/[0.06] px-1 py-px font-mono text-[0.88em] text-zinc-900 dark:bg-white/10 dark:text-zinc-100\">\n          {m[2].replace(/^ (.*) $/, \"$1\")}\n        </code>,\n      );\n    } else if (m[3] !== undefined) {\n      out.push(m[3]);\n    } else if (m[4] !== undefined) {\n      const href = safeHref(m[5]);\n      out.push(\n        href ? (\n          <a\n            key={k}\n            href={href}\n            target=\"_blank\"\n            rel=\"noopener noreferrer nofollow ugc\"\n            className=\"font-medium text-zinc-950 underline decoration-[var(--me-accent)] decoration-[1.5px] underline-offset-[3px] dark:text-white\"\n          >\n            {nest(m[4])}\n          </a>\n        ) : (\n          // Unsafe or relative URL: keep the text, drop the link.\n          <span key={k} title=\"Link removed: only http, https and mailto links are allowed\" className=\"underline decoration-dotted underline-offset-[3px]\">\n            {nest(m[4])}\n          </span>\n        ),\n      );\n    } else if (m[6] !== undefined || m[7] !== undefined) {\n      out.push(\n        <strong key={k} className=\"font-semibold text-zinc-950 dark:text-white\">\n          {nest(m[6] ?? m[7])}\n        </strong>,\n      );\n    } else {\n      out.push(<em key={k}>{nest(m[8] ?? m[9])}</em>);\n    }\n    last = at + m[0].length;\n  }\n  if (last < text.length) out.push(text.slice(last));\n  return out;\n}\n\ntype Block =\n  | { type: \"heading\"; level: number; text: string }\n  | { type: \"paragraph\"; lines: string[] }\n  | { type: \"list\"; ordered: boolean; start: number; items: string[] }\n  | { type: \"quote\"; lines: string[] }\n  | { type: \"code\"; text: string };\n\nconst HEADING = /^(#{1,6})\\s+(.*?)\\s*#*\\s*$/;\nconst BULLET = /^\\s{0,3}[-*+]\\s+(.*)$/;\nconst NUMBERED = /^\\s{0,3}(\\d{1,9})[.)]\\s+(.*)$/;\nconst QUOTE = /^\\s{0,3}>\\s?(.*)$/;\nconst FENCE = /^\\s{0,3}```/;\n\nfunction parseBlocks(source: string): Block[] {\n  const lines = source.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n  const blocks: 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    if (FENCE.test(line)) {\n      const body: string[] = [];\n      i++;\n      while (i < lines.length && !FENCE.test(lines[i])) body.push(lines[i++]);\n      i++; // closing fence (or end of input)\n      blocks.push({ type: \"code\", text: body.join(\"\\n\") });\n      continue;\n    }\n    const heading = HEADING.exec(line);\n    if (heading) {\n      blocks.push({ type: \"heading\", level: heading[1].length, text: heading[2] });\n      i++;\n      continue;\n    }\n    if (QUOTE.test(line)) {\n      const body: string[] = [];\n      while (i < lines.length && QUOTE.test(lines[i])) body.push(QUOTE.exec(lines[i++])![1]);\n      blocks.push({ type: \"quote\", lines: body });\n      continue;\n    }\n    const bullet = BULLET.exec(line);\n    const numbered = NUMBERED.exec(line);\n    if (bullet || numbered) {\n      const ordered = !bullet;\n      const re = ordered ? NUMBERED : BULLET;\n      const items: string[] = [];\n      while (i < lines.length) {\n        const m = re.exec(lines[i]);\n        if (m) {\n          items.push(ordered ? m[2] : m[1]);\n          i++;\n        } else if (items.length && /^\\s{2,}\\S/.test(lines[i])) {\n          items[items.length - 1] += \" \" + lines[i++].trim(); // indented continuation line\n        } else break;\n      }\n      blocks.push({ type: \"list\", ordered, start: numbered ? Number(numbered[1]) : 1, items });\n      continue;\n    }\n    const para: string[] = [];\n    while (\n      i < lines.length &&\n      lines[i].trim() &&\n      !FENCE.test(lines[i]) &&\n      !HEADING.test(lines[i]) &&\n      !QUOTE.test(lines[i]) &&\n      !BULLET.test(lines[i]) &&\n      !NUMBERED.test(lines[i])\n    )\n      para.push(lines[i++]);\n    blocks.push({ type: \"paragraph\", lines: para });\n  }\n  return blocks;\n}\n\nconst HEADING_CLASS = [\n  \"text-[1.5em] font-semibold tracking-tight\",\n  \"text-[1.3em] font-semibold tracking-tight\",\n  \"text-[1.12em] font-semibold tracking-tight\",\n  \"text-[1em] font-semibold\",\n  \"text-[0.92em] font-semibold\",\n  \"text-[0.85em] font-semibold uppercase tracking-[0.06em]\",\n];\n\nfunction renderLines(lines: string[], key: string) {\n  // Single line breaks are kept (comment-style), joined with <br>.\n  return lines.flatMap((l, i) => (i ? [<br key={`${key}-br${i}`} />, ...renderInline(l, `${key}-${i}`)] : renderInline(l, `${key}-${i}`)));\n}\n\nfunction renderBlocks(blocks: Block[], key = \"b\", depth = 0): React.ReactNode[] {\n  return blocks.map((block, i) => {\n    const k = `${key}-${i}`;\n    switch (block.type) {\n      case \"heading\": {\n        const Tag = `h${block.level}` as \"h1\";\n        return (\n          <Tag key={k} className={`text-zinc-950 dark:text-white ${HEADING_CLASS[block.level - 1]}`}>\n            {renderInline(block.text, k)}\n          </Tag>\n        );\n      }\n      case \"paragraph\":\n        return <p key={k}>{renderLines(block.lines, k)}</p>;\n      case \"list\": {\n        const items = block.items.map((item, j) => (\n          <li key={j} className=\"pl-1\">\n            {renderInline(item, `${k}-${j}`)}\n          </li>\n        ));\n        return block.ordered ? (\n          <ol key={k} start={block.start} className=\"list-decimal space-y-1 pl-6 marker:font-mono marker:text-[0.85em] marker:text-zinc-400\">\n            {items}\n          </ol>\n        ) : (\n          <ul key={k} className=\"list-disc space-y-1 pl-6 marker:text-[var(--me-accent)]\">\n            {items}\n          </ul>\n        );\n      }\n      case \"quote\":\n        return (\n          <blockquote key={k} className=\"space-y-3 border-l-2 border-[var(--me-accent)] pl-4 text-zinc-600 dark:text-zinc-400\">\n            {depth < 3 ? renderBlocks(parseBlocks(block.lines.join(\"\\n\")), k, depth + 1) : block.lines.join(\" \")}\n          </blockquote>\n        );\n      case \"code\":\n        return (\n          <pre key={k} className=\"overflow-x-auto rounded-lg border border-zinc-900/10 bg-zinc-900/[0.03] p-3 font-mono text-[12.5px] leading-relaxed text-zinc-800 dark:border-white/10 dark:bg-white/[0.04] dark:text-zinc-200\">\n            <code>{block.text}</code>\n          </pre>\n        );\n    }\n  });\n}\n\n/** Renders the supported Markdown subset as React elements (no HTML strings, no dangerouslySetInnerHTML). */\nexport function MarkdownPreview({ source, className = \"\" }: { source: string; className?: string }) {\n  return (\n    <div className={`space-y-3 break-words text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 ${className}`}>\n      {renderBlocks(parseBlocks(source))}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Text edits                                                                */\n/* -------------------------------------------------------------------------- */\n\n/** Replace [start, end) with `text`, then select [selStart, selEnd) in the new value. */\ninterface Edit {\n  start: number;\n  end: number;\n  text: string;\n  selStart: number;\n  selEnd: number;\n}\n\nfunction wrapEdit(value: string, s: number, e: number, marker: string, placeholder: string): Edit {\n  const m = marker.length;\n  const sel = value.slice(s, e);\n  // Toggle off: markers just outside the selection…\n  if (s >= m && value.slice(s - m, s) === marker && value.slice(e, e + m) === marker) {\n    return { start: s - m, end: e + m, text: sel, selStart: s - m, selEnd: e - m };\n  }\n  // …or included in it.\n  if (sel.length > 2 * m && sel.startsWith(marker) && sel.endsWith(marker)) {\n    return { start: s, end: e, text: sel.slice(m, -m), selStart: s, selEnd: e - 2 * m };\n  }\n  const inner = sel || placeholder;\n  // Keep surrounding spaces outside the markers: \"word \" → \"**word** \".\n  const lead = /^\\s*/.exec(inner)![0];\n  const trail = inner.length > lead.length ? /\\s*$/.exec(inner)![0] : \"\";\n  const core = inner.slice(lead.length, inner.length - trail.length) || placeholder;\n  const startCore = s + lead.length + m;\n  return { start: s, end: e, text: `${lead}${marker}${core}${marker}${trail}`, selStart: startCore, selEnd: startCore + core.length };\n}\n\ntype LineKind = \"heading\" | \"bullet\" | \"number\" | \"quote\";\nconst LINE_PATTERNS: Record<LineKind, RegExp> = {\n  heading: /^#{1,6}\\s+/,\n  bullet: /^[-*+]\\s+/,\n  number: /^\\d+[.)]\\s+/,\n  quote: /^>\\s?/,\n};\nconst ANY_BLOCK_PREFIX = /^(#{1,6}\\s+|[-*+]\\s+|\\d+[.)]\\s+)/;\n\nfunction lineEdit(value: string, s: number, e: number, kind: LineKind): Edit {\n  const start = value.lastIndexOf(\"\\n\", s - 1) + 1;\n  // A selection ending right after a newline does not include the next line.\n  const endFrom = e > s && value[e - 1] === \"\\n\" ? e - 1 : e;\n  const nl = value.indexOf(\"\\n\", endFrom);\n  const end = nl === -1 ? value.length : nl;\n  const lines = value.slice(start, end).split(\"\\n\");\n  const re = LINE_PATTERNS[kind];\n  const content = lines.filter((l) => l.trim());\n  const remove = content.length > 0 && content.every((l) => re.test(l));\n  let counter = 0;\n  const next = lines.map((line) => {\n    if (remove) return line.replace(re, \"\");\n    if (!line.trim() && lines.length > 1) return line;\n    if (kind === \"quote\") return `> ${line}`;\n    // Switching between heading / bullet / numbered replaces the old prefix.\n    const bare = line.replace(ANY_BLOCK_PREFIX, \"\");\n    if (kind === \"heading\") return `## ${bare}`;\n    if (kind === \"bullet\") return `- ${bare}`;\n    return `${++counter}. ${bare}`;\n  });\n  const text = next.join(\"\\n\");\n  // Single caret on one line: keep the caret at the end of that line; otherwise select the block.\n  if (s === e && lines.length === 1) return { start, end, text, selStart: start + text.length, selEnd: start + text.length };\n  return { start, end, text, selStart: start, selEnd: start + text.length };\n}\n\nfunction linkEdit(value: string, s: number, e: number): Edit {\n  const sel = value.slice(s, e);\n  if (/^(https?:\\/\\/|mailto:)\\S+$/i.test(sel.trim())) {\n    // Selected a URL: wrap it and put the caret in the label.\n    return { start: s, end: e, text: `[](${sel.trim()})`, selStart: s + 1, selEnd: s + 1 };\n  }\n  if (!sel) {\n    const label = \"link text\";\n    return { start: s, end: e, text: `[${label}](https://)`, selStart: s + 1, selEnd: s + 1 + label.length };\n  }\n  const urlStart = s + sel.length + 3;\n  return { start: s, end: e, text: `[${sel}](https://)`, selStart: urlStart, selEnd: urlStart + \"https://\".length };\n}\n\nfunction codeEdit(value: string, s: number, e: number): Edit {\n  const sel = value.slice(s, e);\n  if (!sel.includes(\"\\n\")) return wrapEdit(value, s, e, \"`\", \"code\");\n  const before = s > 0 && value[s - 1] !== \"\\n\" ? \"\\n\" : \"\";\n  const after = e < value.length && value[e] !== \"\\n\" ? \"\\n\" : \"\";\n  const text = `${before}\\`\\`\\`\\n${sel.replace(/\\n$/, \"\")}\\n\\`\\`\\`${after}`;\n  const bodyStart = s + before.length + 4;\n  return { start: s, end: e, text, selStart: bodyStart, selEnd: bodyStart + sel.replace(/\\n$/, \"\").length };\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Toolbar                                                                   */\n/* -------------------------------------------------------------------------- */\n\ntype ActionId = \"bold\" | \"italic\" | \"heading\" | \"bullet\" | \"number\" | \"link\" | \"code\" | \"quote\";\n\nconst ACTIONS: { id: ActionId; label: string; shortcut?: string; icon: React.ReactNode }[] = [\n  { id: \"heading\", label: \"Heading\", icon: <path d=\"M4 3v10M12 3v10M4 8h8\" /> },\n  { id: \"bold\", label: \"Bold\", shortcut: \"B\", icon: <path d=\"M5 3h4a2.5 2.5 0 0 1 0 5H5zM5 8h4.6a2.5 2.5 0 0 1 0 5H5z\" /> },\n  { id: \"italic\", label: \"Italic\", shortcut: \"I\", icon: <path d=\"M7 3h5M4 13h5M10 3 6.5 13\" /> },\n  { id: \"quote\", label: \"Quote\", icon: <path d=\"M3 4v8M6.5 5.5h6.5M6.5 8h6.5M6.5 10.5h4\" /> },\n  { id: \"code\", label: \"Code\", icon: <path d=\"M6 4.5 2.5 8 6 11.5M10 4.5l3.5 3.5-3.5 3.5\" /> },\n  { id: \"link\", label: \"Link\", shortcut: \"K\", icon: <path d=\"M7 9.5a2.7 2.7 0 0 0 3.8 0l2-2a2.7 2.7 0 0 0-3.8-3.8l-.6.6M9 6.5a2.7 2.7 0 0 0-3.8 0l-2 2a2.7 2.7 0 0 0 3.8 3.8l.6-.6\" /> },\n  { id: \"bullet\", label: \"Bulleted list\", icon: <path d=\"M6.5 4.5H13M6.5 8H13M6.5 11.5H13M3.2 4.5h.1M3.2 8h.1M3.2 11.5h.1\" /> },\n  { id: \"number\", label: \"Numbered list\", icon: <path d=\"M7 4.5h6M7 8h6M7 11.5h6M2.8 3.5l1-.5v3M2.6 9.5c.3-.5 1.6-.6 1.6.3 0 .6-1.6 1.4-1.6 1.7h1.7\" /> },\n];\n\n/* -------------------------------------------------------------------------- */\n/*  Editor                                                                    */\n/* -------------------------------------------------------------------------- */\n\nexport interface MarkdownEditorProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Controlled Markdown text. */\n  value?: string;\n  /** Initial text when uncontrolled. */\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  label?: React.ReactNode;\n  hint?: React.ReactNode;\n  placeholder?: string;\n  /** Maximum characters; 0 means no limit (the count is still shown). */\n  maxLength?: number;\n  /** Rows shown when empty; the textarea grows from here. */\n  minRows?: number;\n  /** Rows before the textarea stops growing and scrolls. */\n  maxRows?: number;\n  /** Show the character count. */\n  showCount?: boolean;\n  /** Tab shown first. */\n  defaultTab?: \"write\" | \"preview\";\n  /** Active tab indicator, focus rings, link underline and list markers. */\n  accent?: string;\n  /** Name of the submitted textarea. */\n  name?: string;\n  disabled?: boolean;\n}\n\nconst LINE_HEIGHT = 22;\nconst PAD_Y = 24;\n\n/**\n * A textarea Markdown editor: an ARIA toolbar (roving focus) applies formatting to the selection\n * through the browser's own editing pipeline, so Ctrl/Cmd+Z undoes it; Write / Preview tabs show\n * a safe rendering of a small Markdown subset built from React elements.\n */\nexport function MarkdownEditor({\n  value,\n  defaultValue = \"\",\n  onChange,\n  label = \"Comment\",\n  hint,\n  placeholder = \"Write in Markdown…\",\n  maxLength = 0,\n  minRows = 5,\n  maxRows = 12,\n  showCount = true,\n  defaultTab = \"write\",\n  accent = \"#FF7448\",\n  name,\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: MarkdownEditorProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const ids = {\n    label: `${uid}-label`,\n    textarea: `${uid}-textarea`,\n    hint: `${uid}-hint`,\n    count: `${uid}-count`,\n    writeTab: `${uid}-tab-write`,\n    previewTab: `${uid}-tab-preview`,\n    writePanel: `${uid}-panel-write`,\n    previewPanel: `${uid}-panel-preview`,\n  };\n\n  const [inner, setInner] = React.useState(defaultValue);\n  const text = value ?? inner;\n  const [tab, setTab] = React.useState<\"write\" | \"preview\">(defaultTab);\n  const [toolIndex, setToolIndex] = React.useState(0);\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null);\n  const toolRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const setText = (next: string) => {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n  };\n\n  // Auto-grow: measure scrollHeight after each change; clamp between min and max rows.\n  React.useLayoutEffect(() => {\n    const el = textareaRef.current;\n    if (!el || tab !== \"write\") return;\n    el.style.height = \"auto\";\n    const min = minRows * LINE_HEIGHT + PAD_Y;\n    const max = maxRows * LINE_HEIGHT + PAD_Y;\n    el.style.height = `${Math.min(max, Math.max(min, el.scrollHeight + 2))}px`;\n    el.style.overflowY = el.scrollHeight + 2 > max ? \"auto\" : \"hidden\";\n  }, [text, minRows, maxRows, tab]);\n\n  /** Applies an edit through execCommand(\"insertText\") so native undo works; falls back to setRangeText. */\n  const apply = (edit: Edit) => {\n    const el = textareaRef.current;\n    if (!el || disabled) return;\n    el.focus();\n    el.setSelectionRange(edit.start, edit.end);\n    let inserted = false;\n    try {\n      inserted = edit.text.length > 0 && document.execCommand(\"insertText\", false, edit.text);\n    } catch {\n      inserted = false;\n    }\n    if (!inserted) {\n      el.setRangeText(edit.text, edit.start, edit.end, \"end\");\n      // React listens for \"input\" to run onChange.\n      el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n    }\n    el.setSelectionRange(edit.selStart, edit.selEnd);\n  };\n\n  const run = (id: ActionId) => {\n    const el = textareaRef.current;\n    if (!el) return;\n    const { selectionStart: s, selectionEnd: e, value: v } = el;\n    switch (id) {\n      case \"bold\":\n        return apply(wrapEdit(v, s, e, \"**\", \"bold text\"));\n      case \"italic\":\n        return apply(wrapEdit(v, s, e, \"_\", \"italic text\"));\n      case \"code\":\n        return apply(codeEdit(v, s, e));\n      case \"link\":\n        return apply(linkEdit(v, s, e));\n      case \"heading\":\n      case \"bullet\":\n      case \"number\":\n      case \"quote\":\n        return apply(lineEdit(v, s, e, id));\n    }\n  };\n\n  const handleTextareaKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return;\n    const key = event.key.toLowerCase();\n    const id: ActionId | null = key === \"b\" ? \"bold\" : key === \"i\" ? \"italic\" : key === \"k\" ? \"link\" : null;\n    if (!id) return;\n    // The focused editor owns these shortcuts: keep them from reaching page-level hotkeys (e.g. a ⌘K palette).\n    event.preventDefault();\n    event.stopPropagation();\n    run(id);\n  };\n\n  const handleToolKeyDown = (index: number, event: React.KeyboardEvent<HTMLButtonElement>) => {\n    const last = ACTIONS.length - 1;\n    let next = -1;\n    if (event.key === \"ArrowRight\") next = index === last ? 0 : index + 1;\n    else if (event.key === \"ArrowLeft\") next = index === 0 ? last : index - 1;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = last;\n    if (next === -1) return;\n    event.preventDefault();\n    setToolIndex(next);\n    toolRefs.current[next]?.focus();\n  };\n\n  const selectTab = (next: \"write\" | \"preview\", focus = false) => {\n    setTab(next);\n    if (focus) tabRefs.current[next === \"write\" ? 0 : 1]?.focus();\n  };\n\n  const handleTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    if ([\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].includes(event.key)) {\n      event.preventDefault();\n      const next =\n        event.key === \"Home\" ? \"write\" : event.key === \"End\" ? \"preview\" : tab === \"write\" ? \"preview\" : \"write\";\n      selectTab(next, true);\n    }\n  };\n\n  const limit = maxLength > 0 ? maxLength : 0;\n  const count = text.length;\n  const nearLimit = limit > 0 && count >= limit * 0.9;\n  const describedBy = [hint ? ids.hint : null, showCount ? ids.count : null].filter(Boolean).join(\" \") || undefined;\n  const tabs: { id: \"write\" | \"preview\"; label: string }[] = [\n    { id: \"write\", label: \"Write\" },\n    { id: \"preview\", label: \"Preview\" },\n  ];\n\n  return (\n    <div\n      className={`w-full ${className}`}\n      style={{ [\"--me-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <label id={ids.label} htmlFor={ids.textarea} className=\"mb-2 block font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n        {label}\n      </label>\n\n      <div\n        className={`overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${\n          disabled\n            ? \"border-zinc-900/10 opacity-50 dark:border-white/10\"\n            : \"border-zinc-900/15 focus-within:border-[var(--me-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--me-accent)_18%,transparent)] dark:border-white/15\"\n        }`}\n      >\n        <div className=\"flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-zinc-900/10 px-1.5 dark:border-white/10\">\n          <div role=\"tablist\" aria-label=\"Editor mode\" className=\"flex\">\n            {tabs.map((t, i) => {\n              const selected = tab === t.id;\n              return (\n                <button\n                  key={t.id}\n                  ref={(el) => {\n                    tabRefs.current[i] = el;\n                  }}\n                  id={t.id === \"write\" ? ids.writeTab : ids.previewTab}\n                  type=\"button\"\n                  role=\"tab\"\n                  aria-selected={selected}\n                  aria-controls={t.id === \"write\" ? ids.writePanel : ids.previewPanel}\n                  tabIndex={selected ? 0 : -1}\n                  onClick={() => selectTab(t.id)}\n                  onKeyDown={handleTabKeyDown}\n                  className={`relative h-10 px-3 text-[13px] font-medium tracking-tight outline-hidden transition-colors focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--me-accent)] ${\n                    selected ? \"text-zinc-950 dark:text-white\" : \"text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200\"\n                  }`}\n                >\n                  {t.label}\n                  {selected && (\n                    <motion.span\n                      layoutId={`${uid}-tab-indicator`}\n                      transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 40 }}\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-x-2 -bottom-px h-[2px] rounded-full bg-[var(--me-accent)]\"\n                    />\n                  )}\n                </button>\n              );\n            })}\n          </div>\n\n          <div\n            role=\"toolbar\"\n            aria-label=\"Formatting\"\n            aria-controls={ids.textarea}\n            aria-orientation=\"horizontal\"\n            aria-hidden={tab !== \"write\" || undefined}\n            className={`flex flex-wrap items-center gap-0.5 py-1 transition-opacity ${tab === \"write\" ? \"\" : \"pointer-events-none opacity-30\"}`}\n          >\n            {ACTIONS.map((action, i) => (\n              <React.Fragment key={action.id}>\n                {(i === 3 || i === 6) && <span aria-hidden=\"true\" className=\"mx-1 h-4 w-px bg-zinc-900/10 dark:bg-white/10\" />}\n                <button\n                  ref={(el) => {\n                    toolRefs.current[i] = el;\n                  }}\n                  type=\"button\"\n                  aria-label={action.label}\n                  aria-keyshortcuts={action.shortcut ? `Control+${action.shortcut} Meta+${action.shortcut}` : undefined}\n                  title={action.shortcut ? `${action.label} (Ctrl/⌘+${action.shortcut})` : action.label}\n                  tabIndex={tab === \"write\" && i === toolIndex ? 0 : -1}\n                  disabled={disabled || tab !== \"write\"}\n                  // Keep the textarea selection when clicking a tool with the mouse.\n                  onMouseDown={(e) => e.preventDefault()}\n                  onClick={() => {\n                    setToolIndex(i);\n                    run(action.id);\n                  }}\n                  onKeyDown={(e) => handleToolKeyDown(i, e)}\n                  className=\"grid size-8 place-items-center rounded-md text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-[var(--me-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white\"\n                >\n                  <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                    {action.icon}\n                  </svg>\n                </button>\n              </React.Fragment>\n            ))}\n          </div>\n        </div>\n\n        <div id={ids.writePanel} role=\"tabpanel\" aria-labelledby={ids.writeTab} hidden={tab !== \"write\"}>\n          <textarea\n            ref={textareaRef}\n            id={ids.textarea}\n            name={name}\n            value={text}\n            onChange={(e) => setText(e.target.value)}\n            onKeyDown={handleTextareaKeyDown}\n            placeholder={placeholder}\n            disabled={disabled}\n            maxLength={limit || undefined}\n            rows={minRows}\n            aria-describedby={describedBy}\n            spellCheck\n            className=\"block w-full resize-none bg-transparent px-4 py-3 font-mono text-[13px] leading-[22px] text-zinc-900 caret-[var(--me-accent)] outline-hidden placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-100 dark:placeholder:text-zinc-600\"\n          />\n        </div>\n\n        <div\n          id={ids.previewPanel}\n          role=\"tabpanel\"\n          aria-labelledby={ids.previewTab}\n          hidden={tab !== \"preview\"}\n          tabIndex={0}\n          className=\"px-4 py-3 outline-hidden focus-visible:shadow-[inset_0_0_0_2px_var(--me-accent)]\"\n          style={{ minHeight: minRows * LINE_HEIGHT + PAD_Y }}\n        >\n          {tab === \"preview\" &&\n            (text.trim() ? (\n              <MarkdownPreview source={text} />\n            ) : (\n              <p className=\"font-mono text-[11px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">Nothing to preview</p>\n            ))}\n        </div>\n\n        <div className=\"flex items-center justify-between gap-3 border-t border-zinc-900/10 px-4 py-2 dark:border-white/10\">\n          <span className=\"truncate font-mono text-[10px] tracking-[0.04em] text-zinc-400 dark:text-zinc-500\">\n            <span className=\"uppercase tracking-[0.14em]\">Markdown</span> · **bold** _italic_ `code` [link](url)\n          </span>\n          {showCount && (\n            <span\n              id={ids.count}\n              className={`shrink-0 font-mono text-[10px] tabular-nums tracking-[0.1em] ${\n                nearLimit ? \"text-[color-mix(in_srgb,var(--me-accent)_72%,black)] dark:text-[var(--me-accent)]\" : \"text-zinc-400 dark:text-zinc-500\"\n              }`}\n            >\n              {count}\n              {limit ? `/${limit}` : \"\"}\n              <span className=\"sr-only\"> characters{limit ? ` of ${limit}` : \"\"}</span>\n            </span>\n          )}\n        </div>\n      </div>\n\n      {hint && (\n        <p id={ids.hint} className=\"mt-1.5 text-[12px] leading-snug text-zinc-500 dark:text-zinc-400\">\n          {hint}\n        </p>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/markdown-editor"
}
