migrate item grid back to react-window v1

This commit is contained in:
jeffvli
2025-10-26 01:38:03 -07:00
parent 62127df4f4
commit 0b56524b7d
10 changed files with 471 additions and 353 deletions
+2
View File
@@ -126,6 +126,8 @@
"react-loading-skeleton": "^3.5.0",
"react-player": "^2.11.0",
"react-router": "^7.9.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11",
"react-window-v2": "npm:react-window@^2.2.0",
"semver": "^7.5.4",
"string-to-color": "^2.2.2",
+31
View File
@@ -206,6 +206,12 @@ importers:
react-router:
specifier: ^7.9.4
version: 7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-virtualized-auto-sizer:
specifier: ^1.0.26
version: 1.0.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-window:
specifier: 1.8.11
version: 1.8.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-window-v2:
specifier: npm:react-window@^2.2.0
version: react-window@2.2.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -4617,6 +4623,19 @@ packages:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react-virtualized-auto-sizer@1.0.26:
resolution: {integrity: sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==}
peerDependencies:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-window@1.8.11:
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-window@2.2.1:
resolution: {integrity: sha512-jrUMKDLW1B4yX4OU0QjdytGgWIg6wqWfiTe86lUhFsCUltkNNB/zYxFU0DTKAzBOMRbkpLVWS1IkLvQeO4L7nw==}
peerDependencies:
@@ -10503,6 +10522,18 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-virtualized-auto-sizer@1.0.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-window@1.8.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.1
memoize-one: 5.2.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-window@2.2.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
@@ -57,7 +57,7 @@ export const ItemCardControls = ({
<PlayButton
onClick={(e) => {
e.stopPropagation();
controls.onPlay?.(item, itemType, Play.NOW, e);
controls?.onPlay?.(item, itemType, Play.NOW, e);
}}
/>
<SecondaryPlayButton
@@ -65,7 +65,7 @@ export const ItemCardControls = ({
icon="mediaPlayNext"
onClick={(e) => {
e.stopPropagation();
controls.onPlay?.(item, itemType, Play.NEXT, e);
controls?.onPlay?.(item, itemType, Play.NEXT, e);
}}
/>
<SecondaryPlayButton
@@ -73,7 +73,7 @@ export const ItemCardControls = ({
icon="mediaPlayLast"
onClick={(e) => {
e.stopPropagation();
controls.onPlay?.(item, itemType, Play.LAST, e);
controls?.onPlay?.(item, itemType, Play.LAST, e);
}}
/>
<SecondaryButton
@@ -81,7 +81,7 @@ export const ItemCardControls = ({
icon="favorite"
onClick={(e) => {
e.stopPropagation();
controls.onFavorite?.(item, itemType, e);
controls?.onFavorite?.(item, itemType, e);
}}
/>
<Rating className={styles.rating} size="xs" />
@@ -90,16 +90,16 @@ export const ItemCardControls = ({
icon="ellipsisHorizontal"
onClick={(e) => {
e.stopPropagation();
controls.onMore?.(item, itemType, e);
controls?.onMore?.(item, itemType, e);
}}
/>
{controls.onItemExpand && (
{controls?.onItemExpand && (
<SecondaryButton
className={styles.expand}
icon="arrowDownS"
onClick={(e) => {
e.stopPropagation();
controls.onItemExpand?.(item, itemType, e);
controls?.onItemExpand?.(item, itemType, e);
}}
/>
)}
+67 -12
View File
@@ -106,12 +106,29 @@ const CompactItemCard = ({
const [showControls, setShowControls] = useState(false);
if (data) {
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
controls?.onClick?.(data, itemType, e);
};
return (
<div className={clsx(styles.container, styles.compact)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
@@ -140,7 +157,7 @@ const CompactItemCard = ({
return (
<div className={clsx(styles.container, styles.compact)}>
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={styles.image} />
<Skeleton className={styles.image} enableAnimation />
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows.map((row) => (
<div className={styles.row} key={row.id}>
@@ -165,12 +182,29 @@ const DefaultItemCard = ({
const [showControls, setShowControls] = useState(false);
if (data) {
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
controls?.onClick?.(data, itemType, e);
};
return (
<div className={clsx(styles.container)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
@@ -199,7 +233,7 @@ const DefaultItemCard = ({
return (
<div className={clsx(styles.container)}>
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={styles.image} />
<Skeleton className={styles.image} enableAnimation />
</div>
<div className={styles.detailContainer}>
{rows.map((row) => (
@@ -224,19 +258,35 @@ const PosterItemCard = ({
const [showControls, setShowControls] = useState(false);
if (data) {
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
controls?.onClick?.(data, itemType, e);
};
return (
<div className={clsx(styles.container, styles.poster)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={(e) => controls?.onClick?.(data, itemType, e)}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
{withControls && showControls && (
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
item={data}
@@ -245,11 +295,13 @@ const PosterItemCard = ({
/>
)}
</div>
{data && (
<div className={styles.detailContainer}>
{rows.map((row) => (
<ItemCardRow data={data!} key={row.id} row={row} type="poster" />
<ItemCardRow data={data} key={row.id} row={row} type="poster" />
))}
</div>
)}
</div>
);
}
@@ -257,7 +309,10 @@ const PosterItemCard = ({
return (
<div className={clsx(styles.container, styles.poster)}>
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={clsx(styles.image, { [styles.isRound]: isRound })} />
<Skeleton
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableAnimation
/>
</div>
<div className={styles.detailContainer}>
{rows.map((row) => (
@@ -4,7 +4,6 @@ import {
useSuspenseQuery,
UseSuspenseQueryOptions,
} from '@tanstack/react-query';
import throttle from 'lodash/throttle';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
@@ -35,35 +34,39 @@ const getQueryKeyName = (itemType: LibraryItem): string => {
interface UseItemListInfiniteLoaderProps {
eventKey: string;
fetchThreshold?: number;
itemsPerPage: number;
itemType: LibraryItem;
listCountQuery: UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>;
maxPagesToFetch?: number;
query: Record<string, any>;
serverId: string;
}
function getInitialData(itemCount: number) {
return Array.from({ length: itemCount }, () => undefined);
return {
data: Array.from({ length: itemCount }, () => undefined),
pagesLoaded: {},
};
}
export const useItemListInfiniteLoader = ({
eventKey,
fetchThreshold = 0.75,
itemsPerPage = 100,
itemType,
listCountQuery,
listQueryFn,
maxPagesToFetch = 2,
query = {},
serverId,
}: UseItemListInfiniteLoaderProps) => {
const queryClient = useQueryClient();
const currentPageRef = useRef(0);
const scrollStateRef = useRef<ScrollState>({
direction: 'unknown',
lastRange: null,
lastScrollTime: 0,
lastStartIndex: null,
});
const { data: totalItemCount } = useSuspenseQuery<number, any, number, any>(listCountQuery);
@@ -78,19 +81,23 @@ export const useItemListInfiniteLoader = ({
setItemCount(totalItemCount);
}, [setItemCount, totalItemCount]);
const pagesLoaded = useRef<Record<string, boolean>>({});
// Reset the loaded pages when the query changes
useEffect(() => {
pagesLoaded.current = {};
}, [query]);
const dataQueryKey = useMemo(
() => [serverId, 'item-list-infinite-loader', itemType, query],
[serverId, itemType, query],
);
const { data } = useQuery<unknown[]>({
// Reset the loaded pages when the query changes
useEffect(() => {
queryClient.setQueryData(dataQueryKey, (oldData: any) => {
if (!oldData) return oldData;
return {
...oldData,
pagesLoaded: {},
};
});
}, [query, queryClient, dataQueryKey]);
const { data } = useQuery<{ data: unknown[]; pagesLoaded: Record<string, boolean> }>({
enabled: false,
initialData: getInitialData(totalItemCount),
queryFn: () => {
@@ -100,22 +107,30 @@ export const useItemListInfiniteLoader = ({
});
const onRangeChanged = useMemo(() => {
return throttle(async (range: { endIndex: number; startIndex: number }) => {
const fetchRange = getFetchRange(range, scrollStateRef, itemsPerPage);
const startIndex = fetchRange.startIndex;
const endIndex = fetchRange.startIndex + fetchRange.limit;
return async (range: { startIndex: number; stopIndex: number }) => {
const fetchRange = getFetchRange(
range,
scrollStateRef,
itemsPerPage,
maxPagesToFetch,
fetchThreshold,
);
const pageNumber = Math.floor(startIndex / itemsPerPage);
// Filter out pages that are already loaded
const pagesToFetch = fetchRange.pagesToFetch.filter(
(pageNumber) => !data.pagesLoaded[pageNumber],
);
if (pagesLoaded.current[pageNumber]) {
if (pagesToFetch.length === 0) {
return;
}
currentPageRef.current = pageNumber;
// Create fetch promises for all pages
const fetchPromises = pagesToFetch.map(async (pageNumber) => {
const startIndex = pageNumber * itemsPerPage;
const queryParams = {
limit: fetchRange.limit,
startIndex: fetchRange.startIndex,
limit: itemsPerPage,
startIndex,
...query,
};
@@ -133,35 +148,78 @@ export const useItemListInfiniteLoader = ({
staleTime: 1000 * 15,
});
queryClient.setQueryData(dataQueryKey, (oldData: unknown[]) => {
return [...oldData.slice(0, startIndex), ...result, ...oldData.slice(endIndex)];
return {
data: result,
endIndex: startIndex + itemsPerPage,
pageNumber,
startIndex,
};
});
pagesLoaded.current[pageNumber] = true;
}, 500);
}, [itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType]);
// Wait for all pages to be fetched
const pageResults = await Promise.all(fetchPromises);
// Update the query data with all fetched pages
queryClient.setQueryData(
dataQueryKey,
(oldData: { data: unknown[]; pagesLoaded: Record<string, boolean> }) => {
let newData = [...oldData.data];
const newPagesLoaded = { ...oldData.pagesLoaded };
// Update data for each fetched page
pageResults.forEach(({ data: pageData, endIndex, pageNumber, startIndex }) => {
newData = [
...newData.slice(0, startIndex),
...pageData,
...newData.slice(endIndex),
];
newPagesLoaded[pageNumber] = true;
});
return {
data: newData,
pagesLoaded: newPagesLoaded,
};
},
);
};
}, [
itemsPerPage,
query,
queryClient,
serverId,
dataQueryKey,
listQueryFn,
itemType,
data,
maxPagesToFetch,
fetchThreshold,
]);
const refresh = useCallback(
async (force?: boolean) => {
await queryClient.invalidateQueries();
pagesLoaded.current = {};
if (force) {
await queryClient.setQueryData(dataQueryKey, getInitialData(totalItemCount));
}
await onRangeChanged({
endIndex: currentPageRef.current * itemsPerPage,
startIndex: currentPageRef.current * itemsPerPage,
});
// await onRangeChanged({
// endIndex: currentPageRef.current * itemsPerPage,
// startIndex: currentPageRef.current * itemsPerPage,
// });
},
[itemsPerPage, onRangeChanged, queryClient, totalItemCount, dataQueryKey],
[queryClient, totalItemCount, dataQueryKey],
);
const updateItems = useCallback(
(indexes: number[], value: object) => {
queryClient.setQueryData(dataQueryKey, (prev: unknown[]) => {
return prev.map((item: any, index) => {
queryClient.setQueryData(
dataQueryKey,
(prev: { data: unknown[]; pagesLoaded: Record<string, boolean> }) => {
return {
...prev,
data: prev.data.map((item: any, index) => {
if (!item) {
return item;
}
@@ -174,8 +232,10 @@ export const useItemListInfiniteLoader = ({
...item,
...value,
};
});
});
}),
};
},
);
},
[queryClient, dataQueryKey],
);
@@ -198,7 +258,7 @@ export const useItemListInfiniteLoader = ({
useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => {
const idToIndexMap = data
const idToIndexMap = data.data
.filter(Boolean)
.reduce((acc: Record<string, number>, item: any, index: number) => {
acc[item.id] = index;
@@ -215,7 +275,7 @@ export const useItemListInfiniteLoader = ({
};
const handleRating = (payload: UserRatingEventPayload) => {
const idToIndexMap = data
const idToIndexMap = data.data
.filter(Boolean)
.reduce((acc: Record<string, number>, item: any, index: number) => {
acc[item.id] = index;
@@ -240,7 +300,7 @@ export const useItemListInfiniteLoader = ({
};
}, [data, eventKey, updateItems]);
return { data, onRangeChanged, refresh, updateItems };
return { data: data.data, onRangeChanged, refresh, updateItems };
};
export const parseListCountQuery = (query: any) => {
@@ -253,46 +313,79 @@ export const parseListCountQuery = (query: any) => {
interface ScrollState {
direction: 'down' | 'unknown' | 'up';
lastRange: null | { endIndex: number; startIndex: number };
lastScrollTime: number;
lastStartIndex: null | number;
}
const getFetchRange = (
range: { endIndex: number; startIndex: number },
range: { startIndex: number; stopIndex: number },
scrollState: React.MutableRefObject<ScrollState>,
itemsPerPage: number,
maxPagesToFetch: number,
fetchThreshold: number,
) => {
const currentTime = Date.now();
const { lastRange } = scrollState.current;
const { lastStartIndex } = scrollState.current;
// Determine scroll direction
let newDirection: 'down' | 'unknown' | 'up' = 'unknown';
if (lastRange) {
if (range.startIndex < lastRange.startIndex) {
let newDirection: 'down' | 'unknown' | 'up' = scrollState.current.direction;
if (lastStartIndex !== null) {
if (range.startIndex < lastStartIndex) {
newDirection = 'up';
} else if (range.startIndex > lastRange.startIndex) {
} else if (range.startIndex > lastStartIndex) {
newDirection = 'down';
}
}
scrollState.current = {
direction: newDirection,
lastRange: { ...range },
lastScrollTime: currentTime,
lastStartIndex: range.startIndex,
};
let pageIndex = 0;
// Calculate threshold distance
const thresholdDistance = Math.floor(itemsPerPage * fetchThreshold);
// Determine which pages to fetch based on scroll direction and threshold
let pagesToFetch: number[] = [];
if (newDirection === 'down') {
pageIndex = Math.floor(range.endIndex / itemsPerPage);
} else if (newDirection === 'up') {
pageIndex = Math.floor(range.startIndex / itemsPerPage);
} else {
pageIndex = Math.floor(range.endIndex / itemsPerPage);
const currentPage = Math.floor(range.stopIndex / itemsPerPage);
const distanceFromNextPage = (currentPage + 1) * itemsPerPage - range.stopIndex;
// Always include the current page if it's not loaded
pagesToFetch.push(currentPage);
// If we're close to the next page boundary, fetch additional upcoming pages
if (distanceFromNextPage <= thresholdDistance && maxPagesToFetch > 1) {
for (let i = 1; i < maxPagesToFetch; i++) {
pagesToFetch.push(currentPage + i);
}
}
} else if (newDirection === 'up') {
const currentPage = Math.floor(range.startIndex / itemsPerPage);
const distanceFromPrevPage = range.startIndex - currentPage * itemsPerPage;
// Always include the current page if it's not loaded
pagesToFetch.push(currentPage);
// If we're close to the previous page boundary, fetch additional previous pages
if (distanceFromPrevPage <= thresholdDistance && maxPagesToFetch > 1) {
for (let i = 1; i < maxPagesToFetch; i++) {
pagesToFetch.push(currentPage - i);
}
}
} else {
// Unknown direction - fetch current page and next pages
const currentPage = Math.floor(range.stopIndex / itemsPerPage);
for (let i = 0; i < maxPagesToFetch; i++) {
pagesToFetch.push(currentPage + i);
}
}
// Filter out negative page numbers
pagesToFetch = pagesToFetch.filter((page) => page >= 0);
return {
direction: newDirection,
limit: itemsPerPage,
startIndex: pageIndex * itemsPerPage,
pagesToFetch,
thresholdDistance,
};
};
@@ -1,4 +1,4 @@
import { useCallback, useReducer } from 'react';
import { useCallback, useMemo, useReducer } from 'react';
import { itemGridSelectors } from '/@/renderer/components/item-list/helpers/item-list-reducer-utils';
import { LibraryItem } from '/@/shared/types/domain-types';
@@ -242,7 +242,8 @@ export const useItemListState = (): ItemListStateActions => {
return itemGridSelectors.hasAnySelected(state);
}, [state]);
return {
return useMemo(
() => ({
clearAll,
clearExpanded,
clearSelected,
@@ -259,5 +260,24 @@ export const useItemListState = (): ItemListStateActions => {
setSelected,
toggleExpanded,
toggleSelected,
};
}),
[
clearAll,
clearExpanded,
clearSelected,
getExpanded,
getExpandedIds,
getSelected,
getSelectedIds,
getVersion,
hasExpanded,
hasSelected,
isExpanded,
isSelected,
setExpanded,
setSelected,
toggleExpanded,
toggleSelected,
],
);
};
@@ -3,7 +3,10 @@
flex-direction: column !important;
width: 100%;
height: 100%;
padding: 0 var(--theme-spacing-md);
}
.auto-sizer-container {
flex: 1;
}
.grid-list-container {
@@ -8,17 +8,21 @@ import React, {
CSSProperties,
memo,
ReactNode,
Ref,
UIEvent,
RefObject,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { List, RowComponentProps, useListRef } from 'react-window-v2';
import AutoSizer from 'react-virtualized-auto-sizer';
import {
FixedSizeList,
ListChildComponentProps,
ListOnItemsRenderedProps,
ListOnScrollProps,
} from 'react-window';
import { ExpandedListContainer } from '../expanded-list-container';
import styles from './item-grid-list.module.css';
@@ -38,11 +42,14 @@ interface VirtualizedGridListProps {
enableExpansion: boolean;
enableSelection: boolean;
gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
initialTop?: ItemGridListProps['initialTop'];
internalState: ItemListStateActions;
itemGridRef: React.RefObject<any>;
itemType: LibraryItem;
onRowsRendered: (visibleRows: { startIndex: number; stopIndex: number }) => void;
onScroll: (e: UIEvent<HTMLDivElement>) => void;
onRangeChanged?: ItemGridListProps['onRangeChanged'];
onScroll?: ItemGridListProps['onScroll'];
onScrollEnd?: ItemGridListProps['onScrollEnd'];
outerRef: RefObject<any>;
ref: RefObject<FixedSizeList<GridItemProps>>;
tableMeta: null | {
columnCount: number;
itemHeight: number;
@@ -56,14 +63,17 @@ const VirtualizedGridList = React.memo(
enableExpansion,
enableSelection,
gap,
initialTop,
internalState,
itemGridRef,
itemType,
onRowsRendered,
onRangeChanged,
onScroll,
onScrollEnd,
outerRef,
ref,
tableMeta,
}: VirtualizedGridListProps) => {
const itemProps: GridItemProps = useMemo(() => {
const itemData: GridItemProps = useMemo(() => {
return {
columns: tableMeta?.columnCount || 0,
controls: {
@@ -116,27 +126,71 @@ const VirtualizedGridList = React.memo(
gap,
internalState,
itemType,
tableMeta,
};
}, [
data,
tableMeta?.columnCount,
enableExpansion,
enableSelection,
gap,
internalState,
itemType,
]);
}, [enableSelection, enableExpansion, internalState, tableMeta, data, itemType, gap]);
const handleOnRangeChanged = useCallback(
({ visibleStartIndex, visibleStopIndex }: ListOnItemsRenderedProps) => {
onRangeChanged?.({
startIndex: visibleStartIndex * (tableMeta?.columnCount || 0),
stopIndex: visibleStopIndex * (tableMeta?.columnCount || 0),
});
},
[tableMeta?.columnCount, onRangeChanged],
);
const debouncedOnScrollEnd = useMemo(
() =>
onScrollEnd
? debounce((scrollOffset: number, direction: 'down' | 'up') => {
onScrollEnd(scrollOffset, direction);
}, 100)
: undefined,
[onScrollEnd],
);
useEffect(() => {
return () => {
debouncedOnScrollEnd?.cancel();
};
}, [debouncedOnScrollEnd]);
const handleOnScroll = useCallback(
({ scrollDirection, scrollOffset }: ListOnScrollProps) => {
onScroll?.(scrollOffset, scrollDirection === 'forward' ? 'down' : 'up');
debouncedOnScrollEnd?.(scrollOffset, scrollDirection === 'forward' ? 'down' : 'up');
},
[onScroll, debouncedOnScrollEnd],
);
if (!tableMeta) {
return null;
}
return (
<List
listRef={itemGridRef}
onRowsRendered={onRowsRendered}
onScroll={onScroll}
rowComponent={ListComponent}
rowCount={tableMeta?.rowCount || 0}
rowHeight={tableMeta?.itemHeight || 0}
rowProps={itemProps}
/>
<div className={styles.autoSizerContainer}>
<AutoSizer>
{({ height, width }) => {
return (
<FixedSizeList
height={height}
initialScrollOffset={initialTop || 0}
itemCount={itemData.tableMeta?.rowCount || 0}
itemData={itemData}
itemSize={itemData.tableMeta?.itemHeight || 0}
onItemsRendered={handleOnRangeChanged}
onScroll={handleOnScroll}
outerRef={outerRef}
ref={ref}
width={width}
>
{ListComponent}
</FixedSizeList>
);
}}
</AutoSizer>
</div>
);
},
);
@@ -207,6 +261,11 @@ export interface GridItemProps {
gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
internalState: ItemListStateActions;
itemType: LibraryItem;
tableMeta: null | {
columnCount: number;
itemHeight: number;
rowCount: number;
};
}
export interface ItemGridListProps {
@@ -215,23 +274,16 @@ export interface ItemGridListProps {
enableExpansion?: boolean;
enableSelection?: boolean;
gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
initialTop?: {
behavior?: 'auto' | 'smooth';
to: number;
type: 'index' | 'offset';
};
initialTop?: number;
itemsPerRow?: number;
itemType: LibraryItem;
onEndReached?: (index: number, handle: ItemListHandle) => void;
onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
onScroll?: (e: UIEvent<HTMLDivElement>) => void;
onScrollEnd?: (offset: number, handle: ItemListHandle) => void;
onStartReached?: (index: number, handle: ItemListHandle) => void;
ref?: Ref<ItemListHandle>;
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void;
onScroll?: (offset: number, direction: 'down' | 'up') => void;
onScrollEnd?: (offset: number, direction: 'down' | 'up') => void;
ref?: RefObject<ItemListHandle>;
}
export const ItemGridList = ({
currentPage,
data,
enableExpansion = true,
enableSelection = true,
@@ -239,17 +291,16 @@ export const ItemGridList = ({
initialTop,
itemsPerRow,
itemType,
onEndReached,
onRangeChanged,
onScroll,
onScrollEnd,
onStartReached,
ref,
}: ItemGridListProps) => {
const itemGridRef = useListRef(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef(null);
const outerRef = useRef(null);
const listRef = useRef<FixedSizeList<GridItemProps>>(null);
const { ref: containerRef, width: containerWidth } = useElementSize();
const mergedContainerRef = useMergedRef(containerRef, scrollContainerRef);
const mergedContainerRef = useMergedRef(containerRef, rootRef);
const internalState = useItemListState();
@@ -275,66 +326,27 @@ export const ItemGridList = ({
});
useEffect(() => {
const { current: root } = scrollContainerRef;
const { current: root } = rootRef;
const { current: outer } = outerRef;
if (root) {
if (root && outer) {
initialize({
elements: { viewport: root.firstElementChild as HTMLElement },
elements: {
viewport: outer,
},
target: root,
});
}
}, [itemGridRef, initialize]);
const isInitialScrollPositionSet = useRef<boolean>(false);
}, [initialize]);
const hasExpanded = internalState.hasExpanded();
const handleOnScrollEnd = useCallback(
(scrollTop: number, handle: ItemListHandle) => {
onScrollEnd?.(scrollTop, handle);
},
[onScrollEnd],
);
const debouncedOnScrollEnd = useMemo(
() => debounce(handleOnScrollEnd, 150),
[handleOnScrollEnd],
);
useEffect(() => {
return () => {
debouncedOnScrollEnd.cancel();
};
}, [debouncedOnScrollEnd]);
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
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 | {
columnCount: number;
itemHeight: number;
rowCount: number;
}>(null);
// Use throttled function created outside component for better performance
const throttledSetTableMeta = useMemo(() => {
return createThrottledSetTableMeta(itemsPerRow);
}, [itemsPerRow]);
@@ -343,92 +355,6 @@ export const ItemGridList = ({
throttledSetTableMeta(containerWidth, data.length, itemType, setTableMeta);
}, [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,
],
);
// Scroll to top when currentPage changes
useEffect(() => {
if (currentPage !== undefined && tableMeta?.itemHeight) {
scrollToGridOffset(0);
}
}, [currentPage, scrollToGridOffset, tableMeta?.itemHeight]);
useEffect(() => {
if (!initialTop || isInitialScrollPositionSet.current || !tableMeta?.itemHeight) return;
// Only set initial scroll position if we haven't done it yet AND we're not on a page change
// This prevents the initial scroll position from being restored on every page change
if (currentPage !== undefined && currentPage > 0) {
isInitialScrollPositionSet.current = true;
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, currentPage]);
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 (
<div
className={styles.itemGridContainer}
@@ -440,11 +366,14 @@ export const ItemGridList = ({
enableExpansion={enableExpansion}
enableSelection={enableSelection}
gap={gap}
initialTop={initialTop}
internalState={internalState}
itemGridRef={itemGridRef}
itemType={itemType}
onRowsRendered={handleOnRowsRendered}
onScroll={handleScroll}
onRangeChanged={onRangeChanged}
onScroll={onScroll ?? (() => {})}
onScrollEnd={onScrollEnd ?? (() => {})}
outerRef={outerRef}
ref={listRef}
tableMeta={tableMeta}
/>
<AnimatePresence>
@@ -458,16 +387,10 @@ export const ItemGridList = ({
);
};
const ListComponent = memo(
({
columns,
controls,
data,
gap,
index,
itemType,
style,
}: RowComponentProps<GridItemProps>) => {
const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
const { index, style } = props;
const { columns, controls, data, gap, itemType } = props.data;
const items: ReactNode[] = [];
const itemCount = data.length;
const startIndex = index * columns;
@@ -498,5 +421,4 @@ const ListComponent = memo(
{items}
</div>
);
},
);
});
@@ -56,15 +56,11 @@ export const AlbumListInfiniteGrid = forwardRef<any, AlbumListInfiniteGridProps>
<ItemGridList
data={data}
gap={gap}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
initialTop={scrollOffset ?? 0}
itemsPerRow={itemsPerRow}
itemType={LibraryItem.ALBUM}
onRangeChanged={onRangeChanged}
onScrollEnd={handleOnScrollEnd}
ref={ref}
/>
);
},
@@ -52,15 +52,11 @@ export const SongListInfiniteGrid = forwardRef<any, SongListInfiniteGridProps>(
<ItemGridList
data={data}
gap={gap}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
initialTop={scrollOffset ?? 0}
itemsPerRow={itemsPerRow}
itemType={LibraryItem.SONG}
onRangeChanged={onRangeChanged}
onScrollEnd={handleOnScrollEnd}
ref={ref}
/>
);
},