// SPDX-License-Identifier: AGPL-3.0-or-later import {useMasonryGridNavigation} from '@app/features/app/hooks/useMasonryGridNavigation'; import { MASONRY_OVERSCAN_PX, MASONRY_PADDING_PX, MASONRY_SCROLL_CHUNK_PX, } from '@app/features/channel/components/pickers/shared/PickerConstants'; import {TileFlowSolver} from '@app/features/channel/components/TileFlowSolver'; import type * as React from 'react'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; type VisibleItemTuple = [itemKey: string, sectionIndex: number, itemIndex: number]; interface TileBox { position: 'absolute' | 'sticky'; left?: number; right?: number; top?: number; width: number; height: number; } type TileBoxMap = Record; type WindowedTiles = Record>; interface GridCell { section: number; row: number; column: number; } interface NavGrid { rowBase: Array; tileCells: Record; } export interface MasonryExtraSection { sectionIndex: number; height: number; render: () => React.ReactNode; } const EMPTY_EXTRA_SECTIONS: ReadonlyArray = []; export function MasonryVirtualGrid({ data, itemKeys, columns, tileSpacing, viewportWidth, viewportHeight, scrollTop, tileKeyOf, tileHeightOf, onSelectItemKey, checkSuspension, renderItem, onContentSizeChange, extraSections, overscanPx = MASONRY_OVERSCAN_PX, paddingPx = MASONRY_PADDING_PX, bottomPaddingPx = MASONRY_PADDING_PX * 2, }: { data: ReadonlyArray; itemKeys: ReadonlyArray; columns: number; tileSpacing: number; viewportWidth: number; viewportHeight: number; scrollTop: number; tileKeyOf: (item: T, index: number) => string; tileHeightOf: (item: T, index: number, laneWidth: number) => number; onSelectItemKey: (itemKey: string) => void; checkSuspension: () => boolean; renderItem: (args: {item: T; itemKey: string; coords: TileBox; isFocused: boolean; index: number}) => React.ReactNode; onContentSizeChange?: (contentSize: number) => void; extraSections?: ReadonlyArray; overscanPx?: number; paddingPx?: number; bottomPaddingPx?: number; }) { const stableExtraSections = extraSections ?? EMPTY_EXTRA_SECTIONS; const [masonrySolver] = useState(() => new TileFlowSolver()); const containerRef = useRef(null); const [version, setVersion] = useState(0); useEffect(() => { setVersion((v) => v + 1); }, [data, columns, tileSpacing, viewportWidth, viewportHeight, itemKeys, stableExtraSections]); const sectionCount = 1 + stableExtraSections.length; const tileKeyForSolver = useCallback( (sectionIndex: number, itemIndex: number): string | null => { if (sectionIndex !== 0) return null; const item = data[itemIndex]; return item != null ? tileKeyOf(item, itemIndex) : null; }, [data, tileKeyOf], ); const tileHeightForSolver = useCallback( (sectionIndex: number, itemIndex: number, laneWidth: number): number => { if (sectionIndex !== 0) return 0; const item = data[itemIndex]; if (item == null) return 0; return tileHeightOf(item, itemIndex, laneWidth); }, [data, tileHeightOf], ); const sectionHeightForSolver = useCallback( (sectionIndex: number): number => { if (sectionIndex === 0) return 0; const extra = stableExtraSections.find((s) => s.sectionIndex === sectionIndex); return extra?.height ?? 0; }, [stableExtraSections], ); const scrollChunkPx = Math.max(1, Math.min(MASONRY_SCROLL_CHUNK_PX, overscanPx)); const windowStartChunk = Math.floor(scrollTop / scrollChunkPx); const windowEndChunk = Math.ceil((scrollTop + viewportHeight) / scrollChunkPx); const masonryState = useMemo(() => { if (viewportWidth <= 0 || viewportHeight <= 0) { return { tileBoxes: {} as TileBoxMap, windowedTiles: {} as WindowedTiles, contentHeight: 0, navGrid: null, }; } masonrySolver.applySettings({ sectionSizes: [data.length, ...Array.from({length: sectionCount - 1}, () => 0)], laneCount: columns, tileSpacing, tileKeyOf: tileKeyForSolver, tileHeightOf: tileHeightForSolver, sectionHeightOf: sectionHeightForSolver, viewportSpan: viewportWidth, padBox: {left: paddingPx, right: paddingPx, top: 0, bottom: 0}, layoutStamp: version, }); const start = Math.max(0, windowStartChunk * scrollChunkPx - overscanPx); const end = windowEndChunk * scrollChunkPx + overscanPx; masonrySolver.selectWindow(start, end); const state = masonrySolver.readLayout() as { tileBoxes: TileBoxMap; windowedTiles: WindowedTiles; contentHeight: number; navGrid: NavGrid; }; return state; }, [ masonrySolver, data.length, sectionCount, columns, tileSpacing, tileKeyForSolver, tileHeightForSolver, sectionHeightForSolver, viewportWidth, viewportHeight, windowStartChunk, windowEndChunk, scrollChunkPx, overscanPx, paddingPx, version, ]); const {focusedItemKey} = useMasonryGridNavigation({ navGrid: masonryState.navGrid, itemKeys, columns, onSelect: onSelectItemKey, containerRef: containerRef as React.RefObject, checkSuspension, }); const topPadding = paddingPx; const contentSize = masonryState.contentHeight + topPadding + bottomPaddingPx; useEffect(() => { onContentSizeChange?.(contentSize); }, [contentSize, onContentSizeChange]); const visibleEntries = Object.entries(masonryState.windowedTiles) as Array<[string, WindowedTiles[string]]>; const parseSectionIndex = (sectionKey: string): number | null => { const prefix = 'section-'; if (!sectionKey.startsWith(prefix)) return null; const rest = sectionKey.slice(prefix.length); if (rest.includes('-')) return null; const n = Number(rest); return Number.isFinite(n) ? n : null; }; return (
{visibleEntries.flatMap(([sectionKey, items]): Array => { const sectionCoords = masonryState.tileBoxes[sectionKey]; if (!sectionCoords) return []; const sectionTop = (sectionCoords.top ?? 0) + topPadding; const sectionIndex = parseSectionIndex(sectionKey); if (sectionIndex != null && sectionIndex > 0) { const extra = stableExtraSections.find((s) => s.sectionIndex === sectionIndex); if (!extra || extra.height <= 0) return []; return [
{extra.render()}
, ]; } return items.flatMap(([itemKey, itemSectionIndex, itemIndex]): Array => { if (itemSectionIndex !== 0) return []; const itemCoords = masonryState.tileBoxes[itemKey]; if (!itemCoords) return []; const item = data[itemIndex]; if (item == null) return []; const absoluteItemCoords: TileBox = { ...itemCoords, top: (itemCoords.top ?? 0) + sectionTop, }; const node = renderItem({ item, itemKey, coords: absoluteItemCoords, isFocused: focusedItemKey === itemKey, index: itemIndex, }); return node == null ? [] : [node]; }); })}
); }