This commit is contained in:
gpt-engineer-app[bot]
2026-02-27 01:55:52 +00:00
parent fbc20ae717
commit fbc279ff0a
2 changed files with 331 additions and 38 deletions
+263
View File
@@ -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<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const [tool, setTool] = useState<Tool>('arrow');
const [annotations, setAnnotations] = useState<Annotation[]>([]);
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-2xl max-w-[95vw] max-h-[95vh]">
{/* Toolbar */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
{tools.map((t) => (
<Button
key={t.id}
variant={tool === t.id ? 'default' : 'outline'}
size="sm"
className="gap-1.5 h-8 text-xs"
onClick={() => setTool(t.id)}
>
<t.icon className="h-3.5 w-3.5" />
{t.label}
</Button>
))}
<div className="w-px h-6 bg-border mx-1" />
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={undo} disabled={annotations.length === 0}>
<Undo2 className="h-3.5 w-3.5 mr-1" /> Desfazer
</Button>
</div>
<div className="flex items-center gap-1">
<Button variant="default" size="sm" className="h-8 text-xs gap-1" onClick={handleSave}>
<Check className="h-3.5 w-3.5" /> Salvar
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Canvas area */}
<div className="relative">
<canvas
ref={canvasRef}
width={canvasSize.w}
height={canvasSize.h}
className="rounded-lg border cursor-crosshair"
style={{ width: canvasSize.w, height: canvasSize.h }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
{/* Text input overlay */}
{pendingText && (
<div
className="absolute flex items-center gap-1"
style={{ left: (pendingText.x / canvasSize.w) * 100 + '%', top: (pendingText.y / canvasSize.h) * 100 + '%' }}
>
<Input
autoFocus
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && confirmText()}
className="h-7 w-40 text-xs"
placeholder="Digite o texto…"
/>
<Button size="sm" className="h-7 w-7 p-0" onClick={confirmText}>
<Check className="h-3 w-3" />
</Button>
</div>
)}
</div>
<p className="font-mono text-[10px] text-muted-foreground text-center">
{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'}
</p>
</div>
</div>
);
}
+68 -38
View File
@@ -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<number | null>(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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Capturas ({screenshots.length})
</h3>
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={clearScreenshots}>
Limpar
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{screenshots.map((src, i) => (
<div key={i} className="group relative rounded-lg border overflow-hidden bg-muted/30">
<img src={src} alt={`Captura ${i + 1}`} className="w-full aspect-video object-cover" />
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<a
href={src}
download={`TrackkSteelXR_capture_${i + 1}.png`}
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
>
<Download className="h-3 w-3 text-primary" />
</a>
<button
onClick={() => removeScreenshot(i)}
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
>
<X className="h-3 w-3 text-destructive" />
</button>
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Capturas ({screenshots.length})
</h3>
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={clearScreenshots}>
Limpar
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{screenshots.map((src, i) => (
<div key={i} className="group relative rounded-lg border overflow-hidden bg-muted/30">
<img src={src} alt={`Captura ${i + 1}`} className="w-full aspect-video object-cover" />
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setEditingIndex(i)}
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
title="Anotar"
>
<Pencil className="h-3 w-3 text-primary" />
</button>
<a
href={src}
download={`TrackkSteelXR_capture_${i + 1}.png`}
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
>
<Download className="h-3 w-3 text-primary" />
</a>
<button
onClick={() => removeScreenshot(i)}
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
>
<X className="h-3 w-3 text-destructive" />
</button>
</div>
<div className="absolute bottom-1 left-1">
<span className="font-mono text-[9px] bg-card/80 rounded px-1 py-0.5 text-muted-foreground">
#{i + 1}
</span>
</div>
</div>
<div className="absolute bottom-1 left-1">
<span className="font-mono text-[9px] bg-card/80 rounded px-1 py-0.5 text-muted-foreground">
#{i + 1}
</span>
</div>
</div>
))}
))}
</div>
</div>
</div>
{editingIndex !== null && screenshots[editingIndex] && (
<AnnotationEditor
imageSrc={screenshots[editingIndex]}
onSave={handleSaveAnnotated}
onClose={() => setEditingIndex(null)}
/>
)}
</>
);
}