Feature/automatically load more pages until scrollbar is visible (#667)

* Rename "grifRed" to "gridWrapperRef"

* Automatically fetch more pages until scrollbar is visible
This commit is contained in:
schroda
2024-03-23 23:34:33 +01:00
committed by GitHub
parent 83635faf95
commit 66d6c95dfd
2 changed files with 168 additions and 124 deletions

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { ForwardedRef, forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import Grid, { GridTypeMap } from '@mui/material/Grid'; import Grid, { GridTypeMap } from '@mui/material/Grid';
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso'; import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
@@ -73,126 +73,139 @@ type DefaultGridProps = Pick<MangaCardProps, 'mode'> & {
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection']; handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
}; };
const HorizontalGrid = ({ const HorizontalGrid = forwardRef(
isLoading, (
mangas, {
inLibraryIndicator, isLoading,
GridItemContainer, mangas,
gridLayout, inLibraryIndicator,
isSelectModeActive, GridItemContainer,
selectedMangaIds, gridLayout,
handleSelection, isSelectModeActive,
mode, selectedMangaIds,
}: DefaultGridProps) => ( handleSelection,
<Grid mode,
container }: DefaultGridProps,
spacing={1} ref: ForwardedRef<HTMLDivElement | null>,
style={{ ) => (
margin: 0, <Grid
width: '100%', ref={ref}
padding: '5px', container
overflowX: 'auto', spacing={1}
display: '-webkit-inline-box', style={{
flexWrap: 'nowrap', margin: 0,
}} width: '100%',
> padding: '5px',
{isLoading ? ( overflowX: 'auto',
<LoadingPlaceholder /> display: '-webkit-inline-box',
) : ( flexWrap: 'nowrap',
mangas.map((manga) => ( }}
<GridItemContainer key={manga.id}> >
{createMangaCard( {isLoading ? (
manga, <LoadingPlaceholder />
gridLayout, ) : (
inLibraryIndicator, mangas.map((manga) => (
isSelectModeActive, <GridItemContainer key={manga.id}>
selectedMangaIds, {createMangaCard(
handleSelection, manga,
mode, gridLayout,
)} inLibraryIndicator,
</GridItemContainer> isSelectModeActive,
)) selectedMangaIds,
)} handleSelection,
</Grid> mode,
)}
</GridItemContainer>
))
)}
</Grid>
),
); );
const VerticalGrid = ({ const VerticalGrid = forwardRef(
isLoading, (
mangas, {
inLibraryIndicator, isLoading,
GridItemContainer, mangas,
gridLayout, inLibraryIndicator,
hasNextPage, GridItemContainer,
loadMore, gridLayout,
isSelectModeActive, hasNextPage,
selectedMangaIds, loadMore,
handleSelection, isSelectModeActive,
mode, selectedMangaIds,
}: DefaultGridProps & { handleSelection,
hasNextPage: boolean; mode,
loadMore: () => void; }: DefaultGridProps & {
}) => { hasNextPage: boolean;
const location = useLocation<{ snapshot?: GridStateSnapshot }>(); loadMore: () => void;
const navigate = useNavigate(); },
const { snapshot } = location.state ?? {}; ref: ForwardedRef<HTMLDivElement | null>,
) => {
const location = useLocation<{ snapshot?: GridStateSnapshot }>();
const navigate = useNavigate();
const { snapshot } = location.state ?? {};
const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>(); const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>();
const persistGridState = (gridState: GridStateSnapshot) => { const persistGridState = (gridState: GridStateSnapshot) => {
const currentUrl = window.location.href; const currentUrl = window.location.href;
clearTimeout(persistGridStateTimeout.current); clearTimeout(persistGridStateTimeout.current);
persistGridStateTimeout.current = setTimeout(() => { persistGridStateTimeout.current = setTimeout(() => {
const didLocationChange = currentUrl !== window.location.href; const didLocationChange = currentUrl !== window.location.href;
if (didLocationChange) { if (didLocationChange) {
return; return;
}
navigate(
{ pathname: '', search: location.search },
{ replace: true, state: { ...location.state, snapshot: gridState } },
);
}, 250);
};
useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]);
return (
<>
<VirtuosoGrid
useWindowScroll
overscan={window.innerHeight * 0.25}
totalCount={mangas.length}
components={{
List: GridContainer,
Item: GridItemContainer,
}}
restoreStateFrom={snapshot}
stateChanged={persistGridState}
endReached={() => loadMore()}
itemContent={(index) =>
createMangaCard(
mangas[index],
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)
} }
/>
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */ navigate(
/* eslint-disable-next-line no-nested-ternary */} { pathname: '', search: location.search },
{isSelectModeActive && gridLayout === GridLayout.List ? ( { replace: true, state: { ...location.state, snapshot: gridState } },
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} /> );
) : // eslint-disable-next-line no-nested-ternary }, 250);
isLoading ? ( };
<LoadingPlaceholder /> useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]);
) : hasNextPage ? (
<div style={{ height: '75px' }} /> return (
) : null} <>
</> <Box ref={ref}>
); <VirtuosoGrid
}; useWindowScroll
overscan={window.innerHeight * 0.25}
totalCount={mangas.length}
components={{
List: GridContainer,
Item: GridItemContainer,
}}
restoreStateFrom={snapshot}
stateChanged={persistGridState}
endReached={() => loadMore()}
itemContent={(index) =>
createMangaCard(
mangas[index],
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)
}
/>
</Box>
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */
/* eslint-disable-next-line no-nested-ternary */}
{isSelectModeActive && gridLayout === GridLayout.List ? (
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} />
) : // eslint-disable-next-line no-nested-ternary
isLoading ? (
<LoadingPlaceholder />
) : hasNextPage ? (
<div style={{ height: '75px' }} />
) : null}
</>
);
},
);
export interface IMangaGridProps extends Omit<DefaultGridProps, 'GridItemContainer'> { export interface IMangaGridProps extends Omit<DefaultGridProps, 'GridItemContainer'> {
message?: string; message?: string;
@@ -221,9 +234,11 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
mode, mode,
} = props; } = props;
const gridRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth); const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth);
const [gridItemWidth] = useLocalStorage<number>('ItemWidth', 300); const [gridItemWidth] = useLocalStorage<number>('ItemWidth', 300);
const gridRef = useRef<HTMLDivElement>(null); const gridWrapperRef = useRef<HTMLDivElement>(null);
const GridItemContainer = useMemo( const GridItemContainer = useMemo(
() => GridItemContainerWithDimension(dimensions, gridItemWidth, gridLayout), () => GridItemContainerWithDimension(dimensions, gridItemWidth, gridLayout),
[dimensions, gridItemWidth, gridLayout], [dimensions, gridItemWidth, gridLayout],
@@ -231,7 +246,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
const updateGridWidth = () => { const updateGridWidth = () => {
const getDimensions = () => { const getDimensions = () => {
const gridWidth = gridRef.current?.offsetWidth; const gridWidth = gridWrapperRef.current?.offsetWidth;
if (!gridWidth) { if (!gridWidth) {
return document.documentElement.offsetWidth; return document.documentElement.offsetWidth;
@@ -258,6 +273,36 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, []); }, []);
useEffect(() => {
if (!gridRef.current) {
return () => {};
}
if (gridRef.current.offsetHeight > document.documentElement.clientHeight) {
return () => {};
}
const resizeObserver = new ResizeObserver(() => {
const gridHeight = gridRef.current!.offsetHeight;
const isScrollbarVisible = gridHeight > document.documentElement.clientHeight;
if (!gridHeight) {
return;
}
if (isScrollbarVisible) {
resizeObserver.disconnect();
return;
}
loadMore();
resizeObserver.disconnect();
});
resizeObserver.observe(gridRef.current);
return () => resizeObserver.disconnect();
}, [loadMore]);
const hasNoItems = !isLoading && mangas.length === 0; const hasNoItems = !isLoading && mangas.length === 0;
if (hasNoItems) { if (hasNoItems) {
if (noFaces) { if (noFaces) {
@@ -278,7 +323,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
return ( return (
<div <div
ref={gridRef} ref={gridWrapperRef}
style={{ style={{
overflow: 'hidden', overflow: 'hidden',
paddingBottom: '13px', paddingBottom: '13px',
@@ -286,6 +331,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
> >
{horizontal ? ( {horizontal ? (
<HorizontalGrid <HorizontalGrid
ref={gridRef}
isLoading={isLoading} isLoading={isLoading}
mangas={mangas} mangas={mangas}
inLibraryIndicator={inLibraryIndicator} inLibraryIndicator={inLibraryIndicator}
@@ -298,6 +344,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
/> />
) : ( ) : (
<VerticalGrid <VerticalGrid
ref={gridRef}
isLoading={isLoading} isLoading={isLoading}
mangas={mangas} mangas={mangas}
inLibraryIndicator={inLibraryIndicator} inLibraryIndicator={inLibraryIndicator}

View File

@@ -13,7 +13,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
import { useQueryParam, StringParam } from 'use-query-params'; import { useQueryParam, StringParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import { Box, Button, styled, useTheme, useMediaQuery, Tooltip } from '@mui/material'; import { Box, Button, styled, Tooltip } from '@mui/material';
import FavoriteIcon from '@mui/icons-material/Favorite'; import FavoriteIcon from '@mui/icons-material/Favorite';
import NewReleasesIcon from '@mui/icons-material/NewReleases'; import NewReleasesIcon from '@mui/icons-material/NewReleases';
import FilterListIcon from '@mui/icons-material/FilterList'; import FilterListIcon from '@mui/icons-material/FilterList';
@@ -194,9 +194,6 @@ export function SourceMangas() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavBarContext); const { setTitle, setAction } = useContext(NavBarContext);
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
const { sourceId } = useParams<{ sourceId: string }>(); const { sourceId } = useParams<{ sourceId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -232,7 +229,7 @@ export function SourceMangas() {
contentType, contentType,
searchTerm, searchTerm,
filtersToApply, filtersToApply,
isLargeScreen ? 2 : 1, 1,
); );
const mangas = data?.fetchSourceManga.mangas ?? []; const mangas = data?.fetchSourceManga.mangas ?? [];
const hasNextPage = data?.fetchSourceManga.hasNextPage ?? false; const hasNextPage = data?.fetchSourceManga.hasNextPage ?? false;