import { useEffect, useRef, useState, type CSSProperties, type ComponentPropsWithoutRef, type RefObject, type MouseEvent, type SyntheticEvent, type UIEvent, } from "react" import { useMemoizedFn } from "ahooks" import { createPortal } from "react-dom" import { LoaderCircleIcon, Maximize2 } from "lucide-react" import { VirtuosoMasonry, type ItemContent } from "@virtuoso.dev/masonry" import { CloudImage } from "@/components/common/cloud-image" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Skeleton } from "@/components/ui/skeleton" import { cn } from "@/lib/utils" import { GALLERY_CLOUD_LOAD_MORE_THRESHOLD } from "@/utils/consts" import type { AlbumPhoto } from "../utils" import type { SelectionBox } from "../hooks/use-album-selection-box" const masonryContainStyle: CSSProperties = { contentVisibility: "auto", containIntrinsicSize: "var(--app-gallery-masonry-intrinsic-size)", } type ImageSize = { width: number height: number } type MasonryContext = { selectedSet: Set imageSizeOverrides: Record masonryGap: number previewLabel: string onCardClick: ( id: number, modifier?: { shiftKey: boolean metaKey: boolean ctrlKey: boolean } ) => void onToggleSelection: (id: number, checked?: boolean) => void onPreview: (id: number) => void onImageLoad: (image: AlbumPhoto) => (event: SyntheticEvent) => void onVideoLoad: (image: AlbumPhoto) => (event: SyntheticEvent) => void onItemRefChange: (id: number, node: HTMLDivElement | null) => void } const placeholderAspectRatio = 4 / 3 function getMasonryGap(width: number) { if (width >= 1200) return 20 if (width >= 600) return 15 if (width >= 300) return 10 return 5 } function resolveImageSizing( image: AlbumPhoto, overrides: Record ) { const override = overrides[image.id] const width = override?.width ?? image.width const height = override?.height ?? image.height const aspectRatio = width && height ? width / height : placeholderAspectRatio return { width, height, aspectRatio } } function getSelectionBoxRect(box: SelectionBox) { return { left: Math.min(box.startX, box.currentX), top: Math.min(box.startY, box.currentY), width: Math.abs(box.currentX - box.startX), height: Math.abs(box.currentY - box.startY), } } type LazyMediaProps = Omit, "type"> & { isVideo?: boolean onVideoLoad?: (event: SyntheticEvent) => void type?: string } function LazyImage({ className, style, isVideo, onVideoLoad, type, ...imgProps }: LazyMediaProps) { const { src, alt, srcSet, sizes, width, height, crossOrigin, referrerPolicy, useMap, draggable, onLoad, } = imgProps const [isLoaded, setIsLoaded] = useState(false) const mediaClassName = cn( "absolute inset-0 h-full w-full object-cover transition-opacity duration-300", isLoaded ? "opacity-100" : "opacity-0" ) const handleImageLoad = (event: SyntheticEvent) => { setIsLoaded(true) onLoad?.(event) } const handleVideoLoad = (event: SyntheticEvent) => { setIsLoaded(true) onVideoLoad?.(event) } return (
{!isLoaded ? ( ) : null} {isVideo ? (
) } const MasonryItem: ItemContent = ({ data: photo, context, }) => { if (!photo) { console.warn("MasonryItem: photo is undefined") return null } const isSelected = context.selectedSet.has(photo.id) const { aspectRatio, width, height } = resolveImageSizing( photo, context.imageSizeOverrides ) return (
context.onItemRefChange(photo.id, node)} className={cn( "group relative overflow-hidden rounded-lg border transition-shadow", isSelected ? "border-primary/40 ring-2 ring-ring/40" : "border-transparent hover:border-border/80" )} onClick={(event) => { context.onCardClick(photo.id, { shiftKey: event.shiftKey, metaKey: event.metaKey, ctrlKey: event.ctrlKey }) }} >
{ event.stopPropagation() }} > context.onToggleSelection(photo.id, checked === true) } />
{ event.stopPropagation() }} >
{photo.name}
) } export type MasonryViewProps = { images: AlbumPhoto[] layoutScopeKey: string columnCount: number selectedSet: Set albumWidth: number scrollRoot: HTMLElement | null scrollViewportRef: RefObject albumContentRef: RefObject frozenWidth: number | null onScrollRootChange: (root: HTMLElement | null) => void onMouseDown: (event: MouseEvent) => void onCardClick: ( id: number, modifier?: { shiftKey: boolean metaKey: boolean ctrlKey: boolean } ) => void onToggleSelection: (id: number, checked?: boolean) => void onPreview: (id: number) => void onItemRefChange: (id: number, node: HTMLDivElement | null) => void previewLabel: string selectionBox: SelectionBox onEndReached?: () => void hasMore?: boolean isLoadingMore?: boolean } export function MasonryView({ images, layoutScopeKey, columnCount, selectedSet, albumWidth, scrollRoot, scrollViewportRef, albumContentRef, frozenWidth, onScrollRootChange, onMouseDown, onCardClick, onToggleSelection, onPreview, onItemRefChange, previewLabel, selectionBox, onEndReached, hasMore = false, isLoadingMore = false, }: MasonryViewProps) { const [imageSizeOverrides, setImageSizeOverrides] = useState< Record >({}) const masonryRootRef = useRef(null) const lastSyncedScrollerRef = useRef(null) const masonryGap = getMasonryGap(albumWidth) const masonryColumnCount = columnCount // Virtuoso creates its own scrolling element, so we grab that inner scroller // after mount and use it as the shared scroll root for selection and paging logic. const handleMasonryRootRef = useMemoizedFn((node: HTMLDivElement | null) => { if (masonryRootRef.current === node) { return } masonryRootRef.current = node if (!node) { lastSyncedScrollerRef.current = null scrollViewportRef.current = null onScrollRootChange(null) return } requestAnimationFrame(() => { if (masonryRootRef.current !== node) { return } const scroller = node.querySelector( "[data-testid='virtuoso-scroller']" ) ?? null if (lastSyncedScrollerRef.current === scroller) { return } if (scroller) { scroller.style.position = "relative" } lastSyncedScrollerRef.current = scroller scrollViewportRef.current = scroller onScrollRootChange(scroller) }) }) useEffect(() => { if (!scrollRoot || !onEndReached) return // If the first page does not overflow yet, auto-load the next page once. // Without this, the user would be stuck on an unscrollable viewport. const maybeFillViewport = () => { if (isLoadingMore || !hasMore) return const hasOverflow = scrollRoot.scrollHeight > scrollRoot.clientHeight if (hasOverflow) return onEndReached() } const frameId = window.requestAnimationFrame(maybeFillViewport) return () => { window.cancelAnimationFrame(frameId) } }, [scrollRoot, onEndReached, isLoadingMore, hasMore, images.length]) // Scroll events come from Virtuoso's internal scroller, so capture them here // and only fetch more when the real scrolling container is close to the bottom. const handleMasonryScroll = (event: UIEvent) => { if (!onEndReached || isLoadingMore || !hasMore) return const currentTarget = event.currentTarget if (scrollViewportRef.current !== currentTarget) { scrollViewportRef.current = currentTarget } if (scrollRoot !== currentTarget) { onScrollRootChange(currentTarget) } const remainingDistance = currentTarget.scrollHeight - currentTarget.clientHeight - currentTarget.scrollTop if (remainingDistance > GALLERY_CLOUD_LOAD_MORE_THRESHOLD) return onEndReached() } const handleMasonryImageLoad = (image: AlbumPhoto) => (event: SyntheticEvent) => { if (image.height) return const { naturalWidth, naturalHeight } = event.currentTarget if (!naturalWidth || !naturalHeight) return setImageSizeOverrides((prev) => { const current = prev[image.id] if ( current?.width === naturalWidth && current?.height === naturalHeight ) { return prev } return { ...prev, [image.id]: { width: naturalWidth, height: naturalHeight }, } }) } const handleMasonryVideoLoad = (image: AlbumPhoto) => (event: SyntheticEvent) => { if (image.height) return const { videoWidth, videoHeight } = event.currentTarget if (!videoWidth || !videoHeight) return setImageSizeOverrides((prev) => { const current = prev[image.id] if ( current?.width === videoWidth && current?.height === videoHeight ) { return prev } return { ...prev, [image.id]: { width: videoWidth, height: videoHeight }, } }) } const masonryContext: MasonryContext = { selectedSet, imageSizeOverrides, masonryGap, previewLabel, onCardClick, onToggleSelection, onPreview, onImageLoad: handleMasonryImageLoad, onVideoLoad: handleMasonryVideoLoad, onItemRefChange, } const selectionBoxPortal = selectionBox.isVisible && scrollRoot ? createPortal(
, scrollRoot ) : null return (
{isLoadingMore && images.length > 0 ? (
) : null} {selectionBoxPortal}
) }