Files
pinkudex/components/grid/MasonryGrid.tsx
T
2026-05-26 22:46:00 +02:00

303 lines
12 KiB
TypeScript

"use client";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { ImageCard, type CardImage } from "./ImageCard";
import type { LibraryView } from "./ViewToggle";
import { useSettings } from "@/components/settings/SettingsProvider";
/**
* Windowed grid: renders only visible rows, mounting/unmounting cards
* as the user scrolls. Backed by react-virtuoso's window-scroll mode
* so the page itself scrolls (not an inner div).
*
* Items are bucketed into rows of `cols` cards each — Virtuoso treats
* each row as one item and measures its height dynamically. This keeps
* the masonry-style CSS grid (equal columns, variable card heights)
* working without forcing fixed-aspect cards.
*
* Below a small threshold we render a plain CSS grid — virtualization
* overhead isn't worth it for short lists, and the simpler DOM tree
* cooperates better with browser-native fade-in / scroll animations.
*/
interface Props {
images: CardImage[];
view?: LibraryView;
/** Highest page number currently appended. Used to decide whether
* to keep firing endReached. */
loadedEnd?: number;
/** Total pages for the current filter set. */
totalPages?: number;
/** When false, infinite-scroll appends are suppressed (filtered
* views, user toggle, etc.). */
infiniteScrollEnabled?: boolean;
/** Called when Virtuoso detects we're near the end and more pages
* exist. Parent fetches and grows `images`. */
onEndReached?: () => void;
/** True while the parent is fetching the next page — drives the
* small "Loading next page..." footer beneath the grid. */
isFetching?: boolean;
/** Per-batch fade controller. Each row looks up which batch its
* items belong to; the first row of that batch to intersect the
* viewport triggers the batch, fading every row of the batch in
* unison. */
fadeController?: FadeController;
/** Fade animation duration in ms; matches CSS `--fade-duration`. */
fadeMs?: number;
/** Items per logical "page" — used to derive the page currently
* scrolled into view from the first-visible row index. */
pageSize?: number;
/** The page the SSR rendered from. Item index 0 corresponds to this
* page, not page 1, when the user landed via ?page=N. */
ssrAnchorPage?: number;
/** Fires whenever the viewport's leading row changes pages. */
onVisiblePageChange?: (page: number) => void;
/** Receives Virtuoso's imperative handle so the parent can call
* `scrollToIndex` on it (e.g. for Prev/Next navigation jumps). */
virtuosoHandleRef?: React.MutableRefObject<VirtuosoHandle | null>;
}
interface FadeController {
batchIdOf(itemId: number): number | null;
isTriggered(batchId: number): boolean;
trigger(batchId: number): void;
subscribe(batchId: number, cb: () => void): () => void;
expire(batchId: number): void;
}
function MasonryRow({
rowIdx, row, cols, view, fadeController, fadeMs,
}: {
rowIdx: number;
row: CardImage[];
cols: number;
view: LibraryView;
fadeController?: FadeController;
fadeMs?: number;
}) {
// Resolve this row's batch once on mount. If null, the row was part
// of the SSR initial page (or its batch already expired) — no fade.
const [batchId] = useState<number | null>(
() => fadeController?.batchIdOf(row[0]?.id ?? -1) ?? null,
);
const isFresh = batchId != null;
// `animated` flips when the batch's trigger fires. Initial value
// honors a batch that was already triggered (e.g. row remounted via
// Virtuoso unmount/remount mid-fade).
const [animated, setAnimated] = useState<boolean>(
() => batchId != null && !!fadeController?.isTriggered(batchId),
);
const ref = useRef<HTMLDivElement | null>(null);
// Subscribe to batch trigger so every row flips at the same time.
useEffect(() => {
if (!isFresh || !fadeController || batchId == null) return;
return fadeController.subscribe(batchId, () => setAnimated(true));
}, [isFresh, fadeController, batchId]);
// First row to intersect triggers the batch. Virtuoso pre-renders
// rows in its overscan window (well below the viewport), so without
// this gate the animation would finish off-screen.
useEffect(() => {
if (!isFresh || animated || !fadeController || batchId == null) return;
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
fadeController.trigger(batchId);
io.disconnect();
return;
}
}
});
io.observe(el);
return () => io.disconnect();
}, [isFresh, animated, fadeController, batchId]);
// Once the animation has played, drop the batch so that any future
// remount of these rows doesn't replay the keyframe.
useEffect(() => {
if (!animated || !fadeController || batchId == null) return;
const t = setTimeout(() => fadeController.expire(batchId), (fadeMs ?? 0) + 50);
return () => clearTimeout(t);
}, [animated, fadeController, batchId, fadeMs]);
const showFade = isFresh && animated;
const hiddenUntilSeen = isFresh && !animated;
return (
<div
ref={ref}
data-masonry-row={rowIdx}
className={showFade ? "grid gap-5 pb-5 fade-in" : "grid gap-5 pb-5"}
style={{
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
...(hiddenUntilSeen ? { opacity: 0 } : null),
}}
>
{row.map((img) => (
<ImageCard key={img.id} image={img} view={view} />
))}
</div>
);
}
export function MasonryGrid({
images,
view = "landscape",
loadedEnd = 1,
totalPages = 1,
infiniteScrollEnabled = true,
onEndReached,
isFetching = false,
fadeController,
fadeMs,
pageSize = 100,
ssrAnchorPage = 1,
onVisiblePageChange,
virtuosoHandleRef,
}: Props) {
const { settings } = useSettings();
const cols = view === "portrait"
? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6))
: Math.max(2, Math.min(4, settings.gridColumns || 3));
// CSS var is used by the simple-grid path so the column count is
// correct on first paint regardless of what `settings.gridColumns`
// resolves to client-side. Virtuoso path uses the JS number because
// it bucket-rows items + only renders post-hydration anyway.
const cssCols = view === "portrait" ? "var(--grid-cols-portrait, 6)" : "var(--grid-cols, 3)";
const rows = useMemo(() => {
const out: CardImage[][] = [];
for (let i = 0; i < images.length; i += cols) {
out.push(images.slice(i, i + cols));
}
return out;
}, [images, cols]);
if (images.length === 0) return null;
// Threshold for short lists: skip virtualization and render plain
// grid. ImageCard's lazy-loaded thumbnails handle the bytes side.
// Above the threshold we go through Virtuoso *from first render* —
// initialItemCount lets SSR emit a few rows so the simple-grid →
// virtuoso swap (and its flash) is gone.
const SIMPLE_THRESHOLD = 24;
if (images.length <= SIMPLE_THRESHOLD) {
return (
<div
className="grid gap-5"
style={{ gridTemplateColumns: `repeat(${cssCols}, minmax(0, 1fr))` }}
>
{images.map((img) => (
<ImageCard key={img.id} image={img} view={view} />
))}
</div>
);
}
const canLoadMore = infiniteScrollEnabled && loadedEnd < totalPages;
// Track which "page" sits at the top of the viewport. We scan
// rendered rows on each scroll tick and pick the first one whose
// bottom edge is past the viewport top — that's the row currently
// crossing/just-below the top. Virtuoso's own rangeChanged reports
// the *rendered* range (includes overscan above viewport) so it
// lags by a row or two.
const lastReportedPage = useRef<number>(ssrAnchorPage);
const totalItemsRef = useRef<number>(images.length);
useEffect(() => { totalItemsRef.current = images.length; }, [images.length]);
useEffect(() => {
if (!onVisiblePageChange) return;
const update = () => {
// Find the *bottom-most* row currently visible — i.e. the row
// furthest along that has any pixel in the viewport. Combined
// with last-item page derivation, the label flips the moment
// the first card of the next page peeks in from the bottom of
// the viewport.
const vh = window.innerHeight;
const els = document.querySelectorAll<HTMLElement>("[data-masonry-row]");
let chosen: HTMLElement | null = null;
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.top < vh && r.bottom > 0) chosen = el; // keep updating
}
if (!chosen && els.length > 0) chosen = els[0];
if (!chosen) return;
const idx = Number(chosen.dataset.masonryRow ?? 0);
// Last item of the bottom-most visible row drives the page
// label. As soon as a row containing the first card of the
// next page enters from the bottom, the label flips. Clamp to
// the actual item count so a partially-filled trailing row
// (e.g. only 1 of 3 slots populated) doesn't claim a page that
// has no real content yet.
const total = totalItemsRef.current;
const itemIdx = Math.min(
idx * cols + Math.max(0, cols - 1),
Math.max(0, total - 1),
);
const page = ssrAnchorPage + Math.floor(itemIdx / pageSize);
if (page !== lastReportedPage.current) {
lastReportedPage.current = page;
onVisiblePageChange(page);
}
};
let raf: number | null = null;
const onScroll = () => {
if (raf != null) return;
raf = requestAnimationFrame(() => { raf = null; update(); });
};
window.addEventListener("scroll", onScroll, { passive: true });
update();
return () => {
window.removeEventListener("scroll", onScroll);
if (raf != null) cancelAnimationFrame(raf);
};
}, [cols, pageSize, ssrAnchorPage, onVisiblePageChange]);
return (
<>
<Virtuoso
ref={(h) => { if (virtuosoHandleRef) virtuosoHandleRef.current = h; }}
useWindowScroll
// `data` makes Virtuoso re-render rows whenever the array
// reference changes (e.g. on append). Without it, item
// closures freeze on the first render and updates to
// fadeFromIndex / loadedEnd never propagate into rows.
data={rows}
initialItemCount={Math.min(rows.length, 8)}
endReached={canLoadMore ? onEndReached : undefined}
increaseViewportBy={600}
itemContent={(rowIdx, row) => {
if (!row) return null;
return (
<MasonryRow
rowIdx={rowIdx}
row={row}
cols={cols}
view={view}
fadeController={fadeController}
fadeMs={fadeMs}
/>
);
}}
overscan={600}
// Key by the first card's id, falling back to rowIdx for empty
// rows. Joining every id in the row meant a partial trailing
// row (e.g. 2 of 3 columns filled) re-keyed to a brand-new
// identity once infinite-scroll filled the missing slots,
// unmounting the row mid-scroll and blanking the in-flight
// fade-in batch. The first id is stable across that fill.
computeItemKey={(rowIdx, row) => (row && row[0] ? row[0].id : rowIdx)}
/>
{isFetching && (
<div className="flex items-center justify-center gap-2 py-4 text-xs font-mono text-[var(--color-fg-muted)]">
<Loader2 className="w-4 h-4 animate-spin" /> Loading next page...
</div>
)}
</>
);
}