import React, { useRef, useState, useEffect, useCallback } from 'react'; import { X, Save, RotateCcw, Trash2, Loader2, CheckCircle2, Minus, ArrowUpRight, Circle, Square, Type, Pen, MoveRight, Scissors, SeparatorVertical, Ruler } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; // ─── Tipos ──────────────────────────────────────────────────────── type ToolType = 'seta' | 'linha' | 'circulo' | 'retangulo' | 'texto' | 'livre' | 'vetor' | 'extracao' | 'linha_media' | 'escala'; interface Point { x: number; y: number; } interface Anotacao { id: string; tipo: ToolType; cor: string; espessura: number; // Para shapes geométricos inicio?: Point; fim?: Point; // Para texto posicao?: Point; texto?: string; // Para caminho livre pontos?: Point[]; // Para escala de calibração (distância real da linha em mm) mm?: number; } // ─── Constantes ─────────────────────────────────────────────────── const CORES = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#ffffff']; const FERRAMENTAS: { id: ToolType; icon: React.ReactNode; label: string }[] = [ { id: 'seta', icon: , label: 'SETA' }, { id: 'linha', icon: , label: 'LINHA' }, { id: 'circulo', icon: , label: 'CÍRCULO' }, { id: 'retangulo', icon: , label: 'RETÂNGULO' }, { id: 'texto', icon: , label: 'TEXTO' }, { id: 'livre', icon: , label: 'LIVRE' }, ]; // Planejamento visual: vetor de movimentação, marca de extração, linha média. const FERRAMENTAS_PLAN: { id: ToolType; icon: React.ReactNode; label: string }[] = [ { id: 'vetor', icon: , label: 'VETOR' }, { id: 'extracao', icon: , label: 'EXTRAÇÃO' }, { id: 'linha_media', icon: , label: 'LINHA MÉDIA' }, { id: 'escala', icon: , label: 'ESCALA' }, ]; let _idCounter = 0; function uid() { return `a_${Date.now()}_${++_idCounter}`; } // ─── Helpers de desenho SVG ─────────────────────────────────────── function renderShape(a: Anotacao, isPreview = false): React.ReactNode { const stroke = a.cor; const sw = a.espessura; const opacity = isPreview ? 0.7 : 1; if ((a.tipo === 'seta' || a.tipo === 'linha') && a.inicio && a.fim) { const { inicio: s, fim: e } = a; const dx = e.x - s.x, dy = e.y - s.y; const len = Math.sqrt(dx * dx + dy * dy); if (len < 2) return null; const ux = dx / len, uy = dy / len; const hw = sw * 4 + 4; const p1 = { x: e.x - ux * hw - uy * hw * 0.5, y: e.y - uy * hw + ux * hw * 0.5 }; const p2 = { x: e.x - ux * hw + uy * hw * 0.5, y: e.y - uy * hw - ux * hw * 0.5 }; return ( {a.tipo === 'seta' && ( )} ); } if (a.tipo === 'circulo' && a.inicio && a.fim) { const cx = (a.inicio.x + a.fim.x) / 2; const cy = (a.inicio.y + a.fim.y) / 2; const rx = Math.abs(a.fim.x - a.inicio.x) / 2; const ry = Math.abs(a.fim.y - a.inicio.y) / 2; return ; } if (a.tipo === 'retangulo' && a.inicio && a.fim) { const x = Math.min(a.inicio.x, a.fim.x); const y = Math.min(a.inicio.y, a.fim.y); const w = Math.abs(a.fim.x - a.inicio.x); const h = Math.abs(a.fim.y - a.inicio.y); return ; } if (a.tipo === 'texto' && a.posicao && a.texto) { return ( {a.texto} ); } if (a.tipo === 'livre' && a.pontos && a.pontos.length > 1) { const d = a.pontos.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '); return ; } // Vetor de movimentação: haste com barra de origem + cabeça larga (notação ortodôntica). if (a.tipo === 'vetor' && a.inicio && a.fim) { const { inicio: s, fim: e } = a; const dx = e.x - s.x, dy = e.y - s.y; const len = Math.sqrt(dx * dx + dy * dy); if (len < 2) return null; const ux = dx / len, uy = dy / len; const hw = sw * 5 + 5; const p1 = { x: e.x - ux * hw - uy * hw * 0.6, y: e.y - uy * hw + ux * hw * 0.6 }; const p2 = { x: e.x - ux * hw + uy * hw * 0.6, y: e.y - uy * hw - ux * hw * 0.6 }; const tb = sw * 3 + 3; const t1 = { x: s.x - uy * tb, y: s.y + ux * tb }; const t2 = { x: s.x + uy * tb, y: s.y - ux * tb }; return ( ); } // Marca de extração: X. if (a.tipo === 'extracao' && a.posicao) { const { x, y } = a.posicao; const r = sw * 2 + 3; return ( ); } // Linha média: referência vertical tracejada. if (a.tipo === 'linha_media' && a.posicao) { return ; } // Escala de calibração: linha tracejada com ticks perpendiculares nas pontas. if (a.tipo === 'escala' && a.inicio && a.fim) { const { inicio: s, fim: e } = a; const dx = e.x - s.x, dy = e.y - s.y; const len = Math.sqrt(dx * dx + dy * dy) || 1; const ux = dx / len, uy = dy / len, tk = 2; return ( ); } return null; } // ─── Componente principal ───────────────────────────────────────── interface Props { fotoId: string; fotoUrl: string; slotLabel: string; onClose: () => void; } export const PhotoAnnotator: React.FC = ({ fotoId, fotoUrl, slotLabel, onClose }) => { const toast = useToast(); const confirm = useConfirm(); const svgRef = useRef(null); const imgRef = useRef(null); const containerRef = useRef(null); const [anotacoes, setAnotacoes] = useState([]); const [preview, setPreview] = useState(null); const [ferramenta, setFerramenta] = useState('seta'); const [cor, setCor] = useState('#ef4444'); const [espessura, setEspessura] = useState(2); const [drawing, setDrawing] = useState(false); const [startPt, setStartPt] = useState(null); const [livrePoints, setLivrePoints] = useState([]); const [imgDim, setImgDim] = useState({ w: 0, h: 0, offX: 0, offY: 0 }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [textInput, setTextInput] = useState(''); const [textPos, setTextPos] = useState(null); // Carrega anotações existentes useEffect(() => { HybridBackend.getOrtoAnotacoes(fotoId).then(r => { if (r.success) setAnotacoes(r.anotacoes.map((a: any) => ({ ...a.dados, id: a.id }))); }).finally(() => setLoading(false)); }, [fotoId]); // Fecha com ESC useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [onClose]); // Calcula dimensões da imagem renderizada no container const updateImgDim = useCallback(() => { const img = imgRef.current; const container = containerRef.current; if (!img || !container) return; const cr = container.getBoundingClientRect(); const ir = img.getBoundingClientRect(); setImgDim({ w: ir.width, h: ir.height, offX: ir.left - cr.left, offY: ir.top - cr.top }); }, []); const getPoint = (e: React.MouseEvent | React.TouchEvent): Point => { const container = containerRef.current!; const rect = container.getBoundingClientRect(); const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; return { x: ((clientX - rect.left - imgDim.offX) / imgDim.w) * 100, y: ((clientY - rect.top - imgDim.offY) / imgDim.h) * 100, }; }; const onMouseDown = (e: React.MouseEvent) => { if (ferramenta === 'texto') { const pt = getPoint(e); setTextPos(pt); setTextInput(''); return; } // Ferramentas de planejamento por clique único if (ferramenta === 'extracao' || ferramenta === 'linha_media') { const pt = getPoint(e); setAnotacoes(prev => [...prev, { id: uid(), tipo: ferramenta, cor, espessura, posicao: pt }]); return; } setDrawing(true); const pt = getPoint(e); setStartPt(pt); if (ferramenta === 'livre') setLivrePoints([pt]); }; const onMouseMove = (e: React.MouseEvent) => { if (!drawing || !startPt) return; const pt = getPoint(e); if (ferramenta === 'livre') { setLivrePoints(prev => [...prev, pt]); setPreview({ id: 'prev', tipo: 'livre', cor, espessura, pontos: [...livrePoints, pt] }); } else { setPreview({ id: 'prev', tipo: ferramenta, cor, espessura, inicio: startPt, fim: pt }); } }; const onMouseUp = (e: React.MouseEvent) => { if (!drawing || !startPt) return; setDrawing(false); const pt = getPoint(e); // Calibração de escala: pergunta a distância real e guarda uma única referência. if (ferramenta === 'escala') { setPreview(null); setStartPt(null); setLivrePoints([]); const v = window.prompt('Distância REAL desta linha, em milímetros (mm):', '10'); const mm = v ? parseFloat(v.replace(',', '.')) : NaN; if (!isNaN(mm) && mm > 0) { setAnotacoes(prev => [...prev.filter(a => a.tipo !== 'escala'), { id: uid(), tipo: 'escala', cor, espessura, inicio: startPt, fim: pt, mm }]); } return; } const nova: Anotacao = ferramenta === 'livre' ? { id: uid(), tipo: 'livre', cor, espessura, pontos: livrePoints } : { id: uid(), tipo: ferramenta, cor, espessura, inicio: startPt, fim: pt }; setAnotacoes(prev => [...prev, nova]); setPreview(null); setStartPt(null); setLivrePoints([]); }; const confirmText = () => { if (!textPos || !textInput.trim()) { setTextPos(null); return; } setAnotacoes(prev => [...prev, { id: uid(), tipo: 'texto', cor, espessura, posicao: textPos, texto: textInput.toUpperCase() }]); setTextPos(null); setTextInput(''); }; const undo = () => setAnotacoes(prev => prev.slice(0, -1)); const clear = async () => { if (await confirm('LIMPAR TODAS AS ANOTAÇÕES?')) setAnotacoes([]); }; const save = async () => { setSaving(true); try { const res = await HybridBackend.saveOrtoAnotacoes(fotoId, anotacoes); if (!res.success) { toast.error('ERRO AO SALVAR.'); return; } toast.success('ANOTAÇÕES SALVAS!'); } catch { toast.error('ERRO AO SALVAR.'); } finally { setSaving(false); } }; const svgStyle: React.CSSProperties = { position: 'absolute', left: imgDim.offX, top: imgDim.offY, width: imgDim.w, height: imgDim.h, cursor: ferramenta === 'texto' ? 'text' : 'crosshair', userSelect: 'none', }; // Calibração: mm por pixel exibido (usa a linha de escala, se houver). const escalaAnot = anotacoes.find(a => a.tipo === 'escala'); let mmPerPx = 0; if (escalaAnot?.inicio && escalaAnot?.fim && escalaAnot.mm && imgDim.w > 0) { const dpx = Math.hypot((escalaAnot.fim.x - escalaAnot.inicio.x) / 100 * imgDim.w, (escalaAnot.fim.y - escalaAnot.inicio.y) / 100 * imgDim.h); if (dpx > 0) mmPerPx = escalaAnot.mm / dpx; } const midOf = (p?: Point, q?: Point) => (p && q ? { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } : null); return (
{/* Header */}
{slotLabel} — EDITOR DE ANOTAÇÕES
{/* Toolbar */}
{/* Ferramentas */}
{FERRAMENTAS.map(f => ( ))}
{/* Planejamento visual */}
PLANEJ. {FERRAMENTAS_PLAN.map(f => ( ))}
{/* Cores */}
{CORES.map(c => (
{/* Espessura */}
{[1, 2, 4].map((e, i) => ( ))}
{anotacoes.length} ANOTAÇÃO(ÕES)
{/* Canvas area */}
{loading ? ( ) : ( <> {slotLabel} {/* SVG overlay — viewBox em % para escalar com imagem */} {imgDim.w > 0 && ( {anotacoes.map(a => renderShape(a))} {preview && renderShape(preview, true)} )} {/* Rótulos de medida (mm) — fora do SVG p/ não distorcer com o viewBox */} {imgDim.w > 0 && anotacoes.map(a => { if (a.tipo === 'escala' && a.mm) { const m = midOf(a.inicio, a.fim); if (!m) return null; return
📏 {a.mm} mm
; } if (a.tipo === 'vetor' && mmPerPx > 0 && a.inicio && a.fim) { const m = midOf(a.inicio, a.fim)!; const dpx = Math.hypot((a.fim.x - a.inicio.x) / 100 * imgDim.w, (a.fim.y - a.inicio.y) / 100 * imgDim.h); return
{(dpx * mmPerPx).toFixed(1)} mm
; } return null; })} {/* Input de texto flutuante */} {textPos && (
e.stopPropagation()} onMouseDown={e => e.stopPropagation()} > setTextInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') confirmText(); if (e.key === 'Escape') setTextPos(null); }} placeholder="TEXTO..." className="bg-black/80 text-white font-bold text-sm px-3 py-2 rounded-lg border border-white/30 focus:outline-none focus:border-white w-48 uppercase" style={{ color: cor }} />
)} )}
{/* Instrução */}

{ferramenta === 'texto' ? 'CLIQUE NA IMAGEM PARA COLOCAR O TEXTO' : ferramenta === 'livre' ? 'CLIQUE E ARRASTE PARA DESENHAR LIVREMENTE' : ferramenta === 'extracao' ? 'CLIQUE NO DENTE A EXTRAIR (MARCA X)' : ferramenta === 'linha_media' ? 'CLIQUE PARA POSICIONAR A LINHA MÉDIA DE REFERÊNCIA' : ferramenta === 'vetor' ? `CLIQUE E ARRASTE: POSIÇÃO ATUAL → DESTINO${mmPerPx > 0 ? ' (MOSTRA MM)' : ' · DEFINA A ESCALA P/ VER MM'}` : ferramenta === 'escala' ? 'ARRASTE SOBRE UMA DISTÂNCIA CONHECIDA E INFORME O VALOR EM MM' : 'CLIQUE E ARRASTE PARA DESENHAR · ESC PARA FECHAR'}

); };