{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"selection-toolbar","type":"registry:component","title":"Selection toolbar","description":"A formatting toolbar that grows out of whatever text you select.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/selection-toolbar.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { motion, useMotionValue, useSpring } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Format = \"bold\" | \"italic\" | \"link\" | \"highlight\";\n\nconst TAGS: Record<Format, string> = {\n  bold: \"strong\",\n  italic: \"em\",\n  link: \"a\",\n  highlight: \"mark\",\n};\nconst FORMATS = Object.keys(TAGS) as Format[];\nconst LABELS: Record<Format, string> = {\n  bold: \"Bold\",\n  italic: \"Italic\",\n  link: \"Link\",\n  highlight: \"Highlight\",\n};\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Critically damped (2 * sqrt(500) is about 45), so the toolbar glides after\n// a growing selection without ever swinging past it.\nconst FOLLOW = { stiffness: 500, damping: 45 };\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Space between the selection and the toolbar.\nconst GAP = 8;\n// Long enough to read the check, short enough to copy again right away.\nconst COPIED_FOR = 1400;\n\n// Nearest element with this tag that still sits inside the editor.\nfunction closestIn(node: Node, tag: string, root: HTMLElement) {\n  const el = node.nodeType === 1 ? (node as Element) : node.parentElement;\n  const found = el?.closest(tag);\n  return found && root.contains(found) && found !== root ? found : null;\n}\n\nfunction unwrap(el: Element) {\n  const parent = el.parentNode;\n  if (!parent) return;\n  while (el.firstChild) parent.insertBefore(el.firstChild, el);\n  parent.removeChild(el);\n}\n\nfunction sameRange(a: Range | null, b: Range) {\n  return (\n    !!a &&\n    a.startContainer === b.startContainer &&\n    a.startOffset === b.startOffset &&\n    a.endContainer === b.endContainer &&\n    a.endOffset === b.endOffset\n  );\n}\n\nexport function SelectionToolbar({\n  html,\n  label,\n  className,\n}: {\n  // Initial markup only. After mount the text belongs to the reader, so\n  // React never writes to it again.\n  html: string;\n  label: string;\n  className?: string;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const editorRef = useRef<HTMLDivElement>(null);\n  const toolbarRef = useRef<HTMLDivElement>(null);\n  const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const rangeRef = useRef<Range | null>(null);\n  const openRef = useRef(false);\n  const pressing = useRef(false);\n  // Set by Escape: stays hidden until the selection actually changes.\n  const dismissed = useRef(false);\n  const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  const [open, setOpen] = useState(false);\n  // Typing hides the toolbar at once; every other close fades.\n  const [instant, setInstant] = useState(false);\n  const [active, setActive] = useState(\"\");\n  const [copied, setCopied] = useState(false);\n  const [focusIndex, setFocusIndex] = useState(0);\n  const [announce, setAnnounce] = useState(\"\");\n\n  const x = useSpring(0, FOLLOW);\n  const y = useSpring(0, FOLLOW);\n  const origin = useMotionValue(\"50% 100%\");\n\n  const show = (next: boolean, fast = false) => {\n    openRef.current = next;\n    setInstant(fast);\n    setOpen(next);\n  };\n\n  // Latest-callback ref, so the listeners bind once but always see the\n  // current reduced-motion setting.\n  const sync = useRef(() => {});\n  const syncNow = () => {\n    const root = rootRef.current;\n    const editor = editorRef.current;\n    const toolbar = toolbarRef.current;\n    const sel = window.getSelection();\n    if (!root || !editor || !toolbar) return;\n\n    const range = sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null;\n    if (\n      !range ||\n      range.collapsed ||\n      !editor.contains(range.commonAncestorContainer)\n    ) {\n      // Tabbing into the toolbar can nudge the selection; keep it open.\n      if (toolbar.contains(document.activeElement)) return;\n      if (openRef.current) show(false);\n      return;\n    }\n    // A drag in progress settles on release, so the toolbar doesn't chase\n    // every pixel of it.\n    if (pressing.current) return;\n    if (dismissed.current) {\n      if (sameRange(rangeRef.current, range)) return;\n      dismissed.current = false;\n    }\n    rangeRef.current = range.cloneRange();\n\n    const box = root.getBoundingClientRect();\n    const bounds = range.getBoundingClientRect();\n    const lines = [...range.getClientRects()].filter((r) => r.width > 0);\n    const first = lines[0] ?? bounds;\n    const last = lines[lines.length - 1] ?? bounds;\n    const w = toolbar.offsetWidth;\n    const h = toolbar.offsetHeight;\n\n    // Flips below when there's no room above inside the card or on screen.\n    const below =\n      first.top - box.top < h + GAP || first.top < h + GAP;\n    const center = bounds.left + bounds.width / 2 - box.left;\n    const left = Math.min(Math.max(center - w / 2, 0), box.width - w);\n    const top = below\n      ? last.bottom - box.top + GAP\n      : first.top - box.top - h - GAP;\n\n    // Scales out of the selection itself, even when clamped to the edge.\n    origin.set(`${center - left}px ${below ? 0 : h}px`);\n    if (!openRef.current || reduce) {\n      x.jump(left);\n      y.jump(top);\n    } else {\n      x.set(left);\n      y.set(top);\n    }\n\n    const on = FORMATS.filter((f) => {\n      const start = closestIn(range.startContainer, TAGS[f], editor);\n      return !!start && start === closestIn(range.endContainer, TAGS[f], editor);\n    }).join(\",\");\n    setActive(on);\n    if (!openRef.current) {\n      setFocusIndex(0);\n      show(true);\n    }\n  };\n\n  useLayoutEffect(() => {\n    sync.current = syncNow;\n  });\n\n  useEffect(() => {\n    const editor = editorRef.current;\n    const root = rootRef.current;\n    if (!editor || !root) return;\n    let frame = 0;\n    const run = () => sync.current();\n    const release = () => {\n      if (!pressing.current) return;\n      pressing.current = false;\n      // The selection finalises after pointerup, so read it a frame later.\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(run);\n    };\n    const observer = new ResizeObserver(run);\n    observer.observe(root);\n    document.addEventListener(\"selectionchange\", run);\n    window.addEventListener(\"pointerup\", release);\n    window.addEventListener(\"pointercancel\", release);\n    editor.addEventListener(\"scroll\", run, { passive: true });\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n      document.removeEventListener(\"selectionchange\", run);\n      window.removeEventListener(\"pointerup\", release);\n      window.removeEventListener(\"pointercancel\", release);\n      editor.removeEventListener(\"scroll\", run);\n    };\n  }, []);\n\n  useEffect(() => () => clearTimeout(copyTimer.current), []);\n\n  const liveRange = () => {\n    const editor = editorRef.current;\n    const range = rangeRef.current;\n    if (!editor || !range || range.collapsed) return null;\n    return editor.contains(range.commonAncestorContainer) ? range : null;\n  };\n\n  const select = (range: Range) => {\n    const sel = window.getSelection();\n    sel?.removeAllRanges();\n    sel?.addRange(range);\n  };\n\n  const toggle = (format: Format) => {\n    const editor = editorRef.current;\n    const range = liveRange();\n    if (!editor || !range) return;\n    const tag = TAGS[format];\n    const start = closestIn(range.startContainer, tag, editor);\n    const end = closestIn(range.endContainer, tag, editor);\n    const next = document.createRange();\n\n    if (start && start === end) {\n      const firstChild = start.firstChild;\n      const lastChild = start.lastChild;\n      unwrap(start);\n      if (!firstChild || !lastChild) return;\n      next.setStartBefore(firstChild);\n      next.setEndAfter(lastChild);\n      setAnnounce(`${LABELS[format]} off`);\n    } else {\n      const content = range.extractContents();\n      // Merges any partial marks of the same kind into the new one, so\n      // formatting never nests inside itself.\n      content.querySelectorAll(tag).forEach(unwrap);\n      const el = document.createElement(tag);\n      if (format === \"link\") el.setAttribute(\"href\", \"https://lab.xevrion.dev\");\n      el.appendChild(content);\n      range.insertNode(el);\n      next.selectNodeContents(el);\n      setAnnounce(`${LABELS[format]} on`);\n    }\n    // Splitting a partly selected mark can leave an empty shell behind.\n    editor\n      .querySelectorAll(Object.values(TAGS).join(\",\"))\n      .forEach((el) => {\n        if (!el.textContent) el.remove();\n      });\n    select(next);\n  };\n\n  const copy = async () => {\n    const range = liveRange();\n    if (!range) return;\n    // Confirms on press; waiting for the write makes the click feel ignored.\n    setCopied(true);\n    setAnnounce(\"Copied\");\n    clearTimeout(copyTimer.current);\n    copyTimer.current = setTimeout(() => setCopied(false), COPIED_FOR);\n    try {\n      await navigator.clipboard.writeText(range.toString());\n    } catch {\n      setCopied(false);\n      setAnnounce(\"Couldn't copy\");\n    }\n  };\n\n  const dismiss = () => {\n    dismissed.current = true;\n    show(false);\n  };\n\n  const actions = [...FORMATS, \"copy\" as const];\n\n  const focusButton = (index: number) => {\n    const i = (index + actions.length) % actions.length;\n    setFocusIndex(i);\n    buttonRefs.current[i]?.focus();\n  };\n\n  const pressed = new Set(active.split(\",\"));\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative\", className)}\n      onBlur={(e) => {\n        // Focus leaving both the text and the toolbar takes the toolbar along.\n        if (!rootRef.current?.contains(e.relatedTarget as Node | null)) {\n          if (openRef.current) show(false);\n        }\n      }}\n    >\n      <div\n        ref={editorRef}\n        role=\"textbox\"\n        aria-multiline\n        aria-label={label}\n        contentEditable\n        suppressContentEditableWarning\n        spellCheck={false}\n        onPointerDown={(e) => {\n          if (e.button === 0) pressing.current = true;\n        }}\n        onKeyDown={(e) => {\n          const mod = e.metaKey || e.ctrlKey;\n          if (mod && !e.altKey && (e.key === \"b\" || e.key === \"i\")) {\n            // Same path as the buttons, so the shortcut animates the toolbar\n            // exactly like a click does.\n            e.preventDefault();\n            if (liveRange()) toggle(e.key === \"b\" ? \"bold\" : \"italic\");\n            return;\n          }\n          if (e.key === \"Escape\" && openRef.current) {\n            e.preventDefault();\n            dismiss();\n            return;\n          }\n          const typing =\n            !mod &&\n            (e.key.length === 1 ||\n              e.key === \"Backspace\" ||\n              e.key === \"Delete\" ||\n              e.key === \"Enter\");\n          if (typing && openRef.current) show(false, true);\n        }}\n        dangerouslySetInnerHTML={{ __html: html }}\n        className=\"h-full overflow-y-auto rounded-lg text-[15px] leading-relaxed text-pretty text-muted-foreground caret-foreground outline-hidden selection:bg-foreground/15 selection:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-foreground [&_a]:text-foreground [&_a]:underline [&_a]:decoration-foreground/40 [&_a]:underline-offset-4 [&_em]:text-foreground [&_mark]:rounded-sm [&_mark]:bg-foreground/15 [&_mark]:text-foreground [&_p+p]:mt-3 [&_strong]:font-semibold [&_strong]:text-foreground\"\n      />\n\n      <motion.div\n        ref={toolbarRef}\n        role=\"toolbar\"\n        aria-label=\"Formatting\"\n        aria-hidden={!open}\n        inert={!open}\n        // Keeps the text selected and focused when a button is clicked.\n        onMouseDown={(e) => e.preventDefault()}\n        onKeyDown={(e) => {\n          const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key];\n          if (step) {\n            e.preventDefault();\n            focusButton(focusIndex + step);\n          } else if (e.key === \"Home\" || e.key === \"End\") {\n            e.preventDefault();\n            focusButton(e.key === \"Home\" ? 0 : -1);\n          } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            dismiss();\n            editorRef.current?.focus({ preventScroll: true });\n            const range = liveRange();\n            if (range) select(range);\n          }\n        }}\n        initial={false}\n        animate={\n          open\n            ? { opacity: 1, scale: 1, filter: \"blur(0px)\" }\n            : {\n                opacity: 0,\n                scale: reduce ? 1 : 0.96,\n                filter: \"blur(0px)\",\n              }\n        }\n        transition={\n          open\n            ? { duration: 0.18, ease: EASE_OUT }\n            : { duration: instant ? 0 : 0.1, ease: EASE_OUT }\n        }\n        style={{ x, y, transformOrigin: origin }}\n        // 12px radius around 4px padding keeps the 8px buttons concentric.\n        className={cn(\n          \"absolute top-0 left-0 z-10 flex items-center gap-0.5 rounded-xl bg-muted p-1 shadow-raised\",\n          !open && \"pointer-events-none\",\n        )}\n      >\n        {actions.map((action, i) => {\n          const isCopy = action === \"copy\";\n          const on = !isCopy && pressed.has(action);\n          return (\n            <span key={action} className=\"flex items-center\">\n              {isCopy && (\n                <span aria-hidden className=\"mx-1 h-5 w-px bg-border\" />\n              )}\n              <button\n                ref={(el) => {\n                  buttonRefs.current[i] = el;\n                }}\n                type=\"button\"\n                tabIndex={i === focusIndex ? 0 : -1}\n                aria-label={isCopy ? \"Copy\" : LABELS[action]}\n                aria-pressed={isCopy ? undefined : on}\n                onFocus={() => setFocusIndex(i)}\n                onClick={() => (isCopy ? copy() : toggle(action))}\n                className={cn(\n                  \"flex size-9 touch-manipulation items-center justify-center rounded-lg text-muted-foreground outline-hidden transition-[color,background-color,scale] duration-150 ease-out select-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\",\n                  on && \"bg-foreground/[0.08] text-foreground hover:bg-foreground/[0.1]\",\n                )}\n              >\n                {isCopy ? (\n                  <span className=\"grid\" aria-hidden>\n                    <SwapIcon visible={!copied} reduce={reduce}>\n                      <rect x=\"5.25\" y=\"5.25\" width=\"8\" height=\"8\" rx=\"1.75\" />\n                      <path d=\"M10.75 5.25V4.5a1.75 1.75 0 0 0-1.75-1.75H4.5A1.75 1.75 0 0 0 2.75 4.5V9a1.75 1.75 0 0 0 1.75 1.75h.75\" />\n                    </SwapIcon>\n                    <SwapIcon visible={copied} reduce={reduce}>\n                      <path d=\"m3.5 8.5 3 3 6-7\" />\n                    </SwapIcon>\n                  </span>\n                ) : (\n                  <svg\n                    viewBox=\"0 0 16 16\"\n                    aria-hidden\n                    className=\"size-4\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={1.5}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  >\n                    {ICONS[action]}\n                  </svg>\n                )}\n              </button>\n            </span>\n          );\n        })}\n      </motion.div>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\nconst ICONS: Record<Format, React.ReactNode> = {\n  bold: (\n    <path\n      d=\"M4.75 3.25h3.9a2.4 2.4 0 0 1 0 4.8h-3.9Zm0 4.8h4.6a2.35 2.35 0 0 1 0 4.7h-4.6Z\"\n      strokeWidth={1.75}\n    />\n  ),\n  italic: <path d=\"M7 3.25h5M4 12.75h5M9.5 3.25l-3 9.5\" />,\n  link: (\n    <path d=\"M7 9a2.5 2.5 0 0 0 3.54 0l2-2A2.5 2.5 0 0 0 9 3.46l-.5.5M9 7a2.5 2.5 0 0 0-3.54 0l-2 2A2.5 2.5 0 0 0 7 12.54l.5-.5\" />\n  ),\n  highlight: (\n    <>\n      <path d=\"m9.75 2.75 3.5 3.5-5.5 5.5h-3.5v-3.5Z\" />\n      <path d=\"M2.75 14.25h10.5\" />\n    </>\n  ),\n};\n\nfunction SwapIcon({\n  visible,\n  reduce,\n  children,\n}: {\n  visible: boolean;\n  reduce: boolean;\n  children: React.ReactNode;\n}) {\n  // Reduced motion keeps the cross-fade but drops the scale and blur.\n  const hidden = reduce\n    ? { opacity: 0 }\n    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" };\n  return (\n    <motion.svg\n      viewBox=\"0 0 16 16\"\n      className=\"col-start-1 row-start-1 size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      initial={false}\n      animate={visible ? { scale: 1, opacity: 1, filter: \"blur(0px)\" } : hidden}\n      transition={ICON_SWAP}\n    >\n      {children}\n    </motion.svg>\n  );\n}\n\nconst TEXT =\n  \"<p>Good interfaces are made of details nobody notices. The press that \" +\n  \"gives a little under your finger, the menu that grows out of the button \" +\n  \"you clicked, the toolbar that appears right where your attention \" +\n  \"already is.</p><p>Select any part of this note to format it. Try a \" +\n  \"word on the first line too: with no room above, the toolbar flips \" +\n  \"below the text.</p>\";\n\nexport default function SelectionToolbarDemo() {\n  return (\n    // Fixed height, so typing into the note scrolls inside the card instead\n    // of growing the demo.\n    <div className=\"h-64 w-[min(480px,100%)] rounded-2xl bg-background p-6 shadow-raised\">\n      <SelectionToolbar label=\"Note\" html={TEXT} className=\"h-full\" />\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/selection-toolbar"}