@@ -91,6 +91,20 @@ export const GalleryThumbnail: FC<{
9191 }
9292 } , [ ] )
9393
94+ // Virtual scrolling optimization: only render thumbnails near the visible area
95+ // Calculate the range of thumbnails to render
96+ const renderRange = 30 // Render 30 items before and after current index, ~60 total
97+ const startIndex = Math . max ( 0 , currentIndex - renderRange )
98+ const endIndex = Math . min ( photos . length - 1 , currentIndex + renderRange )
99+
100+ // Calculate placeholder widths
101+ const thumbnailWidth = isMobile ? thumbnailSize . mobile : thumbnailSize . desktop
102+ const gapSize = isMobile ? thumbnailGapSize . mobile : thumbnailGapSize . desktop
103+ const itemWidth = thumbnailWidth + gapSize
104+
105+ const leftPlaceholderWidth = startIndex > 0 ? startIndex * itemWidth : 0
106+ const rightPlaceholderWidth = endIndex < photos . length - 1 ? ( photos . length - 1 - endIndex ) * itemWidth : 0
107+
94108 return (
95109 < m . div
96110 className = "pb-safe border-accent/20 bg-material-medium z-10 shrink-0 border-t backdrop-blur-2xl"
@@ -122,33 +136,57 @@ export const GalleryThumbnail: FC<{
122136 padding : isMobile ? thumbnailPaddingSize . mobile : thumbnailPaddingSize . desktop ,
123137 } }
124138 >
125- { photos . map ( ( photo , index ) => (
126- < button
127- type = "button"
128- key = { photo . id }
129- className = { clsxm (
130- 'contain-intrinsic-size relative shrink-0 overflow-hidden rounded-lg border-2 transition-all' ,
131- index === currentIndex
132- ? 'scale-110 border-accent shadow-[0_0_20px_color-mix(in_srgb,var(--color-accent)_20%,transparent)]'
133- : 'grayscale-50 border-accent/20 hover:border-accent hover:grayscale-0' ,
134- ) }
135- style = {
136- isMobile
137- ? {
138- width : thumbnailSize . mobile ,
139- height : thumbnailSize . mobile ,
140- }
141- : {
142- width : thumbnailSize . desktop ,
143- height : thumbnailSize . desktop ,
144- }
145- }
146- onClick = { ( ) => onIndexChange ( index ) }
147- >
148- { photo . thumbHash && < Thumbhash thumbHash = { photo . thumbHash } className = "size-fill absolute inset-0" /> }
149- < img src = { photo . thumbnailUrl } alt = { photo . title } className = "absolute inset-0 h-full w-full object-cover" />
150- </ button >
151- ) ) }
139+ { /* Left placeholder */ }
140+ { leftPlaceholderWidth > 0 && (
141+ < div
142+ style = { {
143+ width : leftPlaceholderWidth ,
144+ flexShrink : 0 ,
145+ } }
146+ />
147+ ) }
148+
149+ { /* Only render thumbnails within visible range */ }
150+ { photos . slice ( startIndex , endIndex + 1 ) . map ( ( photo , sliceIndex ) => {
151+ const index = startIndex + sliceIndex
152+ return (
153+ < button
154+ type = "button"
155+ key = { photo . id }
156+ className = { clsxm (
157+ 'contain-intrinsic-size relative shrink-0 overflow-hidden rounded-lg border-2 transition-all' ,
158+ index === currentIndex
159+ ? 'scale-110 border-accent shadow-[0_0_20px_color-mix(in_srgb,var(--color-accent)_20%,transparent)]'
160+ : 'grayscale-50 border-accent/20 hover:border-accent hover:grayscale-0' ,
161+ ) }
162+ style = {
163+ isMobile
164+ ? {
165+ width : thumbnailSize . mobile ,
166+ height : thumbnailSize . mobile ,
167+ }
168+ : {
169+ width : thumbnailSize . desktop ,
170+ height : thumbnailSize . desktop ,
171+ }
172+ }
173+ onClick = { ( ) => onIndexChange ( index ) }
174+ >
175+ { photo . thumbHash && < Thumbhash thumbHash = { photo . thumbHash } className = "size-fill absolute inset-0" /> }
176+ < img src = { photo . thumbnailUrl } alt = { photo . title } className = "absolute inset-0 h-full w-full object-cover" />
177+ </ button >
178+ )
179+ } ) }
180+
181+ { /* Right placeholder */ }
182+ { rightPlaceholderWidth > 0 && (
183+ < div
184+ style = { {
185+ width : rightPlaceholderWidth ,
186+ flexShrink : 0 ,
187+ } }
188+ />
189+ ) }
152190 </ div >
153191 </ m . div >
154192 )
0 commit comments