{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "markdown-toolbar",
  "title": "Markdown Toolbar",
  "description": "Puts a row of formatting buttons over a plain `<textarea>` — bold, italic, inline code and fenced blocks, links, bulleted and numbered lists, quotes — and writes the markers into the text without taking away the browser's own undo. Reach for it above any box somebody types prose into: a comment field, an issue or bug-report form, a pull-request description, a review box, a chat composer, a changelog or release-notes field, a README editor in an admin page, a support-ticket reply, a CMS body field, a notes or docs panel. Common asks it answers: \"markdown toolbar react\", \"markdown editor toolbar shadcn\", \"textarea formatting buttons\", \"bold button for textarea\", \"wrap selected text in asterisks react\", \"insert markdown at cursor react\", \"textarea selectionStart selectionEnd react\", \"setRangeText react\", \"document.execCommand insertText react\", \"preserve undo after programmatic textarea edit\", \"cmd+z stops working after setRangeText\", \"textarea undo history broken\", \"markdown shortcuts cmd+b textarea\", \"keyboard shortcut bold textarea react\", \"toggle markdown bold off\", \"unwrap markdown markers\", \"prefix every selected line react\", \"bullet list button textarea\", \"renumber ordered list markdown\", \"blockquote button react\", \"insert link markdown caret in url\", \"toolbar button steals focus from textarea\", \"button mousedown preventDefault keep selection\", \"markdown ツールバー react\", \"textarea 選択範囲 装飾\", \"太字ボタン react\", \"undo が効かなくなる textarea\", \"markdown editor react no dependencies\", \"lightweight markdown editor react\", \"simplemde alternative\", \"easymde alternative\", \"@uiw/react-md-editor alternative\", \"react-markdown-editor-lite alternative\", \"toast ui editor alternative\", \"tiptap alternative for markdown\", \"lexical vs markdown textarea\", \"rich text editor without contenteditable\", \"github style comment box react\", \"add formatting buttons to a comment box\", \"shadcn textarea with toolbar\", \"markdown toolbar for react-hook-form textarea\", \"markdown composer next.js\", \"軽量 マークダウンエディタ react\", \"コメント欄 装飾ボタン\", \"textarea に太字ボタンを付けたい\". **The undo stack is the whole engineering problem, and the obvious modern call is the one that breaks it.** `textarea.setRangeText` writes the text and records no undo entry: measured in Chrome 152, a `setRangeText` edit followed by `execCommand(\"undo\")` came back exactly as the edit had left it — and that `undo` *returned `true`*, so the failure is invisible from the return value. The same edit through the deprecated `document.execCommand(\"insertText\")` was reverted, in every focus arrangement tried, with two consecutive writes landing as two separate undo steps and a multi-line rewrite landing as one. So `insertText` is tried first behind a `queryCommandSupported` check and `setRangeText` is the fallback for the day it is removed, which is an uncomfortable thing to ship and still the only arrangement in which Cmd+Z after pressing Bold does what everybody expects. The fallback dispatches its own `input` event, because a controlled React textarea learns about a change from exactly that event and `setRangeText` fires none — without it the edit lands and the next render snaps it back. One edit is one range replacement, which is what keeps it to one undo step. **The focus guard is real and the usual reason given for it is not.** Pressing a toolbar button does not cost you the selection: measured through a real click on an unguarded button, `selectionStart`/`selectionEnd` still read 6 and 11 over the same word at mousedown, at click and after the write, because a textarea's selection belongs to the field and survives it losing focus. What the `mousedown` guard removes is a focus round trip — the unguarded click fired `blur` once and `focus` twice on the textarea, the guarded one fired neither — and those events are load-bearing in real forms, where save-the-draft and run-validation and close-the-composer all hang off blur. **Line markers and surrounding markers are different operations, and the mixed case is where implementations feel broken.** Bold surrounds the selection; a bullet goes at the head of every line the selection touches. Select four lines of which one is already a bullet, and a per-line toggle gives back three bullets and one plain line — so the marker comes off only when every line already carries it, and otherwise it is brought to all of them: one press to normalise, a second to clear. Blank lines are left alone and excluded from that vote. Numbers are rewritten to their position rather than repeated, so a block numbered 3, 7, 7 comes back 1, 2, 3. A selection ending at column 0 — one line of visible highlight, dragged down and released at the next line's left edge — does not reach the line it merely touched. **Toggling off is tested in both of the arrangements markers can be in, which is what stops it doubling them.** After a wrap the highlight stays on the words the person highlighted rather than on the markers, so a second press sees the markers outside the selection, while somebody who dragged across `**bravo**` by hand has them inside it; both are \"this is already bold\". The run of markers on each side is tokenised and the pairing has to balance, so the bold button finds its own markers through a layer of italics — pressing bold on `**_bravo_**` gives `_bravo_` and not `**_**bravo**_**`, which is what a check that only looks at the characters touching the selection produces. That requirement is also what stops it firing on `**bold**text`, where `**` touches the selection on the left and nothing closes it on the right. An empty selection inserts the pair and parks the caret between them; for a link the caret goes to whichever slot is still empty, so a selected URL becomes the target with the caret waiting in the label, and no placeholder word is left behind to be mistaken for a real one. One tab stop for the whole row with the arrow keys moving inside it, Home and End to the ends, and the arrows following the text on an RTL page; `role=\"toolbar\"` with a label, every control a real `type=\"button\"` so a toolbar inside a form cannot submit it, `aria-keyshortcuts` on the buttons that have a chord, Mod+B / Mod+I / Mod+E / Mod+K bound on the field where the hands are and accepting either modifier, and decorative icons `aria-hidden`. Theme tokens throughout with no hardcoded colours, one lucide icon set, and nothing else added. **It is not a markdown editor.** It writes markers into plain text and never parses: no preview, no syntax highlighting, no AST, and the toggles read the characters around the selection rather than a document model, so nesting a real parser would resolve is left to the person. It renders only the toolbar and the textarea stays yours — your rows, your placeholder, your validation, this registry's autosize-textarea if you want one. Official shadcn/ui has nothing here and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks — and searching all 223,287 bytes of it, the strings execCommand, setRangeText, selectionStart, selectionEnd, insertText, markdown, toolbar, italic and blockquote are every one of them a zero hit; its `textarea` is a six-occurrence style wrapper and all six hits for \"bold\" are `font-semibold` in a title class. Official never touches a field's selection. Within pulld it sits beside selection-toolbar, which floats over text somebody is reading, and mention-input, which completes a word somebody is typing; this is the only component here that rewrites the selection in a field. Against the editor libraries rather than against official: tiptap, Lexical, SimpleMDE and the `@uiw` editors replace the field with a contenteditable surface and own the document model, which buys WYSIWYG and costs the native textarea — its undo stack, its spellcheck, its mobile keyboard behaviour, form submission, and a bundle measured in tens of kilobytes. This keeps the `<textarea>` and only writes into it, so what the user types is still markdown and what the form posts is still a string. Reach for a real editor when you need rendered output while typing; reach for this when the field is already a textarea and the text is already markdown.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/markdown-toolbar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { Bold, Code, Italic, Link as LinkIcon, List, ListOrdered, Quote, Strikethrough } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Everything the formatting layer needs to know about the field, and nothing about the DOM.\n *\n * `start` and `end` are the field's own `selectionStart` / `selectionEnd`. A textarea always reports\n * them in order, so nothing here sorts them — but anything *writing* a range back has to, because\n * `setSelectionRange(11, 6)` does not select backwards: measured in Chrome 152, it collapses to\n * `(6, 6)` and the text is silently gone from the selection. `selectionDirection` is no help either;\n * it answered `\"none\"` for a plain forward programmatic set in the same session.\n */\nexport interface FieldState {\n  value: string\n  start: number\n  end: number\n}\n\n/**\n * One replacement, expressed as a range and the text that takes its place.\n *\n * It is an edit rather than a finished string on purpose, and the reason is the undo stack. The\n * write has to go through `document.execCommand(\"insertText\")` to stay undoable (see `writeEdit`),\n * and that command inserts *at the selection* — so the thing it needs is the range to put the\n * selection on and the text to drop in, not the document that should result. `applyEdit` produces\n * the resulting string for anyone who wants it, tests included.\n *\n * `from`/`to` index the old value; `selectionStart`/`selectionEnd` index the new one.\n */\nexport interface Edit {\n  from: number\n  to: number\n  text: string\n  selectionStart: number\n  selectionEnd: number\n}\n\n/** The string an edit produces. */\nexport function applyEdit(value: string, edit: Edit): string {\n  return value.slice(0, edit.from) + edit.text + value.slice(edit.to)\n}\n\n/** Nothing to do — the field is already in the state the action would put it in. */\nconst noEdit = (state: FieldState): Edit => ({\n  from: state.start,\n  to: state.start,\n  text: \"\",\n  selectionStart: state.start,\n  selectionEnd: state.start,\n})\n\nconst ordered = (state: FieldState) => ({\n  start: Math.min(state.start, state.end),\n  end: Math.max(state.start, state.end),\n})\n\n/**\n * A pair of markers that surround the selection — bold, italic, inline code, strikethrough.\n *\n * `blockBefore`/`blockAfter` replace them when the selection spans lines, which is the one place\n * markdown itself changes shape: three backticks and a newline are a code *block*, and wrapping a\n * four-line function in single backticks produces one long broken inline span rather than the thing\n * the button's icon promised.\n */\nexport interface WrapSpec {\n  kind: \"wrap\"\n  before: string\n  after: string\n  blockBefore?: string\n  blockAfter?: string\n}\n\n/**\n * A marker that goes at the head of every line the selection touches — bullets, numbers, quotes.\n *\n * `match` is what recognises one that is already there, and it is a pattern rather than the literal\n * `prefix` because an ordered list's prefix is not a constant: `2. ` has to be recognised by a\n * button whose own prefix is `1. `.\n */\nexport interface LinePrefixSpec {\n  kind: \"linePrefix\"\n  prefix: string\n  match: RegExp\n  /** Renumber the block from 1 instead of repeating `prefix`. */\n  renumber?: boolean\n}\n\n/** `[text](url)`, which is neither of the above: two slots, and which one gets the caret varies. */\nexport interface LinkSpec {\n  kind: \"link\"\n}\n\n/**\n * The marker runs that may sit between a selection and the marker being toggled.\n *\n * Longest first, so `**` is read as one token and not as two `*`. This is what lets the bold button\n * find its own markers through a layer of italics — see `markerRun` and `toggleWrap`.\n */\nconst NESTABLE_MARKERS = [\"**\", \"~~\", \"__\", \"*\", \"_\", \"`\"]\nconst MARKER_CHAR = /[*_~`]/\n\n/** The maximal run of marker characters on each side of a range. */\nfunction markerRun(value: string, start: number, end: number) {\n  let from = start\n  while (from > 0 && MARKER_CHAR.test(value[from - 1])) from -= 1\n  let to = end\n  while (to < value.length && MARKER_CHAR.test(value[to])) to += 1\n  return { left: value.slice(from, start), right: value.slice(end, to), leftFrom: from }\n}\n\n/** Splits a run of markers into whole tokens, or `null` if it contains something that is not one. */\nfunction tokenizeMarkers(run: string): string[] | null {\n  const tokens: string[] = []\n  let i = 0\n  while (i < run.length) {\n    const token = NESTABLE_MARKERS.find((marker) => run.startsWith(marker, i))\n    if (!token) return null\n    tokens.push(token)\n    i += token.length\n  }\n  return tokens\n}\n\n/**\n * Finds `before`/`after` already surrounding the selection, possibly outside other markers.\n *\n * **The adjacent case is not the only one, and treating it as the only one is what produces\n * `**_**bravo**_**`.** Bold a word, italicise it, then press bold again: the bold markers are still\n * there but the italic ones now stand between them and the selection, so a check that looks only at\n * the characters touching the selection concludes there is no bold to remove and adds a second pair\n * *inside* the first. The result is markup no parser and no person can read, produced by pressing a\n * button twice.\n *\n * So the run of marker characters on each side is tokenised and the pairing is required to balance:\n * whatever markers sit between `before` and the selection must appear in mirror order on the other\n * side, with `after` outside them. That requirement is what keeps this from firing on\n * `**bold**text` — where `**` touches the selection on the left and nothing closes it on the right.\n * Markers are their own mirror, so reversing the token order is the whole of it.\n *\n * Returns the outermost match, so bold comes off bold rather than off the italics inside it.\n */\nfunction findSurrounding(\n  value: string,\n  start: number,\n  end: number,\n  before: string,\n  after: string\n): { from: number; to: number; inner: string; close: string } | null {\n  if (before.length === 0) return null\n\n  // Touching the selection, checked as plain text. This has to come first and has to be its own\n  // branch, because the run below is a run of *marker characters* and a fence is `` ```\\n `` — the\n  // newline in it belongs to no run, so the loop can never find it however far it looks.\n  const openFrom = start - before.length\n  const closeTo = end + after.length\n  if (\n    openFrom >= 0 &&\n    closeTo <= value.length &&\n    value.slice(openFrom, start) === before &&\n    value.slice(end, closeTo) === after\n  ) {\n    return { from: openFrom, to: closeTo, inner: \"\", close: \"\" }\n  }\n\n  const run = markerRun(value, start, end)\n\n  for (let i = 0; i + before.length <= run.left.length; i += 1) {\n    if (!run.left.startsWith(before, i)) continue\n    const inner = run.left.slice(i + before.length)\n    const tokens = tokenizeMarkers(inner)\n    if (tokens === null) continue\n    const close = tokens.reverse().join(\"\")\n    if (!run.right.startsWith(close + after)) continue\n    return {\n      from: run.leftFrom + i,\n      to: end + close.length + after.length,\n      inner,\n      close,\n    }\n  }\n  return null\n}\n\nexport type ActionSpec = WrapSpec | LinePrefixSpec | LinkSpec\n\n/**\n * Toggles a pair of markers around the selection.\n *\n * **Already-formatted is two different arrangements, and a toggle that tests only one of them\n * doubles the markers instead of removing them.** After this function adds `**`, the selection it\n * hands back covers `bravo` and not `**bravo**` — the highlight should still be on the word the\n * person highlighted — so a second press sees the markers *outside* the selection. But somebody who\n * drags across `**bravo**` by hand has them *inside* it. Both are \"this is already bold\", so both\n * are checked: inside first, then the characters immediately flanking the selection.\n *\n * An empty selection inserts the pair and puts the caret between them, which is the one case where\n * the caret matters more than the selection: the next thing that happens is typing.\n */\nexport function toggleWrap(state: FieldState, spec: WrapSpec): Edit {\n  const { start, end } = ordered(state)\n  const selected = state.value.slice(start, end)\n  const multiline = selected.includes(\"\\n\")\n  const before = multiline && spec.blockBefore !== undefined ? spec.blockBefore : spec.before\n  const after = multiline && spec.blockAfter !== undefined ? spec.blockAfter : spec.after\n\n  // Inside the selection.\n  if (\n    selected.length >= before.length + after.length &&\n    selected.startsWith(before) &&\n    selected.endsWith(after)\n  ) {\n    const inner = selected.slice(before.length, selected.length - after.length)\n    return {\n      from: start,\n      to: end,\n      text: inner,\n      selectionStart: start,\n      selectionEnd: start + inner.length,\n    }\n  }\n\n  // Around the selection, with anything nested in between left where it is.\n  const surrounding = findSurrounding(state.value, start, end, before, after)\n  if (surrounding) {\n    const text = surrounding.inner + selected + surrounding.close\n    return {\n      from: surrounding.from,\n      to: surrounding.to,\n      text,\n      selectionStart: surrounding.from + surrounding.inner.length,\n      selectionEnd: surrounding.from + surrounding.inner.length + selected.length,\n    }\n  }\n\n  const text = before + selected + after\n  return {\n    from: start,\n    to: end,\n    text,\n    selectionStart: start + before.length,\n    selectionEnd: start + before.length + selected.length,\n  }\n}\n\n/**\n * The whole lines a selection touches.\n *\n * The end is the half that bites. A selection made by dragging down one line and releasing at the\n * left edge of the next ends at that next line's column 0 — it is visibly one line of highlight,\n * and treating the line it merely touches as selected puts a bullet on a line nobody chose. So a\n * selection ending exactly at a line start is measured against the character before it.\n */\nexport function lineBlock(value: string, start: number, end: number): { from: number; to: number } {\n  const from = value.lastIndexOf(\"\\n\", start - 1) + 1\n  const probe = end > start && value[end - 1] === \"\\n\" ? end - 1 : end\n  const newline = value.indexOf(\"\\n\", probe)\n  return { from, to: newline === -1 ? value.length : newline }\n}\n\n/**\n * Toggles a line marker on every line the selection touches.\n *\n * **The mixed case decides what this feels like, and \"remove unless every line has it\" is the\n * answer that matches what the button looks like.** Selecting four lines where one is already a\n * bullet and pressing the list button should produce four bullets; a per-line toggle produces three\n * bullets and one plain line and makes the button feel broken. So the block comes off only when\n * every line it contains already carries the marker, and otherwise the marker is brought to all of\n * them — one press to normalise, a second to clear.\n *\n * Blank lines are left alone and excluded from that vote. A blank line inside a list is ordinary,\n * and counting it as \"not prefixed\" would mean a selection containing one could never be cleared.\n *\n * Adding strips the marker first and re-adds it, which is how renumbering falls out for free: a\n * block whose lines read `3.` `7.` `7.` comes back `1.` `2.` `3.`, and for a bullet the strip-then-\n * add is simply idempotent.\n */\nexport function toggleLinePrefix(state: FieldState, spec: LinePrefixSpec): Edit {\n  const { start, end } = ordered(state)\n  const block = lineBlock(state.value, start, end)\n  const lines = state.value.slice(block.from, block.to).split(\"\\n\")\n\n  const content = lines.filter((line) => line.trim() !== \"\")\n  const allPrefixed = content.length > 0 && content.every((line) => spec.match.test(line))\n\n  let counter = 0\n  const next = lines.map((line) => {\n    if (line.trim() === \"\") return line\n    const bare = line.replace(spec.match, \"\")\n    if (allPrefixed) return bare\n    counter += 1\n    return (spec.renumber ? `${counter}. ` : spec.prefix) + bare\n  })\n\n  const text = next.join(\"\\n\")\n  const delta = text.length - (block.to - block.from)\n\n  // A caret stays a caret. Selecting the line somebody only put their cursor on would be a\n  // surprising answer to pressing a button, and the next thing they do is keep typing.\n  if (state.start === state.end) {\n    const moved = Math.min(Math.max(start + delta, block.from), block.from + text.length)\n    return { from: block.from, to: block.to, text, selectionStart: moved, selectionEnd: moved }\n  }\n\n  return {\n    from: block.from,\n    to: block.to,\n    text,\n    selectionStart: block.from,\n    selectionEnd: block.from + text.length,\n  }\n}\n\nconst LOOKS_LIKE_URL = /^(?:[a-z][a-z0-9+.-]*:|www\\.)/i\nconst WHOLE_LINK = /^\\[([^\\]]*)\\]\\(([^()]*)\\)$/\n\n/**\n * Wraps the selection in `[text](url)`, putting the caret in whichever slot is still empty.\n *\n * **Which slot that is depends on what was selected, and guessing wrong costs the person the thing\n * they had already done.** Somebody who selected a pasted URL wants it to become the target, with\n * the caret waiting in the label; somebody who selected a few words wants those to be the label,\n * with the caret waiting in the parentheses. Dropping the selection into the label either way means\n * the URL case has to be retyped.\n *\n * No placeholder text goes in the empty slot. `[text](url)` with the literal word `url` selected\n * reads as helpful right up until somebody clicks elsewhere instead of typing, and then it is a\n * broken link in their comment that looks deliberate.\n *\n * A selection that is already a whole link unwraps back to its label, so the button toggles.\n */\nexport function insertLink(state: FieldState): Edit {\n  const { start, end } = ordered(state)\n  const selected = state.value.slice(start, end)\n\n  const whole = WHOLE_LINK.exec(selected)\n  if (whole) {\n    const label = whole[1]\n    return {\n      from: start,\n      to: end,\n      text: label,\n      selectionStart: start,\n      selectionEnd: start + label.length,\n    }\n  }\n\n  // The label is the empty slot in both of these: a selected URL belongs in the target, and a\n  // selection of nothing has no label yet either. The caret goes where the typing has to happen.\n  if (selected.length === 0 || LOOKS_LIKE_URL.test(selected)) {\n    const text = `[](${selected})`\n    return { from: start, to: end, text, selectionStart: start + 1, selectionEnd: start + 1 }\n  }\n\n  const text = `[${selected}]()`\n  const caret = start + selected.length + 3\n  return { from: start, to: end, text, selectionStart: caret, selectionEnd: caret }\n}\n\n/** The edit an action would make, or a no-op edit when it has nothing to change. */\nexport function editFor(state: FieldState, spec: ActionSpec): Edit {\n  if (spec.kind === \"wrap\") return toggleWrap(state, spec)\n  if (spec.kind === \"linePrefix\") return toggleLinePrefix(state, spec)\n  if (spec.kind === \"link\") return insertLink(state)\n  return noEdit(state)\n}\n\nexport type MarkdownActionName =\n  | \"bold\"\n  | \"italic\"\n  | \"strikethrough\"\n  | \"code\"\n  | \"link\"\n  | \"bulletList\"\n  | \"orderedList\"\n  | \"quote\"\n\nexport interface MarkdownAction {\n  name: string\n  /** The button's accessible name, and its tooltip. */\n  label: string\n  spec: ActionSpec\n  icon?: React.ReactNode\n  /** A single character combined with the platform's modifier, as in `b` for Mod+B. */\n  shortcut?: string\n}\n\n/**\n * The eight that earn a place in a comment box, with the markers CommonMark agrees on.\n *\n * `_` for emphasis rather than `*`, because `*italic*` inside `**bold**` produces a run of five\n * asterisks that neither a person nor a lenient parser reads reliably, while `**_both_**` is\n * unambiguous. Underscores are the safer half of a pair that has to nest.\n */\nexport const MARKDOWN_ACTIONS: Record<MarkdownActionName, MarkdownAction> = {\n  bold: {\n    name: \"bold\",\n    label: \"Bold\",\n    shortcut: \"b\",\n    icon: <Bold className=\"size-4\" />,\n    spec: { kind: \"wrap\", before: \"**\", after: \"**\" },\n  },\n  italic: {\n    name: \"italic\",\n    label: \"Italic\",\n    shortcut: \"i\",\n    icon: <Italic className=\"size-4\" />,\n    spec: { kind: \"wrap\", before: \"_\", after: \"_\" },\n  },\n  strikethrough: {\n    name: \"strikethrough\",\n    label: \"Strikethrough\",\n    icon: <Strikethrough className=\"size-4\" />,\n    spec: { kind: \"wrap\", before: \"~~\", after: \"~~\" },\n  },\n  code: {\n    name: \"code\",\n    label: \"Code\",\n    shortcut: \"e\",\n    icon: <Code className=\"size-4\" />,\n    spec: {\n      kind: \"wrap\",\n      before: \"`\",\n      after: \"`\",\n      blockBefore: \"```\\n\",\n      blockAfter: \"\\n```\",\n    },\n  },\n  link: {\n    name: \"link\",\n    label: \"Link\",\n    shortcut: \"k\",\n    icon: <LinkIcon className=\"size-4\" />,\n    spec: { kind: \"link\" },\n  },\n  bulletList: {\n    name: \"bulletList\",\n    label: \"Bulleted list\",\n    icon: <List className=\"size-4\" />,\n    spec: { kind: \"linePrefix\", prefix: \"- \", match: /^[-*+] / },\n  },\n  orderedList: {\n    name: \"orderedList\",\n    label: \"Numbered list\",\n    icon: <ListOrdered className=\"size-4\" />,\n    spec: { kind: \"linePrefix\", prefix: \"1. \", match: /^\\d+[.)] /, renumber: true },\n  },\n  quote: {\n    name: \"quote\",\n    label: \"Quote\",\n    icon: <Quote className=\"size-4\" />,\n    spec: { kind: \"linePrefix\", prefix: \"> \", match: /^> ?/ },\n  },\n}\n\nconst DEFAULT_ACTIONS: readonly MarkdownActionName[] = [\n  \"bold\",\n  \"italic\",\n  \"code\",\n  \"link\",\n  \"bulletList\",\n  \"orderedList\",\n  \"quote\",\n]\n\n/**\n * Puts an edit into a real field, keeping it on the browser's own undo stack.\n *\n * **`document.execCommand` is deprecated and it is still the only correct way to do this, which is\n * an uncomfortable thing to ship and worse to work around.** The obvious modern call,\n * `textarea.setRangeText`, writes the value and does not record an undo entry: measured in Chrome\n * 152, a `setRangeText` edit followed by `execCommand(\"undo\")` left the text exactly as the edit had\n * made it — and `undo` *returned `true`*, so the failure cannot even be detected from the return\n * value. The same edit through `execCommand(\"insertText\")` was reverted by the same undo call, in\n * every focus arrangement tried (no blur, a blur before the write, a blur after it), and two\n * consecutive writes came back as two separate undo steps. A multi-line replacement is one step.\n *\n * So `insertText` is tried first and `setRangeText` is the fallback for the day it is removed, with\n * `queryCommandSupported` asked first rather than relying on the return value — Chrome 152 answers\n * `true` to both `queryCommandSupported(\"insertText\")` and `queryCommandEnabled(\"insertText\")`. A\n * field that lands on the fallback still works; it just joins every other implementation in costing\n * the person their undo history.\n *\n * The caveat on all of this: these are `execCommand(\"undo\")` measurements. A real Cmd+Z could not be\n * tested — synthesised key events did not drive the undo stack even for plain typed text — so that\n * is not claimed here beyond what the proxy shows.\n *\n * Returns whether the undo-preserving path was the one taken.\n */\nexport function writeEdit(field: HTMLTextAreaElement | HTMLInputElement, edit: Edit): boolean {\n  field.focus()\n  field.setSelectionRange(edit.from, edit.to)\n\n  let undoable = false\n  const supported =\n    typeof document !== \"undefined\" &&\n    typeof document.queryCommandSupported === \"function\" &&\n    document.queryCommandSupported(\"insertText\")\n\n  if (supported) {\n    try {\n      undoable = document.execCommand(\"insertText\", false, edit.text)\n    } catch {\n      undoable = false\n    }\n  }\n\n  if (!undoable) {\n    if (typeof field.setRangeText === \"function\") {\n      field.setRangeText(edit.text, edit.from, edit.to)\n    } else {\n      field.value = applyEdit(field.value, edit)\n    }\n    // `setRangeText` does not fire `input`, and a controlled React textarea learns about a change\n    // through exactly that event. Without it the component's own value prop snaps the text back on\n    // the next render — the edit lands and then visibly undoes itself.\n    field.dispatchEvent(new Event(\"input\", { bubbles: true }))\n  }\n\n  field.setSelectionRange(edit.selectionStart, edit.selectionEnd)\n  return undoable\n}\n\nexport interface MarkdownToolbarProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /**\n   * The field this formats. Point it at your own `<textarea>` — a plain one, shadcn's `Textarea`,\n   * or this registry's `autosize-textarea`.\n   */\n  textareaRef: React.RefObject<HTMLTextAreaElement | null>\n  /** Which buttons, in order. Names from `MARKDOWN_ACTIONS`, or whole actions of your own. */\n  actions?: readonly (MarkdownActionName | MarkdownAction)[]\n  /** Accessible name for the toolbar. */\n  label?: string\n  /**\n   * The field's full text after an edit.\n   *\n   * A controlled textarea does not need this — every write here goes out as an `input` event, so\n   * the field's own `onChange` has already fired with the same string by the time this does. It is\n   * for mirroring an uncontrolled field into state, and for the case where the two are wired to\n   * different places. Calling both is idempotent.\n   */\n  onValueChange?: (value: string) => void\n  /** Bind Mod+B / Mod+I / Mod+K and friends on the field. Default true. */\n  shortcuts?: boolean\n  disabled?: boolean\n}\n\nconst resolve = (action: MarkdownActionName | MarkdownAction): MarkdownAction =>\n  typeof action === \"string\" ? MARKDOWN_ACTIONS[action] : action\n\n/**\n * Whether an element is laid out right-to-left.\n *\n * Read at the moment of the key press rather than cached: `direction` is inherited and can change\n * underneath, and a stale answer sends Right to the left for the rest of the session.\n */\nfunction isRtl(node: Element | null | undefined): boolean {\n  if (!node || typeof getComputedStyle !== \"function\") return false\n  return getComputedStyle(node).direction === \"rtl\"\n}\n\n/**\n * A row of formatting buttons for a `<textarea>` — bold, italic, code, link, lists, quote.\n *\n * Put it directly above the field it serves and hand it that field's ref. It renders only the\n * toolbar, so the textarea stays yours: your rows, your placeholder, your validation, your\n * `autosize-textarea` if you want one.\n *\n * ```tsx\n * const ref = React.useRef<HTMLTextAreaElement>(null)\n * const [body, setBody] = React.useState(\"\")\n * return (\n *   <div className=\"rounded-md border\">\n *     <MarkdownToolbar textareaRef={ref} onValueChange={setBody} />\n *     <textarea ref={ref} value={body} onChange={(e) => setBody(e.target.value)} />\n *   </div>\n * )\n * ```\n *\n * **Mousedown is prevented across the whole toolbar, and the usual explanation for that is wrong.**\n * Pressing a button does not cost you the selection: measured in Chrome 152 through a real click on\n * an unguarded button, `selectionStart`/`selectionEnd` were still `6`/`11` over `bravo` at\n * mousedown, at click, and after the write — a textarea's selection belongs to the field and\n * survives it losing focus, and `execCommand` found the field and even handed focus back on its\n * own. What the guard actually removes is a focus round trip: the unguarded click fired\n * `blur` once and `focus` twice on the textarea, the guarded one fired neither. Those events are\n * load-bearing in real forms — save-the-draft, run-validation, close-the-composer all hang off\n * blur — so a toolbar without the guard makes every button press look like the person left the\n * field. That is the claim, and it is smaller and more specific than \"the selection is lost\".\n *\n * One tab stop for the row with the arrow keys moving inside it, which is the toolbar pattern: eight\n * buttons above a comment box would otherwise be eight stops between the page and the box every\n * keyboard user crosses on the way to typing. The shortcuts are bound on the field rather than on\n * the toolbar, because that is where the person's hands are.\n *\n * **What this is not: a markdown editor.** It writes markers into plain text and never parses, so\n * there is no preview, no syntax highlighting, no round-tripping through an AST, and the toggles\n * read the characters immediately around the selection rather than a document model — nesting that\n * a real parser would resolve is left to the person. That is the trade the atom is for; if you need\n * the other thing you need a different dependency, not this file.\n */\nexport function MarkdownToolbar({\n  textareaRef,\n  actions = DEFAULT_ACTIONS,\n  label = \"Formatting\",\n  onValueChange,\n  shortcuts = true,\n  disabled = false,\n  className,\n  onMouseDown: onMouseDownProp,\n  onKeyDown: onKeyDownProp,\n  ...props\n}: MarkdownToolbarProps) {\n  const resolved = React.useMemo(\n    () => actions.map(resolve).filter((action): action is MarkdownAction => Boolean(action)),\n    [actions]\n  )\n\n  const [active, setActive] = React.useState(0)\n  const buttonsRef = React.useRef<(HTMLButtonElement | null)[]>([])\n  const rowRef = React.useRef<HTMLDivElement | null>(null)\n\n  // Clamped during render rather than in an effect: a shorter `actions` array would otherwise leave\n  // the roving tab stop past the end for one commit, and that commit is the one where no button has\n  // `tabIndex={0}` and the toolbar cannot be reached by Tab at all.\n  const index = resolved.length === 0 ? 0 : Math.min(active, resolved.length - 1)\n\n  const run = React.useCallback(\n    (action: MarkdownAction) => {\n      const field = textareaRef.current\n      if (!field || disabled) return\n      const edit = editFor(\n        { value: field.value, start: field.selectionStart ?? 0, end: field.selectionEnd ?? 0 },\n        action.spec\n      )\n      writeEdit(field, edit)\n      onValueChange?.(field.value)\n    },\n    [disabled, onValueChange, textareaRef]\n  )\n\n  // Bound on the field, not on the toolbar, and through `addEventListener` because the field is not\n  // rendered here. Attached in the capture phase so a shortcut is handled before anything the\n  // consumer put on the textarea decides to act on the same chord.\n  React.useEffect(() => {\n    const field = textareaRef.current\n    if (!field || !shortcuts || disabled) return\n\n    const byShortcut = new Map(\n      resolved\n        .filter((action) => action.shortcut)\n        .map((action) => [action.shortcut!.toLowerCase(), action])\n    )\n    if (byShortcut.size === 0) return\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      // Either modifier, rather than the platform's own. A page cannot know which keyboard is in\n      // front of it, Ctrl+B is what a Windows keyboard on a Mac sends, and neither chord means\n      // anything else inside a textarea.\n      if (!event.metaKey && !event.ctrlKey) return\n      if (event.altKey) return\n      const action = byShortcut.get(event.key.toLowerCase())\n      if (!action) return\n      event.preventDefault()\n      run(action)\n    }\n\n    field.addEventListener(\"keydown\", onKeyDown, true)\n    return () => field.removeEventListener(\"keydown\", onKeyDown, true)\n  }, [disabled, resolved, run, shortcuts, textareaRef])\n\n  const focusAt = (next: number) => {\n    setActive(next)\n    buttonsRef.current[next]?.focus?.()\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDownProp?.(event)\n    if (event.defaultPrevented || resolved.length === 0) return\n    const last = resolved.length - 1\n    const step = isRtl(rowRef.current) ? -1 : 1\n    const move = (by: number) => {\n      event.preventDefault()\n      const next = index + by\n      focusAt(next < 0 ? last : next > last ? 0 : next)\n    }\n\n    switch (event.key) {\n      case \"ArrowRight\":\n        move(step)\n        break\n      case \"ArrowLeft\":\n        move(-step)\n        break\n      case \"Home\":\n        event.preventDefault()\n        focusAt(0)\n        break\n      case \"End\":\n        event.preventDefault()\n        focusAt(last)\n        break\n    }\n  }\n\n  const onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {\n    onMouseDownProp?.(event)\n    // See the note on this component: this is about the blur, not about the selection.\n    if (!event.defaultPrevented) event.preventDefault()\n  }\n\n  if (resolved.length === 0) return null\n\n  return (\n    <div\n      {...props}\n      ref={rowRef}\n      role=\"toolbar\"\n      aria-label={label}\n      aria-orientation=\"horizontal\"\n      aria-disabled={disabled || undefined}\n      onMouseDown={onMouseDown}\n      onKeyDown={onKeyDown}\n      className={cn(\"flex items-center gap-0.5\", className)}\n    >\n      {resolved.map((action, i) => (\n        <button\n          key={action.name}\n          ref={(node) => {\n            buttonsRef.current[i] = node\n          }}\n          type=\"button\"\n          // One stop for the row. The buttons that are not it stay reachable with the arrows.\n          tabIndex={i === index ? 0 : -1}\n          disabled={disabled}\n          title={action.label}\n          aria-label={action.label}\n          aria-keyshortcuts={action.shortcut ? `Meta+${action.shortcut.toUpperCase()}` : undefined}\n          onFocus={() => setActive(i)}\n          onClick={() => run(action)}\n          className=\"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50\"\n        >\n          {action.icon ? (\n            <span aria-hidden=\"true\" className=\"inline-flex\">\n              {action.icon}\n            </span>\n          ) : (\n            <span aria-hidden=\"true\" className=\"text-xs font-medium\">\n              {action.label.slice(0, 1)}\n            </span>\n          )}\n        </button>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
