From 9db78307266c131bc0346264cd6889fb3d05275c Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 28 Sep 2025 19:20:29 -0700 Subject: [PATCH] update ItemGrid to use react-window v2 --- .../components/item-card/item-card.tsx | 49 ++- .../item-list/item-grid/item-grid.module.css | 107 ++--- .../item-list/item-grid/item-grid.tsx | 383 +++++++++++------- 3 files changed, 298 insertions(+), 241 deletions(-) diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 0e1fc6b6d..0ef816ea6 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -1,6 +1,15 @@ import clsx from 'clsx'; import { AnimatePresence } from 'motion/react'; -import { Dispatch, Fragment, lazy, ReactNode, SetStateAction, useState } from 'react'; +import { + Dispatch, + Fragment, + lazy, + memo, + MouseEvent, + ReactNode, + SetStateAction, + useState, +} from 'react'; import { generatePath, Link } from 'react-router-dom'; import styles from './item-card.module.css'; @@ -35,7 +44,11 @@ interface ItemCardProps { data: Album | AlbumArtist | Artist | Playlist | Song | undefined; isRound?: boolean; itemType: LibraryItem; - onClick?: () => void; + onClick?: ( + e: MouseEvent, + item: Album | AlbumArtist | Artist | Playlist | Song | undefined, + itemType: LibraryItem, + ) => void; onItemExpand?: () => void; onItemSelect?: () => void; type?: 'compact' | 'default' | 'poster'; @@ -121,6 +134,7 @@ const CompactItemCard = ({ data, imageUrl, isRound, + itemType, onClick, onItemExpand, onItemSelect, @@ -134,7 +148,7 @@ const CompactItemCard = ({
onClick?.(e, data, itemType)} onMouseEnter={() => withControls && setShowControls(true)} onMouseLeave={() => withControls && setShowControls(false)} > @@ -158,7 +172,7 @@ const CompactItemCard = ({ return (
- +
{rows.map((row) => (
@@ -175,6 +189,7 @@ const DefaultItemCard = ({ data, imageUrl, isRound, + itemType, onClick, onItemExpand, onItemSelect, @@ -188,7 +203,7 @@ const DefaultItemCard = ({
onClick?.(e, data, itemType)} onDoubleClick={onItemExpand} onMouseEnter={() => withControls && setShowControls(true)} onMouseLeave={() => withControls && setShowControls(false)} @@ -213,7 +228,7 @@ const DefaultItemCard = ({ return (
- +
{rows.map((row) => ( @@ -230,6 +245,7 @@ const PosterItemCard = ({ data, imageUrl, isRound, + itemType, onClick, onItemExpand, onItemSelect, @@ -243,7 +259,7 @@ const PosterItemCard = ({
onClick?.(e, data, itemType)} onMouseEnter={() => withControls && setShowControls(true)} onMouseLeave={() => withControls && setShowControls(false)} > @@ -330,6 +346,23 @@ const getDataRows = (itemType: LibraryItem): DataRow[] => { } }; +export const getDataRowsCount = (itemType: LibraryItem) => { + switch (itemType) { + case LibraryItem.ALBUM: + return 2; + case LibraryItem.ALBUM_ARTIST: + return 1; + case LibraryItem.ARTIST: + return 1; + case LibraryItem.PLAYLIST: + return 2; + case LibraryItem.SONG: + return 2; + default: + return 1; + } +}; + const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => { if (data && 'imageUrl' in data) { return data.imageUrl || undefined; @@ -375,3 +408,5 @@ const ItemCardRow = ({ ); }; + +export const MemoizedItemCard = memo(ItemCard); diff --git a/src/renderer/components/item-list/item-grid/item-grid.module.css b/src/renderer/components/item-list/item-grid/item-grid.module.css index d0a6b6170..78c4307a6 100644 --- a/src/renderer/components/item-list/item-grid/item-grid.module.css +++ b/src/renderer/components/item-list/item-grid/item-grid.module.css @@ -1,95 +1,38 @@ .item-grid-container { display: flex; - flex-direction: column; + flex-direction: column !important; + width: 100%; + height: 100%; + padding: 0 var(--theme-spacing-md); +} + +.auto-sizer { + width: 100% !important; + height: 100% !important; +} + +.list-container { + display: flex; width: 100%; height: 100%; } .grid-list-container { width: 100%; + padding: 0 var(--theme-spacing-md); +} + +.item-list { + display: flex; +} + +.item-row { + flex: 1 1 calc(100% / var(--columns)); + width: 100%; + max-width: calc(100% / var(--columns)); height: 100%; - padding-right: var(--theme-spacing-md); - container-name: grid-list; - container-type: inline-size; -} - -.grid-list-component { - display: grid; - grid-template-columns: repeat(4, 1fr); - grid-auto-flow: row dense; - - @container (min-width: $breakpoint-xs) { - grid-template-columns: repeat(6, 1fr); - } - - @container (min-width: $breakpoint-sm) { - grid-template-columns: repeat(8, 1fr); - } - - @container (min-width: $breakpoint-md) { - grid-template-columns: repeat(10, 1fr); - } - - @container (min-width: $breakpoint-lg) { - grid-template-columns: repeat(12, 1fr); - } - - @container (min-width: $breakpoint-xl) { - grid-template-columns: repeat(14, 1fr); - } - - @container (min-width: $breakpoint-2xl) { - grid-template-columns: repeat(16, 1fr); - } - - @container (min-width: $breakpoint-3xl) { - grid-template-columns: repeat(18, 1fr); - } - - /* display: flex; */ - - /* flex-wrap: wrap; */ -} - -.grid-item-component { - display: flex; - grid-column: span 2; padding: var(--theme-spacing-sm); - - /* box-sizing: border-box; - display: flex; - flex: none; - align-content: stretch; - width: 50%; - padding: var(--theme-spacing-sm); - - @container (min-width: $breakpoint-xs) { - width: 33.33%; - } - - @container (min-width: $breakpoint-sm) { - width: 25%; - } - - @container (min-width: $breakpoint-md) { - width: 20%; - } - - @container (min-width: $breakpoint-lg) { - width: 14.28%; - } - - @container (min-width: $breakpoint-xl) { - width: 12.5%; - } - - @container (min-width: $breakpoint-2xl) { - width: 11.11%; - } - - @container (min-width: $breakpoint-3xl) { - width: 10%; - } */ + overflow: hidden; } .full-width-content { diff --git a/src/renderer/components/item-list/item-grid/item-grid.tsx b/src/renderer/components/item-list/item-grid/item-grid.tsx index 2b8101fbe..7ec46f02a 100644 --- a/src/renderer/components/item-list/item-grid/item-grid.tsx +++ b/src/renderer/components/item-list/item-grid/item-grid.tsx @@ -1,83 +1,32 @@ -import clsx from 'clsx'; +import { useElementSize, useMergedRef } from '@mantine/hooks'; +import { throttle } from 'lodash'; import { AnimatePresence, motion, Variants } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { CSSProperties, - forwardRef, - memo, - ReactNode, + MouseEvent, Ref, - RefObject, + UIEvent, + useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, } from 'react'; -import { - GridComponents, - VirtuosoGrid, - VirtuosoGridHandle, - VirtuosoGridProps, -} from 'react-virtuoso'; +import { List, ListImperativeAPI, RowComponentProps, useListRef } from 'react-window-v2'; -import { ItemListItem, ItemListStateActions, useItemListState } from '../helpers/item-list-state'; import styles from './item-grid.module.css'; -import { ItemCard } from '/@/renderer/components/item-card/item-card'; +import { getDataRowsCount, ItemCard } from '/@/renderer/components/item-card/item-card'; import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item'; +import { + ItemListStateActions, + useItemListState, +} from '/@/renderer/components/item-list/helpers/item-list-state'; import { LibraryItem } from '/@/shared/types/domain-types'; -const gridComponents: GridComponents = { - Item: forwardRef< - HTMLDivElement, - { - children?: ReactNode; - className?: string; - context?: ItemContext; - 'data-index': number; - enableExpanded?: boolean; - style?: CSSProperties; - virtuosoRef?: RefObject; - } - >((props, ref) => { - const { children, context, 'data-index': index } = props; - - return ( -
- {children} -
- ); - }), - List: forwardRef< - HTMLDivElement, - { children?: ReactNode; className?: string; style?: CSSProperties } - >((props, ref) => { - const { children, className, style, ...rest } = props; - - return ( -
- {children} -
- ); - }), -}; - -interface ItemContext { - enableExpansion?: boolean; - enableSelection?: boolean; - internalState: ItemListStateActions; - itemType: LibraryItem; - onItemClick?: (item: unknown, index: number) => void; - onItemContextMenu?: (item: unknown, index: number) => void; - onItemDoubleClick?: (item: unknown, index: number) => void; -} - -interface ItemGridProps { +export interface ItemGridProps { data: unknown[]; enableExpansion?: boolean; enableSelection?: boolean; @@ -91,25 +40,34 @@ interface ItemGridProps { }; itemType: LibraryItem; onEndReached?: (index: number) => void; - onIsScrolling?: VirtuosoGridProps['isScrolling']; onItemClick?: (item: unknown, index: number) => void; onItemContextMenu?: (item: unknown, index: number) => void; onItemDoubleClick?: (item: unknown, index: number) => void; onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void; - onScroll?: VirtuosoGridProps['onScroll']; + onScroll?: (e: UIEvent) => void; + onScrollEnd?: () => void; onStartReached?: (index: number) => void; - ref: Ref; + ref: Ref; totalItemCount?: number; } +interface ItemContext { + enableExpansion?: boolean; + enableSelection?: boolean; + internalState: ItemListStateActions; + itemType: LibraryItem; + onItemClick?: (item: unknown, index: number) => void; + onItemContextMenu?: (item: unknown, index: number) => void; + onItemDoubleClick?: (item: unknown, index: number) => void; +} + const expandedAnimationVariants: Variants = { hidden: { height: 0, - maxHeight: 0, + minHeight: 0, }, show: { - height: '40dvh', - maxHeight: '500px', + minHeight: '300px', transition: { duration: 0.3, ease: 'easeInOut', @@ -124,24 +82,31 @@ export const ItemGrid = ({ initialTopMostItemIndex = 0, itemType, onEndReached, - onIsScrolling, onItemClick, onItemContextMenu, onItemDoubleClick, onRangeChanged, onScroll, + onScrollEnd, onStartReached, - ref, - totalItemCount, + totalItemCount = 0, }: ItemGridProps) => { - const rootRef = useRef(null); - - const [scroller, setScroller] = useState(null); + const itemGridRef = useListRef(null); + const scrollContainerRef = useRef(null); + const { ref: containerRef, width: containerWidth } = useElementSize(); + const mergedContainerRef = useMergedRef(containerRef, scrollContainerRef); const internalState = useItemListState(); - const [initialize, osInstance] = useOverlayScrollbars({ + const [initialize] = useOverlayScrollbars({ defer: true, + events: { + initialized(osInstance) { + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + viewport.style.overflowY = `var(--os-viewport-overflow-y)`; + }, + }, options: { overflow: { x: 'hidden', y: 'scroll' }, paddingAbsolute: true, @@ -156,65 +121,169 @@ export const ItemGrid = ({ }); useEffect(() => { - const { current: root } = rootRef; + const { current: root } = scrollContainerRef; - if (scroller && root) { + if (root) { initialize({ - elements: { viewport: scroller }, + elements: { viewport: root.firstElementChild as HTMLElement }, target: root, }); } - - return () => osInstance()?.destroy(); - }, [scroller, initialize, osInstance]); - - const itemContext = useMemo( - () => ({ - enableExpansion, - enableSelection, - internalState, - itemType, - onItemClick, - onItemContextMenu, - onItemDoubleClick, - }), - [ - internalState, - enableExpansion, - enableSelection, - itemType, - onItemClick, - onItemDoubleClick, - onItemContextMenu, - ], - ); + }, [itemGridRef, initialize]); const hasExpanded = internalState.hasExpanded(); + const handleExpand = useCallback( + (_e: MouseEvent, item: unknown, itemType: LibraryItem) => { + if (item && typeof item === 'object' && 'id' in item && 'serverId' in item) { + internalState.toggleExpanded({ + id: item.id as string, + itemType: itemType, + serverId: item.serverId as string, + }); + } + }, + [internalState], + ); + + const handleScroll = useCallback( + (e: UIEvent) => { + onScroll?.(e); + }, + [onScroll], + ); + + const [tableMeta, setTableMeta] = useState(null); + + // Throttled function to update table meta + const throttledSetTableMeta = useMemo(() => { + return throttle((width: number, dataLength: number, type: LibraryItem) => { + const isSm = width >= 600; + const isMd = width >= 768; + const isLg = width >= 1200; + const isXl = width >= 1500; + const is2xl = width >= 1920; + const is3xl = width >= 2560; + + let itemsPerRow = 2; + + if (is3xl) { + itemsPerRow = 12; + } else if (is2xl) { + itemsPerRow = 10; + } else if (isXl) { + itemsPerRow = 8; + } else if (isLg) { + itemsPerRow = 6; + } else if (isMd) { + itemsPerRow = 4; + } else if (isSm) { + itemsPerRow = 3; + } else { + itemsPerRow = 2; + } + const widthPerItem = Number(width) / itemsPerRow; + const itemHeight = widthPerItem + getDataRowsCount(type) * 26; + + if (widthPerItem === 0) { + return; + } + + setTableMeta({ + columnCount: itemsPerRow, + itemHeight, + rowCount: Math.ceil(dataLength / itemsPerRow), + }); + }, 200); + }, []); + + useLayoutEffect(() => { + throttledSetTableMeta(containerWidth, data.length, itemType); + }, [containerWidth, data.length, itemType, throttledSetTableMeta]); + + const handleOnRowsRendered = useCallback( + (visibleRows: { startIndex: number; stopIndex: number }) => { + onRangeChanged?.({ + endIndex: visibleRows.stopIndex * (tableMeta?.columnCount || 0), + startIndex: visibleRows.startIndex * (tableMeta?.columnCount || 0), + }); + + if (onStartReached || onEndReached) { + const totalRows = Math.ceil(totalItemCount / (tableMeta?.columnCount || 0)); + const startRow = visibleRows.startIndex; + const endRow = visibleRows.stopIndex; + + if (startRow === 0) { + onStartReached?.(startRow); + } + if (endRow >= totalRows) { + onEndReached?.(endRow); + } + } + }, + [onEndReached, onRangeChanged, onStartReached, totalItemCount, tableMeta?.columnCount], + ); + + const elements = useMemo(() => { + if (!tableMeta) { + return []; + } + + console.log('data change'); + + return data + .map((d, i) => { + return { + data: d, + index: i, + }; + }) + .reduce( + (acc, d) => { + if (d.index % (tableMeta?.columnCount || 0) === 0) { + acc.push([]); + } + const prev = acc[acc.length - 1]; + prev.push(d); + return acc; + }, + [] as { data: any; index: number }[][], + ); + }, [tableMeta, data]); + return ( -
-
- -
+ + {hasExpanded && ( )} -
+ ); }; -const itemContent = (index: number, item: any, context: ItemContext) => { - return ; -}; - -const InnerItem = memo( - ({ context, index, item }: { context: ItemContext; index: number; item: ItemListItem }) => { - const handleClick = () => { - context.internalState.toggleExpanded({ - id: item.id, - itemType: item.itemType, - serverId: item.serverId, - }); - }; - - return ( - context.onItemDoubleClick?.(item, index)} - withControls - /> - ); - }, -); +function RowComponent({ + columns, + data, + handleExpand, + index, + itemType, + style, +}: RowComponentProps<{ + columns: number; + data: any[]; + handleExpand: (e: MouseEvent, item: unknown, itemType: LibraryItem) => void; + itemType: LibraryItem; +}>) { + return ( +
+ {data[index].map((d) => ( +
+ handleExpand(e, item, itemType)} + type="poster" + withControls + /> +
+ ))} +
+ ); +}