{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-morph",
  "type": "registry:component",
  "title": "Copy Morph",
  "description": "A copy button where the icon outline morphs into a checkmark with a stroke and spring morph. It really copies the text with navigator.clipboard (with a fallback).",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Buttons"
  ],
  "docs": "Installed in components/furin/copy-morph/. Docs, live controls and props table: https://furin.dev/components/copy-morph/",
  "files": [
    {
      "path": "registry/copy-morph/CopyMorph.tsx",
      "content": "\"use client\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./CopyMorph.css\";\n\nexport interface CopyMorphProps {\n  /** Texto que se copia al portapapeles. */\n  text?: string;\n  /** Texto del botón en reposo. */\n  label?: string;\n  /** Texto del botón después de copiar. */\n  copiedLabel?: string;\n  /** Texto del botón si la copia falla. */\n  errorLabel?: string;\n  /** \"field\" muestra el texto con el botón al lado; \"button\" solo el botón; \"icon\" solo el ícono. */\n  variant?: \"field\" | \"button\" | \"icon\";\n  /** Milisegundos que se queda la palomita antes de volver. */\n  duration?: number;\n  /** Color de la confirmación. */\n  successColor?: string;\n  /** Se llama después de copiar con éxito. */\n  onCopy?: (text: string) => void;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\n/* ---------- Morph de trazo: el contorno del ícono de copiar se convierte en la palomita ---------- */\n\ntype Pt = [number, number];\nconst N = 56;\n\n/** Remuestrea una polilínea a n+1 puntos a igual distancia a lo largo del trazo. */\nfunction remuestrea(pts: Pt[], n: number): Pt[] {\n  const acum = [0];\n  for (let i = 1; i < pts.length; i++) acum.push(acum[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));\n  const total = acum[acum.length - 1];\n  const out: Pt[] = [];\n  let j = 1;\n  for (let i = 0; i <= n; i++) {\n    const d = (i / n) * total;\n    while (j < pts.length - 1 && acum[j] < d) j++;\n    const seg = acum[j] - acum[j - 1] || 1;\n    const t = (d - acum[j - 1]) / seg;\n    out.push([pts[j - 1][0] + (pts[j][0] - pts[j - 1][0]) * t, pts[j - 1][1] + (pts[j][1] - pts[j - 1][1]) * t]);\n  }\n  return out;\n}\n\n/** Rectángulo redondeado como polilínea cerrada, empezando en el borde derecho y bajando. */\nfunction rectRedondeado(x: number, y: number, w: number, h: number, r: number): Pt[] {\n  const pts: Pt[] = [];\n  const arco = (cx: number, cy: number, a0: number) => { for (let k = 0; k <= 6; k++) { const a = a0 + (k / 6) * (Math.PI / 2); pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); } };\n  arco(x + w - r, y + h - r, 0);          // esquina inferior derecha\n  arco(x + r, y + h - r, Math.PI / 2);    // inferior izquierda\n  arco(x + r, y + r, Math.PI);            // superior izquierda\n  arco(x + w - r, y + r, Math.PI * 1.5);  // superior derecha\n  pts.push([x + w, y + r]);               // cierra\n  pts.unshift([x + w, y + r]);\n  return pts;\n}\n\nlet formas: { a: Pt[]; b: Pt[] } | null = null;\nfunction getFormas() {\n  // La palomita se recorre de arriba a la derecha hacia la izquierda, igual que el rectángulo recorre su perímetro.\n  formas ??= {\n    a: remuestrea(rectRedondeado(8.6, 8.6, 11, 11, 2.6), N),\n    b: remuestrea([[18.6, 7.4], [10.2, 16.2], [5.4, 11.8]], N),\n  };\n  return formas;\n}\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nfunction trazo(p: number): string {\n  const { a, b } = getFormas();\n  let d = \"\";\n  for (let i = 0; i <= N; i++) {\n    // Cada punto sale con un pequeño retraso: la forma \"fluye\" de un extremo al otro.\n    const q = Math.min(1.1, Math.max(0, (p - (i / N) * 0.22) / 0.78));\n    d += `${i ? \"L\" : \"M\"}${lerp(a[i][0], b[i][0], q).toFixed(2)} ${lerp(a[i][1], b[i][1], q).toFixed(2)}`;\n  }\n  return d;\n}\n\n/** Copia con respaldo: primero la API moderna y, si el navegador la niega, el método clásico con un textarea. */\nasync function copiar(texto: string): Promise<boolean> {\n  try {\n    if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(texto); return true; }\n  } catch { /* cae al respaldo */ }\n  try {\n    const ta = document.createElement(\"textarea\");\n    ta.value = texto;\n    ta.setAttribute(\"readonly\", \"\");\n    ta.style.cssText = \"position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none\";\n    document.body.appendChild(ta);\n    ta.select();\n    ta.setSelectionRange(0, texto.length);\n    const ok = document.execCommand(\"copy\");\n    ta.remove();\n    return ok;\n  } catch { return false; }\n}\n\n/** Botón de copiar: el contorno del ícono se transforma en una palomita con un resorte y el texto se copia de verdad. */\nexport function CopyMorph({\n  text = \"npx furin add copy-morph\",\n  label = \"Copy\",\n  copiedLabel = \"Copied\",\n  errorLabel = \"Copy failed\",\n  variant = \"field\",\n  duration = 1900,\n  successColor = \"#6EE0A6\",\n  onCopy,\n  className = \"\",\n  style,\n}: CopyMorphProps) {\n  const [estado, setEstado] = useState<\"idle\" | \"copiado\" | \"error\">(\"idle\");\n  const trazoRef = useRef<SVGPathElement>(null);\n  const codigo = useRef<HTMLElement>(null);\n  const timer = useRef(0);\n  const fis = useRef({ p: 0, v: 0, raf: 0, last: 0, to: 0 });\n  const tick = useCallback((t: number) => {\n    const f = fis.current;\n    f.raf = 0;\n    const dt = Math.min(0.032, Math.max(0.001, (t - f.last) / 1000));\n    f.last = t;\n    const k = 230, c = 2 * Math.sqrt(k) * 0.56;\n    for (let i = 0; i < 3; i++) { f.v += (k * (f.to - f.p) - c * f.v) * (dt / 3); f.p += f.v * (dt / 3); }\n    trazoRef.current?.setAttribute(\"d\", trazo(f.p));\n    if (Math.abs(f.to - f.p) < 0.0008 && Math.abs(f.v) < 0.004) { f.p = f.to; trazoRef.current?.setAttribute(\"d\", trazo(f.p)); return; }\n    f.raf = requestAnimationFrame(tick);\n  }, []);\n\n  useEffect(() => {\n    const f = fis.current;\n    f.to = estado === \"copiado\" ? 1 : 0;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) { f.p = f.to; f.v = 0; trazoRef.current?.setAttribute(\"d\", trazo(f.p)); return; }\n    if (!f.raf) { f.last = performance.now(); f.raf = requestAnimationFrame(tick); }\n  }, [estado, tick]);\n\n  useEffect(() => () => { window.clearTimeout(timer.current); cancelAnimationFrame(fis.current.raf); }, []);\n\n  const alPulsar = async () => {\n    window.clearTimeout(timer.current);\n    const ok = await copiar(text);\n    if (ok) {\n      setEstado(\"copiado\");\n      onCopy?.(text);\n    } else {\n      // Si no hay manera de copiar, deja el texto seleccionado para que la persona use ⌘C.\n      setEstado(\"error\");\n      const el = codigo.current;\n      if (el) { const r = document.createRange(); r.selectNodeContents(el); const s = window.getSelection(); s?.removeAllRanges(); s?.addRange(r); }\n    }\n    timer.current = window.setTimeout(() => setEstado(\"idle\"), duration);\n  };\n\n  const vars = { \"--furin-copy-ok\": successColor } as CSSProperties;\n  const msg = estado === \"copiado\" ? copiedLabel : estado === \"error\" ? errorLabel : \"\";\n  const boton = (\n    <button type=\"button\" className={`furin-copy-morph__boton is-${estado}`} onClick={alPulsar} aria-label={variant === \"icon\" ? `${label}: ${text}` : undefined}>\n      <svg className=\"furin-copy-morph__icono\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n        <path className=\"furin-copy-morph__atras\" d=\"M15.4 8.6V7.1A2.6 2.6 0 0 0 12.8 4.5H7.1A2.6 2.6 0 0 0 4.5 7.1v5.7a2.6 2.6 0 0 0 2.6 2.6h1.5\" />\n        <path ref={trazoRef} className=\"furin-copy-morph__frente\" d={trazo(0)} />\n      </svg>\n      {variant !== \"icon\" ? (\n        <span className=\"furin-copy-morph__etq\">\n          <span className={estado === \"idle\" ? \"is-on\" : \"\"} aria-hidden={estado !== \"idle\"}>{label}</span>\n          <span className={estado === \"copiado\" ? \"is-on\" : \"\"} aria-hidden={estado !== \"copiado\"}>{copiedLabel}</span>\n          <span className={estado === \"error\" ? \"is-on\" : \"\"} aria-hidden={estado !== \"error\"}>{errorLabel}</span>\n        </span>\n      ) : null}\n    </button>\n  );\n  return (\n    <div className={`furin-copy-morph furin-copy-morph--${variant} ${className}`} style={{ ...vars, ...style }}>\n      {variant === \"field\" ? (\n        <div className=\"furin-copy-morph__campo\">\n          <code ref={codigo} className=\"furin-copy-morph__texto\"><span className=\"furin-copy-morph__prompt\" aria-hidden=\"true\">$</span>{text}</code>\n          {boton}\n        </div>\n      ) : (\n        boton\n      )}\n      <span className=\"furin-copy-morph__sr\" role=\"status\" aria-live=\"polite\">{msg}</span>\n    </div>\n  );\n}\n\nexport default CopyMorph;\n",
      "type": "registry:component",
      "target": "components/furin/copy-morph/CopyMorph.tsx"
    },
    {
      "path": "registry/copy-morph/CopyMorph.css",
      "content": ".furin-copy-morph {\n  --furin-copy-ok: #6ee0a6;\n  display: inline-block;\n  max-width: 94%;\n  font-family: Geist, system-ui, -apple-system, sans-serif;\n  color: #f2f3f5;\n}\n.furin-copy-morph__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n/* Campo: el comando en mono con el botón pegado a la derecha, como una terminal de vidrio. */\n.furin-copy-morph__campo { display: flex; align-items: center; gap: 6px; min-width: min(380px, 100%); padding: 6px 6px 6px 16px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.1); background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 18px 36px -22px rgba(0, 0, 0, 0.8); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }\n.furin-copy-morph__texto { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font: 400 14px/1.2 \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, monospace; color: rgba(255, 255, 255, 0.88); user-select: all; }\n.furin-copy-morph__texto::-webkit-scrollbar { display: none; }\n.furin-copy-morph__prompt { margin-right: 10px; color: rgba(255, 255, 255, 0.32); user-select: none; }\n\n.furin-copy-morph__boton {\n  --c: rgba(255, 255, 255, 0.82);\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  padding: 0 14px 0 10px;\n  height: 40px;\n  appearance: none;\n  border-radius: 10px;\n  border: 1px solid rgba(255, 255, 255, 0.12);\n  background: rgba(255, 255, 255, 0.06);\n  color: var(--c);\n  font: 500 13.5px/1 Geist, system-ui, sans-serif;\n  letter-spacing: -0.005em;\n  cursor: pointer;\n  transition: background-color 0.3s, border-color 0.3s, color 0.3s, transform 0.25s cubic-bezier(0.34, 1.6, 0.64, 1);\n  -webkit-tap-highlight-color: transparent;\n}\n.furin-copy-morph__boton:hover { background: rgba(255, 255, 255, 0.1); --c: #fff; }\n.furin-copy-morph__boton:active { transform: scale(0.96); }\n.furin-copy-morph__boton:focus-visible { outline: 2px solid rgba(255, 255, 255, 0.85); outline-offset: 2px; }\n.furin-copy-morph__boton.is-copiado { --c: var(--furin-copy-ok); background: color-mix(in srgb, var(--furin-copy-ok) 12%, transparent); border-color: color-mix(in srgb, var(--furin-copy-ok) 38%, transparent); }\n.furin-copy-morph__boton.is-error { --c: #ff8a93; background: rgba(255, 122, 133, 0.1); border-color: rgba(255, 122, 133, 0.35); animation: furin-copy-morph-sacude 0.4s ease; }\n.furin-copy-morph--button .furin-copy-morph__boton { height: 46px; padding: 0 18px 0 14px; border-radius: 12px; font-size: 14.5px; }\n.furin-copy-morph--icon .furin-copy-morph__boton { width: 42px; height: 42px; padding: 0; justify-content: center; border-radius: 12px; }\n\n.furin-copy-morph__icono { width: 22px; height: 22px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; overflow: visible; }\n.furin-copy-morph__atras { transform-box: fill-box; transform-origin: center; transition: opacity 0.3s ease, transform 0.45s cubic-bezier(0.34, 1.4, 0.64, 1); }\n.is-copiado .furin-copy-morph__atras { opacity: 0; transform: translate(2px, 2px) scale(0.7); }\n.furin-copy-morph__frente { filter: drop-shadow(0 0 0 transparent); transition: filter 0.4s; }\n.is-copiado .furin-copy-morph__frente { filter: drop-shadow(0 0 4px color-mix(in srgb, var(--furin-copy-ok) 70%, transparent)); }\n\n/* Etiqueta: los tres textos comparten celda, así el botón no cambia de ancho; el activo sube desde abajo. */\n.furin-copy-morph__etq { display: inline-grid; overflow: hidden; height: 1.3em; align-items: center; justify-items: start; }\n.furin-copy-morph__etq > span { grid-area: 1 / 1; white-space: nowrap; opacity: 0; transform: translateY(110%); transition: transform 0.45s cubic-bezier(0.22, 1.1, 0.36, 1), opacity 0.25s; }\n.furin-copy-morph__etq > .is-on { opacity: 1; transform: none; }\n.furin-copy-morph__boton:not(.is-idle) .furin-copy-morph__etq > :not(.is-on) { transform: translateY(-110%); }\n\n@keyframes furin-copy-morph-sacude { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 60% { transform: translateX(4px); } }\n@media (prefers-reduced-motion: reduce) {\n  .furin-copy-morph__boton, .furin-copy-morph__atras, .furin-copy-morph__etq > span, .furin-copy-morph__frente { transition: none !important; animation: none !important; }\n}\n",
      "type": "registry:file",
      "target": "components/furin/copy-morph/CopyMorph.css"
    }
  ]
}
