import { useState, useEffect, useCallback, useRef } from 'react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { Button, TooltipAnchor } from '@librechat/client'; import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react'; import { useLocalize } from '~/hooks'; const getQualityStyles = (quality: string): string => { if (quality === 'high') { return 'bg-green-100 text-green-800'; } if (quality === 'low') { return 'bg-orange-100 text-orange-800'; } return 'bg-gray-100 text-gray-800'; }; export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage, args, triggerRef, }: { isOpen: boolean; onOpenChange: (open: boolean) => void; src?: string; downloadImage: () => void; args?: { prompt?: string; quality?: string; size?: string; [key: string]: unknown; }; triggerRef?: React.RefObject; }) { const localize = useLocalize(); const [isPromptOpen, setIsPromptOpen] = useState(false); const [imageSize, setImageSize] = useState(null); // Zoom and pan state const [zoom, setZoom] = useState(1); const [panX, setPanX] = useState(0); const [panY, setPanY] = useState(0); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const containerRef = useRef(null); const imageRef = useRef(null); const closeButtonRef = useRef(null); const getImageSize = useCallback(async (url: string) => { try { const response = await fetch(url, { method: 'HEAD' }); const contentLength = response.headers.get('Content-Length'); if (contentLength) { const bytes = parseInt(contentLength, 10); return formatFileSize(bytes); } const fullResponse = await fetch(url); const blob = await fullResponse.blob(); return formatFileSize(blob.size); } catch (error) { console.error('Error getting image size:', error); return null; } }, []); const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; const resetZoom = useCallback(() => { setZoom(1); setPanX(0); setPanY(0); }, []); const getCursor = () => { if (zoom <= 1) return 'default'; return isDragging ? 'grabbing' : 'grab'; }; const handleDoubleClick = useCallback(() => { if (zoom > 1) { resetZoom(); } else { setZoom(2); } }, [zoom, resetZoom]); const handleWheel = useCallback( (e: React.WheelEvent) => { e.preventDefault(); if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.min(Math.max(zoom * zoomFactor, 1), 5); if (newZoom === zoom) return; if (newZoom === 1) { setZoom(1); setPanX(0); setPanY(0); return; } const containerCenterX = rect.width / 2; const containerCenterY = rect.height / 2; const zoomRatio = newZoom / zoom; const deltaX = (mouseX - containerCenterX - panX) * (zoomRatio - 1); const deltaY = (mouseY - containerCenterY - panY) * (zoomRatio - 1); setZoom(newZoom); setPanX(panX - deltaX); setPanY(panY - deltaY); }, [zoom, panX, panY], ); const handleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); if (zoom <= 1) return; setIsDragging(true); setDragStart({ x: e.clientX - panX, y: e.clientY - panY, }); }, [zoom, panX, panY], ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!isDragging || zoom <= 1) return; const newPanX = e.clientX - dragStart.x; const newPanY = e.clientY - dragStart.y; setPanX(newPanX); setPanY(newPanY); }, [isDragging, dragStart, zoom], ); const handleMouseUp = useCallback(() => { setIsDragging(false); }, []); // Handle click on empty areas to close (only if clicking overlay/content directly, not children) const handleBackgroundClick = useCallback( (e: React.MouseEvent) => { // Only close if clicking directly on overlay/content background if (e.target !== e.currentTarget) { return; } // Don't close if zoomed (user might be panning) if (zoom > 1) { return; } onOpenChange(false); }, [onOpenChange, zoom], ); useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (zoom > 1) { resetZoom(); } else { onOpenChange(false); } } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [resetZoom, onOpenChange, isOpen, zoom]); useEffect(() => { if (isOpen && src) { getImageSize(src).then(setImageSize); resetZoom(); } }, [isOpen, src, getImageSize, resetZoom]); useEffect(() => { if (zoom === 1) { setPanX(0); setPanY(0); } }, [zoom]); useEffect(() => { if (zoom === 1) { setPanX(0); setPanY(0); } }, [isPromptOpen, zoom]); // Lock body scroll when dialog is open useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [isOpen]); const imageDetailsLabel = isPromptOpen ? localize('com_ui_hide_image_details') : localize('com_ui_show_image_details'); // Calculate image max dimensions accounting for side panel (w-80 = 320px) const getImageMaxWidth = () => { if (isPromptOpen) { // On mobile, panel overlays so use full width; on desktop, subtract panel width return typeof window !== 'undefined' && window.innerWidth >= 640 ? 'calc(90vw - 320px)' : '90vw'; } return '90vw'; }; return ( { e.preventDefault(); closeButtonRef.current?.focus(); }} onCloseAutoFocus={(e) => { e.preventDefault(); triggerRef?.current?.focus(); }} onPointerDownOutside={(e) => e.preventDefault()} onClick={handleBackgroundClick} > {/* Close button - top left */}
onOpenChange(false)} variant="ghost" className="h-10 w-10 p-0 text-white hover:bg-white/10" aria-label={localize('com_ui_close')} >
{/* Action buttons - top right (336px = 320px panel + 16px gap) */}
{zoom > 1 && (
{/* Image container - centered */}
e.stopPropagation()} >
Image
{/* Side Panel */}
e.stopPropagation()} >

{localize('com_ui_image_details')}

{/* Prompt Section */}

{localize('com_ui_prompt')}

{args?.prompt || 'No prompt available'}

{/* Generation Settings */}

{localize('com_ui_generation_settings')}

{localize('com_ui_size')}: {args?.size || 'Unknown'}
{localize('com_ui_quality')}: {args?.quality || 'Standard'}
{localize('com_ui_file_size')}: {imageSize || 'Loading...'}
); }