From 0b56524b7d6286715321d1446d641c37dfaf58f5 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 26 Oct 2025 01:38:03 -0700 Subject: [PATCH] migrate item grid back to react-window v1 --- package.json | 2 + pnpm-lock.yaml | 31 ++ .../item-card/item-card-controls.tsx | 14 +- .../components/item-card/item-card.tsx | 87 ++++- .../helpers/item-list-infinite-loader.ts | 261 ++++++++----- .../item-list/helpers/item-list-state.ts | 58 ++- .../item-grid-list/item-grid-list.module.css | 5 +- .../item-grid-list/item-grid-list.tsx | 354 +++++++----------- .../components/album-list-infinite-grid.tsx | 6 +- .../components/song-list-infinite-grid.tsx | 6 +- 10 files changed, 471 insertions(+), 353 deletions(-) diff --git a/package.json b/package.json index a851ef68a..a5db7a701 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc0ff6c3f..4283c08e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/renderer/components/item-card/item-card-controls.tsx b/src/renderer/components/item-card/item-card-controls.tsx index cec747da1..de7390c2f 100644 --- a/src/renderer/components/item-card/item-card-controls.tsx +++ b/src/renderer/components/item-card/item-card-controls.tsx @@ -57,7 +57,7 @@ export const ItemCardControls = ({ { e.stopPropagation(); - controls.onPlay?.(item, itemType, Play.NOW, e); + controls?.onPlay?.(item, itemType, Play.NOW, e); }} /> { e.stopPropagation(); - controls.onPlay?.(item, itemType, Play.NEXT, e); + controls?.onPlay?.(item, itemType, Play.NEXT, e); }} /> { e.stopPropagation(); - controls.onPlay?.(item, itemType, Play.LAST, e); + controls?.onPlay?.(item, itemType, Play.LAST, e); }} /> { e.stopPropagation(); - controls.onFavorite?.(item, itemType, e); + controls?.onFavorite?.(item, itemType, e); }} /> @@ -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 && ( { e.stopPropagation(); - controls.onItemExpand?.(item, itemType, e); + controls?.onItemExpand?.(item, itemType, e); }} /> )} diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index af0ab3ff2..e1179b4b3 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -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) => { + controls?.onClick?.(data, itemType, e); + }; + return (
withControls && setShowControls(true)} - onMouseLeave={() => withControls && setShowControls(false)} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} >
- +
{rows.map((row) => (
@@ -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) => { + controls?.onClick?.(data, itemType, e); + }; + return (
withControls && setShowControls(true)} - onMouseLeave={() => withControls && setShowControls(false)} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} >
- +
{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) => { + controls?.onClick?.(data, itemType, e); + }; + return (
controls?.onClick?.(data, itemType, e)} - onMouseEnter={() => withControls && setShowControls(true)} - onMouseLeave={() => withControls && setShowControls(false)} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} > - {withControls && showControls && ( + {withControls && showControls && data && ( )}
-
- {rows.map((row) => ( - - ))} -
+ {data && ( +
+ {rows.map((row) => ( + + ))} +
+ )}
); } @@ -257,7 +309,10 @@ const PosterItemCard = ({ return (
- +
{rows.map((row) => ( diff --git a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts index 819585352..0c91c5396 100644 --- a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts @@ -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; listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>; + maxPagesToFetch?: number; query: Record; 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({ direction: 'unknown', - lastRange: null, - lastScrollTime: 0, + lastStartIndex: null, }); const { data: totalItemCount } = useSuspenseQuery(listCountQuery); @@ -78,19 +81,23 @@ export const useItemListInfiniteLoader = ({ setItemCount(totalItemCount); }, [setItemCount, totalItemCount]); - const pagesLoaded = useRef>({}); - - // 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({ + // 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 }>({ enabled: false, initialData: getInitialData(totalItemCount), queryFn: () => { @@ -100,82 +107,135 @@ 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: itemsPerPage, + startIndex, + ...query, + }; - const queryParams = { - limit: fetchRange.limit, - startIndex: fetchRange.startIndex, - ...query, - }; + const result = await queryClient.ensureQueryData({ + gcTime: 1000 * 15, + queryFn: async ({ signal }) => { + const result = await listQueryFn({ + apiClientProps: { server: getServerById(serverId), signal }, + query: queryParams, + }); - const result = await queryClient.ensureQueryData({ - gcTime: 1000 * 15, - queryFn: async ({ signal }) => { - const result = await listQueryFn({ - apiClientProps: { server: getServerById(serverId), signal }, - query: queryParams, + return result.items; + }, + queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams), + staleTime: 1000 * 15, + }); + + 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 }) => { + 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, - }); - - queryClient.setQueryData(dataQueryKey, (oldData: unknown[]) => { - return [...oldData.slice(0, startIndex), ...result, ...oldData.slice(endIndex)]; - }); - - pagesLoaded.current[pageNumber] = true; - }, 500); - }, [itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType]); + ); + }; + }, [ + 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) => { - if (!item) { - return item; - } - - if (!indexes.includes(index)) { - return item; - } - + queryClient.setQueryData( + dataQueryKey, + (prev: { data: unknown[]; pagesLoaded: Record }) => { return { - ...item, - ...value, + ...prev, + data: prev.data.map((item: any, index) => { + if (!item) { + return item; + } + + if (!indexes.includes(index)) { + return item; + } + + return { + ...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, 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, 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, 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); + 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') { - 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 { - 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 { direction: newDirection, - limit: itemsPerPage, - startIndex: pageIndex * itemsPerPage, + pagesToFetch, + thresholdDistance, }; }; diff --git a/src/renderer/components/item-list/helpers/item-list-state.ts b/src/renderer/components/item-list/helpers/item-list-state.ts index 56dc1514e..054f6556b 100644 --- a/src/renderer/components/item-list/helpers/item-list-state.ts +++ b/src/renderer/components/item-list/helpers/item-list-state.ts @@ -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,22 +242,42 @@ export const useItemListState = (): ItemListStateActions => { return itemGridSelectors.hasAnySelected(state); }, [state]); - return { - clearAll, - clearExpanded, - clearSelected, - getExpanded, - getExpandedIds, - getSelected, - getSelectedIds, - getVersion, - hasExpanded, - hasSelected, - isExpanded, - isSelected, - setExpanded, - setSelected, - toggleExpanded, - toggleSelected, - }; + return useMemo( + () => ({ + clearAll, + clearExpanded, + clearSelected, + getExpanded, + getExpandedIds, + getSelected, + getSelectedIds, + getVersion, + hasExpanded, + hasSelected, + isExpanded, + isSelected, + setExpanded, + setSelected, + toggleExpanded, + toggleSelected, + }), + [ + clearAll, + clearExpanded, + clearSelected, + getExpanded, + getExpandedIds, + getSelected, + getSelectedIds, + getVersion, + hasExpanded, + hasSelected, + isExpanded, + isSelected, + setExpanded, + setSelected, + toggleExpanded, + toggleSelected, + ], + ); }; diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css b/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css index 06f38c125..a6f92fd23 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css @@ -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 { diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index a2ee936e6..a53395d6b 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -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; itemType: LibraryItem; - onRowsRendered: (visibleRows: { startIndex: number; stopIndex: number }) => void; - onScroll: (e: UIEvent) => void; + onRangeChanged?: ItemGridListProps['onRangeChanged']; + onScroll?: ItemGridListProps['onScroll']; + onScrollEnd?: ItemGridListProps['onScrollEnd']; + outerRef: RefObject; + ref: RefObject>; 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 ( - +
+ + {({ height, width }) => { + return ( + + {ListComponent} + + ); + }} + +
); }, ); @@ -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) => void; - onScrollEnd?: (offset: number, handle: ItemListHandle) => void; - onStartReached?: (index: number, handle: ItemListHandle) => void; - ref?: Ref; + onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void; + onScroll?: (offset: number, direction: 'down' | 'up') => void; + onScrollEnd?: (offset: number, direction: 'down' | 'up') => void; + ref?: RefObject; } 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(null); + const rootRef = useRef(null); + const outerRef = useRef(null); + const listRef = useRef>(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(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) => { - 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); - // 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 (
{})} + onScrollEnd={onScrollEnd ?? (() => {})} + outerRef={outerRef} + ref={listRef} tableMeta={tableMeta} /> @@ -458,45 +387,38 @@ export const ItemGridList = ({ ); }; -const ListComponent = memo( - ({ - columns, - controls, - data, - gap, - index, - itemType, - style, - }: RowComponentProps) => { - const items: ReactNode[] = []; - const itemCount = data.length; - const startIndex = index * columns; - const stopIndex = Math.min(itemCount - 1, startIndex + columns - 1); +const ListComponent = memo((props: ListChildComponentProps) => { + const { index, style } = props; + const { columns, controls, data, gap, itemType } = props.data; - 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) { - columnCountToAdd = columns - columnCountInRow; - } + let columnCountToAdd = 0; - for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) { - items.push( -
- -
, - ); - } + if (columnCountInRow !== columns) { + columnCountToAdd = columns - columnCountInRow; + } - return ( -
- {items} -
+ for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) { + items.push( +
+ +
, ); - }, -); + } + + return ( +
+ {items} +
+ ); +}); diff --git a/src/renderer/features/albums/components/album-list-infinite-grid.tsx b/src/renderer/features/albums/components/album-list-infinite-grid.tsx index efd34bd7a..d7e26ee76 100644 --- a/src/renderer/features/albums/components/album-list-infinite-grid.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-grid.tsx @@ -56,15 +56,11 @@ export const AlbumListInfiniteGrid = forwardRef ); }, diff --git a/src/renderer/features/songs/components/song-list-infinite-grid.tsx b/src/renderer/features/songs/components/song-list-infinite-grid.tsx index 8506760f1..d7b45c612 100644 --- a/src/renderer/features/songs/components/song-list-infinite-grid.tsx +++ b/src/renderer/features/songs/components/song-list-infinite-grid.tsx @@ -52,15 +52,11 @@ export const SongListInfiniteGrid = forwardRef( ); },