update ItemGrid to use react-window v2

This commit is contained in:
jeffvli
2025-09-28 19:20:29 -07:00
parent ff83ce5254
commit 9db7830726
3 changed files with 298 additions and 241 deletions
@@ -1,6 +1,15 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence } from 'motion/react'; import { AnimatePresence } from 'motion/react';
import { Dispatch, Fragment, lazy, ReactNode, SetStateAction, useState } from 'react'; import {
Dispatch,
Fragment,
lazy,
memo,
MouseEvent,
ReactNode,
SetStateAction,
useState,
} from 'react';
import { generatePath, Link } from 'react-router-dom'; import { generatePath, Link } from 'react-router-dom';
import styles from './item-card.module.css'; import styles from './item-card.module.css';
@@ -35,7 +44,11 @@ interface ItemCardProps {
data: Album | AlbumArtist | Artist | Playlist | Song | undefined; data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
isRound?: boolean; isRound?: boolean;
itemType: LibraryItem; itemType: LibraryItem;
onClick?: () => void; onClick?: (
e: MouseEvent<HTMLDivElement>,
item: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
) => void;
onItemExpand?: () => void; onItemExpand?: () => void;
onItemSelect?: () => void; onItemSelect?: () => void;
type?: 'compact' | 'default' | 'poster'; type?: 'compact' | 'default' | 'poster';
@@ -121,6 +134,7 @@ const CompactItemCard = ({
data, data,
imageUrl, imageUrl,
isRound, isRound,
itemType,
onClick, onClick,
onItemExpand, onItemExpand,
onItemSelect, onItemSelect,
@@ -134,7 +148,7 @@ const CompactItemCard = ({
<div className={clsx(styles.container, styles.compact)}> <div className={clsx(styles.container, styles.compact)}>
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={onClick} onClick={(e) => onClick?.(e, data, itemType)}
onMouseEnter={() => withControls && setShowControls(true)} onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)} onMouseLeave={() => withControls && setShowControls(false)}
> >
@@ -158,7 +172,7 @@ const CompactItemCard = ({
return ( return (
<div className={clsx(styles.container, styles.compact)}> <div className={clsx(styles.container, styles.compact)}>
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}> <div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={styles.image} enableAnimation={false} /> <Skeleton className={styles.image} />
<div className={clsx(styles.detailContainer, styles.compact)}> <div className={clsx(styles.detailContainer, styles.compact)}>
{rows.map((row) => ( {rows.map((row) => (
<div className={styles.row} key={row.id}> <div className={styles.row} key={row.id}>
@@ -175,6 +189,7 @@ const DefaultItemCard = ({
data, data,
imageUrl, imageUrl,
isRound, isRound,
itemType,
onClick, onClick,
onItemExpand, onItemExpand,
onItemSelect, onItemSelect,
@@ -188,7 +203,7 @@ const DefaultItemCard = ({
<div className={clsx(styles.container)}> <div className={clsx(styles.container)}>
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={onClick} onClick={(e) => onClick?.(e, data, itemType)}
onDoubleClick={onItemExpand} onDoubleClick={onItemExpand}
onMouseEnter={() => withControls && setShowControls(true)} onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)} onMouseLeave={() => withControls && setShowControls(false)}
@@ -213,7 +228,7 @@ const DefaultItemCard = ({
return ( return (
<div className={clsx(styles.container)}> <div className={clsx(styles.container)}>
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}> <div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={styles.image} enableAnimation={false} /> <Skeleton className={styles.image} />
</div> </div>
<div className={styles.detailContainer}> <div className={styles.detailContainer}>
{rows.map((row) => ( {rows.map((row) => (
@@ -230,6 +245,7 @@ const PosterItemCard = ({
data, data,
imageUrl, imageUrl,
isRound, isRound,
itemType,
onClick, onClick,
onItemExpand, onItemExpand,
onItemSelect, onItemSelect,
@@ -243,7 +259,7 @@ const PosterItemCard = ({
<div className={clsx(styles.container, styles.poster)}> <div className={clsx(styles.container, styles.poster)}>
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={onClick} onClick={(e) => onClick?.(e, data, itemType)}
onMouseEnter={() => withControls && setShowControls(true)} onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)} onMouseLeave={() => withControls && setShowControls(false)}
> >
@@ -330,6 +346,23 @@ const getDataRows = (itemType: LibraryItem): DataRow[] => {
} }
}; };
export const getDataRowsCount = (itemType: LibraryItem) => {
switch (itemType) {
case LibraryItem.ALBUM:
return 2;
case LibraryItem.ALBUM_ARTIST:
return 1;
case LibraryItem.ARTIST:
return 1;
case LibraryItem.PLAYLIST:
return 2;
case LibraryItem.SONG:
return 2;
default:
return 1;
}
};
const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => { const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
if (data && 'imageUrl' in data) { if (data && 'imageUrl' in data) {
return data.imageUrl || undefined; return data.imageUrl || undefined;
@@ -375,3 +408,5 @@ const ItemCardRow = ({
</Text> </Text>
); );
}; };
export const MemoizedItemCard = memo(ItemCard);
@@ -1,95 +1,38 @@
.item-grid-container { .item-grid-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column !important;
width: 100%;
height: 100%;
padding: 0 var(--theme-spacing-md);
}
.auto-sizer {
width: 100% !important;
height: 100% !important;
}
.list-container {
display: flex;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.grid-list-container { .grid-list-container {
width: 100%; width: 100%;
padding: 0 var(--theme-spacing-md);
}
.item-list {
display: flex;
}
.item-row {
flex: 1 1 calc(100% / var(--columns));
width: 100%;
max-width: calc(100% / var(--columns));
height: 100%; height: 100%;
padding-right: var(--theme-spacing-md);
container-name: grid-list;
container-type: inline-size;
}
.grid-list-component {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-flow: row dense;
@container (min-width: $breakpoint-xs) {
grid-template-columns: repeat(6, 1fr);
}
@container (min-width: $breakpoint-sm) {
grid-template-columns: repeat(8, 1fr);
}
@container (min-width: $breakpoint-md) {
grid-template-columns: repeat(10, 1fr);
}
@container (min-width: $breakpoint-lg) {
grid-template-columns: repeat(12, 1fr);
}
@container (min-width: $breakpoint-xl) {
grid-template-columns: repeat(14, 1fr);
}
@container (min-width: $breakpoint-2xl) {
grid-template-columns: repeat(16, 1fr);
}
@container (min-width: $breakpoint-3xl) {
grid-template-columns: repeat(18, 1fr);
}
/* display: flex; */
/* flex-wrap: wrap; */
}
.grid-item-component {
display: flex;
grid-column: span 2;
padding: var(--theme-spacing-sm); padding: var(--theme-spacing-sm);
overflow: hidden;
/* box-sizing: border-box;
display: flex;
flex: none;
align-content: stretch;
width: 50%;
padding: var(--theme-spacing-sm);
@container (min-width: $breakpoint-xs) {
width: 33.33%;
}
@container (min-width: $breakpoint-sm) {
width: 25%;
}
@container (min-width: $breakpoint-md) {
width: 20%;
}
@container (min-width: $breakpoint-lg) {
width: 14.28%;
}
@container (min-width: $breakpoint-xl) {
width: 12.5%;
}
@container (min-width: $breakpoint-2xl) {
width: 11.11%;
}
@container (min-width: $breakpoint-3xl) {
width: 10%;
} */
} }
.full-width-content { .full-width-content {
@@ -1,83 +1,32 @@
import clsx from 'clsx'; import { useElementSize, useMergedRef } from '@mantine/hooks';
import { throttle } from 'lodash';
import { AnimatePresence, motion, Variants } from 'motion/react'; import { AnimatePresence, motion, Variants } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { import {
CSSProperties, CSSProperties,
forwardRef, MouseEvent,
memo,
ReactNode,
Ref, Ref,
RefObject, UIEvent,
useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { import { List, ListImperativeAPI, RowComponentProps, useListRef } from 'react-window-v2';
GridComponents,
VirtuosoGrid,
VirtuosoGridHandle,
VirtuosoGridProps,
} from 'react-virtuoso';
import { ItemListItem, ItemListStateActions, useItemListState } from '../helpers/item-list-state';
import styles from './item-grid.module.css'; import styles from './item-grid.module.css';
import { ItemCard } from '/@/renderer/components/item-card/item-card'; import { getDataRowsCount, ItemCard } from '/@/renderer/components/item-card/item-card';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item'; import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import {
ItemListStateActions,
useItemListState,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
const gridComponents: GridComponents<any> = { export interface ItemGridProps {
Item: forwardRef<
HTMLDivElement,
{
children?: ReactNode;
className?: string;
context?: ItemContext;
'data-index': number;
enableExpanded?: boolean;
style?: CSSProperties;
virtuosoRef?: RefObject<VirtuosoGridHandle>;
}
>((props, ref) => {
const { children, context, 'data-index': index } = props;
return (
<div className={clsx(styles.gridItemComponent)} ref={ref}>
{children}
</div>
);
}),
List: forwardRef<
HTMLDivElement,
{ children?: ReactNode; className?: string; style?: CSSProperties }
>((props, ref) => {
const { children, className, style, ...rest } = props;
return (
<div
className={clsx(styles.gridListComponent, className)}
ref={ref}
style={{ ...style }}
{...rest}
>
{children}
</div>
);
}),
};
interface ItemContext {
enableExpansion?: boolean;
enableSelection?: boolean;
internalState: ItemListStateActions;
itemType: LibraryItem;
onItemClick?: (item: unknown, index: number) => void;
onItemContextMenu?: (item: unknown, index: number) => void;
onItemDoubleClick?: (item: unknown, index: number) => void;
}
interface ItemGridProps {
data: unknown[]; data: unknown[];
enableExpansion?: boolean; enableExpansion?: boolean;
enableSelection?: boolean; enableSelection?: boolean;
@@ -91,25 +40,34 @@ interface ItemGridProps {
}; };
itemType: LibraryItem; itemType: LibraryItem;
onEndReached?: (index: number) => void; onEndReached?: (index: number) => void;
onIsScrolling?: VirtuosoGridProps<any, any>['isScrolling'];
onItemClick?: (item: unknown, index: number) => void; onItemClick?: (item: unknown, index: number) => void;
onItemContextMenu?: (item: unknown, index: number) => void; onItemContextMenu?: (item: unknown, index: number) => void;
onItemDoubleClick?: (item: unknown, index: number) => void; onItemDoubleClick?: (item: unknown, index: number) => void;
onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void; onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
onScroll?: VirtuosoGridProps<any, any>['onScroll']; onScroll?: (e: UIEvent<HTMLDivElement>) => void;
onScrollEnd?: () => void;
onStartReached?: (index: number) => void; onStartReached?: (index: number) => void;
ref: Ref<VirtuosoGridHandle>; ref: Ref<ListImperativeAPI>;
totalItemCount?: number; totalItemCount?: number;
} }
interface ItemContext {
enableExpansion?: boolean;
enableSelection?: boolean;
internalState: ItemListStateActions;
itemType: LibraryItem;
onItemClick?: (item: unknown, index: number) => void;
onItemContextMenu?: (item: unknown, index: number) => void;
onItemDoubleClick?: (item: unknown, index: number) => void;
}
const expandedAnimationVariants: Variants = { const expandedAnimationVariants: Variants = {
hidden: { hidden: {
height: 0, height: 0,
maxHeight: 0, minHeight: 0,
}, },
show: { show: {
height: '40dvh', minHeight: '300px',
maxHeight: '500px',
transition: { transition: {
duration: 0.3, duration: 0.3,
ease: 'easeInOut', ease: 'easeInOut',
@@ -124,24 +82,31 @@ export const ItemGrid = ({
initialTopMostItemIndex = 0, initialTopMostItemIndex = 0,
itemType, itemType,
onEndReached, onEndReached,
onIsScrolling,
onItemClick, onItemClick,
onItemContextMenu, onItemContextMenu,
onItemDoubleClick, onItemDoubleClick,
onRangeChanged, onRangeChanged,
onScroll, onScroll,
onScrollEnd,
onStartReached, onStartReached,
ref, totalItemCount = 0,
totalItemCount,
}: ItemGridProps) => { }: ItemGridProps) => {
const rootRef = useRef(null); const itemGridRef = useListRef(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null); const { ref: containerRef, width: containerWidth } = useElementSize();
const mergedContainerRef = useMergedRef(containerRef, scrollContainerRef);
const internalState = useItemListState(); const internalState = useItemListState();
const [initialize, osInstance] = useOverlayScrollbars({ const [initialize] = useOverlayScrollbars({
defer: true, 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: { options: {
overflow: { x: 'hidden', y: 'scroll' }, overflow: { x: 'hidden', y: 'scroll' },
paddingAbsolute: true, paddingAbsolute: true,
@@ -156,65 +121,169 @@ export const ItemGrid = ({
}); });
useEffect(() => { useEffect(() => {
const { current: root } = rootRef; const { current: root } = scrollContainerRef;
if (scroller && root) { if (root) {
initialize({ initialize({
elements: { viewport: scroller }, elements: { viewport: root.firstElementChild as HTMLElement },
target: root, target: root,
}); });
} }
}, [itemGridRef, initialize]);
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
const itemContext = useMemo(
() => ({
enableExpansion,
enableSelection,
internalState,
itemType,
onItemClick,
onItemContextMenu,
onItemDoubleClick,
}),
[
internalState,
enableExpansion,
enableSelection,
itemType,
onItemClick,
onItemDoubleClick,
onItemContextMenu,
],
);
const hasExpanded = internalState.hasExpanded(); const hasExpanded = internalState.hasExpanded();
const handleExpand = useCallback(
(_e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => {
if (item && typeof item === 'object' && 'id' in item && 'serverId' in item) {
internalState.toggleExpanded({
id: item.id as string,
itemType: itemType,
serverId: item.serverId as string,
});
}
},
[internalState],
);
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
onScroll?.(e);
},
[onScroll],
);
const [tableMeta, setTableMeta] = useState<null | {
columnCount: number;
itemHeight: number;
rowCount: number;
}>(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 >= 1200;
const isXl = width >= 1500;
const is2xl = width >= 1920;
const is3xl = width >= 2560;
let itemsPerRow = 2;
if (is3xl) {
itemsPerRow = 12;
} else if (is2xl) {
itemsPerRow = 10;
} else if (isXl) {
itemsPerRow = 8;
} else if (isLg) {
itemsPerRow = 6;
} else if (isMd) {
itemsPerRow = 4;
} else if (isSm) {
itemsPerRow = 3;
} else {
itemsPerRow = 2;
}
const widthPerItem = Number(width) / itemsPerRow;
const itemHeight = widthPerItem + getDataRowsCount(type) * 26;
if (widthPerItem === 0) {
return;
}
setTableMeta({
columnCount: itemsPerRow,
itemHeight,
rowCount: Math.ceil(dataLength / itemsPerRow),
});
}, 200);
}, []);
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(totalItemCount / (tableMeta?.columnCount || 0));
const startRow = visibleRows.startIndex;
const endRow = visibleRows.stopIndex;
if (startRow === 0) {
onStartReached?.(startRow);
}
if (endRow >= totalRows) {
onEndReached?.(endRow);
}
}
},
[onEndReached, onRangeChanged, onStartReached, totalItemCount, tableMeta?.columnCount],
);
const elements = useMemo(() => {
if (!tableMeta) {
return [];
}
console.log('data change');
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]);
return ( return (
<div className={styles.itemGridContainer}> <motion.div
<div animate={{
className={styles.gridListContainer} height: '100%',
data-overlayscrollbars-initialize="" opacity: 1,
ref={rootRef} transition: {
> duration: 0.5,
<VirtuosoGrid ease: 'backInOut',
components={gridComponents} },
context={itemContext} }}
data={data} className={styles.itemGridContainer}
endReached={onEndReached} data-overlayscrollbars-initialize=""
increaseViewportBy={200} initial={{ opacity: 0 }}
initialTopMostItemIndex={initialTopMostItemIndex} ref={mergedContainerRef}
isScrolling={onIsScrolling} >
itemContent={itemContent} <List
onScroll={onScroll} listRef={itemGridRef}
rangeChanged={onRangeChanged} onRowsRendered={handleOnRowsRendered}
ref={ref} onScroll={handleScroll}
scrollerRef={setScroller} rowComponent={RowComponent}
startReached={onStartReached} rowCount={tableMeta?.rowCount || 0}
totalCount={totalItemCount || data.length} rowHeight={tableMeta?.itemHeight || 0}
/> rowProps={{
</div> columns: tableMeta?.columnCount || 0,
data: elements,
handleExpand,
itemType,
}}
/>
<AnimatePresence> <AnimatePresence>
{hasExpanded && ( {hasExpanded && (
<motion.div <motion.div
@@ -222,37 +291,47 @@ export const ItemGrid = ({
className={styles.gridExpandedContainer} className={styles.gridExpandedContainer}
exit="hidden" exit="hidden"
initial="hidden" initial="hidden"
style={{ height: '500px' }}
variants={expandedAnimationVariants} variants={expandedAnimationVariants}
> >
<ExpandedListItem internalState={internalState} itemType={itemType} /> <ExpandedListItem internalState={internalState} itemType={itemType} />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </motion.div>
); );
}; };
const itemContent = (index: number, item: any, context: ItemContext) => { function RowComponent({
return <InnerItem context={context} index={index} item={item} />; columns,
}; data,
handleExpand,
const InnerItem = memo( index,
({ context, index, item }: { context: ItemContext; index: number; item: ItemListItem }) => { itemType,
const handleClick = () => { style,
context.internalState.toggleExpanded({ }: RowComponentProps<{
id: item.id, columns: number;
itemType: item.itemType, data: any[];
serverId: item.serverId, handleExpand: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
}); itemType: LibraryItem;
}; }>) {
return (
return ( <div className={styles.itemList} style={style}>
<ItemCard {data[index].map((d) => (
data={item as any} <div
onClick={handleClick} className={styles.itemRow}
onItemExpand={() => context.onItemDoubleClick?.(item, index)} key={d.index}
withControls style={{ '--columns': columns } as CSSProperties}
/> >
); <ItemCard
}, data={d.data}
); itemType={itemType}
onClick={(e, item, itemType) => handleExpand(e, item, itemType)}
type="poster"
withControls
/>
</div>
))}
</div>
);
}