mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20: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-loading-skeleton": "^3.5.0",
|
||||||
"react-player": "^2.11.0",
|
"react-player": "^2.11.0",
|
||||||
"react-router": "^7.9.4",
|
"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",
|
"react-window-v2": "npm:react-window@^2.2.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"string-to-color": "^2.2.2",
|
"string-to-color": "^2.2.2",
|
||||||
|
|||||||
Generated
+31
@@ -206,6 +206,12 @@ importers:
|
|||||||
react-router:
|
react-router:
|
||||||
specifier: ^7.9.4
|
specifier: ^7.9.4
|
||||||
version: 7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
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:
|
react-window-v2:
|
||||||
specifier: npm:react-window@^2.2.0
|
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)
|
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: '>=16.6.0'
|
||||||
react-dom: '>=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:
|
react-window@2.2.1:
|
||||||
resolution: {integrity: sha512-jrUMKDLW1B4yX4OU0QjdytGgWIg6wqWfiTe86lUhFsCUltkNNB/zYxFU0DTKAzBOMRbkpLVWS1IkLvQeO4L7nw==}
|
resolution: {integrity: sha512-jrUMKDLW1B4yX4OU0QjdytGgWIg6wqWfiTe86lUhFsCUltkNNB/zYxFU0DTKAzBOMRbkpLVWS1IkLvQeO4L7nw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10503,6 +10522,18 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
react-dom: 19.1.0(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):
|
react-window@2.2.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const ItemCardControls = ({
|
|||||||
<PlayButton
|
<PlayButton
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
controls.onPlay?.(item, itemType, Play.NOW, e);
|
controls?.onPlay?.(item, itemType, Play.NOW, e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SecondaryPlayButton
|
<SecondaryPlayButton
|
||||||
@@ -65,7 +65,7 @@ export const ItemCardControls = ({
|
|||||||
icon="mediaPlayNext"
|
icon="mediaPlayNext"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
controls.onPlay?.(item, itemType, Play.NEXT, e);
|
controls?.onPlay?.(item, itemType, Play.NEXT, e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SecondaryPlayButton
|
<SecondaryPlayButton
|
||||||
@@ -73,7 +73,7 @@ export const ItemCardControls = ({
|
|||||||
icon="mediaPlayLast"
|
icon="mediaPlayLast"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
controls.onPlay?.(item, itemType, Play.LAST, e);
|
controls?.onPlay?.(item, itemType, Play.LAST, e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
@@ -81,7 +81,7 @@ export const ItemCardControls = ({
|
|||||||
icon="favorite"
|
icon="favorite"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
controls.onFavorite?.(item, itemType, e);
|
controls?.onFavorite?.(item, itemType, e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Rating className={styles.rating} size="xs" />
|
<Rating className={styles.rating} size="xs" />
|
||||||
@@ -90,16 +90,16 @@ export const ItemCardControls = ({
|
|||||||
icon="ellipsisHorizontal"
|
icon="ellipsisHorizontal"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
controls.onMore?.(item, itemType, e);
|
controls?.onMore?.(item, itemType, e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{controls.onItemExpand && (
|
{controls?.onItemExpand && (
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
className={styles.expand}
|
className={styles.expand}
|
||||||
icon="arrowDownS"
|
icon="arrowDownS"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
controls.onItemExpand?.(item, itemType, e);
|
controls?.onItemExpand?.(item, itemType, e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -106,12 +106,29 @@ const CompactItemCard = ({
|
|||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
|
|
||||||
if (data) {
|
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 (
|
return (
|
||||||
<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 })}
|
||||||
onMouseEnter={() => withControls && setShowControls(true)}
|
onClick={handleClick}
|
||||||
onMouseLeave={() => withControls && setShowControls(false)}
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||||
@@ -140,7 +157,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} />
|
<Skeleton className={styles.image} enableAnimation />
|
||||||
<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}>
|
||||||
@@ -165,12 +182,29 @@ const DefaultItemCard = ({
|
|||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
|
|
||||||
if (data) {
|
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 (
|
return (
|
||||||
<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 })}
|
||||||
onMouseEnter={() => withControls && setShowControls(true)}
|
onClick={handleClick}
|
||||||
onMouseLeave={() => withControls && setShowControls(false)}
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||||
@@ -199,7 +233,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} />
|
<Skeleton className={styles.image} enableAnimation />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailContainer}>
|
<div className={styles.detailContainer}>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
@@ -224,19 +258,35 @@ const PosterItemCard = ({
|
|||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
|
|
||||||
if (data) {
|
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 (
|
return (
|
||||||
<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={(e) => controls?.onClick?.(data, itemType, e)}
|
onClick={handleClick}
|
||||||
onMouseEnter={() => withControls && setShowControls(true)}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={() => withControls && setShowControls(false)}
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
/>
|
/>
|
||||||
{withControls && showControls && (
|
{withControls && showControls && data && (
|
||||||
<ItemCardControls
|
<ItemCardControls
|
||||||
controls={controls}
|
controls={controls}
|
||||||
item={data}
|
item={data}
|
||||||
@@ -245,11 +295,13 @@ const PosterItemCard = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailContainer}>
|
{data && (
|
||||||
{rows.map((row) => (
|
<div className={styles.detailContainer}>
|
||||||
<ItemCardRow data={data!} key={row.id} row={row} type="poster" />
|
{rows.map((row) => (
|
||||||
))}
|
<ItemCardRow data={data} key={row.id} row={row} type="poster" />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,7 +309,10 @@ const PosterItemCard = ({
|
|||||||
return (
|
return (
|
||||||
<div className={clsx(styles.container, styles.poster)}>
|
<div className={clsx(styles.container, styles.poster)}>
|
||||||
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
|
<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>
|
||||||
<div className={styles.detailContainer}>
|
<div className={styles.detailContainer}>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
useSuspenseQuery,
|
useSuspenseQuery,
|
||||||
UseSuspenseQueryOptions,
|
UseSuspenseQueryOptions,
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
import throttle from 'lodash/throttle';
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
@@ -35,35 +34,39 @@ const getQueryKeyName = (itemType: LibraryItem): string => {
|
|||||||
|
|
||||||
interface UseItemListInfiniteLoaderProps {
|
interface UseItemListInfiniteLoaderProps {
|
||||||
eventKey: string;
|
eventKey: string;
|
||||||
|
fetchThreshold?: number;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
listCountQuery: UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
listCountQuery: UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||||
listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>;
|
listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>;
|
||||||
|
maxPagesToFetch?: number;
|
||||||
query: Record<string, any>;
|
query: Record<string, any>;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialData(itemCount: number) {
|
function getInitialData(itemCount: number) {
|
||||||
return Array.from({ length: itemCount }, () => undefined);
|
return {
|
||||||
|
data: Array.from({ length: itemCount }, () => undefined),
|
||||||
|
pagesLoaded: {},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemListInfiniteLoader = ({
|
export const useItemListInfiniteLoader = ({
|
||||||
eventKey,
|
eventKey,
|
||||||
|
fetchThreshold = 0.75,
|
||||||
itemsPerPage = 100,
|
itemsPerPage = 100,
|
||||||
itemType,
|
itemType,
|
||||||
listCountQuery,
|
listCountQuery,
|
||||||
listQueryFn,
|
listQueryFn,
|
||||||
|
maxPagesToFetch = 2,
|
||||||
query = {},
|
query = {},
|
||||||
serverId,
|
serverId,
|
||||||
}: UseItemListInfiniteLoaderProps) => {
|
}: UseItemListInfiniteLoaderProps) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const currentPageRef = useRef(0);
|
|
||||||
|
|
||||||
const scrollStateRef = useRef<ScrollState>({
|
const scrollStateRef = useRef<ScrollState>({
|
||||||
direction: 'unknown',
|
direction: 'unknown',
|
||||||
lastRange: null,
|
lastStartIndex: null,
|
||||||
lastScrollTime: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: totalItemCount } = useSuspenseQuery<number, any, number, any>(listCountQuery);
|
const { data: totalItemCount } = useSuspenseQuery<number, any, number, any>(listCountQuery);
|
||||||
@@ -78,19 +81,23 @@ export const useItemListInfiniteLoader = ({
|
|||||||
setItemCount(totalItemCount);
|
setItemCount(totalItemCount);
|
||||||
}, [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(
|
const dataQueryKey = useMemo(
|
||||||
() => [serverId, 'item-list-infinite-loader', itemType, query],
|
() => [serverId, 'item-list-infinite-loader', itemType, query],
|
||||||
[serverId, 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,
|
enabled: false,
|
||||||
initialData: getInitialData(totalItemCount),
|
initialData: getInitialData(totalItemCount),
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -100,82 +107,135 @@ export const useItemListInfiniteLoader = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onRangeChanged = useMemo(() => {
|
const onRangeChanged = useMemo(() => {
|
||||||
return throttle(async (range: { endIndex: number; startIndex: number }) => {
|
return async (range: { startIndex: number; stopIndex: number }) => {
|
||||||
const fetchRange = getFetchRange(range, scrollStateRef, itemsPerPage);
|
const fetchRange = getFetchRange(
|
||||||
const startIndex = fetchRange.startIndex;
|
range,
|
||||||
const endIndex = fetchRange.startIndex + fetchRange.limit;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPageRef.current = pageNumber;
|
// Create fetch promises for all pages
|
||||||
|
const fetchPromises = pagesToFetch.map(async (pageNumber) => {
|
||||||
|
const startIndex = pageNumber * itemsPerPage;
|
||||||
|
const queryParams = {
|
||||||
|
limit: itemsPerPage,
|
||||||
|
startIndex,
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
|
||||||
const queryParams = {
|
const result = await queryClient.ensureQueryData({
|
||||||
limit: fetchRange.limit,
|
gcTime: 1000 * 15,
|
||||||
startIndex: fetchRange.startIndex,
|
queryFn: async ({ signal }) => {
|
||||||
...query,
|
const result = await listQueryFn({
|
||||||
};
|
apiClientProps: { server: getServerById(serverId), signal },
|
||||||
|
query: queryParams,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await queryClient.ensureQueryData({
|
return result.items;
|
||||||
gcTime: 1000 * 15,
|
},
|
||||||
queryFn: async ({ signal }) => {
|
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
|
||||||
const result = await listQueryFn({
|
staleTime: 1000 * 15,
|
||||||
apiClientProps: { server: getServerById(serverId), signal },
|
});
|
||||||
query: queryParams,
|
|
||||||
|
return {
|
||||||
|
data: result,
|
||||||
|
endIndex: startIndex + itemsPerPage,
|
||||||
|
pageNumber,
|
||||||
|
startIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 result.items;
|
return {
|
||||||
|
data: newData,
|
||||||
|
pagesLoaded: newPagesLoaded,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
|
);
|
||||||
staleTime: 1000 * 15,
|
};
|
||||||
});
|
}, [
|
||||||
|
itemsPerPage,
|
||||||
queryClient.setQueryData(dataQueryKey, (oldData: unknown[]) => {
|
query,
|
||||||
return [...oldData.slice(0, startIndex), ...result, ...oldData.slice(endIndex)];
|
queryClient,
|
||||||
});
|
serverId,
|
||||||
|
dataQueryKey,
|
||||||
pagesLoaded.current[pageNumber] = true;
|
listQueryFn,
|
||||||
}, 500);
|
itemType,
|
||||||
}, [itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType]);
|
data,
|
||||||
|
maxPagesToFetch,
|
||||||
|
fetchThreshold,
|
||||||
|
]);
|
||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (force?: boolean) => {
|
async (force?: boolean) => {
|
||||||
await queryClient.invalidateQueries();
|
await queryClient.invalidateQueries();
|
||||||
pagesLoaded.current = {};
|
|
||||||
|
|
||||||
if (force) {
|
if (force) {
|
||||||
await queryClient.setQueryData(dataQueryKey, getInitialData(totalItemCount));
|
await queryClient.setQueryData(dataQueryKey, getInitialData(totalItemCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
await onRangeChanged({
|
// await onRangeChanged({
|
||||||
endIndex: currentPageRef.current * itemsPerPage,
|
// endIndex: currentPageRef.current * itemsPerPage,
|
||||||
startIndex: currentPageRef.current * itemsPerPage,
|
// startIndex: currentPageRef.current * itemsPerPage,
|
||||||
});
|
// });
|
||||||
},
|
},
|
||||||
[itemsPerPage, onRangeChanged, queryClient, totalItemCount, dataQueryKey],
|
[queryClient, totalItemCount, dataQueryKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateItems = useCallback(
|
const updateItems = useCallback(
|
||||||
(indexes: number[], value: object) => {
|
(indexes: number[], value: object) => {
|
||||||
queryClient.setQueryData(dataQueryKey, (prev: unknown[]) => {
|
queryClient.setQueryData(
|
||||||
return prev.map((item: any, index) => {
|
dataQueryKey,
|
||||||
if (!item) {
|
(prev: { data: unknown[]; pagesLoaded: Record<string, boolean> }) => {
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!indexes.includes(index)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...prev,
|
||||||
...value,
|
data: prev.data.map((item: any, index) => {
|
||||||
|
if (!item) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!indexes.includes(index)) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...value,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
});
|
);
|
||||||
},
|
},
|
||||||
[queryClient, dataQueryKey],
|
[queryClient, dataQueryKey],
|
||||||
);
|
);
|
||||||
@@ -198,7 +258,7 @@ export const useItemListInfiniteLoader = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFavorite = (payload: UserFavoriteEventPayload) => {
|
const handleFavorite = (payload: UserFavoriteEventPayload) => {
|
||||||
const idToIndexMap = data
|
const idToIndexMap = data.data
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.reduce((acc: Record<string, number>, item: any, index: number) => {
|
.reduce((acc: Record<string, number>, item: any, index: number) => {
|
||||||
acc[item.id] = index;
|
acc[item.id] = index;
|
||||||
@@ -215,7 +275,7 @@ export const useItemListInfiniteLoader = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRating = (payload: UserRatingEventPayload) => {
|
const handleRating = (payload: UserRatingEventPayload) => {
|
||||||
const idToIndexMap = data
|
const idToIndexMap = data.data
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.reduce((acc: Record<string, number>, item: any, index: number) => {
|
.reduce((acc: Record<string, number>, item: any, index: number) => {
|
||||||
acc[item.id] = index;
|
acc[item.id] = index;
|
||||||
@@ -240,7 +300,7 @@ export const useItemListInfiniteLoader = ({
|
|||||||
};
|
};
|
||||||
}, [data, eventKey, updateItems]);
|
}, [data, eventKey, updateItems]);
|
||||||
|
|
||||||
return { data, onRangeChanged, refresh, updateItems };
|
return { data: data.data, onRangeChanged, refresh, updateItems };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseListCountQuery = (query: any) => {
|
export const parseListCountQuery = (query: any) => {
|
||||||
@@ -253,46 +313,79 @@ export const parseListCountQuery = (query: any) => {
|
|||||||
|
|
||||||
interface ScrollState {
|
interface ScrollState {
|
||||||
direction: 'down' | 'unknown' | 'up';
|
direction: 'down' | 'unknown' | 'up';
|
||||||
lastRange: null | { endIndex: number; startIndex: number };
|
lastStartIndex: null | number;
|
||||||
lastScrollTime: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFetchRange = (
|
const getFetchRange = (
|
||||||
range: { endIndex: number; startIndex: number },
|
range: { startIndex: number; stopIndex: number },
|
||||||
scrollState: React.MutableRefObject<ScrollState>,
|
scrollState: React.MutableRefObject<ScrollState>,
|
||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
|
maxPagesToFetch: number,
|
||||||
|
fetchThreshold: number,
|
||||||
) => {
|
) => {
|
||||||
const currentTime = Date.now();
|
const { lastStartIndex } = scrollState.current;
|
||||||
const { lastRange } = scrollState.current;
|
|
||||||
|
|
||||||
// Determine scroll direction
|
// Determine scroll direction
|
||||||
let newDirection: 'down' | 'unknown' | 'up' = 'unknown';
|
let newDirection: 'down' | 'unknown' | 'up' = scrollState.current.direction;
|
||||||
if (lastRange) {
|
if (lastStartIndex !== null) {
|
||||||
if (range.startIndex < lastRange.startIndex) {
|
if (range.startIndex < lastStartIndex) {
|
||||||
newDirection = 'up';
|
newDirection = 'up';
|
||||||
} else if (range.startIndex > lastRange.startIndex) {
|
} else if (range.startIndex > lastStartIndex) {
|
||||||
newDirection = 'down';
|
newDirection = 'down';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollState.current = {
|
scrollState.current = {
|
||||||
direction: newDirection,
|
direction: newDirection,
|
||||||
lastRange: { ...range },
|
lastStartIndex: range.startIndex,
|
||||||
lastScrollTime: currentTime,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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') {
|
if (newDirection === 'down') {
|
||||||
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') {
|
} else if (newDirection === 'up') {
|
||||||
pageIndex = Math.floor(range.startIndex / itemsPerPage);
|
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 {
|
} else {
|
||||||
pageIndex = Math.floor(range.endIndex / itemsPerPage);
|
// 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 {
|
return {
|
||||||
direction: newDirection,
|
direction: newDirection,
|
||||||
limit: itemsPerPage,
|
pagesToFetch,
|
||||||
startIndex: pageIndex * itemsPerPage,
|
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 { itemGridSelectors } from '/@/renderer/components/item-list/helpers/item-list-reducer-utils';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
@@ -242,22 +242,42 @@ export const useItemListState = (): ItemListStateActions => {
|
|||||||
return itemGridSelectors.hasAnySelected(state);
|
return itemGridSelectors.hasAnySelected(state);
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
clearAll,
|
() => ({
|
||||||
clearExpanded,
|
clearAll,
|
||||||
clearSelected,
|
clearExpanded,
|
||||||
getExpanded,
|
clearSelected,
|
||||||
getExpandedIds,
|
getExpanded,
|
||||||
getSelected,
|
getExpandedIds,
|
||||||
getSelectedIds,
|
getSelected,
|
||||||
getVersion,
|
getSelectedIds,
|
||||||
hasExpanded,
|
getVersion,
|
||||||
hasSelected,
|
hasExpanded,
|
||||||
isExpanded,
|
hasSelected,
|
||||||
isSelected,
|
isExpanded,
|
||||||
setExpanded,
|
isSelected,
|
||||||
setSelected,
|
setExpanded,
|
||||||
toggleExpanded,
|
setSelected,
|
||||||
toggleSelected,
|
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;
|
flex-direction: column !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0 var(--theme-spacing-md);
|
}
|
||||||
|
|
||||||
|
.auto-sizer-container {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-list-container {
|
.grid-list-container {
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import React, {
|
|||||||
CSSProperties,
|
CSSProperties,
|
||||||
memo,
|
memo,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
Ref,
|
RefObject,
|
||||||
UIEvent,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} 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 { ExpandedListContainer } from '../expanded-list-container';
|
||||||
import styles from './item-grid-list.module.css';
|
import styles from './item-grid-list.module.css';
|
||||||
@@ -38,11 +42,14 @@ interface VirtualizedGridListProps {
|
|||||||
enableExpansion: boolean;
|
enableExpansion: boolean;
|
||||||
enableSelection: boolean;
|
enableSelection: boolean;
|
||||||
gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
||||||
|
initialTop?: ItemGridListProps['initialTop'];
|
||||||
internalState: ItemListStateActions;
|
internalState: ItemListStateActions;
|
||||||
itemGridRef: React.RefObject<any>;
|
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
onRowsRendered: (visibleRows: { startIndex: number; stopIndex: number }) => void;
|
onRangeChanged?: ItemGridListProps['onRangeChanged'];
|
||||||
onScroll: (e: UIEvent<HTMLDivElement>) => void;
|
onScroll?: ItemGridListProps['onScroll'];
|
||||||
|
onScrollEnd?: ItemGridListProps['onScrollEnd'];
|
||||||
|
outerRef: RefObject<any>;
|
||||||
|
ref: RefObject<FixedSizeList<GridItemProps>>;
|
||||||
tableMeta: null | {
|
tableMeta: null | {
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
itemHeight: number;
|
itemHeight: number;
|
||||||
@@ -56,14 +63,17 @@ const VirtualizedGridList = React.memo(
|
|||||||
enableExpansion,
|
enableExpansion,
|
||||||
enableSelection,
|
enableSelection,
|
||||||
gap,
|
gap,
|
||||||
|
initialTop,
|
||||||
internalState,
|
internalState,
|
||||||
itemGridRef,
|
|
||||||
itemType,
|
itemType,
|
||||||
onRowsRendered,
|
onRangeChanged,
|
||||||
onScroll,
|
onScroll,
|
||||||
|
onScrollEnd,
|
||||||
|
outerRef,
|
||||||
|
ref,
|
||||||
tableMeta,
|
tableMeta,
|
||||||
}: VirtualizedGridListProps) => {
|
}: VirtualizedGridListProps) => {
|
||||||
const itemProps: GridItemProps = useMemo(() => {
|
const itemData: GridItemProps = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
columns: tableMeta?.columnCount || 0,
|
columns: tableMeta?.columnCount || 0,
|
||||||
controls: {
|
controls: {
|
||||||
@@ -116,27 +126,71 @@ const VirtualizedGridList = React.memo(
|
|||||||
gap,
|
gap,
|
||||||
internalState,
|
internalState,
|
||||||
itemType,
|
itemType,
|
||||||
|
tableMeta,
|
||||||
};
|
};
|
||||||
}, [
|
}, [enableSelection, enableExpansion, internalState, tableMeta, data, itemType, gap]);
|
||||||
data,
|
|
||||||
tableMeta?.columnCount,
|
const handleOnRangeChanged = useCallback(
|
||||||
enableExpansion,
|
({ visibleStartIndex, visibleStopIndex }: ListOnItemsRenderedProps) => {
|
||||||
enableSelection,
|
onRangeChanged?.({
|
||||||
gap,
|
startIndex: visibleStartIndex * (tableMeta?.columnCount || 0),
|
||||||
internalState,
|
stopIndex: visibleStopIndex * (tableMeta?.columnCount || 0),
|
||||||
itemType,
|
});
|
||||||
]);
|
},
|
||||||
|
[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 (
|
return (
|
||||||
<List
|
<div className={styles.autoSizerContainer}>
|
||||||
listRef={itemGridRef}
|
<AutoSizer>
|
||||||
onRowsRendered={onRowsRendered}
|
{({ height, width }) => {
|
||||||
onScroll={onScroll}
|
return (
|
||||||
rowComponent={ListComponent}
|
<FixedSizeList
|
||||||
rowCount={tableMeta?.rowCount || 0}
|
height={height}
|
||||||
rowHeight={tableMeta?.itemHeight || 0}
|
initialScrollOffset={initialTop || 0}
|
||||||
rowProps={itemProps}
|
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';
|
gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
||||||
internalState: ItemListStateActions;
|
internalState: ItemListStateActions;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
|
tableMeta: null | {
|
||||||
|
columnCount: number;
|
||||||
|
itemHeight: number;
|
||||||
|
rowCount: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemGridListProps {
|
export interface ItemGridListProps {
|
||||||
@@ -215,23 +274,16 @@ export interface ItemGridListProps {
|
|||||||
enableExpansion?: boolean;
|
enableExpansion?: boolean;
|
||||||
enableSelection?: boolean;
|
enableSelection?: boolean;
|
||||||
gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
||||||
initialTop?: {
|
initialTop?: number;
|
||||||
behavior?: 'auto' | 'smooth';
|
|
||||||
to: number;
|
|
||||||
type: 'index' | 'offset';
|
|
||||||
};
|
|
||||||
itemsPerRow?: number;
|
itemsPerRow?: number;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
onEndReached?: (index: number, handle: ItemListHandle) => void;
|
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void;
|
||||||
onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
|
onScroll?: (offset: number, direction: 'down' | 'up') => void;
|
||||||
onScroll?: (e: UIEvent<HTMLDivElement>) => void;
|
onScrollEnd?: (offset: number, direction: 'down' | 'up') => void;
|
||||||
onScrollEnd?: (offset: number, handle: ItemListHandle) => void;
|
ref?: RefObject<ItemListHandle>;
|
||||||
onStartReached?: (index: number, handle: ItemListHandle) => void;
|
|
||||||
ref?: Ref<ItemListHandle>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemGridList = ({
|
export const ItemGridList = ({
|
||||||
currentPage,
|
|
||||||
data,
|
data,
|
||||||
enableExpansion = true,
|
enableExpansion = true,
|
||||||
enableSelection = true,
|
enableSelection = true,
|
||||||
@@ -239,17 +291,16 @@ export const ItemGridList = ({
|
|||||||
initialTop,
|
initialTop,
|
||||||
itemsPerRow,
|
itemsPerRow,
|
||||||
itemType,
|
itemType,
|
||||||
onEndReached,
|
|
||||||
onRangeChanged,
|
onRangeChanged,
|
||||||
onScroll,
|
onScroll,
|
||||||
onScrollEnd,
|
onScrollEnd,
|
||||||
onStartReached,
|
|
||||||
ref,
|
ref,
|
||||||
}: ItemGridListProps) => {
|
}: ItemGridListProps) => {
|
||||||
const itemGridRef = useListRef(null);
|
const rootRef = useRef(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const outerRef = useRef(null);
|
||||||
|
const listRef = useRef<FixedSizeList<GridItemProps>>(null);
|
||||||
const { ref: containerRef, width: containerWidth } = useElementSize();
|
const { ref: containerRef, width: containerWidth } = useElementSize();
|
||||||
const mergedContainerRef = useMergedRef(containerRef, scrollContainerRef);
|
const mergedContainerRef = useMergedRef(containerRef, rootRef);
|
||||||
|
|
||||||
const internalState = useItemListState();
|
const internalState = useItemListState();
|
||||||
|
|
||||||
@@ -275,66 +326,27 @@ export const ItemGridList = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { current: root } = scrollContainerRef;
|
const { current: root } = rootRef;
|
||||||
|
const { current: outer } = outerRef;
|
||||||
|
|
||||||
if (root) {
|
if (root && outer) {
|
||||||
initialize({
|
initialize({
|
||||||
elements: { viewport: root.firstElementChild as HTMLElement },
|
elements: {
|
||||||
|
viewport: outer,
|
||||||
|
},
|
||||||
target: root,
|
target: root,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [itemGridRef, initialize]);
|
}, [initialize]);
|
||||||
|
|
||||||
const isInitialScrollPositionSet = useRef<boolean>(false);
|
|
||||||
|
|
||||||
const hasExpanded = internalState.hasExpanded();
|
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 | {
|
const [tableMeta, setTableMeta] = useState<null | {
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
itemHeight: number;
|
itemHeight: number;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
}>(null);
|
}>(null);
|
||||||
|
|
||||||
// Use throttled function created outside component for better performance
|
|
||||||
const throttledSetTableMeta = useMemo(() => {
|
const throttledSetTableMeta = useMemo(() => {
|
||||||
return createThrottledSetTableMeta(itemsPerRow);
|
return createThrottledSetTableMeta(itemsPerRow);
|
||||||
}, [itemsPerRow]);
|
}, [itemsPerRow]);
|
||||||
@@ -343,92 +355,6 @@ export const ItemGridList = ({
|
|||||||
throttledSetTableMeta(containerWidth, data.length, itemType, setTableMeta);
|
throttledSetTableMeta(containerWidth, data.length, itemType, setTableMeta);
|
||||||
}, [containerWidth, data.length, itemType, throttledSetTableMeta]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.itemGridContainer}
|
className={styles.itemGridContainer}
|
||||||
@@ -440,11 +366,14 @@ export const ItemGridList = ({
|
|||||||
enableExpansion={enableExpansion}
|
enableExpansion={enableExpansion}
|
||||||
enableSelection={enableSelection}
|
enableSelection={enableSelection}
|
||||||
gap={gap}
|
gap={gap}
|
||||||
|
initialTop={initialTop}
|
||||||
internalState={internalState}
|
internalState={internalState}
|
||||||
itemGridRef={itemGridRef}
|
|
||||||
itemType={itemType}
|
itemType={itemType}
|
||||||
onRowsRendered={handleOnRowsRendered}
|
onRangeChanged={onRangeChanged}
|
||||||
onScroll={handleScroll}
|
onScroll={onScroll ?? (() => {})}
|
||||||
|
onScrollEnd={onScrollEnd ?? (() => {})}
|
||||||
|
outerRef={outerRef}
|
||||||
|
ref={listRef}
|
||||||
tableMeta={tableMeta}
|
tableMeta={tableMeta}
|
||||||
/>
|
/>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -458,45 +387,38 @@ export const ItemGridList = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListComponent = memo(
|
const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
|
||||||
({
|
const { index, style } = props;
|
||||||
columns,
|
const { columns, controls, data, gap, itemType } = props.data;
|
||||||
controls,
|
|
||||||
data,
|
|
||||||
gap,
|
|
||||||
index,
|
|
||||||
itemType,
|
|
||||||
style,
|
|
||||||
}: RowComponentProps<GridItemProps>) => {
|
|
||||||
const items: ReactNode[] = [];
|
|
||||||
const itemCount = data.length;
|
|
||||||
const startIndex = index * columns;
|
|
||||||
const stopIndex = Math.min(itemCount - 1, startIndex + columns - 1);
|
|
||||||
|
|
||||||
const columnCountInRow = stopIndex - startIndex + 1;
|
const items: ReactNode[] = [];
|
||||||
|
const itemCount = data.length;
|
||||||
|
const startIndex = index * columns;
|
||||||
|
const stopIndex = Math.min(itemCount - 1, startIndex + columns - 1);
|
||||||
|
|
||||||
let columnCountToAdd = 0;
|
const columnCountInRow = stopIndex - startIndex + 1;
|
||||||
|
|
||||||
if (columnCountInRow !== columns) {
|
let columnCountToAdd = 0;
|
||||||
columnCountToAdd = columns - columnCountInRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
|
if (columnCountInRow !== columns) {
|
||||||
items.push(
|
columnCountToAdd = columns - columnCountInRow;
|
||||||
<div
|
}
|
||||||
className={clsx(styles.itemRow, styles[`gap-${gap}`])}
|
|
||||||
key={`card-${i}-${index}`}
|
|
||||||
style={{ '--columns': columns } as CSSProperties}
|
|
||||||
>
|
|
||||||
<ItemCard controls={controls} data={data[i]} itemType={itemType} withControls />
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
|
||||||
<div className={styles.itemList} style={style}>
|
items.push(
|
||||||
{items}
|
<div
|
||||||
</div>
|
className={clsx(styles.itemRow, styles[`gap-${gap}`])}
|
||||||
|
key={`card-${i}-${index}`}
|
||||||
|
style={{ '--columns': columns } as CSSProperties}
|
||||||
|
>
|
||||||
|
<ItemCard controls={controls} data={data[i]} itemType={itemType} withControls />
|
||||||
|
</div>,
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
return (
|
||||||
|
<div className={styles.itemList} style={style}>
|
||||||
|
{items}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,15 +56,11 @@ export const AlbumListInfiniteGrid = forwardRef<any, AlbumListInfiniteGridProps>
|
|||||||
<ItemGridList
|
<ItemGridList
|
||||||
data={data}
|
data={data}
|
||||||
gap={gap}
|
gap={gap}
|
||||||
initialTop={{
|
initialTop={scrollOffset ?? 0}
|
||||||
to: scrollOffset ?? 0,
|
|
||||||
type: 'offset',
|
|
||||||
}}
|
|
||||||
itemsPerRow={itemsPerRow}
|
itemsPerRow={itemsPerRow}
|
||||||
itemType={LibraryItem.ALBUM}
|
itemType={LibraryItem.ALBUM}
|
||||||
onRangeChanged={onRangeChanged}
|
onRangeChanged={onRangeChanged}
|
||||||
onScrollEnd={handleOnScrollEnd}
|
onScrollEnd={handleOnScrollEnd}
|
||||||
ref={ref}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,15 +52,11 @@ export const SongListInfiniteGrid = forwardRef<any, SongListInfiniteGridProps>(
|
|||||||
<ItemGridList
|
<ItemGridList
|
||||||
data={data}
|
data={data}
|
||||||
gap={gap}
|
gap={gap}
|
||||||
initialTop={{
|
initialTop={scrollOffset ?? 0}
|
||||||
to: scrollOffset ?? 0,
|
|
||||||
type: 'offset',
|
|
||||||
}}
|
|
||||||
itemsPerRow={itemsPerRow}
|
itemsPerRow={itemsPerRow}
|
||||||
itemType={LibraryItem.SONG}
|
itemType={LibraryItem.SONG}
|
||||||
onRangeChanged={onRangeChanged}
|
onRangeChanged={onRangeChanged}
|
||||||
onScrollEnd={handleOnScrollEnd}
|
onScrollEnd={handleOnScrollEnd}
|
||||||
ref={ref}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user