Add infinite scroll to reader

This commit is contained in:
schroda
2025-01-28 02:16:00 +01:00
parent 4789a74785
commit 4a9d2903b8
20 changed files with 1068 additions and 238 deletions

View File

@@ -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)}

View File

@@ -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,
};
}
}

View File

@@ -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<ReaderStateChapters, 'setReaderStateChapters'> & {
updateCurrentPageIndex: ReturnType<typeof ReaderControls.useUpdateCurrentPageIndex>;
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<ReaderStatePages['totalPages']>(
READER_STATE_PAGES_DEFAULTS.totalPages,
);
const [pageUrls, setPageUrls] = useState<ReaderStatePages['pageUrls']>(READER_STATE_PAGES_DEFAULTS.pageUrls);
const [pages, setPages] = useState<ReaderStatePages['pages']>(READER_STATE_PAGES_DEFAULTS.pages);
const [pageLoadStates, setPageLoadStates] = useState<ReaderStatePages['pageLoadStates']>(
READER_STATE_PAGES_DEFAULTS.pageLoadStates,
);
const [pagesToSpreadState, setPagesToSpreadState] = useState<ReaderPageSpreadState[]>(
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 = <T,>(
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 (
<Box
sx={{
minHeight: '100%',
minWidth: '100%',
display: 'grid',
placeItems: 'center',
position: 'relative',
}}
>
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(pagesResponse.error)}
retry={() => {
doFetchPages();
}}
/>
</Box>
);
}
if (pagesResponse.loading || !arePagesFetched) {
if (shouldHideChapter) {
return null;
}
return (
<Box
sx={{
minHeight: '100%',
minWidth: '100%',
display: 'grid',
placeItems: 'center',
}}
>
<LoadingPlaceholder />
</Box>
);
}
if (chapterId != null && !totalPages) {
if (shouldHideChapter) {
return null;
}
return (
<Box sx={{ minWidth: '100%', minHeight: '100%', position: 'relative' }}>
<EmptyViewAbsoluteCentered message={t('reader.error.label.no_pages_found')} retry={doFetchPages} />
</Box>
);
}
return (
<Stack
sx={{
width: '100%',
minWidth: 'fit-content',
height: '100%',
minHeight: 'fit-content',
margin: 'auto',
flexWrap: 'nowrap',
...applyStyles(shouldHideChapter, {
maxWidth: 0,
maxHeight: 0,
minWidth: 'unset',
minHeight: 'unset',
overflow: 'hidden',
}),
...applyStyles(
isContinuousVerticalReadingMode(readingMode) && shouldApplyReaderWidth(readerWidth, pageScaleMode),
{ alignItems: 'center' },
),
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
...applyStyles(themeDirection === 'ltr', {
flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse',
}),
...applyStyles(themeDirection === 'rtl', {
flexDirection: isLtrReadingDirection ? 'row-reverse' : 'row',
}),
}),
}}
>
<ReaderInfiniteScrollUpdateChapter
readingMode={readingMode}
readingDirection={readingDirection}
chapterId={chapterId}
previousChapterId={previousChapterId}
nextChapterId={nextChapterId}
isCurrentChapter={isCurrentChapter}
isPreviousChapterVisible={isPreviousChapterVisible}
isNextChapterVisible={isNextChapterVisible}
firstImage={imageRefs.current[0]}
lastImage={imageRefs.current[imageRefs.current.length - 1]}
/>
{((!isContinuousReadingModeActive && isCurrentChapter) ||
(isContinuousReadingModeActive && (isInitialChapter || isLeadingChapter))) && (
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.PREVIOUS} />
)}
<Pager
totalPages={totalPages}
currentPageIndex={currentPageIndex}
pages={actualPages}
transitionPageMode={transitionPageMode}
pageLoadStates={pageLoadStates}
retryFailedPagesKeyPrefix={retryFailedPagesKeyPrefix}
imageRefs={imageRefs}
onLoad={onLoad}
onError={onError}
isCurrentChapter={isCurrentChapter}
isPreviousChapter={isPreviousChapter}
isNextChapter={isNextChapter}
/>
{((!isContinuousReadingModeActive && isCurrentChapter) ||
(isContinuousReadingModeActive && (isInitialChapter || isTrailingChapter))) && (
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.NEXT} />
)}
</Stack>
);
};
export const ReaderChapterViewer = memo(BaseReaderChapterViewer);

