{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rich-text-editor",
  "type": "registry:ui",
  "title": "Rich text editor",
  "description": "A lightweight editor with markdown shortcuts, a floating toolbar, a slash menu, and HTML and markdown output.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/rich-text-editor/rich-text-editor.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type {\n  ClipboardEvent as ReactClipboardEvent,\n  CSSProperties,\n  FormEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n  ReactNode,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport {\n  ArrowLeft,\n  Bold,\n  Check,\n  Code,\n  Heading1,\n  Heading2,\n  Heading3,\n  Italic,\n  Link,\n  List,\n  ListOrdered,\n  Minus,\n  Pilcrow,\n  Quote,\n  SquareCode,\n  Strikethrough,\n  Unlink,\n} from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/* ------------------------------------------------------------------------------------------------\n * Markdown and HTML. Small, dependency free converters for the subset the editor writes.\n * ---------------------------------------------------------------------------------------------- */\n\nexport type BlockType = \"p\" | \"h1\" | \"h2\" | \"h3\" | \"ul\" | \"ol\" | \"blockquote\" | \"pre\" | \"hr\";\n\nconst ZWSP = /\\u200b/g;\nconst escapeHtml = (value: string) =>\n  value.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\");\nconst safeUrl = (url: string) => /^(https?:|mailto:|tel:|\\/|#)/i.test(url.trim());\n\n/** Adds a scheme to bare domains so `example.com/docs` becomes a working link. */\nexport function normalizeUrl(input: string) {\n  const url = input.trim();\n  if (!url) return \"\";\n  if (/^[a-z][a-z\\d+.-]*:/i.test(url) || url.startsWith(\"/\") || url.startsWith(\"#\")) return url;\n  if (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(url)) return `mailto:${url}`;\n  return `https://${url}`;\n}\n\nfunction inlineMarkdown(text: string) {\n  const codes: string[] = [];\n  let out = escapeHtml(text).replace(/`([^`]+)`/g, (_, code: string) => `\\u0000${codes.push(code) - 1}\\u0000`);\n  out = out.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g, (_, label: string, url: string) =>\n    safeUrl(url) ? `<a href=\"${url}\">${label}</a>` : label,\n  );\n  out = out.replace(/(\\*\\*|__)(?=\\S)([\\s\\S]*?\\S)\\1/g, \"<strong>$2</strong>\");\n  out = out.replace(/~~(?=\\S)([\\s\\S]*?\\S)~~/g, \"<s>$1</s>\");\n  out = out.replace(/(^|[^\\w*])\\*(?=\\S)([^*]*?\\S)\\*(?!\\*)/g, \"$1<em>$2</em>\");\n  out = out.replace(/(^|[^\\w_])_(?=\\S)([^_]*?\\S)_(?![\\w_])/g, \"$1<em>$2</em>\");\n  // oxlint-disable-next-line no-control-regex -- NUL marks the code spans set aside above; typed text never contains it\n  return out.replace(/\\u0000(\\d+)\\u0000/g, (_, index: string) => `<code>${codes[Number(index)]}</code>`);\n}\n\nfunction listMarkdown(lines: string[]) {\n  const items = lines.map((line) => {\n    const match = /^(\\s*)([-*+]|\\d+[.)])\\s+(.*)$/.exec(line)!;\n    return { indent: match[1]!.replace(/\\t/g, \"  \").length, tag: /\\d/.test(match[2]!) ? \"ol\" : \"ul\", text: match[3]! };\n  });\n  let html = \"\";\n  const stack: { indent: number; tag: string }[] = [];\n  for (const item of items) {\n    while (stack.length > 1 && item.indent < stack[stack.length - 1]!.indent) html += `</li></${stack.pop()!.tag}>`;\n    const top = stack[stack.length - 1];\n    if (!top || item.indent > top.indent) {\n      html += `<${item.tag}><li>`;\n      stack.push({ indent: item.indent, tag: item.tag });\n    } else if (top.tag !== item.tag) {\n      html += `</li></${top.tag}><${item.tag}><li>`;\n      top.tag = item.tag;\n    } else html += \"</li><li>\";\n    html += inlineMarkdown(item.text);\n  }\n  while (stack.length) html += `</li></${stack.pop()!.tag}>`;\n  return html;\n}\n\n/** Converts Markdown to the editor's HTML: headings (three levels), lists, quotes, fenced code, rules, and inline marks. */\nexport function markdownToHtml(markdown: string) {\n  const lines = markdown.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n  const out: string[] = [];\n  const paragraph: string[] = [];\n  const flush = () => {\n    if (paragraph.length) {\n      out.push(`<p>${inlineMarkdown(paragraph.join(\" \"))}</p>`);\n      paragraph.length = 0;\n    }\n  };\n  const isItem = (line: string) => /^\\s*([-*+]|\\d+[.)])\\s+/.test(line);\n  for (let index = 0; index < lines.length;) {\n    const line = lines[index]!;\n    if (/^\\s*```/.test(line)) {\n      flush();\n      const code: string[] = [];\n      index++;\n      while (index < lines.length && !/^\\s*```/.test(lines[index]!)) code.push(lines[index++]!);\n      index++;\n      out.push(`<pre>${escapeHtml(code.join(\"\\n\")) || \"<br>\"}</pre>`);\n      continue;\n    }\n    if (!line.trim()) {\n      flush();\n      index++;\n      continue;\n    }\n    const heading = /^(#{1,6})\\s+(.*)$/.exec(line);\n    if (heading) {\n      flush();\n      const level = Math.min(3, heading[1]!.length);\n      out.push(`<h${level}>${inlineMarkdown(heading[2]!)}</h${level}>`);\n      index++;\n      continue;\n    }\n    if (/^ {0,3}([-*_])( *\\1){2,} *$/.test(line)) {\n      flush();\n      out.push(\"<hr>\");\n      index++;\n      continue;\n    }\n    if (/^\\s*>/.test(line)) {\n      flush();\n      const quote: string[] = [];\n      while (index < lines.length && /^\\s*>/.test(lines[index]!)) quote.push(lines[index++]!.replace(/^\\s*>\\s?/, \"\"));\n      out.push(`<blockquote>${quote.map(inlineMarkdown).join(\"<br>\")}</blockquote>`);\n      continue;\n    }\n    if (isItem(line)) {\n      flush();\n      const items: string[] = [];\n      while (index < lines.length && isItem(lines[index]!)) items.push(lines[index++]!);\n      out.push(listMarkdown(items));\n      continue;\n    }\n    paragraph.push(line.trim());\n    index++;\n  }\n  flush();\n  return out.join(\"\") || \"<p><br></p>\";\n}\n\nfunction wrapMark(inner: string, mark: string) {\n  const match = /^(\\s*)([\\s\\S]*?)(\\s*)$/.exec(inner)!;\n  return match[2] ? `${match[1]}${mark}${match[2]}${mark}${match[3]}` : inner;\n}\n\nfunction inlineToMarkdown(node: Node): string {\n  let out = \"\";\n  node.childNodes.forEach((child) => {\n    if (child.nodeType === Node.TEXT_NODE) {\n      out += (child as Text).data.replace(ZWSP, \"\");\n      return;\n    }\n    if (child.nodeType !== Node.ELEMENT_NODE) return;\n    const element = child as HTMLElement;\n    switch (element.nodeName) {\n      case \"BR\":\n        out += \"\\n\";\n        break;\n      case \"STRONG\":\n      case \"B\":\n        out += wrapMark(inlineToMarkdown(element), \"**\");\n        break;\n      case \"EM\":\n      case \"I\":\n        out += wrapMark(inlineToMarkdown(element), \"_\");\n        break;\n      case \"S\":\n      case \"STRIKE\":\n      case \"DEL\":\n        out += wrapMark(inlineToMarkdown(element), \"~~\");\n        break;\n      case \"CODE\":\n        out += `\\`${(element.textContent ?? \"\").replace(ZWSP, \"\")}\\``;\n        break;\n      case \"A\":\n        out += `[${inlineToMarkdown(element)}](${element.getAttribute(\"href\") ?? \"\"})`;\n        break;\n      case \"UL\":\n      case \"OL\":\n        break;\n      default:\n        out += inlineToMarkdown(element);\n    }\n  });\n  return out;\n}\n\nfunction listToMarkdown(list: Element, indent: string): string {\n  let number = 1;\n  return Array.from(list.children)\n    .filter((item) => item.nodeName === \"LI\")\n    .map((item) => {\n      const marker = list.nodeName === \"OL\" ? `${number++}. ` : \"- \";\n      const nested = Array.from(item.children)\n        .filter((child) => child.nodeName === \"UL\" || child.nodeName === \"OL\")\n        .map((child) => listToMarkdown(child, indent + \" \".repeat(marker.length)));\n      return [`${indent}${marker}${inlineToMarkdown(item).trim()}`, ...nested].join(\"\\n\");\n    })\n    .join(\"\\n\");\n}\n\nfunction blockToMarkdown(node: Node): string {\n  if (node.nodeType === Node.TEXT_NODE) return (node as Text).data.replace(ZWSP, \"\").trim();\n  if (node.nodeType !== Node.ELEMENT_NODE) return \"\";\n  const element = node as HTMLElement;\n  switch (element.nodeName) {\n    case \"H1\":\n      return `# ${inlineToMarkdown(element).trim()}`;\n    case \"H2\":\n      return `## ${inlineToMarkdown(element).trim()}`;\n    case \"H3\":\n      return `### ${inlineToMarkdown(element).trim()}`;\n    case \"HR\":\n      return \"---\";\n    case \"PRE\":\n      return `\\`\\`\\`\\n${(element.textContent ?? \"\").replace(ZWSP, \"\").replace(/\\n$/, \"\")}\\n\\`\\`\\``;\n    case \"UL\":\n    case \"OL\":\n      return listToMarkdown(element, \"\");\n    case \"BLOCKQUOTE\": {\n      const blocks = Array.from(element.children).some((child) => BLOCK_TAGS.has(child.nodeName));\n      const body = blocks\n        ? Array.from(element.childNodes).map(blockToMarkdown).filter(Boolean).join(\"\\n\\n\")\n        : inlineToMarkdown(element).trim();\n      return body\n        .split(\"\\n\")\n        .map((line) => (line ? `> ${line}` : \">\"))\n        .join(\"\\n\");\n    }\n    default:\n      return inlineToMarkdown(element).trim();\n  }\n}\n\n/** Converts editor HTML (or an element) to Markdown. */\nexport function htmlToMarkdown(input: string | HTMLElement) {\n  const root = typeof input === \"string\" ? parseHtml(input) : input;\n  return Array.from(root.childNodes).map(blockToMarkdown).filter(Boolean).join(\"\\n\\n\");\n}\n\nfunction parseHtml(html: string) {\n  const template = document.createElement(\"template\");\n  template.innerHTML = html;\n  const root = document.createElement(\"div\");\n  root.appendChild(template.content);\n  return root;\n}\n\nconst insertAfter = (reference: Node, ...nodes: Node[]) => {\n  let at = reference;\n  nodes.forEach((node) => {\n    at.parentNode!.insertBefore(node, at.nextSibling);\n    at = node;\n  });\n};\nconst insertBefore = (reference: Node, ...nodes: Node[]) =>\n  nodes.forEach((node) => reference.parentNode!.insertBefore(node, reference));\nconst replaceNode = (old: Node, ...nodes: Node[]) => {\n  insertBefore(old, ...nodes);\n  old.parentNode!.removeChild(old);\n};\nconst appendNodes = (parent: Node, ...nodes: Node[]) => nodes.forEach((node) => parent.appendChild(node));\n\nconst INLINE_MAP: Record<string, string> = {\n  B: \"strong\",\n  STRONG: \"strong\",\n  I: \"em\",\n  EM: \"em\",\n  S: \"s\",\n  STRIKE: \"s\",\n  DEL: \"s\",\n  CODE: \"code\",\n  A: \"a\",\n  BR: \"br\",\n};\nconst BLOCK_MAP: Record<string, string> = {\n  P: \"p\",\n  H1: \"h1\",\n  H2: \"h2\",\n  H3: \"h3\",\n  H4: \"h3\",\n  H5: \"h3\",\n  H6: \"h3\",\n  UL: \"ul\",\n  OL: \"ol\",\n  LI: \"li\",\n  BLOCKQUOTE: \"blockquote\",\n  PRE: \"pre\",\n  HR: \"hr\",\n};\nconst DROP = new Set([\n  \"SCRIPT\",\n  \"STYLE\",\n  \"META\",\n  \"LINK\",\n  \"TITLE\",\n  \"TEMPLATE\",\n  \"IFRAME\",\n  \"OBJECT\",\n  \"EMBED\",\n  \"SVG\",\n  \"CANVAS\",\n  \"IMG\",\n  \"VIDEO\",\n  \"AUDIO\",\n  \"INPUT\",\n  \"BUTTON\",\n  \"SELECT\",\n  \"TEXTAREA\",\n  \"HEAD\",\n]);\nconst BLOCK_TAGS = new Set([\"P\", \"H1\", \"H2\", \"H3\", \"LI\", \"PRE\", \"BLOCKQUOTE\", \"DIV\", \"UL\", \"OL\", \"HR\"]);\n\n/** Keeps only the tags the editor understands and drops every attribute except safe link targets. Bold and italic spans from other editors become marks. */\nexport function sanitizeHtml(html: string) {\n  const source = new DOMParser().parseFromString(html, \"text/html\").body;\n  const out = document.createElement(\"div\");\n  const convert = (from: Node, into: Node) => {\n    from.childNodes.forEach((child) => {\n      if (child.nodeType === Node.TEXT_NODE) {\n        into.appendChild(document.createTextNode((child as Text).data));\n        return;\n      }\n      if (child.nodeType !== Node.ELEMENT_NODE) return;\n      const element = child as HTMLElement;\n      if (DROP.has(element.nodeName)) return;\n      const style = element.getAttribute(\"style\") ?? \"\";\n      let tag = INLINE_MAP[element.nodeName] ?? BLOCK_MAP[element.nodeName];\n      if (element.nodeName === \"B\" && /font-weight:\\s*(normal|[1-4]00)/.test(style)) tag = \"\";\n      if (element.nodeName === \"SPAN\")\n        tag = /font-weight:\\s*(bold|[6-9]00)/.test(style)\n          ? \"strong\"\n          : /font-style:\\s*italic/.test(style)\n            ? \"em\"\n            : /line-through/.test(style)\n              ? \"s\"\n              : \"\";\n      if (!tag) {\n        convert(element, into);\n        return;\n      }\n      const next = document.createElement(tag);\n      if (tag === \"a\") {\n        const href = element.getAttribute(\"href\") ?? \"\";\n        if (!safeUrl(href)) {\n          convert(element, into);\n          return;\n        }\n        next.setAttribute(\"href\", href);\n      }\n      if (tag === \"pre\") next.textContent = element.textContent ?? \"\";\n      else if (tag !== \"br\" && tag !== \"hr\") convert(element, next);\n      into.appendChild(next);\n    });\n  };\n  convert(source, out);\n  // Inline runs at the top level become paragraphs; whitespace between blocks disappears.\n  const fixed = document.createElement(\"div\");\n  let run: HTMLElement | null = null;\n  Array.from(out.childNodes).forEach((node) => {\n    const block = node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.nodeName);\n    if (block) {\n      run = null;\n      fixed.appendChild(node);\n      return;\n    }\n    if (node.nodeType === Node.TEXT_NODE && !(node as Text).data.trim() && !run) return;\n    if (!run) {\n      run = document.createElement(\"p\");\n      fixed.appendChild(run);\n    }\n    run.appendChild(node);\n  });\n  fixed.querySelectorAll(\"li > p\").forEach((paragraph) => replaceNode(paragraph, ...Array.from(paragraph.childNodes)));\n  // The editor keeps typed whitespace, so formatting whitespace between blocks and list items has to go.\n  [fixed, ...Array.from(fixed.querySelectorAll(\"ul, ol, blockquote, li\"))].forEach((parent) =>\n    Array.from(parent.childNodes).forEach((node) => {\n      if (\n        isText(node) &&\n        !node.data.trim() &&\n        (parent === fixed ||\n          parent.nodeName === \"UL\" ||\n          parent.nodeName === \"OL\" ||\n          (node.previousSibling && BLOCK_TAGS.has(node.previousSibling.nodeName)) ||\n          (node.nextSibling && BLOCK_TAGS.has(node.nextSibling.nodeName)))\n      )\n        node.remove();\n    }),\n  );\n  return fixed.innerHTML;\n}\n\n/* ------------------------------------------------------------------------------------------------\n * DOM helpers. Positions are stored as character offsets where every block start and line break\n * counts as one character, so the caret survives any rewrite that keeps the text.\n * ---------------------------------------------------------------------------------------------- */\n\nconst COUNTED = new Set([\"P\", \"H1\", \"H2\", \"H3\", \"LI\", \"PRE\", \"BLOCKQUOTE\", \"DIV\"]);\nconst isElement = (node: Node | null): node is HTMLElement => !!node && node.nodeType === Node.ELEMENT_NODE;\nconst isText = (node: Node | null): node is Text => !!node && node.nodeType === Node.TEXT_NODE;\n\nfunction pointToOffset(root: Node, container: Node, offset: number) {\n  let count = 0,\n    found = -1;\n  const visit = (node: Node) => {\n    if (found >= 0) return;\n    if (isText(node)) {\n      if (node === container) found = count + offset;\n      else count += node.data.length;\n      return;\n    }\n    if (!isElement(node)) return;\n    if (node !== root && COUNTED.has(node.nodeName)) count += 1;\n    if (node.nodeName === \"BR\" || node.nodeName === \"HR\") count += 1;\n    const children = node.childNodes;\n    for (let index = 0; index < children.length; index++) {\n      if (node === container && index === offset) {\n        found = count;\n        return;\n      }\n      visit(children[index]!);\n      if (found >= 0) return;\n    }\n    if (node === container) found = count;\n  };\n  visit(root);\n  return found < 0 ? count : found;\n}\n\nfunction offsetToPoint(root: Node, target: number): [Node, number] {\n  let count = 0;\n  let result: [Node, number] | null = null;\n  const visit = (node: Node) => {\n    if (result) return;\n    if (isText(node)) {\n      if (target <= count + node.data.length) {\n        result = [node, Math.max(0, target - count)];\n        return;\n      }\n      count += node.data.length;\n      return;\n    }\n    if (!isElement(node)) return;\n    if (node !== root && COUNTED.has(node.nodeName)) count += 1;\n    if (node.nodeName === \"BR\" || node.nodeName === \"HR\") {\n      if (count === target && node.parentNode) {\n        result = [node.parentNode, Array.prototype.indexOf.call(node.parentNode.childNodes, node)];\n        return;\n      }\n      count += 1;\n      return;\n    }\n    node.childNodes.forEach(visit);\n    if (!result && count === target && node !== root && COUNTED.has(node.nodeName))\n      result = [node, node.childNodes.length];\n  };\n  visit(root);\n  if (result) return result;\n  const last = root.lastChild;\n  return last && isElement(last) && last.nodeName !== \"HR\"\n    ? [last, last.childNodes.length]\n    : [root, root.childNodes.length];\n}\n\ntype Offsets = { start: number; end: number };\n\nfunction readOffsets(root: HTMLElement): Offsets | null {\n  const selection = window.getSelection();\n  if (!selection?.rangeCount) return null;\n  const range = selection.getRangeAt(0);\n  if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;\n  return {\n    start: pointToOffset(root, range.startContainer, range.startOffset),\n    end: pointToOffset(root, range.endContainer, range.endOffset),\n  };\n}\n\nfunction writeOffsets(root: HTMLElement, offsets: Offsets | null) {\n  const selection = window.getSelection();\n  if (!selection) return;\n  const range = document.createRange();\n  if (!offsets) {\n    range.selectNodeContents(root.lastElementChild ?? root);\n    range.collapse(false);\n  } else {\n    const [startNode, startOffset] = offsetToPoint(root, offsets.start);\n    const [endNode, endOffset] =\n      offsets.end === offsets.start ? [startNode, startOffset] : offsetToPoint(root, offsets.end);\n    range.setStart(startNode, startOffset);\n    range.setEnd(endNode, endOffset);\n  }\n  selection.removeAllRanges();\n  selection.addRange(range);\n}\n\n/** The visible band of the nearest scrolling or clipping ancestor, so floating layers flip before they get cut off. */\nfunction clipBand(element: HTMLElement) {\n  let top = 0,\n    bottom = window.innerHeight;\n  for (let node = element.parentElement; node; node = node.parentElement) {\n    const style = getComputedStyle(node);\n    if (style.overflowY !== \"visible\" || style.overflowX !== \"visible\") {\n      const rect = node.getBoundingClientRect();\n      top = Math.max(top, rect.top);\n      bottom = Math.min(bottom, rect.bottom);\n    }\n  }\n  return { top, bottom };\n}\n\nfunction placeCaret(node: Node, offset: number) {\n  const selection = window.getSelection();\n  if (!selection) return;\n  const range = document.createRange();\n  range.setStart(node, offset);\n  range.collapse(true);\n  selection.removeAllRanges();\n  selection.addRange(range);\n}\n\nfunction placeAtStart(element: HTMLElement) {\n  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);\n  const first = walker.nextNode();\n  if (first) placeCaret(first, 0);\n  else placeCaret(element, 0);\n}\n\n/** The nearest block around a node: a paragraph, heading, list item, quote, or code block. */\nfunction blockAt(root: HTMLElement, node: Node | null): HTMLElement | null {\n  let current: Node | null = node;\n  while (current && current !== root) {\n    if (isElement(current) && COUNTED.has(current.nodeName)) return current;\n    current = current.parentNode;\n  }\n  return null;\n}\n\nconst visibleText = (element: Node) => (element.textContent ?? \"\").replace(ZWSP, \"\");\n\nfunction ensureFilled(element: HTMLElement) {\n  if (element.nodeName === \"HR\") return;\n  if (!visibleText(element) && !element.querySelector(\"br, hr, ul, ol\"))\n    element.appendChild(document.createElement(\"br\"));\n}\n\nfunction textBefore(block: HTMLElement, range: Range) {\n  const before = document.createRange();\n  before.setStart(block, 0);\n  before.setEnd(range.startContainer, range.startOffset);\n  return before\n    .toString()\n    .replace(ZWSP, \"\")\n    .replace(/\\u00a0/g, \" \");\n}\n\nfunction atBlockStart(block: HTMLElement, range: Range) {\n  if (!range.collapsed) return false;\n  const before = document.createRange();\n  before.setStart(block, 0);\n  before.setEnd(range.startContainer, range.startOffset);\n  return !before.toString().replace(ZWSP, \"\") && !before.cloneContents().querySelector(\"br, hr\");\n}\n\nfunction moveChildren(from: Node, to: Node) {\n  while (from.firstChild) to.appendChild(from.firstChild);\n}\n\n/** Plain text of a block, with line breaks for `<br>`, for moving content into a code block. */\nfunction plainText(element: HTMLElement) {\n  let out = \"\";\n  const walk = (node: Node) =>\n    node.childNodes.forEach((child) => {\n      if (isText(child)) out += child.data.replace(ZWSP, \"\");\n      else if (child.nodeName === \"BR\") out += \"\\n\";\n      else walk(child);\n    });\n  walk(element);\n  return out.replace(/\\n$/, \"\");\n}\n\nfunction linesToNodes(text: string) {\n  const nodes: Node[] = [];\n  text\n    .replace(/\\n$/, \"\")\n    .split(\"\\n\")\n    .forEach((line, index) => {\n      if (index) nodes.push(document.createElement(\"br\"));\n      if (line) nodes.push(document.createTextNode(line));\n    });\n  return nodes;\n}\n\nfunction mergeAdjacentLists(list: HTMLElement) {\n  const next = list.nextElementSibling;\n  if (next && next.nodeName === list.nodeName) {\n    moveChildren(next, list);\n    next.remove();\n  }\n  const previous = list.previousElementSibling;\n  if (previous && previous.nodeName === list.nodeName) {\n    moveChildren(list, previous);\n    list.remove();\n    return previous as HTMLElement;\n  }\n  return list;\n}\n\n/** Moves a list item one level out. A top level item leaves the list and becomes a paragraph between its halves. */\nfunction outdent(item: HTMLElement): HTMLElement {\n  const list = item.parentElement!;\n  const parentItem = list.parentElement;\n  const following: Node[] = [];\n  for (let node = item.nextSibling; node; node = node.nextSibling) following.push(node);\n  if (parentItem?.nodeName === \"LI\") {\n    if (following.length) {\n      const sub = document.createElement(list.nodeName.toLowerCase());\n      following.forEach((node) => sub.appendChild(node));\n      item.appendChild(sub);\n    }\n    insertAfter(parentItem, item);\n    if (!list.children.length) list.remove();\n    return item;\n  }\n  const paragraph = document.createElement(\"p\");\n  const nested: Node[] = [];\n  Array.from(item.childNodes).forEach((child) => {\n    if (child.nodeName === \"UL\" || child.nodeName === \"OL\") nested.push(child);\n    else paragraph.appendChild(child);\n  });\n  ensureFilled(paragraph);\n  const tail = following.length || nested.length ? document.createElement(list.nodeName.toLowerCase()) : null;\n  if (tail) {\n    nested.forEach(\n      (sub) => sub.childNodes.length && Array.from(sub.childNodes).forEach((node) => tail.appendChild(node)),\n    );\n    following.forEach((node) => tail.appendChild(node));\n  }\n  insertAfter(list, paragraph);\n  if (tail) insertAfter(paragraph, tail);\n  item.remove();\n  if (!list.children.length) list.remove();\n  return paragraph;\n}\n\nfunction indent(item: HTMLElement) {\n  const previous = item.previousElementSibling as HTMLElement | null;\n  if (!previous || previous.nodeName !== \"LI\") return false;\n  const listTag = item.parentElement!.nodeName;\n  let sub = previous.lastElementChild as HTMLElement | null;\n  if (!sub || (sub.nodeName !== \"UL\" && sub.nodeName !== \"OL\")) {\n    sub = document.createElement(listTag.toLowerCase());\n    previous.appendChild(sub);\n  }\n  sub.appendChild(item);\n  return true;\n}\n\n/** Turns one block into another type, keeping its content. Choosing the type a block already has turns it back into a paragraph. */\nfunction convertBlock(block: HTMLElement, type: Exclude<BlockType, \"hr\">): HTMLElement {\n  if (block.nodeName === \"LI\") {\n    const list = block.parentElement!;\n    if (type === \"ul\" || type === \"ol\") {\n      if (list.nodeName.toLowerCase() === type) return outdent(block);\n      const swapped = document.createElement(type);\n      moveChildren(list, swapped);\n      replaceNode(list, swapped);\n      return block;\n    }\n    const paragraph = outdent(block);\n    return paragraph.nodeName === \"P\" && type !== \"p\" ? convertBlock(paragraph, type) : paragraph;\n  }\n  if (type === \"ul\" || type === \"ol\") {\n    const item = document.createElement(\"li\");\n    if (block.nodeName === \"PRE\") appendNodes(item, ...linesToNodes(block.textContent ?? \"\"));\n    else moveChildren(block, item);\n    ensureFilled(item);\n    const list = document.createElement(type);\n    list.appendChild(item);\n    replaceNode(block, list);\n    mergeAdjacentLists(list);\n    return item;\n  }\n  const tag = block.nodeName.toLowerCase() === type ? \"p\" : type;\n  const next = document.createElement(tag);\n  if (tag === \"pre\") next.textContent = plainText(block);\n  else if (block.nodeName === \"PRE\") appendNodes(next, ...linesToNodes(block.textContent ?? \"\"));\n  else moveChildren(block, next);\n  ensureFilled(next);\n  replaceNode(block, next);\n  return next;\n}\n\n/** Makes sure a paragraph follows a block the caret cannot type after, such as a rule or a code block at the end. */\nfunction paragraphAfter(block: Element) {\n  const next = block.nextElementSibling;\n  if (next && next.nodeName === \"P\") return next as HTMLElement;\n  const paragraph = document.createElement(\"p\");\n  paragraph.appendChild(document.createElement(\"br\"));\n  insertAfter(block, paragraph);\n  return paragraph;\n}\n\n/** Repairs what browsers leave behind: styled spans from merges, stray inline content at the top level, and an empty root. */\nfunction tidy(root: HTMLElement) {\n  const loose = Array.from(root.childNodes).some(\n    (node) => (isText(node) && node.data.trim()) || (isElement(node) && !BLOCK_TAGS.has(node.nodeName)),\n  );\n  const dirty = loose || !root.firstChild || root.querySelector(\"span, font, [style], div, b, i, strike\");\n  if (!dirty) return;\n  const offsets = readOffsets(root);\n  root.querySelectorAll(\"[style]\").forEach((element) => {\n    const style = element.getAttribute(\"style\") ?? \"\";\n    if (element.nodeName === \"SPAN\" && /font-weight:\\s*(bold|[6-9]00)/.test(style))\n      replaceNode(element, Object.assign(document.createElement(\"strong\"), { innerHTML: element.innerHTML }));\n    else element.removeAttribute(\"style\");\n  });\n  const rename = (selector: string, tag: string) =>\n    root.querySelectorAll(selector).forEach((element) => {\n      const next = document.createElement(tag);\n      moveChildren(element, next);\n      replaceNode(element, next);\n    });\n  rename(\"b\", \"strong\");\n  rename(\"i\", \"em\");\n  rename(\"strike\", \"s\");\n  rename(\":scope > div\", \"p\");\n  root\n    .querySelectorAll(\"span, font, div\")\n    .forEach((element) => replaceNode(element, ...Array.from(element.childNodes)));\n  let run: HTMLElement | null = null;\n  Array.from(root.childNodes).forEach((node) => {\n    if (isElement(node) && BLOCK_TAGS.has(node.nodeName)) {\n      run = null;\n      return;\n    }\n    if (isText(node) && !node.data.trim() && !run) {\n      node.remove();\n      return;\n    }\n    if (!run) {\n      run = document.createElement(\"p\");\n      insertBefore(node, run);\n    }\n    run.appendChild(node);\n  });\n  if (!root.firstChild) root.innerHTML = \"<p><br></p>\";\n  if (root.contains(document.activeElement) || document.activeElement === root) writeOffsets(root, offsets);\n}\n\n/** A clean copy for output: canonical tags, no attributes but links, no invisible helpers, no trailing empty lines. */\nfunction cleanClone(root: HTMLElement) {\n  const clone = root.cloneNode(true) as HTMLElement;\n  clone.querySelectorAll(\"*\").forEach((element) => {\n    Array.from(element.attributes).forEach((attribute) => {\n      if (!(element.nodeName === \"A\" && attribute.name === \"href\")) element.removeAttribute(attribute.name);\n    });\n  });\n  const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT);\n  const empty: Text[] = [];\n  for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {\n    node.data = node.data.replace(ZWSP, \"\");\n    if (!node.data) empty.push(node);\n  }\n  empty.forEach((node) => node.remove());\n  clone.querySelectorAll(\"p, h1, h2, h3, li, blockquote\").forEach((block) => {\n    const last = block.lastChild;\n    if (last?.nodeName === \"BR\" && visibleText(block)) last.remove();\n  });\n  clone.querySelectorAll(\"pre\").forEach((pre) => {\n    pre.textContent = (pre.textContent ?? \"\").replace(/\\n$/, \"\");\n  });\n  while (\n    clone.lastElementChild &&\n    clone.lastElementChild.nodeName !== \"HR\" &&\n    !visibleText(clone.lastElementChild) &&\n    clone.children.length > 1\n  )\n    clone.lastElementChild.remove();\n  return clone;\n}\n\nfunction plainOutput(clone: HTMLElement) {\n  return Array.from(clone.querySelectorAll(\"p, h1, h2, h3, li, blockquote, pre\"))\n    .filter((block) => !block.querySelector(\"p, li\"))\n    .map((block) => (block.textContent ?? \"\").trim())\n    .filter(Boolean)\n    .join(\"\\n\");\n}\n\n/* ------------------------------------------------------------------------------------------------\n * Component\n * ---------------------------------------------------------------------------------------------- */\n\nexport interface RichTextValue {\n  html: string;\n  markdown: string;\n  text: string;\n  empty: boolean;\n}\n\nexport interface RichTextEditorHandle {\n  focus: () => void;\n  getHTML: () => string;\n  getMarkdown: () => string;\n  getText: () => string;\n  setHTML: (html: string) => void;\n  setMarkdown: (markdown: string) => void;\n  clear: () => void;\n  undo: () => void;\n  redo: () => void;\n  element: HTMLDivElement | null;\n}\n\nexport interface RichTextEditorProps {\n  /** Controlled HTML. The editor only rewrites its content when this differs from what it last emitted. */\n  value?: string;\n  defaultValue?: string;\n  /** Initial content as Markdown, used when no HTML value is given. */\n  defaultMarkdown?: string;\n  onChange?: (value: RichTextValue) => void;\n  /** Reports whether undo and redo are available, for your own toolbar. */\n  onHistoryChange?: (state: { canUndo: boolean; canRedo: boolean }) => void;\n  placeholder?: string;\n  /** The hint on an empty line while typing. */\n  blockHint?: string;\n  readOnly?: boolean;\n  autoFocus?: boolean;\n  \"aria-label\"?: string;\n  className?: string;\n  style?: CSSProperties;\n}\n\ntype Formats = { bold: boolean; italic: boolean; strike: boolean; code: boolean; link: string | null; block: string };\ntype SlashItem = { type: BlockType; label: string; hint: string; keywords: string; icon: ReactNode };\ntype Slash = { node: Text; offset: number; query: string; x: number; y: number; above: boolean; max: number };\ntype Snapshot = { html: string; selection: Offsets | null; kind: \"type\" | \"command\"; at: number };\n\nconst icon = { size: 16, strokeWidth: 1.75, \"aria-hidden\": true } as const;\nconst SLASH_ITEMS: SlashItem[] = [\n  { type: \"p\", label: \"Text\", hint: \"\", keywords: \"paragraph plain body\", icon: <Pilcrow {...icon} /> },\n  { type: \"h1\", label: \"Heading 1\", hint: \"#\", keywords: \"title h1 large\", icon: <Heading1 {...icon} /> },\n  { type: \"h2\", label: \"Heading 2\", hint: \"##\", keywords: \"subtitle h2 section\", icon: <Heading2 {...icon} /> },\n  { type: \"h3\", label: \"Heading 3\", hint: \"###\", keywords: \"h3 small\", icon: <Heading3 {...icon} /> },\n  { type: \"ul\", label: \"Bulleted list\", hint: \"-\", keywords: \"bullet unordered ul points\", icon: <List {...icon} /> },\n  {\n    type: \"ol\",\n    label: \"Numbered list\",\n    hint: \"1.\",\n    keywords: \"ordered ol steps numbers\",\n    icon: <ListOrdered {...icon} />,\n  },\n  { type: \"blockquote\", label: \"Quote\", hint: \">\", keywords: \"blockquote citation callout\", icon: <Quote {...icon} /> },\n  { type: \"pre\", label: \"Code block\", hint: \"```\", keywords: \"pre snippet monospace\", icon: <SquareCode {...icon} /> },\n  { type: \"hr\", label: \"Divider\", hint: \"---\", keywords: \"rule hr line separator\", icon: <Minus {...icon} /> },\n];\n\nconst { spring, duration, ease, blur } = motionTokens;\nconst enter = [...ease.enter] as [number, number, number, number];\nconst standard = [...ease.standard] as [number, number, number, number];\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst MORPH = physical(spring.morph.visualDuration, 0.12),\n  GLIDE = physical(0.3, 0),\n  GROW = physical(spring.smooth.visualDuration, 0);\nconst TRAVEL = 18;\n\nconst faceVariants: Variants = {\n  hidden: (direction: number) => ({ opacity: 0, x: direction * TRAVEL, filter: `blur(${blur.subtle}px)` }),\n  shown: {\n    opacity: 1,\n    x: 0,\n    filter: \"blur(0px)\",\n    transition: {\n      x: MORPH,\n      opacity: { duration: 0.2, ease: enter, delay: 0.03 },\n      filter: { duration: 0.2, ease: enter, delay: 0.03 },\n    },\n  },\n  gone: (direction: number) => ({\n    opacity: 0,\n    x: direction * -TRAVEL,\n    filter: `blur(${blur.subtle}px)`,\n    transition: { x: MORPH, opacity: { duration: 0.12, ease: standard }, filter: { duration: 0.12, ease: standard } },\n  }),\n};\nconst fadeVariants: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1, transition: { duration: 0.14 } },\n  gone: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/**\n * The writing surface. Everything inside is plain semantic HTML, styled here so pasted and typed content look the same.\n * Block spacing between top-level blocks: headings open 1.1em above (none as the first block), anything right after a heading\n * sits .45em below it, other blocks .6em apart, while paragraphs and lists add none of their own and a divider keeps 1.1em on both sides.\n * List markers are set explicitly: app resets that strip list styles would otherwise leave lists unmarked.\n */\nconst EDITOR = [\n  \"min-h-32 py-0.5 text-base leading-[1.65] whitespace-pre-wrap text-ink caret-accent outline-none [overflow-wrap:break-word] [word-break:break-word] selection:bg-[color-mix(in_oklab,var(--accent)_22%,transparent)]\",\n  \"[&>:not(h1,h2,h3)+:not(p,ul,ol,h1,h2,h3,hr)]:mt-[.6em] [&>:not(h1,h2,h3)+:is(h1,h2,h3)]:mt-[1.1em] [&>:is(h1,h2,h3)+:not(ul,ol,hr)]:mt-[.45em]\",\n  \"[&_:is(h1,h2,h3)]:font-medium [&_:is(h1,h2,h3)]:tracking-[-.02em] [&_h1]:text-2xl/[1.25] [&_h2]:text-xl/[1.25] [&_h3]:text-lg/[1.25]\",\n  \"[&_strong]:font-semibold [&_s]:text-ink-2 [&_s]:decoration-1\",\n  \"[&_a]:cursor-text [&_a]:text-accent-ink [&_a]:underline [&_a]:decoration-[color:color-mix(in_oklab,var(--accent)_45%,transparent)] [&_a]:decoration-1 [&_a]:underline-offset-3\",\n  \"[&_code]:rounded-chip [&_code]:bg-hover [&_code]:px-[.38em] [&_code]:py-[.12em] [&_code]:text-[.86em] [&_code]:[font-family:ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace]\",\n  \"[&_blockquote]:border-l-2 [&_blockquote]:border-line-strong [&_blockquote]:pl-3.5 [&_blockquote]:text-ink-2\",\n  \"[&_:is(ul,ol)]:pl-[1.45em] [&_ul]:list-disc [&_ul_ul]:list-[circle] [&_ol]:list-decimal [&_li]:pl-[.2em] [&_li+li]:mt-[.2em] [&_li>:is(ul,ol)]:mt-[.2em] [&_li]:marker:text-ink-3 [&_ol>li]:marker:[font-variant-numeric:tabular-nums]\",\n  \"[&_pre]:overflow-x-auto [&_pre]:rounded-surface [&_pre]:border [&_pre]:border-line-soft [&_pre]:bg-hover [&_pre]:px-3.5 [&_pre]:py-3 [&_pre]:text-sm/[1.6] [&_pre]:whitespace-pre [&_pre]:[tab-size:2] [&_pre]:[font-family:ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace]\",\n  \"[&_hr]:my-[1.1em] [&_hr]:h-px [&_hr]:border-0 [&_hr]:bg-line\",\n].join(\" \");\n/** Placeholders: the whole document when empty, and a quiet hint on the empty line holding the caret. */\nconst HINT =\n  \"focus:[&_[data-hint]]:before:pointer-events-none focus:[&_[data-hint]]:before:float-left focus:[&_[data-hint]]:before:h-0 focus:[&_[data-hint]]:before:text-ink-3\";\nconst PLACEHOLDER =\n  \"[&>:first-child]:before:pointer-events-none [&>:first-child]:before:float-left [&>:first-child]:before:h-0 [&>:first-child]:before:text-ink-3 [&>:first-child]:before:content-(--placeholder)\";\nconst TOOL =\n  \"grid size-8 flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),scale_var(--duration-instant)_var(--ease-standard)] hover:bg-hover hover:text-ink focus-visible:bg-hover focus-visible:text-ink active:scale-92 aria-pressed:bg-accent-tint aria-pressed:text-accent-ink aria-pressed:hover:bg-accent-tint aria-pressed:hover:text-accent-ink [&[aria-pressed=true]:focus-visible:not(:hover)]:bg-hover [&[aria-pressed=true]:focus-visible:not(:hover)]:text-ink\";\n\n/** A toolbar face reports its natural size while current, so the shared surface can spring to it. */\nfunction Face({\n  direction,\n  reduced,\n  onSize,\n  children,\n}: {\n  direction: number;\n  reduced: boolean;\n  onSize: (width: number, height: number) => void;\n  children: ReactNode;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const present = useIsPresent();\n  useLayoutEffect(() => {\n    const node = ref.current;\n    if (!node || !present) return;\n    const report = () => onSize(node.offsetWidth, node.offsetHeight);\n    report();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(report);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [onSize, present]);\n  return (\n    <motion.div\n      ref={ref}\n      className=\"absolute top-0 left-0 w-max inert:pointer-events-none\"\n      custom={direction}\n      variants={reduced ? fadeVariants : faceVariants}\n      initial=\"hidden\"\n      animate=\"shown\"\n      exit=\"gone\"\n      inert={!present || undefined}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nfunction ToolButton({\n  label,\n  pressed,\n  shortcut,\n  onPress,\n  children,\n}: {\n  label: string;\n  pressed: boolean;\n  shortcut?: string;\n  onPress: () => void;\n  children: ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      className={TOOL}\n      aria-label={label}\n      aria-pressed={pressed}\n      title={shortcut ? `${label} (${shortcut})` : label}\n      onMouseDown={(event) => event.preventDefault()}\n      onClick={onPress}\n    >\n      {children}\n    </button>\n  );\n}\n\n/**\n * A light rich text editor on contentEditable. Markdown shortcuts as you type, a floating selection toolbar that morphs into a link field,\n * a slash menu for blocks, its own undo history, and HTML plus Markdown output. No dependencies beyond Motion and icons.\n */\nexport const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(function RichTextEditor(\n  {\n    value,\n    defaultValue,\n    defaultMarkdown,\n    onChange,\n    onHistoryChange,\n    placeholder = \"Start writing\",\n    blockHint = \"Type / for blocks\",\n    readOnly = false,\n    autoFocus = false,\n    \"aria-label\": ariaLabel = \"Editor\",\n    className,\n    style,\n  },\n  ref,\n) {\n  const reduced = !!useReducedMotion();\n  const uid = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const editorRef = useRef<HTMLDivElement>(null);\n  const busy = useRef(false);\n  const dragging = useRef(false);\n  const lastInput = useRef(\"\");\n  const lastEmitted = useRef<string | null>(null);\n  const history = useRef<{ stack: Snapshot[]; index: number }>({ stack: [], index: -1 });\n  const callbacks = useRef({ onChange, onHistoryChange });\n  useEffect(() => {\n    callbacks.current = { onChange, onHistoryChange };\n  }, [onChange, onHistoryChange]);\n\n  const [empty, setEmpty] = useState(true);\n  const [formats, setFormats] = useState<Formats>({\n    bold: false,\n    italic: false,\n    strike: false,\n    code: false,\n    link: null,\n    block: \"p\",\n  });\n  const [bar, setBar] = useState<{ x: number; y: number; below: boolean } | null>(null);\n  const [face, setFace] = useState<\"format\" | \"link\">(\"format\");\n  const [linkDraft, setLinkDraft] = useState(\"\");\n  const [slash, setSlash] = useState<Slash | null>(null);\n  const [slashActive, setSlashActive] = useState(0);\n  const [announce, setAnnounce] = useState(\"\");\n  const faceRef = useRef(face);\n  const slashRef = useRef(slash);\n  const savedRange = useRef<Range | null>(null);\n  const linkInputRef = useRef<HTMLInputElement>(null);\n  const hinted = useRef<HTMLElement | null>(null);\n  useEffect(() => {\n    faceRef.current = face;\n  }, [face]);\n  useEffect(() => {\n    slashRef.current = slash;\n  }, [slash]);\n\n  /* Output and history. */\n  const emit = useCallback(() => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    const clone = cleanClone(editor);\n    const html = clone.innerHTML;\n    const text = plainOutput(clone);\n    const isEmpty = !text && !clone.querySelector(\"hr\");\n    lastEmitted.current = html;\n    setEmpty(isEmpty);\n    callbacks.current.onChange?.({ html, markdown: htmlToMarkdown(clone), text, empty: isEmpty });\n  }, []);\n\n  const reportHistory = useCallback(() => {\n    const { stack, index } = history.current;\n    callbacks.current.onHistoryChange?.({ canUndo: index > 0, canRedo: index < stack.length - 1 });\n  }, []);\n\n  const record = useCallback(\n    (kind: Snapshot[\"kind\"], boundary = false) => {\n      const editor = editorRef.current;\n      if (!editor) return;\n      const snap: Snapshot = {\n        html: editor.innerHTML.replace(/ data-hint=\"\"/g, \"\"),\n        selection: readOffsets(editor),\n        kind,\n        at: Date.now(),\n      };\n      const state = history.current;\n      const top = state.stack[state.index];\n      if (top && top.html === snap.html) {\n        top.selection = snap.selection;\n        return;\n      }\n      if (kind === \"type\" && top?.kind === \"type\" && state.index > 0 && snap.at - top.at < 1000 && !boundary) {\n        state.stack[state.index] = { ...snap, at: top.at };\n      } else {\n        state.stack = state.stack.slice(0, state.index + 1);\n        state.stack.push(snap);\n        if (state.stack.length > 300) state.stack.shift();\n        state.index = state.stack.length - 1;\n      }\n      reportHistory();\n    },\n    [reportHistory],\n  );\n\n  const restore = useCallback(\n    (snap: Snapshot) => {\n      const editor = editorRef.current;\n      if (!editor) return;\n      editor.innerHTML = snap.html;\n      hinted.current = null;\n      editor.focus({ preventScroll: true });\n      writeOffsets(editor, snap.selection);\n      emit();\n      reportHistory();\n    },\n    [emit, reportHistory],\n  );\n\n  const undo = useCallback(() => {\n    const state = history.current;\n    if (state.index <= 0) return;\n    state.index -= 1;\n    setSlash(null);\n    restore(state.stack[state.index]!);\n    setAnnounce(\"Undone\");\n  }, [restore]);\n  const redo = useCallback(() => {\n    const state = history.current;\n    if (state.index >= state.stack.length - 1) return;\n    state.index += 1;\n    setSlash(null);\n    restore(state.stack[state.index]!);\n    setAnnounce(\"Redone\");\n  }, [restore]);\n\n  /* Selection driven chrome: block hint, active formats, the slash query, and the toolbar. */\n  const sync = useCallback(() => {\n    const editor = editorRef.current,\n      root = rootRef.current;\n    if (!editor || !root || busy.current) return;\n    const selection = window.getSelection();\n    const inside = !!selection?.rangeCount && editor.contains(selection.anchorNode);\n    const range = inside ? selection!.getRangeAt(0) : null;\n\n    const block = range ? blockAt(editor, range.startContainer) : null;\n    const hint = range?.collapsed && block && !visibleText(block) && block.nodeName !== \"PRE\" ? block : null;\n    if (hinted.current !== hint) {\n      hinted.current?.removeAttribute(\"data-hint\");\n      hint?.setAttribute(\"data-hint\", \"\");\n      hinted.current = hint;\n    }\n\n    if (!range) {\n      if (faceRef.current !== \"link\") setBar(null);\n      setSlash(null);\n      return;\n    }\n\n    const start = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;\n    const within = (selector: string) => {\n      const found = start?.closest(selector);\n      return !!found && editor.contains(found);\n    };\n    const anchor = start?.closest(\"a\");\n    const listItem = block?.nodeName === \"LI\" ? (block.parentElement?.nodeName.toLowerCase() ?? \"ul\") : null;\n    const next: Formats = {\n      bold: within(\"strong, b\"),\n      italic: within(\"em, i\"),\n      strike: within(\"s, strike, del\"),\n      code: within(\"code\") && !within(\"pre\"),\n      link: anchor && editor.contains(anchor) ? anchor.getAttribute(\"href\") : null,\n      block: listItem ?? block?.nodeName.toLowerCase() ?? \"p\",\n    };\n    setFormats((current) =>\n      (Object.keys(next) as (keyof Formats)[]).every((key) => current[key] === next[key]) ? current : next,\n    );\n\n    const open = slashRef.current;\n    if (open) {\n      const valid =\n        range.collapsed &&\n        range.startContainer === open.node &&\n        range.startOffset > open.offset &&\n        open.node.data[open.offset] === \"/\";\n      const query = valid ? open.node.data.slice(open.offset + 1, range.startOffset) : \"\";\n      if (!valid || query.length > 24 || /^\\s/.test(query) || /\\s\\s/.test(query)) setSlash(null);\n      else if (query !== open.query) setSlash({ ...open, query });\n    }\n\n    const inCode = !!start?.closest(\"pre\");\n    if (range.collapsed || dragging.current || inCode || slashRef.current || readOnly) {\n      if (faceRef.current !== \"link\") setBar(null);\n      return;\n    }\n    const rects = range.getClientRects();\n    const rect = rects.length ? rects[0]! : range.getBoundingClientRect();\n    const whole = range.getBoundingClientRect();\n    const box = root.getBoundingClientRect();\n    const below = rect.top - clipBand(root).top < 60;\n    setBar({\n      x: whole.left + whole.width / 2 - box.left,\n      y: below ? whole.bottom - box.top + 8 : rect.top - box.top - 8,\n      below,\n    });\n  }, [readOnly]);\n\n  const run = useCallback(\n    (change: () => void) => {\n      const editor = editorRef.current;\n      if (!editor) return;\n      busy.current = true;\n      try {\n        change();\n        tidy(editor);\n      } finally {\n        busy.current = false;\n      }\n      record(\"command\");\n      emit();\n      sync();\n    },\n    [emit, record, sync],\n  );\n\n  /* Content in and out. */\n  const load = useCallback((html: string) => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    editor.innerHTML = sanitizeHtml(html) || \"<p><br></p>\";\n    editor\n      .querySelectorAll(\"p, h1, h2, h3, li, blockquote, pre\")\n      .forEach((block) => ensureFilled(block as HTMLElement));\n    hinted.current = null;\n  }, []);\n\n  const initial = useRef({ value, defaultValue, defaultMarkdown, autoFocus });\n  useLayoutEffect(() => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    const start = initial.current;\n    load(\n      start.value ??\n        start.defaultValue ??\n        (start.defaultMarkdown !== undefined ? markdownToHtml(start.defaultMarkdown) : \"<p><br></p>\"),\n    );\n    history.current = { stack: [{ html: editor.innerHTML, selection: null, kind: \"command\", at: 0 }], index: 0 };\n    emit();\n    reportHistory();\n    if (start.autoFocus) {\n      editor.focus();\n      writeOffsets(editor, null);\n    }\n  }, [emit, load, reportHistory]);\n\n  useEffect(() => {\n    if (value === undefined || value === lastEmitted.current) return;\n    load(value);\n    record(\"command\");\n    emit();\n  }, [emit, load, record, value]);\n\n  /* Markdown shortcuts, applied right after the character that completes them. */\n  const shortcut = useCallback((data: string | null) => {\n    const editor = editorRef.current;\n    const selection = window.getSelection();\n    if (!editor || !data || !selection?.rangeCount) return false;\n    const range = selection.getRangeAt(0);\n    if (!range.collapsed) return false;\n    const block = blockAt(editor, range.startContainer);\n    if (!block || block.nodeName === \"PRE\") return false;\n    const before = textBefore(block, range);\n    const plainBlock = block.nodeName === \"P\" || block.nodeName === \"DIV\";\n\n    if (data === \" \" && plainBlock && block.parentElement === editor) {\n      const rule: [RegExp, Exclude<BlockType, \"hr\">][] = [\n        [/^# $/, \"h1\"],\n        [/^## $/, \"h2\"],\n        [/^### $/, \"h3\"],\n        [/^[-*+] $/, \"ul\"],\n        [/^\\d+[.)] $/, \"ol\"],\n        [/^> $/, \"blockquote\"],\n      ];\n      const match = rule.find(([pattern]) => pattern.test(before));\n      if (match) {\n        const prefix = document.createRange();\n        prefix.setStart(block, 0);\n        prefix.setEnd(range.startContainer, range.startOffset);\n        prefix.deleteContents();\n        const next = convertBlock(block, match[1]);\n        placeAtStart(next);\n        return true;\n      }\n    }\n    if (\n      data === \"-\" &&\n      plainBlock &&\n      block.parentElement === editor &&\n      before === \"---\" &&\n      visibleText(block) === \"---\"\n    ) {\n      const rule = document.createElement(\"hr\");\n      replaceNode(block, rule);\n      placeAtStart(paragraphAfter(rule));\n      return true;\n    }\n    if (\n      data === \"`\" &&\n      plainBlock &&\n      block.parentElement === editor &&\n      before === \"```\" &&\n      visibleText(block) === \"```\"\n    ) {\n      block.textContent = \"\";\n      const next = convertBlock(block, \"pre\");\n      placeAtStart(next);\n      return true;\n    }\n    const node = range.startContainer;\n    if (!isText(node) || node.parentElement?.closest(\"code\")) return false;\n    const text = node.data.slice(0, range.startOffset);\n    const inline: [RegExp, string][] = [\n      [/(\\*\\*|__)([^*_\\s](?:[^*_]*[^*_\\s])?)\\1$/, \"strong\"],\n      [/~~([^~\\s](?:[^~]*[^~\\s])?)~~$/, \"s\"],\n      [/`([^`]+)`$/, \"code\"],\n      [/(?:^|[^*\\w])(\\*([^*\\s](?:[^*]*[^*\\s])?)\\*)$/, \"em\"],\n      [/(?:^|[^_\\w])(_([^_\\s](?:[^_]*[^_\\s])?)_)$/, \"em\"],\n    ];\n    for (const [pattern, tag] of inline) {\n      const match = pattern.exec(text);\n      if (!match) continue;\n      const whole = tag === \"em\" ? match[1]! : match[0];\n      const inner = tag === \"em\" ? match[2]! : tag === \"code\" ? match[1]! : tag === \"s\" ? match[1]! : match[2]!;\n      const from = range.startOffset - whole.length;\n      node.splitText(range.startOffset);\n      const middle = node.splitText(from);\n      const element = document.createElement(tag);\n      element.textContent = inner;\n      replaceNode(middle, element);\n      // A zero width space after the mark keeps the next keystroke outside it; output strips it.\n      const exit = document.createTextNode(\"\\u200b\");\n      insertAfter(element, exit);\n      placeCaret(exit, 1);\n      return true;\n    }\n    return false;\n  }, []);\n\n  const openSlashIfTyped = useCallback((data: string | null) => {\n    const editor = editorRef.current,\n      root = rootRef.current;\n    const selection = window.getSelection();\n    if (data !== \"/\" || !editor || !root || !selection?.rangeCount) return;\n    const range = selection.getRangeAt(0);\n    const node = range.startContainer;\n    if (!range.collapsed || !isText(node) || node.parentElement?.closest(\"pre, code\")) return;\n    const offset = range.startOffset - 1;\n    if (node.data[offset] !== \"/\" || (offset > 0 && !/[\\s\\u00a0\\u200b]/.test(node.data[offset - 1]!))) return;\n    const mark = document.createRange();\n    mark.setStart(node, offset);\n    mark.setEnd(node, offset + 1);\n    const rect = mark.getBoundingClientRect(),\n      box = root.getBoundingClientRect();\n    const band = clipBand(root);\n    const above = band.bottom - rect.bottom < 330 && rect.top - band.top > band.bottom - rect.bottom;\n    const room = above ? rect.top - band.top - 12 : band.bottom - rect.bottom - 12;\n    setSlash({\n      node,\n      offset,\n      query: \"\",\n      x: Math.max(0, Math.min(rect.left - box.left - 8, box.width - 244)),\n      y: above ? rect.top - box.top - 6 : rect.bottom - box.top + 6,\n      above,\n      max: Math.max(150, room),\n    });\n    setSlashActive(0);\n    setBar(null);\n  }, []);\n\n  const slashResults = useMemo(() => {\n    const query = slash?.query.trim().toLowerCase() ?? \"\";\n    if (!query) return SLASH_ITEMS;\n    return SLASH_ITEMS.filter(\n      (item) =>\n        `${item.label} ${item.keywords}`\n          .toLowerCase()\n          .split(/\\s+/)\n          .some((word) => word.startsWith(query)) || item.label.toLowerCase().includes(query),\n    );\n  }, [slash?.query]);\n  const slashIndex = Math.min(slashActive, Math.max(0, slashResults.length - 1));\n  const slashOpen = !!slash && slashResults.length > 0;\n\n  const applyBlock = useCallback((type: BlockType, target?: HTMLElement | null) => {\n    const editor = editorRef.current;\n    const selection = window.getSelection();\n    if (!editor || !selection?.rangeCount) return;\n    const range = selection.getRangeAt(0);\n    const offsets = readOffsets(editor);\n    const block = target ?? blockAt(editor, range.startContainer);\n    if (!block) return;\n    if (type === \"hr\") {\n      const top = block.nodeName === \"LI\" ? block.closest(\"ul, ol\")! : block;\n      const rule = document.createElement(\"hr\");\n      if (!visibleText(block) && block.nodeName !== \"LI\") replaceNode(block, rule);\n      else insertAfter(top, rule);\n      placeAtStart(paragraphAfter(rule));\n      return;\n    }\n    // Several selected blocks turn together; if every one already has the type, they all turn back into paragraphs.\n    const blocks = range.collapsed\n      ? [block]\n      : Array.from(editor.querySelectorAll<HTMLElement>(\"p, h1, h2, h3, li, blockquote, pre\")).filter(\n          (candidate) => range.intersectsNode(candidate) && !candidate.querySelector(\"p, li\"),\n        );\n    const current = (candidate: HTMLElement) =>\n      candidate.nodeName === \"LI\" ? candidate.parentElement!.nodeName.toLowerCase() : candidate.nodeName.toLowerCase();\n    const allSame = blocks.every((candidate) => current(candidate) === type);\n    blocks.forEach((candidate) => {\n      if (allSame) convertBlock(candidate, type);\n      else if (current(candidate) !== type) convertBlock(candidate, type);\n    });\n    writeOffsets(editor, offsets);\n    if (type === \"pre\") {\n      const pre = blockAt(editor, window.getSelection()?.anchorNode ?? null);\n      if (pre && !pre.nextElementSibling) paragraphAfter(pre);\n    }\n  }, []);\n\n  const chooseSlash = (item: SlashItem | undefined) => {\n    const open = slashRef.current;\n    const editor = editorRef.current;\n    if (!item || !open || !editor) return;\n    const selection = window.getSelection();\n    const caret =\n      selection?.rangeCount && selection.getRangeAt(0).startContainer === open.node\n        ? selection.getRangeAt(0).startOffset\n        : open.offset + 1 + open.query.length;\n    setSlash(null);\n    run(() => {\n      open.node.deleteData(open.offset, Math.max(1, caret - open.offset));\n      const block = blockAt(editor, open.node);\n      placeCaret(open.node, open.offset);\n      if (block) {\n        ensureFilled(block);\n        applyBlock(item.type, block);\n      }\n    });\n    setAnnounce(`${item.label} added`);\n  };\n\n  /* Inline marks and links. */\n  const toggleMark = (mark: \"bold\" | \"italic\" | \"strike\" | \"code\") =>\n    run(() => {\n      const editor = editorRef.current!;\n      const selection = window.getSelection();\n      if (!selection?.rangeCount) return;\n      const range = selection.getRangeAt(0);\n      if (mark !== \"code\") {\n        document.execCommand(\"styleWithCSS\", false, \"false\");\n        document.execCommand(mark === \"strike\" ? \"strikeThrough\" : mark);\n        return;\n      }\n      const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;\n      const code = startElement?.closest(\"code\");\n      if (code && editor.contains(code) && !code.closest(\"pre\")) {\n        const text = document.createTextNode(code.textContent ?? \"\");\n        replaceNode(code, text);\n        const next = document.createRange();\n        next.selectNodeContents(text);\n        selection.removeAllRanges();\n        selection.addRange(next);\n        return;\n      }\n      if (range.collapsed || blockAt(editor, range.startContainer) !== blockAt(editor, range.endContainer)) return;\n      const element = document.createElement(\"code\");\n      element.textContent = range.toString();\n      range.deleteContents();\n      range.insertNode(element);\n      const next = document.createRange();\n      next.selectNodeContents(element);\n      selection.removeAllRanges();\n      selection.addRange(next);\n    });\n\n  const openLink = () => {\n    const editor = editorRef.current;\n    const selection = window.getSelection();\n    if (!editor || !selection?.rangeCount) return;\n    let range = selection.getRangeAt(0);\n    const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;\n    const existing = startElement?.closest(\"a\");\n    if (existing && editor.contains(existing)) {\n      range = document.createRange();\n      range.selectNodeContents(existing);\n      selection.removeAllRanges();\n      selection.addRange(range);\n    }\n    if (range.collapsed) return;\n    savedRange.current = range.cloneRange();\n    setLinkDraft(existing?.getAttribute(\"href\") ?? \"\");\n    setFace(\"link\");\n    faceRef.current = \"link\";\n    sync();\n    requestAnimationFrame(() => linkInputRef.current?.focus({ preventScroll: true }));\n  };\n\n  const closeLink = (focusEditor = true) => {\n    setFace(\"format\");\n    faceRef.current = \"format\";\n    const editor = editorRef.current,\n      range = savedRange.current;\n    savedRange.current = null;\n    if (focusEditor && editor && range) {\n      editor.focus({ preventScroll: true });\n      const selection = window.getSelection();\n      selection?.removeAllRanges();\n      selection?.addRange(range);\n    }\n    sync();\n  };\n\n  const applyLink = (remove = false) => {\n    const range = savedRange.current;\n    const editor = editorRef.current;\n    const url = normalizeUrl(linkDraft);\n    if (!range || !editor) {\n      closeLink();\n      return;\n    }\n    editor.focus({ preventScroll: true });\n    const selection = window.getSelection()!;\n    selection.removeAllRanges();\n    selection.addRange(range);\n    run(() => {\n      const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;\n      const existing = startElement?.closest(\"a\");\n      if (remove || !url) {\n        if (existing && editor.contains(existing)) replaceNode(existing, ...Array.from(existing.childNodes));\n        else document.execCommand(\"unlink\");\n        return;\n      }\n      if (existing && editor.contains(existing) && range.toString() === existing.textContent) {\n        existing.setAttribute(\"href\", url);\n        return;\n      }\n      if (blockAt(editor, range.startContainer) !== blockAt(editor, range.endContainer)) {\n        document.execCommand(\"createLink\", false, url);\n        return;\n      }\n      const contents = range.extractContents();\n      contents.querySelectorAll(\"a\").forEach((anchor) => replaceNode(anchor, ...Array.from(anchor.childNodes)));\n      const anchor = document.createElement(\"a\");\n      anchor.setAttribute(\"href\", url);\n      anchor.appendChild(contents);\n      range.insertNode(anchor);\n      const after = document.createRange();\n      after.setStartAfter(anchor);\n      after.collapse(true);\n      selection.removeAllRanges();\n      selection.addRange(after);\n    });\n    savedRange.current = null;\n    setFace(\"format\");\n    faceRef.current = \"format\";\n    setBar(null);\n    setAnnounce(remove || !url ? \"Link removed\" : \"Link added\");\n  };\n\n  /* Keyboard. Enter, Backspace at a block edge, and history go through beforeinput so mobile keyboards behave the same. */\n  const enterKey = useCallback((soft: boolean) => {\n    const editor = editorRef.current!;\n    const selection = window.getSelection();\n    if (!selection?.rangeCount) return;\n    let range = selection.getRangeAt(0);\n    if (!range.collapsed) {\n      range.deleteContents();\n      range = selection.getRangeAt(0);\n    }\n    const block = blockAt(editor, range.startContainer);\n    if (!block) return;\n\n    if (block.nodeName === \"PRE\") {\n      const full = block.textContent ?? \"\";\n      const caret = textBefore(block, range).length;\n      const head = full.slice(0, caret),\n        tail = full.slice(caret);\n      if (!tail.replace(/\\n$/, \"\") && head.endsWith(\"\\n\") && !soft) {\n        block.textContent = head.slice(0, -1) || \"\";\n        ensureFilled(block);\n        const paragraph = document.createElement(\"p\");\n        paragraph.appendChild(document.createElement(\"br\"));\n        insertAfter(block, paragraph);\n        placeAtStart(paragraph);\n        return;\n      }\n      block.textContent = head + \"\\n\" + (tail || \"\\n\");\n      placeCaret(block.firstChild!, caret + 1);\n      return;\n    }\n\n    if (soft) {\n      const br = document.createElement(\"br\");\n      range.insertNode(br);\n      if (!br.nextSibling || (isText(br.nextSibling) && !br.nextSibling.data && !br.nextSibling.nextSibling))\n        insertAfter(br, document.createElement(\"br\"));\n      const after = document.createRange();\n      after.setStartAfter(br);\n      after.collapse(true);\n      selection.removeAllRanges();\n      selection.addRange(after);\n      return;\n    }\n\n    if (block.nodeName === \"LI\" && !visibleText(block) && !block.querySelector(\"ul, ol\")) {\n      placeAtStart(outdent(block));\n      return;\n    }\n    if (block.nodeName === \"BLOCKQUOTE\" && !visibleText(block)) {\n      placeAtStart(convertBlock(block, \"p\"));\n      return;\n    }\n\n    const tail = document.createRange();\n    tail.setStart(range.startContainer, range.startOffset);\n    tail.setEnd(block, block.childNodes.length);\n    const atEnd = !tail.toString().replace(ZWSP, \"\") && !tail.cloneContents().querySelector(\"ul, ol\");\n    const heading = /^H[1-3]$/.test(block.nodeName);\n    const fragment = tail.extractContents();\n    const next = document.createElement(heading && atEnd ? \"p\" : block.nodeName.toLowerCase());\n    if (!(heading && atEnd)) next.appendChild(fragment);\n    // Formatting open at the caret carries into the new line only when there is text to carry.\n    if (heading && atEnd) next.appendChild(document.createElement(\"br\"));\n    insertAfter(block, next);\n    block.querySelectorAll(\":scope > br:last-child\").forEach((br) => {\n      if (visibleText(block)) br.remove();\n    });\n    ensureFilled(block);\n    ensureFilled(next);\n    if (!visibleText(next))\n      next.querySelectorAll(\"strong, em, s, code, a\").forEach((element) => {\n        if (!visibleText(element)) element.remove();\n      });\n    ensureFilled(next);\n    placeAtStart(next);\n  }, []);\n\n  const backspaceAtStart = useCallback(() => {\n    const editor = editorRef.current!;\n    const selection = window.getSelection();\n    if (!selection?.rangeCount) return false;\n    const range = selection.getRangeAt(0);\n    const block = blockAt(editor, range.startContainer);\n    if (!block || !atBlockStart(block, range)) return false;\n    if (/^(H[1-3]|BLOCKQUOTE|PRE)$/.test(block.nodeName)) {\n      placeAtStart(convertBlock(block, \"p\"));\n      return true;\n    }\n    if (block.nodeName === \"LI\") {\n      placeAtStart(outdent(block));\n      return true;\n    }\n    const previous = block.previousElementSibling;\n    if (previous?.nodeName === \"HR\") {\n      previous.remove();\n      return true;\n    }\n    if (!previous && !visibleText(block) && block.nextElementSibling) {\n      const next = block.nextElementSibling as HTMLElement;\n      block.remove();\n      placeAtStart(next);\n      return true;\n    }\n    return false;\n  }, []);\n\n  useEffect(() => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    const onBeforeInput = (event: InputEvent) => {\n      if (readOnly) return;\n      switch (event.inputType) {\n        case \"historyUndo\":\n          event.preventDefault();\n          undo();\n          return;\n        case \"historyRedo\":\n          event.preventDefault();\n          redo();\n          return;\n        case \"insertParagraph\":\n        case \"insertLineBreak\":\n          event.preventDefault();\n          if (slashRef.current) return;\n          run(() => enterKey(event.inputType === \"insertLineBreak\"));\n          return;\n        case \"deleteContentBackward\": {\n          const selection = window.getSelection();\n          if (!selection?.rangeCount || !selection.getRangeAt(0).collapsed) return;\n          busy.current = true;\n          let handled = false;\n          try {\n            handled = backspaceAtStart();\n          } finally {\n            busy.current = false;\n          }\n          if (handled) {\n            event.preventDefault();\n            tidy(editor);\n            record(\"command\");\n            emit();\n            sync();\n          }\n          return;\n        }\n        case \"insertText\": {\n          // Typing into an emptied editor lands in a paragraph, never loose at the root.\n          const selection = window.getSelection();\n          if (\n            !selection?.rangeCount ||\n            blockAt(editor, selection.anchorNode) ||\n            editor.querySelector(\"p, h1, h2, h3, li, pre, blockquote\")\n          )\n            return;\n          event.preventDefault();\n          const paragraph = document.createElement(\"p\");\n          const text = document.createTextNode(event.data ?? \"\");\n          paragraph.appendChild(text);\n          editor.replaceChildren(paragraph);\n          placeCaret(text, text.length);\n          record(\"type\");\n          emit();\n          sync();\n          return;\n        }\n        case \"insertFromDrop\":\n          event.preventDefault();\n          return;\n        case \"formatUnderline\":\n          event.preventDefault();\n          return;\n      }\n    };\n    editor.addEventListener(\"beforeinput\", onBeforeInput);\n    return () => editor.removeEventListener(\"beforeinput\", onBeforeInput);\n  }, [backspaceAtStart, emit, enterKey, readOnly, record, redo, run, sync, undo]);\n\n  const onInput = (event: FormEvent<HTMLDivElement>) => {\n    if (busy.current) return;\n    const editor = editorRef.current!;\n    const native = event.nativeEvent as InputEvent;\n    if (native.isComposing) return;\n    busy.current = true;\n    let converted = false;\n    try {\n      tidy(editor);\n      if (native.inputType === \"insertText\") converted = shortcut(native.data);\n    } finally {\n      busy.current = false;\n    }\n    if (converted) {\n      tidy(editor);\n      record(\"command\");\n      setAnnounce(\"Formatted\");\n    } else record(\"type\", native.data === \" \" || native.inputType !== lastInput.current);\n    lastInput.current = native.inputType;\n    emit();\n    if (!converted && native.inputType === \"insertText\") openSlashIfTyped(native.data);\n    sync();\n  };\n\n  const focusToolbar = () => rootRef.current?.querySelector<HTMLElement>(`[data-toolbar=\"${uid}\"] button`)?.focus();\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (readOnly) return;\n    const mod = event.metaKey || event.ctrlKey;\n    const key = event.key.toLowerCase();\n    if (slashOpen) {\n      if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n        event.preventDefault();\n        setSlashActive((slashIndex + (event.key === \"ArrowDown\" ? 1 : -1) + slashResults.length) % slashResults.length);\n        return;\n      }\n      if (event.key === \"Enter\" || event.key === \"Tab\") {\n        event.preventDefault();\n        chooseSlash(slashResults[slashIndex]);\n        return;\n      }\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        event.stopPropagation();\n        setSlash(null);\n        return;\n      }\n    }\n    if (mod && key === \"z\") {\n      event.preventDefault();\n      if (event.shiftKey) redo();\n      else undo();\n      return;\n    }\n    if (mod && key === \"y\") {\n      event.preventDefault();\n      redo();\n      return;\n    }\n    if (mod && !event.shiftKey && key === \"b\") {\n      event.preventDefault();\n      toggleMark(\"bold\");\n      return;\n    }\n    if (mod && !event.shiftKey && key === \"i\") {\n      event.preventDefault();\n      toggleMark(\"italic\");\n      return;\n    }\n    if (mod && event.shiftKey && key === \"x\") {\n      event.preventDefault();\n      toggleMark(\"strike\");\n      return;\n    }\n    if (mod && key === \"e\") {\n      event.preventDefault();\n      toggleMark(\"code\");\n      return;\n    }\n    if (mod && key === \"k\") {\n      event.preventDefault();\n      openLink();\n      return;\n    }\n    if (mod && key === \"u\") {\n      event.preventDefault();\n      return;\n    }\n    if (event.altKey && event.key === \"F10\" && bar) {\n      event.preventDefault();\n      focusToolbar();\n      return;\n    }\n    if (event.key === \"Tab\") {\n      const editor = editorRef.current!;\n      const selection = window.getSelection();\n      const block = selection?.rangeCount ? blockAt(editor, selection.anchorNode) : null;\n      if (block?.nodeName === \"LI\") {\n        event.preventDefault();\n        run(() => {\n          const offsets = readOffsets(editor);\n          if (event.shiftKey) outdent(block);\n          else indent(block);\n          writeOffsets(editor, offsets);\n        });\n      } else if (block?.nodeName === \"PRE\" && !event.shiftKey) {\n        event.preventDefault();\n        run(() => {\n          const range = selection!.getRangeAt(0);\n          range.deleteContents();\n          const spaces = document.createTextNode(\"  \");\n          range.insertNode(spaces);\n          placeCaret(spaces, 2);\n          block.normalize();\n        });\n      }\n    }\n  };\n\n  const onPaste = (event: ReactClipboardEvent<HTMLDivElement>) => {\n    if (readOnly) return;\n    event.preventDefault();\n    const editor = editorRef.current!;\n    const html = event.clipboardData.getData(\"text/html\");\n    const text = event.clipboardData.getData(\"text/plain\");\n    run(() => {\n      const selection = window.getSelection();\n      if (!selection?.rangeCount) return;\n      const range = selection.getRangeAt(0);\n      range.deleteContents();\n      const block = blockAt(editor, range.startContainer);\n      const markdownish = /(^|\\n)\\s*(#{1,3}\\s|[-*+]\\s|\\d+[.)]\\s|>|```)|\\*\\*|`[^`]+`|\\[[^\\]]+\\]\\(/.test(text);\n      const source = html ? sanitizeHtml(html) : markdownish || text.includes(\"\\n\\n\") ? markdownToHtml(text) : \"\";\n      const fragment = source ? parseHtml(source) : null;\n      const single = fragment && fragment.children.length === 1 && fragment.firstElementChild!.nodeName === \"P\";\n      // Plain text, code blocks, and list items take the text as it is; everything else keeps its structure.\n      if (!fragment || !block || block.nodeName === \"PRE\" || (block.nodeName === \"LI\" && !single)) {\n        const plain = block?.nodeName === \"PRE\" ? text : text.replace(/\\s*\\n\\s*/g, \" \");\n        const node = document.createTextNode(plain);\n        range.insertNode(node);\n        placeCaret(node, node.length);\n        return;\n      }\n      if (single) {\n        const nodes = Array.from(fragment.firstElementChild!.childNodes);\n        const last = nodes[nodes.length - 1];\n        const holder = document.createDocumentFragment();\n        nodes.forEach((node) => holder.appendChild(node));\n        range.insertNode(holder);\n        if (last) {\n          const after = document.createRange();\n          after.setStartAfter(last);\n          after.collapse(true);\n          selection.removeAllRanges();\n          selection.addRange(after);\n        }\n        return;\n      }\n      const tail = document.createRange();\n      tail.setStart(range.startContainer, range.startOffset);\n      tail.setEnd(block, block.childNodes.length);\n      const rest = document.createElement(block.nodeName === \"LI\" ? \"p\" : block.nodeName.toLowerCase());\n      rest.appendChild(tail.extractContents());\n      const top = block.nodeName === \"LI\" ? block.closest(\"ul, ol\")! : block;\n      const blocks = Array.from(fragment.childNodes);\n      let anchor: Element = top;\n      blocks.forEach((node) => {\n        insertAfter(anchor, node);\n        if (isElement(node)) anchor = node;\n      });\n      if (visibleText(rest) || rest.querySelector(\"br\")) {\n        insertAfter(anchor, rest);\n        ensureFilled(rest);\n      }\n      if (!visibleText(block) && block.nodeName === \"P\") block.remove();\n      const end = document.createRange();\n      end.selectNodeContents(anchor);\n      end.collapse(false);\n      selection.removeAllRanges();\n      selection.addRange(end);\n    });\n  };\n\n  /* Selection events. */\n  useEffect(() => {\n    let frame = 0;\n    const onChange = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(sync);\n    };\n    const onUp = () => {\n      if (!dragging.current) return;\n      dragging.current = false;\n      onChange();\n    };\n    document.addEventListener(\"selectionchange\", onChange);\n    document.addEventListener(\"pointerup\", onUp);\n    return () => {\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"selectionchange\", onChange);\n      document.removeEventListener(\"pointerup\", onUp);\n    };\n  }, [sync]);\n\n  useEffect(() => {\n    if (face !== \"link\") return;\n    const down = (event: PointerEvent) => {\n      if (!rootRef.current?.querySelector(`[data-toolbar=\"${uid}\"]`)?.contains(event.target as Node)) {\n        savedRange.current = null;\n        setFace(\"format\");\n        faceRef.current = \"format\";\n        setBar(null);\n      }\n    };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [face, uid]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      focus: () => editorRef.current?.focus(),\n      getHTML: () => (editorRef.current ? cleanClone(editorRef.current).innerHTML : \"\"),\n      getMarkdown: () => (editorRef.current ? htmlToMarkdown(cleanClone(editorRef.current)) : \"\"),\n      getText: () => (editorRef.current ? plainOutput(cleanClone(editorRef.current)) : \"\"),\n      setHTML: (html) => {\n        load(html);\n        record(\"command\");\n        emit();\n      },\n      setMarkdown: (markdown) => {\n        load(markdownToHtml(markdown));\n        record(\"command\");\n        emit();\n      },\n      clear: () => {\n        load(\"<p><br></p>\");\n        record(\"command\");\n        emit();\n      },\n      undo,\n      redo,\n      get element() {\n        return editorRef.current;\n      },\n    }),\n    [emit, load, record, redo, undo],\n  );\n\n  /* Toolbar geometry: one surface whose size springs between faces and whose position glides with the selection. */\n  const tx = useMotionValue(0),\n    ty = useMotionValue(0),\n    tw = useMotionValue(0),\n    th = useMotionValue(0);\n  const shown = useRef(false);\n  /* Until a face has reported its size the surface has nothing to show, so it stays hidden rather than flashing a 1px dot. */\n  const toolbarVisibility = useTransform(tw, (width) => (width > 0 ? \"visible\" : \"hidden\"));\n  const targetSize = useRef({ width: 0, height: 0 });\n  const onFaceSize = useCallback(\n    (width: number, height: number) => {\n      targetSize.current = { width, height };\n      if (!shown.current || reduced || tw.get() === 0) {\n        tw.jump(width);\n        th.jump(height);\n        return;\n      }\n      animate(tw, width, MORPH);\n      animate(th, height, MORPH);\n    },\n    [reduced, th, tw],\n  );\n  const toolbarOpen = !!bar && !readOnly;\n  // A fresh toolbar is placed, not glided, and stays \"fresh\" until it has painted once, so a selection that settles in the same\n  // frame cannot send it sliding in from its last spot or from the root's corner. Motion resets a remounted element's bound\n  // values to what they held at render (Strict Mode does this on every open in development), so the placement is applied\n  // again once mounting is done. Its size is left alone on close so the exit keeps its shape instead of collapsing.\n  const placedFrame = useRef(0);\n  const placement = useRef<{ x: number; y: number } | null>(null);\n  const place = useCallback(() => {\n    const at = placement.current;\n    if (!at) return;\n    const { width, height } = targetSize.current;\n    if (tx.get() !== at.x) tx.jump(at.x);\n    if (ty.get() !== at.y) ty.jump(at.y);\n    if (width && tw.get() !== width) {\n      tw.jump(width);\n      th.jump(height);\n    }\n  }, [th, tw, tx, ty]);\n  useLayoutEffect(() => {\n    if (!bar) {\n      shown.current = false;\n      placement.current = null;\n      cancelAnimationFrame(placedFrame.current);\n      return;\n    }\n    const root = rootRef.current;\n    const half = (targetSize.current.width || 280) / 2;\n    const x = root ? Math.max(half + 4, Math.min(bar.x, root.offsetWidth - half - 4)) : bar.x;\n    if (!shown.current || reduced) {\n      placement.current = { x, y: bar.y };\n      place();\n      cancelAnimationFrame(placedFrame.current);\n      placedFrame.current = requestAnimationFrame(() => {\n        place();\n        shown.current = true;\n        placement.current = null;\n      });\n      return;\n    }\n    animate(tx, x, GLIDE);\n    animate(ty, bar.y, GLIDE);\n  }, [bar, place, reduced, tx, ty]);\n  useEffect(() => {\n    if (bar) place();\n  }, [bar, place]);\n  useEffect(() => () => cancelAnimationFrame(placedFrame.current), []);\n  const direction = face === \"link\" ? 1 : -1;\n\n  /* Slash menu geometry: the highlight glides between rows and the panel springs to its rows. */\n  const listRef = useRef<HTMLUListElement>(null);\n  const hy = useMotionValue(0),\n    hh = useMotionValue(0),\n    panelHeight = useMotionValue<number | \"auto\">(\"auto\");\n  const panelBody = useRef<HTMLDivElement>(null);\n  const slashPanel = useRef<HTMLDivElement>(null);\n  const slashMax = slash?.max ?? 320;\n  const panelSized = useRef(false);\n  const highlightPlaced = useRef(false);\n  useLayoutEffect(() => {\n    const row = listRef.current?.querySelector<HTMLElement>(`[data-index=\"${slashIndex}\"]`);\n    if (!row) return;\n    if (reduced || !highlightPlaced.current || hh.get() === 0) {\n      hy.jump(row.offsetTop);\n      hh.jump(row.offsetHeight);\n      highlightPlaced.current = true;\n      return;\n    }\n    animate(hy, row.offsetTop, GLIDE);\n    animate(hh, row.offsetHeight, GLIDE);\n    // A short panel scrolls to keep the active row in view.\n    const panel = slashPanel.current;\n    if (panel) {\n      const top = row.offsetTop + 6,\n        bottom = top + row.offsetHeight;\n      if (top - 6 < panel.scrollTop) panel.scrollTop = top - 6;\n      else if (bottom + 6 > panel.scrollTop + panel.clientHeight) panel.scrollTop = bottom + 6 - panel.clientHeight;\n    }\n  }, [hh, hy, reduced, slashIndex, slashResults, slashOpen]);\n  useLayoutEffect(() => {\n    // The highlight keeps its row while the menu fades out; the next open places it fresh.\n    if (!slashOpen) {\n      panelSized.current = false;\n      highlightPlaced.current = false;\n      return;\n    }\n    const body = panelBody.current;\n    if (!body) return;\n    const fit = () => {\n      const target = Math.min(body.offsetHeight, slashMax);\n      if (!panelSized.current || reduced) {\n        panelHeight.jump(target);\n        panelSized.current = true;\n        return;\n      }\n      animate(panelHeight, target, GROW);\n    };\n    fit();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(fit);\n    observer.observe(body);\n    return () => observer.disconnect();\n  }, [hh, panelHeight, reduced, slashMax, slashOpen]);\n\n  const onToolbarKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (face !== \"format\") return;\n    const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>(\"button\"));\n    const at = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      buttons[(at + (event.key === \"ArrowRight\" ? 1 : -1) + buttons.length) % buttons.length]?.focus();\n    } else if (event.key === \"Escape\") {\n      event.preventDefault();\n      editorRef.current?.focus();\n      sync();\n    }\n  };\n\n  const optionId = (index: number) => `${uid}-slash-${index}`;\n  const cssVars = {\n    \"--placeholder\": JSON.stringify(placeholder),\n    \"--hint\": JSON.stringify(blockHint),\n  } as CSSProperties;\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"@container relative w-full font-body tracking-body text-ink\", className)}\n      style={style}\n    >\n      <div\n        ref={editorRef}\n        className={cn(\n          EDITOR,\n          readOnly && \"caret-transparent\",\n          HINT,\n          empty\n            ? cn(PLACEHOLDER, \"focus:[&_[data-hint]]:before:content-(--placeholder)\")\n            : \"focus:[&_[data-hint]]:before:content-(--hint)\",\n        )}\n        tabIndex={readOnly ? undefined : 0}\n        style={cssVars}\n        contentEditable={!readOnly}\n        suppressContentEditableWarning\n        role=\"textbox\"\n        aria-multiline=\"true\"\n        aria-label={ariaLabel}\n        aria-readonly={readOnly || undefined}\n        aria-controls={slashOpen ? `${uid}-slash` : undefined}\n        aria-activedescendant={slashOpen ? optionId(slashIndex) : undefined}\n        data-empty={empty || undefined}\n        spellCheck\n        onInput={onInput}\n        onKeyDown={onKeyDown}\n        onPaste={onPaste}\n        onDrop={(event) => event.preventDefault()}\n        onPointerDown={() => {\n          dragging.current = true;\n        }}\n        onCompositionEnd={() => {\n          record(\"type\", true);\n          emit();\n        }}\n      />\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announce}\n      </span>\n\n      <AnimatePresence>\n        {toolbarOpen && (\n          <motion.div\n            key=\"toolbar\"\n            data-toolbar={uid}\n            role=\"toolbar\"\n            aria-label=\"Formatting\"\n            className={cn(\n              \"absolute z-40 -translate-x-1/2 overflow-hidden rounded-[16px] border border-line bg-surface shadow-floating\",\n              bar.below ? \"translate-y-0\" : \"-translate-y-full\",\n            )}\n            data-below={bar.below || undefined}\n            style={{\n              left: tx,\n              top: ty,\n              width: tw,\n              height: th,\n              visibility: toolbarVisibility,\n              transformOrigin: bar.below ? \"50% 0\" : \"50% 100%\",\n            }}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: bar.below ? -4 : 4 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={\n              reduced\n                ? { opacity: 0 }\n                : { opacity: 0, scale: 0.96, transition: { duration: duration.exit * 0.8, ease: standard } }\n            }\n            transition={\n              reduced\n                ? { duration: duration.fast }\n                : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } }\n            }\n            onKeyDown={onToolbarKey}\n          >\n            <AnimatePresence initial={false} custom={direction}>\n              {face === \"format\" ? (\n                <Face key=\"format\" direction={direction} reduced={reduced} onSize={onFaceSize}>\n                  <div className=\"flex items-center gap-0.5 p-1\">\n                    <ToolButton label=\"Bold\" pressed={formats.bold} shortcut=\"⌘B\" onPress={() => toggleMark(\"bold\")}>\n                      <Bold {...icon} />\n                    </ToolButton>\n                    <ToolButton\n                      label=\"Italic\"\n                      pressed={formats.italic}\n                      shortcut=\"⌘I\"\n                      onPress={() => toggleMark(\"italic\")}\n                    >\n                      <Italic {...icon} />\n                    </ToolButton>\n                    <ToolButton\n                      label=\"Strikethrough\"\n                      pressed={formats.strike}\n                      shortcut=\"⌘⇧X\"\n                      onPress={() => toggleMark(\"strike\")}\n                    >\n                      <Strikethrough {...icon} />\n                    </ToolButton>\n                    <ToolButton\n                      label=\"Inline code\"\n                      pressed={formats.code}\n                      shortcut=\"⌘E\"\n                      onPress={() => toggleMark(\"code\")}\n                    >\n                      <Code {...icon} />\n                    </ToolButton>\n                    <ToolButton\n                      label={formats.link ? \"Edit link\" : \"Link\"}\n                      pressed={!!formats.link}\n                      shortcut=\"⌘K\"\n                      onPress={openLink}\n                    >\n                      <Link {...icon} />\n                    </ToolButton>\n                    {/* Narrow editors keep the inline marks; block types stay one slash away. */}\n                    <span className=\"flex items-center gap-0.5 @max-[330px]:hidden\">\n                      <span className=\"mx-1 h-[18px] w-px bg-line\" aria-hidden=\"true\" />\n                      <ToolButton\n                        label=\"Heading 1\"\n                        pressed={formats.block === \"h1\"}\n                        onPress={() => run(() => applyBlock(\"h1\"))}\n                      >\n                        <Heading1 {...icon} />\n                      </ToolButton>\n                      <ToolButton\n                        label=\"Heading 2\"\n                        pressed={formats.block === \"h2\"}\n                        onPress={() => run(() => applyBlock(\"h2\"))}\n                      >\n                        <Heading2 {...icon} />\n                      </ToolButton>\n                      <ToolButton\n                        label=\"Quote\"\n                        pressed={formats.block === \"blockquote\"}\n                        onPress={() => run(() => applyBlock(\"blockquote\"))}\n                      >\n                        <Quote {...icon} />\n                      </ToolButton>\n                    </span>\n                  </div>\n                </Face>\n              ) : (\n                <Face key=\"link\" direction={direction} reduced={reduced} onSize={onFaceSize}>\n                  <form\n                    className=\"flex items-center gap-0.5 p-1\"\n                    onSubmit={(event) => {\n                      event.preventDefault();\n                      applyLink();\n                    }}\n                  >\n                    <button type=\"button\" className={TOOL} aria-label=\"Back to formatting\" onClick={() => closeLink()}>\n                      <ArrowLeft {...icon} />\n                    </button>\n                    <input\n                      ref={linkInputRef}\n                      className=\"h-8 w-[min(15rem,60vw)] min-w-0 border-0 bg-transparent px-2 text-sm/[inherit] text-ink outline-none placeholder:text-ink-3\"\n                      value={linkDraft}\n                      onChange={(event) => setLinkDraft(event.target.value)}\n                      onKeyDown={(event) => {\n                        if (event.key === \"Escape\") {\n                          event.preventDefault();\n                          event.stopPropagation();\n                          closeLink();\n                        }\n                      }}\n                      placeholder=\"Paste or type a link\"\n                      aria-label=\"Link address\"\n                      inputMode=\"url\"\n                      autoComplete=\"off\"\n                      spellCheck={false}\n                    />\n                    {formats.link && (\n                      <button type=\"button\" className={TOOL} aria-label=\"Remove link\" onClick={() => applyLink(true)}>\n                        <Unlink {...icon} />\n                      </button>\n                    )}\n                    <button\n                      type=\"submit\"\n                      className={cn(\n                        \"grid size-8 flex-none cursor-pointer place-items-center rounded-button border-0 p-0 [transition:background-color_var(--duration-standard)_var(--ease-standard),color_var(--duration-standard)_var(--ease-standard),scale_var(--duration-instant)_var(--ease-standard)] active:scale-92\",\n                        linkDraft.trim() ? \"bg-accent text-on-accent\" : \"bg-hover text-ink-3\",\n                      )}\n                      aria-label=\"Apply link\"\n                      data-ready={!!linkDraft.trim() || undefined}\n                    >\n                      <Check size={16} strokeWidth={2} aria-hidden=\"true\" />\n                    </button>\n                  </form>\n                </Face>\n              )}\n            </AnimatePresence>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <AnimatePresence>\n        {slashOpen && slash && (\n          <motion.div\n            key={`${slash.offset}-${slash.y}`}\n            ref={slashPanel}\n            className={cn(\n              \"absolute z-40 w-60 max-w-[calc(100%-8px)] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-line bg-surface shadow-floating [scrollbar-width:none]\",\n              slash.above && \"-translate-y-full\",\n            )}\n            data-above={slash.above || undefined}\n            style={{\n              left: slash.x,\n              top: slash.y,\n              height: panelHeight,\n              maxHeight: slashMax,\n              transformOrigin: slash.above ? \"16px 100%\" : \"16px 0\",\n            }}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: slash.above ? 4 : -4 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={\n              reduced\n                ? { opacity: 0 }\n                : { opacity: 0, scale: 0.97, transition: { duration: duration.exit * 0.8, ease: standard } }\n            }\n            transition={\n              reduced\n                ? { duration: duration.fast }\n                : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } }\n            }\n            onMouseDown={(event) => event.preventDefault()}\n          >\n            <div ref={panelBody} className=\"p-1.5\">\n              <ul\n                ref={listRef}\n                id={`${uid}-slash`}\n                // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- the ARIA combobox pattern: the editor owns focus and points into this listbox\n                role=\"listbox\"\n                aria-label=\"Blocks\"\n                className=\"relative m-0 grid list-none p-0\"\n              >\n                <motion.li\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute top-0 right-0 left-0 rounded-[12px] bg-[color-mix(in_oklab,var(--ink)_6.5%,transparent)]\"\n                  style={{ y: hy, height: hh }}\n                />\n                {slashResults.map((item, index) => (\n                  // oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- options are reached through aria-activedescendant and the editor handles the keys; <option> only works inside <select>\n                  <li\n                    key={item.type}\n                    id={optionId(index)}\n                    // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- options are reached through aria-activedescendant and the editor handles the keys; <option> only works inside <select>\n                    role=\"option\"\n                    aria-selected={index === slashIndex}\n                    data-index={index}\n                    className=\"relative flex h-[34px] cursor-pointer items-center gap-2.5 rounded-[12px] px-2.5 text-sm/[inherit] text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[color] duration-fast ease-standard aria-selected:text-ink\"\n                    onPointerMove={() => {\n                      if (index !== slashIndex) setSlashActive(index);\n                    }}\n                    onClick={() => chooseSlash(item)}\n                  >\n                    <span className=\"grid w-5 flex-none place-items-center\">{item.icon}</span>\n                    <span className=\"min-w-0 flex-1 truncate font-medium\">{item.label}</span>\n                    {item.hint && (\n                      <kbd className=\"flex-none text-xs/[inherit] text-ink-3 [font-family:ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace]\">\n                        {item.hint}\n                      </kbd>\n                    )}\n                  </li>\n                ))}\n              </ul>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n});\n\nexport default RichTextEditor;\n",
      "type": "registry:component",
      "target": "@components/iris/rich-text-editor/rich-text-editor.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/rich-text-editor",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "RichTextEditor",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/rich-text-editor"
  }
}
