From 62fd908d35b98177a6d012b524ea657a3e01b448 Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 30 Nov 2025 01:11:47 +0800 Subject: [PATCH] feat: enhance GalleryThumbnail with virtualized rendering and hover card support - Integrated virtualized rendering using @tanstack/react-virtual for improved performance in the GalleryThumbnail component. - Added HoverCard functionality for displaying additional photo information on hover. - Removed unused thumbnail gap and padding size constants to streamline the code. - Updated scroll behavior to utilize virtual item measurements for accurate thumbnail positioning. Signed-off-by: Innei --- .../src/modules/viewer/GalleryThumbnail.tsx | 262 +++++++++++------- packages/ui/src/hover-card/index.tsx | 6 +- 2 files changed, 170 insertions(+), 98 deletions(-) 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 ( -
- {/* Left placeholder */} - {leftPlaceholderWidth > 0 && ( -
- )} +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const photo = photos[virtualItem.index] + const thumbnailWidth = thumbnailHeight * photo.aspectRatio + const isCurrent = virtualItem.index === currentIndex - {/* Only render thumbnails within visible range */} - {photos.slice(startIndex, endIndex + 1).map((photo, sliceIndex) => { - const index = startIndex + sliceIndex - return ( - - ) - })} + return ( +
+ {!isMobile ? ( + + + + - {/* Right placeholder */} - {rightPlaceholderWidth > 0 && ( -
- )} + +
+ {/* Preview image */} +
+ {photo.thumbHash && ( + + )} + {photo.title} +
+ {/* Photo info overlay */} + {(photo.title || photo.dateTaken) && ( +
+ {photo.title && ( +
{photo.title}
+ )} + {photo.dateTaken && ( +
+ {new Date(photo.dateTaken).toLocaleDateString()} +
+ )} +
+ )} +
+
+ + ) : ( + + )} +
+ ) + })} +
) diff --git a/packages/ui/src/hover-card/index.tsx b/packages/ui/src/hover-card/index.tsx index bb225632..21e75211 100644 --- a/packages/ui/src/hover-card/index.tsx +++ b/packages/ui/src/hover-card/index.tsx @@ -3,6 +3,8 @@ import * as HoverCardPrimitive from '@radix-ui/react-hover-card' import { m } from 'motion/react' import * as React from 'react' +import { RootPortal } from '../portal' + const HoverCard = HoverCardPrimitive.Root const HoverCardTrigger = HoverCardPrimitive.Trigger @@ -16,7 +18,7 @@ const HoverCardContent = ({ }: React.ComponentPropsWithoutRef & { ref?: React.RefObject | null> }) => ( - + {props.children}
- + ) HoverCardContent.displayName = HoverCardPrimitive.Content.displayName