diff --git a/apps/web/src/modules/viewer/GalleryThumbnail.tsx b/apps/web/src/modules/viewer/GalleryThumbnail.tsx
index 58c3a50e..5228d4dd 100644
--- a/apps/web/src/modules/viewer/GalleryThumbnail.tsx
+++ b/apps/web/src/modules/viewer/GalleryThumbnail.tsx
@@ -1,5 +1,6 @@
-import { Thumbhash } from '@afilmory/ui'
+import { HoverCard, HoverCardContent, HoverCardTrigger, Thumbhash } from '@afilmory/ui'
import { clsxm, Spring } from '@afilmory/utils'
+import { useVirtualizer } from '@tanstack/react-virtual'
import { m } from 'motion/react'
import type { FC } from 'react'
import { useEffect, useRef, useState } from 'react'
@@ -13,16 +14,6 @@ const thumbnailSize = {
desktop: 64,
}
-const thumbnailGapSize = {
- mobile: 8,
- desktop: 12,
-}
-
-const thumbnailPaddingSize = {
- mobile: 12,
- desktop: 16,
-}
-
export const GalleryThumbnail: FC<{
currentIndex: number
photos: PhotoManifest[]
@@ -35,6 +26,20 @@ export const GalleryThumbnail: FC<{
const [scrollContainerWidth, setScrollContainerWidth] = useState(0)
+ const thumbnailHeight = isMobile ? thumbnailSize.mobile : thumbnailSize.desktop
+
+ // Use tanstack virtual for horizontal scrolling
+ const virtualizer = useVirtualizer({
+ count: photos.length,
+ getScrollElement: () => scrollContainerRef.current,
+ estimateSize: (index) => {
+ const photo = photos[index]
+ return photo ? thumbnailHeight * photo.aspectRatio : thumbnailHeight
+ },
+ horizontal: true,
+ overscan: 5,
+ })
+
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (scrollContainer) {
@@ -52,22 +57,47 @@ export const GalleryThumbnail: FC<{
useEffect(() => {
const scrollContainer = scrollContainerRef.current
- if (scrollContainer) {
- const containerWidth = scrollContainerWidth
- const thumbnailLeft =
- currentIndex * (isMobile ? thumbnailSize.mobile : thumbnailSize.desktop) +
- (isMobile ? thumbnailGapSize.mobile : thumbnailGapSize.desktop) * currentIndex
- const thumbnailWidth = isMobile ? thumbnailSize.mobile : thumbnailSize.desktop
+ if (scrollContainer && photos.length > 0 && currentIndex < photos.length) {
+ // Use virtualizer's actual measurements for accurate positioning
+ const virtualItem = virtualizer.getVirtualItems().find((item) => item.index === currentIndex)
- const scrollLeft = thumbnailLeft - containerWidth / 2 + thumbnailWidth / 2
- nextFrame(() => {
- scrollContainer.scrollTo({
- left: scrollLeft,
- behavior: 'smooth',
+ if (virtualItem) {
+ // virtualItem.start is the actual measured start position
+ // virtualItem.size is the actual measured size
+ const thumbnailCenter = virtualItem.start + virtualItem.size / 2
+
+ // Center the thumbnail in the viewport
+ const scrollLeft = thumbnailCenter - scrollContainerWidth / 2
+
+ nextFrame(() => {
+ scrollContainer.scrollTo({
+ left: Math.max(0, scrollLeft),
+ behavior: 'smooth',
+ })
})
- })
+ } else {
+ // Fallback: calculate manually if virtual item not yet rendered
+ let thumbnailLeft = 0
+ for (let i = 0; i < currentIndex; i++) {
+ const photo = photos[i]
+ const width = thumbnailHeight * photo.aspectRatio
+ thumbnailLeft += width
+ }
+
+ const currentPhoto = photos[currentIndex]
+ const currentThumbnailWidth = thumbnailHeight * currentPhoto.aspectRatio
+ const thumbnailCenter = thumbnailLeft + currentThumbnailWidth / 2
+ const scrollLeft = thumbnailCenter - scrollContainerWidth / 2
+
+ nextFrame(() => {
+ scrollContainer.scrollTo({
+ left: Math.max(0, scrollLeft),
+ behavior: 'smooth',
+ })
+ })
+ }
}
- }, [currentIndex, isMobile, scrollContainerWidth])
+ }, [currentIndex, isMobile, scrollContainerWidth, photos, thumbnailHeight, virtualizer])
// 处理鼠标滚轮事件,映射为横向滚动
useEffect(() => {
@@ -91,23 +121,9 @@ export const GalleryThumbnail: FC<{
}
}, [])
- // Virtual scrolling optimization: only render thumbnails near the visible area
- // Calculate the range of thumbnails to render
- const renderRange = 30 // Render 30 items before and after current index, ~60 total
- const startIndex = Math.max(0, currentIndex - renderRange)
- const endIndex = Math.min(photos.length - 1, currentIndex + renderRange)
-
- // Calculate placeholder widths
- const thumbnailWidth = isMobile ? thumbnailSize.mobile : thumbnailSize.desktop
- const gapSize = isMobile ? thumbnailGapSize.mobile : thumbnailGapSize.desktop
- const itemWidth = thumbnailWidth + gapSize
-
- const leftPlaceholderWidth = startIndex > 0 ? startIndex * itemWidth : 0
- const rightPlaceholderWidth = endIndex < photos.length - 1 ? (photos.length - 1 - endIndex) * itemWidth : 0
-
return (