From 19d27fdf2621c7271294486d835124a274bb3d1f Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sat, 17 Jun 2023 16:50:17 +0200 Subject: [PATCH] Feature/virtualize manga grid (#363) * Disable ssr by default for "useMediaQuery" SSR would cause the hook to run once with default values and only after the first render with real values. This can cause issue on rendering when depending on useMediaQuery results like in 8c129f2e08e8c807b57c7eef802556faa2edcf1b * Virtualize manga grids * Remove SourceMangas load more guard Was required due to the previous load more trigger logic from the MangaGrid. Due to using Virtuoso now, load more will be triggered only once when the bottom of the grid was reached and thus won't trigger multiple load more requests * Remove old Library pagination Pagination is handled by virtuoso, thus, the previous pagination can be removed * Increase initial loaded pages to make infinite load work virtuoso requires enough initial items to be rendered, so that actual virtualization takes effect, for it to fire "endReached" * Prevent virtuoso grid scrollbar from jumping around In case the items have a big height difference there is a bug where the scrollbar starts jumping around the moment these new items are rendered * Restore scroll position For some reason the UI jumps around when accessing "document.documentElement" during loading more items. After the items are rendered the loading placeholder gets removed and the previous items jump to the bottom of the viewport. * Limit manga grid titles to two lines * Remove "last page" info from MangaGrid Info is not needed. The only thing the MangaGrid has to do is to trigger a request to load more data --- src/components/MangaCard.tsx | 338 ++++++++++---------- src/components/MangaGrid.tsx | 265 ++++++++++----- src/components/library/LibraryMangaGrid.tsx | 17 +- src/components/source/SourceMangaGrid.tsx | 5 +- src/screens/SearchAll.tsx | 5 +- src/screens/SourceMangas.tsx | 32 +- src/theme.ts | 5 + 7 files changed, 382 insertions(+), 285 deletions(-) diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx index f718a442..9fa2052a 100644 --- a/src/components/MangaCard.tsx +++ b/src/components/MangaCard.tsx @@ -11,11 +11,10 @@ import Card from '@mui/material/Card'; import CardActionArea from '@mui/material/CardActionArea'; import Typography from '@mui/material/Typography'; import { Link } from 'react-router-dom'; -import { Avatar, Box, CardContent, Grid, styled } from '@mui/material'; +import { Avatar, Box, CardContent, styled } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { IMangaCard } from '@/typings'; import requestManager from '@/lib/RequestManager'; -import useLocalStorage from '@/util/useLocalStorage'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import SpinnerImage from '@/components/util/SpinnerImage'; @@ -36,9 +35,21 @@ const BottomGradientDoubledDown = styled('div')({ }); const MangaTitle = styled(Typography)({ + lineHeight: '1.5rem', + maxHeight: '3rem', + display: '-webkit-box', + WebkitLineClamp: '2', + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +const GridMangaTitle = styled(MangaTitle)({ + width: '100%', position: 'absolute', bottom: 0, - padding: '0.5em', + margin: '0.5em 0', + padding: '0 0.5em', fontSize: '1.05rem', }); @@ -55,219 +66,198 @@ const BadgeContainer = styled('div')({ }, }); -const truncateText = (str: string, maxLength: number) => { - const ending = '...'; - // trim the string to the maximum length - const trimmedString = str.substr(0, maxLength - ending.length); - - if (trimmedString.length < str.length) { - return trimmedString + ending; - } - return str; -}; - interface IProps { manga: IMangaCard; gridLayout?: GridLayout; - dimensions: number; inLibraryIndicator?: boolean; } -const MangaCard = React.forwardRef((props: IProps, ref) => { +const MangaCard = (props: IProps) => { const { t } = useTranslation(); const { manga: { id, title, thumbnailUrl, downloadCount, unreadCount: unread, inLibrary }, gridLayout, - dimensions, inLibraryIndicator, } = props; const { options: { showUnreadBadge, showDownloadBadge }, } = useLibraryOptionsContext(); - const [ItemWidth] = useLocalStorage('ItemWidth', 300); - const mangaLinkTo = `/manga/${id}/`; if (gridLayout !== GridLayout.List) { - const columns = Math.ceil(dimensions / ItemWidth); - const columnsPerItem = 12 / columns; return ( - - - + + - - - - {inLibraryIndicator && inLibrary && ( - - {t('manga.button.in_library')} - - )} - {showUnreadBadge && unread! > 0 && ( - {unread} - )} - {showDownloadBadge && downloadCount! > 0 && ( - - {downloadCount} - - )} - - - {gridLayout !== GridLayout.Comfortable && ( - <> - - - - {truncateText(title, 61)} - - + {inLibraryIndicator && inLibrary && ( + + {t('manga.button.in_library')} + )} - - - {gridLayout === GridLayout.Comfortable && ( - 0 && ( + {unread} + )} + {showDownloadBadge && downloadCount! > 0 && ( + + {downloadCount} + + )} + + - {truncateText(title, 61)} - - )} - - - + /> + {gridLayout !== GridLayout.Comfortable && ( + <> + + + + {title} + + + )} + + + {gridLayout === GridLayout.Comfortable && ( + + {title} + + )} + + ); } return ( - - - - + + + + - - - - {truncateText(title, 61)} + + {title} + + + + {inLibraryIndicator && inLibrary && ( + + {t('manga.button.in_library')} - - - {inLibraryIndicator && inLibrary && ( - - {t('manga.button.in_library')} - - )} - {showUnreadBadge && unread! > 0 && ( - {unread} - )} - {showDownloadBadge && downloadCount! > 0 && ( - - {downloadCount} - - )} - - - - - + )} + {showUnreadBadge && unread! > 0 && ( + {unread} + )} + {showDownloadBadge && downloadCount! > 0 && ( + + {downloadCount} + + )} + + + + ); -}); +}; export default MangaCard; diff --git a/src/components/MangaGrid.tsx b/src/components/MangaGrid.tsx index a2af3c87..3ba4cab2 100644 --- a/src/components/MangaGrid.tsx +++ b/src/components/MangaGrid.tsx @@ -6,14 +6,145 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import Grid from '@mui/material/Grid'; -import { Typography, Box } from '@mui/material'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import Grid, { GridTypeMap } from '@mui/material/Grid'; +import { Box, Typography } from '@mui/material'; +import { GridItemProps, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso'; +import { useNavigate, useLocation } from 'react-router-dom'; import { IMangaCard } from '@/typings'; import EmptyView from '@/components/util/EmptyView'; import LoadingPlaceholder from '@/components/util/LoadingPlaceholder'; import MangaCard from '@/components/MangaCard'; import { GridLayout } from '@/components/context/LibraryOptionsContext'; +import useLocalStorage from '@/util/useLocalStorage'; + +const GridContainer = React.forwardRef(({ children, ...props }, ref) => ( + + {children} + +)); + +const GridItemContainerWithDimension = + (dimensions: number, itemWidth: number, gridLayout?: GridLayout, maxColumns: number = 12) => + ({ children, ...itemProps }: GridTypeMap['props'] & Partial) => { + const itemsPerRow = Math.ceil(dimensions / itemWidth); + const columnsPerItem = gridLayout === GridLayout.List ? maxColumns : maxColumns / itemsPerRow; + + return ( + + {children} + + ); + }; + +const createMangaCard = (manga: IMangaCard, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => ( + +); + +type DefaultGridProps = { + isLoading: boolean; + mangas: IMangaCard[]; + inLibraryIndicator?: boolean; + GridItemContainer: (props: GridTypeMap['props'] & Partial) => JSX.Element; + gridLayout?: GridLayout; +}; + +const HorizontalGrid = ({ isLoading, mangas, inLibraryIndicator, GridItemContainer, gridLayout }: DefaultGridProps) => ( + + {isLoading ? ( + + ) : ( + mangas.map((manga) => ( + + {createMangaCard(manga, gridLayout, inLibraryIndicator)} + + )) + )} + +); + +const VerticalGrid = ({ + isLoading, + mangas, + inLibraryIndicator, + GridItemContainer, + gridLayout, + hasNextPage, + loadMore, +}: DefaultGridProps & { + hasNextPage: boolean; + loadMore: () => void; +}) => { + const [restoredScrollPosition, setRestoredScrollPosition] = useState(mangas.length === 0); + const location = useLocation<{ lastScrollPosition?: number }>(); + const navigate = useNavigate(); + const virtuoso = useRef(null); + + const { lastScrollPosition = 0 } = location.state ?? {}; + + useEffect(() => { + const updateLastScrollPosition = () => { + if (!restoredScrollPosition) { + return; + } + + navigate( + { pathname: '', search: location.search }, + { replace: true, state: { ...location.state, lastScrollPosition: window.scrollY } }, + ); + }; + + window.addEventListener('scroll', updateLastScrollPosition, true); + window.addEventListener('resize', updateLastScrollPosition, true); + + return () => { + window.removeEventListener('scroll', updateLastScrollPosition, true); + window.removeEventListener('resize', updateLastScrollPosition, true); + }; + }, [restoredScrollPosition, location.state, location.search]); + + useEffect(() => { + const haveItemsRendered = document.documentElement.offsetHeight >= lastScrollPosition; + const restoreScrollPosition = !restoredScrollPosition && haveItemsRendered && virtuoso.current; + if (!restoreScrollPosition) { + return; + } + + virtuoso.current.scrollTo({ top: lastScrollPosition }); + setRestoredScrollPosition(true); + }, [document.documentElement.offsetHeight, virtuoso.current]); + + return ( + <> + loadMore()} + itemContent={(index) => createMangaCard(mangas[index], gridLayout, inLibraryIndicator)} + /> + {/* render div to prevent UI jumping around when showing/hiding loading placeholder */ + /* eslint-disable-next-line no-nested-ternary */} + {isLoading ? : hasNextPage ?
: null} + + ); +}; export interface IMangaGridProps { mangas: IMangaCard[]; @@ -21,8 +152,7 @@ export interface IMangaGridProps { message?: string; messageExtra?: JSX.Element; hasNextPage: boolean; - lastPageNum: number; - setLastPageNum: (lastPageNum: number) => void; + loadMore: () => void; gridLayout?: GridLayout; horizontal?: boolean | undefined; noFaces?: boolean | undefined; @@ -36,53 +166,47 @@ const MangaGrid: React.FC = (props) => { message, messageExtra, hasNextPage, - lastPageNum, - setLastPageNum, + loadMore, gridLayout, horizontal, noFaces, inLibraryIndicator, } = props; - let mapped; - const lastManga = useRef(null); - - const scrollHandler = () => { - if (lastManga.current) { - const rect = lastManga.current.getBoundingClientRect(); - if ((rect.y + rect.height) / window.innerHeight < 2 && hasNextPage) { - setLastPageNum(lastPageNum + 1); - } - } - }; - useEffect(() => { - window.addEventListener('scroll', scrollHandler, true); - return () => { - window.removeEventListener('scroll', scrollHandler, true); - }; - }, [hasNextPage, mangas]); - - const [dimensions, setDimensions] = useState(1); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const [gridItemWidth] = useLocalStorage('ItemWidth', 300); const gridRef = useRef(null); + const GridItemContainer = useMemo( + () => GridItemContainerWithDimension(dimensions.width, gridItemWidth, gridLayout), + [dimensions, gridItemWidth, gridLayout], + ); - const TestDimensions = () => { - setDimensions(gridRef.current ? gridRef.current.offsetWidth : 0); + const updateGridWidth = () => { + setDimensions({ + width: gridRef.current?.offsetWidth ?? 0, + height: gridRef.current?.offsetHeight ?? 0, + }); }; - useLayoutEffect(TestDimensions, []); + useLayoutEffect(updateGridWidth, []); - let movementTimer: NodeJS.Timeout; + useEffect(() => { + let movementTimer: NodeJS.Timeout; - window.addEventListener('resize', () => { - clearInterval(movementTimer); - movementTimer = setTimeout(TestDimensions, 100); - }); + const onResize = () => { + clearInterval(movementTimer); + movementTimer = setTimeout(updateGridWidth, 100); + }; - if (mangas.length === 0) { - if (isLoading) { - mapped = ; - } else { - mapped = noFaces ? ( + window.addEventListener('resize', onResize); + + return () => window.removeEventListener('resize', onResize); + }, []); + + const hasNoItems = !isLoading && mangas.length === 0; + if (hasNoItems) { + if (noFaces) { + return ( = (props) => { {message} {messageExtra} - ) : ( - ); } - } else { - mapped = mangas.map((it, idx) => ( - - )); + + return ; } return ( -
- - {mapped} - +
+ {horizontal ? ( + + ) : ( + + )}
); }; diff --git a/src/components/library/LibraryMangaGrid.tsx b/src/components/library/LibraryMangaGrid.tsx index 6d7be526..c3250e05 100644 --- a/src/components/library/LibraryMangaGrid.tsx +++ b/src/components/library/LibraryMangaGrid.tsx @@ -6,9 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { StringParam, useQueryParam } from 'use-query-params'; -import { useMediaQuery, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { IMangaCard, LibrarySortMode, NullAndUndefined } from '@/typings'; import { useSearchSettings } from '@/util/searchSettings'; @@ -120,11 +119,6 @@ const LibraryMangaGrid: React.FC(defaultPageNumber); const { settings } = useSearchSettings(); const filteredMangas = useMemo( @@ -135,23 +129,20 @@ const LibraryMangaGrid: React.FC sortManga(filteredMangas, options.sorts, options.sortDesc), [filteredMangas, lastLibraryUpdate, options.sorts, options.sortDesc], ); - const filteredPaginatedMangas = useMemo(() => sortedMangas.slice(0, lastPageNum * 10), [sortedMangas, lastPageNum]); const showFilteredOutMessage = (unread != null || downloaded != null || query) && filteredMangas.length === 0 && mangas.length > 0; useEffect(() => { - setLastPageNum(defaultPageNumber); window.scrollTo(0, 0); }, [filteredMangas]); return ( undefined} message={showFilteredOutMessage ? t('library.error.label.no_matches') : message} gridLayout={options.gridLayout} /> diff --git a/src/components/source/SourceMangaGrid.tsx b/src/components/source/SourceMangaGrid.tsx index aa0e3193..194205a9 100644 --- a/src/components/source/SourceMangaGrid.tsx +++ b/src/components/source/SourceMangaGrid.tsx @@ -17,7 +17,7 @@ function filterManga(mangas: IMangaCard[]): IMangaCard[] { export default function SourceMangaGrid(props: IMangaGridProps) { const { t } = useTranslation(); - const { mangas, isLoading, hasNextPage, lastPageNum, setLastPageNum, message, messageExtra, gridLayout } = props; + const { mangas, isLoading, hasNextPage, loadMore, message, messageExtra, gridLayout } = props; const filteredManga = filterManga(mangas); const showFilteredOutMessage = filteredManga.length === 0 && mangas.length > 0; @@ -27,8 +27,7 @@ export default function SourceMangaGrid(props: IMangaGridProps) { mangas={filteredManga} isLoading={isLoading} hasNextPage={hasNextPage} - lastPageNum={lastPageNum} - setLastPageNum={setLastPageNum} + loadMore={loadMore} message={showFilteredOutMessage ? t('manga.error.label.no_matches') : message} messageExtra={messageExtra} gridLayout={gridLayout} diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx index 09701bd2..7f88c988 100644 --- a/src/screens/SearchAll.tsx +++ b/src/screens/SearchAll.tsx @@ -101,8 +101,6 @@ const SourceSearchPreview = React.memo( const { id, displayName, lang } = source; const { data: searchResult, - size, - setSize, isLoading, error, abortRequest, @@ -149,8 +147,7 @@ const SourceSearchPreview = React.memo( mangas={mangas} isLoading={isLoading} hasNextPage={false} - lastPageNum={size} - setLastPageNum={setSize} + loadMore={() => undefined} horizontal noFaces message={errorMessage} diff --git a/src/screens/SourceMangas.tsx b/src/screens/SourceMangas.tsx index 78773ffa..355e746b 100644 --- a/src/screens/SourceMangas.tsx +++ b/src/screens/SourceMangas.tsx @@ -13,7 +13,7 @@ import SettingsIcon from '@mui/icons-material/Settings'; import { useQueryParam, StringParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; import Link from '@mui/material/Link'; -import { Box, Button, styled } from '@mui/material'; +import { Box, Button, styled, useTheme, useMediaQuery } from '@mui/material'; import FavoriteIcon from '@mui/icons-material/Favorite'; import NewReleasesIcon from '@mui/icons-material/NewReleases'; import FilterListIcon from '@mui/icons-material/FilterList'; @@ -48,13 +48,13 @@ const ContentTypeButton = styled(Button)(() => ({ const StyledGridWrapper = styled(Box, { shouldForwardProp: (prop) => prop !== 'hasContent' })<{ hasContent: boolean }>( ({ theme, hasContent }) => ({ // 62.5px ContentTypeMenu height (- padding of grid + grid item) - marginTop: `calc(62.5px ${hasContent ? '- 13px' : ''})`, + marginTop: `calc(62.5px ${hasContent ? '- 8px' : ''})`, // header height - ContentTypeMenu height minHeight: 'calc(100vh - 64px - 62.5px)', position: 'relative', [theme.breakpoints.down('sm')]: { - // 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid + grid item) - marginTop: `calc(62.5px - 8px ${hasContent ? '- 13px' : ''})`, + // 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid item) + marginTop: `calc(62.5px - 8px ${hasContent ? '- 8px' : ''})`, // header height (+ 8px margin) - footer height - ContentTypeMenu height minHeight: 'calc(100vh - 64px - 64px - 62.5px)', }, @@ -106,17 +106,18 @@ const useSourceManga = ( contentType: SourceContentType, searchTerm: string | null | undefined, filters: IPos[], + initialPages = 1, ): SourceMangaResponse => { let result: AbortableSWRInfiniteResponse; switch (contentType) { case SourceContentType.POPULAR: - result = requestManager.useGetSourcePopularMangas(sourceId, 1); + result = requestManager.useGetSourcePopularMangas(sourceId, initialPages); break; case SourceContentType.LATEST: - result = requestManager.useGetSourceLatestMangas(sourceId, 1); + result = requestManager.useGetSourceLatestMangas(sourceId, initialPages); break; case SourceContentType.SEARCH: - result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], 1); + result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], initialPages); break; case SourceContentType.FILTER: result = requestManager.useSourceQuickSearch( @@ -138,7 +139,7 @@ const useSourceManga = ( return filter; }), - 1, + initialPages, { disableCache: true }, ); break; @@ -160,6 +161,8 @@ const useSourceManga = ( export default function SourceMangas() { const { t } = useTranslation(); const { setTitle, setAction } = useContext(NavbarContext); + const theme = useTheme(); + const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm')); const { sourceId } = useParams<{ sourceId: string }>(); @@ -183,7 +186,7 @@ export default function SourceMangas() { setSize: setPages, mutate: refreshData, abortRequest, - } = useSourceManga(sourceId, contentType, searchTerm, filtersToApply); + } = useSourceManga(sourceId, contentType, searchTerm, filtersToApply, isLargeScreen ? 2 : 1); const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId); const { data: source } = requestManager.useGetSource(sourceId); const [triggerDataRefresh, setTriggerDataRefresh] = useState(false); @@ -222,15 +225,13 @@ export default function SourceMangas() { updateContentType(currentLocationContentType, false); } - let wasLoadMoreTriggered = false; - const setLastPageNum = useCallback(() => { - if (!hasNextPage || wasLoadMoreTriggered) { + const loadMore = useCallback(() => { + if (!hasNextPage) { return; } - wasLoadMoreTriggered = true; setPages(lastPageNum + 1); - }, [setPages, hasNextPage, lastPageNum]); + }, [setPages, lastPageNum, hasNextPage]); const resetFilters = useCallback(async () => { setDialogFiltersToApply([]); @@ -325,8 +326,7 @@ export default function SourceMangas() { { }, }, components: { + MuiUseMediaQuery: { + defaultProps: { + noSsr: true, + }, + }, MuiCssBaseline: { styleOverrides: ` *::-webkit-scrollbar {