View File

@@ -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<typeof ReaderControls.useOpenChapter>;
}) => {
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'],
);

View File

@@ -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, PageInViewportType> = {
[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<TReaderScrollbarContext, 'setScrollbarXSize' | 'setScrollbarYSize'> &
Pick<TReaderOverlayContext, 'isVisible' | 'setIsVisible'> &
Pick<
ReaderStateChapters,
| 'initialChapter'
| 'currentChapter'
| 'chapters'
| 'visibleChapters'
| 'setReaderStateChapters'
| 'isCurrentChapterReady'
> &
TReaderTapZoneContext & {
updateCurrentPageIndex: ReturnType<typeof ReaderControls.useUpdateCurrentPageIndex>;
},
@@ -121,10 +140,14 @@ const BaseReaderViewer = forwardRef(
ref: ForwardedRef<HTMLDivElement | null>,
) => {
const { direction: themeDirection } = useTheme();
const { resumeMode } = useLocation<ReaderOpenChapterLocationState>().state ?? {
resumeMode: ReaderResumeMode.START,
};
const scrollElementRef = useRef<HTMLDivElement | null>(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<ReaderPageSpreadState[]>(
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 (
<Stack
ref={scrollElementRef}
@@ -254,11 +227,10 @@ const BaseReaderViewer = forwardRef(
overflow: 'auto',
flexWrap: 'nowrap',
...applyStyles(
isContinuousVerticalReadingMode(readingMode) &&
shouldApplyReaderWidth(readerWidth, pageScaleMode),
isContinuousVerticalReadingModeActive && shouldApplyReaderWidth(readerWidth, pageScaleMode),
{ alignItems: 'center' },
),
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
...applyStyles(!isContinuousVerticalReadingModeActive, {
...applyStyles(themeDirection === 'ltr', {
flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse',
}),
@@ -278,19 +250,78 @@ const BaseReaderViewer = forwardRef(
)
}
>
<ReaderTransitionPage type={ReaderTransitionPageMode.PREVIOUS} />
<Pager
totalPages={totalPages}
currentPageIndex={currentPageIndex}
pages={actualPages}
transitionPageMode={transitionPageMode}
pageLoadStates={pageLoadStates}
retryFailedPagesKeyPrefix={retryFailedPagesKeyPrefix}
imageRefs={imageRefs}
onLoad={onLoad}
onError={onError}
/>
<ReaderTransitionPage type={ReaderTransitionPageMode.NEXT} />
{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 (
<ReaderChapterViewer
key={chapter.id}
chapterId={chapter.id}
previousChapterId={previousChapter?.id}
nextChapterId={nextChapter?.id}
isPreviousChapterVisible={!!chaptersToRender[chapterIndex + 1]}
isNextChapterVisible={!!chaptersToRender[chapterIndex - 1]}
lastPageRead={chapter.lastPageRead}
currentPageIndex={getReaderChapterViewerCurrentPageIndex(
currentPageIndex,
chapter,
currentChapter,
isCurrentChapter,
isCurrentChapterReady,
isLeadingChapter,
isTrailingChapter,
visibleChapters,
)}
isInitialChapter={isInitialChapter}
isCurrentChapter={isCurrentChapter}
isPreviousChapter={isPreviousChapter}
isNextChapter={isNextChapter}
isLeadingChapter={isLeadingChapter}
isTrailingChapter={isTrailingChapter}
imageRefs={imageRefs}
setPages={setPages}
setPageLoadStates={setPageLoadStates}
setTotalPages={setTotalPages}
setCurrentPageIndex={setCurrentPageIndex}
setPageToScrollToIndex={setPageToScrollToIndex}
transitionPageMode={transitionPageMode}
retryFailedPagesKeyPrefix={retryFailedPagesKeyPrefix}
readingMode={readingMode}
readerWidth={readerWidth}
pageScaleMode={pageScaleMode}
shouldOffsetDoubleSpreads={shouldOffsetDoubleSpreads}
readingDirection={readingDirection}
updateCurrentPageIndex={isCurrentChapter ? updateCurrentPageIndex : noOp}
scrollIntoView={isCurrentChapter && visibleChapters.scrollIntoView}
resumeMode={getReaderChapterViewResumeMode(
isCurrentChapter,
isInitialChapter,
isLeadingChapter,
isTrailingChapter,
visibleChapters.resumeMode,
resumeMode,
)}
setReaderStateChapters={setReaderStateChapters}
setTransitionPageMode={setTransitionPageMode}
/>
);
})}
</Stack>
);
},
@@ -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',
],
);

View File

@@ -25,6 +25,9 @@ const BaseBasePager = ({
slots,
readingMode,
imagePreLoadAmount,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
}: Omit<ReaderPagerProps, 'pageLoadStates' | 'retryFailedPagesKeyPrefix'> &
Pick<IReaderSettings, 'readingMode' | 'imagePreLoadAmount'> & {
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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -9,9 +9,22 @@
import { createContext, useContext } from 'react';
import { ReaderStateChapters } from '@/modules/reader/types/Reader.types.ts';
export const ReaderStateChaptersContext = createContext<ReaderStateChapters>({
export const READER_STATE_CHAPTERS_DEFAULTS: Omit<ReaderStateChapters, 'setReaderStateChapters'> = {
mangaChapters: [],
chapters: [],
isCurrentChapterReady: false,
visibleChapters: {
leading: 0,
trailing: 0,
lastLeadingChapterSourceOrder: 99999,
lastTrailingChapterSourceOrder: -1,
scrollIntoView: false,
resumeMode: undefined,
},
};
export const ReaderStateChaptersContext = createContext<ReaderStateChapters>({
...READER_STATE_CHAPTERS_DEFAULTS,
setReaderStateChapters: () => {},
});

View File

@@ -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<Omit<ReaderStateChapters, 'setReaderStateChapters'>>({
mangaChapters: [],
chapters: [],
});
const [state, setState] =
useState<Omit<ReaderStateChapters, 'setReaderStateChapters'>>(READER_STATE_CHAPTERS_DEFAULTS);
const value = useMemo(
() => ({

View File

@@ -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<typeof ReaderControls.useOpenChapter>,
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,
]);
};

View File

@@ -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);

View File

@@ -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<typeof requestManager.useGetMangaChapters<GetChaptersReaderQuery>>,
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]);
};

View File

@@ -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<typeof requestManager.useGetChapterPagesFetch>[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<string[]>();
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]);
};

