diff --git a/.gitignore b/.gitignore index 1b0f2b0171..97b1efa248 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,4 @@ dist-ssr .aider* .coverage -backend/README.md +backend/README.md \ No newline at end of file diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index ad9c1aaf88..fbe3af60b2 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -87,6 +87,9 @@ # Allow users to edit their own messages edit_message = true +# Enable lightbox for images - making it possible to view images in full screen +image_lightbox = true + # Authorize users to spontaneously upload files with messages [features.spontaneous_file_upload] enabled = true @@ -241,6 +244,7 @@ class FeaturesSettings(DataClassJsonMixin): unsafe_allow_html: bool = False auto_tag_thread: bool = True edit_message: bool = True + image_lightbox: bool = True @dataclass() diff --git a/frontend/package.json b/frontend/package.json index 635dbed1ce..5c98ab934a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "unist-util-visit": "^5.0.0", "usehooks-ts": "^2.9.1", "uuid": "^9.0.0", + "yet-another-react-lightbox": "^3.21.6", "yup": "^1.2.0" }, "devDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 06dfed2047..c03cda4032 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: uuid: specifier: ^9.0.0 version: 9.0.0 + yet-another-react-lightbox: + specifier: ^3.21.6 + version: 3.21.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) yup: specifier: ^1.2.0 version: 1.2.0 @@ -3918,6 +3921,13 @@ packages: engines: {node: '>= 14'} hasBin: true + yet-another-react-lightbox@3.21.6: + resolution: {integrity: sha512-uKcRmmezsj1Fbj38B6hFOGwbAu94fPr8d5H6I0+1FmcToX56freEGXXXtdA1oRo6036ug+UgrKZzzvsw/MIM/w==} + engines: {node: '>=14'} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + yocto-queue@1.0.0: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} @@ -8156,6 +8166,11 @@ snapshots: yaml@2.4.1: {} + yet-another-react-lightbox@3.21.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + yocto-queue@1.0.0: {} yup@1.2.0: diff --git a/frontend/src/components/atoms/elements/Image.tsx b/frontend/src/components/atoms/elements/Image.tsx index 201e450567..dfc1a0df9a 100644 --- a/frontend/src/components/atoms/elements/Image.tsx +++ b/frontend/src/components/atoms/elements/Image.tsx @@ -1,77 +1,90 @@ -import { useState } from 'react'; +import { Suspense, lazy, useState } from 'react'; import Skeleton from '@mui/material/Skeleton'; -import { type IImageElement } from 'client-types/'; +import { type IImageElement, useConfig } from '@chainlit/react-client'; import { FrameElement } from './Frame'; +// Lazy load the Lightbox component and its dependencies +const LightboxWrapper = lazy(() => import('./LightboxWrapper')); + interface Props { element: IImageElement; } -const handleImageClick = (name: string, src: string) => { - const width = window.innerWidth / 2; - const height = window.innerHeight / 2; - const left = window.innerWidth / 4; - const top = window.innerHeight / 4; - - const newWindow = window.open( - '', - '_blank', - `width=${width},height=${height},left=${left},top=${top}` - ); - if (newWindow) { - newWindow.document.write(` - - - ${name} - - - - - ${name} - Download - - - `); - newWindow.document.close(); - } -}; - const ImageElement = ({ element }: Props) => { const [loading, setLoading] = useState(true); + const [lightboxOpen, setLightboxOpen] = useState(false); + const config = useConfig(); if (!element.url) { return null; } + const enableLightbox = + config.config?.features.image_lightbox && element.display === 'inline'; + + const handleImageClick = () => { + if (enableLightbox) { + setLightboxOpen(true); + } else { + // Fall back to popup window behavior + const width = window.innerWidth / 2; + const height = window.innerHeight / 2; + const left = window.innerWidth / 4; + const top = window.innerHeight / 4; + + const newWindow = window.open( + '', + '_blank', + `width=${width},height=${height},left=${left},top=${top}` + ); + if (newWindow) { + newWindow.document.write(` + + + ${element.name} + + + + + ${element.name} + Download + + + `); + newWindow.document.close(); + } + } + }; + return ( {loading && } @@ -79,23 +92,28 @@ const ImageElement = ({ element }: Props) => { className={`${element.display}-image`} src={element.url} onLoad={() => setLoading(false)} - onClick={() => { - if (element.display === 'inline') { - const name = `${element.name}.png`; - handleImageClick(name, element.url!); - } - }} + onClick={handleImageClick} style={{ objectFit: 'cover', maxWidth: '100%', margin: 'auto', height: 'auto', display: 'block', - cursor: element.display === 'inline' ? 'pointer' : 'default' + cursor: enableLightbox ? 'pointer' : 'default' }} alt={element.name} loading="lazy" /> + {enableLightbox && lightboxOpen && ( + Loading...}> + setLightboxOpen(false)} + imageUrl={element.url} + imageName={element.name} + /> + + )} ); }; diff --git a/frontend/src/components/atoms/elements/LightboxWrapper.tsx b/frontend/src/components/atoms/elements/LightboxWrapper.tsx new file mode 100644 index 0000000000..3d125b1f74 --- /dev/null +++ b/frontend/src/components/atoms/elements/LightboxWrapper.tsx @@ -0,0 +1,52 @@ +import Lightbox from 'yet-another-react-lightbox'; +import Download from 'yet-another-react-lightbox/plugins/download'; +import Zoom from 'yet-another-react-lightbox/plugins/zoom'; + +import 'yet-another-react-lightbox/styles.css'; + +interface LightboxWrapperProps { + isOpen: boolean; + onClose: () => void; + imageUrl: string; + imageName: string; +} + +const LightboxWrapper = ({ + isOpen, + onClose, + imageUrl, + imageName +}: LightboxWrapperProps) => { + return ( + null, buttonNext: () => null }} + plugins={[Zoom, Download]} + zoom={{ + maxZoomPixelRatio: 5, + zoomInMultiplier: 2 + }} + download={{ + download: async ({ slide }) => { + try { + const response = await fetch(slide.src, { mode: 'cors' }); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = imageName || 'image'; + link.click(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('Failed to download image:', error); + } + } + }} + /> + ); +}; + +export default LightboxWrapper; diff --git a/libs/react-client/src/types/config.ts b/libs/react-client/src/types/config.ts index 0184e35c4e..0dae4df4ba 100644 --- a/libs/react-client/src/types/config.ts +++ b/libs/react-client/src/types/config.ts @@ -49,6 +49,7 @@ export interface IChainlitConfig { unsafe_allow_html?: boolean; latex?: boolean; edit_message?: boolean; + image_lightbox?: boolean; }; debugUrl?: string; userEnv: string[]; diff --git a/package.json b/package.json index 4d1d2b640a..6da2b6e657 100644 --- a/package.json +++ b/package.json @@ -34,4 +34,4 @@ "micromatch@<4.0.8": ">=4.0.8" } } -} +} \ No newline at end of file