Changes
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,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 { Button } from '@/components/ui/button';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
import { AnnotationEditor } from '@/components/AnnotationEditor';
|
||||||
|
|
||||||
export function ScreenshotGallery() {
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2">
|
||||||
<h3 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
<div className="flex items-center justify-between">
|
||||||
Capturas ({screenshots.length})
|
<h3 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
</h3>
|
Capturas ({screenshots.length})
|
||||||
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={clearScreenshots}>
|
</h3>
|
||||||
Limpar
|
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={clearScreenshots}>
|
||||||
</Button>
|
Limpar
|
||||||
</div>
|
</Button>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
</div>
|
||||||
{screenshots.map((src, i) => (
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div key={i} className="group relative rounded-lg border overflow-hidden bg-muted/30">
|
{screenshots.map((src, i) => (
|
||||||
<img src={src} alt={`Captura ${i + 1}`} className="w-full aspect-video object-cover" />
|
<div key={i} className="group relative rounded-lg border overflow-hidden bg-muted/30">
|
||||||
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<img src={src} alt={`Captura ${i + 1}`} className="w-full aspect-video object-cover" />
|
||||||
<a
|
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
href={src}
|
<button
|
||||||
download={`TrackkSteelXR_capture_${i + 1}.png`}
|
onClick={() => setEditingIndex(i)}
|
||||||
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
|
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
|
||||||
>
|
title="Anotar"
|
||||||
<Download className="h-3 w-3 text-primary" />
|
>
|
||||||
</a>
|
<Pencil className="h-3 w-3 text-primary" />
|
||||||
<button
|
</button>
|
||||||
onClick={() => removeScreenshot(i)}
|
<a
|
||||||
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
|
href={src}
|
||||||
>
|
download={`TrackkSteelXR_capture_${i + 1}.png`}
|
||||||
<X className="h-3 w-3 text-destructive" />
|
className="h-6 w-6 rounded bg-card/90 flex items-center justify-center"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
||||||
<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">
|
</div>
|
||||||
#{i + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{editingIndex !== null && screenshots[editingIndex] && (
|
||||||
|
<AnnotationEditor
|
||||||
|
imageSrc={screenshots[editingIndex]}
|
||||||
|
onSave={handleSaveAnnotated}
|
||||||
|
onClose={() => setEditingIndex(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user