feat(preview): fix button layout and add mouse-following zoom

- Move close (X) button to far right, zoom button left of X
- File info (name + size) occupies the left of the top bar
- Zoom now follows the mouse cursor: transform-origin updates in
  real-time as the mouse moves, so 2.5× scale always magnifies
  the area under the pointer
- overflow-hidden on preview area keeps the scaled image contained

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-11 09:39:27 +02:00
parent 310ba1aff6
commit a7f4d6bc1e
@@ -40,7 +40,10 @@ function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { f
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [objectUrl, setObjectUrl] = useState<string | null>(null); const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [zoomed, setZoomed] = useState(false); const [zoomed, setZoomed] = useState(false);
// Posição do mouse como % dentro da área da imagem (para o zoom seguir o cursor)
const [mouseOrigin, setMouseOrigin] = useState({ x: 50, y: 50 });
const captionRef = useRef<HTMLInputElement>(null); const captionRef = useRef<HTMLInputElement>(null);
const previewAreaRef = useRef<HTMLDivElement>(null);
const isImage = file.type.startsWith('image/'); const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/'); const isVideo = file.type.startsWith('video/');
@@ -51,6 +54,7 @@ function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { f
setCaption(''); setCaption('');
setSending(false); setSending(false);
setZoomed(false); setZoomed(false);
setMouseOrigin({ x: 50, y: 50 });
setTimeout(() => captionRef.current?.focus(), 250); setTimeout(() => captionRef.current?.focus(), 250);
return () => URL.revokeObjectURL(url); return () => URL.revokeObjectURL(url);
}, [file]); }, [file]);
@@ -74,6 +78,17 @@ function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { f
if (e.key === 'Enter') { e.preventDefault(); handleSend(); } if (e.key === 'Enter') { e.preventDefault(); handleSend(); }
}; };
// Atualiza a origem do zoom conforme o mouse se move na área de preview
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isImage) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setMouseOrigin({ x, y });
}, [isImage]);
const toggleZoom = useCallback(() => setZoomed(z => !z), []);
return ( return (
<motion.div <motion.div
key="file-preview-overlay" key="file-preview-overlay"
@@ -86,33 +101,39 @@ function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { f
> >
{/* ── Top bar ── */} {/* ── Top bar ── */}
<div className="flex items-center gap-3 px-4 py-3 bg-[#202c33] shrink-0 shadow-md"> <div className="flex items-center gap-3 px-4 py-3 bg-[#202c33] shrink-0 shadow-md">
<button {/* Info do arquivo — lado esquerdo */}
onClick={onCancel}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[#e9edef] text-sm font-medium truncate">{file.name}</p> <p className="text-[#e9edef] text-sm font-medium truncate">{file.name}</p>
<p className="text-[#8696a0] text-xs">{getFileLabel(file.type)} · {formatFileSize(file.size)}</p> <p className="text-[#8696a0] text-xs">{getFileLabel(file.type)} · {formatFileSize(file.size)}</p>
</div> </div>
{isImage && ( {/* Botões — lado direito: zoom (se imagem) → fechar */}
<div className="flex items-center gap-1 shrink-0">
{isImage && (
<button
onClick={toggleZoom}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
title={zoomed ? 'Zoom normal' : 'Ampliar'}
>
{zoomed ? <ZoomOut className="w-5 h-5" /> : <ZoomIn className="w-5 h-5" />}
</button>
)}
<button <button
onClick={() => setZoomed(z => !z)} onClick={onCancel}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors shrink-0" className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
title={zoomed ? 'Zoom normal' : 'Ampliar'} title="Fechar"
> >
{zoomed ? <ZoomOut className="w-5 h-5" /> : <ZoomIn className="w-5 h-5" />} <X className="w-5 h-5" />
</button> </button>
)} </div>
</div> </div>
{/* ── Preview area ── */} {/* ── Preview area ── */}
<div <div
className="flex-1 flex items-center justify-center min-h-0 overflow-auto p-4" ref={previewAreaRef}
onClick={() => isImage && setZoomed(z => !z)} className="flex-1 flex items-center justify-center min-h-0 overflow-hidden p-4"
onMouseMove={handleMouseMove}
onClick={isImage ? toggleZoom : undefined}
style={{ cursor: isImage ? (zoomed ? 'zoom-out' : 'zoom-in') : 'default' }} style={{ cursor: isImage ? (zoomed ? 'zoom-out' : 'zoom-in') : 'default' }}
> >
{isImage && objectUrl && ( {isImage && objectUrl && (
@@ -122,12 +143,26 @@ function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { f
initial={{ scale: 0.94, opacity: 0 }} initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }} transition={{ duration: 0.22 }}
style={zoomed
? { maxWidth: 'none', maxHeight: 'none', width: 'auto', height: 'auto', cursor: 'zoom-out' }
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', cursor: 'zoom-in' }
}
className="rounded-lg shadow-2xl select-none"
draggable={false} draggable={false}
className="select-none rounded-lg shadow-2xl"
style={zoomed ? {
// Zoom 2.5× centrado no cursor
transform: `scale(2.5)`,
transformOrigin: `${mouseOrigin.x}% ${mouseOrigin.y}%`,
transition: 'transform-origin 0s', // origin segue o mouse instantaneamente
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
objectFit: 'contain',
cursor: 'zoom-out',
} : {
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
objectFit: 'contain',
cursor: 'zoom-in',
transition: 'transform 0.2s ease',
transform: 'scale(1)',
transformOrigin: 'center center',
}}
/> />
)} )}