| | import { useState, useEffect, useCallback, useRef } from 'react'; |
| | import { Button, OGDialog, OGDialogContent, 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 }) { |
| | const localize = useLocalize(); |
| | const [isPromptOpen, setIsPromptOpen] = useState(false); |
| | const [imageSize, setImageSize] = useState<string | null>(null); |
| |
|
| | |
| | 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<HTMLDivElement>(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 getImageMaxWidth = () => { |
| | |
| | |
| | if (isPromptOpen) { |
| | return window.innerWidth >= 640 ? 'calc(100vw - 22rem)' : 'calc(100vw - 2rem)'; |
| | } |
| | return 'calc(100vw - 2rem)'; |
| | }; |
| |
|
| | 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<HTMLDivElement>) => { |
| | 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<HTMLDivElement>) => { |
| | 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<HTMLDivElement>) => { |
| | 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); |
| | }, []); |
| |
|
| | useEffect(() => { |
| | const onKey = (e: KeyboardEvent) => e.key === 'Escape' && resetZoom(); |
| | document.addEventListener('keydown', onKey); |
| | return () => document.removeEventListener('keydown', onKey); |
| | }, [resetZoom]); |
| |
|
| | 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]); |
| |
|
| | const imageDetailsLabel = isPromptOpen |
| | ? localize('com_ui_hide_image_details') |
| | : localize('com_ui_show_image_details'); |
| |
|
| | return ( |
| | <OGDialog open={isOpen} onOpenChange={onOpenChange}> |
| | <OGDialogContent |
| | showCloseButton={false} |
| | className="h-full w-full rounded-none bg-transparent" |
| | disableScroll={false} |
| | overlayClassName="bg-surface-primary opacity-95 z-50" |
| | > |
| | <div |
| | className={`ease-[cubic-bezier(0.175,0.885,0.32,1.275)] absolute left-0 top-0 z-10 flex items-center justify-between p-3 transition-all duration-500 sm:p-4 ${isPromptOpen ? 'right-0 sm:right-80' : 'right-0'}`} |
| | > |
| | <TooltipAnchor |
| | description={localize('com_ui_close')} |
| | render={ |
| | <Button |
| | onClick={() => onOpenChange(false)} |
| | variant="ghost" |
| | className="h-10 w-10 p-0 hover:bg-surface-hover" |
| | aria-label={localize('com_ui_close')} |
| | > |
| | <X className="size-7 sm:size-6" /> |
| | </Button> |
| | } |
| | /> |
| | <div className="flex items-center gap-1 sm:gap-2"> |
| | {zoom > 1 && ( |
| | <TooltipAnchor |
| | description={localize('com_ui_reset_zoom')} |
| | render={ |
| | <Button |
| | onClick={resetZoom} |
| | variant="ghost" |
| | className="h-10 w-10 p-0" |
| | aria-label={localize('com_ui_reset_zoom')} |
| | > |
| | <RotateCcw className="size-6" /> |
| | </Button> |
| | } |
| | /> |
| | )} |
| | <TooltipAnchor |
| | description={localize('com_ui_download')} |
| | render={ |
| | <Button |
| | onClick={() => downloadImage()} |
| | variant="ghost" |
| | className="h-10 w-10 p-0" |
| | aria-label={localize('com_ui_download')} |
| | > |
| | <ArrowDownToLine className="size-6" /> |
| | </Button> |
| | } |
| | /> |
| | <TooltipAnchor |
| | description={imageDetailsLabel} |
| | render={ |
| | <Button |
| | onClick={() => setIsPromptOpen(!isPromptOpen)} |
| | variant="ghost" |
| | className="h-10 w-10 p-0" |
| | aria-label={imageDetailsLabel} |
| | > |
| | {isPromptOpen ? ( |
| | <PanelLeftOpen className="size-7 sm:size-6" /> |
| | ) : ( |
| | <PanelLeftClose className="size-7 sm:size-6" /> |
| | )} |
| | </Button> |
| | } |
| | /> |
| | </div> |
| | </div> |
| | |
| | {/* Main content area with image */} |
| | <div |
| | className={`ease-[cubic-bezier(0.175,0.885,0.32,1.275)] flex h-full transition-all duration-500 ${isPromptOpen ? 'mr-0 sm:mr-80' : 'mr-0'}`} |
| | > |
| | <div |
| | ref={containerRef} |
| | className="flex flex-1 items-center justify-center px-2 pb-4 pt-16 sm:px-4 sm:pt-20" |
| | onWheel={handleWheel} |
| | onMouseDown={handleMouseDown} |
| | onMouseMove={handleMouseMove} |
| | onMouseUp={handleMouseUp} |
| | onMouseLeave={handleMouseUp} |
| | onDoubleClick={handleDoubleClick} |
| | style={{ |
| | cursor: getCursor(), |
| | overflow: zoom > 1 ? 'hidden' : 'visible', |
| | minHeight: 0, // Allow flexbox to shrink |
| | }} |
| | > |
| | <div |
| | className="flex items-center justify-center transition-transform duration-100 ease-out" |
| | style={{ |
| | transform: `translate(${panX}px, ${panY}px) scale(${zoom})`, |
| | transformOrigin: 'center center', |
| | width: '100%', |
| | height: '100%', |
| | display: 'flex', |
| | alignItems: 'center', |
| | justifyContent: 'center', |
| | }} |
| | > |
| | <img |
| | src={src} |
| | alt="Image" |
| | className="block object-contain" |
| | style={{ |
| | maxHeight: 'calc(100vh - 8rem)', |
| | maxWidth: getImageMaxWidth(), |
| | width: 'auto', |
| | height: 'auto', |
| | }} |
| | /> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | {/* Side Panel */} |
| | <div |
| | className={`sm:shadow-l-lg ease-[cubic-bezier(0.175,0.885,0.32,1.275)] fixed right-0 top-0 z-20 h-full w-full transform border-l border-border-light bg-surface-primary shadow-2xl backdrop-blur-sm transition-transform duration-500 sm:w-80 sm:rounded-l-2xl ${ |
| | isPromptOpen ? 'translate-x-0' : 'translate-x-full' |
| | }`} |
| | > |
| | {/* Mobile pull handle - removed for cleaner look */} |
| | |
| | <div className="h-full overflow-y-auto p-4 sm:p-6"> |
| | {/* Mobile close button */} |
| | <div className="mb-4 flex items-center justify-between sm:hidden"> |
| | <h3 className="text-lg font-semibold text-text-primary"> |
| | {localize('com_ui_image_details')} |
| | </h3> |
| | <Button |
| | onClick={() => setIsPromptOpen(false)} |
| | variant="ghost" |
| | className="h-12 w-12 p-0" |
| | > |
| | <X className="size-6" /> |
| | </Button> |
| | </div> |
| | |
| | <div className="mb-4 hidden sm:block"> |
| | <h3 className="mb-2 text-lg font-semibold text-text-primary"> |
| | {localize('com_ui_image_details')} |
| | </h3> |
| | <div className="mb-4 h-px bg-border-medium"></div> |
| | </div> |
| | |
| | <div className="space-y-4 sm:space-y-6"> |
| | {/* Prompt Section */} |
| | <div> |
| | <h4 className="mb-2 text-sm font-medium text-text-primary"> |
| | {localize('com_ui_prompt')} |
| | </h4> |
| | <div className="rounded-md bg-surface-tertiary p-3"> |
| | <p className="text-sm leading-relaxed text-text-primary"> |
| | {args?.prompt || 'No prompt available'} |
| | </p> |
| | </div> |
| | </div> |
| | |
| | {/* Generation Settings */} |
| | <div> |
| | <h4 className="mb-3 text-sm font-medium text-text-primary"> |
| | {localize('com_ui_generation_settings')} |
| | </h4> |
| | <div className="space-y-3"> |
| | <div className="flex items-center justify-between"> |
| | <span className="text-sm text-text-primary">{localize('com_ui_size')}:</span> |
| | <span className="text-sm font-medium text-text-primary"> |
| | {args?.size || 'Unknown'} |
| | </span> |
| | </div> |
| | <div className="flex items-center justify-between"> |
| | <span className="text-sm text-text-primary">{localize('com_ui_quality')}:</span> |
| | <span |
| | className={`rounded px-2 py-1 text-xs font-medium capitalize ${getQualityStyles(args?.quality || '')}`} |
| | > |
| | {args?.quality || 'Standard'} |
| | </span> |
| | </div> |
| | <div className="flex items-center justify-between"> |
| | <span className="text-sm text-text-primary"> |
| | {localize('com_ui_file_size')}: |
| | </span> |
| | <span className="text-sm font-medium text-text-primary"> |
| | {imageSize || 'Loading...'} |
| | </span> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | </OGDialogContent> |
| | </OGDialog> |
| | ); |
| | } |
| |
|