View File

@@ -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<IReaderSettingsWithDefaultFlag, 'readingMode' | 'tapZoneLayout' | 'tapZoneInvertMode'> &
Pick<ReaderStateChapters, 'chapterForDuplicatesHandling' | 'currentChapter' | 'mangaChapters' | 'setReaderStateChapters'> &
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<ReaderOpenChapterLocationState>().state ?? {};
const scrollElementRef = useRef<HTMLDivElement | null>(null);
@@ -128,20 +128,6 @@ const BaseReader = ({
const mangaResponse = requestManager.useGetManga<GetMangaReaderQuery>(GET_MANGA_READER, mangaId);
const chaptersResponse = requestManager.useGetMangaChapters<GetChaptersReaderQuery>(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 <EmptyViewAbsoluteCentered message={t('reader.error.label.chapter_not_found')} />;
}
if (currentChapter && !currentChapter?.pageCount) {
return <EmptyViewAbsoluteCentered message={t('reader.error.label.no_pages_found')} retry={doFetchPages} />;
}
if (!manga || !currentChapter) {
return null;
}
@@ -395,9 +348,9 @@ export const Reader = withPropsFrom(
'shouldShowReadingModePreview',
'shouldShowTapZoneLayoutPreview',
'setSettings',
'initialChapter',
'chapterForDuplicatesHandling',
'currentChapter',
'mangaChapters',
'setReaderStateChapters',
'firstPageUrl',
'totalPages',

View File

@@ -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(

View File

@@ -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<React.SetStateAction<Omit<ReaderStateChapters, 'setReaderStateChapters'>>>;
}
@@ -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;

View File

@@ -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<ReaderStateChapters, 'setReaderStateChapters'>,
chapterToOpenSourceOrder: TChapterReader['sourceOrder'],
scrollIntoView: boolean,
): Omit<ReaderStateChapters, 'setReaderStateChapters'> => {
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;
};

View File

@@ -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)

View File

@@ -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,