From 4a9d2903b820fa4d1187c03f78f5177c3c67a64b Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Tue, 28 Jan 2025 02:16:00 +0100 Subject: [PATCH] Add infinite scroll to reader --- .../chapter/components/cards/ChapterCard.tsx | 2 +- src/modules/chapter/services/Chapters.ts | 8 +- .../components/viewer/ReaderChapterViewer.tsx | 403 ++++++++++++++++++ .../ReaderInfiniteScrollUpdateChapter.tsx | 71 +++ .../reader/components/viewer/ReaderViewer.tsx | 249 ++++++----- .../components/viewer/pager/BasePager.tsx | 8 +- .../viewer/pager/ReaderDoublePagedPager.tsx | 4 +- .../viewer/pager/ReaderPagedPager.tsx | 2 +- .../state/ReaderStateChaptersContext.tsx | 15 +- .../ReaderStateChaptersContextProvider.tsx | 11 +- .../useReaderInfiniteScrollUpdateChapter.ts | 120 ++++++ .../reader/hooks/useReaderResetStates.ts | 6 +- .../reader/hooks/useReaderSetChaptersState.ts | 75 ++-- .../reader/hooks/useReaderSetPagesState.ts | 58 ++- src/modules/reader/screens/Reader.tsx | 71 +-- src/modules/reader/services/ReaderControls.ts | 15 +- src/modules/reader/types/Reader.types.ts | 23 +- src/modules/reader/utils/Reader.utils.ts | 116 ++++- .../reader/utils/ReaderPager.utils.tsx | 46 +- .../reader/utils/ReaderProgressBar.utils.tsx | 3 +- 20 files changed, 1068 insertions(+), 238 deletions(-) create mode 100644 src/modules/reader/components/viewer/ReaderChapterViewer.tsx create mode 100644 src/modules/reader/components/viewer/ReaderInfiniteScrollUpdateChapter.tsx create mode 100644 src/modules/reader/hooks/useReaderInfiniteScrollUpdateChapter.ts diff --git a/src/modules/chapter/components/cards/ChapterCard.tsx b/src/modules/chapter/components/cards/ChapterCard.tsx index 0a6c01c5..97b12450 100644 --- a/src/modules/chapter/components/cards/ChapterCard.tsx +++ b/src/modules/chapter/components/cards/ChapterCard.tsx @@ -124,7 +124,7 @@ export const ChapterCard = memo((props: IProps) => { style={{ color: theme.palette.text[chapter.isRead ? 'disabled' : 'primary'], }} - state={Chapters.getReaderOpenChapterLocationState(chapter)} + state={Chapters.getReaderOpenChapterLocationState(chapter, true)} replace={mode === 'reader'} onClick={(e) => handleClick(e)} {...longPressBind(popupState.open)} diff --git a/src/modules/chapter/services/Chapters.ts b/src/modules/chapter/services/Chapters.ts index a7c3a959..09b27bd7 100644 --- a/src/modules/chapter/services/Chapters.ts +++ b/src/modules/chapter/services/Chapters.ts @@ -23,7 +23,7 @@ import { CHAPTER_LIST_FIELDS } from '@/lib/graphql/fragments/ChapterFragments.ts import { DirectionOffset, TranslationKey } from '@/Base.types.ts'; import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; -import { ReaderResumeMode } from '@/modules/reader/types/Reader.types.ts'; +import { ReaderOpenChapterLocationState, ReaderResumeMode } from '@/modules/reader/types/Reader.types.ts'; import { AppRoutes } from '@/modules/core/AppRoute.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { DOWNLOAD_TYPE_FIELDS } from '@/lib/graphql/fragments/DownloadFragments.ts'; @@ -460,9 +460,13 @@ export class Chapters { return ReaderResumeMode.LAST_READ; } - static getReaderOpenChapterLocationState(chapter: ChapterReadInfo): { resumeMode: ReaderResumeMode } { + static getReaderOpenChapterLocationState( + chapter: ChapterReadInfo, + updateInitialChapter?: boolean, + ): ReaderOpenChapterLocationState { return { resumeMode: Chapters.getReaderResumeMode(chapter), + updateInitialChapter, }; } } diff --git a/src/modules/reader/components/viewer/ReaderChapterViewer.tsx b/src/modules/reader/components/viewer/ReaderChapterViewer.tsx new file mode 100644 index 00000000..64ffb62a --- /dev/null +++ b/src/modules/reader/components/viewer/ReaderChapterViewer.tsx @@ -0,0 +1,403 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { memo, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Stack from '@mui/material/Stack'; +import { useTheme } from '@mui/material/styles'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import { + IReaderSettings, + ReaderPageSpreadState, + ReaderResumeMode, + ReaderStateChapters, + ReaderTransitionPageMode, + ReadingDirection, + ReadingMode, +} from '@/modules/reader/types/Reader.types.ts'; +import { getDoublePageModePages } from '@/modules/reader/utils/ReaderPager.utils.tsx'; +import { ReaderControls } from '@/modules/reader/services/ReaderControls.ts'; +import { + getPagerForReadingMode, + isContinuousReadingMode, + isContinuousVerticalReadingMode, + shouldApplyReaderWidth, +} from '@/modules/reader/utils/ReaderSettings.utils.tsx'; +import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts'; +import { + createHandleReaderPageLoadError, + createUpdateReaderPageLoadState, +} from '@/modules/reader/utils/Reader.utils.ts'; +import { useReaderConvertPagesForReadingMode } from '@/modules/reader/hooks/useReaderConvertPagesForReadingMode.ts'; +import { ReaderTransitionPage } from '@/modules/reader/components/viewer/ReaderTransitionPage.tsx'; +import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; +import { ChapterIdInfo } from '@/modules/chapter/services/Chapters.ts'; +import { READER_STATE_PAGES_DEFAULTS } from '@/modules/reader/constants/ReaderContext.constants.ts'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; +import { useReaderSetPagesState } from '@/modules/reader/hooks/useReaderSetPagesState.ts'; +import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; +import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts'; +import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; +import { ReaderInfiniteScrollUpdateChapter } from '@/modules/reader/components/viewer/ReaderInfiniteScrollUpdateChapter.tsx'; + +const BaseReaderChapterViewer = ({ + currentPageIndex, + setPages: setContextPages, + setPageLoadStates: setContextPageLoadStates, + setTotalPages: setContextTotalPages, + setCurrentPageIndex: setContextCurrentPageIndex, + updateCurrentPageIndex, + setPageToScrollToIndex, + transitionPageMode, + retryFailedPagesKeyPrefix, + setTransitionPageMode, + readingMode, + readerWidth, + pageScaleMode, + shouldOffsetDoubleSpreads, + readingDirection, + chapterId, + previousChapterId, + nextChapterId, + isPreviousChapterVisible, + isNextChapterVisible, + lastPageRead, + isInitialChapter, + isCurrentChapter, + isPreviousChapter, + isNextChapter, + isLeadingChapter, + isTrailingChapter, + imageRefs: globalImageRefs, + scrollIntoView, + setReaderStateChapters, + resumeMode, +}: Pick< + ReaderStatePages, + | 'currentPageIndex' + | 'setPages' + | 'setPageLoadStates' + | 'setTotalPages' + | 'setCurrentPageIndex' + | 'setPageToScrollToIndex' + | 'transitionPageMode' + | 'retryFailedPagesKeyPrefix' + | 'setTransitionPageMode' +> & + Pick< + IReaderSettings, + 'readingMode' | 'shouldOffsetDoubleSpreads' | 'readingDirection' | 'readerWidth' | 'pageScaleMode' + > & + Pick & { + updateCurrentPageIndex: ReturnType; + chapterId: ChapterIdInfo['id']; + previousChapterId?: ChapterIdInfo['id']; + nextChapterId?: ChapterIdInfo['id']; + isPreviousChapterVisible: boolean; + isNextChapterVisible: boolean; + lastPageRead: number; + isInitialChapter: boolean; + isCurrentChapter: boolean; + isPreviousChapter: boolean; + isNextChapter: boolean; + isLeadingChapter: boolean; + isTrailingChapter: boolean; + imageRefs: MutableRefObject<(HTMLElement | null)[]>; + scrollIntoView: boolean; + resumeMode: ReaderResumeMode; + }) => { + const { t } = useTranslation(); + const { direction: themeDirection } = useTheme(); + + const [fetchPages, pagesResponse] = requestManager.useGetChapterPagesFetch(chapterId ?? -1); + + const [arePagesFetched, setArePagesFetched] = useState(false); + const [totalPages, setTotalPages] = useState( + READER_STATE_PAGES_DEFAULTS.totalPages, + ); + const [pageUrls, setPageUrls] = useState(READER_STATE_PAGES_DEFAULTS.pageUrls); + const [pages, setPages] = useState(READER_STATE_PAGES_DEFAULTS.pages); + const [pageLoadStates, setPageLoadStates] = useState( + READER_STATE_PAGES_DEFAULTS.pageLoadStates, + ); + const [pagesToSpreadState, setPagesToSpreadState] = useState( + pageLoadStates.map(({ url }) => ({ url, isSpread: false })), + ); + + const isCurrentChapterRef = useRef(isCurrentChapter); + const imageRefs = useRef<(HTMLElement | null)[]>(pages.map(() => null)); + + const actualPages = useMemo(() => { + const arePagesLoaded = !!totalPages; + if (!arePagesLoaded) { + return pages; + } + + const isSpreadStateUpdated = totalPages === pagesToSpreadState.length; + if (!isSpreadStateUpdated) { + return pages; + } + + if (readingMode === ReadingMode.DOUBLE_PAGE) { + return getDoublePageModePages(pageUrls, pagesToSpreadState, shouldOffsetDoubleSpreads, readingDirection); + } + + return pages; + }, [pagesToSpreadState, readingMode, shouldOffsetDoubleSpreads, readingDirection, totalPages]); + + const Pager = useMemo(() => getPagerForReadingMode(readingMode), [readingMode]); + const isLtrReadingDirection = readingDirection === ReadingDirection.LTR; + const isContinuousReadingModeActive = isContinuousReadingMode(readingMode); + const shouldHideChapter = !isContinuousReadingModeActive && !isCurrentChapter; + + isCurrentChapterRef.current = isCurrentChapter; + if (isCurrentChapter) { + // eslint-disable-next-line no-param-reassign + globalImageRefs.current = imageRefs.current; + } + + const doFetchPages = useCallback(() => { + if (!chapterId) { + return; + } + + setArePagesFetched(false); + + fetchPages({ variables: { input: { chapterId } } }).catch( + defaultPromiseErrorHandler(`ReaderChapterViewer(${chapterId})::fetchPages`), + ); + }, [fetchPages, chapterId]); + + const onLoad = useMemo( + () => + createUpdateReaderPageLoadState( + actualPages, + pagesToSpreadState, + setPagesToSpreadState, + pageLoadStates, + (value) => { + if (isCurrentChapterRef.current) { + setContextPageLoadStates(value); + } + + setPageLoadStates(value); + }, + readingMode, + ), + // do not add "pagesToSpreadState" and "pageLoadStates" as a dependency, otherwise, every page gets re-rendered + // when they change which impacts the performance massively (depending on the total page count) + [actualPages, readingMode], + ); + + const onError = useMemo( + () => + createHandleReaderPageLoadError((value) => { + if (isCurrentChapterRef.current) { + setContextPageLoadStates(value); + } + + setPageLoadStates(value); + }), + [], + ); + + useEffect(() => { + doFetchPages(); + }, [chapterId]); + + const updatePageState = ( + value: T, + setLocalState: (value: T) => void, + setGlobalState: (value: T) => void, + forceLocal: boolean = false, + ) => { + if (forceLocal || !arePagesFetched) { + setLocalState(value); + } + + if (isCurrentChapter) { + setGlobalState(value); + } + }; + useReaderSetPagesState( + isCurrentChapter, + pagesResponse, + resumeMode, + lastPageRead, + actualPages, + pageLoadStates, + pagesToSpreadState, + arePagesFetched, + setArePagesFetched, + setReaderStateChapters, + (value) => updatePageState(value, setTotalPages, setContextTotalPages), + (value) => updatePageState(value, setPages, setContextPages), + (value) => updatePageState(value, setPageUrls, noOp), + (value) => updatePageState(value, setPageLoadStates, setContextPageLoadStates), + (value) => updatePageState(value, setPagesToSpreadState, noOp), + (value) => updatePageState(value, noOp, setContextCurrentPageIndex), + (value) => { + if ((isInitialChapter && !arePagesFetched) || scrollIntoView) { + setPageToScrollToIndex(value); + setReaderStateChapters((prevState) => ({ + ...prevState, + visibleChapters: { ...prevState.visibleChapters, scrollIntoView: false, resumeMode: undefined }, + })); + } + }, + (value) => updatePageState(value, noOp, setTransitionPageMode), + ); + + useReaderConvertPagesForReadingMode( + currentPageIndex, + actualPages, + pageUrls, + (value) => updatePageState(value, setPages, setContextPages, true), + (value) => updatePageState(value, setPagesToSpreadState, noOp, true), + (value) => updatePageState(value, noOp, updateCurrentPageIndex), + readingMode, + ); + + // for non-continuous reading modes, only the current, previous and next chapter are relevant + // every other chapter does not need to be rendered all the time since it's not affecting the + // visible content anyway + // the previous and next chapter are rendered so that going to the previous/next chapter feels smoother + // since the relevant pages are already rendered + const shouldRenderChapterViewer = + isContinuousReadingModeActive || isCurrentChapter || isPreviousChapter || isNextChapter; + if (!shouldRenderChapterViewer) { + return null; + } + + if (pagesResponse.error) { + if (shouldHideChapter) { + return null; + } + + return ( + + { + doFetchPages(); + }} + /> + + ); + } + + if (pagesResponse.loading || !arePagesFetched) { + if (shouldHideChapter) { + return null; + } + + return ( + + + + ); + } + + if (chapterId != null && !totalPages) { + if (shouldHideChapter) { + return null; + } + + return ( + + + + ); + } + + return ( + + + {((!isContinuousReadingModeActive && isCurrentChapter) || + (isContinuousReadingModeActive && (isInitialChapter || isLeadingChapter))) && ( + + )} + + {((!isContinuousReadingModeActive && isCurrentChapter) || + (isContinuousReadingModeActive && (isInitialChapter || isTrailingChapter))) && ( + + )} + + ); +}; + +export const ReaderChapterViewer = memo(BaseReaderChapterViewer); diff --git a/src/modules/reader/components/viewer/ReaderInfiniteScrollUpdateChapter.tsx b/src/modules/reader/components/viewer/ReaderInfiniteScrollUpdateChapter.tsx new file mode 100644 index 00000000..5961c871 --- /dev/null +++ b/src/modules/reader/components/viewer/ReaderInfiniteScrollUpdateChapter.tsx @@ -0,0 +1,71 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { memo } from 'react'; +import { withPropsFrom } from '@/modules/core/hoc/withPropsFrom.tsx'; +import { useReaderInfiniteScrollUpdateChapter } from '@/modules/reader/hooks/useReaderInfiniteScrollUpdateChapter.ts'; +import { ReadingDirection, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; +import { ReaderControls } from '@/modules/reader/services/ReaderControls.ts'; +import { ChapterIdInfo } from '@/modules/chapter/services/Chapters.ts'; + +const BaseReaderInfiniteScrollUpdateChapter = ({ + readingMode, + readingDirection, + chapterId, + previousChapterId, + nextChapterId, + isPreviousChapterVisible, + isCurrentChapter, + isNextChapterVisible, + firstImage, + lastImage, + openChapter, +}: { + readingMode: ReadingMode; + readingDirection: ReadingDirection; + chapterId: ChapterIdInfo['id']; + previousChapterId?: ChapterIdInfo['id']; + nextChapterId?: ChapterIdInfo['id']; + isPreviousChapterVisible: boolean; + isCurrentChapter: boolean; + isNextChapterVisible: boolean; + firstImage: HTMLElement | null; + lastImage: HTMLElement | null; + openChapter: ReturnType; +}) => { + useReaderInfiniteScrollUpdateChapter( + 'first', + chapterId, + previousChapterId, + isCurrentChapter, + isPreviousChapterVisible, + readingMode, + readingDirection, + openChapter, + firstImage, + ); + useReaderInfiniteScrollUpdateChapter( + 'last', + chapterId, + nextChapterId, + isCurrentChapter, + isNextChapterVisible, + readingMode, + readingDirection, + openChapter, + lastImage, + ); + + return null; +}; + +export const ReaderInfiniteScrollUpdateChapter = withPropsFrom( + memo(BaseReaderInfiniteScrollUpdateChapter), + [() => ({ openChapter: ReaderControls.useOpenChapter() })], + ['openChapter'], +); diff --git a/src/modules/reader/components/viewer/ReaderViewer.tsx b/src/modules/reader/components/viewer/ReaderViewer.tsx index ba7f5745..fa315771 100644 --- a/src/modules/reader/components/viewer/ReaderViewer.tsx +++ b/src/modules/reader/components/viewer/ReaderViewer.tsx @@ -10,33 +10,31 @@ import { ForwardedRef, forwardRef, memo, - useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, - useState, } from 'react'; import Stack from '@mui/material/Stack'; import { useTheme } from '@mui/material/styles'; +import { useLocation } from 'react-router-dom'; import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; import { IReaderSettings, PageInViewportType, - ReaderPageSpreadState, - ReaderTransitionPageMode, + ReaderOpenChapterLocationState, + ReaderResumeMode, + ReaderStateChapters, ReadingDirection, ReadingMode, TReaderScrollbarContext, } from '@/modules/reader/types/Reader.types.ts'; import { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx'; -import { getDoublePageModePages, isPageOfOutdatedPageLoadStates } from '@/modules/reader/utils/ReaderPager.utils.tsx'; import { useReaderScrollbarContext } from '@/modules/reader/contexts/ReaderScrollbarContext.tsx'; import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx'; import { ReaderControls } from '@/modules/reader/services/ReaderControls.ts'; import { - getPagerForReadingMode, isContinuousReadingMode, isContinuousVerticalReadingMode, shouldApplyReaderWidth, @@ -48,7 +46,6 @@ import { TReaderOverlayContext } from '@/modules/reader/types/ReaderOverlay.type import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts'; import { withPropsFrom } from '@/modules/core/hoc/withPropsFrom.tsx'; import { useReaderAutoScrollContext } from '@/modules/reader/contexts/ReaderAutoScrollContext.tsx'; -import { createUpdateReaderPageLoadState } from '@/modules/reader/utils/Reader.utils.ts'; import { TReaderTapZoneContext } from '@/modules/reader/types/TapZoneLayout.types.ts'; import { useReaderTapZoneContext } from '@/modules/reader/contexts/ReaderTapZoneContext.tsx'; import { useReaderAutoScroll } from '@/modules/reader/hooks/useReaderAutoScroll.ts'; @@ -57,8 +54,13 @@ import { useReaderHorizontalModeInvertXYScrolling } from '@/modules/reader/hooks import { useReaderHideCursorOnInactivity } from '@/modules/reader/hooks/useReaderHideCursorOnInactivity.ts'; import { useReaderScrollToStartOnPageChange } from '@/modules/reader/hooks/useReaderScrollToStartOnPageChange.ts'; import { useReaderHandlePageSelection } from '@/modules/reader/hooks/useReaderHandlePageSelection.ts'; -import { useReaderConvertPagesForReadingMode } from '@/modules/reader/hooks/useReaderConvertPagesForReadingMode.ts'; -import { ReaderTransitionPage } from '@/modules/reader/components/viewer/ReaderTransitionPage.tsx'; +import { useReaderStateChaptersContext } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; +import { ReaderChapterViewer } from '@/modules/reader/components/viewer/ReaderChapterViewer.tsx'; +import { + getReaderChapterViewerCurrentPageIndex, + getReaderChapterViewResumeMode, +} from '@/modules/reader/utils/Reader.utils.ts'; +import { noOp } from '@/lib/HelperFunctions.ts'; const READING_MODE_TO_IN_VIEWPORT_TYPE: Record = { [ReadingMode.SINGLE_PAGE]: PageInViewportType.X, @@ -75,18 +77,19 @@ const BaseReaderViewer = forwardRef( pageToScrollToIndex, setPageToScrollToIndex, pages, - setPages, totalPages, - pageUrls, - pageLoadStates, + setPages, setPageLoadStates, + setTotalPages, + setCurrentPageIndex, transitionPageMode, retryFailedPagesKeyPrefix, + setTransitionPageMode, readingMode, - shouldOffsetDoubleSpreads, readingDirection, readerWidth, pageScaleMode, + shouldOffsetDoubleSpreads, setScrollbarXSize, setScrollbarYSize, isVisible: isOverlayVisible, @@ -94,26 +97,42 @@ const BaseReaderViewer = forwardRef( updateCurrentPageIndex, showPreview, setShowPreview, + initialChapter, + currentChapter, + chapters, + visibleChapters, + setReaderStateChapters, + isCurrentChapterReady, }: Pick< ReaderStatePages, | 'currentPageIndex' | 'pageToScrollToIndex' | 'setPageToScrollToIndex' | 'pages' - | 'setPages' | 'totalPages' - | 'pageUrls' - | 'pageLoadStates' + | 'setPages' | 'setPageLoadStates' + | 'setTotalPages' + | 'setCurrentPageIndex' | 'transitionPageMode' | 'retryFailedPagesKeyPrefix' + | 'setTransitionPageMode' > & Pick< IReaderSettings, - 'readingMode' | 'shouldOffsetDoubleSpreads' | 'readingDirection' | 'readerWidth' | 'pageScaleMode' + 'readingMode' | 'readingDirection' | 'readerWidth' | 'pageScaleMode' | 'shouldOffsetDoubleSpreads' > & Pick & Pick & + Pick< + ReaderStateChapters, + | 'initialChapter' + | 'currentChapter' + | 'chapters' + | 'visibleChapters' + | 'setReaderStateChapters' + | 'isCurrentChapterReady' + > & TReaderTapZoneContext & { updateCurrentPageIndex: ReturnType; }, @@ -121,10 +140,14 @@ const BaseReaderViewer = forwardRef( ref: ForwardedRef, ) => { const { direction: themeDirection } = useTheme(); + const { resumeMode } = useLocation().state ?? { + resumeMode: ReaderResumeMode.START, + }; const scrollElementRef = useRef(null); useImperativeHandle(ref, () => scrollElementRef.current!); + const isContinuousVerticalReadingModeActive = isContinuousVerticalReadingMode(readingMode); const isContinuousReadingModeActive = isContinuousReadingMode(readingMode); const isDragging = useMouseDragScroll(scrollElementRef); @@ -140,81 +163,27 @@ const BaseReaderViewer = forwardRef( const handleClick = ReaderControls.useHandleClick(scrollElementRef.current); - const previousTotalPages = useRef(totalPages); - const [pagesToSpreadState, setPagesToSpreadState] = useState( - pageLoadStates.map(({ url }) => ({ url, isSpread: false })), - ); - - const resetPagesSpreadState = previousTotalPages.current !== totalPages; - if (resetPagesSpreadState) { - previousTotalPages.current = totalPages; - setPagesToSpreadState(pageLoadStates.map(({ url }) => ({ url, isSpread: false }))); - } - const imageRefs = useRef<(HTMLElement | null)[]>(pages.map(() => null)); - const actualPages = useMemo(() => { - const arePagesLoaded = !!totalPages; - if (!arePagesLoaded) { - return pages; - } - - if (readingMode === ReadingMode.DOUBLE_PAGE) { - return getDoublePageModePages( - pageUrls, - pagesToSpreadState, - shouldOffsetDoubleSpreads, - readingDirection, - ); - } - - return pages; - }, [pagesToSpreadState, readingMode, shouldOffsetDoubleSpreads, readingDirection, totalPages]); - - const Pager = useMemo(() => getPagerForReadingMode(readingMode), [readingMode]); const inViewportType = READING_MODE_TO_IN_VIEWPORT_TYPE[readingMode]; const isLtrReadingDirection = readingDirection === ReadingDirection.LTR; - - const onLoad = useMemo( + const initialChapterIndex = useMemo( + () => chapters.findIndex((chapter) => chapter.id === initialChapter?.id), + [initialChapter?.id], + ); + const chaptersToRender = useMemo( () => - createUpdateReaderPageLoadState( - actualPages, - pagesToSpreadState, - setPagesToSpreadState, - pageLoadStates, - setPageLoadStates, - readingMode, + chapters.slice( + Math.max(0, initialChapterIndex - visibleChapters.trailing), + Math.min(chapters.length, initialChapterIndex + visibleChapters.leading + 1), ), - // do not add "pagesToSpreadState" and "pageLoadStates" as a dependency, otherwise, every page gets re-rendered - // when they change which impacts the performance massively (depending on the total page count) - [actualPages, readingMode], + [chapters, initialChapterIndex, visibleChapters.trailing, visibleChapters.leading, chapters.length], + ); + const currentChapterIndex = useMemo( + () => chaptersToRender.findIndex((chapter) => chapter.id === currentChapter?.id), + [currentChapter, chaptersToRender], ); - const onError = useCallback((pageIndex: number, url: string) => { - setPageLoadStates((statePageLoadStates) => { - const pageLoadState = statePageLoadStates[pageIndex]; - - if (isPageOfOutdatedPageLoadStates(url, pageLoadState)) { - return statePageLoadStates; - } - - return statePageLoadStates.toSpliced(pageIndex, 1, { - ...pageLoadState, - loaded: false, - error: true, - }); - }); - }, []); - - useReaderConvertPagesForReadingMode( - currentPageIndex, - actualPages, - pageUrls, - setPages, - setPagesToSpreadState, - updateCurrentPageIndex, - readingMode, - ); useReaderHandlePageSelection( pageToScrollToIndex, currentPageIndex, @@ -245,6 +214,10 @@ const BaseReaderViewer = forwardRef( ); useReaderAutoScroll(isOverlayVisible, automaticScrolling); + if (!initialChapter || !currentChapter) { + throw new Error('ReaderViewer: illegal state - initialChapter and currentChapter should not be undefined'); + } + return ( - - - + {chaptersToRender.map((_, index) => { + // chapters are sorted by latest to oldest, thus, loop over it in reversed order + const chapterIndex = Math.max(0, chaptersToRender.length - index - 1); + const chapter = chaptersToRender[chapterIndex]; + + const previousChapter = + chaptersToRender[chapterIndex + 1] ?? + chapters[initialChapterIndex + visibleChapters.leading + 1]; + const nextChapter = + chaptersToRender[chapterIndex - 1] ?? + chapters[initialChapterIndex - visibleChapters.trailing - 1]; + + const isInitialChapter = chapter.id === initialChapter.id; + const isCurrentChapter = chapter.id === currentChapter.id; + const isPreviousChapter = chapter.id === chaptersToRender[currentChapterIndex + 1]?.id; + const isNextChapter = chapter.id === chaptersToRender[currentChapterIndex - 1]?.id; + const isLeadingChapter = initialChapter.sourceOrder > chapter.sourceOrder; + const isTrailingChapter = initialChapter.sourceOrder < chapter.sourceOrder; + + return ( + + ); + })} ); }, @@ -305,24 +336,26 @@ export const ReaderViewer = withPropsFrom( useReaderOverlayContext, () => ({ updateCurrentPageIndex: ReaderControls.useUpdateCurrentPageIndex() }), useReaderTapZoneContext, + useReaderStateChaptersContext, ], [ 'currentPageIndex', 'pageToScrollToIndex', 'setPageToScrollToIndex', 'pages', - 'setPages', 'totalPages', - 'pageUrls', - 'pageLoadStates', + 'setPages', 'setPageLoadStates', - 'transitionPageMode', + 'setTotalPages', + 'setCurrentPageIndex', 'retryFailedPagesKeyPrefix', + 'setTransitionPageMode', 'readingMode', - 'shouldOffsetDoubleSpreads', 'readingDirection', 'readerWidth', 'pageScaleMode', + 'shouldOffsetDoubleSpreads', + 'transitionPageMode', 'setScrollbarXSize', 'setScrollbarYSize', 'isVisible', @@ -330,5 +363,11 @@ export const ReaderViewer = withPropsFrom( 'updateCurrentPageIndex', 'showPreview', 'setShowPreview', + 'initialChapter', + 'currentChapter', + 'chapters', + 'visibleChapters', + 'setReaderStateChapters', + 'isCurrentChapterReady', ], ); diff --git a/src/modules/reader/components/viewer/pager/BasePager.tsx b/src/modules/reader/components/viewer/pager/BasePager.tsx index c3737ce5..1ac436bb 100644 --- a/src/modules/reader/components/viewer/pager/BasePager.tsx +++ b/src/modules/reader/components/viewer/pager/BasePager.tsx @@ -25,6 +25,9 @@ const BaseBasePager = ({ slots, readingMode, imagePreLoadAmount, + isCurrentChapter, + isPreviousChapter, + isNextChapter, }: Omit & Pick & { createPage: ( @@ -45,8 +48,11 @@ const BaseBasePager = ({ previousCurrentPageIndex.current, imagePreLoadAmount, readingMode, + isCurrentChapter, + isPreviousChapter, + isNextChapter, ), - [currentPageIndex, pages, imagePreLoadAmount, readingMode], + [currentPageIndex, pages, imagePreLoadAmount, readingMode, isCurrentChapter, isPreviousChapter, isNextChapter], ); useEffect(() => { previousCurrentPageIndex.current = currentPageIndex; diff --git a/src/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx b/src/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx index c4f1c988..bedb3e86 100644 --- a/src/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx +++ b/src/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx @@ -81,7 +81,7 @@ const BaseReaderDoublePagedPager = ({ onLoad, onError, shouldLoad, - shouldDisplay && isPrimaryPage, + shouldDisplay && isPrimaryPage && shouldLoad, currentPage.primary.index, totalPages, pageLoadStates[primary.index].error ? retryFailedPagesKeyPrefix : undefined, @@ -97,7 +97,7 @@ const BaseReaderDoublePagedPager = ({ onLoad, onError, shouldLoad, - shouldDisplay && isSecondaryPage, + shouldDisplay && isSecondaryPage && shouldLoad, currentSecondaryPageIndex, totalPages, pageLoadStates[secondary.index].error ? retryFailedPagesKeyPrefix : undefined, diff --git a/src/modules/reader/components/viewer/pager/ReaderPagedPager.tsx b/src/modules/reader/components/viewer/pager/ReaderPagedPager.tsx index 1da5323a..9719e112 100644 --- a/src/modules/reader/components/viewer/pager/ReaderPagedPager.tsx +++ b/src/modules/reader/components/viewer/pager/ReaderPagedPager.tsx @@ -33,7 +33,7 @@ const BaseReaderPagedPager = ({ onLoad, onError, shouldLoad, - shouldDisplay && currentPageIndex === page.primary.index, + shouldDisplay && shouldLoad && currentPageIndex === page.primary.index, currentPageIndex, totalPages, pageLoadStates[page.primary.index].error ? retryFailedPagesKeyPrefix : undefined, diff --git a/src/modules/reader/contexts/state/ReaderStateChaptersContext.tsx b/src/modules/reader/contexts/state/ReaderStateChaptersContext.tsx index dbf4f04d..582b688a 100644 --- a/src/modules/reader/contexts/state/ReaderStateChaptersContext.tsx +++ b/src/modules/reader/contexts/state/ReaderStateChaptersContext.tsx @@ -9,9 +9,22 @@ import { createContext, useContext } from 'react'; import { ReaderStateChapters } from '@/modules/reader/types/Reader.types.ts'; -export const ReaderStateChaptersContext = createContext({ +export const READER_STATE_CHAPTERS_DEFAULTS: Omit = { mangaChapters: [], chapters: [], + isCurrentChapterReady: false, + visibleChapters: { + leading: 0, + trailing: 0, + lastLeadingChapterSourceOrder: 99999, + lastTrailingChapterSourceOrder: -1, + scrollIntoView: false, + resumeMode: undefined, + }, +}; + +export const ReaderStateChaptersContext = createContext({ + ...READER_STATE_CHAPTERS_DEFAULTS, setReaderStateChapters: () => {}, }); diff --git a/src/modules/reader/contexts/state/ReaderStateChaptersContextProvider.tsx b/src/modules/reader/contexts/state/ReaderStateChaptersContextProvider.tsx index a485971e..54386b58 100644 --- a/src/modules/reader/contexts/state/ReaderStateChaptersContextProvider.tsx +++ b/src/modules/reader/contexts/state/ReaderStateChaptersContextProvider.tsx @@ -7,14 +7,15 @@ */ import { ReactNode, useMemo, useState } from 'react'; -import { ReaderStateChaptersContext } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; +import { + READER_STATE_CHAPTERS_DEFAULTS, + ReaderStateChaptersContext, +} from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; import { ReaderStateChapters } from '@/modules/reader/types/Reader.types.ts'; export const ReaderStateChaptersContextProvider = ({ children }: { children: ReactNode }) => { - const [state, setState] = useState>({ - mangaChapters: [], - chapters: [], - }); + const [state, setState] = + useState>(READER_STATE_CHAPTERS_DEFAULTS); const value = useMemo( () => ({ diff --git a/src/modules/reader/hooks/useReaderInfiniteScrollUpdateChapter.ts b/src/modules/reader/hooks/useReaderInfiniteScrollUpdateChapter.ts new file mode 100644 index 00000000..06a3755f --- /dev/null +++ b/src/modules/reader/hooks/useReaderInfiniteScrollUpdateChapter.ts @@ -0,0 +1,120 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { useLayoutEffect } from 'react'; +import { ReaderControls } from '@/modules/reader/services/ReaderControls.ts'; +import { ReadingDirection, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; +import { + isContinuousReadingMode, + isContinuousVerticalReadingMode, +} from '@/modules/reader/utils/ReaderSettings.utils.tsx'; +import { READING_DIRECTION_TO_THEME_DIRECTION } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; +import { getOptionForDirection } from '@/modules/theme/services/ThemeCreator.ts'; + +const OPEN_CHAPTER_INTERSECTION_RATIO = 0.1; + +const getScrollingDirection = ( + readingMode: ReadingMode, + readingDirection: ReadingDirection, + { bottom, left, right }: DOMRect, +): 'backward' | 'forward' => { + if (isContinuousVerticalReadingMode(readingMode)) { + const isScrollingBackward = bottom >= window.innerHeight; + if (isScrollingBackward) { + return 'backward'; + } + + return 'forward'; + } + + const themeDirectionOfReadingDirection = READING_DIRECTION_TO_THEME_DIRECTION[readingDirection]; + const endOfElement = getOptionForDirection(right, left, themeDirectionOfReadingDirection); + + const isScrollingBackward = + endOfElement >= getOptionForDirection(window.innerWidth, 0, themeDirectionOfReadingDirection); + if (isScrollingBackward) { + return 'backward'; + } + + return 'forward'; +}; + +export const useReaderInfiniteScrollUpdateChapter = ( + pageType: 'first' | 'last', + chapterId: number, + chapterToOpenId: number | undefined, + isCurrentChapter: boolean, + isChapterToOpenVisible: boolean, + readingMode: ReadingMode, + readingDirection: ReadingDirection, + openChapter: ReturnType, + image: HTMLElement | null, +) => { + useLayoutEffect(() => { + if (!image || !isContinuousReadingMode(readingMode) || chapterToOpenId === undefined) { + return () => {}; + } + + // gets immediately observed once on initial render + let isInitialObserve = true; + let wasNextChapterOpened = false; + const intersectionObserver = new IntersectionObserver( + (entries) => { + if (isInitialObserve) { + isInitialObserve = false; + return; + } + + if (wasNextChapterOpened) { + return; + } + + const entry = entries[entries.length - 1]; + + const wasPageScrolledOutOfView = entry.intersectionRatio < OPEN_CHAPTER_INTERSECTION_RATIO; + const isScrollingBackward = + getScrollingDirection(readingMode, readingDirection, entry.target.getBoundingClientRect()) === + 'backward'; + const isScrollingForward = !isScrollingBackward; + + // the first page only opens the previous chapter in case it hasn't been loaded yet, otherwise, the last + // page handles setting the correct chapter + const initialOpenPreviousChapter = + pageType === 'first' && isScrollingBackward && !isChapterToOpenVisible; + const openPreviousChapter = pageType === 'last' && !isCurrentChapter && isScrollingForward; + const openNextChapter = pageType === 'last' && isScrollingForward && wasPageScrolledOutOfView; + + const openChapterToOpen = initialOpenPreviousChapter || openNextChapter; + if (openChapterToOpen) { + openChapter(chapterToOpenId, !isChapterToOpenVisible, false); + wasNextChapterOpened = true; + return; + } + + if (openPreviousChapter) { + openChapter(chapterId, false, false); + wasNextChapterOpened = true; + } + }, + { threshold: [OPEN_CHAPTER_INTERSECTION_RATIO] }, + ); + intersectionObserver.observe(image); + + return () => intersectionObserver.unobserve(image); + }, [ + pageType, + chapterId, + chapterToOpenId, + isCurrentChapter, + isChapterToOpenVisible, + readingMode, + readingDirection, + openChapter, + image, + ]); +}; diff --git a/src/modules/reader/hooks/useReaderResetStates.ts b/src/modules/reader/hooks/useReaderResetStates.ts index 46fa6c32..a8be49bb 100644 --- a/src/modules/reader/hooks/useReaderResetStates.ts +++ b/src/modules/reader/hooks/useReaderResetStates.ts @@ -18,6 +18,7 @@ import { import { DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG } from '@/modules/reader/services/ReaderSettingsMetadata.ts'; import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts'; import { TReaderOverlayContext } from '@/modules/reader/types/ReaderOverlay.types.ts'; +import { READER_STATE_CHAPTERS_DEFAULTS } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; export const useReaderResetStates = ( setManga: TReaderStateMangaContext['setManga'], @@ -36,10 +37,7 @@ export const useReaderResetStates = ( useEffect( () => () => { setManga(undefined); - setReaderStateChapters({ - mangaChapters: [], - chapters: [], - }); + setReaderStateChapters(READER_STATE_CHAPTERS_DEFAULTS); setCurrentPageIndex(0); setPageToScrollToIndex(null); diff --git a/src/modules/reader/hooks/useReaderSetChaptersState.ts b/src/modules/reader/hooks/useReaderSetChaptersState.ts index b67b9504..fd8cbcd0 100644 --- a/src/modules/reader/hooks/useReaderSetChaptersState.ts +++ b/src/modules/reader/hooks/useReaderSetChaptersState.ts @@ -12,10 +12,12 @@ import { DirectionOffset } from '@/Base.types.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { GetChaptersReaderQuery } from '@/lib/graphql/generated/graphql.ts'; import { IReaderSettings, ReaderStateChapters } from '@/modules/reader/types/Reader.types.ts'; +import { READER_STATE_CHAPTERS_DEFAULTS } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; export const useReaderSetChaptersState = ( chaptersResponse: ReturnType>, chapterSourceOrder: number, + initialChapter: ReaderStateChapters['initialChapter'], chapterForDuplicatesHandling: ReaderStateChapters['chapterForDuplicatesHandling'], setReaderStateChapters: ReaderStateChapters['setReaderStateChapters'], shouldSkipDupChapters: IReaderSettings['shouldSkipDupChapters'], @@ -25,32 +27,55 @@ export const useReaderSetChaptersState = ( const newCurrentChapter = newMangaChapters ? (newMangaChapters[newMangaChapters.length - chapterSourceOrder] ?? null) : undefined; + const newInitialChapter = initialChapter ?? newCurrentChapter; const newChapterForDuplicatesHandling = chapterForDuplicatesHandling ?? newCurrentChapter; - setReaderStateChapters({ - mangaChapters: newMangaChapters ?? [], - chapters: - newChapterForDuplicatesHandling && newMangaChapters - ? Chapters.removeDuplicates(newChapterForDuplicatesHandling, newMangaChapters) - : [], - chapterForDuplicatesHandling: newChapterForDuplicatesHandling, - currentChapter: newCurrentChapter, - nextChapter: - newMangaChapters && - newCurrentChapter && - Chapters.getNextChapter(newCurrentChapter, newMangaChapters, { - offset: DirectionOffset.NEXT, - skipDupe: shouldSkipDupChapters, - skipDupeChapter: newChapterForDuplicatesHandling, - }), - previousChapter: - newMangaChapters && - newCurrentChapter && - Chapters.getNextChapter(newCurrentChapter, newMangaChapters, { - offset: DirectionOffset.PREVIOUS, - skipDupe: shouldSkipDupChapters, - skipDupeChapter: newChapterForDuplicatesHandling, - }), + const nextChapter = + newMangaChapters && + newCurrentChapter && + Chapters.getNextChapter(newCurrentChapter, newMangaChapters, { + offset: DirectionOffset.NEXT, + skipDupe: shouldSkipDupChapters, + skipDupeChapter: newChapterForDuplicatesHandling, + }); + const previousChapter = + newMangaChapters && + newCurrentChapter && + Chapters.getNextChapter(newCurrentChapter, newMangaChapters, { + offset: DirectionOffset.PREVIOUS, + skipDupe: shouldSkipDupChapters, + skipDupeChapter: newChapterForDuplicatesHandling, + }); + + const hasInitialChapterChanged = newInitialChapter != null && newInitialChapter.id !== initialChapter?.id; + + setReaderStateChapters((prevState) => { + const hasCurrentChapterChanged = newCurrentChapter?.id !== prevState.currentChapter?.id; + return { + ...prevState, + mangaChapters: newMangaChapters ?? [], + chapters: + newChapterForDuplicatesHandling && newMangaChapters + ? Chapters.removeDuplicates(newChapterForDuplicatesHandling, newMangaChapters) + : [], + initialChapter: newInitialChapter, + chapterForDuplicatesHandling: newChapterForDuplicatesHandling, + currentChapter: newCurrentChapter, + nextChapter, + previousChapter, + isCurrentChapterReady: hasCurrentChapterChanged ? false : prevState.isCurrentChapterReady, + visibleChapters: hasInitialChapterChanged + ? { + ...READER_STATE_CHAPTERS_DEFAULTS.visibleChapters, + lastLeadingChapterSourceOrder: + newInitialChapter?.sourceOrder ?? + READER_STATE_CHAPTERS_DEFAULTS.visibleChapters.lastLeadingChapterSourceOrder, + lastTrailingChapterSourceOrder: + newInitialChapter?.sourceOrder ?? + READER_STATE_CHAPTERS_DEFAULTS.visibleChapters.lastTrailingChapterSourceOrder, + } + : prevState.visibleChapters, + }; }); - }, [chaptersResponse.data?.chapters.nodes, chapterSourceOrder, shouldSkipDupChapters]); + }, [chaptersResponse.data?.chapters.nodes, chapterSourceOrder, shouldSkipDupChapters, initialChapter]); }; diff --git a/src/modules/reader/hooks/useReaderSetPagesState.ts b/src/modules/reader/hooks/useReaderSetPagesState.ts index 9009390e..1590f6a7 100644 --- a/src/modules/reader/hooks/useReaderSetPagesState.ts +++ b/src/modules/reader/hooks/useReaderSetPagesState.ts @@ -6,62 +6,78 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useEffect } from 'react'; +import { useLayoutEffect, useRef } from 'react'; import { getInitialReaderPageIndex } from '@/modules/reader/utils/Reader.utils.ts'; import { createPagesData } from '@/modules/reader/utils/ReaderPager.utils.tsx'; import { + ReaderPageSpreadState, ReaderResumeMode, ReaderStateChapters, ReaderTransitionPageMode, } from '@/modules/reader/types/Reader.types.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts'; -import { READER_STATE_PAGES_DEFAULTS } from '@/modules/reader/constants/ReaderContext.constants.ts'; +import { TChapterReader } from '@/modules/chapter/Chapter.types.ts'; export const useReaderSetPagesState = ( + isCurrentChapter: boolean, pagesResponse: ReturnType[1], resumeMode: ReaderResumeMode, - currentChapter: ReaderStateChapters['currentChapter'], + lastPageRead: TChapterReader['lastPageRead'] | undefined, + pages: ReaderStatePages['pages'], + pageLoadStates: ReaderStatePages['pageLoadStates'], + pagesToSpreadState: ReaderPageSpreadState[], + arePagesFetched: boolean, setArePagesFetched: (fetched: boolean) => void, + setReaderStateChapters: ReaderStateChapters['setReaderStateChapters'], setTotalPages: ReaderStatePages['setTotalPages'], setPages: ReaderStatePages['setPages'], setPageUrls: ReaderStatePages['setPageUrls'], setPageLoadStates: ReaderStatePages['setPageLoadStates'], + setPagesToSpreadState: (state: ReaderPageSpreadState[]) => void, setCurrentPageIndex: ReaderStatePages['setCurrentPageIndex'], setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'], setTransitionPageMode: ReaderStatePages['setTransitionPageMode'], ) => { - useEffect(() => { + const previousPageData = useRef(); + + useLayoutEffect(() => { const pagesPayload = pagesResponse.data?.fetchChapterPages; - if (pagesPayload) { - const { pages } = pagesPayload; - const newPages = pages.length ? pages : ['']; + if (!pagesPayload) { + return; + } - const initialReaderPageIndex = getInitialReaderPageIndex( - resumeMode, - currentChapter?.lastPageRead ?? 0, - newPages.length - 1, - ); + const { pages: pagesFromResponse } = pagesPayload; + const newPages = pages.length ? pagesFromResponse : ['']; + const initialReaderPageIndex = getInitialReaderPageIndex(resumeMode, lastPageRead ?? 0, newPages.length - 1); + const didPagesChange = previousPageData.current !== pagesPayload?.pages; + if (didPagesChange) { + previousPageData.current = pagesPayload.pages; const newPageData = createPagesData(newPages); setArePagesFetched(true); - setTotalPages(pagesPayload.chapter.pageCount); setPages(newPageData); setPageUrls(newPages); setPageLoadStates(newPageData.map(({ primary: { url } }) => ({ url, loaded: false }))); + setPagesToSpreadState(newPageData.map(({ primary: { url } }) => ({ url, isSpread: false }))); setCurrentPageIndex(initialReaderPageIndex); setPageToScrollToIndex(initialReaderPageIndex); } else { - setArePagesFetched(false); - setCurrentPageIndex(READER_STATE_PAGES_DEFAULTS.currentPageIndex); - setPageToScrollToIndex(READER_STATE_PAGES_DEFAULTS.pageToScrollToIndex); - setTotalPages(READER_STATE_PAGES_DEFAULTS.totalPages); - setPages(READER_STATE_PAGES_DEFAULTS.pages); - setPageUrls(READER_STATE_PAGES_DEFAULTS.pageUrls); - setPageLoadStates(READER_STATE_PAGES_DEFAULTS.pageLoadStates); + setPages(pages); + setPageLoadStates(pageLoadStates); + setPagesToSpreadState(pagesToSpreadState); } + setTotalPages(pagesPayload.pages.length); + setPageUrls(newPages); + setCurrentPageIndex(initialReaderPageIndex); + setPageToScrollToIndex(initialReaderPageIndex); + setReaderStateChapters((prevState) => ({ + ...prevState, + isCurrentChapterReady: arePagesFetched || didPagesChange, + })); + setTransitionPageMode(ReaderTransitionPageMode.NONE); - }, [pagesResponse.data?.fetchChapterPages?.pages]); + }, [pagesResponse.data?.fetchChapterPages?.pages, isCurrentChapter]); }; diff --git a/src/modules/reader/screens/Reader.tsx b/src/modules/reader/screens/Reader.tsx index 498454cb..cd369633 100644 --- a/src/modules/reader/screens/Reader.tsx +++ b/src/modules/reader/screens/Reader.tsx @@ -7,7 +7,7 @@ */ import Box from '@mui/material/Box'; -import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useDefaultReaderSettings } from '@/modules/reader/services/ReaderSettingsMetadata.ts'; @@ -33,7 +33,7 @@ import { ReaderHotkeys } from '@/modules/reader/components/ReaderHotkeys.tsx'; import { IReaderSettings, IReaderSettingsWithDefaultFlag, - ReaderResumeMode, + ReaderOpenChapterLocationState, ReaderStateChapters, TReaderAutoScrollContext, TReaderStateMangaContext, @@ -49,7 +49,6 @@ import { useReaderAutoScrollContext } from '@/modules/reader/contexts/ReaderAuto import { TReaderTapZoneContext } from '@/modules/reader/types/TapZoneLayout.types.ts'; import { useReaderTapZoneContext } from '@/modules/reader/contexts/ReaderTapZoneContext.tsx'; import { useReaderResetStates } from '@/modules/reader/hooks/useReaderResetStates.ts'; -import { useReaderSetPagesState } from '@/modules/reader/hooks/useReaderSetPagesState.ts'; import { useReaderSetSettingsState } from '@/modules/reader/hooks/useReaderSetSettingsState.ts'; import { useReaderShowSettingPreviewOnChange } from '@/modules/reader/hooks/useReaderShowSettingPreviewOnChange.ts'; import { useReaderSetChaptersState } from '@/modules/reader/hooks/useReaderSetChaptersState.ts'; @@ -70,9 +69,9 @@ const BaseReader = ({ shouldShowReadingModePreview, shouldShowTapZoneLayoutPreview, setSettings, + initialChapter, chapterForDuplicatesHandling, currentChapter, - mangaChapters, setReaderStateChapters, firstPageUrl, totalPages, @@ -94,7 +93,10 @@ const BaseReader = ({ 'shouldSkipDupChapters' | 'backgroundColor' | 'shouldShowReadingModePreview' | 'shouldShowTapZoneLayoutPreview' > & Pick & - Pick & + Pick< + ReaderStateChapters, + 'initialChapter' | 'chapterForDuplicatesHandling' | 'currentChapter' | 'setReaderStateChapters' + > & Pick< ReaderStatePages, | 'totalPages' @@ -111,9 +113,7 @@ const BaseReader = ({ cancelAutoScroll: TReaderAutoScrollContext['cancel']; }) => { const { t } = useTranslation(); - const { resumeMode } = useLocation<{ - resumeMode: ReaderResumeMode; - }>().state ?? { resumeMode: ReaderResumeMode.START }; + const { updateInitialChapter } = useLocation().state ?? {}; const scrollElementRef = useRef(null); @@ -128,20 +128,6 @@ const BaseReader = ({ const mangaResponse = requestManager.useGetManga(GET_MANGA_READER, mangaId); const chaptersResponse = requestManager.useGetMangaChapters(GET_CHAPTERS_READER, mangaId); - const [arePagesFetched, setArePagesFetched] = useState(false); - const [fetchPages, pagesResponse] = requestManager.useGetChapterPagesFetch(-1); - - const doFetchPages = useCallback(() => { - if (!currentChapter) { - return; - } - - setArePagesFetched(false); - - fetchPages({ variables: { input: { chapterId: currentChapter.id } } }).catch( - defaultPromiseErrorHandler('Reader::fetchPages'), - ); - }, [fetchPages, currentChapter?.id]); const { metadata: defaultSettingsMetadata, @@ -149,21 +135,13 @@ const BaseReader = ({ request: defaultSettingsResponse, } = useDefaultReaderSettings(); - const doesChapterExist = - !chaptersResponse.loading && - !chaptersResponse.error && - chapterSourceOrder >= 0 && - chapterSourceOrder <= mangaChapters.length; - const isLoading = currentChapter === undefined || !areSettingsSet || mangaResponse.loading || chaptersResponse.loading || - defaultSettingsResponse.loading || - pagesResponse.loading || - (doesChapterExist && !arePagesFetched && !chaptersResponse.error && !pagesResponse.error); - const error = mangaResponse.error ?? chaptersResponse.error ?? defaultSettingsResponse.error ?? pagesResponse.error; + defaultSettingsResponse.loading; + const error = mangaResponse.error ?? chaptersResponse.error ?? defaultSettingsResponse.error; useLayoutEffect(() => { if (!manga || !currentChapter) { @@ -174,11 +152,6 @@ const BaseReader = ({ setTitle(`${manga.title}: ${currentChapter.name}`); }, [t, mangaId, chapterSourceOrder, manga, currentChapter]); - useEffect(() => { - doFetchPages(); - return () => setArePagesFetched(false); - }, [currentChapter?.id]); - useEffect(() => { setManga(mangaResponse.data?.manga); }, [mangaResponse.data?.manga]); @@ -197,19 +170,6 @@ const BaseReader = ({ setSettings, cancelAutoScroll, ); - useReaderSetPagesState( - pagesResponse, - resumeMode, - currentChapter, - setArePagesFetched, - setTotalPages, - setPages, - setPageUrls, - setPageLoadStates, - setCurrentPageIndex, - setPageToScrollToIndex, - setTransitionPageMode, - ); useReaderSetSettingsState( mangaResponse, defaultSettingsResponse, @@ -232,6 +192,7 @@ const BaseReader = ({ useReaderSetChaptersState( chaptersResponse, chapterSourceOrder, + updateInitialChapter ? undefined : initialChapter, chapterForDuplicatesHandling, setReaderStateChapters, shouldSkipDupChapters, @@ -283,10 +244,6 @@ const BaseReader = ({ if (chaptersResponse.error) { chaptersResponse.refetch().catch(defaultPromiseErrorHandler('Reader::refetchChapters')); } - - if (pagesResponse.error) { - doFetchPages(); - } }} /> ); @@ -311,10 +268,6 @@ const BaseReader = ({ return ; } - if (currentChapter && !currentChapter?.pageCount) { - return ; - } - if (!manga || !currentChapter) { return null; } @@ -395,9 +348,9 @@ export const Reader = withPropsFrom( 'shouldShowReadingModePreview', 'shouldShowTapZoneLayoutPreview', 'setSettings', + 'initialChapter', 'chapterForDuplicatesHandling', 'currentChapter', - 'mangaChapters', 'setReaderStateChapters', 'firstPageUrl', 'totalPages', diff --git a/src/modules/reader/services/ReaderControls.ts b/src/modules/reader/services/ReaderControls.ts index 828bd8a1..e70c6be2 100644 --- a/src/modules/reader/services/ReaderControls.ts +++ b/src/modules/reader/services/ReaderControls.ts @@ -169,7 +169,11 @@ export class ReaderControls { } } - static useOpenChapter(): (offset: 'previous' | 'next' | ChapterIdInfo['id'], doTransitionCheck?: boolean) => void { + static useOpenChapter(): ( + offset: 'previous' | 'next' | ChapterIdInfo['id'], + doTransitionCheck?: boolean, + scrollIntoView?: boolean, + ) => void { const { t } = useTranslation(); const { readingMode, shouldInformAboutMissingChapter, shouldInformAboutScanlatorChange } = ReaderService.useSettings(); @@ -179,7 +183,7 @@ export class ReaderControls { const openChapter = ReaderService.useNavigateToChapter(); return useCallback( - (offset, doTransitionCheck = true) => { + (offset, doTransitionCheck = true, scrollIntoView = true) => { if (!currentChapter) { return; } @@ -227,7 +231,12 @@ export class ReaderControls { } setReaderStateChapters((prevState) => - updateReaderStateVisibleChapters(isPreviousChapter, prevState, chapterToOpen.sourceOrder), + updateReaderStateVisibleChapters( + isPreviousChapter, + prevState, + chapterToOpen.sourceOrder, + scrollIntoView, + ), ); openChapter( diff --git a/src/modules/reader/types/Reader.types.ts b/src/modules/reader/types/Reader.types.ts index 5c5f123f..68b1ac91 100644 --- a/src/modules/reader/types/Reader.types.ts +++ b/src/modules/reader/types/Reader.types.ts @@ -189,9 +189,22 @@ export interface ReaderStateChapters { */ chapters: TChapterReader[]; chapterForDuplicatesHandling?: TChapterReader | null; + initialChapter?: TChapterReader | null; currentChapter?: TChapterReader | null; nextChapter?: TChapterReader; previousChapter?: TChapterReader; + isCurrentChapterReady: boolean; + /** + * Based from the initial chapter index + */ + visibleChapters: { + leading: number; + trailing: number; + lastLeadingChapterSourceOrder: number; + lastTrailingChapterSourceOrder: number; + scrollIntoView: boolean; + resumeMode?: ReaderResumeMode; + }; setReaderStateChapters: React.Dispatch>>; } @@ -246,9 +259,12 @@ export interface ReaderPagerProps | 'pageLoadStates' | 'retryFailedPagesKeyPrefix' > { - imageRefs: MutableRefObject<(HTMLElement | null)[]>; onLoad?: (pagesIndex: number, url: string, isPrimary?: boolean) => void; onError?: (pageIndex: number, url: string) => void; + imageRefs: MutableRefObject<(HTMLElement | null)[]>; + isCurrentChapter: boolean; + isPreviousChapter: boolean; + isNextChapter: boolean; } export enum PageInViewportType { @@ -269,6 +285,11 @@ export enum ReaderResumeMode { LAST_READ, } +export interface ReaderOpenChapterLocationState { + resumeMode: ReaderResumeMode; + updateInitialChapter?: boolean; +} + export type TReaderScrollbarContext = { scrollbarXSize: number; setScrollbarXSize: (size: number) => void; diff --git a/src/modules/reader/utils/Reader.utils.ts b/src/modules/reader/utils/Reader.utils.ts index 04301218..d89e58f0 100644 --- a/src/modules/reader/utils/Reader.utils.ts +++ b/src/modules/reader/utils/Reader.utils.ts @@ -6,7 +6,12 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { ReaderPageSpreadState, ReaderResumeMode, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; +import { + ReaderPageSpreadState, + ReaderResumeMode, + ReaderStateChapters, + ReadingMode, +} from '@/modules/reader/types/Reader.types.ts'; import { UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts'; import { TChapterReader } from '@/modules/chapter/Chapter.types.ts'; import { ChapterIdInfo, Chapters } from '@/modules/chapter/services/Chapters.ts'; @@ -180,3 +185,112 @@ export const getReaderOpenChapterResumeMode = ( return ReaderResumeMode.START; }; + +export const createHandleReaderPageLoadError = + (setPageLoadStates: ReaderStatePages['setPageLoadStates']) => (pageIndex: number, url: string) => { + setPageLoadStates((statePageLoadStates) => { + const pageLoadState = statePageLoadStates[pageIndex]; + + if (isPageOfOutdatedPageLoadStates(url, pageLoadState)) { + return statePageLoadStates; + } + + return statePageLoadStates.toSpliced(pageIndex, 1, { + ...pageLoadState, + loaded: false, + error: true, + }); + }); + }; + +export const updateReaderStateVisibleChapters = ( + isPreviousChapter: boolean, + state: Omit, + chapterToOpenSourceOrder: TChapterReader['sourceOrder'], + scrollIntoView: boolean, +): Omit => { + const { leading, trailing, lastLeadingChapterSourceOrder, lastTrailingChapterSourceOrder } = state.visibleChapters; + + const isNewLeadingChapter = isPreviousChapter && chapterToOpenSourceOrder < lastLeadingChapterSourceOrder; + const isNewTrailingChapter = !isPreviousChapter && chapterToOpenSourceOrder > lastTrailingChapterSourceOrder; + + return { + ...state, + visibleChapters: { + ...state.visibleChapters, + leading: leading + Number(isNewLeadingChapter), + trailing: trailing + Number(isNewTrailingChapter), + lastLeadingChapterSourceOrder: isNewLeadingChapter + ? chapterToOpenSourceOrder + : lastLeadingChapterSourceOrder, + lastTrailingChapterSourceOrder: isNewTrailingChapter + ? chapterToOpenSourceOrder + : lastTrailingChapterSourceOrder, + scrollIntoView, + resumeMode: isPreviousChapter ? ReaderResumeMode.END : ReaderResumeMode.START, + }, + }; +}; + +export const getReaderChapterViewerCurrentPageIndex = ( + currentPageIndex: number, + chapter: TChapterReader, + currentChapter: TChapterReader, + isCurrentChapter: boolean, + isCurrentChapterReady: boolean, + isLeadingChapter: boolean, + isTrailingChapter: boolean, + visibleChapters: ReaderStateChapters['visibleChapters'], +): number => { + if (isCurrentChapter) { + if (isCurrentChapterReady) { + return coerceIn(currentPageIndex, 0, chapter.pageCount - 1); + } + + if (visibleChapters.scrollIntoView && visibleChapters.resumeMode !== undefined) { + return getInitialReaderPageIndex(visibleChapters.resumeMode, 0, chapter.pageCount - 1); + } + + if (isTrailingChapter) { + return Math.max(0, chapter.pageCount - 1); + } + + if (isLeadingChapter) { + return 0; + } + } + + const isPreviousChapter = chapter.sourceOrder < currentChapter.sourceOrder; + if (isPreviousChapter) { + return Math.max(chapter.pageCount - 1, 0); + } + + return 0; +}; + +export const getReaderChapterViewResumeMode = ( + isCurrentChapter: boolean, + isInitialChapter: boolean, + isLeadingChapter: boolean, + isTrailingChapter: boolean, + forcedResumeMode: ReaderResumeMode | undefined, + resumeMode: ReaderResumeMode = ReaderResumeMode.START, +): ReaderResumeMode => { + if (isCurrentChapter && forcedResumeMode !== undefined) { + return forcedResumeMode; + } + + if (isInitialChapter) { + return resumeMode; + } + + if (isLeadingChapter) { + return ReaderResumeMode.END; + } + + if (isTrailingChapter) { + return ReaderResumeMode.START; + } + + return ReaderResumeMode.START; +}; diff --git a/src/modules/reader/utils/ReaderPager.utils.tsx b/src/modules/reader/utils/ReaderPager.utils.tsx index c8f44a2f..65d83b39 100644 --- a/src/modules/reader/utils/ReaderPager.utils.tsx +++ b/src/modules/reader/utils/ReaderPager.utils.tsx @@ -505,6 +505,29 @@ export const isSpreadPage = (image: HTMLImageElement): boolean => { return aspectRatio < 1; }; +const MIN_PREVIOUS_NEXT_CHAPTER_IMAGE_LOAD_AMOUNT = 0; +const MAX_PREVIOUS_NEXT_CHAPTER_IMAGE_LOAD_AMOUNT = 1; +const getImagePreLoadAmount = ( + isCurrentChapter: boolean, + isPreviousChapter: boolean, + isNextChapter: boolean, + imagePreLoadAmount: number, +): number => { + if (isCurrentChapter) { + return imagePreLoadAmount; + } + + if (isPreviousChapter || isNextChapter) { + return coerceIn( + MAX_PREVIOUS_NEXT_CHAPTER_IMAGE_LOAD_AMOUNT, + MIN_PREVIOUS_NEXT_CHAPTER_IMAGE_LOAD_AMOUNT, + imagePreLoadAmount, + ); + } + + return 0; +}; + const PREVIOUS_IMAGE_LOAD_AMOUNT = 2; export const getPageIndexesToLoad = ( currentPageIndex: number, @@ -512,20 +535,33 @@ export const getPageIndexesToLoad = ( previousCurrentPageIndex: number, imagePreLoadAmount: number, readingMode: ReadingMode, + isCurrentChapter: boolean, + isPreviousChapter: boolean, + isNextChapter: boolean, ): number[] => { - const currentPagesIndex = getPage(currentPageIndex, pages).pagesIndex; + if (!isCurrentChapter && !isPreviousChapter && !isNextChapter) { + return []; + } - const directionInvert = previousCurrentPageIndex <= currentPageIndex ? 1 : -1; + const currentPagesIndex = getPage(currentPageIndex, pages).pagesIndex; + const finalImagePreLoadAmount = getImagePreLoadAmount( + isCurrentChapter, + isPreviousChapter, + isNextChapter, + imagePreLoadAmount, + ); + + const directionInvert = previousCurrentPageIndex <= currentPageIndex && !isPreviousChapter ? 1 : -1; // load at most PREVIOUS_IMAGE_LOAD_AMOUNT of the previous pages to ensure that you do not have to wait too long // when going back to the previous pages const startPagesIndexTrailingIncluded = Math.max( 0, - currentPagesIndex - Math.min(PREVIOUS_IMAGE_LOAD_AMOUNT, imagePreLoadAmount) * directionInvert, + currentPagesIndex - Math.min(PREVIOUS_IMAGE_LOAD_AMOUNT, finalImagePreLoadAmount) * directionInvert, ); // do not load previous pages for continuous pagers to prevent layout shifts due to leading pages getting loaded const startPagesIndex = !isContinuousReadingMode(readingMode) ? startPagesIndexTrailingIncluded : currentPageIndex; - const endPagesIndex = currentPagesIndex + imagePreLoadAmount * directionInvert; - const pagesToRenderLength = Math.abs(endPagesIndex - startPagesIndex) + 1; + const endPagesIndex = currentPagesIndex + finalImagePreLoadAmount * directionInvert; + const pagesToRenderLength = Math.max(1, Math.abs(endPagesIndex - startPagesIndex)); return Array(pagesToRenderLength) .fill(1) diff --git a/src/modules/reader/utils/ReaderProgressBar.utils.tsx b/src/modules/reader/utils/ReaderProgressBar.utils.tsx index db01c65e..c41e922d 100644 --- a/src/modules/reader/utils/ReaderProgressBar.utils.tsx +++ b/src/modules/reader/utils/ReaderProgressBar.utils.tsx @@ -9,12 +9,13 @@ import { ReaderProgressBarProps, TReaderProgressCurrentPage } from '@/modules/reader/types/ReaderProgressBar.types.ts'; import { getOptionForDirection as getOptionForDirectionImpl } from '@/modules/theme/services/ThemeCreator.ts'; import { ProgressBarPosition } from '@/modules/reader/types/Reader.types.ts'; +import { coerceIn } from '@/lib/HelperFunctions.ts'; export const getPage = (pageIndex: number, pages: ReaderProgressBarProps['pages']): TReaderProgressCurrentPage => { const pagesIndex = pages.findIndex(({ primary, secondary }) => [primary.index, secondary?.index].includes(pageIndex), ); - const page = pages[pagesIndex]; + const page = pages[coerceIn(pagesIndex, 0, pages.length - 1)]; return { ...page, pagesIndex,