Files
suwayomi-material-you-webui/src/features/reader/viewer/ReaderChapterViewer.tsx
schroda 4da2f67b75 Fix page shift when toggling "offset double spreads" setting
The pages should get shifted to
- the left when enabling
- the right when disabling
the setting.

Currently, the "page push" is inverted.
This can cause the page that is most likely being read to get pushed out of the screen. Which then requires the user to change the page.

Example:

Active page: 1+2
Enable setting: -> 2 (currently) instead of -> 1
2026-02-21 15:15:47 +01:00

459 lines
17 KiB
TypeScript

/*
* 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 Box from '@mui/material/Box';
import { useLingui } from '@lingui/react/macro';
import {
IReaderSettings,
ReaderPagerProps,
ReaderPageSpreadState,
ReaderResumeMode,
ReaderStatePages,
ReaderTransitionPageMode,
ReadingDirection,
ReadingMode,
} from '@/features/reader/Reader.types.ts';
import { getDoublePageModePages } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
import {
getPagerForReadingMode,
isContinuousReadingMode,
isContinuousVerticalReadingMode,
shouldApplyReaderWidth,
} from '@/features/reader/settings/ReaderSettings.utils.tsx';
import { createHandleReaderPageLoadError, createUpdateReaderPageLoadState } from '@/features/reader/Reader.utils.ts';
import { useReaderConvertPagesForReadingMode } from '@/features/reader/viewer/hooks/useReaderConvertPagesForReadingMode.ts';
import { ReaderTransitionPage } from '@/features/reader/viewer/components/ReaderTransitionPage.tsx';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { useReaderSetPagesState } from '@/features/reader/viewer/hooks/useReaderSetPagesState.ts';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
import { ReaderInfiniteScrollUpdateChapter } from '@/features/reader/infinite-scroll/ReaderInfiniteScrollUpdateChapter.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
import { READER_DEFAULT_PAGES_STATE } from '@/features/reader/stores/ReaderPagesStore.ts';
import { getReaderChaptersStore } from '@/features/reader/stores/ReaderStore.ts';
const BaseReaderChapterViewer = ({
currentPageIndex,
setPages: setContextPages,
setPageUrls: setContextPageUrls,
setPageSpreadStates: setContextPageSpreadStates,
setPageLoadStates: setContextPageLoadStates,
setTotalPages: setContextTotalPages,
setCurrentPageIndex: setContextCurrentPageIndex,
updateCurrentPageIndex,
setPageToScrollToIndex,
transitionPageMode,
retryFailedPagesKeyPrefix,
setTransitionPageMode,
readingMode,
readerWidth,
pageScaleMode,
shouldOffsetDoubleSpreads,
readingDirection,
imagePreLoadAmount,
pageGap,
chapterId,
previousChapterId,
nextChapterId,
isPreviousChapterVisible,
isNextChapterVisible,
lastPageRead,
isInitialChapter,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
isLeadingChapter,
isTrailingChapter,
isPreloadMode,
imageRefs: globalImageRefs,
scrollIntoView,
resumeMode,
customFilter,
shouldStretchPage,
readerNavBarWidth,
onSizeChange,
minWidth,
minHeight,
scrollElement,
}: Pick<
ReaderStatePages,
| 'currentPageIndex'
| 'setPages'
| 'setPageUrls'
| 'setPageSpreadStates'
| 'setPageLoadStates'
| 'setTotalPages'
| 'setCurrentPageIndex'
| 'setPageToScrollToIndex'
| 'transitionPageMode'
| 'retryFailedPagesKeyPrefix'
| 'setTransitionPageMode'
> &
Omit<ReaderPagerProps, 'pages' | 'totalPages' | 'pageLoadStates' | 'handleAsInitialRender' | 'resumeMode'> &
Pick<
IReaderSettings,
'readingMode' | 'shouldOffsetDoubleSpreads' | 'readingDirection' | 'readerWidth' | 'pageScaleMode'
> & {
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;
onSizeChange: (width: number, height: number, chapterId: ChapterIdInfo['id']) => void;
minWidth: number;
minHeight: number;
scrollElement: HTMLElement | null;
}) => {
const { t } = useLingui();
const { direction: themeDirection } = useTheme();
const [fetchPages, pagesResponse] = requestManager.useGetChapterPagesFetch(chapterId ?? -1);
const [arePagesFetched, setArePagesFetched] = useState(false);
const [totalPages, setTotalPages] = useState<ReaderStatePages['totalPages']>(READER_DEFAULT_PAGES_STATE.totalPages);
const [pageUrls, setPageUrls] = useState<ReaderStatePages['pageUrls']>(READER_DEFAULT_PAGES_STATE.pageUrls);
const [pages, setPages] = useState<ReaderStatePages['pages']>(READER_DEFAULT_PAGES_STATE.pages);
const [pageLoadStates, setPageLoadStates] = useState<ReaderStatePages['pageLoadStates']>(
READER_DEFAULT_PAGES_STATE.pageLoadStates,
);
const [pagesToSpreadState, setPagesToSpreadState] = useState<ReaderPageSpreadState[]>(
READER_DEFAULT_PAGES_STATE.pageSpreadStates,
);
const ref = useRef<HTMLDivElement>(null);
const isCurrentChapterRef = useRef(isCurrentChapter);
const imageRefs = useRef<(HTMLElement | null)[]>(pages.map(() => null));
const pagerRef = useRef<HTMLDivElement>(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) || isPreloadMode;
const isCurrentChapterInSinglePager = !isContinuousReadingModeActive && isCurrentChapter;
const showPreviousTransitionPage =
!shouldHideChapter &&
(isCurrentChapterInSinglePager || (isContinuousReadingModeActive && (isInitialChapter || isLeadingChapter)));
const showNextTransitionPage =
!shouldHideChapter &&
(isCurrentChapterInSinglePager || (isContinuousReadingModeActive && (isInitialChapter || isTrailingChapter)));
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,
(value) => {
if (isCurrentChapterRef.current) {
setContextPageSpreadStates(value);
}
setPagesToSpreadState(value);
},
(value) => {
if (isCurrentChapterRef.current) {
setContextPageLoadStates(value);
}
setPageLoadStates(value);
},
readingMode,
),
[actualPages, readingMode],
);
const onError = useMemo(
() =>
createHandleReaderPageLoadError((value) => {
if (isCurrentChapterRef.current) {
setContextPageLoadStates(value);
}
setPageLoadStates(value);
}),
[],
);
useEffect(() => {
doFetchPages();
}, [chapterId]);
useResizeObserver(
ref,
useCallback(
(entries) => {
const { clientWidth, clientHeight } = entries[0].target;
onSizeChange(clientWidth, clientHeight, chapterId);
},
[onSizeChange, ref.current, chapterId],
),
);
const updateState = <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,
(value) => updateState(value, noOp, getReaderChaptersStore().setReaderStateChapters),
(value) => updateState(value, setTotalPages, setContextTotalPages),
(value) => updateState(value, setPages, setContextPages),
(value) => updateState(value, setPageUrls, setContextPageUrls),
(value) => updateState(value, setPageLoadStates, setContextPageLoadStates),
(value) => updateState(value, setPagesToSpreadState, setContextPageSpreadStates),
(value) => updateState(value, noOp, setContextCurrentPageIndex),
(value) => {
if ((isInitialChapter && !arePagesFetched) || scrollIntoView) {
setPageToScrollToIndex(value);
getReaderChaptersStore().setReaderStateChapters((prevState) => ({
...prevState,
visibleChapters: { ...prevState.visibleChapters, scrollIntoView: false, resumeMode: undefined },
}));
}
},
(value) => updateState(value, noOp, setTransitionPageMode),
);
useReaderConvertPagesForReadingMode(
currentPageIndex,
actualPages,
pageUrls,
(value) => updateState(value, setPages, setContextPages, true),
(value) => updateState(value, setPagesToSpreadState, noOp, true),
(value) => updateState(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`Unable 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`No pages found`} retry={doFetchPages} />
</Box>
);
}
return (
<Stack
ref={ref}
sx={{
width: 'fit-content',
height: 'fit-content',
margin: 'auto',
flexWrap: 'nowrap',
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
minHeight,
}),
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
minWidth,
}),
...applyStyles(shouldHideChapter, {
maxWidth: 0,
maxHeight: 0,
minWidth: 'unset',
minHeight: 'unset',
overflow: 'hidden',
margin: 'unset',
}),
...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',
}),
}),
}}
>
{!isPreloadMode && (
<ReaderInfiniteScrollUpdateChapter
chapterId={chapterId}
previousChapterId={previousChapterId}
nextChapterId={nextChapterId}
isCurrentChapter={isCurrentChapter}
isPreviousChapterVisible={isPreviousChapterVisible}
isNextChapterVisible={isNextChapterVisible}
imageWrapper={pagerRef.current}
scrollElement={scrollElement}
/>
)}
{showPreviousTransitionPage && (
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.PREVIOUS} />
)}
<Pager
ref={pagerRef}
totalPages={totalPages}
currentPageIndex={currentPageIndex}
pages={actualPages}
transitionPageMode={transitionPageMode}
pageLoadStates={pageLoadStates}
retryFailedPagesKeyPrefix={retryFailedPagesKeyPrefix}
imageRefs={imageRefs}
onLoad={onLoad}
onError={onError}
isCurrentChapter={isCurrentChapter}
isPreviousChapter={isPreviousChapter}
isNextChapter={isNextChapter}
readingMode={readingMode}
imagePreLoadAmount={imagePreLoadAmount}
readingDirection={readingDirection}
pageScaleMode={pageScaleMode}
pageGap={pageGap}
customFilter={customFilter}
shouldStretchPage={shouldStretchPage}
readerWidth={readerWidth}
readerNavBarWidth={readerNavBarWidth}
isPreloadMode={isPreloadMode}
resumeMode={resumeMode}
handleAsInitialRender={scrollIntoView}
/>
{showNextTransitionPage && (
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.NEXT} />
)}
</Stack>
);
};
export const ReaderChapterViewer = memo(BaseReaderChapterViewer);