{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "frame-markup",
  "title": "Frame Markup",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Rabisco sobre um quadro de vídeo ou imagem que sai como PNG, com desfazer.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@visant/signature-pad",
    "@visant/css-color"
  ],
  "files": [
    {
      "path": "registry/visant/ui/frame-markup.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Check, Eraser, Undo2 } from \"lucide-react\";\n\nimport { getStrokeOutline, outlineToPath, type InkPoint } from \"@/components/ui/signature-pad\";\nimport { resolveCssColor } from \"@/lib/css-color\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Frame Markup — rabiscar por cima de um quadro e sair com um PNG.\n *\n * Colhido do Estúdio (jaques-os), onde existia duas vezes: o rabisco do painel\n * do agente (sobre o quadro parado do vídeo, vira anexo) e o do editor (sobre a\n * imagem do quadro, vira nota). Os dois resolviam \"aponta aqui\" melhor que\n * qualquer frase: \"esse canto está escuro\" não diz qual canto.\n *\n * ── O que não é óbvio ──────────────────────────────────────────────────────\n *\n * 1. **O vídeo é congelado na montagem.** O quadro é copiado para um canvas\n *    assim que o componente abre. Se o vídeo continuar tocando atrás, o rabisco\n *    segue apontando para o quadro em que a pessoa pensou, e o PNG sai igual\n *    ao que ela viu.\n * 2. **Quadro de outra origem não quebra o export.** Vídeo ou imagem sem CORS\n *    \"suja\" o canvas, e o `toBlob` explodiria só no fim, com o rabisco pronto.\n *    A sujeira é testada na montagem: o fundo vira o `--muted` do tema, o\n *    rabisco continua valendo, e `frameLost` avisa quem chamou.\n * 3. **A tinta é `var(--brand)` resolvida na hora de exportar.** Na tela o\n *    traço é `fill-[var(--brand)]` e acompanha o tema; o PNG precisa de cor\n *    concreta, então ela é lida do token (via `css-color`) no clique de\n *    concluir, já com o tema que estiver valendo.\n * 4. **O motor de traço é o do `signature-pad`.** Mesma tinta de pena: afina\n *    na velocidade, engrossa na pressão da caneta, tampa redonda. Os pontos\n *    moram no espaço de pixels do quadro exportado, então o traço da tela e o\n *    do PNG são o mesmo caminho.\n *\n * Teclado: Ctrl/Cmd+Z desfaz, Delete limpa (e Ctrl/Cmd+Z traz de volta),\n * Esc cancela, Ctrl/Cmd+Enter conclui.\n */\n\nexport type FrameSource = HTMLVideoElement | HTMLImageElement | HTMLCanvasElement | string | null;\n\nexport interface MarkupStroke {\n  points: InkPoint[];\n}\n\nexport interface FrameMarkupResult {\n  png: Blob;\n  /** Tamanho do PNG; os pontos dos traços estão neste espaço. */\n  width: number;\n  height: number;\n  strokes: MarkupStroke[];\n  /** O quadro não pôde ser lido (outra origem sem CORS): o PNG tem só o rabisco. */\n  frameLost: boolean;\n}\n\nexport interface FrameMarkupProps {\n  /** O quadro: vídeo (congela o quadro atual), imagem, canvas ou URL de imagem. */\n  source?: FrameSource;\n  onDone: (result: FrameMarkupResult) => void;\n  onCancel?: () => void;\n  /** Lado maior do PNG. O quadro é reduzido até caber. */\n  maxSize?: number;\n  /** Proporção quando não há quadro (ou enquanto a imagem carrega). */\n  fallbackSize?: { width: number; height: number };\n  /** Espessura do traço em fração do lado maior. */\n  strokeSize?: number;\n  label?: string;\n  hint?: string;\n  doneLabel?: string;\n  cancelLabel?: string;\n  undoLabel?: string;\n  clearLabel?: string;\n  autoFocus?: boolean;\n  className?: string;\n}\n\nconst foco = \"focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\";\nconst botaoIcone = cn(\n  \"grid size-9 shrink-0 cursor-pointer place-items-center rounded-[var(--r-control)] border-0 bg-transparent p-0 text-muted-foreground\",\n  \"[transition:background-color_var(--dur-fast)_var(--ease-out),color_var(--dur-fast)_var(--ease-out)] motion-reduce:transition-none\",\n  \"hover:enabled:bg-muted hover:enabled:text-foreground disabled:cursor-default disabled:opacity-45 pointer-coarse:size-11 [&_svg]:size-[18px]\",\n  foco,\n);\nconst botaoTexto = cn(\n  \"inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-[var(--r-control)] border px-3.5 text-sm font-medium\",\n  \"[transition:background-color_var(--dur-fast)_var(--ease-out),opacity_var(--dur-fast)_var(--ease-out)] motion-reduce:transition-none\",\n  \"disabled:cursor-default disabled:opacity-45 pointer-coarse:h-11 [&_svg]:size-4\",\n  foco,\n);\n\nfunction dimensoes(s: FrameSource): { width: number; height: number } | null {\n  if (!s || typeof s === \"string\") return null;\n  if (s instanceof HTMLVideoElement) return s.videoWidth ? { width: s.videoWidth, height: s.videoHeight } : null;\n  if (s instanceof HTMLImageElement) return s.naturalWidth ? { width: s.naturalWidth, height: s.naturalHeight } : null;\n  return s.width ? { width: s.width, height: s.height } : null;\n}\n\nfunction caminho(s: MarkupStroke, size: number, last = true) {\n  const caneta = s.points.some((p) => p.p > 0);\n  return outlineToPath(\n    getStrokeOutline(s.points, {\n      size,\n      simulatePressure: !caneta,\n      taperStart: size * 1.5,\n      taperEnd: caneta ? 0 : size * 4,\n      last,\n    }),\n  );\n}\n\nconst corConcreta = (token: string, el: Element) => {\n  const [r, g, b] = resolveCssColor(token, el);\n  return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;\n};\n\nexport function FrameMarkup({\n  source = null,\n  onDone,\n  onCancel,\n  maxSize = 1280,\n  fallbackSize = { width: 1280, height: 720 },\n  // Mais grosso que uma assinatura: o traço aqui aponta, e precisa ler na miniatura do anexo.\n  strokeSize = 1 / 150,\n  label = \"Rabisco sobre o quadro\",\n  hint = \"Desenhe por cima para apontar\",\n  doneLabel = \"Concluir\",\n  cancelLabel = \"Cancelar\",\n  undoLabel = \"Desfazer\",\n  clearLabel = \"Limpar\",\n  autoFocus = true,\n  className,\n}: FrameMarkupProps) {\n  const uid = React.useId();\n  const raiz = React.useRef<HTMLDivElement>(null);\n  const palco = React.useRef<HTMLDivElement>(null);\n  const fundo = React.useRef<HTMLCanvasElement>(null);\n  const aoVivo = React.useRef<SVGPathElement>(null);\n  const atual = React.useRef<{ id: number; stroke: MarkupStroke; stamp: number } | null>(null);\n\n  const [tam, setTam] = React.useState(() => {\n    const d = dimensoes(source) ?? fallbackSize;\n    const k = Math.min(1, maxSize / Math.max(d.width, d.height));\n    return { width: Math.round(d.width * k), height: Math.round(d.height * k) };\n  });\n  const [temQuadro, setTemQuadro] = React.useState(false);\n  const [perdido, setPerdido] = React.useState(false);\n  const [hist, setHist] = React.useState<{ past: MarkupStroke[][]; present: MarkupStroke[] }>({ past: [], present: [] });\n  const [desenhando, setDesenhando] = React.useState(false);\n  const [status, setStatus] = React.useState(\"\");\n  const tracos = hist.present;\n  const espessura = Math.max(2, Math.max(tam.width, tam.height) * strokeSize);\n\n  // Congela o quadro (nota 1) e testa se ele pode ser exportado (nota 2).\n  React.useEffect(() => {\n    let vivo = true;\n    const pintar = (fonte: CanvasImageSource, w: number, h: number) => {\n      if (!vivo) return;\n      const k = Math.min(1, maxSize / Math.max(w, h));\n      const W = Math.round(w * k);\n      const H = Math.round(h * k);\n      setTam({ width: W, height: H });\n      const c = fundo.current;\n      if (!c) return;\n      c.width = W;\n      c.height = H;\n      const g = c.getContext(\"2d\");\n      if (!g) return;\n      try {\n        g.drawImage(fonte, 0, 0, W, H);\n        g.getImageData(0, 0, 1, 1); // lança se o canvas ficou sujo\n        setTemQuadro(true);\n        setPerdido(false);\n      } catch {\n        g.clearRect(0, 0, W, H);\n        setTemQuadro(false);\n        setPerdido(true);\n      }\n    };\n    if (typeof source === \"string\") {\n      const img = new Image();\n      img.crossOrigin = \"anonymous\";\n      img.onload = () => pintar(img, img.naturalWidth, img.naturalHeight);\n      img.onerror = () => vivo && setPerdido(true);\n      img.src = source;\n    } else {\n      const d = dimensoes(source);\n      if (source && d) pintar(source, d.width, d.height);\n    }\n    return () => {\n      vivo = false;\n    };\n  }, [source, maxSize]);\n\n  React.useEffect(() => {\n    if (autoFocus) palco.current?.focus({ preventScroll: true });\n  }, [autoFocus]);\n\n  const gravar = (prox: MarkupStroke[]) => setHist((h) => ({ past: [...h.past, h.present].slice(-60), present: prox }));\n  const desfazer = () => {\n    if (!hist.past.length || atual.current) return;\n    setHist((h) => (h.past.length ? { past: h.past.slice(0, -1), present: h.past[h.past.length - 1]! } : h));\n    setStatus(\"Desfeito\");\n  };\n  const limpar = () => {\n    if (!tracos.length || atual.current) return;\n    gravar([]);\n    setStatus(\"Rabisco apagado. Desfazer traz de volta.\");\n  };\n\n  function paraQuadro(e: { clientX: number; clientY: number }) {\n    // Razão do retângulo de tela: funciona com o quadro escalado por CSS ou transform.\n    const r = palco.current!.getBoundingClientRect();\n    return { x: ((e.clientX - r.left) / r.width) * tam.width, y: ((e.clientY - r.top) / r.height) * tam.height };\n  }\n  function pintarAoVivo() {\n    const a = atual.current;\n    if (a && aoVivo.current) aoVivo.current.setAttribute(\"d\", caminho(a.stroke, espessura, false));\n  }\n\n  function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {\n    if (atual.current || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    e.preventDefault();\n    palco.current?.focus({ preventScroll: true });\n    e.currentTarget.setPointerCapture(e.pointerId);\n    const { x, y } = paraQuadro(e);\n    const caneta = e.pointerType === \"pen\" && e.pressure > 0;\n    atual.current = { id: e.pointerId, stamp: e.nativeEvent.timeStamp, stroke: { points: [{ x, y, p: caneta ? e.pressure : -1, t: 0 }] } };\n    setDesenhando(true);\n    pintarAoVivo();\n  }\n  function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {\n    const a = atual.current;\n    if (!a || a.id !== e.pointerId) return;\n    const juntos = typeof e.nativeEvent.getCoalescedEvents === \"function\" ? e.nativeEvent.getCoalescedEvents() : [];\n    for (const s of juntos.length ? juntos : [e.nativeEvent]) {\n      const { x, y } = paraQuadro(s);\n      const caneta = s.pointerType === \"pen\" && s.pressure > 0;\n      const ult = a.stroke.points[a.stroke.points.length - 1]!.t;\n      a.stroke.points.push({ x, y, p: caneta ? s.pressure : -1, t: Math.max(ult, s.timeStamp - a.stamp) });\n    }\n    pintarAoVivo();\n  }\n  function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {\n    const a = atual.current;\n    if (!a || a.id !== e.pointerId) return;\n    atual.current = null;\n    gravar([...tracos, a.stroke]);\n    aoVivo.current?.setAttribute(\"d\", \"\");\n    setDesenhando(false);\n    setStatus(`${tracos.length + 1} ${tracos.length ? \"traços\" : \"traço\"}`);\n  }\n\n  const concluir = () => {\n    if (!tracos.length || !raiz.current) return;\n    const c = document.createElement(\"canvas\");\n    c.width = tam.width;\n    c.height = tam.height;\n    const g = c.getContext(\"2d\");\n    if (!g) return;\n    if (temQuadro && fundo.current) g.drawImage(fundo.current, 0, 0);\n    else {\n      g.fillStyle = corConcreta(\"var(--muted)\", raiz.current);\n      g.fillRect(0, 0, c.width, c.height);\n    }\n    g.fillStyle = corConcreta(\"var(--brand)\", raiz.current);\n    for (const s of tracos) g.fill(new Path2D(caminho(s, espessura)));\n    const strokes = tracos.map((s) => ({ points: s.points.map((p) => ({ ...p })) }));\n    c.toBlob((png) => {\n      if (png) onDone({ png, width: c.width, height: c.height, strokes, frameLost: perdido });\n    }, \"image/png\");\n  };\n\n  function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {\n    const mod = e.metaKey || e.ctrlKey;\n    if (e.key === \"Escape\" && onCancel) {\n      e.preventDefault();\n      e.stopPropagation();\n      onCancel();\n    } else if (mod && e.key.toLowerCase() === \"z\") {\n      e.preventDefault();\n      desfazer();\n    } else if (mod && e.key === \"Enter\") {\n      e.preventDefault();\n      concluir();\n    } else if (!mod && (e.key === \"Delete\" || e.key === \"Backspace\") && e.target === palco.current) {\n      e.preventDefault();\n      limpar();\n    }\n  }\n\n  const vazio = tracos.length === 0;\n\n  return (\n    <div ref={raiz} className={cn(\"flex w-full min-w-0 flex-col gap-2.5 text-foreground\", className)} onKeyDown={onKeyDown}>\n      <div\n        ref={palco}\n        tabIndex={0}\n        role=\"img\"\n        aria-roledescription=\"área de desenho\"\n        aria-label={`${label}. ${vazio ? \"Sem traços.\" : `${tracos.length} ${tracos.length === 1 ? \"traço\" : \"traços\"}.`}`}\n        aria-describedby={`${uid}-teclas`}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        style={{ aspectRatio: `${tam.width} / ${tam.height}` }}\n        className={cn(\n          \"relative isolate w-full cursor-crosshair touch-none select-none overflow-hidden rounded-[var(--r-surface)] border bg-muted\",\n          \"[transition:border-color_var(--dur-fast)_var(--ease-out)] motion-reduce:transition-none\",\n          desenhando ? \"border-foreground/40\" : \"border-border\",\n          foco,\n        )}\n      >\n        <canvas ref={fundo} aria-hidden=\"true\" className={cn(\"absolute inset-0 size-full\", !temQuadro && \"invisible\")} />\n        <svg\n          viewBox={`0 0 ${tam.width} ${tam.height}`}\n          preserveAspectRatio=\"none\"\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 size-full fill-[var(--brand)]\"\n        >\n          {tracos.map((s, i) => (\n            <path key={i} d={caminho(s, espessura)} />\n          ))}\n          <path ref={aoVivo} />\n        </svg>\n        {vazio && !desenhando ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-[var(--r-control)] bg-background/85 px-2.5 py-1 text-xs text-muted-foreground\"\n          >\n            {hint}\n          </span>\n        ) : null}\n      </div>\n\n      <div className=\"flex flex-wrap items-center gap-1\">\n        <button type=\"button\" className={botaoIcone} aria-label={undoLabel} title={undoLabel} onClick={desfazer} disabled={!hist.past.length}>\n          <Undo2 aria-hidden=\"true\" strokeWidth={1.75} />\n        </button>\n        <button type=\"button\" className={botaoIcone} aria-label={clearLabel} title={clearLabel} onClick={limpar} disabled={vazio}>\n          <Eraser aria-hidden=\"true\" strokeWidth={1.75} />\n        </button>\n        {perdido ? <span className=\"ml-1 text-xs text-muted-foreground\">O quadro não pôde ser lido; vai só o rabisco.</span> : null}\n        <span className=\"min-w-0 flex-1\" />\n        {onCancel ? (\n          <button type=\"button\" className={cn(botaoTexto, \"border-transparent bg-transparent text-foreground hover:bg-muted\")} onClick={onCancel}>\n            {cancelLabel}\n          </button>\n        ) : null}\n        <button\n          type=\"button\"\n          className={cn(botaoTexto, \"border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-foreground)] hover:enabled:opacity-90\")}\n          onClick={concluir}\n          disabled={vazio}\n        >\n          <Check aria-hidden=\"true\" />\n          {doneLabel}\n        </button>\n      </div>\n\n      <p id={`${uid}-teclas`} className=\"sr-only\">\n        Desenhe com mouse, caneta ou dedo. Control ou Command mais Z desfaz, Delete limpa, Esc cancela, Control ou Command mais Enter conclui.\n      </p>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {status}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Apontar algo num quadro de vídeo ou imagem para revisão",
      "evite": "Assinatura: signature-pad; desenho com camadas e formas"
    }
  },
  "categories": [
    "controles",
    "desenho",
    "anotacao",
    "video",
    "revisao",
    "feedback"
  ],
  "type": "registry:ui"
}