import React, { useCallback, useMemo, useRef } from 'react'; import { GRID_COVER_WARM_LIMIT } from '../cover/layoutSizes'; import { useWarmGridCovers } from '../hooks/useWarmGridCovers'; import { useVirtualizer } from '@tanstack/react-virtual'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useElementClientHeightById } from '../hooks/useResizeClientHeight'; import { useCardGridMetrics } from '../hooks/useCardGridMetrics'; import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer'; import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin'; import type { CardGridRowHeightVariant } from '../utils/cardGridLayout'; export type VirtualCardGridProps = { items: readonly T[]; itemKey: (item: T, flatIndex: number) => string; renderItem: (item: T) => React.ReactNode; rowVariant: CardGridRowHeightVariant; disableVirtualization: boolean; /** Bumps layout when list shape changes (e.g. `items.length`). */ layoutSignal: number; wrapClassName?: string; /** Optional styles on the outer measurement wrapper (e.g. enter animation). */ wrapStyle?: React.CSSProperties; /** Defaults to `var(--space-4)`; composer grid uses `var(--space-2)`. */ gridGap?: string; /** When set, row virtualization uses this scroll container instead of the main route viewport. */ scrollRootId?: string; /** Pre-peek disk WebP for the first viewport of cards (one IPC batch before cells ensure). */ warmGridCovers?: { pickCoverArtId: (item: T) => string | null | undefined; displayCssPx: number; limit?: number; }; }; /** * Album-/playlist-style card grids: at most six columns, proportional stretch, * optional row virtualization with scroll root `#APP_MAIN_SCROLL_VIEWPORT_ID` * (or `scrollRootId` when the grid lives in an in-page overlay viewport). */ export function VirtualCardGrid({ items, itemKey, renderItem, rowVariant, disableVirtualization, layoutSignal, wrapClassName = 'album-grid-wrap', wrapStyle, gridGap = 'var(--space-4)', scrollRootId, warmGridCovers, }: VirtualCardGridProps): React.JSX.Element { const warmLimit = warmGridCovers?.limit ?? GRID_COVER_WARM_LIMIT; const warmItems = useMemo(() => { if (!warmGridCovers) return []; return items .slice(0, warmLimit) .map(item => ({ coverArt: warmGridCovers.pickCoverArtId(item) ?? null })); }, [items, warmGridCovers, warmLimit]); const warmPeekKey = useMemo( () => warmItems.map(i => i.coverArt ?? '').join('\u0001'), [warmItems], ); useWarmGridCovers(warmItems, warmGridCovers?.displayCssPx ?? 0, { enabled: Boolean(warmGridCovers && warmGridCovers.displayCssPx > 0), limit: warmLimit, warmKey: warmPeekKey, }); const wrapRef = useRef(null); const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal); const cols = Math.max(1, gridCols); const virtualRowCount = Math.max(0, Math.ceil(items.length / cols)); const scrollMetricsElementId = scrollRootId ?? APP_MAIN_SCROLL_VIEWPORT_ID; const scrollViewportClientHeight = useElementClientHeightById(scrollMetricsElementId); const overscan = Math.max(2, Math.ceil(scrollViewportClientHeight / Math.max(1, rowHeightEst))); const getScrollElement = useCallback((): HTMLElement | null => { if (scrollRootId) { return ( document.getElementById(scrollRootId) as HTMLElement | null ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null) ); } return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null; }, [scrollRootId]); const scrollMargin = useVirtualizerScrollMargin(wrapRef, getScrollElement, { active: !disableVirtualization, deps: [layoutSignal, virtualRowCount, scrollRootId], }); const virtualizer = useVirtualizer({ count: disableVirtualization ? 0 : virtualRowCount, getScrollElement, estimateSize: () => rowHeightEst, overscan, scrollMargin, }); useRemeasureGridVirtualizer(virtualizer, { active: !disableVirtualization && virtualRowCount > 0, gridCols: cols, rowHeightEst, virtualRowCount, }); if (disableVirtualization) { return (
{items.map((item, i) => ( {renderItem(item)} ))}
); } return (
{virtualizer.getVirtualItems().map(vRow => { const start = vRow.index * cols; const rowItems = items.slice(start, start + cols); // `vRow.start` is measured from the scroll element's top; our wrapper // already sits `scrollMargin` below that, so subtract to land back // in wrapper-local coordinates. return (
{rowItems.map((item, i) => ( {renderItem(item)} ))}
); })}
); }