{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"magic-wand","type":"registry:ui","title":"MagicWand","description":"MagicWand from react-fancy","package":"react-fancy","dependencies":[],"registryDependencies":["button","badge","inputs"],"files":[{"path":"components\/fancy\/magic-wand\/MagicWand.tsx","content":"import { useCallback, useEffect, useRef, useState } from \"react\";\r\nimport { Button } from \"..\/Button\";\r\nimport { Badge } from \"..\/Badge\";\r\nimport { Textarea } from \"..\/inputs\/Textarea\/Textarea\";\r\n\r\n\/**\r\n * MagicWand \u2014 selection-anchored floating toolbar for text inputs.\r\n *\r\n * When the user highlights text inside the wrapped <Textarea>, a small\r\n * pill of AI quick-actions floats above the selection. Clicking an\r\n * action invokes the host callback with the selected substring + range;\r\n * the host returns a replacement string and MagicWand swaps it in-place.\r\n *\r\n *   <MagicWand\r\n *     value={body}\r\n *     onValueChange={setBody}\r\n *     actions={[\r\n *       { id: \"rephrase\", label: \"Rephrase\", hint: \"same meaning, different words\",\r\n *         run: async (s) => await ai.rephrase(s) },\r\n *       \u2026\r\n *     ]}\r\n *   \/>\r\n *\r\n * Two appearances: `\"floating\"` (default \u2014 full labels) and `\"pill\"`\r\n * (icon-only, compact). Auto-hides on click-away or scroll by default.\r\n *\/\r\nexport type MagicWandSelection = { start: number; end: number; text: string };\r\n\r\nexport type MagicWandAction = {\r\n  \/** Stable id, also shown as the busy-state key. *\/\r\n  id: string;\r\n  \/** Display label (first character used in `pill` mode). *\/\r\n  label: string;\r\n  \/** Tooltip body. *\/\r\n  hint?: string;\r\n  \/** Optional kind chip rendered next to the label. *\/\r\n  tag?: string;\r\n  \/** Returns the replacement text for the selection. *\/\r\n  run: (selection: string, range: MagicWandSelection) => Promise<string> | string;\r\n};\r\n\r\nexport type MagicWandAppearance = \"pill\" | \"floating\";\r\n\r\nexport interface MagicWandProps {\r\n  \/** Controlled textarea value. *\/\r\n  value: string;\r\n  \/** Called on every edit. *\/\r\n  onValueChange: (v: string) => void;\r\n  \/** Action list. *\/\r\n  actions: MagicWandAction[];\r\n  \/** Visual treatment. Defaults to \"floating\". *\/\r\n  appearance?: MagicWandAppearance;\r\n  \/** Auto-hide the wand on click-away or scroll. Defaults to true. *\/\r\n  autoHide?: boolean;\r\n  \/** Textarea rows. *\/\r\n  rows?: number;\r\n  \/** Placeholder. *\/\r\n  placeholder?: string;\r\n  \/** Called after an action runs successfully. *\/\r\n  onAction?: (action: MagicWandAction, selection: MagicWandSelection, replacement: string) => void;\r\n}\r\n\r\nexport function MagicWand({\r\n  value,\r\n  onValueChange,\r\n  actions,\r\n  appearance = \"floating\",\r\n  autoHide = true,\r\n  rows = 6,\r\n  placeholder,\r\n  onAction,\r\n}: MagicWandProps) {\r\n  const taRef = useRef<HTMLTextAreaElement>(null);\r\n  const wandRef = useRef<HTMLDivElement>(null);\r\n  const [sel, setSel] = useState<MagicWandSelection | null>(null);\r\n  const [pos, setPos] = useState<{ x: number; y: number } | null>(null);\r\n  const [busy, setBusy] = useState<string | null>(null);\r\n\r\n  const measureSelection = useCallback(() => {\r\n    const ta = taRef.current;\r\n    if (!ta) return;\r\n    const start = ta.selectionStart ?? 0;\r\n    const end = ta.selectionEnd ?? 0;\r\n    if (start === end) {\r\n      setSel(null);\r\n      setPos(null);\r\n      return;\r\n    }\r\n    const text = ta.value.slice(start, end);\r\n    setSel({ start, end, text });\r\n    const rect = caretRect(ta, start, end);\r\n    if (rect) setPos({ x: rect.x, y: rect.y });\r\n  }, []);\r\n\r\n  useEffect(() => {\r\n    if (!autoHide) return;\r\n    const onScroll = () => {\r\n      setSel(null);\r\n      setPos(null);\r\n    };\r\n    window.addEventListener(\"scroll\", onScroll, true);\r\n    return () => window.removeEventListener(\"scroll\", onScroll, true);\r\n  }, [autoHide]);\r\n\r\n  useEffect(() => {\r\n    if (!autoHide) return;\r\n    const onDocMouseDown = (e: MouseEvent) => {\r\n      if (wandRef.current?.contains(e.target as Node)) return;\r\n      if (taRef.current?.contains(e.target as Node)) return;\r\n      setSel(null);\r\n      setPos(null);\r\n    };\r\n    document.addEventListener(\"mousedown\", onDocMouseDown);\r\n    return () => document.removeEventListener(\"mousedown\", onDocMouseDown);\r\n  }, [autoHide]);\r\n\r\n  const handleAction = async (action: MagicWandAction) => {\r\n    if (!sel) return;\r\n    setBusy(action.id);\r\n    try {\r\n      const replacement = await action.run(sel.text, sel);\r\n      const next = value.slice(0, sel.start) + replacement + value.slice(sel.end);\r\n      onValueChange(next);\r\n      onAction?.(action, sel, replacement);\r\n    } finally {\r\n      setBusy(null);\r\n      setSel(null);\r\n      setPos(null);\r\n    }\r\n  };\r\n\r\n  return (\r\n    <div className=\"relative\">\r\n      <Textarea\r\n        ref={taRef as unknown as React.Ref<HTMLTextAreaElement>}\r\n        value={value}\r\n        onValueChange={onValueChange}\r\n        onSelect={measureSelection}\r\n        onKeyUp={measureSelection}\r\n        onMouseUp={measureSelection}\r\n        rows={rows}\r\n        placeholder={placeholder}\r\n      \/>\r\n      {sel && pos && (\r\n        <Wand\r\n          ref={wandRef}\r\n          pos={pos}\r\n          actions={actions}\r\n          appearance={appearance}\r\n          busy={busy}\r\n          onAction={handleAction}\r\n          selectionLength={sel.text.length}\r\n        \/>\r\n      )}\r\n    <\/div>\r\n  );\r\n}\r\n\r\nfunction Wand({\r\n  ref,\r\n  pos,\r\n  actions,\r\n  appearance,\r\n  busy,\r\n  onAction,\r\n  selectionLength,\r\n}: {\r\n  ref: React.RefObject<HTMLDivElement | null>;\r\n  pos: { x: number; y: number };\r\n  actions: MagicWandAction[];\r\n  appearance: MagicWandAppearance;\r\n  busy: string | null;\r\n  onAction: (a: MagicWandAction) => void;\r\n  selectionLength: number;\r\n}) {\r\n  return (\r\n    <div\r\n      ref={ref}\r\n      className=\"absolute z-20 -translate-x-1\/2 -translate-y-full\"\r\n      style={{ left: pos.x, top: pos.y - 6 }}\r\n    >\r\n      <div className=\"flex items-center gap-1 rounded-full border border-violet-300 bg-white px-1.5 py-1 shadow-lg ring-1 ring-violet-200 dark:border-violet-700 dark:bg-zinc-900 dark:ring-violet-900\">\r\n        <span className=\"ml-1 mr-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-violet-700 dark:text-violet-300\">\r\n          <span aria-hidden>\u2726<\/span>\r\n          {appearance === \"floating\" && (\r\n            <Badge color=\"violet\">{selectionLength} chars<\/Badge>\r\n          )}\r\n        <\/span>\r\n        {actions.map((a) => (\r\n          <Button\r\n            key={a.id}\r\n            size=\"sm\"\r\n            variant=\"ghost\"\r\n            onClick={() => onAction(a)}\r\n            disabled={busy !== null}\r\n            title={a.hint}\r\n          >\r\n            {busy === a.id ? \"\u2026\" : appearance === \"pill\" ? a.label[0] : a.label}\r\n            {appearance === \"floating\" && a.tag && (\r\n              <span className=\"ml-1 rounded-full bg-zinc-100 px-1 text-[9px] font-medium text-zinc-500 dark:bg-zinc-800\">\r\n                {a.tag}\r\n              <\/span>\r\n            )}\r\n          <\/Button>\r\n        ))}\r\n      <\/div>\r\n      <div className=\"absolute left-1\/2 top-full -translate-x-1\/2 -translate-y-px\">\r\n        <div className=\"h-2 w-2 rotate-45 border-b border-r border-violet-300 bg-white dark:border-violet-700 dark:bg-zinc-900\" \/>\r\n      <\/div>\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\/**\r\n * Caret\/selection geometry: mirror the textarea into a hidden div and\r\n * measure the selected substring's bounding rect. Not perfect for\r\n * resized textareas, but works for typical chat composers.\r\n *\/\r\nfunction caretRect(\r\n  ta: HTMLTextAreaElement,\r\n  start: number,\r\n  end: number,\r\n): { x: number; y: number } | null {\r\n  const div = document.createElement(\"div\");\r\n  const style = getComputedStyle(ta);\r\n  const props = [\r\n    \"boxSizing\",\r\n    \"width\",\r\n    \"height\",\r\n    \"overflowX\",\r\n    \"overflowY\",\r\n    \"borderTopWidth\",\r\n    \"borderRightWidth\",\r\n    \"borderBottomWidth\",\r\n    \"borderLeftWidth\",\r\n    \"paddingTop\",\r\n    \"paddingRight\",\r\n    \"paddingBottom\",\r\n    \"paddingLeft\",\r\n    \"fontStyle\",\r\n    \"fontVariant\",\r\n    \"fontWeight\",\r\n    \"fontStretch\",\r\n    \"fontSize\",\r\n    \"fontSizeAdjust\",\r\n    \"lineHeight\",\r\n    \"fontFamily\",\r\n    \"textAlign\",\r\n    \"textTransform\",\r\n    \"textIndent\",\r\n    \"letterSpacing\",\r\n    \"wordSpacing\",\r\n    \"tabSize\",\r\n  ] as const;\r\n  for (const p of props) (div.style as any)[p] = (style as any)[p];\r\n  div.style.position = \"absolute\";\r\n  div.style.top = \"-9999px\";\r\n  div.style.left = \"-9999px\";\r\n  div.style.whiteSpace = \"pre-wrap\";\r\n  div.style.wordWrap = \"break-word\";\r\n\r\n  const value = ta.value;\r\n  div.textContent = value.substring(0, start);\r\n  const startSpan = document.createElement(\"span\");\r\n  startSpan.textContent = value.substring(start, end) || \".\";\r\n  div.appendChild(startSpan);\r\n\r\n  document.body.appendChild(div);\r\n  const taRect = ta.getBoundingClientRect();\r\n  const parentRect =\r\n    (ta.offsetParent as HTMLElement | null)?.getBoundingClientRect() ?? taRect;\r\n  const spanRect = startSpan.getBoundingClientRect();\r\n  const divRect = div.getBoundingClientRect();\r\n  const offsetX = spanRect.left - divRect.left;\r\n  const offsetY = spanRect.top - divRect.top;\r\n  document.body.removeChild(div);\r\n\r\n  const x = taRect.left - parentRect.left + offsetX + spanRect.width \/ 2;\r\n  const y = taRect.top - parentRect.top + offsetY - ta.scrollTop;\r\n  return { x, y };\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/magic-wand\/MagicWand.tsx"},{"path":"components\/fancy\/magic-wand\/index.ts","content":"export { MagicWand } from \".\/MagicWand\";\r\nexport type {\r\n  MagicWandProps,\r\n  MagicWandAction,\r\n  MagicWandSelection,\r\n  MagicWandAppearance,\r\n} from \".\/MagicWand\";\r\n","type":"registry:ui","target":"components\/fancy\/magic-wand\/index.ts"}]}