{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"message-edit","type":"registry:component","title":"Edit and resend","description":"Fix a sent message where it sits. The bubble grows into an editor, then folds back and branches the reply.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/message-edit.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronLeft, ChevronRight, Copy, Pencil } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * EDIT AND RESEND: change a sent message in place\n *\n *   sent      the message, with Copy and Edit on hover; Copy\n *             blurs into a check that draws itself\n *   editing   the bubble grows into an editor where it sits;\n *             Enter sends, Escape cancels\n *   versions  after a resend the pager slides open; \"2 / 3\"\n *             rolls, and the message slides in from the side\n *             you stepped toward while the bubble eases to fit\n *   disabled  editing waits while an answer is streaming\n *\n * The morph is one surface: it widens from its right edge and\n * grows to fit the field, its contents fade across, and it folds\n * back the same way.\n * ───────────────────────────────────────────────────────── */\n\nexport interface MessageEditProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSubmit\"> {\n  text: string;\n  /** Called with the edited text; replace the message and regenerate from it. */\n  onSubmit: (text: string) => void;\n  /** Disable editing, e.g. while an answer streams. */\n  disabled?: boolean;\n  disabledReason?: string;\n  /** Branches of this message, shown as \"2 / 3\". */\n  versions?: { index: number; count: number; onIndexChange: (index: number) => void };\n  /** How the sent message renders; defaults to the text. */\n  children?: React.ReactNode;\n}\n\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst ICON = `flex size-7 items-center justify-center rounded-full text-muted-foreground transition-[background-color,color,transform] duration-150 hover:bg-accent hover:text-foreground active:scale-[0.94] disabled:pointer-events-none disabled:opacity-35 [&_svg]:size-3.5 ${FOCUS}`;\n\ntype Outgoing = { key: string; content: React.ReactNode; dir: number; width: number };\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\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\nfunction CopyGlyph({ copied, reduced }: { copied: boolean; reduced: boolean }) {\n  return (\n    <span className=\"relative flex size-3.5 items-center justify-center\">\n      <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(!copied, reduced)}>\n        <Copy />\n      </span>\n      <span className=\"absolute inset-0 flex items-center justify-center text-foreground\" style={swap(copied, reduced)}>\n        <svg viewBox=\"0 0 16 16\" fill=\"none\">\n          <path\n            d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.8\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            pathLength={1}\n            strokeDasharray={1}\n            style={{ strokeDashoffset: copied ? 0 : 1, transition: copied && !reduced ? `stroke-dashoffset 380ms ${MORPH} 100ms` : \"none\" }}\n          />\n        </svg>\n      </span>\n    </span>\n  );\n}\n\n/** The message you stepped away from: it leaves toward the side you came from. */\nfunction Leaving({ item, onDone }: { item: Outgoing; onDone: (key: string) => void }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const animation = el.animate(\n      [\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n        { opacity: 0, filter: \"blur(6px)\", transform: `translateX(${-item.dir * 16}px)` },\n      ],\n      { duration: 240, easing: MORPH, fill: \"forwards\" }\n    );\n    animation.onfinish = () => onDone(item.key);\n    return () => animation.cancel();\n  }, [item, onDone]);\n  return (\n    <div\n      ref={ref}\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute right-0 top-0 whitespace-pre-wrap px-4 py-2.5 text-[14px] leading-relaxed text-foreground\"\n      style={{ width: item.width }}\n    >\n      {item.content}\n    </div>\n  );\n}\n\nexport function MessageEdit({\n  text,\n  onSubmit,\n  disabled = false,\n  disabledReason = \"Wait for the answer to finish\",\n  versions,\n  children,\n  className = \"\",\n  ...props\n}: MessageEditProps) {\n  const reduced = useReducedMotion();\n  const [editing, setEditing] = React.useState(false);\n  const [draft, setDraft] = React.useState(text);\n  const [copied, setCopied] = React.useState(false);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const fieldRef = React.useRef<HTMLTextAreaElement>(null);\n  const editRef = React.useRef<HTMLButtonElement>(null);\n  const fromRef = React.useRef<DOMRect | null>(null);\n  const returnFocus = React.useRef(false);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const size = React.useRef<{ width: number; height: number } | null>(null);\n\n  // Stepping between versions: remember what was showing, so it can leave while the new one arrives.\n  const content = children ?? text;\n  const index = versions?.index ?? 0;\n  const [shown, setShown] = React.useState({ text, index, content, editing });\n  const [outgoing, setOutgoing] = React.useState<Outgoing[]>([]);\n  const [enterDir, setEnterDir] = React.useState(0);\n  if (shown.text !== text || shown.editing !== editing) {\n    // A resend lands while the editor folds back, which is motion enough; only a step between versions slides.\n    const stepped = shown.text !== text && !editing && !shown.editing && !reduced;\n    const dir = Math.sign(index - shown.index) || 1;\n    setShown({ text, index, content, editing });\n    setEnterDir(stepped ? dir : 0);\n    if (stepped) setOutgoing((o) => [...o, { key: `${shown.index}-${shown.text}`, content: shown.content, dir, width: size.current?.width ?? 0 }]);\n  }\n  const doneLeaving = React.useCallback((key: string) => setOutgoing((o) => o.filter((x) => x.key !== key)), []);\n\n  const morph = (next: boolean) => {\n    fromRef.current = surfaceRef.current?.getBoundingClientRect() ?? null;\n    setEditing(next);\n  };\n\n  const start = () => {\n    if (disabled) return;\n    setDraft(text);\n    morph(true);\n  };\n\n  const cancel = (refocus = true) => {\n    returnFocus.current = refocus;\n    morph(false);\n  };\n\n  const send = () => {\n    const next = draft.trim();\n    if (!next || next === text.trim()) return;\n    onSubmit(next);\n    returnFocus.current = true;\n    morph(false);\n  };\n\n  // Animate the surface from where it was to where it lands (both directions).\n  React.useLayoutEffect(() => {\n    const el = surfaceRef.current;\n    const from = fromRef.current;\n    fromRef.current = null;\n    if (editing) {\n      const field = fieldRef.current;\n      if (field) {\n        field.style.height = \"auto\";\n        field.style.height = `${field.scrollHeight}px`;\n        field.focus({ preventScroll: true });\n        field.setSelectionRange(field.value.length, field.value.length);\n      }\n    } else if (returnFocus.current) {\n      returnFocus.current = false;\n      editRef.current?.focus({ preventScroll: true });\n    }\n    if (!el || !from || reduced) return;\n    const to = el.getBoundingClientRect();\n    el.animate(\n      [\n        { width: `${from.width}px`, height: `${from.height}px` },\n        { width: `${to.width}px`, height: `${to.height}px` },\n      ],\n      { duration: 380, easing: EASE }\n    );\n  }, [editing, reduced]);\n\n  // A new version: the bubble eases from its old size to the new text, which slides in from the side you stepped toward.\n  React.useLayoutEffect(() => {\n    const el = surfaceRef.current;\n    const from = size.current;\n    if (!enterDir || !el || !from) return;\n    el.animate(\n      [\n        { width: `${from.width}px`, height: `${from.height}px` },\n        { width: `${el.offsetWidth}px`, height: `${el.offsetHeight}px` },\n      ],\n      { duration: 420, easing: MORPH }\n    );\n    contentRef.current?.animate(\n      [\n        { opacity: 0, filter: \"blur(6px)\", transform: `translateX(${enterDir * 16}px)` },\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n      ],\n      { duration: 420, easing: MORPH, delay: 60, fill: \"backwards\" }\n    );\n  }, [text, enterDir]);\n\n  // The size it settled at, for the next morph.\n  React.useLayoutEffect(() => {\n    const el = surfaceRef.current;\n    if (el) size.current = { width: el.offsetWidth, height: el.offsetHeight };\n  });\n\n  React.useEffect(() => {\n    if (!copied) return;\n    const timer = window.setTimeout(() => setCopied(false), 1600);\n    return () => window.clearTimeout(timer);\n  }, [copied]);\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(text);\n      setCopied(true);\n    } catch {\n      // Clipboard blocked; nothing to show.\n    }\n  };\n\n  const unchanged = draft.trim() === text.trim() || !draft.trim();\n\n  return (\n    <div className={`group/message flex w-full flex-col items-end ${className}`} {...props}>\n      <div\n        ref={surfaceRef}\n        className={`overflow-hidden rounded-[20px] transition-[background-color,box-shadow] duration-300 ${\n          editing\n            ? \"w-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n            : \"w-fit max-w-[85%] bg-muted shadow-[0_0_0_1px_transparent]\"\n        }`}\n      >\n        {editing ? (\n          <div key=\"editor\" className=\"p-3 animate-[ui-fade-in_220ms_ease-out_100ms_both] motion-reduce:animate-none\">\n            <textarea\n              ref={fieldRef}\n              value={draft}\n              rows={1}\n              aria-label=\"Edit message\"\n              onChange={(e) => {\n                setDraft(e.target.value);\n                e.target.style.height = \"auto\";\n                e.target.style.height = `${e.target.scrollHeight}px`;\n              }}\n              onKeyDown={(e) => {\n                if (e.nativeEvent.isComposing) return;\n                if (e.key === \"Escape\") {\n                  e.preventDefault();\n                  cancel();\n                } else if (e.key === \"Enter\" && !e.shiftKey) {\n                  e.preventDefault();\n                  send();\n                }\n              }}\n              className=\"block max-h-60 w-full resize-none bg-transparent px-1 text-[14px] leading-relaxed text-foreground outline-none\"\n            />\n            <div className=\"mt-2 flex items-center justify-end gap-1\">\n              <button\n                type=\"button\"\n                onClick={() => cancel()}\n                className={`h-8 rounded-full px-3 text-[12.5px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n              >\n                Cancel\n              </button>\n              <button\n                type=\"button\"\n                onClick={send}\n                disabled={unchanged}\n                className={`h-8 rounded-full bg-primary px-3.5 text-[12.5px] font-medium text-primary-foreground transition-[opacity,transform] duration-150 active:scale-[0.96] disabled:opacity-35 ${FOCUS}`}\n              >\n                Send\n              </button>\n            </div>\n          </div>\n        ) : (\n          <div key=\"message\" className=\"relative animate-[ui-fade-in_220ms_ease-out_100ms_both] motion-reduce:animate-none\">\n            {outgoing.map((o) => (\n              <Leaving key={o.key} item={o} onDone={doneLeaving} />\n            ))}\n            <div key={text} ref={contentRef} className=\"whitespace-pre-wrap px-4 py-2.5 text-[14px] leading-relaxed text-foreground\">\n              {content}\n            </div>\n          </div>\n        )}\n      </div>\n\n      <div\n        inert={editing}\n        className={`mt-1 flex items-center gap-0.5 transition-opacity duration-200 ${\n          editing ? \"opacity-0\" : \"opacity-0 focus-within:opacity-100 group-hover/message:opacity-100 [@media(hover:none)]:opacity-100\"\n        }`}\n      >\n        {versions && (\n          // Slides open the first time there is a second version.\n          <span\n            inert={versions.count < 2}\n            className=\"grid\"\n            style={{\n              gridTemplateColumns: versions.count > 1 ? \"1fr\" : \"0fr\",\n              opacity: versions.count > 1 ? 1 : 0,\n              transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity 300ms ${MORPH}`,\n            }}\n          >\n            <span className=\"flex min-w-0 items-center overflow-hidden\">\n              <span className=\"mr-1 flex shrink-0 items-center\">\n                <button\n                  type=\"button\"\n                  aria-label=\"Previous version\"\n                  disabled={versions.index <= 0}\n                  onClick={() => versions.onIndexChange(versions.index - 1)}\n                  className={ICON}\n                >\n                  <ChevronLeft />\n                </button>\n                <span aria-live=\"polite\" className=\"flex min-w-[2.5rem] justify-center gap-[0.5ch] font-mono text-[11px] tabular-nums text-muted-foreground\">\n                  <span className=\"sr-only\">Version </span>\n                  <NumberRoll value={versions.index + 1} duration={700} />\n                  <span aria-hidden=\"true\">/</span>\n                  <span className=\"sr-only\">of</span>\n                  <NumberRoll value={versions.count} duration={700} />\n                </span>\n                <button\n                  type=\"button\"\n                  aria-label=\"Next version\"\n                  disabled={versions.index >= versions.count - 1}\n                  onClick={() => versions.onIndexChange(versions.index + 1)}\n                  className={ICON}\n                >\n                  <ChevronRight />\n                </button>\n              </span>\n            </span>\n          </span>\n        )}\n        <button type=\"button\" aria-label=\"Copy message\" onClick={copy} className={ICON}>\n          <CopyGlyph copied={copied} reduced={reduced} />\n        </button>\n        <button\n          ref={editRef}\n          type=\"button\"\n          aria-label=\"Edit message\"\n          title={disabled ? disabledReason : undefined}\n          aria-disabled={disabled || undefined}\n          onClick={start}\n          className={`${ICON} aria-disabled:cursor-not-allowed aria-disabled:opacity-35 aria-disabled:hover:bg-transparent aria-disabled:hover:text-muted-foreground`}\n        >\n          <Pencil />\n        </button>\n      </div>\n      {/* Outside the buttons, so their names stay their labels. */}\n      <span role=\"status\" className=\"sr-only\">\n        {copied ? \"Copied\" : \"\"}\n      </span>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/message-edit"}