diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 95e6849b..0410c45f 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -1,14 +1,8 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; import type { Video } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import VideoCard from "./VideoCard"; +import VirtualGrid from "./VirtualGrid"; -// Column sizing mirrors the CSS the feed used before virtualization -// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). The per-view -// minimum column width lives with the rest of the view matrix in lib/feedView.ts. -const GRID_GAP = 16; -const LIST_GAP = 4; // Estimated row heights before measurement; real heights are measured per-row so these only affect // the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the // browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in. @@ -24,35 +18,6 @@ const ROW_EST: Record = { tiles: 132, }; -/** Columns that fit `width`, or 1 for the single-column views. */ -function columnsFor(view: FeedView, width: number): number { - const { minCol } = FEED_VIEW_SPEC[view]; - if (minCol == null) return 1; - return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP))); -} -// Start fetching the next page this many rows before the end scrolls into view -// (keeps the old IntersectionObserver's generous prefetch feel). -const PREFETCH_ROWS = 4; - -function chunk(arr: T[], size: number): T[][] { - if (size <= 1) return arr.map((x) => [x]); - const out: T[][] = []; - for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); - return out; -} - -// The feed scrolls inside the app's
, not the window, so the -// virtualizer needs that element. Walk up to the nearest scrollable ancestor. -function getScrollParent(node: HTMLElement | null): HTMLElement { - let el = node?.parentElement ?? null; - while (el) { - const oy = getComputedStyle(el).overflowY; - if (oy === "auto" || oy === "scroll") return el; - el = el.parentElement; - } - return document.documentElement; -} - export interface VirtualFeedProps { items: Video[]; view: FeedView; @@ -65,6 +30,9 @@ export interface VirtualFeedProps { fetchNextPage: () => void; } +// The feed's videos on the shared VirtualGrid engine — this wrapper only maps a FeedView to the +// grid's layout spec and renders a VideoCard per item. All the virtualization mechanics (measure, +// chunk, place, infinite-scroll) live in VirtualGrid. export default function VirtualFeed({ items, view, @@ -76,123 +44,31 @@ export default function VirtualFeed({ isFetchingNextPage, fetchNextPage, }: VirtualFeedProps) { - const listRef = useRef(null); - const [scrollEl, setScrollEl] = useState(null); - const { minCol, maxCol } = FEED_VIEW_SPEC[view]; - const singleCol = minCol == null; - const [colCount, setColCount] = useState(singleCol ? 1 : 4); - // Distance from the scroll element's content top to where this list starts (the - // feed toolbar sits above it inside the same scroll area). - const [scrollMargin, setScrollMargin] = useState(0); - // How wide ONE row/card actually renders. The one-line row drops columns as this shrinks, and it - // has to be the real width, not the viewport: the nav rail (56 slim / ~230 pinned) and the filter - // panel (~80 tab / ~256 pinned) move it by ~430px between them. A viewport breakpoint got this - // wrong — at 1280 with both pinned every column still showed while the row had 762px for 766px of - // them, which squeezed the title to nothing. This is measured here anyway, for colCount. - const [itemWidth, setItemWidth] = useState(0); - - useLayoutEffect(() => { - if (listRef.current) setScrollEl(getScrollParent(listRef.current)); - }, []); - - // Re-measure the column count and start offset on container/scroll-area resize. - useLayoutEffect(() => { - const node = listRef.current; - if (!node) return; - const measure = () => { - if (scrollEl) { - const m = - node.getBoundingClientRect().top - - scrollEl.getBoundingClientRect().top + - scrollEl.scrollTop; - setScrollMargin(m); - } - const w = node.clientWidth; - const cols = columnsFor(view, w); - setColCount(cols); - const { minCol: min, maxCol: max } = FEED_VIEW_SPEC[view]; - setItemWidth( - min == null ? Math.min(w, max ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols) - ); - }; - measure(); - const ro = new ResizeObserver(measure); - ro.observe(node); - if (scrollEl) ro.observe(scrollEl); - return () => ro.disconnect(); - }, [scrollEl, view]); - - const rows = useMemo(() => chunk(items, colCount), [items, colCount]); - - const virtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => scrollEl, - estimateSize: () => ROW_EST[view], - overscan: 4, - gap: singleCol ? LIST_GAP : GRID_GAP, - scrollMargin, - }); - - const virtualRows = virtualizer.getVirtualItems(); - - // Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of - // the end, pull the next keyset page. - useEffect(() => { - const last = virtualRows[virtualRows.length - 1]; - if (!last) return; - if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]); - + const { minCol, maxCol, thumb } = FEED_VIEW_SPEC[view]; return ( -
- {virtualRows.map((vr) => { - const row = rows[vr.index]; - if (!row) return null; - return ( -
- {/* One branch for both shapes: the single-column views just chunk to colCount 1 and - get their reading-width cap instead of grid columns. */} -
- {row.map((v) => ( - - ))} -
-
- ); - })} -
+ v.id} + minCol={minCol} + maxCol={maxCol} + rowEst={ROW_EST[view]} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + renderItem={(v, itemWidth) => ( + + )} + /> ); } diff --git a/frontend/src/components/VirtualGrid.tsx b/frontend/src/components/VirtualGrid.tsx new file mode 100644 index 00000000..8a875bc0 --- /dev/null +++ b/frontend/src/components/VirtualGrid.tsx @@ -0,0 +1,181 @@ +import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can +// reuse it (the feed's video grid, the channel manager's card/row views). It owns ONLY the layout +// mechanics — measure the container, derive the column count, chunk into rows, virtualize, and place +// each row absolutely — and knows nothing about what an item is. The caller supplies `renderItem`. +// +// Column sizing mirrors the CSS the feed used before virtualization +// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). +const GRID_GAP = 16; +const LIST_GAP = 4; +// Start fetching the next page this many rows before the end scrolls into view +// (keeps the old IntersectionObserver's generous prefetch feel). +const PREFETCH_ROWS = 4; + +/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */ +function columnsFor(minCol: number | null, width: number): number { + if (minCol == null) return 1; + return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP))); +} + +function chunk(arr: T[], size: number): T[][] { + if (size <= 1) return arr.map((x) => [x]); + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +// The list scrolls inside an
(the page scroller), not the window, so the +// virtualizer needs that element. Walk up to the nearest scrollable ancestor. +function getScrollParent(node: HTMLElement | null): HTMLElement { + let el = node?.parentElement ?? null; + while (el) { + const oy = getComputedStyle(el).overflowY; + if (oy === "auto" || oy === "scroll") return el; + el = el.parentElement; + } + return document.documentElement; +} + +export interface VirtualGridProps { + items: T[]; + getKey: (item: T) => string; + /** Narrowest grid column in px, or null for ONE full-width column. The column count is derived + * from this and the container width, so every multi-column layout reflows for free. */ + minCol: number | null; + /** Single-column layout only: the reading-width cap in px (null = fill). */ + maxCol?: number | null; + /** Estimated row height before measurement; real heights are measured per-row, so this only + * affects the very first paint and the scrollbar's initial guess. Measure it, don't guess — a + * bad estimate makes the scrollbar jump as you scroll in. */ + rowEst: number; + /** Renders one item. `itemWidth` is the measured px width of one cell — for width-derived + * layouts (e.g. a one-line row that drops columns as it narrows); ignore it otherwise, and to + * protect a memo'd item from re-rendering on every resize frame, pass a stable value down. */ + renderItem: (item: T, itemWidth: number) => ReactNode; + /** Infinite scroll — omit all three for a finite client-side list. */ + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + fetchNextPage?: () => void; +} + +export default function VirtualGrid({ + items, + getKey, + minCol, + maxCol = null, + rowEst, + renderItem, + hasNextPage = false, + isFetchingNextPage = false, + fetchNextPage, +}: VirtualGridProps) { + const listRef = useRef(null); + const [scrollEl, setScrollEl] = useState(null); + const singleCol = minCol == null; + const [colCount, setColCount] = useState(singleCol ? 1 : 4); + // Distance from the scroll element's content top to where this list starts (a toolbar sits above + // it inside the same scroll area). + const [scrollMargin, setScrollMargin] = useState(0); + // How wide ONE row/card actually renders. It has to be the real container width, not the + // viewport: side rails/panels move it independently of the window, and a viewport breakpoint got + // this wrong before. This is measured here anyway, for colCount. + const [itemWidth, setItemWidth] = useState(0); + + useLayoutEffect(() => { + if (listRef.current) setScrollEl(getScrollParent(listRef.current)); + }, []); + + // Re-measure the column count and start offset on container/scroll-area resize. + useLayoutEffect(() => { + const node = listRef.current; + if (!node) return; + const measure = () => { + if (scrollEl) { + const m = + node.getBoundingClientRect().top - + scrollEl.getBoundingClientRect().top + + scrollEl.scrollTop; + setScrollMargin(m); + } + const w = node.clientWidth; + const cols = columnsFor(minCol, w); + setColCount(cols); + setItemWidth( + minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols) + ); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(node); + if (scrollEl) ro.observe(scrollEl); + return () => ro.disconnect(); + }, [scrollEl, minCol, maxCol]); + + const rows = useMemo(() => chunk(items, colCount), [items, colCount]); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollEl, + estimateSize: () => rowEst, + overscan: 4, + gap: singleCol ? LIST_GAP : GRID_GAP, + scrollMargin, + }); + + const virtualRows = virtualizer.getVirtualItems(); + + // Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull + // the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false). + useEffect(() => { + if (!fetchNextPage) return; + const last = virtualRows[virtualRows.length - 1]; + if (!last) return; + if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( +
+ {virtualRows.map((vr) => { + const row = rows[vr.index]; + if (!row) return null; + return ( +
+ {/* One branch for both shapes: the single-column layout chunks to colCount 1 and gets + its reading-width cap instead of grid columns. */} +
+ {/* A transparent Fragment carries React's key so the rendered item stays the DIRECT + grid/column child — some cards rely on that (e.g. `h-full` stretch, actions pinned + to the bottom edge), which a wrapper element would silently break. */} + {row.map((item) => ( + {renderItem(item, itemWidth)} + ))} +
+
+ ); + })} +
+ ); +}