Files
suwayomi-material-you-webui/src/features/reader/viewer/ReaderChapterViewer.tsx

450 lines
16 KiB
TypeScript
Raw Normal View History

2025-01-28 02:16:00 +01:00
/*
* 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,
ReaderPagerProps,
2025-01-28 02:16:00 +01:00
ReaderPageSpreadState,
ReaderResumeMode,
ReaderStateChapters,
ReaderStatePages,
2025-01-28 02:16:00 +01:00
ReaderTransitionPageMode,
ReadingDirection,
ReadingMode,
2025-08-16 02:02:13 +02:00
} from '@/features/reader/Reader.types.ts';
import { getDoublePageModePages } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
2025-08-15 22:02:58 +02:00
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
2025-01-28 02:16:00 +01:00
import {
getPagerForReadingMode,
isContinuousReadingMode,
isContinuousVerticalReadingMode,
shouldApplyReaderWidth,
2025-08-16 02:02:13 +02:00
} 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';
2025-01-28 02:16:00 +01:00
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
2025-08-16 02:02:13 +02:00
import { useReaderSetPagesState } from '@/features/reader/viewer/hooks/useReaderSetPagesState.ts';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
2025-01-28 02:16:00 +01:00
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
2025-08-16 02:02:13 +02:00
import { ReaderInfiniteScrollUpdateChapter } from '@/features/reader/infinite-scroll/ReaderInfiniteScrollUpdateChapter.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
2025-08-15 22:02:58 +02:00
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
2025-09-20 17:53:56 +02:00
import { READER_DEFAULT_PAGES_STATE } from '@/features/reader/stores/ReaderStore.constants.ts';
2025-01-28 02:16:00 +01:00
const BaseReaderChapterViewer = ({
currentPageIndex,
setPages: setContextPages,
setPageLoadStates: setContextPageLoadStates,
setTotalPages: setContextTotalPages,
setCurrentPageIndex: setContextCurrentPageIndex,
updateCurrentPageIndex,
setPageToScrollToIndex,
transitionPageMode,
retryFailedPagesKeyPrefix,
setTransitionPageMode,
readingMode,
readerWidth,
pageScaleMode,
shouldOffsetDoubleSpreads,
readingDirection,
imagePreLoadAmount,
pageGap,
2025-01-28 02:16:00 +01:00
chapterId,
previousChapterId,
nextChapterId,
isPreviousChapterVisible,
isNextChapterVisible,
lastPageRead,
isInitialChapter,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
isLeadingChapter,
isTrailingChapter,
isPreloadMode,
2025-01-28 02:16:00 +01:00
imageRefs: globalImageRefs,
scrollIntoView,
setReaderStateChapters,
resumeMode,
customFilter,
shouldStretchPage,
readerNavBarWidth,
onSizeChange,
minWidth,
minHeight,
scrollElement,
2025-01-28 02:16:00 +01:00
}: Pick<
ReaderStatePages,
| 'currentPageIndex'
| 'setPages'
| 'setPageLoadStates'
| 'setTotalPages'
| 'setCurrentPageIndex'
| 'setPageToScrollToIndex'
| 'transitionPageMode'
| 'retryFailedPagesKeyPrefix'
| 'setTransitionPageMode'
> &
Omit<ReaderPagerProps, 'pages' | 'totalPages' | 'pageLoadStates' | 'handleAsInitialRender' | 'resumeMode'> &
2025-01-28 02:16:00 +01:00
Pick<
IReaderSettings,
'readingMode' | 'shouldOffsetDoubleSpreads' | 'readingDirection' | 'readerWidth' | 'pageScaleMode'
2025-01-28 02:16:00 +01:00
> &
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;
Reader preserve current page on window resize (#905) * Reader: Reset bounds on window size change 19f2d993 introduced a `minWidth` to make sure sizes are consistent over chapter changes (or when images load later), but when the window itself changes, this makes the reader incorrectly sized * Reader: Update size on navbar width change This happens mostly when the bar is pinned; same effect as previous commit * Reader: Attempt to set the page on resize It's not perfect, but not changing position at all seems impossible due to how resizes are handled by the browser (since we adjust our content to the window size) * Simplify and abstract reader size reset Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * fix: Reader page full width was not properly calculated for static navbar Only relevant for Fit Content, this did not properly consider the navbar when the user clamps the content to a custom width * Refactor: Put reader min sizes to reader itself instead of chapter This allows some logic simplification. The `ResizeObserver` can needs to be on an element without restrictions; previously, the observer would not see the chapter elements get smaller, since the `min-width` prevented it from shrinking. By applying to the outer element, we still preserve the centering (since `margin: auto` is also applied here), but allow the chapter itself to be smaller. * Reader: Only scroll in continuous reading modes * Reader: clean up size tracking Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Fixup previous commit stupidness * Reader: move `minWidth`/`minHeight` back to chapter wrapper * Apply suggestions from code review Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Also revert `fullWidth` calculation * Simplify `chapterViewerSize` Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Move size logic entirely to hook --------- Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
2025-03-24 23:45:06 +01:00
onSizeChange: (width: number, height: number, chapterId: ChapterIdInfo['id']) => void;
minWidth: number;
minHeight: number;
scrollElement: HTMLElement | null;
2025-01-28 02:16:00 +01:00
}) => {
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_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);
2025-01-28 02:16:00 +01:00
const [pageLoadStates, setPageLoadStates] = useState<ReaderStatePages['pageLoadStates']>(
READER_DEFAULT_PAGES_STATE.pageLoadStates,
2025-01-28 02:16:00 +01:00
);
const [pagesToSpreadState, setPagesToSpreadState] = useState<ReaderPageSpreadState[]>(
pageLoadStates.map(({ url }) => ({ url, isSpread: false })),
);
const ref = useRef<HTMLDivElement>(null);
2025-01-28 02:16:00 +01:00
const isCurrentChapterRef = useRef(isCurrentChapter);
const imageRefs = useRef<(HTMLElement | null)[]>(pages.map(() => null));
const pagerRef = useRef<HTMLDivElement>(null);
2025-01-28 02:16:00 +01:00
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)));
2025-01-28 02:16:00 +01:00
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,
setPagesToSpreadState,
(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;
Reader preserve current page on window resize (#905) * Reader: Reset bounds on window size change 19f2d993 introduced a `minWidth` to make sure sizes are consistent over chapter changes (or when images load later), but when the window itself changes, this makes the reader incorrectly sized * Reader: Update size on navbar width change This happens mostly when the bar is pinned; same effect as previous commit * Reader: Attempt to set the page on resize It's not perfect, but not changing position at all seems impossible due to how resizes are handled by the browser (since we adjust our content to the window size) * Simplify and abstract reader size reset Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * fix: Reader page full width was not properly calculated for static navbar Only relevant for Fit Content, this did not properly consider the navbar when the user clamps the content to a custom width * Refactor: Put reader min sizes to reader itself instead of chapter This allows some logic simplification. The `ResizeObserver` can needs to be on an element without restrictions; previously, the observer would not see the chapter elements get smaller, since the `min-width` prevented it from shrinking. By applying to the outer element, we still preserve the centering (since `margin: auto` is also applied here), but allow the chapter itself to be smaller. * Reader: Only scroll in continuous reading modes * Reader: clean up size tracking Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Fixup previous commit stupidness * Reader: move `minWidth`/`minHeight` back to chapter wrapper * Apply suggestions from code review Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Also revert `fullWidth` calculation * Simplify `chapterViewerSize` Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Move size logic entirely to hook --------- Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
2025-03-24 23:45:06 +01:00
onSizeChange(clientWidth, clientHeight, chapterId);
},
Reader preserve current page on window resize (#905) * Reader: Reset bounds on window size change 19f2d993 introduced a `minWidth` to make sure sizes are consistent over chapter changes (or when images load later), but when the window itself changes, this makes the reader incorrectly sized * Reader: Update size on navbar width change This happens mostly when the bar is pinned; same effect as previous commit * Reader: Attempt to set the page on resize It's not perfect, but not changing position at all seems impossible due to how resizes are handled by the browser (since we adjust our content to the window size) * Simplify and abstract reader size reset Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * fix: Reader page full width was not properly calculated for static navbar Only relevant for Fit Content, this did not properly consider the navbar when the user clamps the content to a custom width * Refactor: Put reader min sizes to reader itself instead of chapter This allows some logic simplification. The `ResizeObserver` can needs to be on an element without restrictions; previously, the observer would not see the chapter elements get smaller, since the `min-width` prevented it from shrinking. By applying to the outer element, we still preserve the centering (since `margin: auto` is also applied here), but allow the chapter itself to be smaller. * Reader: Only scroll in continuous reading modes * Reader: clean up size tracking Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Fixup previous commit stupidness * Reader: move `minWidth`/`minHeight` back to chapter wrapper * Apply suggestions from code review Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Also revert `fullWidth` calculation * Simplify `chapterViewerSize` Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Move size logic entirely to hook --------- Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
2025-03-24 23:45:06 +01:00
[onSizeChange, ref.current, chapterId],
),
);
const updateState = <T,>(
2025-01-28 02:16:00 +01:00
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, setReaderStateChapters),
(value) => updateState(value, setTotalPages, setContextTotalPages),
(value) => updateState(value, setPages, setContextPages),
(value) => updateState(value, setPageUrls, noOp),
(value) => updateState(value, setPageLoadStates, setContextPageLoadStates),
(value) => updateState(value, setPagesToSpreadState, noOp),
(value) => updateState(value, noOp, setContextCurrentPageIndex),
2025-01-28 02:16:00 +01:00
(value) => {
if ((isInitialChapter && !arePagesFetched) || scrollIntoView) {
setPageToScrollToIndex(value);
setReaderStateChapters((prevState) => ({
...prevState,
visibleChapters: { ...prevState.visibleChapters, scrollIntoView: false, resumeMode: undefined },
}));
}
},
(value) => updateState(value, noOp, setTransitionPageMode),
2025-01-28 02:16:00 +01:00
);
useReaderConvertPagesForReadingMode(
currentPageIndex,
actualPages,
pageUrls,
(value) => updateState(value, setPages, setContextPages, true),
(value) => updateState(value, setPagesToSpreadState, noOp, true),
(value) => updateState(value, noOp, updateCurrentPageIndex),
2025-01-28 02:16:00 +01:00
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
ref={ref}
2025-01-28 02:16:00 +01:00
sx={{
width: 'fit-content',
height: 'fit-content',
Reader preserve current page on window resize (#905) * Reader: Reset bounds on window size change 19f2d993 introduced a `minWidth` to make sure sizes are consistent over chapter changes (or when images load later), but when the window itself changes, this makes the reader incorrectly sized * Reader: Update size on navbar width change This happens mostly when the bar is pinned; same effect as previous commit * Reader: Attempt to set the page on resize It's not perfect, but not changing position at all seems impossible due to how resizes are handled by the browser (since we adjust our content to the window size) * Simplify and abstract reader size reset Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * fix: Reader page full width was not properly calculated for static navbar Only relevant for Fit Content, this did not properly consider the navbar when the user clamps the content to a custom width * Refactor: Put reader min sizes to reader itself instead of chapter This allows some logic simplification. The `ResizeObserver` can needs to be on an element without restrictions; previously, the observer would not see the chapter elements get smaller, since the `min-width` prevented it from shrinking. By applying to the outer element, we still preserve the centering (since `margin: auto` is also applied here), but allow the chapter itself to be smaller. * Reader: Only scroll in continuous reading modes * Reader: clean up size tracking Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Fixup previous commit stupidness * Reader: move `minWidth`/`minHeight` back to chapter wrapper * Apply suggestions from code review Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Also revert `fullWidth` calculation * Simplify `chapterViewerSize` Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Move size logic entirely to hook --------- Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
2025-03-24 23:45:06 +01:00
margin: 'auto',
flexWrap: 'nowrap',
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
minHeight,
}),
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
minWidth,
}),
2025-01-28 02:16:00 +01:00
...applyStyles(shouldHideChapter, {
maxWidth: 0,
maxHeight: 0,
minWidth: 'unset',
minHeight: 'unset',
overflow: 'hidden',
margin: 'unset',
2025-01-28 02:16:00 +01:00
}),
...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 && (
2025-01-28 02:16:00 +01:00
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.PREVIOUS} />
)}
<Pager
ref={pagerRef}
2025-01-28 02:16:00 +01:00
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}
2025-01-28 02:16:00 +01:00
/>
{showNextTransitionPage && (
2025-01-28 02:16:00 +01:00
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.NEXT} />
)}
</Stack>
);
};
export const ReaderChapterViewer = memo(BaseReaderChapterViewer);