{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/rich-text-editor/rich-text-editor.tsx",
      "type": "registry:component",
      "target": "@components/xzero/rich-text-editor/rich-text-editor.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ClipboardEvent as ReactClipboardEvent, CSSProperties, FormEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { ArrowLeft, Bold, Check, Code, Heading1, Heading2, Heading3, Italic, Link, List, ListOrdered, Minus, Pilcrow, Quote, SquareCode, Strikethrough, Unlink } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./rich-text-editor.module.css\";\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) => 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) => safeUrl(url) ? `<a href=\"${url}\">${label}</a>` : label);\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  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) { html += `<${item.tag}><li>`; stack.push({ indent: item.indent, tag: item.tag }); }\n    else if (top.tag !== item.tag) { html += `</li></${top.tag}><${item.tag}><li>`; 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 = () => { if (paragraph.length) { out.push(`<p>${inlineMarkdown(paragraph.join(\" \"))}</p>`); paragraph.length = 0; } };\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()) { flush(); index++; continue; }\n    const heading = /^(#{1,6})\\s+(.*)$/.exec(line);\n    if (heading) { flush(); const level = Math.min(3, heading[1]!.length); out.push(`<h${level}>${inlineMarkdown(heading[2]!)}</h${level}>`); index++; continue; }\n    if (/^ {0,3}([-*_])( *\\1){2,} *$/.test(line)) { flush(); out.push(\"<hr>\"); index++; continue; }\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) { out += (child as Text).data.replace(ZWSP, \"\"); return; }\n    if (child.nodeType !== Node.ELEMENT_NODE) return;\n    const element = child as HTMLElement;\n    switch (element.nodeName) {\n      case \"BR\": out += \"\\n\"; break;\n      case \"STRONG\": case \"B\": out += wrapMark(inlineToMarkdown(element), \"**\"); break;\n      case \"EM\": case \"I\": out += wrapMark(inlineToMarkdown(element), \"_\"); break;\n      case \"S\": case \"STRIKE\": case \"DEL\": out += wrapMark(inlineToMarkdown(element), \"~~\"); break;\n      case \"CODE\": out += `\\`${(element.textContent ?? \"\").replace(ZWSP, \"\")}\\``; break;\n      case \"A\": out += `[${inlineToMarkdown(element)}](${element.getAttribute(\"href\") ?? \"\"})`; break;\n      case \"UL\": case \"OL\": break;\n      default: 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).filter(item => item.nodeName === \"LI\").map(item => {\n    const marker = list.nodeName === \"OL\" ? `${number++}. ` : \"- \";\n    const nested = Array.from(item.children).filter(child => child.nodeName === \"UL\" || child.nodeName === \"OL\").map(child => listToMarkdown(child, indent + \" \".repeat(marker.length)));\n    return [`${indent}${marker}${inlineToMarkdown(item).trim()}`, ...nested].join(\"\\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\": return `# ${inlineToMarkdown(element).trim()}`;\n    case \"H2\": return `## ${inlineToMarkdown(element).trim()}`;\n    case \"H3\": return `### ${inlineToMarkdown(element).trim()}`;\n    case \"HR\": return \"---\";\n    case \"PRE\": return `\\`\\`\\`\\n${(element.textContent ?? \"\").replace(ZWSP, \"\").replace(/\\n$/, \"\")}\\n\\`\\`\\``;\n    case \"UL\": case \"OL\": return listToMarkdown(element, \"\");\n    case \"BLOCKQUOTE\": {\n      const blocks = Array.from(element.children).some(child => BLOCK_TAGS.has(child.nodeName));\n      const body = blocks ? Array.from(element.childNodes).map(blockToMarkdown).filter(Boolean).join(\"\\n\\n\") : inlineToMarkdown(element).trim();\n      return body.split(\"\\n\").map(line => line ? `> ${line}` : \">\").join(\"\\n\");\n    }\n    default: 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[]) => { let at = reference; nodes.forEach(node => { at.parentNode!.insertBefore(node, at.nextSibling); at = node; }); };\nconst insertBefore = (reference: Node, ...nodes: Node[]) => nodes.forEach(node => reference.parentNode!.insertBefore(node, reference));\nconst replaceNode = (old: Node, ...nodes: Node[]) => { insertBefore(old, ...nodes); old.parentNode!.removeChild(old); };\nconst appendNodes = (parent: Node, ...nodes: Node[]) => nodes.forEach(node => parent.appendChild(node));\n\nconst INLINE_MAP: Record<string, string> = { B: \"strong\", STRONG: \"strong\", I: \"em\", EM: \"em\", S: \"s\", STRIKE: \"s\", DEL: \"s\", CODE: \"code\", A: \"a\", BR: \"br\" };\nconst BLOCK_MAP: Record<string, string> = { P: \"p\", H1: \"h1\", H2: \"h2\", H3: \"h3\", H4: \"h3\", H5: \"h3\", H6: \"h3\", UL: \"ul\", OL: \"ol\", LI: \"li\", BLOCKQUOTE: \"blockquote\", PRE: \"pre\", HR: \"hr\" };\nconst DROP = new Set([\"SCRIPT\", \"STYLE\", \"META\", \"LINK\", \"TITLE\", \"TEMPLATE\", \"IFRAME\", \"OBJECT\", \"EMBED\", \"SVG\", \"CANVAS\", \"IMG\", \"VIDEO\", \"AUDIO\", \"INPUT\", \"BUTTON\", \"SELECT\", \"TEXTAREA\", \"HEAD\"]);\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) { into.appendChild(document.createTextNode((child as Text).data)); return; }\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\") tag = /font-weight:\\s*(bold|[6-9]00)/.test(style) ? \"strong\" : /font-style:\\s*italic/.test(style) ? \"em\" : /line-through/.test(style) ? \"s\" : \"\";\n      if (!tag) { convert(element, into); return; }\n      const next = document.createElement(tag);\n      if (tag === \"a\") {\n        const href = element.getAttribute(\"href\") ?? \"\";\n        if (!safeUrl(href)) { convert(element, into); return; }\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) { run = null; fixed.appendChild(node); return; }\n    if (node.nodeType === Node.TEXT_NODE && !(node as Text).data.trim() && !run) return;\n    if (!run) { run = document.createElement(\"p\"); fixed.appendChild(run); }\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 => Array.from(parent.childNodes).forEach(node => {\n    if (isText(node) && !node.data.trim() && (parent === fixed || parent.nodeName === \"UL\" || parent.nodeName === \"OL\" || (node.previousSibling && BLOCK_TAGS.has(node.previousSibling.nodeName)) || (node.nextSibling && BLOCK_TAGS.has(node.nextSibling.nodeName)))) node.remove();\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, found = -1;\n  const visit = (node: Node) => {\n    if (found >= 0) return;\n    if (isText(node)) { if (node === container) found = count + offset; else count += node.data.length; return; }\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) { found = count; return; }\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) { result = [node, Math.max(0, target - count)]; return; }\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) { result = [node.parentNode, Array.prototype.indexOf.call(node.parentNode.childNodes, node)]; return; }\n      count += 1;\n      return;\n    }\n    node.childNodes.forEach(visit);\n    if (!result && count === target && node !== root && COUNTED.has(node.nodeName)) 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\" ? [last, last.childNodes.length] : [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 { start: pointToOffset(root, range.startContainer, range.startOffset), end: pointToOffset(root, range.endContainer, range.endOffset) };\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) { range.selectNodeContents(root.lastElementChild ?? root); range.collapse(false); }\n  else {\n    const [startNode, startOffset] = offsetToPoint(root, offsets.start);\n    const [endNode, endOffset] = 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, 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); 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\")) 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.toString().replace(ZWSP, \"\").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) => 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.replace(/\\n$/, \"\").split(\"\\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) { moveChildren(next, list); next.remove(); }\n  const previous = list.previousElementSibling;\n  if (previous && previous.nodeName === list.nodeName) { moveChildren(list, previous); list.remove(); return previous as HTMLElement; }\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 => { if (child.nodeName === \"UL\" || child.nodeName === \"OL\") nested.push(child); else paragraph.appendChild(child); });\n  ensureFilled(paragraph);\n  const tail = following.length || nested.length ? document.createElement(list.nodeName.toLowerCase()) : null;\n  if (tail) {\n    nested.forEach(sub => sub.childNodes.length && Array.from(sub.childNodes).forEach(node => tail.appendChild(node)));\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\")) { sub = document.createElement(listTag.toLowerCase()); previous.appendChild(sub); }\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 ?? \"\")); 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(node => (isText(node) && node.data.trim()) || (isElement(node) && !BLOCK_TAGS.has(node.nodeName)));\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)) replaceNode(element, Object.assign(document.createElement(\"strong\"), { innerHTML: element.innerHTML }));\n    else element.removeAttribute(\"style\");\n  });\n  const rename = (selector: string, tag: string) => 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.querySelectorAll(\"span, font, div\").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)) { run = null; return; }\n    if (isText(node) && !node.data.trim() && !run) { node.remove(); return; }\n    if (!run) { run = document.createElement(\"p\"); insertBefore(node, run); }\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 => { if (!(element.nodeName === \"A\" && attribute.name === \"href\")) element.removeAttribute(attribute.name); });\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 => { pre.textContent = (pre.textContent ?? \"\").replace(/\\n$/, \"\"); });\n  while (clone.lastElementChild && clone.lastElementChild.nodeName !== \"HR\" && !visibleText(clone.lastElementChild) && clone.children.length > 1) 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\")).filter(block => !block.querySelector(\"p, li\")).map(block => (block.textContent ?? \"\").trim()).filter(Boolean).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  { type: \"ol\", label: \"Numbered list\", hint: \"1.\", keywords: \"ordered ol steps numbers\", icon: <ListOrdered {...icon} /> },\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, .12), GLIDE = physical(.3, 0), 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: { opacity: 1, x: 0, filter: \"blur(0px)\", transition: { x: MORPH, opacity: { duration: .2, ease: enter, delay: .03 }, filter: { duration: .2, ease: enter, delay: .03 } } },\n  gone: (direction: number) => ({ opacity: 0, x: direction * -TRAVEL, filter: `blur(${blur.subtle}px)`, transition: { x: MORPH, opacity: { duration: .12, ease: standard }, filter: { duration: .12, ease: standard } } }),\n};\nconst fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: .14 } }, gone: { opacity: 0, transition: { duration: .1 } } };\n\n/** A toolbar face reports its natural size while current, so the shared surface can spring to it. */\nfunction Face({ direction, reduced, onSize, children }: { direction: number; reduced: boolean; onSize: (width: number, height: number) => void; children: ReactNode }) {\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 <motion.div ref={ref} className={styles.face} custom={direction} variants={reduced ? fadeVariants : faceVariants} initial=\"hidden\" animate=\"shown\" exit=\"gone\" inert={!present || undefined}>{children}</motion.div>;\n}\n\nfunction ToolButton({ label, pressed, shortcut, onPress, children }: { label: string; pressed: boolean; shortcut?: string; onPress: () => void; children: ReactNode }) {\n  return <button type=\"button\" className={styles.tool} aria-label={label} aria-pressed={pressed} title={shortcut ? `${label} (${shortcut})` : label} onMouseDown={event => event.preventDefault()} onClick={onPress}>{children}</button>;\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  value, defaultValue, defaultMarkdown, onChange, onHistoryChange, placeholder = \"Start writing\", blockHint = \"Type / for blocks\",\n  readOnly = false, autoFocus = false, \"aria-label\": ariaLabel = \"Editor\", className, style,\n}, ref) {\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(() => { callbacks.current = { onChange, onHistoryChange }; }, [onChange, onHistoryChange]);\n\n  const [empty, setEmpty] = useState(true);\n  const [formats, setFormats] = useState<Formats>({ bold: false, italic: false, strike: false, code: false, link: null, block: \"p\" });\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(() => { faceRef.current = face; }, [face]);\n  useEffect(() => { slashRef.current = slash; }, [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((kind: Snapshot[\"kind\"], boundary = false) => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    const snap: Snapshot = { html: editor.innerHTML.replace(/ data-hint=\"\"/g, \"\"), selection: readOffsets(editor), kind, at: Date.now() };\n    const state = history.current;\n    const top = state.stack[state.index];\n    if (top && top.html === snap.html) { top.selection = snap.selection; return; }\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  }, [reportHistory]);\n\n  const restore = useCallback((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  }, [emit, reportHistory]);\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, 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) => { const found = start?.closest(selector); return !!found && editor.contains(found); };\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\"), italic: within(\"em, i\"), 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 => (Object.keys(next) as (keyof Formats)[]).every(key => current[key] === next[key]) ? current : next);\n\n    const open = slashRef.current;\n    if (open) {\n      const valid = range.collapsed && range.startContainer === open.node && range.startOffset > open.offset && 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({ x: whole.left + whole.width / 2 - box.left, y: below ? whole.bottom - box.top + 8 : rect.top - box.top - 8, below });\n  }, [readOnly]);\n\n  const run = useCallback((change: () => void) => {\n    const editor = editorRef.current;\n    if (!editor) return;\n    busy.current = true;\n    try { change(); tidy(editor); } finally { busy.current = false; }\n    record(\"command\");\n    emit();\n    sync();\n  }, [emit, record, sync]);\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.querySelectorAll(\"p, h1, h2, h3, li, blockquote, pre\").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(start.value ?? start.defaultValue ?? (start.defaultMarkdown !== undefined ? markdownToHtml(start.defaultMarkdown) : \"<p><br></p>\"));\n    history.current = { stack: [{ html: editor.innerHTML, selection: null, kind: \"command\", at: 0 }], index: 0 };\n    emit();\n    reportHistory();\n    if (start.autoFocus) { editor.focus(); writeOffsets(editor, null); }\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\">][] = [[/^# $/, \"h1\"], [/^## $/, \"h2\"], [/^### $/, \"h3\"], [/^[-*+] $/, \"ul\"], [/^\\d+[.)] $/, \"ol\"], [/^> $/, \"blockquote\"]];\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 (data === \"-\" && plainBlock && block.parentElement === editor && before === \"---\" && visibleText(block) === \"---\") {\n      const rule = document.createElement(\"hr\");\n      replaceNode(block, rule);\n      placeAtStart(paragraphAfter(rule));\n      return true;\n    }\n    if (data === \"`\" && plainBlock && block.parentElement === editor && before === \"```\" && visibleText(block) === \"```\") {\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, 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(), 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({ node, offset, query: \"\", x: Math.max(0, Math.min(rect.left - box.left - 8, box.width - 244)), y: above ? rect.top - box.top - 6 : rect.bottom - box.top + 6, above, max: Math.max(150, room) });\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(item => `${item.label} ${item.keywords}`.toLowerCase().split(/\\s+/).some(word => word.startsWith(query)) || item.label.toLowerCase().includes(query));\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); 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 ? [block] : Array.from(editor.querySelectorAll<HTMLElement>(\"p, h1, h2, h3, li, blockquote, pre\")).filter(candidate => range.intersectsNode(candidate) && !candidate.querySelector(\"p, li\"));\n    const current = (candidate: HTMLElement) => 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 = selection?.rangeCount && selection.getRangeAt(0).startContainer === open.node ? selection.getRangeAt(0).startOffset : 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) { ensureFilled(block); applyBlock(item.type, block); }\n    });\n    setAnnounce(`${item.label} added`);\n  };\n\n  /* Inline marks and links. */\n  const toggleMark = (mark: \"bold\" | \"italic\" | \"strike\" | \"code\") => 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\") { document.execCommand(\"styleWithCSS\", false, \"false\"); document.execCommand(mark === \"strike\" ? \"strikeThrough\" : mark); return; }\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, 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) { closeLink(); return; }\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) { existing.setAttribute(\"href\", url); return; }\n      if (blockAt(editor, range.startContainer) !== blockAt(editor, range.endContainer)) { document.execCommand(\"createLink\", false, url); return; }\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) { range.deleteContents(); range = selection.getRangeAt(0); }\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), 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)) 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\")) { placeAtStart(outdent(block)); return; }\n    if (block.nodeName === \"BLOCKQUOTE\" && !visibleText(block)) { placeAtStart(convertBlock(block, \"p\")); return; }\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 => { if (visibleText(block)) br.remove(); });\n    ensureFilled(block);\n    ensureFilled(next);\n    if (!visibleText(next)) next.querySelectorAll(\"strong, em, s, code, a\").forEach(element => { if (!visibleText(element)) element.remove(); });\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)) { placeAtStart(convertBlock(block, \"p\")); return true; }\n    if (block.nodeName === \"LI\") { placeAtStart(outdent(block)); return true; }\n    const previous = block.previousElementSibling;\n    if (previous?.nodeName === \"HR\") { previous.remove(); return true; }\n    if (!previous && !visibleText(block) && block.nextElementSibling) { const next = block.nextElementSibling as HTMLElement; block.remove(); placeAtStart(next); return true; }\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\": event.preventDefault(); undo(); return;\n        case \"historyRedo\": event.preventDefault(); redo(); 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 { handled = backspaceAtStart(); } finally { busy.current = false; }\n          if (handled) { event.preventDefault(); tidy(editor); record(\"command\"); emit(); sync(); }\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 (!selection?.rangeCount || blockAt(editor, selection.anchorNode) || editor.querySelector(\"p, h1, h2, h3, li, pre, blockquote\")) 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\": event.preventDefault(); return;\n        case \"formatUnderline\": event.preventDefault(); 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 { busy.current = false; }\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\") { event.preventDefault(); chooseSlash(slashResults[slashIndex]); return; }\n      if (event.key === \"Escape\") { event.preventDefault(); event.stopPropagation(); setSlash(null); return; }\n    }\n    if (mod && key === \"z\") { event.preventDefault(); if (event.shiftKey) redo(); else undo(); return; }\n    if (mod && key === \"y\") { event.preventDefault(); redo(); return; }\n    if (mod && !event.shiftKey && key === \"b\") { event.preventDefault(); toggleMark(\"bold\"); return; }\n    if (mod && !event.shiftKey && key === \"i\") { event.preventDefault(); toggleMark(\"italic\"); return; }\n    if (mod && event.shiftKey && key === \"x\") { event.preventDefault(); toggleMark(\"strike\"); return; }\n    if (mod && key === \"e\") { event.preventDefault(); toggleMark(\"code\"); return; }\n    if (mod && key === \"k\") { event.preventDefault(); openLink(); return; }\n    if (mod && key === \"u\") { event.preventDefault(); return; }\n    if (event.altKey && event.key === \"F10\" && bar) { event.preventDefault(); focusToolbar(); return; }\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); 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) { const after = document.createRange(); after.setStartAfter(last); after.collapse(true); selection.removeAllRanges(); selection.addRange(after); }\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 => { insertAfter(anchor, node); if (isElement(node)) anchor = node; });\n      if (visibleText(rest) || rest.querySelector(\"br\")) { insertAfter(anchor, rest); ensureFilled(rest); }\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 = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(sync); };\n    const onUp = () => { if (!dragging.current) return; dragging.current = false; onChange(); };\n    document.addEventListener(\"selectionchange\", onChange);\n    document.addEventListener(\"pointerup\", onUp);\n    return () => { cancelAnimationFrame(frame); document.removeEventListener(\"selectionchange\", onChange); document.removeEventListener(\"pointerup\", onUp); };\n  }, [sync]);\n\n  useEffect(() => {\n    if (face !== \"link\") return;\n    const down = (event: PointerEvent) => { if (!rootRef.current?.querySelector(`[data-toolbar=\"${uid}\"]`)?.contains(event.target as Node)) { savedRange.current = null; setFace(\"format\"); faceRef.current = \"format\"; setBar(null); } };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [face, uid]);\n\n  useImperativeHandle(ref, () => ({\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 => { load(html); record(\"command\"); emit(); },\n    setMarkdown: markdown => { load(markdownToHtml(markdown)); record(\"command\"); emit(); },\n    clear: () => { load(\"<p><br></p>\"); record(\"command\"); emit(); },\n    undo,\n    redo,\n    get element() { return editorRef.current; },\n  }), [emit, load, record, redo, undo]);\n\n  /* Toolbar geometry: one surface whose size springs between faces and whose position glides with the selection. */\n  const tx = useMotionValue(0), ty = useMotionValue(0), tw = useMotionValue(0), 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((width: number, height: number) => {\n    targetSize.current = { width, height };\n    if (!shown.current || reduced || tw.get() === 0) { tw.jump(width); th.jump(height); return; }\n    animate(tw, width, MORPH);\n    animate(th, height, MORPH);\n  }, [reduced, th, tw]);\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) { tw.jump(width); th.jump(height); }\n  }, [th, tw, tx, ty]);\n  useLayoutEffect(() => {\n    if (!bar) { shown.current = false; placement.current = null; cancelAnimationFrame(placedFrame.current); return; }\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(() => { place(); shown.current = true; placement.current = null; });\n      return;\n    }\n    animate(tx, x, GLIDE);\n    animate(ty, bar.y, GLIDE);\n  }, [bar, place, reduced, tx, ty]);\n  useEffect(() => { if (bar) place(); }, [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), hh = useMotionValue(0), 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) { hy.jump(row.offsetTop); hh.jump(row.offsetHeight); highlightPlaced.current = true; return; }\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, 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) { panelSized.current = false; highlightPlaced.current = false; return; }\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) { panelHeight.jump(target); panelSized.current = true; return; }\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 = { \"--placeholder\": JSON.stringify(placeholder), \"--hint\": JSON.stringify(blockHint) } as CSSProperties;\n\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} style={style}>\n    <div\n      ref={editorRef}\n      className={styles.editor}\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={() => { dragging.current = true; }}\n      onCompositionEnd={() => { record(\"type\", true); emit(); }}\n    />\n    <span className={styles.srOnly} aria-live=\"polite\">{announce}</span>\n\n    <AnimatePresence>\n      {toolbarOpen && <motion.div\n        key=\"toolbar\"\n        data-toolbar={uid}\n        role=\"toolbar\"\n        aria-label=\"Formatting\"\n        className={styles.toolbar}\n        data-below={bar.below || undefined}\n        style={{ left: tx, top: ty, width: tw, height: th, visibility: toolbarVisibility, transformOrigin: bar.below ? \"50% 0\" : \"50% 100%\" }}\n        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .94, y: bar.below ? -4 : 4 }}\n        animate={{ opacity: 1, scale: 1, y: 0 }}\n        exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .96, transition: { duration: duration.exit * .8, ease: standard } }}\n        transition={reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } }}\n        onKeyDown={onToolbarKey}\n      >\n        <AnimatePresence initial={false} custom={direction}>\n          {face === \"format\" ? <Face key=\"format\" direction={direction} reduced={reduced} onSize={onFaceSize}>\n            <div className={styles.row}>\n              <ToolButton label=\"Bold\" pressed={formats.bold} shortcut=\"⌘B\" onPress={() => toggleMark(\"bold\")}><Bold {...icon} /></ToolButton>\n              <ToolButton label=\"Italic\" pressed={formats.italic} shortcut=\"⌘I\" onPress={() => toggleMark(\"italic\")}><Italic {...icon} /></ToolButton>\n              <ToolButton label=\"Strikethrough\" pressed={formats.strike} shortcut=\"⌘⇧X\" onPress={() => toggleMark(\"strike\")}><Strikethrough {...icon} /></ToolButton>\n              <ToolButton label=\"Inline code\" pressed={formats.code} shortcut=\"⌘E\" onPress={() => toggleMark(\"code\")}><Code {...icon} /></ToolButton>\n              <ToolButton label={formats.link ? \"Edit link\" : \"Link\"} pressed={!!formats.link} shortcut=\"⌘K\" onPress={openLink}><Link {...icon} /></ToolButton>\n              <span className={styles.blockTools}>\n              <span className={styles.divider} aria-hidden=\"true\" />\n              <ToolButton label=\"Heading 1\" pressed={formats.block === \"h1\"} onPress={() => run(() => applyBlock(\"h1\"))}><Heading1 {...icon} /></ToolButton>\n              <ToolButton label=\"Heading 2\" pressed={formats.block === \"h2\"} onPress={() => run(() => applyBlock(\"h2\"))}><Heading2 {...icon} /></ToolButton>\n              <ToolButton label=\"Quote\" pressed={formats.block === \"blockquote\"} onPress={() => run(() => applyBlock(\"blockquote\"))}><Quote {...icon} /></ToolButton>\n              </span>\n            </div>\n          </Face> : <Face key=\"link\" direction={direction} reduced={reduced} onSize={onFaceSize}>\n            <form className={styles.linkRow} onSubmit={event => { event.preventDefault(); applyLink(); }}>\n              <button type=\"button\" className={styles.tool} aria-label=\"Back to formatting\" onClick={() => closeLink()}><ArrowLeft {...icon} /></button>\n              <input\n                ref={linkInputRef}\n                className={styles.linkInput}\n                value={linkDraft}\n                onChange={event => setLinkDraft(event.target.value)}\n                onKeyDown={event => { if (event.key === \"Escape\") { event.preventDefault(); event.stopPropagation(); closeLink(); } }}\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 && <button type=\"button\" className={styles.tool} aria-label=\"Remove link\" onClick={() => applyLink(true)}><Unlink {...icon} /></button>}\n              <button type=\"submit\" className={styles.apply} aria-label=\"Apply link\" data-ready={!!linkDraft.trim() || undefined}><Check size={16} strokeWidth={2} aria-hidden=\"true\" /></button>\n            </form>\n          </Face>}\n        </AnimatePresence>\n      </motion.div>}\n    </AnimatePresence>\n\n    <AnimatePresence>\n      {slashOpen && slash && <motion.div\n        key={`${slash.offset}-${slash.y}`}\n        ref={slashPanel}\n        className={styles.slash}\n        data-above={slash.above || undefined}\n        style={{ left: slash.x, top: slash.y, height: panelHeight, maxHeight: slashMax, transformOrigin: slash.above ? \"16px 100%\" : \"16px 0\" }}\n        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: slash.above ? 4 : -4 }}\n        animate={{ opacity: 1, scale: 1, y: 0 }}\n        exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .97, transition: { duration: duration.exit * .8, ease: standard } }}\n        transition={reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } }}\n        onMouseDown={event => event.preventDefault()}\n      >\n        <div ref={panelBody} className={styles.slashBody}>\n          <ul ref={listRef} id={`${uid}-slash`} role=\"listbox\" aria-label=\"Blocks\" className={styles.slashList}>\n            <motion.li aria-hidden=\"true\" className={styles.slashHighlight} style={{ y: hy, height: hh }} />\n            {slashResults.map((item, index) => <li key={item.type} id={optionId(index)} role=\"option\" aria-selected={index === slashIndex} data-index={index} className={styles.slashOption}\n              onPointerMove={() => { if (index !== slashIndex) setSlashActive(index); }} onClick={() => chooseSlash(item)}>\n              <span className={styles.slashIcon}>{item.icon}</span>\n              <span className={styles.slashLabel}>{item.label}</span>\n              {item.hint && <kbd className={styles.slashHint}>{item.hint}</kbd>}\n            </li>)}\n          </ul>\n        </div>\n      </motion.div>}\n    </AnimatePresence>\n  </div>;\n});\n\nexport default RichTextEditor;\n"
    },
    {
      "path": "registry/components/rich-text-editor/rich-text-editor.module.css",
      "type": "registry:component",
      "target": "@components/xzero/rich-text-editor/rich-text-editor.module.css",
      "content": ".root {\n  position: relative;\n  width: 100%;\n  container-type: inline-size;\n  color: var(--foreground);\n  font-family: var(--font-body);\n  letter-spacing: var(--tracking-body);\n}\n\n/* The writing surface. Everything inside is plain semantic HTML, styled here so pasted and typed content look the same. */\n.editor {\n  min-height: 8rem;\n  padding: 2px 0;\n  outline: none;\n  color: var(--foreground);\n  font-size: var(--text-base);\n  line-height: 1.65;\n  overflow-wrap: break-word;\n  white-space: pre-wrap;\n  word-break: break-word;\n  caret-color: var(--accent);\n}\n.editor[contenteditable=\"false\"] { caret-color: transparent; }\n.editor::selection,\n.editor *::selection { background: color-mix(in oklab, var(--accent) 22%, transparent); }\n\n.editor > * { margin: 0; }\n.editor > * + * { margin-top: .6em; }\n.editor p { margin: 0; }\n.editor h1,\n.editor h2,\n.editor h3 { font-weight: 500; letter-spacing: -.02em; line-height: 1.25; }\n.editor h1 { font-size: var(--text-2xl); }\n.editor h2 { font-size: var(--text-xl); }\n.editor h3 { font-size: var(--text-lg); }\n.editor > :is(h1, h2, h3) { margin-top: 1.1em; }\n.editor > :is(h1, h2, h3):first-child { margin-top: 0; }\n.editor > :is(h1, h2, h3) + * { margin-top: .45em; }\n\n.editor strong { font-weight: 600; }\n.editor s { color: var(--text-secondary); text-decoration-thickness: 1px; }\n.editor a { color: var(--accent-strong); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--accent) 45%, transparent); text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: text; }\n.editor code {\n  padding: .12em .38em;\n  border-radius: 6px;\n  background: var(--surface-muted);\n  font-family: ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace;\n  font-size: .86em;\n}\n\n.editor blockquote { padding-left: 14px; border-left: 2px solid var(--border-strong); color: var(--text-secondary); }\n\n.editor ul,\n.editor ol { margin-block: 0; padding-left: 1.45em; }\n/* Set the markers explicitly: app resets that strip list styles would otherwise leave lists unmarked. */\n.editor ul { list-style: disc; }\n.editor ul ul { list-style: circle; }\n.editor ol { list-style: decimal; }\n.editor li { padding-left: .2em; }\n.editor li + li { margin-top: .2em; }\n.editor li > :is(ul, ol) { margin-top: .2em; }\n.editor li::marker { color: var(--text-muted); }\n.editor ol > li::marker { font-variant-numeric: tabular-nums; }\n\n.editor pre {\n  padding: 12px 14px;\n  overflow-x: auto;\n  border: 1px solid var(--border-subtle);\n  border-radius: 14px;\n  background: var(--surface-muted);\n  font-family: ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace;\n  font-size: var(--text-sm);\n  line-height: 1.6;\n  tab-size: 2;\n  white-space: pre;\n}\n\n.editor hr { height: 1px; margin-block: 1.1em; border: 0; background: var(--border); }\n\n/* Placeholders: the whole document when empty, and a quiet hint on the empty line holding the caret. */\n.editor[data-empty] > :first-child::before,\n.editor:focus [data-hint]::before {\n  float: left;\n  height: 0;\n  color: var(--text-muted);\n  pointer-events: none;\n  content: var(--placeholder);\n}\n.editor:focus:not([data-empty]) [data-hint]::before { content: var(--hint); }\n\n/* Floating toolbar. One surface whose size springs between its two faces. */\n.toolbar {\n  position: absolute;\n  z-index: 40;\n  translate: -50% -100%;\n  overflow: hidden;\n  border: 1px solid var(--border);\n  border-radius: 16px;\n  background: var(--surface-raised);\n  box-shadow: var(--shadow-floating);\n}\n.toolbar[data-below] { translate: -50% 0; }\n.face { position: absolute; top: 0; left: 0; width: max-content; }\n.face[inert] { pointer-events: none; }\n\n.row,\n.linkRow { display: flex; align-items: center; gap: 2px; padding: 4px; }\n.blockTools { display: flex; align-items: center; gap: 2px; }\n/* Narrow editors keep the inline marks; block types stay one slash away. */\n@container (max-width: 330px) { .blockTools { display: none; } }\n.divider { width: 1px; height: 18px; margin: 0 4px; background: var(--border); }\n\n.tool {\n  display: grid;\n  width: 32px;\n  height: 32px;\n  flex: none;\n  place-items: center;\n  padding: 0;\n  border: 0;\n  border-radius: 11px;\n  background: transparent;\n  color: var(--text-secondary);\n  cursor: pointer;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.tool:active { transform: scale(.92); }\n.tool[aria-pressed=\"true\"] { background: var(--accent-subtle); color: var(--accent-strong); }\n.tool:focus-visible { background: var(--surface-muted); color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) { .tool:hover { background: var(--surface-muted); color: var(--foreground); } .tool[aria-pressed=\"true\"]:hover { background: var(--accent-subtle); color: var(--accent-strong); } }\n\n.linkInput {\n  width: min(15rem, 60vw);\n  height: 32px;\n  min-width: 0;\n  padding: 0 8px;\n  border: 0;\n  background: transparent;\n  color: var(--foreground);\n  font: inherit;\n  font-size: var(--text-sm);\n  outline: none;\n}\n.linkInput::placeholder { color: var(--text-muted); }\n.apply {\n  display: grid;\n  width: 32px;\n  height: 32px;\n  flex: none;\n  place-items: center;\n  padding: 0;\n  border: 0;\n  border-radius: 11px;\n  background: var(--surface-muted);\n  color: var(--text-muted);\n  cursor: pointer;\n  transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.apply[data-ready] { background: var(--accent); color: var(--accent-foreground); }\n.apply:active { transform: scale(.92); }\n\n/* Slash menu. */\n.slash {\n  position: absolute;\n  z-index: 40;\n  width: 240px;\n  max-width: calc(100% - 8px);\n  overflow-x: hidden;\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  scrollbar-width: none;\n  border: 1px solid var(--border);\n  border-radius: 18px;\n  background: var(--surface-raised);\n  box-shadow: var(--shadow-floating);\n}\n.slash[data-above] { translate: 0 -100%; }\n.slashBody { padding: 6px; }\n.slashList { position: relative; display: grid; margin: 0; padding: 0; list-style: none; }\n.slashHighlight { position: absolute; top: 0; right: 0; left: 0; border-radius: 12px; background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }\n.slashOption {\n  position: relative;\n  display: flex;\n  height: 34px;\n  align-items: center;\n  gap: 10px;\n  padding: 0 10px;\n  border-radius: 12px;\n  color: var(--text-secondary);\n  cursor: pointer;\n  font-size: var(--text-sm);\n  -webkit-tap-highlight-color: transparent;\n  transition: color var(--duration-fast) var(--ease-standard);\n}\n.slashOption[aria-selected=\"true\"] { color: var(--foreground); }\n.slashIcon { display: grid; width: 20px; flex: none; place-items: center; }\n.slashLabel { min-width: 0; flex: 1; overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.slashHint { flex: none; color: var(--text-muted); font-family: ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace; font-size: var(--text-xs); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/rich-text-editor",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/rich-text-editor",
    "markdown": "https://ui.xzero.co.uk/components/rich-text-editor/markdown"
  }
}