{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "editor-1",
  "title": "Editor 1: Markdown with live preview",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A Markdown editor with live preview: a toolbar and shortcuts that wrap the selection, a preview built from safe elements, word count and reading time, and autosave that says Saved, Saving, Unsaved changes or Not saved with Retry.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/editor-1/editor-1.tsx",
      "content": "// Ballmac UI: Editor 1. https://ui.ballmac.com/blocks/editor-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Bold, CircleAlert, CircleCheck, Code, Heading2, Italic, Link2, List, ListOrdered, Loader2, Quote, RotateCw } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Editor1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onChange\"> &\n  StateProps & {\n    title?: string\n    defaultValue?: string\n    /** Milliseconds to wait after typing before saving. 0 saves on every change. */\n    autosaveMs?: number\n    /** Called to save the text. Return a promise; reject and the editor says Not saved, with Retry. Without it, saving is simulated. */\n    onSave?: (value: string) => void | Promise<void>\n    onChange?: (value: string) => void\n    /** Start on the Preview tab (narrow screens). */\n    defaultTab?: \"write\" | \"preview\"\n  }\n\nconst starter = `# Launch notes\n\nWe are shipping **three things** this week, and one of them is *small but loved*.\n\n## What is new\n\n1. A faster search that finds words, not just titles\n2. Notes that save as you type\n3. [Read the full changelog](https://example.com/changelog)\n\n> People forgive missing features. They do not forgive losing their work.\n\nUse \\`autosave\\` for anything longer than a paragraph.\n\n- Fewer clicks\n- Clearer errors\n- One place for everything\n`\n\n// A small, safe Markdown reader. It builds React elements, never HTML strings, and only lets http, https and mailto links through.\nfunction inline(text: string, key: string): React.ReactNode[] {\n  const out: React.ReactNode[] = []\n  const re = /(\\*\\*[^*\\n]+\\*\\*|\\*[^*\\n]+\\*|_[^_\\n]+_|`[^`\\n]+`|\\[[^\\]\\n]+\\]\\([^)\\s]+\\))/g\n  let last = 0\n  let i = 0\n  for (const m of text.matchAll(re)) {\n    if (m.index! > last) out.push(text.slice(last, m.index))\n    const t = m[0]\n    const k = `${key}-${i++}`\n    if (t.startsWith(\"**\")) out.push(<strong key={k}>{t.slice(2, -2)}</strong>)\n    else if (t.startsWith(\"`\")) out.push(<code key={k} className=\"bg-muted rounded px-1.5 py-0.5 font-mono text-[0.9em]\">{t.slice(1, -1)}</code>)\n    else if (t.startsWith(\"[\")) {\n      const [, label, href] = t.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/)!\n      out.push(/^(https?:|mailto:)/i.test(href!) ? <a key={k} href={href} className=\"underline underline-offset-4\" rel=\"noreferrer\">{label}</a> : <span key={k}>{label}</span>)\n    } else out.push(<em key={k}>{t.slice(1, -1)}</em>)\n    last = m.index! + t.length\n  }\n  if (last < text.length) out.push(text.slice(last))\n  return out\n}\n\nfunction render(md: string): React.ReactNode[] {\n  const lines = md.replace(/\\r/g, \"\").split(\"\\n\")\n  const out: React.ReactNode[] = []\n  let i = 0\n  let k = 0\n  while (i < lines.length) {\n    const line = lines[i]!\n    if (!line.trim()) { i++; continue }\n    let m: RegExpMatchArray | null\n    if ((m = line.match(/^(#{1,3})\\s+(.*)$/))) {\n      const level = m[1]!.length\n      const cls = level === 1 ? \"mt-6 mb-3 text-3xl font-semibold tracking-tight first:mt-0\" : level === 2 ? \"mt-6 mb-2 text-xl font-semibold tracking-tight first:mt-0\" : \"mt-4 mb-2 text-base font-semibold first:mt-0\"\n      out.push(React.createElement(`h${level}`, { key: k++, className: cls }, ...inline(m[2]!, `h${k}`)))\n      i++\n    } else if (/^(-{3,}|\\*{3,})$/.test(line.trim())) { out.push(<hr key={k++} className=\"my-6\" />); i++ }\n    else if (/^>\\s?/.test(line)) {\n      const buf: string[] = []\n      while (i < lines.length && /^>\\s?/.test(lines[i]!)) buf.push(lines[i++]!.replace(/^>\\s?/, \"\"))\n      out.push(<blockquote key={k++} className=\"text-muted-foreground my-4 border-s-2 ps-4 italic\">{inline(buf.join(\" \"), `q${k}`)}</blockquote>)\n    } else if (/^\\s*[-*]\\s+/.test(line)) {\n      const items: string[] = []\n      while (i < lines.length && /^\\s*[-*]\\s+/.test(lines[i]!)) items.push(lines[i++]!.replace(/^\\s*[-*]\\s+/, \"\"))\n      out.push(<ul key={k++} className=\"my-3 list-disc space-y-1 ps-6\">{items.map((t, n) => <li key={n}>{inline(t, `u${k}${n}`)}</li>)}</ul>)\n    } else if (/^\\s*\\d+\\.\\s+/.test(line)) {\n      const items: string[] = []\n      while (i < lines.length && /^\\s*\\d+\\.\\s+/.test(lines[i]!)) items.push(lines[i++]!.replace(/^\\s*\\d+\\.\\s+/, \"\"))\n      out.push(<ol key={k++} className=\"my-3 list-decimal space-y-1 ps-6\">{items.map((t, n) => <li key={n}>{inline(t, `o${k}${n}`)}</li>)}</ol>)\n    } else {\n      const buf: string[] = []\n      while (i < lines.length && lines[i]!.trim() && !/^(#{1,3}\\s|>|\\s*[-*]\\s|\\s*\\d+\\.\\s)/.test(lines[i]!)) buf.push(lines[i++]!)\n      out.push(<p key={k++} className=\"my-3 leading-relaxed text-pretty\">{inline(buf.join(\" \"), `p${k}`)}</p>)\n    }\n  }\n  return out\n}\n\nconst words = (t: string) => (t.trim() ? t.trim().split(/\\s+/).length : 0)\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"bg-card rounded-2xl border p-5\">\n      <Skeleton className=\"h-10\" />\n      <Skeleton className=\"mt-4 h-72\" />\n    </div>\n  )\n}\n\n/**\n * A Markdown editor with a live preview: a toolbar and the usual shortcuts (Ctrl or Command with B, I and K) wrap the selected text, the preview\n * updates as you type and is built from safe elements, and a word count and reading time sit under the page. It saves on its own after you stop typing and\n * always says where it stands: Saved, Saving, Unsaved changes, or Not saved with a Retry that keeps your text.\n */\nfunction Editor1({ title = \"Launch notes\", defaultValue = starter, autosaveMs = 1200, onSave, onChange, defaultTab = \"write\", state = \"ready\", onRetry, errorState, className, ...props }: Editor1Props) {\n  const id = React.useId()\n  const [text, setText] = React.useState(defaultValue)\n  const [tab, setTab] = React.useState(defaultTab)\n  const [save, setSave] = React.useState<\"saved\" | \"dirty\" | \"saving\" | \"failed\">(\"saved\")\n  const area = React.useRef<HTMLTextAreaElement>(null)\n  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n  const latest = React.useRef(text)\n  latest.current = text\n\n  const doSave = React.useCallback(async () => {\n    const value = latest.current\n    setSave(\"saving\")\n    try {\n      if (onSave) await onSave(value)\n      else await new Promise((r) => setTimeout(r, 400))\n      // Typing during the save leaves it unsaved; the autosave timer will pick that up.\n      setSave(latest.current === value ? \"saved\" : \"dirty\")\n    } catch {\n      setSave(\"failed\")\n    }\n  }, [onSave])\n  React.useEffect(() => () => clearTimeout(timer.current), [])\n\n  function change(next: string) {\n    setText(next)\n    onChange?.(next)\n    setSave(\"dirty\")\n    clearTimeout(timer.current)\n    timer.current = setTimeout(() => void doSave(), autosaveMs)\n  }\n  // Wrap the selection, or put the marks around a placeholder and select it so typing replaces it.\n  function wrap(before: string, after = before, placeholder = \"text\") {\n    const el = area.current\n    if (!el) return\n    const { selectionStart: a, selectionEnd: b, value } = el\n    const sel = value.slice(a, b) || placeholder\n    const next = value.slice(0, a) + before + sel + after + value.slice(b)\n    change(next)\n    requestAnimationFrame(() => { el.focus(); el.setSelectionRange(a + before.length, a + before.length + sel.length) })\n  }\n  function prefix(mark: string) {\n    const el = area.current\n    if (!el) return\n    const { selectionStart: a, selectionEnd: b, value } = el\n    const start = value.lastIndexOf(\"\\n\", a - 1) + 1\n    const end = b + (value.indexOf(\"\\n\", b) === -1 ? value.length - b : value.indexOf(\"\\n\", b) - b)\n    const block = value.slice(start, end).split(\"\\n\").map((l, n) => (mark === \"1. \" ? `${n + 1}. ` : mark) + l).join(\"\\n\")\n    change(value.slice(0, start) + block + value.slice(end))\n    requestAnimationFrame(() => el.focus())\n  }\n  function key(e: React.KeyboardEvent) {\n    if (!(e.metaKey || e.ctrlKey)) return\n    const k = e.key.toLowerCase()\n    if (k === \"b\") { e.preventDefault(); wrap(\"**\") }\n    else if (k === \"i\") { e.preventDefault(); wrap(\"*\") }\n    else if (k === \"k\") { e.preventDefault(); wrap(\"[\", \"](https://)\", \"link text\") }\n    else if (k === \"s\") { e.preventDefault(); clearTimeout(timer.current); void doSave() }\n  }\n\n  const tools = [\n    { label: \"Bold\", hint: \"Ctrl B\", icon: Bold, run: () => wrap(\"**\") },\n    { label: \"Italic\", hint: \"Ctrl I\", icon: Italic, run: () => wrap(\"*\") },\n    { label: \"Heading\", icon: Heading2, run: () => prefix(\"## \") },\n    { label: \"Link\", hint: \"Ctrl K\", icon: Link2, run: () => wrap(\"[\", \"](https://)\", \"link text\") },\n    { label: \"Bulleted list\", icon: List, run: () => prefix(\"- \") },\n    { label: \"Numbered list\", icon: ListOrdered, run: () => prefix(\"1. \") },\n    { label: \"Quote\", icon: Quote, run: () => prefix(\"> \") },\n    { label: \"Code\", icon: Code, run: () => wrap(\"`\", \"`\", \"code\") },\n  ]\n  const n = words(text)\n  const status = { saved: { icon: CircleCheck, text: \"Saved\" }, dirty: { icon: Loader2, text: \"Unsaved changes\" }, saving: { icon: Loader2, text: \"Saving…\" }, failed: { icon: CircleAlert, text: \"Not saved\" } }[save]\n\n  const content = (\n    <div className=\"bg-card overflow-hidden rounded-2xl border\">\n      <div className=\"flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2.5\">\n        <div role=\"toolbar\" aria-label=\"Formatting\" className=\"flex flex-wrap items-center gap-0.5\">\n          {tools.map((t) => (\n            <Button key={t.label} type=\"button\" variant=\"ghost\" size=\"icon\" aria-label={t.hint ? `${t.label} (${t.hint})` : t.label} title={t.hint ? `${t.label} (${t.hint})` : t.label} onClick={t.run}>\n              <t.icon aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />\n            </Button>\n          ))}\n        </div>\n        <div role=\"tablist\" aria-label=\"View\" className=\"bg-muted flex rounded-lg p-0.5 lg:hidden\">\n          {([\"write\", \"preview\"] as const).map((k) => (\n            <button key={k} type=\"button\" role=\"tab\" aria-selected={tab === k} onClick={() => setTab(k)} className={cn(\"focus-visible:ring-ring/50 rounded-md px-3 py-1.5 text-sm font-medium capitalize outline-none focus-visible:ring-[3px]\", tab === k ? \"bg-background shadow-sm\" : \"text-muted-foreground\")}>\n              {k}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"grid lg:grid-cols-2 lg:divide-x rtl:lg:divide-x-reverse\">\n        <div className={cn(\"flex\", tab === \"preview\" && \"hidden lg:flex\")}>\n          <label htmlFor={`${id}-ta`} className=\"sr-only\">\n            Write in Markdown\n          </label>\n          <textarea\n            id={`${id}-ta`}\n            ref={area}\n            dir=\"auto\"\n            value={text}\n            onChange={(e) => change(e.target.value)}\n            onKeyDown={key}\n            spellCheck\n            className=\"focus-visible:ring-ring/50 block min-h-[26rem] w-full flex-1 resize-none bg-transparent p-5 font-mono text-sm leading-relaxed outline-none focus-visible:ring-[3px] focus-visible:ring-inset\"\n          />\n        </div>\n        <div className={cn(\"bg-muted/20 min-h-[26rem] p-5 sm:p-6\", tab === \"write\" && \"hidden lg:block\")} aria-label=\"Preview\" role=\"region\">\n          {text.trim() ? render(text) : <p className=\"text-muted-foreground text-sm\">Nothing to preview yet. Start writing and it appears here.</p>}\n        </div>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-3 border-t px-4 py-2.5 text-sm\">\n        <p className=\"text-muted-foreground tabular-nums\">\n          {n} {n === 1 ? \"word\" : \"words\"} · {Math.max(1, Math.round(n / 220))} min read\n        </p>\n        <p role=\"status\" className={cn(\"flex items-center gap-2\", save === \"failed\" && \"font-medium\")}>\n          <status.icon aria-hidden=\"true\" className={cn(\"size-4\", (save === \"dirty\" || save === \"saving\") && \"text-muted-foreground\", save === \"saving\" && \"animate-spin motion-reduce:animate-none\", save === \"saved\" && \"text-chart-2\")} />\n          {status.text}\n          {save === \"failed\" ? (\n            <>\n              . Your text is still here.\n              <button type=\"button\" onClick={() => void doSave()} className=\"focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded-sm font-semibold underline underline-offset-2 outline-none focus-visible:ring-[3px]\">\n                <RotateCw aria-hidden=\"true\" className=\"size-3\" />\n                Retry\n              </button>\n            </>\n          ) : null}\n        </p>\n      </div>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"editor-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <h1 id={`${id}-title`} className=\"mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance\">\n        {title}\n      </h1>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the editor\" loading={<Skel />} error={{ title: errorState?.title ?? \"The document could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Editor1, type Editor1Props }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/editor-1/editor-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "editor",
      "markdown",
      "writing",
      "notes",
      "preview",
      "autosave",
      "text editor",
      "docs",
      "wysiwyg",
      "composer"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A Markdown editor. <Editor1 title defaultValue autosaveMs={1200} onSave={(value) => Promise} onChange={(value) => ...} defaultTab='write' | 'preview' />. Supports headings (1 to 3), bold, italic, inline code, links (http, https, mailto only), bulleted and numbered lists, quotes and rules. Ctrl or Command with B, I, K format and S saves now. Saving happens autosaveMs after the last keystroke; reject onSave to show Not saved with Retry. The preview never uses dangerouslySetInnerHTML.",
      "whenToUse": [
        "Notes, docs, release notes and posts written in Markdown",
        "A comment or message composer that needs formatting"
      ],
      "whenNotToUse": [
        "A full rich text editor with tables and images",
        "A short single-line field (use input)"
      ],
      "composesWith": [
        "app-shell-1",
        "tickets-1",
        "changelog-1",
        "help-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Ctrl or Command + B / I / K / S",
          "action": "The toolbar buttons say their shortcut in their name; the writing field has its own label; S saves right away"
        },
        {
          "keys": "Saving",
          "action": "The status line is a live region: Saved, Saving, Unsaved changes, or Not saved with a Retry button"
        },
        {
          "keys": "Preview",
          "action": "A labelled region updated as you type; on narrow screens a Write and Preview tablist switches between the two"
        }
      ],
      "customization": [
        "The writing field uses dir=auto, so Arabic and Hebrew text flow the right way as you type",
        "Formatting marks wrap the selection, or a placeholder that is selected so typing replaces it",
        "Reading time assumes 220 words a minute"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "editor-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton toolbar and page under the real heading, announced as loading."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "editor-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A save that fails shows Not saved with Retry and keeps the text."
      }
    ],
    "examples": [
      "editor-1-demo",
      "editor-1-preview",
      "editor-1-failed",
      "editor-1-loading",
      "editor-1-error"
    ],
    "url": "https://ui.ballmac.com/components/editor-1"
  },
  "categories": [
    "blocks",
    "editor"
  ],
  "type": "registry:block"
}