From 87a4f891a0127520665e13d51b909d004e076cd2 Mon Sep 17 00:00:00 2001 From: PhilReact <philliplangmartinez@gmail.com> Date: Wed, 12 Mar 2025 09:26:43 +0200 Subject: [PATCH] added last item seen in virtualized list --- package-lock.json | 15 ++ package.json | 1 + src/common/VirtualizedList.tsx | 200 +++++++++++++----- .../ResourceList/ResourceListDisplay.tsx | 4 +- 4 files changed, 161 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ea2e3a..51b2e40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@mui/material": "^6.4.7", "@tanstack/react-virtual": "^3.13.2", "react": "^19.0.0", + "react-intersection-observer": "^9.16.0", "zustand": "^4.3.2" }, "devDependencies": { @@ -2121,6 +2122,20 @@ "react": "^19.0.0" } }, + "node_modules/react-intersection-observer": { + "version": "9.16.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", + "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", diff --git a/package.json b/package.json index 72851ae..24d10c2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@mui/material": "^6.4.7", "@tanstack/react-virtual": "^3.13.2", "react": "^19.0.0", + "react-intersection-observer": "^9.16.0", "zustand": "^4.3.2" }, "devDependencies": { diff --git a/src/common/VirtualizedList.tsx b/src/common/VirtualizedList.tsx index 1324683..c66d63b 100644 --- a/src/common/VirtualizedList.tsx +++ b/src/common/VirtualizedList.tsx @@ -1,92 +1,176 @@ -import React, { CSSProperties, useCallback, useRef } from 'react' +import React, { + CSSProperties, + ReactNode, + useCallback, + useEffect, + useRef, +} from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { useInView } from "react-intersection-observer"; +import { QortalMetadata } from "../types/interfaces/resources"; interface PropsVirtualizedList { - list: any[] + list: any[]; children: (item: any, index: number) => React.ReactNode; + onSeenLastItem?: (item: QortalMetadata)=> void; } -export const VirtualizedList = ({list, children}: PropsVirtualizedList) => { - const parentRef = useRef(null); +export const VirtualizedList = ({ list, children, onSeenLastItem }: PropsVirtualizedList) => { + const parentRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: list.length, + getItemKey: useCallback( + (index: number) => + list[index]?.name && list[index]?.name + ? `${list[index].name}-${list[index].identifier}` + : list[index]?.id, + [list] + ), + getScrollElement: () => parentRef.current, + estimateSize: () => 80, // Provide an estimated height of items, adjust this as needed + overscan: 10, // Number of items to render outside the visible area to improve smoothness + }); + + const onSeenLastItemFunc = useCallback((lastItem: QortalMetadata) => { + if(onSeenLastItem){ + onSeenLastItem(lastItem) + } - const rowVirtualizer = useVirtualizer({ - count: list.length, - getItemKey: useCallback((index: number) => (list[index]?.name && list[index]?.name) ?`${list[index].name}-${list[index].identifier}`: list[index]?.id, [list]), - getScrollElement: () => parentRef.current, - estimateSize: () => 80, // Provide an estimated height of items, adjust this as needed - overscan: 10, // Number of items to render outside the visible area to improve smoothness - }); + }, []); return ( - <div - style={{ - display: "flex", - width: "100%", - height: "100%", - }} - > <div style={{ - height: "100%", - position: "relative", display: "flex", - flexDirection: "column", width: "100%", + height: "100%", }} > <div - ref={parentRef} - className="List" style={{ - flexGrow: 1, - overflow: "auto", + height: "100%", position: "relative", display: "flex", - height: "0px", + flexDirection: "column", + width: "100%", }} > <div + ref={parentRef} + className="List" style={{ - height: rowVirtualizer.getTotalSize(), - width: "100%", + flexGrow: 1, + overflow: "auto", + position: "relative", + display: "flex", + height: "0px", }} > <div style={{ - position: "absolute", - top: 0, - left: 0, + height: rowVirtualizer.getTotalSize(), width: "100%", }} > - {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const index = virtualRow.index; - const item = list[index]; - return ( - <div - data-index={virtualRow.index} //needed for dynamic row height measurement - ref={rowVirtualizer.measureElement} //measure dynamic row height - key={`${item.name}-${item.identifier}`} - style={{ - position: "absolute", - top: 0, - left: "50%", // Move to the center horizontally - transform: `translateY(${virtualRow.start}px) translateX(-50%)`, // Adjust for centering - width: "100%", // Control width (90% of the parent) - display: "flex", - alignItems: "center", - overscrollBehavior: "none", - flexDirection: "column" - }} - > - {typeof children === "function" ? children(item, index) : null} - - </div> - ); - })} + <div + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + }} + > + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const index = virtualRow.index; + const item = list[index]; + return ( + <div + data-index={virtualRow.index} //needed for dynamic row height measurement + ref={rowVirtualizer.measureElement} //measure dynamic row height + key={`${item.name}-${item.identifier}`} + style={{ + position: "absolute", + top: 0, + left: "50%", // Move to the center horizontally + transform: `translateY(${virtualRow.start}px) translateX(-50%)`, // Adjust for centering + width: "100%", // Control width (90% of the parent) + display: "flex", + alignItems: "center", + overscrollBehavior: "none", + flexDirection: "column", + }} + > + <MessageWrapper + isLast={index === list?.length - 1} + onSeen={() => onSeenLastItemFunc(item)} + > + {typeof children === "function" + ? children(item, index) + : null} + </MessageWrapper> + </div> + ); + })} + </div> </div> </div> </div> </div> - </div> - ) + ); +}; + +interface MessageWrapperProps { + onSeen: () => void; + isLast: boolean; + children: ReactNode; } + +export const MessageWrapper: React.FC<MessageWrapperProps> = ({ + onSeen, + isLast, + children, +}) => { + if (isLast) { + return ( + <WatchComponent onSeen={onSeen} isLast={isLast}> + {children} + </WatchComponent> + ); + } + return <>{children}</>; +}; + +interface WatchComponentProps { + onSeen: () => void; + isLast: boolean; + children: ReactNode; +} + +const WatchComponent: React.FC<WatchComponentProps> = ({ + onSeen, + isLast, + children, +}) => { + const { ref, inView } = useInView({ + threshold: 0.7, + triggerOnce: true, // Ensure it only triggers once per mount + }); + + const hasBeenTriggered = useRef(false); // Prevent multiple triggers + + useEffect(() => { + if (inView && isLast && onSeen && !hasBeenTriggered.current) { + onSeen(); + hasBeenTriggered.current = true; // Mark as triggered + } + }, [inView, isLast, onSeen]); + + return ( + <div + ref={ref} + style={{ width: "100%", display: "flex", justifyContent: "center" }} + > + {children} + </div> + ); +}; diff --git a/src/components/ResourceList/ResourceListDisplay.tsx b/src/components/ResourceList/ResourceListDisplay.tsx index 9e5a752..9dc9bcd 100644 --- a/src/components/ResourceList/ResourceListDisplay.tsx +++ b/src/components/ResourceList/ResourceListDisplay.tsx @@ -37,6 +37,7 @@ interface PropsResourceListDisplay { defaultLoaderParams?: DefaultLoaderParams; loaderList?: (status: "LOADING" | "NO_RESULTS") => React.ReactNode; // Function type disableVirtualization?: boolean; + onSeenLastItem?: (listItem: QortalMetadata)=> void; } export const ResourceListDisplay = ({ @@ -49,6 +50,7 @@ export const ResourceListDisplay = ({ loaderItem, loaderList, disableVirtualization, + onSeenLastItem }: PropsResourceListDisplay) => { const [list, setList] = useState<QortalMetadata[]>([]); const { fetchResources } = useResources(); @@ -94,7 +96,7 @@ export const ResourceListDisplay = ({ > <div style={{ display: "flex", flexGrow: 1 }}> {!disableVirtualization && ( - <VirtualizedList list={list}> + <VirtualizedList list={list} onSeenLastItem={onSeenLastItem}> {(item: QortalMetadata, index: number) => ( <> {styles?.gap && <Spacer height={`${styles.gap / 2}rem`} />}