{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inline-rewrite","type":"registry:component","title":"Inline rewrite","description":"Drag a dial and the paragraph rewrites itself in place: shared words glide, the rest blur out and rise in.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/inline-rewrite.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * INLINE REWRITE: drag a dial, the paragraph rewrites itself\n *\n *   dial      one thumb on a track of versions (Shorter, Original,\n *             Longer; or Blunt, Neutral, Warm). Drag it, tap the\n *             track or use the arrow keys; its label morphs and\n *             it settles onto the nearest stop with a little throw\n *   rewrite   the paragraph changes in place, word by word: the\n *             words both versions share glide to their new places,\n *             the ones that go blur out where they stood, the new\n *             ones rise in one after another and glow for a moment\n *             so you can see what changed. The box eases to its\n *             new height\n *   on demand versions without text are written the first time the\n *             dial settles on them (onRewrite): a spinner opens in\n *             the thumb and a sheen runs over the paragraph until\n *             the new words arrive\n *   count     the word count rolls; +added and −cut against the\n *             original roll beside it\n * ───────────────────────────────────────────────────────── */\n\nexport interface RewriteVersion {\n  label: string;\n  /** Leave it out to have onRewrite write it the first time it's chosen. */\n  text?: string;\n}\n\nexport interface RewriteRequest {\n  index: number;\n  label: string;\n  /** The paragraph as it reads now, and the original version. */\n  current: string;\n  original: string;\n  /** Aborted if the component unmounts first. */\n  signal: AbortSignal;\n}\n\nexport interface InlineRewriteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\" | \"defaultValue\"> {\n  versions: RewriteVersion[];\n  /** The chosen version's index. */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (index: number) => void;\n  /** The version the counts compare against. Defaults to the one chosen first. */\n  originalIndex?: number;\n  onRewrite?: (request: RewriteRequest) => Promise<string>;\n  /** The dial's accessible name, e.g. \"Length\". */\n  label?: string;\n  /** Let new words glow for a moment after each rewrite. */\n  highlight?: boolean;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst SHEEN =\n  \"[mask-image:linear-gradient(90deg,rgb(0_0_0/0.4)_35%,#000_50%,rgb(0_0_0/0.4)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none]\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst split = (text: string) => text.split(/\\s+/).filter(Boolean);\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\n\n/** Longest common subsequence table over words: dp[i][j] is the match length of a[i..] and b[j..]. */\nfunction table(a: string[], b: string[]) {\n  const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1));\n  for (let i = a.length - 1; i >= 0; i--)\n    for (let j = b.length - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);\n  return dp;\n}\n\ntype Word = { key: number; text: string };\ntype Leaving = Word & { x: number; y: number; batch: number };\n\n/** Keep the words two texts share (and their keys); everything else is new. */\nfunction morphWords(previous: Word[], next: string[], nextKey: () => number): Word[] {\n  const a = previous.map((w) => w.text);\n  const dp = table(a, next);\n  const out: Word[] = [];\n  let i = 0;\n  let j = 0;\n  while (j < next.length) {\n    if (i < a.length && a[i] === next[j]) {\n      out.push(previous[i]);\n      i++;\n      j++;\n    } else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) i++;\n    else out.push({ key: nextKey(), text: next[j++] });\n  }\n  return out;\n}\n\n/** The paragraph: words as their own boxes so each can move, leave or arrive. */\nfunction Words({ text, reduced, highlight, className }: { text: string; reduced: boolean; highlight: boolean; className: string }) {\n  const boxRef = React.useRef<HTMLParagraphElement>(null);\n  const seq = React.useRef(1_000_000); // above the first render's keys\n  const [words, setWords] = React.useState<Word[]>(() => split(text).map((t, i) => ({ key: i + 1, text: t })));\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  const shown = React.useRef(text);\n  const flip = React.useRef<{ first: Map<number, DOMRect>; height: number } | null>(null);\n  const batch = React.useRef(0);\n\n  // Before the new words render: note where every word is, and keep the leaving ones where they stood.\n  React.useLayoutEffect(() => {\n    if (text === shown.current) return;\n    shown.current = text;\n    const next = morphWords(words, split(text), () => ++seq.current);\n    const box = boxRef.current;\n    if (!box || reduced) {\n      setWords(next);\n      setLeaving([]);\n      return;\n    }\n    const b = box.getBoundingClientRect();\n    const first = new Map<number, DOMRect>();\n    box.querySelectorAll<HTMLElement>(\"[data-k]\").forEach((el) => {\n      // Undo the last glow's reach over the spaces (it never moved anything) so boxes are just the words.\n      el.style.marginLeft = el.style.paddingLeft = el.style.borderRadius = \"\";\n      first.set(Number(el.dataset.k), el.getBoundingClientRect());\n    });\n    const keep = new Set(next.map((w) => w.key));\n    const id = ++batch.current;\n    const gone: Leaving[] = [];\n    for (const w of words) {\n      const r = first.get(w.key);\n      if (!keep.has(w.key) && r) gone.push({ ...w, x: r.left - b.left, y: r.top - b.top, batch: id });\n    }\n    flip.current = { first, height: b.height };\n    setWords(next);\n    if (gone.length) {\n      setLeaving((l) => [...l, ...gone]);\n      window.setTimeout(() => setLeaving((l) => l.filter((w) => w.batch !== id)), 320);\n    }\n  }, [text, reduced, words]);\n\n  // After: glide the kept words from where they were, raise the new ones, blur the leaving ones away.\n  React.useLayoutEffect(() => {\n    const f = flip.current;\n    const box = boxRef.current;\n    if (!f || !box) return;\n    flip.current = null;\n    const els = Array.from(box.querySelectorAll<HTMLElement>(\"[data-k]\"));\n    box.getAnimations().forEach((a) => a.cancel());\n    els.forEach((el) => el.getAnimations().forEach((a) => a.cancel()));\n    const height = box.getBoundingClientRect().height;\n    const rects = els.map((el) => el.getBoundingClientRect());\n    if (Math.abs(height - f.height) > 0.5) box.animate([{ height: `${f.height}px` }, { height: `${height}px` }], { duration: 480, easing: EASE });\n    const primary = highlight ? getComputedStyle(box).getPropertyValue(\"--primary\").trim() : \"\";\n    const glow = primary ? `color-mix(in oklab, ${primary} 16%, transparent)` : \"rgb(0 0 0 / 0.06)\";\n    const isNew = els.map((el) => !f.first.has(Number(el.dataset.k)));\n    const sameLine = (i: number, j: number) => Math.abs(rects[i].top - rects[j].top) < 2;\n    let fresh = 0;\n    els.forEach((el, i) => {\n      const was = f.first.get(Number(el.dataset.k));\n      if (was) {\n        const dx = was.left - rects[i].left;\n        const dy = was.top - rects[i].top;\n        if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)\n          el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: \"none\" }], { duration: 480, easing: EASE });\n        return;\n      }\n      const delay = Math.min(90 + fresh++ * 14, 420);\n      el.animate(\n        [\n          { opacity: 0, transform: \"translateY(0.35em)\", filter: \"blur(4px)\" },\n          { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n        ],\n        { duration: 380, delay, easing: EASE, fill: \"backwards\" },\n      );\n      if (!highlight) return;\n      // New words side by side glow as one run: reach back over the space between them, round only the run's ends.\n      const joinsBefore = i > 0 && isNew[i - 1] && sameLine(i - 1, i);\n      const joinsAfter = i < els.length - 1 && isNew[i + 1] && sameLine(i, i + 1);\n      if (joinsBefore) {\n        const gap = Math.max(0, rects[i].left - rects[i - 1].right);\n        el.style.marginLeft = `${-gap}px`;\n        el.style.paddingLeft = `${gap}px`;\n      }\n      el.style.borderRadius = `${joinsBefore ? 0 : 4}px ${joinsAfter ? 0 : 4}px ${joinsAfter ? 0 : 4}px ${joinsBefore ? 0 : 4}px`;\n      el.animate([{ backgroundColor: glow }, { backgroundColor: glow, offset: 0.35 }, { backgroundColor: \"transparent\" }], {\n        duration: 1800,\n        delay,\n        easing: \"ease-out\",\n        fill: \"backwards\",\n      });\n    });\n    box.querySelectorAll<HTMLElement>(\"[data-leave]:not([data-out])\").forEach((el) => {\n      el.dataset.out = \"\";\n      el.animate(\n        [\n          { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n          { opacity: 0, transform: \"translateY(-0.2em) scale(0.96)\", filter: \"blur(4px)\" },\n        ],\n        { duration: 240, easing: EASE, fill: \"forwards\" },\n      );\n    });\n  }, [words, highlight]);\n\n  return (\n    <p ref={boxRef} className={`relative ${className}`}>\n      {words.map((w, i) => (\n        <React.Fragment key={w.key}>\n          {i > 0 && \" \"}\n          <span data-k={w.key} className=\"inline-block\">\n            {w.text}\n          </span>\n        </React.Fragment>\n      ))}\n      {leaving.map((w) => (\n        <span\n          key={`gone-${w.batch}-${w.key}`}\n          data-leave=\"\"\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute whitespace-nowrap\"\n          style={{ left: w.x, top: w.y }}\n        >\n          {w.text}\n        </span>\n      ))}\n    </p>\n  );\n}\n\n/** The dial: stops on a track, one thumb that follows the pointer and settles on the nearest stop. */\nfunction Dial({\n  labels,\n  index,\n  busy,\n  label,\n  reduced,\n  onMove,\n}: {\n  labels: string[];\n  index: number;\n  busy: boolean;\n  label: string;\n  reduced: boolean;\n  onMove: (index: number, settled: boolean) => void;\n}) {\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const thumbRef = React.useRef<HTMLDivElement>(null);\n  const measureRef = React.useRef<HTMLDivElement>(null);\n  const [size, setSize] = React.useState({ width: 0, pad: 48 });\n  const [ready, setReady] = React.useState(false);\n  const [drag, setDrag] = React.useState<number | null>(null);\n  const live = React.useRef({ index, drag: null as number | null });\n  live.current = { index, drag };\n  const n = labels.length;\n  const key = labels.join(\"|\");\n\n  // Stops sit far enough in from the ends that the widest label never leaves the track.\n  React.useLayoutEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n    const measure = () => {\n      const widest = Math.max(0, ...Array.from(measureRef.current?.children ?? []).map((c) => (c as HTMLElement).offsetWidth));\n      setSize({ width: track.clientWidth, pad: widest / 2 + 4 });\n    };\n    measure();\n    // Only glide once the thumb has been placed, so it doesn't fly in from the edge.\n    const frame = requestAnimationFrame(() => setReady(true));\n    const observer = new ResizeObserver(measure);\n    observer.observe(track);\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n    };\n  }, [key]);\n\n  const span = Math.max(1, size.width - size.pad * 2);\n  const stopX = (i: number) => (n < 2 ? size.width / 2 : size.pad + (i * span) / (n - 1));\n  const nearest = (x: number) => (n < 2 ? 0 : clamp(Math.round(((x - size.pad) / span) * (n - 1)), 0, n - 1));\n  const x = drag === null ? stopX(index) : clamp(drag, size.pad, size.width - size.pad);\n\n  const at = (e: React.PointerEvent) => e.clientX - (trackRef.current?.getBoundingClientRect().left ?? 0);\n  const follow = (px: number, settled: boolean) => {\n    const i = nearest(px);\n    if (i !== live.current.index || settled) onMove(i, settled);\n  };\n\n  const onKey = (e: React.KeyboardEvent) => {\n    const steps: Record<string, number> = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1 };\n    let next: number | null = null;\n    if (e.key in steps) next = clamp(index + steps[e.key], 0, n - 1);\n    if (e.key === \"Home\") next = 0;\n    if (e.key === \"End\") next = n - 1;\n    if (next === null) return;\n    e.preventDefault();\n    if (next !== index) onMove(next, true);\n  };\n\n  return (\n    <div\n      ref={trackRef}\n      onPointerDown={(e) => {\n        if (e.button !== 0) return;\n        e.currentTarget.setPointerCapture(e.pointerId);\n        const px = at(e);\n        setDrag(px);\n        follow(px, false);\n        thumbRef.current?.focus({ preventScroll: true });\n      }}\n      onPointerMove={(e) => {\n        if (live.current.drag === null) return;\n        const px = at(e);\n        setDrag(px);\n        follow(px, false);\n      }}\n      onPointerUp={() => {\n        // Settle where the pointer last was; some touch screens report the lift-off point as 0.\n        const px = live.current.drag;\n        if (px === null) return;\n        setDrag(null);\n        follow(px, true);\n      }}\n      onPointerCancel={() => {\n        if (live.current.drag === null) return;\n        setDrag(null);\n        onMove(live.current.index, true);\n      }}\n      className=\"relative h-10 w-full cursor-pointer touch-pan-y select-none rounded-full border border-border bg-muted/40\"\n    >\n      <div ref={measureRef} aria-hidden=\"true\" className=\"invisible absolute flex\">\n        {labels.map((l) => (\n          <span key={l} className=\"whitespace-nowrap px-3 text-[12.5px] font-medium\">\n            <span className=\"mr-1.5 inline-block size-3\" />\n            {l}\n          </span>\n        ))}\n      </div>\n      {size.width > 0 &&\n        labels.map((l, i) => (\n          <span\n            key={l}\n            aria-hidden=\"true\"\n            className=\"absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/25\"\n            style={{ left: stopX(i) }}\n          />\n        ))}\n      <div\n        ref={thumbRef}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={n - 1}\n        aria-valuenow={index}\n        aria-valuetext={labels[index]}\n        aria-busy={busy || undefined}\n        onKeyDown={onKey}\n        className={`absolute top-1/2 flex h-8 -translate-x-1/2 -translate-y-1/2 items-center rounded-full bg-primary px-3 text-[12.5px] font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background ${\n          drag === null ? \"cursor-grab\" : \"cursor-grabbing\"\n        }`}\n        style={{\n          left: x,\n          opacity: size.width ? 1 : 0,\n          transition: drag !== null || reduced || !ready ? \"none\" : `left 420ms ${THROW}`,\n        }}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"grid\"\n          style={{\n            gridTemplateColumns: busy ? \"1fr\" : \"0fr\",\n            opacity: busy ? 1 : 0,\n            transition: reduced ? \"none\" : `grid-template-columns 300ms ${EASE}, opacity 200ms ${EASE}`,\n          }}\n        >\n          <span className=\"overflow-hidden\">\n            <span className=\"mr-1.5 block size-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none\" />\n          </span>\n        </span>\n        <TextMorph>{labels[index] ?? \"\"}</TextMorph>\n      </div>\n    </div>\n  );\n}\n\nexport function InlineRewrite({\n  versions,\n  value,\n  defaultValue = 0,\n  onValueChange,\n  originalIndex,\n  onRewrite,\n  label = \"Rewrite\",\n  highlight = true,\n  className = \"\",\n  ...props\n}: InlineRewriteProps) {\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState(defaultValue);\n  const index = clamp(value ?? own, 0, versions.length - 1);\n  const [first] = React.useState(index);\n  const original = originalIndex ?? first;\n  const [made, setMade] = React.useState<Record<number, string>>({});\n  const textAt = (i: number) => versions[i]?.text ?? made[i];\n  const [shown, setShown] = React.useState(() => (textAt(index) !== undefined ? index : original));\n  const [settled, setSettled] = React.useState(true);\n  const [busy, setBusy] = React.useState<number | null>(null);\n  const [failed, setFailed] = React.useState(false);\n  const inflight = React.useRef(new Set<number>());\n  const controllers = React.useRef(new Set<AbortController>());\n  const latest = React.useRef({ shown, original, value, onValueChange, onRewrite, versions, made });\n  latest.current = { shown, original, value, onValueChange, onRewrite, versions, made };\n\n  const choose = (i: number, done: boolean) => {\n    setSettled(done);\n    if (i === index) return;\n    setFailed(false);\n    if (value === undefined) setOwn(i);\n    onValueChange?.(i);\n  };\n\n  // Show the chosen version as soon as it has words; write it first if it has none (once the dial settles).\n  React.useEffect(() => {\n    if (textAt(index) !== undefined) {\n      setShown(index);\n      return;\n    }\n    const { onRewrite: rewrite } = latest.current;\n    if (!settled || !rewrite || inflight.current.has(index)) return;\n    const i = index;\n    const controller = new AbortController();\n    controllers.current.add(controller);\n    inflight.current.add(i);\n    setBusy(i);\n    setFailed(false);\n    const at = (n: number) => latest.current.versions[n]?.text ?? latest.current.made[n] ?? \"\";\n    rewrite({ index: i, label: versions[i].label, current: at(latest.current.shown), original: at(latest.current.original), signal: controller.signal })\n      .then((t) => {\n        if (!controller.signal.aborted) setMade((m) => ({ ...m, [i]: t }));\n      })\n      .catch(() => {\n        if (controller.signal.aborted) return;\n        // It didn't work: the dial goes back to what the paragraph says.\n        setFailed(true);\n        const back = latest.current.shown;\n        if (latest.current.value === undefined) setOwn(back);\n        latest.current.onValueChange?.(back);\n      })\n      .finally(() => {\n        inflight.current.delete(i);\n        controllers.current.delete(controller);\n        setBusy((b) => (b === i ? null : b));\n      });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [index, settled, made, versions]);\n\n  React.useEffect(() => {\n    if (!failed) return;\n    const timer = window.setTimeout(() => setFailed(false), 4000);\n    return () => window.clearTimeout(timer);\n  }, [failed]);\n\n  React.useEffect(() => {\n    const all = controllers.current;\n    return () => all.forEach((c) => c.abort());\n  }, []);\n\n  const words = split(textAt(shown) ?? \"\");\n  const base = split(textAt(original) ?? \"\");\n  const common = React.useMemo(() => table(base, words)[0][0], [base.join(\" \"), words.join(\" \")]); // eslint-disable-line react-hooks/exhaustive-deps\n  const added = words.length - common;\n  const cut = base.length - common;\n  const working = busy !== null && busy === index;\n  const status = failed ? \"Couldn’t rewrite it\" : working ? \"Rewriting\" : \"\";\n\n  return (\n    <div className={`@container w-full ${className}`} {...props}>\n      <Dial labels={versions.map((v) => v.label)} index={index} busy={working} label={label} reduced={reduced} onMove={choose} />\n\n      <Words\n        text={textAt(shown) ?? \"\"}\n        reduced={reduced}\n        highlight={highlight}\n        className={`mt-4 text-[14px] leading-[1.65] text-foreground @md:text-[15px] ${working ? SHEEN : \"\"}`}\n      />\n\n      <div className=\"mt-3 flex items-center gap-2.5 text-[12px] tabular-nums text-muted-foreground\">\n        <span>\n          <NumberRoll value={words.length} /> <TextMorph>{words.length === 1 ? \"word\" : \"words\"}</TextMorph>\n        </span>\n        <span aria-hidden=\"true\" className=\"size-[3px] rounded-full bg-muted-foreground/40\" />\n        {/* Against the original: the counts, or the word \"Original\" when you're on it. */}\n        <span className=\"grid\">\n          <span\n            className=\"col-start-1 row-start-1 flex gap-2\"\n            aria-hidden={shown === original}\n            style={{ opacity: shown === original ? 0 : 1, transition: `opacity 220ms ${EASE}` }}\n          >\n            <span className=\"text-emerald-600 dark:text-emerald-400\">\n              +<NumberRoll value={added} />\n            </span>\n            <span className=\"text-red-600 dark:text-red-400\">\n              −<NumberRoll value={cut} />\n            </span>\n          </span>\n          <span\n            className=\"col-start-1 row-start-1\"\n            aria-hidden={shown !== original}\n            style={{ opacity: shown === original ? 1 : 0, transition: `opacity 220ms ${EASE}` }}\n          >\n            Original\n          </span>\n        </span>\n        <span className={`ml-auto ${failed ? \"text-red-600 dark:text-red-400\" : \"\"}`}>\n          <TextMorph>{status}</TextMorph>\n        </span>\n      </div>\n      <p className=\"sr-only\" role=\"status\">\n        {failed ? \"Couldn’t rewrite it. Try again.\" : `${versions[shown]?.label ?? \"\"}, ${words.length} words`}\n      </p>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/inline-rewrite"}