import { useElementSize, useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; import { AnimatePresence } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { CSSProperties, Ref, UIEvent, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { List, RowComponentProps, useListRef } from 'react-window-v2'; import { ExpandedListContainer } from '../expanded-list-container'; import styles from './item-grid-list.module.css'; import { getDataRowsCount, ItemCard } from '/@/renderer/components/item-card/item-card'; import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item'; import { itemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { ItemListStateActions, useItemListState, } from '/@/renderer/components/item-list/helpers/item-list-state'; import { ItemListHandle } from '/@/renderer/components/item-list/types'; import { LibraryItem } from '/@/shared/types/domain-types'; export interface GridItemProps { columns: number; data: any[]; enableExpansion?: boolean; enableSelection?: boolean; gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; internalState: ItemListStateActions; itemType: LibraryItem; } export interface ItemGridListProps { data: unknown[]; enableExpansion?: boolean; enableSelection?: boolean; gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; initialTop?: { behavior?: 'auto' | 'smooth'; to: number; type: 'index' | 'offset'; }; itemsPerRow?: number; itemType: LibraryItem; onEndReached?: (index: number, handle: ItemListHandle) => void; onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void; onScroll?: (e: UIEvent) => void; onScrollEnd?: (offset: number, handle: ItemListHandle) => void; onStartReached?: (index: number, handle: ItemListHandle) => void; ref?: Ref; } export const ItemGridList = ({ data, enableExpansion = true, enableSelection = true, gap = 'sm', initialTop, itemsPerRow, itemType, onEndReached, onRangeChanged, onScroll, onScrollEnd, onStartReached, ref, }: ItemGridListProps) => { const itemGridRef = useListRef(null); const scrollContainerRef = useRef(null); const { ref: containerRef, width: containerWidth } = useElementSize(); const mergedContainerRef = useMergedRef(containerRef, scrollContainerRef); const internalState = useItemListState(); const [initialize] = useOverlayScrollbars({ defer: true, events: { initialized(osInstance) { const { viewport } = osInstance.elements(); viewport.style.overflowX = `var(--os-viewport-overflow-x)`; viewport.style.overflowY = `var(--os-viewport-overflow-y)`; }, }, options: { overflow: { x: 'hidden', y: 'scroll' }, paddingAbsolute: true, scrollbars: { autoHide: 'leave', autoHideDelay: 500, pointers: ['mouse', 'pen', 'touch'], theme: 'feishin-os-scrollbar', }, }, }); useEffect(() => { const { current: root } = scrollContainerRef; if (root) { initialize({ elements: { viewport: root.firstElementChild as HTMLElement }, target: root, }); } }, [itemGridRef, initialize]); const isInitialScrollPositionSet = useRef(false); const hasExpanded = internalState.hasExpanded(); const handleOnScrollEnd = useCallback( (scrollTop: number, handle: ItemListHandle) => { onScrollEnd?.(scrollTop, handle); }, [onScrollEnd], ); const debouncedOnScrollEnd = debounce(handleOnScrollEnd, 150); const handleScroll = useCallback( (e: UIEvent) => { onScroll?.(e); debouncedOnScrollEnd( e.currentTarget.scrollTop, itemGridRef.current ?? (undefined as any), ); }, [onScroll, debouncedOnScrollEnd, itemGridRef], ); const scrollToGridOffset = useCallback((offset: number) => { const scrollContainer = scrollContainerRef.current?.firstElementChild as | HTMLElement | undefined; if (scrollContainer) { scrollContainer.scrollTo({ behavior: 'instant', top: offset }); } }, []); const [tableMeta, setTableMeta] = useState(null); // Throttled function to update table meta const throttledSetTableMeta = useMemo(() => { return throttle((width: number, dataLength: number, type: LibraryItem) => { const isSm = width >= 600; const isMd = width >= 768; const isLg = width >= 960; const isXl = width >= 1200; const is2xl = width >= 1440; const is3xl = width >= 1920; const is4xl = width >= 2560; let dynamicItemsPerRow = 2; if (is4xl) { dynamicItemsPerRow = 12; } else if (is3xl) { dynamicItemsPerRow = 10; } else if (is2xl) { dynamicItemsPerRow = 8; } else if (isXl) { dynamicItemsPerRow = 6; } else if (isLg) { dynamicItemsPerRow = 5; } else if (isMd) { dynamicItemsPerRow = 4; } else if (isSm) { dynamicItemsPerRow = 3; } else { dynamicItemsPerRow = 2; } const setItemsPerRow = itemsPerRow || dynamicItemsPerRow; const widthPerItem = Number(width) / setItemsPerRow; const itemHeight = widthPerItem + getDataRowsCount(type) * 26; if (widthPerItem === 0) { return; } setTableMeta({ columnCount: setItemsPerRow, itemHeight, rowCount: Math.ceil(dataLength / setItemsPerRow), }); }, 200); }, [itemsPerRow]); useLayoutEffect(() => { throttledSetTableMeta(containerWidth, data.length, itemType); }, [containerWidth, data.length, itemType, throttledSetTableMeta]); const handleOnRowsRendered = useCallback( (visibleRows: { startIndex: number; stopIndex: number }) => { onRangeChanged?.({ endIndex: visibleRows.stopIndex * (tableMeta?.columnCount || 0), startIndex: visibleRows.startIndex * (tableMeta?.columnCount || 0), }); if (onStartReached || onEndReached) { const totalRows = Math.ceil(data.length / (tableMeta?.columnCount || 0)); const startRow = visibleRows.startIndex; const endRow = visibleRows.stopIndex; if (startRow === 0) { onStartReached?.(startRow, itemGridRef.current ?? (undefined as any)); } if (endRow >= totalRows) { onEndReached?.(endRow, itemGridRef.current ?? (undefined as any)); } } }, [ onRangeChanged, tableMeta?.columnCount, onStartReached, onEndReached, data.length, itemGridRef, ], ); const elements = useMemo(() => { if (!tableMeta) { return []; } return data .map((d, i) => { return { data: d, index: i, }; }) .reduce( (acc, d) => { if (d.index % (tableMeta?.columnCount || 0) === 0) { acc.push([]); } const prev = acc[acc.length - 1]; prev.push(d); return acc; }, [] as { data: any; index: number }[][], ); }, [tableMeta, data]); const itemProps: GridItemProps = { columns: tableMeta?.columnCount || 0, data: elements, enableExpansion, enableSelection, gap, internalState, itemType, }; useEffect(() => { if (!initialTop || isInitialScrollPositionSet.current || !tableMeta?.itemHeight) return; isInitialScrollPositionSet.current = true; if (initialTop.type === 'offset') { scrollToGridOffset(initialTop.to); } else { itemGridRef.current?.scrollToRow({ behavior: initialTop.behavior, index: initialTop.to, }); } }, [initialTop, itemGridRef, scrollToGridOffset, tableMeta?.itemHeight]); const imperativeHandle: ItemListHandle = useMemo(() => { return { clearExpanded: () => { internalState.clearExpanded(); }, clearSelected: () => { internalState.clearSelected(); }, getItem: (index: number) => data[index], getItemCount: () => data.length, getItems: () => data, internalState, scrollToIndex: (index: number) => { itemGridRef.current?.scrollToRow({ align: 'smart', behavior: 'auto', index: Math.floor(index / (tableMeta?.columnCount || 1)), }); }, scrollToOffset: (offset: number) => { scrollToGridOffset(offset); }, }; }, [data, internalState, scrollToGridOffset, tableMeta?.columnCount, itemGridRef]); useImperativeHandle(ref, () => imperativeHandle); return (
{hasExpanded && ( )}
); }; const ListComponent = ({ columns, data, enableExpansion, enableSelection, gap, index, internalState, itemType, style, }: RowComponentProps) => { return (
{data[index].map((d) => (
{ return itemListControls.handleItemClick( item, itemType, internalState, ); } : undefined, onDoubleClick: (item, itemType) => { return itemListControls.handleItemDoubleClick( item, itemType, internalState, ); }, onFavorite: (item, itemType) => { return itemListControls.handleItemFavorite( item, itemType, internalState, ); }, onItemExpand: enableExpansion ? (item, itemType) => { return itemListControls.handleItemExpand( item, itemType, internalState, ); } : undefined, onMore: (item, itemType) => { return itemListControls.handleItemMore( item, itemType, internalState, ); }, onPlay: (item, itemType, playType) => { return itemListControls.handleItemPlay( item, itemType, playType, internalState, ); }, onRating: (item, itemType) => { return itemListControls.handleItemRating( item, itemType, internalState, ); }, }} data={d.data} itemType={itemType} withControls />
))}
); };