Adiciona anotações visuais
Implementa anotação sobre screenshots capturados: - Adiciona AnnotationEditor para desenhar setas, círculos e textos sobre imagens. - Integra editor com ScreenshotGallery para editar capturas existentes. - Atualiza gallery para suportar abertura do editor por item e salvar as annotações como URL anotado. X-Lovable-Edit-ID: edt-c5fc57ba-5c2a-47a2-91f7-257401616673
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
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">
|
||||
@@ -22,6 +36,13 @@ export function ScreenshotGallery() {
|
||||
<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`}
|
||||
@@ -45,5 +66,14 @@ export function ScreenshotGallery() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingIndex !== null && screenshots[editingIndex] && (
|
||||
<AnnotationEditor
|
||||
imageSrc={screenshots[editingIndex]}
|
||||
onSave={handleSaveAnnotated}
|
||||
onClose={() => setEditingIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user