From 78f8c17bd1109ed442f3e10550b71380a69bc017 Mon Sep 17 00:00:00 2001 From: SejoB Date: Tue, 12 Nov 2024 17:39:02 +0100 Subject: [PATCH 1/2] in progress --- src/App.tsx | 7 +- src/components/atoms/InfiniteScroll.tsx | 108 ++++++++++++++++++----- src/components/organisms/News.tsx | 64 +++++++++----- src/components/organisms/SideBar.tsx | 23 +++-- src/hooks/useScrollObserver.hooks.ts | 112 ++++++++++++++++++++++++ src/models/NewFlash.ts | 12 +++ src/pages/HomePageRedirect.tsx | 2 +- src/services/init.service.ts | 4 +- src/services/news.data.service.ts | 37 +++++--- src/store/news-flash-store.ts | 53 ++++++----- src/store/root.store.ts | 2 + src/utils/url.utils.ts | 6 ++ 12 files changed, 337 insertions(+), 93 deletions(-) create mode 100644 src/hooks/useScrollObserver.hooks.ts create mode 100644 src/utils/url.utils.ts diff --git a/src/App.tsx b/src/App.tsx index 67979abb..225e698d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { FC, useEffect } from 'react'; -import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import { BrowserRouter as Router, Route, Routes, useParams } from 'react-router-dom'; import HomePage from './pages/HomePage'; import { Footer } from './components/organisms/Footer'; import { Box, createStyles, makeStyles, Theme, ThemeProvider } from '@material-ui/core'; @@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next'; import { useTheme } from '@material-ui/core/styles'; import PopUpRedirect from './components/atoms/PopUpRedirect'; import WidgetsTemplate from './components/organisms/WidgetsTemplate'; -import {observer} from "mobx-react-lite"; +import { observer } from 'mobx-react-lite'; // main components height - must add up to 100 const headerHeight = '5vh'; @@ -31,7 +31,8 @@ const App: FC = () => { const classes = useStyles(); const store = useStore(); const theme = useTheme(); - + const { newsId } = useParams(); + console.log('馃殌 ~ newsId:', newsId); const appDir = i18n.dir(); useEffect(() => { diff --git a/src/components/atoms/InfiniteScroll.tsx b/src/components/atoms/InfiniteScroll.tsx index 290adcf5..4f8f142e 100644 --- a/src/components/atoms/InfiniteScroll.tsx +++ b/src/components/atoms/InfiniteScroll.tsx @@ -1,46 +1,108 @@ -import React, { FC, useEffect, useCallback, useRef } from 'react'; -import { makeStyles } from '@material-ui/core'; -import { useStore } from 'store/storeConfig'; +// InfiniteScroll.tsx +import { FC, UIEventHandler, useCallback, useRef } from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +const TOP_THRESHOLD = 20; // Smaller threshold for top +const BOTTOM_THRESHOLD = 100; // Larger threshold for bottom const INFINITE_SCROLLING_OFFSET: number = 30; function isScrollEnd(element: HTMLDivElement) { return element.scrollTop + element.clientHeight > element.scrollHeight - INFINITE_SCROLLING_OFFSET; } - const useStyles = makeStyles({ root: { overflow: 'auto', position: 'relative', + height: '100%', }, }); -interface IProps { - onScrollEnd: () => any; +export enum Direction { + PREV = 'PREV', + NEXT = 'NEXT', +} + +interface InfiniteScrollProps { + children: React.ReactNode; + onScroll: (direction: Direction) => void; + loading?: boolean; } -const InfiniteScroll: FC = ({ children, onScrollEnd }) => { +const InfiniteScroll: FC = ({ children, onScroll, loading = false }) => { const classes = useStyles(); - const scrollList = useRef(null); - const store = useStore(); - const { newsFlashStore } = store; - - const handleScroll = useCallback(() => { - if (scrollList.current !== null && !newsFlashStore.newsFlashLoading) { - if (isScrollEnd(scrollList.current)) { - onScrollEnd(); + const containerRef = useRef(null); + const lastScrollTop = useRef(0); + const hasScrolledDown = useRef(false); + const initialLoading = useRef(true); + + // const handleScroll = useCallback( + // (e) => { + // if (initialLoading.current) { + // initialLoading.current = false; + // return; + // } + // if (!e || loading) return; + + // const { scrollTop, scrollHeight, clientHeight } = e.target; + + // // Track scroll direction + // const direction: Direction = scrollTop > lastScrollTop.current ? Direction.NEXT : Direction.PREV; + // lastScrollTop.current = scrollTop; + + // // Only set hasScrolledDown when actually scrolling down + // if (direction === Direction.NEXT && scrollTop > TOP_THRESHOLD) { + // hasScrolledDown.current = true; + // } + + // // Near bottom - load more (larger threshold) + // if (scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD) { + // onScroll(Direction.NEXT); + // } + + // // Near top - load previous (smaller threshold) + // if (scrollTop <= TOP_THRESHOLD && hasScrolledDown.current) { + // onScroll(Direction.PREV); + // } + // }, + // [loading, onScroll], + // ); + const BUFFER_OFFSET = 500; + const handleScroll = useCallback( + (e) => { + if (initialLoading.current) { + initialLoading.current = false; + return; } - } - }, [scrollList, onScrollEnd, newsFlashStore.newsFlashLoading]); + if (!e || loading) return; - useEffect(() => { - if (scrollList.current) { - scrollList.current.addEventListener('scroll', handleScroll); - } - }, [handleScroll]); + const { scrollTop, scrollHeight, clientHeight } = e.target; + const scrollOffset = scrollTop; + const totalSize = scrollHeight; + + // Track direction + const direction: Direction = scrollTop > lastScrollTop.current ? Direction.NEXT : Direction.PREV; + lastScrollTop.current = scrollTop; + + // Enable prev loading after scroll down + if (direction === Direction.NEXT) { + hasScrolledDown.current = true; + } + + // Load more at bottom + if (scrollOffset > totalSize - clientHeight - BUFFER_OFFSET) { + onScroll(Direction.NEXT); + } + + // Load previous at top + if (scrollOffset < BUFFER_OFFSET && hasScrolledDown.current) { + onScroll(Direction.PREV); + } + }, + [loading, onScroll], + ); return ( -
+
{children}
); diff --git a/src/components/organisms/News.tsx b/src/components/organisms/News.tsx index 2774e2b1..ca23fae9 100644 --- a/src/components/organisms/News.tsx +++ b/src/components/organisms/News.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import { FC, useEffect, useRef } from 'react'; import { Typography } from 'components/atoms'; import { Box, makeStyles } from '@material-ui/core'; import { useStore } from 'store/storeConfig'; @@ -7,41 +7,63 @@ import RootStore from 'store/root.store'; import { observer } from 'mobx-react-lite'; import LocationSearchIndicator from 'components/molecules/LocationSearchIndicator'; import { IRouteProps } from 'models/Route'; -import NewsFlashComp from "components/molecules/NewsFlashComp"; - +import NewsFlashComp from 'components/molecules/NewsFlashComp'; const useStyles = makeStyles({ container: {}, newsFeed: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, overflow: 'auto', }, }); - +; + const News: FC = () => { const store: RootStore = useStore(); const classes = useStyles(); - const { gpsId, street, city } = useParams(); + const { gpsId, street, city, newsId = '' } = useParams(); const { newsFlashStore } = store; + const selectedItemRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (newsId && newsFlashStore.newsFlashCollection.data.length > 0 && !newsFlashStore.newsFlashLoading) { + // Find selected item index + const itemIndex = newsFlashStore.newsFlashCollection.data.findIndex((item) => item.id.toString() === newsId); + + if (itemIndex !== -1) { + setTimeout(() => { + selectedItemRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }, 100); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( - - - - {gpsId && } - {street && city && } - {newsFlashStore.newsFlashCollection.length > 0 ? ( - newsFlashStore.newsFlashCollection.map((news) => - - ) - ) : ( - - 诇讗 谞诪爪讗讜 转讜爪讗讜转 诪讛诪拽讜专 讛诪讘讜拽砖 - - )} +
+ {gpsId && } + {street && city && } + {newsFlashStore.newsFlashCollection.data.length > 0 ? ( + newsFlashStore.newsFlashCollection.data.map((news, index) => { + return ( +
+ +
+ ); + }) + ) : ( + + 诇讗 谞诪爪讗讜 转讜爪讗讜转 诪讛诪拽讜专 讛诪讘讜拽砖 - - + )} +
); }; diff --git a/src/components/organisms/SideBar.tsx b/src/components/organisms/SideBar.tsx index 0cf70696..a3509cdf 100644 --- a/src/components/organisms/SideBar.tsx +++ b/src/components/organisms/SideBar.tsx @@ -11,8 +11,7 @@ import RootStore from 'store/root.store'; import { InfiniteScroll } from 'components/atoms'; import SideBarMap from 'components/molecules/SideBarMap'; import { useTranslation } from 'react-i18next'; - -const INFINITE_SCROLL_FETCH_SIZE = 100; +import { Direction } from 'hooks/useScrollObserver.hooks'; interface IProps {} @@ -37,10 +36,20 @@ const SideBar: FC = () => { const mapTitle = `${t('sideBar')}`; const location = newsFlashStore.activeNewsFlashLocation; const loading = newsFlashStore.newsFlashLoading; + const initialPageNumber = newsFlashStore.newsFlashInitialPageNumber; + const currentPageNumber = newsFlashStore.newsFlashPageNumber; + const totalPages = newsFlashStore.newsFlashCollection.pagination.totalPages; - const fetchMoreNewsItems = useCallback(() => { - newsFlashStore.infiniteFetchLimit(INFINITE_SCROLL_FETCH_SIZE); - }, [newsFlashStore]); + const fetchMoreNewsItems = useCallback( + (direction: Direction) => { + if (direction === Direction.PREV && initialPageNumber !== 1 && currentPageNumber > 1) { + newsFlashStore.filterNewsFlashCollection(direction); + } else if (totalPages > currentPageNumber) { + newsFlashStore.filterNewsFlashCollection(Direction.NEXT); + } + }, + [currentPageNumber, initialPageNumber, newsFlashStore, totalPages], + ); return ( @@ -51,7 +60,7 @@ const SideBar: FC = () => { - +
@@ -62,7 +71,7 @@ const SideBar: FC = () => { {location && ( - + )}
diff --git a/src/hooks/useScrollObserver.hooks.ts b/src/hooks/useScrollObserver.hooks.ts new file mode 100644 index 00000000..464cf519 --- /dev/null +++ b/src/hooks/useScrollObserver.hooks.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export enum Direction { + PREV = 'PREV', + NEXT = 'NEXT', +} + +interface IProps { + loading: boolean; + currentPage: number; + totalPages: number; + onFetch: (direction: Direction) => void; +} + +export const useScrollObserver = ({ onFetch, loading, currentPage, totalPages }: IProps) => { + const topObserver = useRef(); + const bottomObserver = useRef(); + const containerRef = useRef(null); + const prevHeightRef = useRef(0); + const hasScrolledDown = useRef(false); + + // Track scroll direction + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let lastScrollTop = 0; + const handleScroll = () => { + const scrollTop = container.scrollTop; + hasScrolledDown.current = scrollTop > lastScrollTop; + lastScrollTop = scrollTop; + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, []); + + const handlePrevFetch = useCallback(() => { + // Only fetch prev if we've scrolled down before + if (!hasScrolledDown.current) return; + + if (containerRef.current) { + prevHeightRef.current = containerRef.current.scrollHeight; + } + + onFetch(Direction.PREV); + + setTimeout(() => { + if (containerRef.current) { + const newHeight = containerRef.current.scrollHeight; + const heightDiff = newHeight - prevHeightRef.current; + containerRef.current.scrollTop = heightDiff; + } + }, 100); + }, [onFetch]); + + const firstElementRef = useCallback( + (node: HTMLDivElement) => { + //Return if already fetching + if (loading || !node) return; + + // Disconnect if already observer exists + if (topObserver.current) topObserver.current.disconnect(); + + //Create new observer for the last element, and call fetchNextPage if visible(isIntersecting) + topObserver.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && currentPage < totalPages) { + handlePrevFetch(); + } + }, + { + root: null, + rootMargin: '100px', + threshold: 0.1, + }, + ); + + if (node) { + topObserver.current.observe(node); + } + }, + [currentPage, loading, handlePrevFetch, totalPages], + ); + + const lastElementRef = useCallback( + (node: HTMLDivElement) => { + if (loading || !node) return; + if (bottomObserver.current) bottomObserver.current.disconnect(); + + bottomObserver.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && currentPage > 1) { + onFetch(Direction.NEXT); + } + }, + { + root: null, + rootMargin: '100px', + threshold: 0.1, + }, + ); + + if (node) { + bottomObserver.current.observe(node); + } + }, + [currentPage, loading, onFetch], + ); + + return { lastElementRef, firstElementRef, containerRef }; +}; diff --git a/src/models/NewFlash.ts b/src/models/NewFlash.ts index cced7cc2..c90dbb30 100644 --- a/src/models/NewFlash.ts +++ b/src/models/NewFlash.ts @@ -17,3 +17,15 @@ export interface INewsFlash { source: string; critical?: boolean; } + +export interface IPagination { + pageNumber: number; + pageSize: number; + totalRecords: number; + totalPages: number; +} + +export interface INewsFlashCollection { + data: INewsFlash[]; + pagination: IPagination; +} diff --git a/src/pages/HomePageRedirect.tsx b/src/pages/HomePageRedirect.tsx index 188fc738..82cc127d 100644 --- a/src/pages/HomePageRedirect.tsx +++ b/src/pages/HomePageRedirect.tsx @@ -10,7 +10,7 @@ interface IProps {} const HomePageRedirect: FC = () => { const store: RootStore = useStore(); const { newsFlashStore } = store; - const defaultId = newsFlashStore?.newsFlashCollection[0]?.id; + const defaultId = newsFlashStore?.newsFlashCollection.data[0]?.id; return defaultId ? : null; }; diff --git a/src/services/init.service.ts b/src/services/init.service.ts index eca51d48..514a3b33 100644 --- a/src/services/init.service.ts +++ b/src/services/init.service.ts @@ -2,12 +2,14 @@ import axios from 'axios'; import { fetchNews } from './news.data.service'; import L from 'leaflet'; import { serverUrl } from 'const/generalConst'; +import { getNewsIdFromUrl } from 'utils/url.utils'; export function initService(): Promise { setAxiosDefaults(); setLocationMapDefaults(); - const promiseArray = [fetchNews()]; + const queryParams = getNewsIdFromUrl(); + const promiseArray = [fetchNews(queryParams)]; return Promise.all(promiseArray).then((promiseCollection) => ({ newsFlashCollection: promiseCollection[0], diff --git a/src/services/news.data.service.ts b/src/services/news.data.service.ts index 317bcbab..401de65c 100644 --- a/src/services/news.data.service.ts +++ b/src/services/news.data.service.ts @@ -22,35 +22,44 @@ const errorNews: INewsFlash = { const NEWS_FLASH_API: string = '/api/news-flash'; export interface IFetchNewsQueryParams { source?: string; - offSet?: number; - limit?: number; critical?: boolean | null; -}; + newsFlashId?: number; + pageNumber?: number; + pageSize?: number; +} -export function fetchNews({source = '', offSet = 0, limit = 100, critical = null}: IFetchNewsQueryParams = {}): Promise { +export function fetchNews({ + source = '', + pageNumber, + pageSize = 100, + critical = null, + newsFlashId, +}: IFetchNewsQueryParams): Promise { const query = []; if (source) { query.push(`source=${source}`); } - if (limit) { - query.push(`limit=${limit}`); + if (pageSize) { + query.push(`pageSize=${pageSize}`); } if (critical !== null) { query.push(`critical=${critical}`); } - query.push(`offset=${offSet}`); - + if (newsFlashId) { + query.push(`newsFlashId=${newsFlashId}`); + } + if (pageNumber) { + query.push(`pageNumber=${pageNumber}`); + } query.push('resolution=suburban_road'); query.push('resolution=street'); const url = `${NEWS_FLASH_API}?${query.join('&')}`; - return ( - axios - .get(url) - .then((res) => res.data) - .catch(onErrorFetchNewsFlash) - ); + return axios + .get(url) + .then((res) => res.data) + .catch(onErrorFetchNewsFlash); } function onErrorFetchNewsFlash() { diff --git a/src/store/news-flash-store.ts b/src/store/news-flash-store.ts index 86e0d62e..e6a0e2c3 100644 --- a/src/store/news-flash-store.ts +++ b/src/store/news-flash-store.ts @@ -1,9 +1,10 @@ import { runInAction, makeAutoObservable } from 'mobx'; import { SourceFilterEnum } from 'models/SourceFilter'; import { fetchNews, IFetchNewsQueryParams } from 'services/news.data.service'; -import { INewsFlash } from 'models/NewFlash'; +import { INewsFlash, INewsFlashCollection } from 'models/NewFlash'; import { IPoint } from 'models/Point'; import RootStore from './root.store'; +import { Direction } from 'hooks/useScrollObserver.hooks'; const DEFAULT_TIME_FILTER = 5; const DEFAULT_LOCATION = { latitude: 32.0853, longitude: 34.7818 }; @@ -11,9 +12,13 @@ const LOCAL_FILTERS: { [key in SourceFilterEnum]?: (newsFlashCollection: Array = []; + newsFlashCollection: INewsFlashCollection = { + data: [] as INewsFlash[], + pagination: { pageNumber: 1, pageSize: 100, totalRecords: 0, totalPages: 0 }, + }; activeNewsFlashId: number = 0; // active newsflash id - newsFlashFetchOffSet = 0; + newsFlashPageNumber = 1; + newsFlashInitialPageNumber = 1; newsFlashActiveFilter: SourceFilterEnum = SourceFilterEnum.all; newsFlashLoading: boolean = false; newsFlashWidgetsTimerFilter = DEFAULT_TIME_FILTER; // newsflash time filter (in years ago, 5 is the default) @@ -24,7 +29,7 @@ export default class NewsFlashStore { } get activeNewsFlash(): INewsFlash | undefined { - return this.newsFlashCollection.find((item) => item.id === this.activeNewsFlashId); + return this.newsFlashCollection?.data.find((item) => item.id === this.activeNewsFlashId); } selectNewsFlash(id: number): void { @@ -89,49 +94,51 @@ export default class NewsFlashStore { if (filter !== this.newsFlashActiveFilter) { runInAction(() => { this.newsFlashActiveFilter = filter; - this.newsFlashFetchOffSet = 0; + this.newsFlashPageNumber = 0; }); if (!(filter in LOCAL_FILTERS)) { runInAction(() => { - this.newsFlashCollection = []; + this.newsFlashCollection.data = []; }); } this.filterNewsFlashCollection(); } } - filterNewsFlashCollection(): void { + async filterNewsFlashCollection(direction: Direction = Direction.NEXT) { runInAction(() => (this.newsFlashLoading = true)); if (this.newsFlashActiveFilter in LOCAL_FILTERS) { const filterMethod = LOCAL_FILTERS[this.newsFlashActiveFilter]; - const filtered = filterMethod && filterMethod(this.newsFlashCollection); - runInAction(() => (this.newsFlashCollection = [...(filtered || [])])); + const filtered = filterMethod && filterMethod(this.newsFlashCollection.data); + runInAction(() => (this.newsFlashCollection.data = [...(filtered || [])])); runInAction(() => (this.newsFlashLoading = false)); } else { + const prevPage = this.newsFlashInitialPageNumber - 1; const queryParams: IFetchNewsQueryParams = { - offSet: this.newsFlashFetchOffSet + pageNumber: direction === Direction.NEXT ? this.newsFlashPageNumber + 1 : prevPage, }; - if (this.newsFlashActiveFilter === "critical") { - queryParams["critical"] = true; + if (this.newsFlashActiveFilter === 'critical') { + queryParams['critical'] = true; } else { - queryParams["source"] = this.newsFlashActiveFilter; + queryParams['source'] = this.newsFlashActiveFilter; } - fetchNews(queryParams).then((data: any) => { + + fetchNews(queryParams).then((res: any) => { runInAction(() => (this.newsFlashLoading = false)); - if (data) { - runInAction(() => (this.newsFlashCollection = [...this.newsFlashCollection, ...data])); + if (res) { + runInAction(() => { + this.newsFlashCollection = { + data: res.data, + pagination: res.pagination, + }; + runInAction(() => (this.newsFlashPageNumber = res.pagination.pageNumber)); + }); } else { - console.error(`filterNewsFlashCollection(filter:${this.newsFlashActiveFilter}) invalid data:`, data); + console.error(`filterNewsFlashCollection(filter:${this.newsFlashActiveFilter}) invalid data:`, res); } }); } } - infiniteFetchLimit(fetchSize: number): void { - runInAction(() => (this.newsFlashFetchOffSet += fetchSize)); - if (this.newsFlashCollection.length >= this.newsFlashFetchOffSet - fetchSize) { - this.filterNewsFlashCollection(); - } - } get activeNewsFlashLocation() { let location: IPoint = DEFAULT_LOCATION; // default location diff --git a/src/store/root.store.ts b/src/store/root.store.ts index 02f71d9c..004e5239 100644 --- a/src/store/root.store.ts +++ b/src/store/root.store.ts @@ -42,6 +42,8 @@ export default class RootStore { runInAction(() => { if (initData.newsFlashCollection) { this.newsFlashStore.newsFlashCollection = initData.newsFlashCollection; + this.newsFlashStore.newsFlashInitialPageNumber = initData.newsFlashCollection.pagination.pageNumber; + this.newsFlashStore.newsFlashPageNumber = initData.newsFlashCollection.pagination.pageNumber; } if (initData.newsFlashWidgetsData) { this.widgetsStore.newsFlashWidgetsData = initData.newsFlashWidgetsData.widgets; diff --git a/src/utils/url.utils.ts b/src/utils/url.utils.ts new file mode 100644 index 00000000..974efc01 --- /dev/null +++ b/src/utils/url.utils.ts @@ -0,0 +1,6 @@ +export const getNewsIdFromUrl = () => { + const pathname = window.location.pathname; + const [, , newsId] = pathname.split('/'); + + return newsId ? { newsFlashId: +newsId } : { pageNumber: 1 }; +}; From c12247fe757e54b6509ade5819546a109decfcbc Mon Sep 17 00:00:00 2001 From: SejoB Date: Tue, 26 Nov 2024 22:32:31 +0100 Subject: [PATCH 2/2] add scroll observer --- package-lock.json | 15 ++ package.json | 7 +- src/App.tsx | 4 +- src/components/atoms/InfiniteScroll.tsx | 111 ------------- src/components/atoms/index.ts | 1 - src/components/molecules/NewsFlashComp.tsx | 2 +- src/components/organisms/News.tsx | 44 +++--- src/components/organisms/SideBar.tsx | 20 +-- src/hooks/useScrollObserver.hooks.ts | 171 +++++++++------------ src/models/ScrollObserver.model.ts | 4 + src/store/news-flash-store.ts | 33 ++-- src/store/root.store.ts | 3 +- src/utils/element.util.ts | 12 ++ 13 files changed, 169 insertions(+), 258 deletions(-) delete mode 100644 src/components/atoms/InfiniteScroll.tsx create mode 100644 src/models/ScrollObserver.model.ts create mode 100644 src/utils/element.util.ts diff --git a/package-lock.json b/package-lock.json index 3d4a3e17..09571d75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "react-async-loader": "^0.1.2", "react-dom": "^17.0.2", "react-i18next": "^14.1.1", + "react-intersection-observer": "^9.13.1", "react-leaflet": "3.2.5", "react-leaflet-google-layer": "^2.2.0", "react-router-dom": "^6.23.0", @@ -31379,6 +31380,20 @@ } } }, + "node_modules/react-intersection-observer": { + "version": "9.13.1", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.13.1.tgz", + "integrity": "sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==", + "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" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index e3f95080..0263c078 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-async-loader": "^0.1.2", "react-dom": "^17.0.2", "react-i18next": "^14.1.1", + "react-intersection-observer": "^9.13.1", "react-leaflet": "3.2.5", "react-leaflet-google-layer": "^2.2.0", "react-router-dom": "^6.23.0", @@ -133,7 +134,7 @@ "prettier --write" ] }, -"overrides": { - "react-refresh": "0.11.0" -} + "overrides": { + "react-refresh": "0.11.0" + } } diff --git a/src/App.tsx b/src/App.tsx index 225e698d..832a84f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { FC, useEffect } from 'react'; -import { BrowserRouter as Router, Route, Routes, useParams } from 'react-router-dom'; +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import HomePage from './pages/HomePage'; import { Footer } from './components/organisms/Footer'; import { Box, createStyles, makeStyles, Theme, ThemeProvider } from '@material-ui/core'; @@ -31,8 +31,6 @@ const App: FC = () => { const classes = useStyles(); const store = useStore(); const theme = useTheme(); - const { newsId } = useParams(); - console.log('馃殌 ~ newsId:', newsId); const appDir = i18n.dir(); useEffect(() => { diff --git a/src/components/atoms/InfiniteScroll.tsx b/src/components/atoms/InfiniteScroll.tsx deleted file mode 100644 index 4f8f142e..00000000 --- a/src/components/atoms/InfiniteScroll.tsx +++ /dev/null @@ -1,111 +0,0 @@ -// InfiniteScroll.tsx -import { FC, UIEventHandler, useCallback, useRef } from 'react'; -import { makeStyles } from '@material-ui/core/styles'; - -const TOP_THRESHOLD = 20; // Smaller threshold for top -const BOTTOM_THRESHOLD = 100; // Larger threshold for bottom -const INFINITE_SCROLLING_OFFSET: number = 30; - -function isScrollEnd(element: HTMLDivElement) { - return element.scrollTop + element.clientHeight > element.scrollHeight - INFINITE_SCROLLING_OFFSET; -} -const useStyles = makeStyles({ - root: { - overflow: 'auto', - position: 'relative', - height: '100%', - }, -}); - -export enum Direction { - PREV = 'PREV', - NEXT = 'NEXT', -} - -interface InfiniteScrollProps { - children: React.ReactNode; - onScroll: (direction: Direction) => void; - loading?: boolean; -} - -const InfiniteScroll: FC = ({ children, onScroll, loading = false }) => { - const classes = useStyles(); - const containerRef = useRef(null); - const lastScrollTop = useRef(0); - const hasScrolledDown = useRef(false); - const initialLoading = useRef(true); - - // const handleScroll = useCallback( - // (e) => { - // if (initialLoading.current) { - // initialLoading.current = false; - // return; - // } - // if (!e || loading) return; - - // const { scrollTop, scrollHeight, clientHeight } = e.target; - - // // Track scroll direction - // const direction: Direction = scrollTop > lastScrollTop.current ? Direction.NEXT : Direction.PREV; - // lastScrollTop.current = scrollTop; - - // // Only set hasScrolledDown when actually scrolling down - // if (direction === Direction.NEXT && scrollTop > TOP_THRESHOLD) { - // hasScrolledDown.current = true; - // } - - // // Near bottom - load more (larger threshold) - // if (scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD) { - // onScroll(Direction.NEXT); - // } - - // // Near top - load previous (smaller threshold) - // if (scrollTop <= TOP_THRESHOLD && hasScrolledDown.current) { - // onScroll(Direction.PREV); - // } - // }, - // [loading, onScroll], - // ); - const BUFFER_OFFSET = 500; - const handleScroll = useCallback( - (e) => { - if (initialLoading.current) { - initialLoading.current = false; - return; - } - if (!e || loading) return; - - const { scrollTop, scrollHeight, clientHeight } = e.target; - const scrollOffset = scrollTop; - const totalSize = scrollHeight; - - // Track direction - const direction: Direction = scrollTop > lastScrollTop.current ? Direction.NEXT : Direction.PREV; - lastScrollTop.current = scrollTop; - - // Enable prev loading after scroll down - if (direction === Direction.NEXT) { - hasScrolledDown.current = true; - } - - // Load more at bottom - if (scrollOffset > totalSize - clientHeight - BUFFER_OFFSET) { - onScroll(Direction.NEXT); - } - - // Load previous at top - if (scrollOffset < BUFFER_OFFSET && hasScrolledDown.current) { - onScroll(Direction.PREV); - } - }, - [loading, onScroll], - ); - - return ( -
- {children} -
- ); -}; - -export default InfiniteScroll; diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts index 5b14a8e4..266238e9 100644 --- a/src/components/atoms/index.ts +++ b/src/components/atoms/index.ts @@ -7,7 +7,6 @@ export { default as Textbox } from './Textbox'; export { default as ErrorBoundary } from './ErrorBoundary'; export { default as MetaTag } from './MetaTag'; export { default as Loader } from './Loader'; -export { default as InfiniteScroll } from './InfiniteScroll'; export { default as Dialog } from './Dialog'; export { default as AppBar } from './AppBar'; export { default as Logo } from './Logo'; diff --git a/src/components/molecules/NewsFlashComp.tsx b/src/components/molecules/NewsFlashComp.tsx index 5b766834..4f1526af 100644 --- a/src/components/molecules/NewsFlashComp.tsx +++ b/src/components/molecules/NewsFlashComp.tsx @@ -57,7 +57,7 @@ const NewsFlashComp: FC = ({ news }) => { const verificationIcon = getVerificationIcon(news.newsflash_location_qualification); const criticalIcon = news.critical && ; const {newsId} = useParams() - const newsID = newsId ? parseInt(newsId) : '' + const newsID = newsId ? parseInt(newsId) : ''; const className = news.id === newsID ? classes.activeNewsFlash : ''; const date = news.date == null ? '' : dateFormat(new Date(news.date.replace(/-/g, '/')), locale); const handleLocationEditorOpen = () => setOpen(true); diff --git a/src/components/organisms/News.tsx b/src/components/organisms/News.tsx index ca23fae9..b5a05456 100644 --- a/src/components/organisms/News.tsx +++ b/src/components/organisms/News.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useRef } from 'react'; +import { FC, useRef } from 'react'; import { Typography } from 'components/atoms'; import { Box, makeStyles } from '@material-ui/core'; import { useStore } from 'store/storeConfig'; @@ -8,6 +8,9 @@ import { observer } from 'mobx-react-lite'; import LocationSearchIndicator from 'components/molecules/LocationSearchIndicator'; import { IRouteProps } from 'models/Route'; import NewsFlashComp from 'components/molecules/NewsFlashComp'; +import { useScrollObserver } from 'hooks/useScrollObserver.hooks'; +import { Direction } from 'models/ScrollObserver.model'; +import { combineRefs } from 'utils/element.util'; const useStyles = makeStyles({ container: {}, @@ -19,32 +22,24 @@ const useStyles = makeStyles({ }, }); -; +interface InfiniteScrollProps { + onScroll: (direction: Direction) => void; +} -const News: FC = () => { +const News: FC = ({ onScroll }) => { const store: RootStore = useStore(); const classes = useStyles(); const { gpsId, street, city, newsId = '' } = useParams(); const { newsFlashStore } = store; - const selectedItemRef = useRef(null); const containerRef = useRef(null); - useEffect(() => { - if (newsId && newsFlashStore.newsFlashCollection.data.length > 0 && !newsFlashStore.newsFlashLoading) { - // Find selected item index - const itemIndex = newsFlashStore.newsFlashCollection.data.findIndex((item) => item.id.toString() === newsId); - - if (itemIndex !== -1) { - setTimeout(() => { - selectedItemRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - }, 100); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { firstItemRef, lastItemRef, selectedItemRef } = useScrollObserver({ + newsId, + onScroll, + containerRef, + newsData: newsFlashStore.newsFlashCollection.data, + newsLoading: newsFlashStore.newsFlashLoading, + }); return (
@@ -52,8 +47,15 @@ const News: FC = () => { {street && city && } {newsFlashStore.newsFlashCollection.data.length > 0 ? ( newsFlashStore.newsFlashCollection.data.map((news, index) => { + const isFirst = index === 0; + const isLast = index === newsFlashStore.newsFlashCollection.data.length - 1; + const selectedItem = news.id === +newsId ? selectedItemRef : undefined; + return ( -
+
); diff --git a/src/components/organisms/SideBar.tsx b/src/components/organisms/SideBar.tsx index a3509cdf..8a71b83b 100644 --- a/src/components/organisms/SideBar.tsx +++ b/src/components/organisms/SideBar.tsx @@ -8,10 +8,9 @@ import { Typography, ErrorBoundary } from 'components/atoms'; import { observer } from 'mobx-react-lite'; import { useStore } from 'store/storeConfig'; import RootStore from 'store/root.store'; -import { InfiniteScroll } from 'components/atoms'; import SideBarMap from 'components/molecules/SideBarMap'; import { useTranslation } from 'react-i18next'; -import { Direction } from 'hooks/useScrollObserver.hooks'; +import { Direction } from 'models/ScrollObserver.model'; interface IProps {} @@ -36,19 +35,22 @@ const SideBar: FC = () => { const mapTitle = `${t('sideBar')}`; const location = newsFlashStore.activeNewsFlashLocation; const loading = newsFlashStore.newsFlashLoading; - const initialPageNumber = newsFlashStore.newsFlashInitialPageNumber; const currentPageNumber = newsFlashStore.newsFlashPageNumber; + const lastPrevPage = newsFlashStore.newsFlashLastPrevPage; const totalPages = newsFlashStore.newsFlashCollection.pagination.totalPages; const fetchMoreNewsItems = useCallback( (direction: Direction) => { - if (direction === Direction.PREV && initialPageNumber !== 1 && currentPageNumber > 1) { + if (loading) return; + if (direction === Direction.PREV && currentPageNumber > 1 && lastPrevPage > 1) { + newsFlashStore.filterNewsFlashCollection(direction); + return; + } + if (direction === Direction.NEXT && totalPages > currentPageNumber) { newsFlashStore.filterNewsFlashCollection(direction); - } else if (totalPages > currentPageNumber) { - newsFlashStore.filterNewsFlashCollection(Direction.NEXT); } }, - [currentPageNumber, initialPageNumber, newsFlashStore, totalPages], + [currentPageNumber, lastPrevPage, loading, newsFlashStore, totalPages], ); return ( @@ -60,9 +62,7 @@ const SideBar: FC = () => { - - - + diff --git a/src/hooks/useScrollObserver.hooks.ts b/src/hooks/useScrollObserver.hooks.ts index 464cf519..479c35ef 100644 --- a/src/hooks/useScrollObserver.hooks.ts +++ b/src/hooks/useScrollObserver.hooks.ts @@ -1,112 +1,87 @@ -import { useCallback, useEffect, useRef } from 'react'; - -export enum Direction { - PREV = 'PREV', - NEXT = 'NEXT', -} +import { Direction } from 'models/ScrollObserver.model'; +import { useEffect, useRef } from 'react'; +import { useInView } from 'react-intersection-observer'; interface IProps { - loading: boolean; - currentPage: number; - totalPages: number; - onFetch: (direction: Direction) => void; + newsId: string; + containerRef: React.RefObject; + newsData: any[]; + newsLoading: boolean; + onScroll: (direction: Direction) => void; } -export const useScrollObserver = ({ onFetch, loading, currentPage, totalPages }: IProps) => { - const topObserver = useRef(); - const bottomObserver = useRef(); - const containerRef = useRef(null); - const prevHeightRef = useRef(0); - const hasScrolledDown = useRef(false); +export const useScrollObserver = ({ newsId, onScroll, containerRef, newsData, newsLoading }: IProps) => { + const selectedItemRef = useRef(null); + const isFirstRender = useRef(true); + const isInViewFirstRender = useRef(true); - // Track scroll direction useEffect(() => { - const container = containerRef.current; - if (!container) return; - - let lastScrollTop = 0; - const handleScroll = () => { - const scrollTop = container.scrollTop; - hasScrolledDown.current = scrollTop > lastScrollTop; - lastScrollTop = scrollTop; - }; - - container.addEventListener('scroll', handleScroll); - return () => container.removeEventListener('scroll', handleScroll); - }, []); - - const handlePrevFetch = useCallback(() => { - // Only fetch prev if we've scrolled down before - if (!hasScrolledDown.current) return; - - if (containerRef.current) { - prevHeightRef.current = containerRef.current.scrollHeight; + if (isFirstRender.current && newsId && newsData.length > 0 && !newsLoading) { + const itemIndex = newsData.findIndex((item) => item.id.toString() === newsId); + + if (itemIndex !== -1) { + requestAnimationFrame(() => { + selectedItemRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }); + } + isFirstRender.current = false; } - - onFetch(Direction.PREV); - - setTimeout(() => { - if (containerRef.current) { - const newHeight = containerRef.current.scrollHeight; - const heightDiff = newHeight - prevHeightRef.current; - containerRef.current.scrollTop = heightDiff; + }, [newsId, newsData, newsLoading]); + + const [firstItemRef] = useInView({ + threshold: 0.1, + delay: 100, + onChange: (inView) => { + if (isInViewFirstRender.current) { + isInViewFirstRender.current = false; + return; } - }, 100); - }, [onFetch]); - - const firstElementRef = useCallback( - (node: HTMLDivElement) => { - //Return if already fetching - if (loading || !node) return; - - // Disconnect if already observer exists - if (topObserver.current) topObserver.current.disconnect(); - - //Create new observer for the last element, and call fetchNextPage if visible(isIntersecting) - topObserver.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && currentPage < totalPages) { - handlePrevFetch(); - } - }, - { - root: null, - rootMargin: '100px', - threshold: 0.1, - }, - ); - - if (node) { - topObserver.current.observe(node); + if (inView && !newsLoading) { + const container = containerRef.current; + if (!container) return; + + const oldScrollHeight = container.scrollHeight; + const oldScrollTop = container.scrollTop; + + // Create mutation observer before fetching + const observer = new MutationObserver(() => { + const newScrollHeight = container.scrollHeight; + const heightDiff = newScrollHeight - oldScrollHeight; + + // Adjust scroll position to prevent jump + container.scrollTop = oldScrollTop + heightDiff; + observer.disconnect(); + }); + // Start observing before fetch + observer.observe(container, { childList: true, subtree: true }); + + onScroll(Direction.PREV); + isInViewFirstRender.current = true; } }, - [currentPage, loading, handlePrevFetch, totalPages], - ); - - const lastElementRef = useCallback( - (node: HTMLDivElement) => { - if (loading || !node) return; - if (bottomObserver.current) bottomObserver.current.disconnect(); - - bottomObserver.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && currentPage > 1) { - onFetch(Direction.NEXT); - } - }, - { - root: null, - rootMargin: '100px', - threshold: 0.1, - }, - ); - - if (node) { - bottomObserver.current.observe(node); + }); + + const [lastItemRef] = useInView({ + threshold: 0.1, + delay: 100, + onChange: (inView) => { + if (isInViewFirstRender.current) { + isInViewFirstRender.current = false; + return; + } + if (inView && !newsLoading) { + onScroll(Direction.NEXT); + isInViewFirstRender.current = true; } }, - [currentPage, loading, onFetch], - ); + }); - return { lastElementRef, firstElementRef, containerRef }; + return { + firstItemRef, + lastItemRef, + selectedItemRef, + }; }; diff --git a/src/models/ScrollObserver.model.ts b/src/models/ScrollObserver.model.ts new file mode 100644 index 00000000..e3dd4cf4 --- /dev/null +++ b/src/models/ScrollObserver.model.ts @@ -0,0 +1,4 @@ +export enum Direction { + PREV = 'PREV', + NEXT = 'NEXT', +} diff --git a/src/store/news-flash-store.ts b/src/store/news-flash-store.ts index e6a0e2c3..7a547593 100644 --- a/src/store/news-flash-store.ts +++ b/src/store/news-flash-store.ts @@ -4,7 +4,7 @@ import { fetchNews, IFetchNewsQueryParams } from 'services/news.data.service'; import { INewsFlash, INewsFlashCollection } from 'models/NewFlash'; import { IPoint } from 'models/Point'; import RootStore from './root.store'; -import { Direction } from 'hooks/useScrollObserver.hooks'; +import { Direction } from 'models/ScrollObserver.model'; const DEFAULT_TIME_FILTER = 5; const DEFAULT_LOCATION = { latitude: 32.0853, longitude: 34.7818 }; @@ -18,7 +18,8 @@ export default class NewsFlashStore { }; activeNewsFlashId: number = 0; // active newsflash id newsFlashPageNumber = 1; - newsFlashInitialPageNumber = 1; + newsFlashLastNextPage = 1; + newsFlashLastPrevPage = 1; newsFlashActiveFilter: SourceFilterEnum = SourceFilterEnum.all; newsFlashLoading: boolean = false; newsFlashWidgetsTimerFilter = DEFAULT_TIME_FILTER; // newsflash time filter (in years ago, 5 is the default) @@ -94,7 +95,7 @@ export default class NewsFlashStore { if (filter !== this.newsFlashActiveFilter) { runInAction(() => { this.newsFlashActiveFilter = filter; - this.newsFlashPageNumber = 0; + this.newsFlashPageNumber = 1; }); if (!(filter in LOCAL_FILTERS)) { runInAction(() => { @@ -107,16 +108,20 @@ export default class NewsFlashStore { async filterNewsFlashCollection(direction: Direction = Direction.NEXT) { runInAction(() => (this.newsFlashLoading = true)); + if (this.newsFlashActiveFilter in LOCAL_FILTERS) { const filterMethod = LOCAL_FILTERS[this.newsFlashActiveFilter]; const filtered = filterMethod && filterMethod(this.newsFlashCollection.data); - runInAction(() => (this.newsFlashCollection.data = [...(filtered || [])])); - runInAction(() => (this.newsFlashLoading = false)); + runInAction(() => { + this.newsFlashCollection.data = [...(filtered || [])]; + this.newsFlashLoading = false; + }); } else { - const prevPage = this.newsFlashInitialPageNumber - 1; const queryParams: IFetchNewsQueryParams = { - pageNumber: direction === Direction.NEXT ? this.newsFlashPageNumber + 1 : prevPage, + pageNumber: + direction === Direction.NEXT ? this.newsFlashLastNextPage + 1 : Math.max(this.newsFlashLastPrevPage - 1, 1), }; + if (this.newsFlashActiveFilter === 'critical') { queryParams['critical'] = true; } else { @@ -128,13 +133,23 @@ export default class NewsFlashStore { if (res) { runInAction(() => { this.newsFlashCollection = { - data: res.data, + data: + direction === Direction.NEXT + ? [...this.newsFlashCollection.data, ...res.data] + : [...res.data, ...this.newsFlashCollection.data], pagination: res.pagination, }; - runInAction(() => (this.newsFlashPageNumber = res.pagination.pageNumber)); + if (direction === Direction.NEXT) { + this.newsFlashLastNextPage = res.pagination.pageNumber; + } else { + this.newsFlashLastPrevPage = res.pagination.pageNumber; + } + this.newsFlashPageNumber = res.pagination.pageNumber; + this.newsFlashLoading = false; }); } else { console.error(`filterNewsFlashCollection(filter:${this.newsFlashActiveFilter}) invalid data:`, res); + runInAction(() => (this.newsFlashLoading = false)); } }); } diff --git a/src/store/root.store.ts b/src/store/root.store.ts index 004e5239..0af907b3 100644 --- a/src/store/root.store.ts +++ b/src/store/root.store.ts @@ -42,7 +42,8 @@ export default class RootStore { runInAction(() => { if (initData.newsFlashCollection) { this.newsFlashStore.newsFlashCollection = initData.newsFlashCollection; - this.newsFlashStore.newsFlashInitialPageNumber = initData.newsFlashCollection.pagination.pageNumber; + this.newsFlashStore.newsFlashLastNextPage = initData.newsFlashCollection.pagination.pageNumber; + this.newsFlashStore.newsFlashLastPrevPage = initData.newsFlashCollection.pagination.pageNumber; this.newsFlashStore.newsFlashPageNumber = initData.newsFlashCollection.pagination.pageNumber; } if (initData.newsFlashWidgetsData) { diff --git a/src/utils/element.util.ts b/src/utils/element.util.ts new file mode 100644 index 00000000..6cd49716 --- /dev/null +++ b/src/utils/element.util.ts @@ -0,0 +1,12 @@ +export const combineRefs = + (...refs: any[]) => + (element: HTMLElement | null) => { + refs.forEach((ref) => { + if (!ref) return; + if (typeof ref === 'function') { + ref(element); + } else { + ref.current = element; + } + }); + };