From fcdd5436161d546b8d6b14146da2f261e6ab053d Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 12 Oct 2025 21:54:51 -0700 Subject: [PATCH] add list scroll persistence --- .../helpers/use-item-list-scroll-persist.ts | 17 ++++ .../item-grid-list/item-grid-list.tsx | 79 ++++++++++++++++++- src/renderer/components/item-list/types.ts | 1 + 3 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts diff --git a/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts b/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts new file mode 100644 index 000000000..a80ef0c99 --- /dev/null +++ b/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts @@ -0,0 +1,17 @@ +import { parseAsInteger, useQueryState } from 'nuqs'; + +interface UseItemListScrollPersistProps { + enabled: boolean; +} + +export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => { + const [scrollOffset, setScrollOffset] = useQueryState('scrollOffset', parseAsInteger); + + const handleOnScrollEnd = (offset: number) => { + if (!enabled) return; + + setScrollOffset(offset); + }; + + return { handleOnScrollEnd, scrollOffset }; +}; 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 1f5a523e7..1e7f5803e 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 @@ -1,6 +1,7 @@ import { useElementSize, useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; -import { throttle } from 'lodash'; +import debounce from 'lodash/debounce'; +import throttle from 'lodash/throttle'; import { AnimatePresence } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { @@ -9,12 +10,13 @@ import { UIEvent, useCallback, useEffect, + useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react'; -import { List, ListImperativeAPI, RowComponentProps, useListRef } from 'react-window-v2'; +import { List, RowComponentProps, useListRef } from 'react-window-v2'; import { ExpandedListContainer } from '../expanded-list-container'; import styles from './item-grid-list.module.css'; @@ -56,7 +58,7 @@ export interface ItemGridListProps { onScroll?: (e: UIEvent) => void; onScrollEnd?: (offset: number, handle: ItemListHandle) => void; onStartReached?: (index: number, handle: ItemListHandle) => void; - ref: Ref; + ref?: Ref; } export const ItemGridList = ({ @@ -64,12 +66,15 @@ export const ItemGridList = ({ enableExpansion = true, enableSelection = true, gap = 'sm', + initialTop, itemsPerRow, itemType, onEndReached, onRangeChanged, onScroll, + onScrollEnd, onStartReached, + ref, }: ItemGridListProps) => { const itemGridRef = useListRef(null); const scrollContainerRef = useRef(null); @@ -110,15 +115,40 @@ export const ItemGridList = ({ } }, [itemGridRef, initialize]); + const isInitialScrollPositionSet = useRef(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) => { onScroll?.(e); + debouncedOnScrollEnd( + e.currentTarget.scrollTop, + itemGridRef.current ?? (undefined as any), + ); }, - [onScroll], + [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 { + 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 (
{ itemsPerPage?: number; query: Omit; + saveScrollOffset?: boolean; serverId: string; }