Files
feishin/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx
T
2025-11-29 19:30:51 -08:00

427 lines
14 KiB
TypeScript

import { useElementSize, useMergedRef } from '@mantine/hooks';
import clsx from 'clsx';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import { AnimatePresence } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import {
CSSProperties,
Ref,
UIEvent,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { List, RowComponentProps, useListRef } from 'react-window-v2';
import { ExpandedListContainer } from '../expanded-list-container';
import styles from './item-grid-list.module.css';
import { getDataRowsCount, ItemCard } from '/@/renderer/components/item-card/item-card';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { itemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
useItemListState,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
import { LibraryItem } from '/@/shared/types/domain-types';
export interface GridItemProps {
columns: number;
data: any[];
enableExpansion?: boolean;
enableSelection?: boolean;
gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
internalState: ItemListStateActions;
itemType: LibraryItem;
}
export interface ItemGridListProps {
data: unknown[];
enableExpansion?: boolean;
enableSelection?: boolean;
gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
initialTop?: {
behavior?: 'auto' | 'smooth';
to: number;
type: 'index' | 'offset';
};
itemsPerRow?: number;
itemType: LibraryItem;
onEndReached?: (index: number, handle: ItemListHandle) => void;
onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
onScroll?: (e: UIEvent<HTMLDivElement>) => void;
onScrollEnd?: (offset: number, handle: ItemListHandle) => void;
onStartReached?: (index: number, handle: ItemListHandle) => void;
ref?: Ref<ItemListHandle>;
}
export const ItemGridList = ({
data,
enableExpansion = true,
enableSelection = true,
gap = 'sm',
initialTop,
itemsPerRow,
itemType,
onEndReached,
onRangeChanged,
onScroll,
onScrollEnd,
onStartReached,
ref,
}: ItemGridListProps) => {
const itemGridRef = useListRef(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const { ref: containerRef, width: containerWidth } = useElementSize();
const mergedContainerRef = useMergedRef(containerRef, scrollContainerRef);
const internalState = useItemListState();
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,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
},
},
});
useEffect(() => {
const { current: root } = scrollContainerRef;
if (root) {
initialize({
elements: { viewport: root.firstElementChild as HTMLElement },
target: root,
});
}
}, [itemGridRef, initialize]);
const isInitialScrollPositionSet = useRef<boolean>(false);
const hasExpanded = internalState.hasExpanded();
const handleOnScrollEnd = useCallback(
(scrollTop: number, handle: ItemListHandle) => {
onScrollEnd?.(scrollTop, handle);
},
[onScrollEnd],
);
const debouncedOnScrollEnd = debounce(handleOnScrollEnd, 150);
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
onScroll?.(e);
debouncedOnScrollEnd(
e.currentTarget.scrollTop,
itemGridRef.current ?? (undefined as any),
);
},
[onScroll, debouncedOnScrollEnd, itemGridRef],
);
const scrollToGridOffset = useCallback((offset: number) => {
const scrollContainer = scrollContainerRef.current?.firstElementChild as
| HTMLElement
| undefined;
if (scrollContainer) {
scrollContainer.scrollTo({ behavior: 'instant', top: offset });
}
}, []);
const [tableMeta, setTableMeta] = useState<null | {
columnCount: number;
itemHeight: number;
rowCount: number;
}>(null);
// 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 >= 960;
const isXl = width >= 1200;
const is2xl = width >= 1440;
const is3xl = width >= 1920;
const is4xl = width >= 2560;
let dynamicItemsPerRow = 2;
if (is4xl) {
dynamicItemsPerRow = 12;
} else if (is3xl) {
dynamicItemsPerRow = 10;
} else if (is2xl) {
dynamicItemsPerRow = 8;
} else if (isXl) {
dynamicItemsPerRow = 6;
} else if (isLg) {
dynamicItemsPerRow = 5;
} else if (isMd) {
dynamicItemsPerRow = 4;
} else if (isSm) {
dynamicItemsPerRow = 3;
} else {
dynamicItemsPerRow = 2;
}
const setItemsPerRow = itemsPerRow || dynamicItemsPerRow;
const widthPerItem = Number(width) / setItemsPerRow;
const itemHeight = widthPerItem + getDataRowsCount(type) * 26;
if (widthPerItem === 0) {
return;
}
setTableMeta({
columnCount: setItemsPerRow,
itemHeight,
rowCount: Math.ceil(dataLength / setItemsPerRow),
});
}, 200);
}, [itemsPerRow]);
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(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,
],
);
const elements = useMemo(() => {
if (!tableMeta) {
return [];
}
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]);
const itemProps: GridItemProps = {
columns: tableMeta?.columnCount || 0,
data: elements,
enableExpansion,
enableSelection,
gap,
internalState,
itemType,
};
useEffect(() => {
if (!initialTop || isInitialScrollPositionSet.current || !tableMeta?.itemHeight) 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]);
const imperativeHandle: ItemListHandle = useMemo(() => {
return {
clearExpanded: () => {
internalState.clearExpanded();
},
clearSelected: () => {
internalState.clearSelected();
},
getItem: (index: number) => data[index],
getItemCount: () => data.length,
getItems: () => data,
internalState,
scrollToIndex: (index: number) => {
itemGridRef.current?.scrollToRow({
align: 'smart',
behavior: 'auto',
index: Math.floor(index / (tableMeta?.columnCount || 1)),
});
},
scrollToOffset: (offset: number) => {
scrollToGridOffset(offset);
},
};
}, [data, internalState, scrollToGridOffset, tableMeta?.columnCount, itemGridRef]);
useImperativeHandle(ref, () => imperativeHandle);
return (
<div
className={styles.itemGridContainer}
data-overlayscrollbars-initialize=""
ref={mergedContainerRef}
>
<List
listRef={itemGridRef}
onRowsRendered={handleOnRowsRendered}
onScroll={handleScroll}
rowComponent={ListComponent}
rowCount={tableMeta?.rowCount || 0}
rowHeight={tableMeta?.itemHeight || 0}
rowProps={itemProps}
/>
<AnimatePresence>
{hasExpanded && (
<ExpandedListContainer>
<ExpandedListItem internalState={internalState} itemType={itemType} />
</ExpandedListContainer>
)}
</AnimatePresence>
</div>
);
};
const ListComponent = ({
columns,
data,
enableExpansion,
enableSelection,
gap,
index,
internalState,
itemType,
style,
}: RowComponentProps<GridItemProps>) => {
return (
<div className={styles.itemList} style={style}>
{data[index].map((d) => (
<div
className={clsx(styles.itemRow, styles[`gap-${gap}`])}
key={d.index}
style={{ '--columns': columns } as CSSProperties}
>
<ItemCard
controls={{
onClick: enableSelection
? (item, itemType) => {
return itemListControls.handleItemClick(
item,
itemType,
internalState,
);
}
: undefined,
onDoubleClick: (item, itemType) => {
return itemListControls.handleItemDoubleClick(
item,
itemType,
internalState,
);
},
onFavorite: (item, itemType) => {
return itemListControls.handleItemFavorite(
item,
itemType,
internalState,
);
},
onItemExpand: enableExpansion
? (item, itemType) => {
return itemListControls.handleItemExpand(
item,
itemType,
internalState,
);
}
: undefined,
onMore: (item, itemType) => {
return itemListControls.handleItemMore(
item,
itemType,
internalState,
);
},
onPlay: (item, itemType, playType) => {
return itemListControls.handleItemPlay(
item,
itemType,
playType,
internalState,
);
},
onRating: (item, itemType) => {
return itemListControls.handleItemRating(
item,
itemType,
internalState,
);
},
}}
data={d.data}
itemType={itemType}
withControls
/>
</div>
))}
</div>
);
};