| import { useState, useEffect, useCallback } from 'react'; |
| import { Maximize2 } from 'lucide-react'; |
| import { FileSources } from 'librechat-data-provider'; |
| import { OGDialog, OGDialogContent } from '@librechat/client'; |
| import ProgressCircle from './ProgressCircle'; |
| import SourceIcon from './SourceIcon'; |
| import { cn } from '~/utils'; |
|
|
| type styleProps = { |
| backgroundImage?: string; |
| backgroundSize?: string; |
| backgroundPosition?: string; |
| backgroundRepeat?: string; |
| }; |
|
|
| interface CloseModalEvent { |
| stopPropagation: () => void; |
| preventDefault: () => void; |
| } |
|
|
| const ImagePreview = ({ |
| imageBase64, |
| url, |
| progress = 1, |
| className = '', |
| source, |
| alt = 'Preview image', |
| }: { |
| imageBase64?: string; |
| url?: string; |
| progress?: number; |
| className?: string; |
| source?: FileSources; |
| alt?: string; |
| }) => { |
| const [isModalOpen, setIsModalOpen] = useState(false); |
| const [isHovered, setIsHovered] = useState(false); |
| const [previousActiveElement, setPreviousActiveElement] = useState<Element | null>(null); |
|
|
| const openModal = useCallback(() => { |
| setPreviousActiveElement(document.activeElement); |
| setIsModalOpen(true); |
| }, []); |
|
|
| const closeModal = useCallback( |
| (e: CloseModalEvent): void => { |
| setIsModalOpen(false); |
| e.stopPropagation(); |
| e.preventDefault(); |
|
|
| if ( |
| previousActiveElement instanceof HTMLElement && |
| !previousActiveElement.closest('[data-skip-refocus="true"]') |
| ) { |
| previousActiveElement.focus(); |
| } |
| }, |
| [previousActiveElement], |
| ); |
|
|
| const handleKeyDown = useCallback( |
| (e: KeyboardEvent) => { |
| if (e.key === 'Escape') { |
| closeModal(e); |
| } |
| }, |
| [closeModal], |
| ); |
|
|
| useEffect(() => { |
| if (isModalOpen) { |
| document.addEventListener('keydown', handleKeyDown); |
| document.body.style.overflow = 'hidden'; |
| const closeButton = document.querySelector('[aria-label="Close full view"]') as HTMLElement; |
| if (closeButton) { |
| setTimeout(() => closeButton.focus(), 0); |
| } |
| } |
|
|
| return () => { |
| document.removeEventListener('keydown', handleKeyDown); |
| document.body.style.overflow = 'unset'; |
| }; |
| }, [isModalOpen, handleKeyDown]); |
|
|
| const baseStyle: styleProps = { |
| backgroundSize: 'cover', |
| backgroundPosition: 'center', |
| backgroundRepeat: 'no-repeat', |
| }; |
|
|
| const imageUrl = imageBase64 ?? url ?? ''; |
|
|
| const style: styleProps = imageUrl |
| ? { |
| ...baseStyle, |
| backgroundImage: `url(${imageUrl})`, |
| } |
| : baseStyle; |
|
|
| if (typeof style.backgroundImage !== 'string' || style.backgroundImage.length === 0) { |
| return null; |
| } |
|
|
| const radius = 55; |
| const circumference = 2 * Math.PI * radius; |
| const offset = circumference - progress * circumference; |
| const circleCSSProperties = { |
| transition: 'stroke-dashoffset 0.3s linear', |
| }; |
|
|
| return ( |
| <> |
| <div |
| className={cn('relative size-14 rounded-xl', className)} |
| onMouseEnter={() => setIsHovered(true)} |
| onMouseLeave={() => setIsHovered(false)} |
| > |
| <button |
| type="button" |
| className="size-full overflow-hidden rounded-xl" |
| style={style} |
| aria-label={`View ${alt} in full size`} |
| aria-haspopup="dialog" |
| onClick={(e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| openModal(); |
| }} |
| /> |
| {progress < 1 ? ( |
| <ProgressCircle |
| circumference={circumference} |
| offset={offset} |
| circleCSSProperties={circleCSSProperties} |
| aria-label={`Loading progress: ${Math.round(progress * 100)}%`} |
| /> |
| ) : ( |
| <div |
| className={cn( |
| 'absolute inset-0 flex transform-gpu cursor-pointer items-center justify-center rounded-xl transition-opacity duration-200 ease-in-out', |
| isHovered ? 'bg-black/20 opacity-100' : 'opacity-0', |
| )} |
| onClick={(e) => { |
| e.stopPropagation(); |
| openModal(); |
| }} |
| aria-hidden="true" |
| > |
| <Maximize2 |
| className={cn( |
| 'size-5 transform-gpu text-white drop-shadow-lg transition-all duration-200', |
| isHovered ? 'scale-110' : '', |
| )} |
| /> |
| </div> |
| )} |
| <SourceIcon source={source} aria-label={source ? `Source: ${source}` : undefined} /> |
| </div> |
| |
| <OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}> |
| <OGDialogContent |
| showCloseButton={false} |
| className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto" |
| disableScroll={false} |
| > |
| <img |
| src={imageUrl} |
| alt={alt} |
| className="max-w-screen h-full max-h-screen w-full object-contain" |
| /> |
| </OGDialogContent> |
| </OGDialog> |
| </> |
| ); |
| }; |
|
|
| export default ImagePreview; |
|
|