mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-06 20:10:12 +02:00
migrate item grid back to react-window v1
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+31
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user