From fbc279ff0a412b24d046868084de8acafbdef707 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:55:52 +0000 Subject: [PATCH] Changes --- src/components/AnnotationEditor.tsx | 263 +++++++++++++++++++++++++++ src/components/ScreenshotGallery.tsx | 106 +++++++---- 2 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 src/components/AnnotationEditor.tsx diff --git a/src/components/AnnotationEditor.tsx b/src/components/AnnotationEditor.tsx new file mode 100644 index 0000000..d7031b1 --- /dev/null +++ b/src/components/AnnotationEditor.tsx @@ -0,0 +1,263 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + X, ArrowRight, Circle, Type, Undo2, Download, Check, +} from 'lucide-react'; + +type Tool = 'arrow' | 'circle' | 'text'; + +interface Annotation { + type: Tool; + x1: number; y1: number; + x2: number; y2: number; + text?: string; +} + +interface AnnotationEditorProps { + imageSrc: string; + onSave: (annotatedDataUrl: string) => void; + onClose: () => void; +} + +export function AnnotationEditor({ imageSrc, onSave, onClose }: AnnotationEditorProps) { + const canvasRef = useRef(null); + const imgRef = useRef(null); + const [tool, setTool] = useState('arrow'); + const [annotations, setAnnotations] = useState([]); + const [drawing, setDrawing] = useState(false); + const [start, setStart] = useState({ x: 0, y: 0 }); + const [current, setCurrent] = useState({ x: 0, y: 0 }); + const [pendingText, setPendingText] = useState<{ x: number; y: number } | null>(null); + const [textValue, setTextValue] = useState(''); + const [canvasSize, setCanvasSize] = useState({ w: 800, h: 450 }); + + // Load image + useEffect(() => { + const img = new Image(); + img.onload = () => { + imgRef.current = img; + // Fit within container while preserving aspect ratio + const maxW = Math.min(900, window.innerWidth - 80); + const maxH = Math.min(600, window.innerHeight - 200); + const scale = Math.min(maxW / img.width, maxH / img.height, 1); + setCanvasSize({ w: Math.round(img.width * scale), h: Math.round(img.height * scale) }); + }; + img.src = imageSrc; + }, [imageSrc]); + + const getPos = useCallback((e: React.MouseEvent) => { + const rect = canvasRef.current!.getBoundingClientRect(); + return { + x: ((e.clientX - rect.left) / rect.width) * canvasSize.w, + y: ((e.clientY - rect.top) / rect.height) * canvasSize.h, + }; + }, [canvasSize]); + + // Draw everything + const redraw = useCallback((extra?: Annotation) => { + const ctx = canvasRef.current?.getContext('2d'); + const img = imgRef.current; + if (!ctx || !img) return; + + ctx.clearRect(0, 0, canvasSize.w, canvasSize.h); + ctx.drawImage(img, 0, 0, canvasSize.w, canvasSize.h); + + const allAnnotations = extra ? [...annotations, extra] : annotations; + allAnnotations.forEach((a) => drawAnnotation(ctx, a)); + }, [annotations, canvasSize]); + + useEffect(() => { redraw(); }, [redraw]); + + const drawAnnotation = (ctx: CanvasRenderingContext2D, a: Annotation) => { + ctx.strokeStyle = '#ef4444'; + ctx.fillStyle = '#ef4444'; + ctx.lineWidth = 2.5; + ctx.font = 'bold 16px monospace'; + + if (a.type === 'arrow') { + const dx = a.x2 - a.x1; + const dy = a.y2 - a.y1; + const angle = Math.atan2(dy, dx); + const len = Math.sqrt(dx * dx + dy * dy); + if (len < 3) return; + + ctx.beginPath(); + ctx.moveTo(a.x1, a.y1); + ctx.lineTo(a.x2, a.y2); + ctx.stroke(); + + // Arrowhead + const headLen = Math.min(14, len * 0.3); + ctx.beginPath(); + ctx.moveTo(a.x2, a.y2); + ctx.lineTo(a.x2 - headLen * Math.cos(angle - 0.4), a.y2 - headLen * Math.sin(angle - 0.4)); + ctx.lineTo(a.x2 - headLen * Math.cos(angle + 0.4), a.y2 - headLen * Math.sin(angle + 0.4)); + ctx.closePath(); + ctx.fill(); + } else if (a.type === 'circle') { + const rx = Math.abs(a.x2 - a.x1) / 2; + const ry = Math.abs(a.y2 - a.y1) / 2; + const cx = (a.x1 + a.x2) / 2; + const cy = (a.y1 + a.y2) / 2; + ctx.beginPath(); + ctx.ellipse(cx, cy, Math.max(rx, 2), Math.max(ry, 2), 0, 0, Math.PI * 2); + ctx.stroke(); + } else if (a.type === 'text' && a.text) { + // Background + const metrics = ctx.measureText(a.text); + const pad = 4; + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(a.x1 - pad, a.y1 - 16 - pad, metrics.width + pad * 2, 20 + pad * 2); + ctx.fillStyle = '#ffffff'; + ctx.fillText(a.text, a.x1, a.y1); + } + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (pendingText) return; + const pos = getPos(e); + if (tool === 'text') { + setPendingText(pos); + setTextValue(''); + return; + } + setDrawing(true); + setStart(pos); + setCurrent(pos); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!drawing) return; + const pos = getPos(e); + setCurrent(pos); + redraw({ type: tool, x1: start.x, y1: start.y, x2: pos.x, y2: pos.y }); + }; + + const handleMouseUp = () => { + if (!drawing) return; + setDrawing(false); + setAnnotations((prev) => [ + ...prev, + { type: tool, x1: start.x, y1: start.y, x2: current.x, y2: current.y }, + ]); + }; + + const confirmText = () => { + if (!pendingText || !textValue.trim()) { + setPendingText(null); + return; + } + setAnnotations((prev) => [ + ...prev, + { type: 'text', x1: pendingText.x, y1: pendingText.y, x2: pendingText.x, y2: pendingText.y, text: textValue.trim() }, + ]); + setPendingText(null); + setTextValue(''); + }; + + const undo = () => setAnnotations((prev) => prev.slice(0, -1)); + + const handleSave = () => { + // Render full resolution + const img = imgRef.current; + if (!img) return; + const offscreen = document.createElement('canvas'); + offscreen.width = img.width; + offscreen.height = img.height; + const ctx = offscreen.getContext('2d')!; + const scaleX = img.width / canvasSize.w; + const scaleY = img.height / canvasSize.h; + + ctx.drawImage(img, 0, 0); + ctx.save(); + ctx.scale(scaleX, scaleY); + annotations.forEach((a) => drawAnnotation(ctx, a)); + ctx.restore(); + + onSave(offscreen.toDataURL('image/png')); + }; + + const tools: { id: Tool; icon: typeof ArrowRight; label: string }[] = [ + { id: 'arrow', icon: ArrowRight, label: 'Seta' }, + { id: 'circle', icon: Circle, label: 'Círculo' }, + { id: 'text', icon: Type, label: 'Texto' }, + ]; + + return ( +
+
+ {/* Toolbar */} +
+
+ {tools.map((t) => ( + + ))} +
+ +
+
+ + +
+
+ + {/* Canvas area */} +
+ + + {/* Text input overlay */} + {pendingText && ( +
+ setTextValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && confirmText()} + className="h-7 w-40 text-xs" + placeholder="Digite o texto…" + /> + +
+ )} +
+ +

