1
+ import { useState , useEffect , useCallback } from 'react' ;
2
+ import { Maximize2 } from 'lucide-react' ;
1
3
import { FileSources } from 'librechat-data-provider' ;
2
4
import ProgressCircle from './ProgressCircle' ;
3
5
import SourceIcon from './SourceIcon' ;
@@ -10,67 +12,199 @@ type styleProps = {
10
12
backgroundRepeat ?: string ;
11
13
} ;
12
14
15
+ interface CloseModalEvent {
16
+ stopPropagation : ( ) => void ;
17
+ preventDefault : ( ) => void ;
18
+ }
19
+
13
20
const ImagePreview = ( {
14
21
imageBase64,
15
22
url,
16
23
progress = 1 ,
17
24
className = '' ,
18
25
source,
26
+ alt = 'Preview image' ,
19
27
} : {
20
28
imageBase64 ?: string ;
21
29
url ?: string ;
22
- progress ?: number ; // between 0 and 1
30
+ progress ?: number ;
23
31
className ?: string ;
24
32
source ?: FileSources ;
33
+ alt ?: string ;
25
34
} ) => {
26
- let style : styleProps = {
35
+ const [ isModalOpen , setIsModalOpen ] = useState ( false ) ;
36
+ const [ isHovered , setIsHovered ] = useState ( false ) ;
37
+ const [ previousActiveElement , setPreviousActiveElement ] = useState < Element | null > ( null ) ;
38
+
39
+ const openModal = useCallback ( ( ) => {
40
+ setPreviousActiveElement ( document . activeElement ) ;
41
+ setIsModalOpen ( true ) ;
42
+ } , [ ] ) ;
43
+
44
+ const closeModal = useCallback (
45
+ ( e : CloseModalEvent ) : void => {
46
+ setIsModalOpen ( false ) ;
47
+ e . stopPropagation ( ) ;
48
+ e . preventDefault ( ) ;
49
+
50
+ if (
51
+ previousActiveElement instanceof HTMLElement &&
52
+ ! previousActiveElement . closest ( '[data-skip-refocus="true"]' )
53
+ ) {
54
+ previousActiveElement . focus ( ) ;
55
+ }
56
+ } ,
57
+ [ previousActiveElement ] ,
58
+ ) ;
59
+
60
+ const handleKeyDown = useCallback (
61
+ ( e : KeyboardEvent ) => {
62
+ if ( e . key === 'Escape' ) {
63
+ closeModal ( e ) ;
64
+ }
65
+ } ,
66
+ [ closeModal ] ,
67
+ ) ;
68
+
69
+ useEffect ( ( ) => {
70
+ if ( isModalOpen ) {
71
+ document . addEventListener ( 'keydown' , handleKeyDown ) ;
72
+ document . body . style . overflow = 'hidden' ;
73
+ const closeButton = document . querySelector ( '[aria-label="Close full view"]' ) as HTMLElement ;
74
+ if ( closeButton ) {
75
+ setTimeout ( ( ) => closeButton . focus ( ) , 0 ) ;
76
+ }
77
+ }
78
+
79
+ return ( ) => {
80
+ document . removeEventListener ( 'keydown' , handleKeyDown ) ;
81
+ document . body . style . overflow = 'unset' ;
82
+ } ;
83
+ } , [ isModalOpen , handleKeyDown ] ) ;
84
+
85
+ const baseStyle : styleProps = {
27
86
backgroundSize : 'cover' ,
28
87
backgroundPosition : 'center' ,
29
88
backgroundRepeat : 'no-repeat' ,
30
89
} ;
31
- if ( imageBase64 ) {
32
- style = {
33
- ...style ,
34
- backgroundImage : `url(${ imageBase64 } )` ,
35
- } ;
36
- } else if ( url ) {
37
- style = {
38
- ...style ,
39
- backgroundImage : `url(${ url } )` ,
40
- } ;
41
- }
42
90
43
- if ( ! style . backgroundImage ) {
91
+ const imageUrl = imageBase64 ?? url ?? '' ;
92
+
93
+ const style : styleProps = imageUrl
94
+ ? {
95
+ ...baseStyle ,
96
+ backgroundImage : `url(${ imageUrl } )` ,
97
+ }
98
+ : baseStyle ;
99
+
100
+ if ( typeof style . backgroundImage !== 'string' || style . backgroundImage . length === 0 ) {
44
101
return null ;
45
102
}
46
103
47
- const radius = 55 ; // Radius of the SVG circle
104
+ const radius = 55 ;
48
105
const circumference = 2 * Math . PI * radius ;
49
-
50
- // Calculate the offset based on the loading progress
51
106
const offset = circumference - progress * circumference ;
52
107
const circleCSSProperties = {
53
108
transition : 'stroke-dashoffset 0.3s linear' ,
54
109
} ;
55
110
56
111
return (
57
- < div className = { cn ( 'h-14 w-14' , className ) } >
58
- < button
59
- type = "button"
60
- aria-haspopup = "dialog"
61
- aria-expanded = "false"
62
- className = "h-full w-full"
63
- style = { style }
64
- />
65
- { progress < 1 && (
66
- < ProgressCircle
67
- circumference = { circumference }
68
- offset = { offset }
69
- circleCSSProperties = { circleCSSProperties }
112
+ < >
113
+ < div
114
+ className = { cn ( 'relative size-14 rounded-lg' , className ) }
115
+ onMouseEnter = { ( ) => setIsHovered ( true ) }
116
+ onMouseLeave = { ( ) => setIsHovered ( false ) }
117
+ >
118
+ < button
119
+ type = "button"
120
+ className = "size-full overflow-hidden rounded-lg"
121
+ style = { style }
122
+ aria-label = { `View ${ alt } in full size` }
123
+ aria-haspopup = "dialog"
124
+ onClick = { ( e ) => {
125
+ e . preventDefault ( ) ;
126
+ e . stopPropagation ( ) ;
127
+ openModal ( ) ;
128
+ } }
70
129
/>
130
+ { progress < 1 ? (
131
+ < ProgressCircle
132
+ circumference = { circumference }
133
+ offset = { offset }
134
+ circleCSSProperties = { circleCSSProperties }
135
+ aria-label = { `Loading progress: ${ Math . round ( progress * 100 ) } %` }
136
+ />
137
+ ) : (
138
+ < div
139
+ className = { cn (
140
+ 'absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg transition-opacity duration-200 ease-in-out' ,
141
+ isHovered ? 'bg-black/20 opacity-100' : 'opacity-0' ,
142
+ ) }
143
+ onClick = { ( e ) => {
144
+ e . stopPropagation ( ) ;
145
+ openModal ( ) ;
146
+ } }
147
+ aria-hidden = "true"
148
+ >
149
+ < Maximize2
150
+ className = { cn (
151
+ 'size-5 transform-gpu text-white drop-shadow-lg transition-all duration-200' ,
152
+ isHovered ? 'scale-110' : '' ,
153
+ ) }
154
+ />
155
+ </ div >
156
+ ) }
157
+ < SourceIcon source = { source } aria-label = { source ? `Source: ${ source } ` : undefined } />
158
+ </ div >
159
+
160
+ { isModalOpen && (
161
+ < div
162
+ role = "dialog"
163
+ aria-modal = "true"
164
+ aria-label = { `Full view of ${ alt } ` }
165
+ className = "fixed inset-0 z-[999] bg-black bg-opacity-80 transition-opacity duration-200 ease-in-out"
166
+ onClick = { closeModal }
167
+ >
168
+ < div className = "flex h-full w-full cursor-default items-center justify-center" >
169
+ < button
170
+ type = "button"
171
+ className = "absolute right-4 top-4 z-[1000] rounded-full p-2 text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
172
+ onClick = { ( e ) => {
173
+ e . stopPropagation ( ) ;
174
+ closeModal ( e ) ;
175
+ } }
176
+ aria-label = "Close full view"
177
+ >
178
+ < svg
179
+ className = "h-6 w-6"
180
+ fill = "none"
181
+ stroke = "currentColor"
182
+ viewBox = "0 0 24 24"
183
+ aria-hidden = "true"
184
+ >
185
+ < path
186
+ strokeLinecap = "round"
187
+ strokeLinejoin = "round"
188
+ strokeWidth = { 2 }
189
+ d = "M6 18L18 6M6 6l12 12"
190
+ />
191
+ </ svg >
192
+ </ button >
193
+ < div
194
+ className = "max-h-[90vh] max-w-[90vw] transform transition-transform duration-50 ease-in-out animate-in zoom-in-90"
195
+ role = "presentation"
196
+ >
197
+ < img
198
+ src = { imageUrl }
199
+ alt = { alt }
200
+ className = "max-w-screen max-h-screen object-contain"
201
+ onClick = { ( e ) => e . stopPropagation ( ) }
202
+ />
203
+ </ div >
204
+ </ div >
205
+ </ div >
71
206
) }
72
- < SourceIcon source = { source } />
73
- </ div >
207
+ </ >
74
208
) ;
75
209
} ;
76
210
0 commit comments