+ {tool === 'arrow' && 'Clique e arraste para desenhar uma seta'} + {tool === 'circle' && 'Clique e arraste para desenhar um círculo'} + {tool === 'text' && 'Clique no ponto desejado para adicionar texto'} +

+
+
+ ); +} diff --git a/src/components/ScreenshotGallery.tsx b/src/components/ScreenshotGallery.tsx index 6d9cf19..5a15743 100644 --- a/src/components/ScreenshotGallery.tsx +++ b/src/components/ScreenshotGallery.tsx @@ -1,49 +1,79 @@ -import { Camera, X, Download } from 'lucide-react'; +import { useState } from 'react'; +import { X, Download, Pencil } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useModelStore } from '@/stores/useModelStore'; +import { AnnotationEditor } from '@/components/AnnotationEditor'; export function ScreenshotGallery() { - const { screenshots, removeScreenshot, clearScreenshots } = useModelStore(); + const { screenshots, removeScreenshot, clearScreenshots, addScreenshot } = useModelStore(); + const [editingIndex, setEditingIndex] = useState(null); - if (screenshots.length === 0) return null; + if (screenshots.length === 0 && editingIndex === null) return null; + + const handleSaveAnnotated = (dataUrl: string) => { + if (editingIndex !== null) { + // Replace the original screenshot with the annotated version + useModelStore.setState((state) => ({ + screenshots: state.screenshots.map((s, i) => (i === editingIndex ? dataUrl : s)), + })); + setEditingIndex(null); + } + }; return ( -
-
-

- Capturas ({screenshots.length}) -

- -
-
- {screenshots.map((src, i) => ( -
- {`Captura -
- - - - + <> +
+
+

+ Capturas ({screenshots.length}) +

+ +
+
+ {screenshots.map((src, i) => ( +
+ {`Captura +
+ + + + + +
+
+ + #{i + 1} + +
-
- - #{i + 1} - -
-
- ))} + ))} +
-
+ + {editingIndex !== null && screenshots[editingIndex] && ( + setEditingIndex(null)} + /> + )} + ); }