Structure "reader" in sub-features

This commit is contained in:
schroda
2025-08-16 02:02:13 +02:00
parent fc12f5dfd3
commit f548ba9502
140 changed files with 435 additions and 429 deletions

View File

@@ -0,0 +1,466 @@
/*
* 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,
ReaderPageSpreadState,
ReaderResumeMode,
ReaderStateChapters,
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 { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
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 '@/features/core/utils/ApplyStyles.ts';
import { READER_STATE_PAGES_DEFAULTS } from '@/features/reader/ReaderContext.constants.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 '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { ReaderInfiniteScrollUpdateChapter } from '@/features/reader/infinite-scroll/ReaderInfiniteScrollUpdateChapter.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
const BaseReaderChapterViewer = ({
currentPageIndex,
setPages: setContextPages,
setPageLoadStates: setContextPageLoadStates,
setTotalPages: setContextTotalPages,
setCurrentPageIndex: setContextCurrentPageIndex,
updateCurrentPageIndex,
setPageToScrollToIndex,
transitionPageMode,
retryFailedPagesKeyPrefix,
setTransitionPageMode,
readingMode,
readerWidth,
pageScaleMode,
shouldOffsetDoubleSpreads,
readingDirection,
shouldUseInfiniteScroll,
imagePreLoadAmount,
pageGap,
chapterId,
previousChapterId,
nextChapterId,
isPreviousChapterVisible,
isNextChapterVisible,
lastPageRead,
isInitialChapter,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
isLeadingChapter,
isTrailingChapter,
isPreloadMode,
imageRefs: globalImageRefs,
scrollIntoView,
setReaderStateChapters,
resumeMode,
customFilter,
shouldStretchPage,
scrollbarXSize,
scrollbarYSize,
readerNavBarWidth,
onSizeChange,
minWidth,
minHeight,
scrollElement,
}: Pick<
ReaderStatePages,
| 'currentPageIndex'
| 'setPages'
| 'setPageLoadStates'
| 'setTotalPages'
| 'setCurrentPageIndex'
| 'setPageToScrollToIndex'
| 'transitionPageMode'
| 'retryFailedPagesKeyPrefix'
| 'setTransitionPageMode'
> &
Omit<ReaderPagerProps, 'pages' | 'totalPages' | 'pageLoadStates' | 'handleAsInitialRender' | 'resumeMode'> &
Pick<
IReaderSettings,
| 'readingMode'
| 'shouldOffsetDoubleSpreads'
| 'readingDirection'
| 'readerWidth'
| 'pageScaleMode'
| 'shouldUseInfiniteScroll'
> &
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;
onSizeChange: (width: number, height: number, chapterId: ChapterIdInfo['id']) => void;
minWidth: number;
minHeight: number;
scrollElement: HTMLElement | null;
}) => {
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 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,
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;
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, 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),
(value) => {
if ((isInitialChapter && !arePagesFetched) || scrollIntoView) {
setPageToScrollToIndex(value);
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('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}
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
readingMode={readingMode}
readingDirection={readingDirection}
shouldUseInfiniteScroll={shouldUseInfiniteScroll}
chapterId={chapterId}
previousChapterId={previousChapterId}
nextChapterId={nextChapterId}
isCurrentChapter={isCurrentChapter}
isPreviousChapterVisible={isPreviousChapterVisible}
isNextChapterVisible={isNextChapterVisible}
imageWrapper={pagerRef.current}
scrollbarXSize={scrollbarXSize}
scrollbarYSize={scrollbarYSize}
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}
scrollbarXSize={scrollbarXSize}
scrollbarYSize={scrollbarYSize}
readerNavBarWidth={readerNavBarWidth}
isPreloadMode={isPreloadMode}
resumeMode={resumeMode}
handleAsInitialRender={scrollIntoView}
/>
{showNextTransitionPage && (
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.NEXT} />
)}
</Stack>
);
};
export const ReaderChapterViewer = memo(BaseReaderChapterViewer);

View File

@@ -0,0 +1,501 @@
/*
* 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 {
ForwardedRef,
forwardRef,
memo,
useCallback,
useEffect,
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 { useMergedRef } from '@mantine/hooks';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import {
IReaderSettings,
PageInViewportType,
ReaderOpenChapterLocationState,
ReaderResumeMode,
ReaderStateChapters,
ReadingDirection,
ReadingMode,
TReaderScrollbarContext,
} from '@/features/reader/Reader.types.ts';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { useReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
import {
isContinuousReadingMode,
isContinuousVerticalReadingMode,
shouldApplyReaderWidth,
} from '@/features/reader/settings/ReaderSettings.utils.tsx';
import { useMouseDragScroll } from '@/features/core/hooks/useMouseDragScroll.tsx';
import { useReaderOverlayContext } from '@/features/reader/overlay/contexts/ReaderOverlayContext.tsx';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { TReaderOverlayContext } from '@/features/reader/overlay/ReaderOverlay.types.ts';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useReaderAutoScrollContext } from '@/features/reader/auto-scroll/contexts/ReaderAutoScrollContext.tsx';
import { TReaderTapZoneContext } from '@/features/reader/tap-zones/TapZoneLayout.types.ts';
import { useReaderTapZoneContext } from '@/features/reader/tap-zones/contexts/ReaderTapZoneContext.tsx';
import { useReaderAutoScroll } from '@/features/reader/auto-scroll/hooks/useReaderAutoScroll.ts';
import { useReaderHideOverlayOnUserScroll } from '@/features/reader/overlay/hooks/useReaderHideOverlayOnUserScroll.ts';
import { useReaderHorizontalModeInvertXYScrolling } from '@/features/reader/viewer/hooks/useReaderHorizontalModeInvertXYScrolling.ts';
import { useReaderHideCursorOnInactivity } from '@/features/reader/viewer/hooks/useReaderHideCursorOnInactivity.ts';
import { useReaderScrollToStartOnPageChange } from '@/features/reader/viewer/hooks/useReaderScrollToStartOnPageChange.ts';
import { useReaderHandlePageSelection } from '@/features/reader/viewer/hooks/useReaderHandlePageSelection.ts';
import { useReaderStateChaptersContext } from '@/features/reader/contexts/state/ReaderStateChaptersContext.tsx';
import { ReaderChapterViewer } from '@/features/reader/viewer/ReaderChapterViewer.tsx';
import {
getPreviousNextChapterVisibility,
getReaderChapterViewerCurrentPageIndex,
getReaderChapterViewResumeMode,
} from '@/features/reader/Reader.utils.ts';
import { coerceIn, noOp } from '@/lib/HelperFunctions.ts';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
import { useReaderPreserveScrollPosition } from '@/features/reader/viewer/hooks/useReaderPreserveScrollPosition.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
const READING_MODE_TO_IN_VIEWPORT_TYPE: Record<ReadingMode, PageInViewportType> = {
[ReadingMode.SINGLE_PAGE]: PageInViewportType.X,
[ReadingMode.DOUBLE_PAGE]: PageInViewportType.X,
[ReadingMode.CONTINUOUS_VERTICAL]: PageInViewportType.Y,
[ReadingMode.CONTINUOUS_HORIZONTAL]: PageInViewportType.X,
[ReadingMode.WEBTOON]: PageInViewportType.Y,
};
const BaseReaderViewer = forwardRef(
(
{
currentPageIndex,
pageToScrollToIndex,
setPageToScrollToIndex,
pages,
totalPages,
setPages,
setPageLoadStates,
setTotalPages,
setCurrentPageIndex,
transitionPageMode,
retryFailedPagesKeyPrefix,
setTransitionPageMode,
readingMode,
readingDirection,
shouldUseInfiniteScroll,
readerWidth,
pageScaleMode,
shouldOffsetDoubleSpreads,
imagePreLoadAmount,
pageGap,
customFilter,
shouldStretchPage,
isStaticNav,
readerNavBarWidth,
setScrollbarXSize,
setScrollbarYSize,
isVisible: isOverlayVisible,
setIsVisible: setIsOverlayVisible,
updateCurrentPageIndex,
showPreview,
setShowPreview,
initialChapter,
currentChapter,
chapters,
visibleChapters,
setReaderStateChapters,
isCurrentChapterReady,
}: Pick<
ReaderStatePages,
| 'currentPageIndex'
| 'pageToScrollToIndex'
| 'setPageToScrollToIndex'
| 'pages'
| 'totalPages'
| 'setPages'
| 'setPageLoadStates'
| 'setTotalPages'
| 'setCurrentPageIndex'
| 'transitionPageMode'
| 'retryFailedPagesKeyPrefix'
| 'setTransitionPageMode'
> &
Pick<
IReaderSettings,
| 'readingMode'
| 'readingDirection'
| 'shouldUseInfiniteScroll'
| 'readerWidth'
| 'pageScaleMode'
| 'shouldOffsetDoubleSpreads'
| 'imagePreLoadAmount'
| 'pageGap'
| 'customFilter'
| 'shouldStretchPage'
| 'isStaticNav'
> &
Pick<TReaderScrollbarContext, 'setScrollbarXSize' | 'setScrollbarYSize'> &
Pick<NavbarContextType, 'readerNavBarWidth'> &
Pick<TReaderOverlayContext, 'isVisible' | 'setIsVisible'> &
Pick<
ReaderStateChapters,
| 'initialChapter'
| 'currentChapter'
| 'chapters'
| 'visibleChapters'
| 'setReaderStateChapters'
| 'isCurrentChapterReady'
> &
TReaderTapZoneContext & {
updateCurrentPageIndex: ReturnType<typeof ReaderControls.useUpdateCurrentPageIndex>;
},
ref: ForwardedRef<HTMLDivElement | null>,
) => {
const { direction: themeDirection } = useTheme();
const { resumeMode = ReaderResumeMode.START } = useLocation<ReaderOpenChapterLocationState>().state ?? {
resumeMode: ReaderResumeMode.START,
};
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const mergedRef = useMergedRef(ref, scrollElementRef);
const isContinuousVerticalReadingModeActive = isContinuousVerticalReadingMode(readingMode);
const isContinuousReadingModeActive = isContinuousReadingMode(readingMode);
const isDragging = useMouseDragScroll(scrollElementRef);
const automaticScrolling = useReaderAutoScrollContext();
useEffect(() => automaticScrolling.setScrollRef(scrollElementRef), []);
const scrollbarXSize = MediaQuery.useGetScrollbarSize('width', scrollElementRef.current);
const scrollbarYSize = MediaQuery.useGetScrollbarSize('height', scrollElementRef.current);
useLayoutEffect(() => {
setScrollbarXSize(scrollbarXSize);
setScrollbarYSize(scrollbarYSize);
}, [scrollbarXSize, scrollbarYSize]);
const handleClick = ReaderControls.useHandleClick(scrollElementRef.current);
const imageRefs = useRef<(HTMLElement | null)[]>(pages.map(() => null));
const [{ minChapterViewWidth, minChapterViewHeight, minChapterSizeSourceChapterId }, setChapterViewerSize] =
useState({
minChapterViewWidth: 0,
minChapterViewHeight: 0,
minChapterSizeSourceChapterId: -1,
});
const [, setTriggerReRender] = useState({});
const inViewportType = READING_MODE_TO_IN_VIEWPORT_TYPE[readingMode];
const isLtrReadingDirection = readingDirection === ReadingDirection.LTR;
const initialChapterIndex = useMemo(
() => chapters.findIndex((chapter) => chapter.id === initialChapter?.id),
[chapters, initialChapter?.id],
);
const chaptersToRender = useMemo(
() =>
chapters.slice(
Math.max(0, initialChapterIndex - visibleChapters.trailing),
Math.min(chapters.length, initialChapterIndex + visibleChapters.leading + 1),
),
[chapters, initialChapterIndex, visibleChapters.trailing, visibleChapters.leading],
);
const currentChapterIndex = useMemo(
() => chaptersToRender.findIndex((chapter) => chapter.id === currentChapter?.id),
[currentChapter, chaptersToRender],
);
const onChapterViewSizeChange = useCallback(
(width: number, height: number, chapterId: ChapterIdInfo['id']) => {
if (!isContinuousReadingModeActive) {
return;
}
const isSameChapterId = chapterId === minChapterSizeSourceChapterId;
if (isContinuousVerticalReadingModeActive) {
if (!isSameChapterId && minChapterViewWidth >= width) {
return;
}
setChapterViewerSize({
minChapterViewWidth: width,
minChapterViewHeight: 0,
minChapterSizeSourceChapterId: chapterId,
});
return;
}
if (isSameChapterId || minChapterViewHeight < height) {
setChapterViewerSize({
minChapterViewWidth: 0,
minChapterViewHeight: height,
minChapterSizeSourceChapterId: chapterId,
});
}
},
[
isContinuousReadingModeActive,
isContinuousVerticalReadingModeActive,
minChapterViewWidth,
minChapterViewHeight,
minChapterSizeSourceChapterId,
],
);
useReaderHandlePageSelection(
pageToScrollToIndex,
currentPageIndex,
pages,
totalPages,
setPageToScrollToIndex,
updateCurrentPageIndex,
isContinuousReadingModeActive,
imageRefs,
themeDirection,
readingDirection,
);
useReaderScrollToStartOnPageChange(
currentPageIndex,
isContinuousReadingModeActive,
themeDirection,
readingDirection,
scrollElementRef,
);
useReaderHideCursorOnInactivity(scrollElementRef);
useReaderHorizontalModeInvertXYScrolling(readingMode, readingDirection, scrollElementRef);
useReaderHideOverlayOnUserScroll(
isOverlayVisible,
setIsOverlayVisible,
showPreview,
setShowPreview,
scrollElementRef,
);
useReaderAutoScroll(isOverlayVisible, automaticScrolling, isStaticNav);
useReaderPreserveScrollPosition(
scrollElementRef,
currentChapter?.id,
currentChapterIndex,
currentPageIndex,
chaptersToRender,
visibleChapters,
readingMode,
readingDirection,
setPageToScrollToIndex,
pageScaleMode,
);
useLayoutEffect(() => {
setChapterViewerSize({
minChapterViewWidth: 0,
minChapterViewHeight: 0,
minChapterSizeSourceChapterId: -1,
});
setTriggerReRender({});
}, [readingMode]);
if (!initialChapter || !currentChapter) {
throw new Error('ReaderViewer: illegal state - initialChapter and currentChapter should not be undefined');
}
return (
<Stack
ref={mergedRef}
sx={{
width: '100%',
height: '100%',
overflow: 'auto',
flexWrap: 'nowrap',
...applyStyles(
isContinuousVerticalReadingModeActive && shouldApplyReaderWidth(readerWidth, pageScaleMode),
{ alignItems: 'center' },
),
...applyStyles(!isContinuousVerticalReadingModeActive, {
...applyStyles(themeDirection === 'ltr', {
flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse',
}),
...applyStyles(themeDirection === 'rtl', {
flexDirection: isLtrReadingDirection ? 'row-reverse' : 'row',
}),
}),
}}
onClick={(e) => !isDragging && handleClick(e)}
onScroll={() =>
ReaderControls.updateCurrentPageOnScroll(
imageRefs,
totalPages - 1,
updateCurrentPageIndex,
inViewportType,
readingDirection,
)
}
>
{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;
const isLastLeadingChapter = visibleChapters.lastLeadingChapterSourceOrder === chapter.sourceOrder;
const isLastTrailingChapter =
visibleChapters.lastTrailingChapterSourceOrder === chapter.sourceOrder;
const isPreloadMode =
(isLastLeadingChapter && visibleChapters.isLeadingChapterPreloadMode) ||
(isLastTrailingChapter && visibleChapters.isTrailingChapterPreloadMode);
const previousNextChapterVisibility = getPreviousNextChapterVisibility(
chapterIndex,
chaptersToRender,
visibleChapters,
);
const isChapterSizeSourceChapter = chapter.id === minChapterSizeSourceChapterId;
return (
<ReaderChapterViewer
key={chapter.id}
chapterId={chapter.id}
previousChapterId={previousChapter?.id}
nextChapterId={nextChapter?.id}
isPreviousChapterVisible={previousNextChapterVisibility.previous}
isNextChapterVisible={previousNextChapterVisibility.next}
lastPageRead={coerceIn(chapter.lastPageRead, 0, chapter.pageCount - 1)}
currentPageIndex={getReaderChapterViewerCurrentPageIndex(
currentPageIndex,
chapter,
currentChapter,
isCurrentChapter,
isCurrentChapterReady,
isLeadingChapter,
isTrailingChapter,
visibleChapters,
)}
isInitialChapter={isInitialChapter}
isCurrentChapter={isCurrentChapter}
isPreviousChapter={isPreviousChapter}
isNextChapter={isNextChapter}
isLeadingChapter={isLeadingChapter}
isTrailingChapter={isTrailingChapter}
isPreloadMode={isPreloadMode}
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}
shouldUseInfiniteScroll={shouldUseInfiniteScroll}
updateCurrentPageIndex={isCurrentChapter ? updateCurrentPageIndex : noOp}
scrollIntoView={isCurrentChapter && visibleChapters.scrollIntoView}
resumeMode={getReaderChapterViewResumeMode(
isCurrentChapter,
isInitialChapter,
isLeadingChapter,
isTrailingChapter,
visibleChapters.resumeMode,
resumeMode,
)}
setReaderStateChapters={setReaderStateChapters}
setTransitionPageMode={setTransitionPageMode}
pageGap={pageGap}
imagePreLoadAmount={imagePreLoadAmount}
customFilter={customFilter}
shouldStretchPage={shouldStretchPage}
scrollbarXSize={scrollbarXSize}
scrollbarYSize={scrollbarYSize}
readerNavBarWidth={readerNavBarWidth}
onSizeChange={onChapterViewSizeChange}
minWidth={isChapterSizeSourceChapter ? 0 : minChapterViewWidth}
minHeight={isChapterSizeSourceChapter ? 0 : minChapterViewHeight}
scrollElement={scrollElementRef.current}
/>
);
})}
</Stack>
);
},
);
export const ReaderViewer = withPropsFrom(
memo(BaseReaderViewer),
[
userReaderStatePagesContext,
ReaderService.useSettingsWithoutDefaultFlag,
useReaderScrollbarContext,
useReaderOverlayContext,
() => ({ updateCurrentPageIndex: ReaderControls.useUpdateCurrentPageIndex() }),
useReaderTapZoneContext,
useReaderStateChaptersContext,
useNavBarContext,
],
[
'currentPageIndex',
'pageToScrollToIndex',
'setPageToScrollToIndex',
'pages',
'totalPages',
'setPages',
'setPageLoadStates',
'setTotalPages',
'setCurrentPageIndex',
'retryFailedPagesKeyPrefix',
'setTransitionPageMode',
'readingMode',
'readingDirection',
'shouldUseInfiniteScroll',
'readerWidth',
'pageScaleMode',
'shouldOffsetDoubleSpreads',
'imagePreLoadAmount',
'pageGap',
'customFilter',
'shouldStretchPage',
'isStaticNav',
'readerNavBarWidth',
'transitionPageMode',
'setScrollbarXSize',
'setScrollbarYSize',
'isVisible',
'setIsVisible',
'updateCurrentPageIndex',
'showPreview',
'setShowPreview',
'initialChapter',
'currentChapter',
'chapters',
'visibleChapters',
'setReaderStateChapters',
'isCurrentChapterReady',
],
);

View File

@@ -0,0 +1,170 @@
/*
* 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, useCallback } from 'react';
import { SpinnerImage, SpinnerImageProps } from '@/features/core/components/SpinnerImage.tsx';
import {
IReaderSettings,
ReaderCustomFilter,
ReaderPagerProps,
TReaderScrollbarContext,
} from '@/features/reader/Reader.types.ts';
import {
getImageMarginStyling,
getImagePlaceholderStyling,
getReaderImageStyling,
} from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
const getCustomFilterString = (customFilter: ReaderCustomFilter): string =>
Object.keys(customFilter)
.map((key) => {
const filter = key as keyof ReaderCustomFilter;
const value = customFilter[filter];
switch (filter) {
case 'brightness':
case 'contrast':
case 'saturate':
return (value as ReaderCustomFilter['brightness' | 'contrast' | 'saturate']).enabled
? `${filter}(${(value as ReaderCustomFilter['brightness' | 'contrast' | 'saturate']).value / 100})`
: '';
case 'hue':
return (value as ReaderCustomFilter['hue']).enabled
? `hue-rotate(${(value as ReaderCustomFilter['brightness' | 'contrast' | 'saturate']).value}deg)`
: '';
case 'rgba':
return '';
case 'sepia':
case 'grayscale':
case 'invert':
return value ? `${filter}(${Number(value)})` : '';
default:
throw new Error(`Unexpected "CustomFilter" (${filter})`);
}
})
.join(' ');
const BaseReaderPage = ({
pageIndex,
pagesIndex,
isPrimaryPage,
display,
doublePage = false,
position,
marginTop,
shouldLoad,
readingMode,
customFilter,
pageScaleMode,
shouldStretchPage,
readerWidth,
scrollbarXSize,
scrollbarYSize,
onLoad,
onError,
setRef,
readerNavBarWidth,
isLoaded,
...props
}: Omit<SpinnerImageProps, 'spinnerStyle' | 'imgStyle' | 'onLoad' | 'onError'> &
Pick<IReaderSettings, 'readingMode' | 'customFilter' | 'pageScaleMode' | 'shouldStretchPage' | 'readerWidth'> &
Pick<TReaderScrollbarContext, 'scrollbarXSize' | 'scrollbarYSize'> &
Pick<NavbarContextType, 'readerNavBarWidth'> & {
pageIndex: number;
pagesIndex: number;
isPrimaryPage: boolean;
display: boolean;
doublePage?: boolean;
position?: 'left' | 'right';
marginTop?: number;
onLoad: ReaderPagerProps['onLoad'];
onError: ReaderPagerProps['onError'];
setRef?: (pagesIndex: number, ref: HTMLElement | null) => void;
isLoaded?: boolean;
}) => {
const { src } = props;
const isTabletWidth = MediaQuery.useIsTabletWidth();
const handleLoad = useCallback(
() => onLoad?.(pagesIndex, src, isPrimaryPage),
[onLoad, pagesIndex, src, isPrimaryPage],
);
const handleError = useCallback(() => onError?.(pageIndex, src), [onError, pageIndex, src]);
const updateRef = useCallback((element: HTMLElement | null) => setRef?.(pagesIndex, element), [pagesIndex, setRef]);
if (!display && !shouldLoad) {
return null;
}
return (
<SpinnerImage
{...props}
onLoad={handleLoad}
onError={handleError}
shouldLoad={shouldLoad}
shouldDecode
ref={updateRef}
spinnerStyle={{
backgroundColor: 'background.paper',
...getImagePlaceholderStyling(
readingMode,
shouldStretchPage,
pageScaleMode,
readerWidth,
readerNavBarWidth + scrollbarYSize,
scrollbarXSize,
doublePage,
isTabletWidth,
),
...applyStyles(!display, {
display: 'none',
}),
...getImageMarginStyling(doublePage, position),
}}
imgStyle={{
...getReaderImageStyling(
readingMode,
shouldStretchPage,
pageScaleMode,
doublePage,
readerWidth,
readerNavBarWidth + scrollbarYSize,
scrollbarXSize,
),
filter: getCustomFilterString(customFilter),
objectFit: 'contain',
objectPosition: position,
userSelect: 'none',
...getImageMarginStyling(doublePage, position),
...applyStyles(marginTop !== undefined, {
mt: `${marginTop}px`,
}),
display: 'block',
...applyStyles(!display, {
display: 'none',
}),
...applyStyles(!isLoaded, {
margin: 'unset',
}),
}}
hideImgStyle={{
visibility: 'hidden',
minWidth: 0,
minHeight: 0,
width: 0,
height: 0,
}}
/>
);
};
export const ReaderPage = memo(BaseReaderPage);

View File

@@ -0,0 +1,305 @@
/*
* 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 Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import { Link } from 'react-router-dom';
import { ComponentProps, memo, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material/styles';
import { useReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
import { useReaderStateChaptersContext } from '@/features/reader/contexts/state/ReaderStateChaptersContext.tsx';
import {
IReaderSettings,
ReaderTransitionPageMode,
ReadingMode,
TReaderScrollbarContext,
TReaderStateMangaContext,
} from '@/features/reader/Reader.types.ts';
import { isTransitionPageVisible } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { useBackButton } from '@/features/core/hooks/useBackButton.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import {
isContinuousReadingMode,
isContinuousVerticalReadingMode,
} from '@/features/reader/settings/ReaderSettings.utils.tsx';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
import { getValueFromObject, noOp } from '@/lib/HelperFunctions.ts';
import { READER_BACKGROUND_TO_COLOR } from '@/features/reader/settings/ReaderSettings.constants.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { ChapterType } from '@/lib/graphql/generated/graphql.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
const ChapterInfo = ({
title,
name,
scanlator,
backgroundColor,
}: {
title: string;
name?: ChapterType['name'];
scanlator?: ChapterType['scanlator'];
backgroundColor: IReaderSettings['backgroundColor'];
}) => {
const theme = useTheme();
const contrastText = theme.palette.getContrastText(
getValueFromObject(theme.palette, READER_BACKGROUND_TO_COLOR[backgroundColor]),
);
const disabledText = alpha(contrastText, 0.5);
if (!name) {
return null;
}
return (
<Stack>
<Typography color={contrastText}>{title}</Typography>
<Typography color={contrastText} variant="h6" component="h1">
{name}
</Typography>
{scanlator && (
<Typography variant="body2" color={disabledText}>
{scanlator}
</Typography>
)}
</Stack>
);
};
const BaseReaderTransitionPage = ({
type,
transitionPageMode,
readingMode,
backgroundColor,
shouldShowTransitionPage,
manga,
currentChapterName,
currentChapterScanlator,
previousChapterName,
previousChapterScanlator,
nextChapterName,
nextChapterScanlator,
scrollbarXSize,
scrollbarYSize,
readerNavBarWidth,
handleBack,
}: Pick<IReaderSettings, 'readingMode' | 'backgroundColor' | 'shouldShowTransitionPage'> &
Pick<TReaderStateMangaContext, 'manga'> &
Pick<TReaderScrollbarContext, 'scrollbarXSize' | 'scrollbarYSize'> &
Pick<ReaderStatePages, 'transitionPageMode'> &
Pick<NavbarContextType, 'readerNavBarWidth'> & {
// gets used in the "source props creators" of the "withPropsFrom" call
// eslint-disable-next-line react/no-unused-prop-types
chapterId: ChapterIdInfo['id'];
currentChapterName?: ChapterType['name'];
currentChapterScanlator?: ChapterType['scanlator'];
previousChapterName?: ChapterType['name'];
previousChapterScanlator?: ChapterType['scanlator'];
nextChapterName?: ChapterType['name'];
nextChapterScanlator?: ChapterType['scanlator'];
type: Exclude<ReaderTransitionPageMode, ReaderTransitionPageMode.NONE | ReaderTransitionPageMode.BOTH>;
handleBack: () => void;
}) => {
const { t } = useTranslation();
const isPreviousType = type === ReaderTransitionPageMode.PREVIOUS;
const isNextType = type === ReaderTransitionPageMode.NEXT;
const isFirstChapter = !!currentChapterName && !previousChapterName;
const isLastChapter = !!currentChapterName && !nextChapterName;
const forceShowFirstChapterPreviousTransitionPage = isFirstChapter && type === ReaderTransitionPageMode.PREVIOUS;
const forceShowLastChapterNextTransitionPage = isLastChapter && type === ReaderTransitionPageMode.NEXT;
const forceShowTransitionPage =
forceShowFirstChapterPreviousTransitionPage || forceShowLastChapterNextTransitionPage;
if (!shouldShowTransitionPage && !forceShowTransitionPage) {
return null;
}
if (!isTransitionPageVisible(type, transitionPageMode, readingMode)) {
return null;
}
return (
<Stack
sx={{
justifyContent: 'center',
alignItems: 'center',
...applyStyles(!isContinuousReadingMode(readingMode), {
width: '100%',
height: '100%',
}),
...applyStyles(isContinuousReadingMode(readingMode), {
position: 'sticky',
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
left: 0,
maxWidth: `calc(100vw - ${scrollbarYSize}px - ${readerNavBarWidth}px)`,
minHeight: `calc(100vh - ${scrollbarXSize}px)`,
}),
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
top: 0,
minWidth: `calc(100vw - ${scrollbarYSize}px - ${readerNavBarWidth}px)`,
maxHeight: `calc(100vh - ${scrollbarXSize}px)`,
}),
}),
}}
>
<Stack
sx={{
gap: 2,
maxWidth: (theme) =>
// spacing = added padding left + right
`calc(100vw - ${scrollbarYSize}px - ${readerNavBarWidth}px - ${theme.spacing(2)})`,
maxHeight: `calc(100vh - ${scrollbarXSize}px)`,
width: 'max-content',
p: 1,
}}
>
{isPreviousType && isFirstChapter && (
<Typography variant="h6">{t('reader.transition_page.first_chapter')}</Typography>
)}
<Stack sx={{ gap: 5 }}>
{isPreviousType && !isFirstChapter && (
<ChapterInfo
title={t('reader.transition_page.previous')}
name={previousChapterName}
scanlator={previousChapterScanlator}
backgroundColor={backgroundColor}
/>
)}
{!!currentChapterName && (
<ChapterInfo
title={t(
isPreviousType ? 'reader.transition_page.current' : 'reader.transition_page.finished',
)}
name={currentChapterName}
scanlator={currentChapterScanlator}
backgroundColor={backgroundColor}
/>
)}
{isNextType && !isLastChapter && (
<ChapterInfo
title={t('reader.transition_page.next')}
name={nextChapterName}
scanlator={nextChapterScanlator}
backgroundColor={backgroundColor}
/>
)}
</Stack>
{isNextType && isLastChapter && (
<Typography variant="h6">{t('reader.transition_page.last_chapter')}</Typography>
)}
{((isPreviousType && isFirstChapter) || (isNextType && isLastChapter)) && (
<Stack sx={{ flexDirection: 'row', flexWrap: 'wrap', gap: 1 }}>
<Button
sx={{ flexGrow: 1 }}
onClick={(e) => {
e.stopPropagation();
handleBack();
}}
variant="contained"
>
{t('reader.transition_page.exit.previous_page')}
</Button>
<Button
sx={{ flexGrow: 1 }}
component={Link}
onClick={(e) => {
e.stopPropagation();
}}
variant="contained"
to={AppRoutes.manga.path(manga?.id ?? -1)}
>
{t('reader.transition_page.exit.manga_page')}
</Button>
</Stack>
)}
</Stack>
</Stack>
);
};
export const ReaderTransitionPage = withPropsFrom(
memo(BaseReaderTransitionPage) as typeof BaseReaderTransitionPage,
[
useReaderStateMangaContext,
({ chapterId }: Pick<ComponentProps<typeof BaseReaderTransitionPage>, 'chapterId'>) => {
const { chapters } = useReaderStateChaptersContext();
const currentChapterIndex = useMemo(
() => chapters.findIndex((chapter) => chapter.id === chapterId),
[chapterId, chapters],
);
const currentChapter = chapters[currentChapterIndex];
// chapters are sorted from latest to oldest
const previousChapter = useMemo(() => chapters[currentChapterIndex + 1], [currentChapterIndex, chapters]);
const nextChapter = useMemo(() => chapters[currentChapterIndex - 1], [currentChapterIndex, chapters]);
return {
currentChapterName: currentChapter?.name,
currentChapterScanlator: currentChapter?.scanlator,
previousChapterName: previousChapter?.name,
previousChapterScanlator: previousChapter?.name,
nextChapterName: nextChapter?.name,
nextChapterScanlator: nextChapter?.scanlator,
};
},
useReaderScrollbarContext,
useNavBarContext,
userReaderStatePagesContext,
ReaderService.useSettingsWithoutDefaultFlag,
({ chapterId, type }: Pick<ComponentProps<typeof BaseReaderTransitionPage>, 'chapterId' | 'type'>) => {
const handleBack = useBackButton();
const { chapters } = useReaderStateChaptersContext();
const currentChapterIndex = useMemo(
() => chapters.findIndex((chapter) => chapter.id === chapterId),
[chapterId, chapters],
);
// chapters are sorted from latest to oldest
const isLastChapter = currentChapterIndex === 0;
const isFirstChapter = currentChapterIndex === chapters.length - 1;
const handleBackFirstChapter = type === ReaderTransitionPageMode.PREVIOUS && isFirstChapter;
const handleBackLastChapter = type === ReaderTransitionPageMode.NEXT && isLastChapter;
const needsToHandleBack = handleBackFirstChapter || handleBackLastChapter;
return {
handleBack: needsToHandleBack ? handleBack : noOp,
};
},
],
[
'manga',
'currentChapterName',
'currentChapterScanlator',
'previousChapterName',
'previousChapterScanlator',
'nextChapterName',
'nextChapterScanlator',
'scrollbarXSize',
'scrollbarYSize',
'readerNavBarWidth',
'backgroundColor',
'transitionPageMode',
'readingMode',
'handleBack',
'shouldShowTransitionPage',
],
);

View File

@@ -0,0 +1,47 @@
/*
* 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, useState } from 'react';
import { ReaderPageSpreadState, ReadingMode } from '@/features/reader/Reader.types.ts';
import { getNextIndexFromPage, getPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { createPagesData } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
export const useReaderConvertPagesForReadingMode = (
currentPageIndex: number,
actualPages: ReaderStatePages['pages'],
pageUrls: ReaderStatePages['pageUrls'],
setPages: ReaderStatePages['setPages'],
setPagesToSpreadState: (states: ReaderPageSpreadState[]) => void,
updateCurrentPageIndex: ReturnType<typeof ReaderControls.useUpdateCurrentPageIndex>,
readingMode: ReadingMode,
) => {
const [wasDoublePageMode, setWasDoublePageMode] = useState(readingMode === ReadingMode.DOUBLE_PAGE);
useLayoutEffect(() => {
const convertPagesToNormalPageMode = wasDoublePageMode && readingMode !== ReadingMode.DOUBLE_PAGE;
if (convertPagesToNormalPageMode) {
setWasDoublePageMode(false);
const newPageData = createPagesData(pageUrls);
setPages(newPageData);
setPagesToSpreadState(newPageData.map(({ primary: { url } }) => ({ url, isSpread: false })));
return;
}
const convertPagesToDoublePageMode = readingMode === ReadingMode.DOUBLE_PAGE;
if (convertPagesToDoublePageMode) {
if (!wasDoublePageMode) {
updateCurrentPageIndex(getNextIndexFromPage(getPage(currentPageIndex, actualPages)));
}
setPages(actualPages);
setWasDoublePageMode(readingMode === ReadingMode.DOUBLE_PAGE);
}
}, [actualPages, readingMode]);
};

View File

@@ -0,0 +1,54 @@
/*
* 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 { MutableRefObject, useLayoutEffect } from 'react';
import { Direction } from '@mui/material/styles';
import { ReadingDirection } from '@/features/reader/Reader.types.ts';
import { getNextIndexFromPage, getPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { DirectionOffset } from '@/Base.types.ts';
import { getScrollIntoViewInlineOption } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
export const useReaderHandlePageSelection = (
pageToScrollToIndex: ReaderStatePages['pageToScrollToIndex'],
currentPageIndex: number,
pages: ReaderStatePages['pages'],
totalPages: number,
setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'],
updateCurrentPageIndex: ReturnType<typeof ReaderControls.useUpdateCurrentPageIndex>,
isContinuousReadingModeActive: boolean,
imageRefs: MutableRefObject<(HTMLElement | null)[]>,
themeDirection: Direction,
readingDirection: ReadingDirection,
) => {
useLayoutEffect(() => {
if (pageToScrollToIndex == null) {
return;
}
const pageToScrollTo = getPage(pageToScrollToIndex, pages);
if (isContinuousReadingModeActive) {
const directionOffset =
pageToScrollToIndex > currentPageIndex ? DirectionOffset.PREVIOUS : DirectionOffset.NEXT;
const imageRef = imageRefs.current[pageToScrollTo.pagesIndex];
imageRef?.scrollIntoView({
block: 'start',
inline: getScrollIntoViewInlineOption(directionOffset, themeDirection, readingDirection),
});
}
const newPageIndex = getNextIndexFromPage(pageToScrollTo);
const isLastPage = newPageIndex === totalPages - 1;
setPageToScrollToIndex(null);
updateCurrentPageIndex(newPageIndex, !isLastPage, isLastPage);
}, [pageToScrollToIndex]);
};

View File

@@ -0,0 +1,40 @@
/*
* 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 { MutableRefObject, useEffect, useRef } from 'react';
export const useReaderHideCursorOnInactivity = (scrollElementRef: MutableRefObject<HTMLDivElement | null>) => {
const mouseInactiveTimeout = useRef<NodeJS.Timeout>(undefined);
useEffect(() => {
const setCursorVisibility = (visible: boolean) => {
const scrollElement = scrollElementRef.current;
if (!scrollElement) {
return;
}
scrollElement.style.cursor = visible ? 'default' : 'none';
};
const handleMouseMove = () => {
setCursorVisibility(true);
clearTimeout(mouseInactiveTimeout.current);
mouseInactiveTimeout.current = setTimeout(() => {
setCursorVisibility(false);
}, 5000);
};
handleMouseMove();
window.addEventListener('mousemove', handleMouseMove);
return () => {
setCursorVisibility(true);
window.removeEventListener('mousemove', handleMouseMove);
clearTimeout(mouseInactiveTimeout.current);
};
}, []);
};

View File

@@ -0,0 +1,75 @@
/*
* 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 { MutableRefObject, useEffect } from 'react';
import { ReadingDirection, ReadingMode } from '@/features/reader/Reader.types.ts';
export const useReaderHorizontalModeInvertXYScrolling = (
readingMode: ReadingMode,
readingDirection: ReadingDirection,
scrollElementRef: MutableRefObject<HTMLDivElement | null>,
) => {
// invert x and y scrolling for the continuous horizontal reading mode
useEffect(() => {
if (readingMode !== ReadingMode.CONTINUOUS_HORIZONTAL) {
return () => {};
}
if (!scrollElementRef.current) {
return () => {};
}
let previousDeltaX: number | undefined;
let previousDeltaY: number | undefined;
// Trackpads can be identified by checking the delta values.
// For a trackpad these values are not consistent since it basically behaves like scrolling via touch.
// While for a mouse the values should always have the same value because each wheel turn scrolls the same exact amount.
let isTrackpad: boolean | undefined;
const handleScroll = (e: WheelEvent) => {
// Trackpads can scroll horizontally without the need of having "shift" pressed.
// This is a problem because the hook will consider this to be a vertical scroll event and will invert it, which
// breaks scrolling horizontally on trackpads.
if (isTrackpad === true) {
return;
}
e.preventDefault();
const isConsistentDeltaX = !Math.abs(Math.abs(previousDeltaX ?? e.deltaX) - Math.abs(e.deltaX));
const isConsistentDeltaY = !Math.abs(Math.abs(previousDeltaY ?? e.deltaY) - Math.abs(e.deltaY));
previousDeltaX = e.deltaX;
previousDeltaY = e.deltaY;
if (!isTrackpad) {
isTrackpad = !isConsistentDeltaX || !isConsistentDeltaY;
}
const preventInversion = isTrackpad === undefined;
if (preventInversion) {
return;
}
if (e.shiftKey) {
scrollElementRef.current?.scrollBy({
top: e.deltaY,
});
return;
}
scrollElementRef.current?.scrollBy({
left: readingDirection === ReadingDirection.LTR ? e.deltaY : e.deltaY * -1,
});
};
scrollElementRef.current.addEventListener('wheel', handleScroll);
return () => scrollElementRef.current?.removeEventListener('wheel', handleScroll);
}, [readingMode, readingDirection]);
};

View File

@@ -0,0 +1,361 @@
/*
* 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 { RefObject, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { useWindowEvent } from '@mantine/hooks';
import {
ReaderPageScaleMode,
ReaderStateChapters,
ReadingDirection,
ReadingMode,
} from '@/features/reader/Reader.types.ts';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import {
isContinuousReadingMode,
isContinuousVerticalReadingMode,
isHeightPageScaleMode,
isReaderWidthEditable,
} from '@/features/reader/settings/ReaderSettings.utils.tsx';
import { getPreviousNextChapterVisibility } from '@/features/reader/Reader.utils.ts';
import { ChapterIdInfo, TChapterReader } from '@/features/chapter/Chapter.types.ts';
const shouldPreserveOnResizeChange = (
readingMode: ReadingMode,
pageScaleMode: ReaderPageScaleMode,
previousWidth: number,
previousHeight: number,
): boolean => {
const isEditableReaderWidth = isReaderWidthEditable(pageScaleMode);
const isHeightPageScaleModeActive = isHeightPageScaleMode(pageScaleMode);
const didWidthChange = previousWidth !== window.innerWidth;
const didHeightChange = previousHeight !== window.innerHeight;
const handleWidthChange = isEditableReaderWidth && didWidthChange;
const handleHeightChange = isHeightPageScaleModeActive && didHeightChange;
if (!isContinuousVerticalReadingMode(readingMode) && didWidthChange) {
return true;
}
return handleWidthChange || handleHeightChange;
};
const usePreserveOnValueChange = (
value: unknown,
currentPageIndex: number,
setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'],
) => {
useLayoutEffect(() => {
setPageToScrollToIndex(currentPageIndex);
}, [value]);
};
const usePreserveOnWindowResize = (
readingMode: ReadingMode,
pageScaleMode: ReaderPageScaleMode,
setPageToScrollToIndex: React.Dispatch<React.SetStateAction<number | null>>,
pageIndex: number,
) => {
const previousDimensionsRef = useRef({ width: window.innerWidth, height: window.innerHeight });
const handleResize = useCallback(() => {
const { width, height } = previousDimensionsRef.current;
previousDimensionsRef.current = { width: window.innerWidth, height: window.innerHeight };
if (!shouldPreserveOnResizeChange(readingMode, pageScaleMode, width, height)) {
return;
}
setPageToScrollToIndex(pageIndex);
}, [readingMode, pageScaleMode, pageIndex]);
useWindowEvent('resize', handleResize);
};
interface ScrollPreservationInfo {
left: number;
top: number;
visibleElement: HTMLElement | undefined;
visibleElementLeft: number;
visibleElementTop: number;
}
const useScrollPreservationData = (
scrollElementRef: RefObject<HTMLElement | null>,
): RefObject<ScrollPreservationInfo> => {
const dataRef = useRef<ScrollPreservationInfo>({
left: 0,
top: 0,
visibleElement: undefined,
visibleElementLeft: 0,
visibleElementTop: 0,
});
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement) {
return () => {};
}
const onScroll = () => {
const { visibleElement } = dataRef.current;
dataRef.current = {
...dataRef.current,
left: scrollElement.scrollLeft,
top: scrollElement.scrollTop,
visibleElementLeft: visibleElement?.offsetLeft ?? 0,
visibleElementTop: visibleElement?.offsetTop ?? 0,
};
};
scrollElement.addEventListener('scroll', onScroll);
return () => scrollElement.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement) {
return () => {};
}
const updateObservation = (nodes: NodeList, intersectionAction: (element: Element) => void) =>
Array.from(nodes)
.filter((node) => node instanceof HTMLElement)
.flatMap((element) =>
element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img')),
)
.forEach(intersectionAction);
const intersectionObserver = new IntersectionObserver((entries) => {
const firstVisibleElement = entries.find((entry) => entry.isIntersecting);
if (!(firstVisibleElement?.target instanceof HTMLElement)) {
return;
}
dataRef.current = {
...dataRef.current,
visibleElement: firstVisibleElement.target,
visibleElementLeft: firstVisibleElement.target.offsetLeft,
visibleElementTop: firstVisibleElement.target.offsetTop,
};
});
const mutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
updateObservation(entry.addedNodes, (element) => intersectionObserver.observe(element));
updateObservation(entry.removedNodes, (element) => intersectionObserver.unobserve(element));
}
});
mutationObserver.observe(scrollElement, {
childList: true,
subtree: true,
});
return () => {
mutationObserver.disconnect();
intersectionObserver.disconnect();
};
}, []);
return dataRef;
};
const usePreserveOnLeadingPageRender = (scrollElementRef: RefObject<HTMLElement | null>, readingMode: ReadingMode) => {
const preservationData = useScrollPreservationData(scrollElementRef);
const isContinuousReadingModeActive = isContinuousReadingMode(readingMode);
const isContinuousVerticalReadingModeActive = isContinuousVerticalReadingMode(readingMode);
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement || !isContinuousReadingModeActive) {
return () => {};
}
const preserveScrollPosition: ResizeObserverCallback = (entries) => {
const { left, top, visibleElement, visibleElementLeft, visibleElementTop } = preservationData.current;
if (!visibleElement) {
return;
}
const entriesBeforeScrollPosition = entries.filter((entry) => {
if (!(entry.target instanceof HTMLElement)) {
return false;
}
const isPreloadPage = !entry.target.clientWidth && !entry.target.clientHeight;
if (isPreloadPage) {
return false;
}
if (isContinuousVerticalReadingModeActive) {
return entry.target.offsetTop < top;
}
return Math.abs(entry.target.offsetLeft) < Math.abs(left);
});
const includesElementsBeforeScrollPosition = !!entriesBeforeScrollPosition.length;
if (!includesElementsBeforeScrollPosition) {
return;
}
const newLeft = left - visibleElementLeft + visibleElement.offsetLeft;
const newTop = top - visibleElementTop + visibleElement.offsetTop;
scrollElement.scrollTo(newLeft, newTop);
};
const updateObservation = (nodes: NodeList, resizeAction: (element: Element) => void) =>
Array.from(nodes)
.filter((node) => node instanceof HTMLElement)
.flatMap((element) =>
element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img')),
)
.forEach(resizeAction);
const resizeObserver = new ResizeObserver(preserveScrollPosition);
const mutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
updateObservation(entry.addedNodes, (element) => resizeObserver.observe(element));
updateObservation(entry.removedNodes, (element) => resizeObserver.unobserve(element));
}
});
mutationObserver.observe(scrollElement, {
childList: true,
subtree: true,
});
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [isContinuousReadingModeActive, isContinuousVerticalReadingModeActive]);
};
const usePreserveOnInfiniteScrollPreviousChapterInitialRender = (
scrollElementRef: RefObject<HTMLElement | null>,
currentChapterId: ChapterIdInfo['id'] | undefined,
currentChapterIndex: number,
currentPageIndex: number,
chaptersToRender: TChapterReader[],
visibleChapters: ReaderStateChapters['visibleChapters'],
isContinuousReadingModeActive: boolean,
) => {
const preservationDataRef = useScrollPreservationData(scrollElementRef);
const preserveScrollPosition = (): boolean => {
const scrollElement = scrollElementRef.current;
const { left, top, visibleElementLeft, visibleElementTop, visibleElement } = preservationDataRef.current;
if (!scrollElement || !isContinuousReadingModeActive || !visibleElement) {
return false;
}
const previousNextChapterVisibility = getPreviousNextChapterVisibility(
currentChapterIndex,
chaptersToRender,
visibleChapters,
);
const isRenderOfPreviousChapter = currentPageIndex === 0;
// only relevant when prepending content to the dom due to the resulting layout shift
const isFirstRenderOfPreviousChapter = !previousNextChapterVisibility.previous && isRenderOfPreviousChapter;
if (!isFirstRenderOfPreviousChapter) {
return false;
}
const newLeft = left - visibleElementLeft + visibleElement.offsetLeft;
const newTop = top - visibleElementTop + visibleElement.offsetTop;
scrollElement.scrollTo(newLeft, newTop);
return true;
};
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement || !isContinuousReadingModeActive) {
return () => {};
}
let preservedScrollPosition = false;
const updateObservation = (nodes: NodeList, resizeAction: (element: Element) => void) => {
Array.from(nodes)
.filter((node) => node instanceof HTMLElement)
.flatMap((element) =>
element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img')),
)
.forEach(resizeAction);
};
const resizeObserver = new ResizeObserver(() => {
if (preservedScrollPosition) {
return;
}
preservedScrollPosition = preserveScrollPosition();
if (preservedScrollPosition) {
resizeObserver.disconnect();
}
});
const mutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
updateObservation(entry.addedNodes, (element) => resizeObserver.observe(element));
updateObservation(entry.removedNodes, (element) => resizeObserver.unobserve(element));
}
});
mutationObserver.observe(scrollElement, {
childList: true,
subtree: true,
});
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [currentChapterId]);
useLayoutEffect(() => {
preserveScrollPosition();
}, [currentChapterId]);
};
export const useReaderPreserveScrollPosition = (
scrollElementRef: RefObject<HTMLElement | null>,
currentChapterId: ChapterIdInfo['id'] | undefined,
currentChapterIndex: number,
currentPageIndex: number,
chaptersToRender: TChapterReader[],
visibleChapters: ReaderStateChapters['visibleChapters'],
readingMode: ReadingMode,
readingDirection: ReadingDirection,
setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'],
pageScaleMode: ReaderPageScaleMode,
) => {
usePreserveOnInfiniteScrollPreviousChapterInitialRender(
scrollElementRef,
currentChapterId,
currentChapterIndex,
currentPageIndex,
chaptersToRender,
visibleChapters,
isContinuousReadingMode(readingMode),
);
usePreserveOnLeadingPageRender(scrollElementRef, readingMode);
usePreserveOnWindowResize(readingMode, pageScaleMode, setPageToScrollToIndex, currentPageIndex);
usePreserveOnValueChange(readingDirection, currentPageIndex, setPageToScrollToIndex);
usePreserveOnValueChange(readingMode, currentPageIndex, setPageToScrollToIndex);
};

View File

@@ -0,0 +1,30 @@
/*
* 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 { MutableRefObject, useLayoutEffect } from 'react';
import { Direction } from '@mui/material/styles';
import { ReadingDirection } from '@/features/reader/Reader.types.ts';
import { getScrollToXForReadingDirection } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
export const useReaderScrollToStartOnPageChange = (
currentPageIndex: ReaderStatePages['currentPageIndex'],
isContinuousReadingModeActive: boolean,
themeDirection: Direction,
readingDirection: ReadingDirection,
scrollElementRef: MutableRefObject<HTMLDivElement | null>,
): void => {
useLayoutEffect(() => {
if (!isContinuousReadingModeActive) {
scrollElementRef.current?.scrollTo(
getScrollToXForReadingDirection(scrollElementRef.current, themeDirection, readingDirection),
0,
);
}
}, [currentPageIndex]);
};

View File

@@ -0,0 +1,81 @@
/*
* 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, useRef } from 'react';
import { getInitialReaderPageIndex } from '@/features/reader/Reader.utils.ts';
import { createPagesData } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import {
ReaderPageSpreadState,
ReaderResumeMode,
ReaderStateChapters,
ReaderTransitionPageMode,
} from '@/features/reader/Reader.types.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { TChapterReader } from '@/features/chapter/Chapter.types.ts';
export const useReaderSetPagesState = (
isCurrentChapter: boolean,
pagesResponse: ReturnType<typeof requestManager.useGetChapterPagesFetch>[1],
resumeMode: ReaderResumeMode,
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'],
) => {
const previousPageData = useRef<string[]>(undefined);
useLayoutEffect(() => {
const pagesPayload = pagesResponse.data?.fetchChapterPages;
if (!pagesPayload) {
return;
}
const { pages: pagesFromResponse } = pagesPayload;
const newPages = pagesFromResponse.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);
setPages(newPageData);
setPageUrls(newPages);
setPageLoadStates(newPageData.map(({ primary: { url } }) => ({ url, loaded: false })));
setPagesToSpreadState(newPageData.map(({ primary: { url } }) => ({ url, isSpread: false })));
} else {
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, isCurrentChapter]);
};

View File

@@ -0,0 +1,634 @@
/*
* 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 { Direction, Theme } from '@mui/material/styles';
import { ComponentProps, ReactNode } from 'react';
import {
IReaderSettings,
PageInViewportType,
ReaderPageScaleMode,
ReaderPageSpreadState,
ReaderTransitionPageMode,
ReadingDirection,
ReadingMode,
TReaderScrollbarContext,
} from '@/features/reader/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import {
getSetReaderWidth,
isContinuousReadingMode,
isContinuousVerticalReadingMode,
shouldApplyReaderWidth,
} from '@/features/reader/settings/ReaderSettings.utils.tsx';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ReaderPage } from '@/features/reader/viewer/components/ReaderPage.tsx';
import { reverseString } from '@/util/Strings.ts';
import { getPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { DirectionOffset } from '@/Base.types.ts';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { READING_DIRECTION_TO_THEME_DIRECTION } from '@/features/reader/settings/ReaderSettings.constants.tsx';
import { coerceIn } from '@/lib/HelperFunctions.ts';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
type CSSObject = ReturnType<Theme['applyStyles']>;
const getPageWidthPercentage = (
pageScaleMode: IReaderSettings['pageScaleMode'],
isDoublePage: boolean,
readerWidth: IReaderSettings['readerWidth'],
isImage: boolean,
): number => {
if (shouldApplyReaderWidth(readerWidth, pageScaleMode)) {
const width = readerWidth.value;
if (isImage && isDoublePage) {
return width / 100 / 2;
}
return width / 100;
}
if (isImage && isDoublePage) {
return 0.5;
}
return 1;
};
export const getImagePlaceholderStyling = (
readingMode: IReaderSettings['readingMode'],
shouldStretchPage: IReaderSettings['shouldStretchPage'],
pageScaleMode: IReaderSettings['pageScaleMode'],
readerWidth: IReaderSettings['readerWidth'],
widthOffset: number,
heightOffset: number,
isDoublePage?: boolean,
isTabletWidth?: boolean,
): CSSObject => {
const OVER_9000 = 9000;
const getMaxWidth = (width: string) => `calc(${width} - ${widthOffset}px)`;
const getDesktopWidth = (width: number, readerWidthValue?: number) =>
getMaxWidth(`${coerceIn(readerWidthValue ?? width, Math.min(width, readerWidthValue ?? OVER_9000), width)}vw`);
const setReaderWidth = getSetReaderWidth(readerWidth, pageScaleMode);
const fullWidth = getMaxWidth(`${Math.min(100, setReaderWidth ?? OVER_9000)}vw`);
const fullHeight = `calc(100vh - ${heightOffset}px)`;
const DEFAULT_SINGLE_PAGE_WIDTH = isTabletWidth ? fullWidth : getDesktopWidth(40, setReaderWidth);
const DEFAULT_SINGLE_PAGE_HEIGHT = isTabletWidth ? fullHeight : '85vh';
const READING_MODE_TO_PLACEHOLDER_SIZE: Record<ReadingMode, { minWidth: string; minHeight: string }> = {
[ReadingMode.SINGLE_PAGE]: { minWidth: DEFAULT_SINGLE_PAGE_WIDTH, minHeight: DEFAULT_SINGLE_PAGE_HEIGHT },
[ReadingMode.DOUBLE_PAGE]: {
minWidth: isTabletWidth ? '50vw' : getDesktopWidth(35, setReaderWidth ? setReaderWidth / 2 : undefined),
minHeight: DEFAULT_SINGLE_PAGE_HEIGHT,
},
[ReadingMode.CONTINUOUS_VERTICAL]: {
minWidth: DEFAULT_SINGLE_PAGE_WIDTH,
minHeight: '100vh',
},
[ReadingMode.CONTINUOUS_HORIZONTAL]: {
minWidth: DEFAULT_SINGLE_PAGE_WIDTH,
minHeight: DEFAULT_SINGLE_PAGE_HEIGHT,
},
[ReadingMode.WEBTOON]: {
minWidth: DEFAULT_SINGLE_PAGE_WIDTH,
minHeight: '100vh',
},
};
const defaultStyling = {
...READING_MODE_TO_PLACEHOLDER_SIZE[readingMode],
// the SpinnerImage placeholder has a default height of 100%, this causes the placeholder to take always take up 100% of the viewport
height: 'unset',
};
const minWidthForStretch = isDoublePage ? `calc(${getMaxWidth(`${setReaderWidth ?? 100}vw`)} / 2)` : fullWidth;
switch (pageScaleMode) {
case ReaderPageScaleMode.WIDTH:
return {
...defaultStyling,
...applyStyles(shouldStretchPage, {
minWidth: minWidthForStretch,
}),
};
case ReaderPageScaleMode.HEIGHT:
return {
...defaultStyling,
...applyStyles(shouldStretchPage, {
minHeight: fullHeight,
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
minHeight: '100vh',
}),
}),
};
case ReaderPageScaleMode.SCREEN:
return {
...defaultStyling,
...applyStyles(shouldStretchPage, {
minWidth: minWidthForStretch,
minHeight: fullHeight,
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
minHeight: '100vh',
}),
}),
};
case ReaderPageScaleMode.ORIGINAL:
return {
...defaultStyling,
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
height: undefined,
}),
};
default:
throw new Error(`Unexpected "PageScaleMode" (${pageScaleMode})`);
}
};
const getReaderDimensionStyling = (
widthPercentage: number,
readingMode: IReaderSettings['readingMode'],
shouldStretchPage: IReaderSettings['shouldStretchPage'],
pageScaleMode: IReaderSettings['pageScaleMode'],
widthOffset: number,
heightOffset: number,
): CSSObject => {
const fullWidth = `calc((100vw - ${widthOffset}px) * ${widthPercentage})`;
const fullHeight = `calc(100vh - ${heightOffset}px)`;
switch (pageScaleMode) {
case ReaderPageScaleMode.WIDTH:
return {
minWidth: 0,
...applyStyles(isContinuousReadingMode(readingMode), {
minWidth: 'unset',
}),
...applyStyles(shouldStretchPage, {
minWidth: fullWidth,
}),
maxWidth: fullWidth,
};
case ReaderPageScaleMode.HEIGHT:
return {
minHeight: 0,
...applyStyles(isContinuousReadingMode(readingMode), {
minHeight: 'unset',
}),
...applyStyles(shouldStretchPage, {
minHeight: fullHeight,
}),
maxHeight: fullHeight,
};
case ReaderPageScaleMode.SCREEN:
return {
minWidth: 0,
minHeight: 0,
...applyStyles(isContinuousReadingMode(readingMode), {
minWidth: 'unset',
minHeight: 'unset',
}),
...applyStyles(shouldStretchPage, {
minWidth: fullWidth,
minHeight: fullHeight,
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
minWidth: 'unset',
minHeight: fullHeight,
}),
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
minWidth: fullWidth,
minHeight: 'unset',
}),
}),
maxWidth: fullWidth,
maxHeight: fullHeight,
};
case ReaderPageScaleMode.ORIGINAL:
return {};
default:
throw new Error(`Unexpected "PageScaleMode" (${pageScaleMode})`);
}
};
export const getReaderImageStyling = (
readingMode: IReaderSettings['readingMode'],
shouldStretchPage: IReaderSettings['shouldStretchPage'],
pageScaleMode: IReaderSettings['pageScaleMode'],
isDoublePage: boolean,
readerWidth: IReaderSettings['readerWidth'],
widthOffset: number,
heightOffset: number,
): CSSObject => {
const widthPercentage = getPageWidthPercentage(pageScaleMode, isDoublePage, readerWidth, true);
return getReaderDimensionStyling(
widthPercentage,
readingMode,
shouldStretchPage,
pageScaleMode,
widthOffset,
heightOffset,
);
};
export const getImageMarginStyling = (doublePage: boolean, objectFitPosition?: 'left' | 'right'): CSSObject => ({
m: 'auto',
...applyStyles(doublePage, {
// the margin on the object fit position needs to be removed so that there is no space between both images
...applyStyles(objectFitPosition === 'right', { mr: 'unset ' }),
...applyStyles(objectFitPosition === 'left', { ml: 'unset ' }),
}),
});
export const createSinglePageData = (url: string, index: number): ReaderStatePages['pages'][number]['primary'] => ({
index,
alt: `Page #${index + 1}`,
url: `${requestManager.getBaseUrl()}${url}`,
});
export const createPageData = (url: string, index: number): ReaderStatePages['pages'][number] => ({
name: `${index + 1}`,
primary: createSinglePageData(url, index),
});
export const createPagesData = (pageUrls: string[]): ReaderStatePages['pages'] => pageUrls.map(createPageData);
const getPageDownloadPriority = (
currentPageIndex: number,
pageIndex: number,
totalPages: number,
shouldLoad: boolean,
isPreloadMode: boolean,
): number => {
if (!shouldLoad || isPreloadMode) {
return Number.MAX_SAFE_INTEGER;
}
const distanceToCurrentPage = Math.abs(pageIndex - currentPageIndex);
const priorityBasedOnDistance = totalPages - distanceToCurrentPage;
const isPreviousPage = pageIndex < currentPageIndex;
if (isPreviousPage) {
return priorityBasedOnDistance - 1;
}
return priorityBasedOnDistance;
};
export const createReaderPage = (
{ primary: { index, alt, url } }: ReaderStatePages['pages'][number],
pagesIndex: number,
isPrimaryPage: boolean,
isLoaded: boolean,
isPreloadMode: boolean,
onLoad: ComponentProps<typeof ReaderPage>['onLoad'],
onError: ComponentProps<typeof ReaderPage>['onError'],
shouldLoad: boolean,
display: boolean,
currentPageIndex: number,
totalPages: number,
readingMode: IReaderSettings['readingMode'],
customFilter: IReaderSettings['customFilter'],
pageScaleMode: IReaderSettings['pageScaleMode'],
shouldStretchPage: IReaderSettings['shouldStretchPage'],
readerWidth: IReaderSettings['readerWidth'],
scrollbarXSize: TReaderScrollbarContext['scrollbarXSize'],
scrollbarYSize: TReaderScrollbarContext['scrollbarYSize'],
readerNavBarWidth: NavbarContextType['readerNavBarWidth'],
retryKeyPrefix?: string,
position?: 'left' | 'right',
isDoublePage?: boolean,
marginTop?: number,
setRef?: (pagesIndex: number, ref: HTMLElement | null) => void,
): ReactNode => (
<ReaderPage
setRef={setRef}
pageIndex={index}
pagesIndex={pagesIndex}
isPrimaryPage={isPrimaryPage}
key={url}
src={url}
alt={alt}
display={display}
priority={getPageDownloadPriority(currentPageIndex, index, totalPages, shouldLoad, isPreloadMode)}
position={position}
onLoad={onLoad}
onError={onError}
doublePage={isDoublePage}
shouldLoad={shouldLoad}
retryKeyPrefix={retryKeyPrefix}
marginTop={marginTop}
isLoaded={isLoaded}
readingMode={readingMode}
customFilter={customFilter}
pageScaleMode={pageScaleMode}
shouldStretchPage={shouldStretchPage}
readerWidth={readerWidth}
scrollbarXSize={scrollbarXSize}
scrollbarYSize={scrollbarYSize}
readerNavBarWidth={readerNavBarWidth}
/>
);
type InViewportThresholds = {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
const getIsPageInViewportInfo = (
element: HTMLElement,
/**
* Thresholds are not considered for the detection of an image filling the whole viewport.
* They are only used for detecting if a specific side of an image is inside the viewport
*/
argThresholds?: InViewportThresholds,
): {
isLeftInViewport: boolean;
isRightInViewport: boolean;
isFillingWidthViewportCompletely: boolean;
isTopInViewport: boolean;
isBottomInViewport: boolean;
isFillingHeightViewportCompletely: boolean;
} => {
const { top, bottom, left, right } = element.getBoundingClientRect();
const thresholds = {
top: 0,
bottom: 0,
left: 0,
right: 0,
...argThresholds,
};
const isLeftInViewport = left >= thresholds.left && left <= window.innerWidth;
const isRightInViewport = right >= thresholds.right && right <= window.innerWidth;
const isFillingWidthViewportCompletely = left <= 0 && right >= window.innerWidth;
const isTopInViewport = top >= thresholds.top && top <= window.innerHeight;
const isBottomInViewport = bottom >= thresholds.bottom && bottom <= window.innerHeight;
const isFillingHeightViewportCompletely = top <= 0 && bottom >= window.innerHeight;
return {
isLeftInViewport,
isRightInViewport,
isFillingWidthViewportCompletely,
isTopInViewport,
isBottomInViewport,
isFillingHeightViewportCompletely,
};
};
export const isPageInViewport = (
element: HTMLElement,
type: PageInViewportType,
thresholds?: InViewportThresholds,
): boolean => {
const {
isLeftInViewport,
isRightInViewport,
isFillingWidthViewportCompletely,
isTopInViewport,
isBottomInViewport,
isFillingHeightViewportCompletely,
} = getIsPageInViewportInfo(element, thresholds);
const isInViewportX = isLeftInViewport || isRightInViewport || isFillingWidthViewportCompletely;
const isInViewportY = isTopInViewport || isBottomInViewport || isFillingHeightViewportCompletely;
switch (type) {
case PageInViewportType.X:
return isInViewportX;
case PageInViewportType.Y:
return isInViewportY;
default:
throw new Error(`unexpected "type" (${type})`);
}
};
export const isEndOfPageInViewport = (
element: HTMLElement,
type: PageInViewportType,
direction: ReadingDirection,
): boolean => {
const { isLeftInViewport, isRightInViewport, isBottomInViewport } = getIsPageInViewportInfo(element);
switch (type) {
case PageInViewportType.X:
return direction === ReadingDirection.LTR ? isRightInViewport : isLeftInViewport;
case PageInViewportType.Y:
return isBottomInViewport;
default:
throw new Error(`unexpected "type" (${type})`);
}
};
export const getDoublePageModePages = (
pageUrls: ReaderStatePages['pageUrls'],
pagesToSpreadState: ReaderPageSpreadState[],
shouldOffsetDoubleSpreads: IReaderSettings['shouldOffsetDoubleSpreads'],
direction: ReadingDirection,
): ReaderStatePages['pages'] => {
const doublePageModePages: ReaderStatePages['pages'] = [];
// each spread page has to be counted as 2 pages and all trailing page numbers have to be increased by the count
// of leading spread pages
const pageToActualPageIndex = pagesToSpreadState.map(
(_, page) => page + pagesToSpreadState.slice(0, page).filter(({ isSpread }) => isSpread).length,
);
pageUrls.forEach((url, index) => {
/*
| = page separator
+ = double page
_ = double spread
without double spreads:
without double spread offset: | 0 + 1 | 2 + 3 | 4 + 5 | 6 + 7 | 8 |
with double spread offset : | 0 | 1 + 2 | 3 + 4 | 5 + 6 | 7 + 8 |
with double spreads
to handle double spreads:
each double spread has to count as 2 pages, thus, each page number after a double spread has to increase
by the number of leading double spreads
without double spread offset: | 0 + 1 | 2 | _3/4_ | 5 | 6 + 7 | 8 | _9/10_ | 11 | 12 + 13 | 14 |
with double spread offset : | 0 | 1 + 2 | _3/4_ | 5 + 6 | 7 + 8 | _9/10_ | 11 + 12 | 13 + 14 |
thus, to get the second page:
without offset: second page = current page number even ? +1 : -1
with offset : second page = current page number even ? -1 : +1
the second page has to be ignored in case:
- double spreads are offset, and the current page is the first page
- either the current or second page is a double spread
*/
const normalizedIndex = pageToActualPageIndex[index];
const isCurrentPageEven = !(normalizedIndex % 2);
const secondPageOffset = (() => {
const invert = shouldOffsetDoubleSpreads ? -1 : 1;
const offset = isCurrentPageEven ? 1 : -1;
return offset * invert;
})();
const secondPageIndex = index + secondPageOffset;
const isPrimaryPage = index < secondPageIndex;
const isFirstPage = index === 0;
const isLastPage = index === pageUrls.length - 1;
const hasSecondPage = isPrimaryPage && !isLastPage;
const isPrimaryPageSpreadPage = pagesToSpreadState[index].isSpread;
const isSecondPageSpreadPage = !!pagesToSpreadState[secondPageIndex]?.isSpread;
const hasSpreadPage = isPrimaryPageSpreadPage || isSecondPageSpreadPage;
const ignoreSecondPageDueToOffset = isFirstPage && shouldOffsetDoubleSpreads;
const displaySecondPage = !hasSpreadPage && !ignoreSecondPageDueToOffset;
if (!isPrimaryPage && displaySecondPage) {
const doublePageModePage = doublePageModePages[doublePageModePages.length - 1];
doublePageModePages[doublePageModePages.length - 1] = {
...doublePageModePage,
secondary: createSinglePageData(url, index),
};
return;
}
const SEPARATOR = '-';
const pageName = `${index + 1}${hasSecondPage && displaySecondPage ? `${SEPARATOR}${secondPageIndex + 1}` : ''}`;
doublePageModePages.push({
name: direction === ReadingDirection.LTR ? pageName : reverseString(pageName, SEPARATOR),
primary: createSinglePageData(url, index),
});
});
return doublePageModePages;
};
export const isSpreadPage = (image: HTMLImageElement): boolean => {
const aspectRatio = image.height / image.width;
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,
pages: ReaderStatePages['pages'],
previousCurrentPageIndex: number,
imagePreLoadAmount: number,
readingMode: ReadingMode,
isCurrentChapter: boolean,
isPreviousChapter: boolean,
isNextChapter: boolean,
): number[] => {
if (!isCurrentChapter && !isPreviousChapter && !isNextChapter) {
return [];
}
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, 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 + finalImagePreLoadAmount * directionInvert;
// add 1 for the current page in case it's the current chapter, otherwise, only the preload amount should get rendered
const pagesToRenderLength = Math.max(1, Math.abs(endPagesIndex - startPagesIndex) + Number(isCurrentChapter));
return Array(pagesToRenderLength)
.fill(1)
.map((_, index) => startPagesIndex + index * directionInvert);
};
export const isTransitionPageVisible = (
type: ReaderTransitionPageMode,
activeMode: ReaderTransitionPageMode,
readingMode: IReaderSettings['readingMode'],
): boolean => [ReaderTransitionPageMode.BOTH, type].includes(activeMode) || isContinuousReadingMode(readingMode);
export const isATransitionPageVisible = (activeMode: ReaderTransitionPageMode, readingMode: ReadingMode): boolean =>
activeMode !== ReaderTransitionPageMode.NONE || isContinuousReadingMode(readingMode);
export const getScrollIntoViewInlineOption = (
offset: DirectionOffset,
themeDirection: Direction,
readingDirection: ReadingDirection,
): ScrollIntoViewOptions['inline'] => {
const themeDirectionForReadingDirection = READING_DIRECTION_TO_THEME_DIRECTION[readingDirection];
if (themeDirection === 'ltr') {
if (offset === DirectionOffset.PREVIOUS) {
return getOptionForDirection('start', 'end', themeDirectionForReadingDirection);
}
return getOptionForDirection('start', 'end', themeDirectionForReadingDirection);
}
if (offset === DirectionOffset.PREVIOUS) {
return getOptionForDirection('end', 'start', themeDirectionForReadingDirection);
}
return getOptionForDirection('end', 'start', themeDirectionForReadingDirection);
};
export const getScrollToXForReadingDirection = (
element: HTMLElement,
themeDirection: Direction,
readingDirection: ReadingDirection,
): number => {
const themeDirectionForReadingDirection = READING_DIRECTION_TO_THEME_DIRECTION[readingDirection];
if (themeDirection === 'ltr') {
return getOptionForDirection(0, element.scrollWidth, themeDirectionForReadingDirection);
}
return getOptionForDirection(-element.scrollWidth, 0, themeDirectionForReadingDirection);
};
export const isPageOfOutdatedPageLoadStates = (
url: string,
pageLoadState: ReaderStatePages['pageLoadStates'][number] | undefined,
): boolean => pageLoadState === undefined || pageLoadState.url !== url;

View File

@@ -0,0 +1,160 @@
/*
* 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 { forwardRef, memo, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import Box, { BoxProps } from '@mui/material/Box';
import { getPageIndexesToLoad, isATransitionPageVisible } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import {
IReaderSettings,
ReaderPagerProps,
ReaderResumeMode,
ReaderTransitionPageMode,
} from '@/features/reader/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { isContinuousReadingMode } from '@/features/reader/settings/ReaderSettings.utils.tsx';
const getPreviousCurrentPageIndex = (resumeMode: ReaderResumeMode): number =>
resumeMode === ReaderResumeMode.END ? Number.MAX_SAFE_INTEGER : -1;
const BaseBasePager = forwardRef<
HTMLDivElement,
Omit<ReaderPagerProps, 'pageLoadStates' | 'retryFailedPagesKeyPrefix' | 'isPreloadMode'> &
Pick<IReaderSettings, 'readingMode' | 'imagePreLoadAmount'> & {
createPage: (
page: ReaderStatePages['pages'][number],
pagesIndex: number,
shouldLoad: boolean,
shouldDisplay: boolean,
setRef: (pagesIndex: number, element: HTMLElement | null) => void,
readingMode: ReaderPagerProps['readingMode'],
customFilter: ReaderPagerProps['customFilter'],
pageScaleMode: ReaderPagerProps['pageScaleMode'],
shouldStretchPage: ReaderPagerProps['shouldStretchPage'],
readerWidth: ReaderPagerProps['readerWidth'],
scrollbarXSize: ReaderPagerProps['scrollbarXSize'],
scrollbarYSize: ReaderPagerProps['scrollbarYSize'],
readerNavBarWidth: ReaderPagerProps['readerNavBarWidth'],
) => ReactNode;
slots?: { boxProps?: BoxProps };
}
>(
(
{
currentPageIndex,
pages,
transitionPageMode,
imageRefs,
createPage,
slots,
readingMode,
imagePreLoadAmount,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
customFilter,
pageScaleMode,
shouldStretchPage,
readerWidth,
scrollbarXSize,
scrollbarYSize,
readerNavBarWidth,
resumeMode,
handleAsInitialRender,
},
ref,
) => {
const previousCurrentPageIndex = useRef(getPreviousCurrentPageIndex(resumeMode));
if (handleAsInitialRender) {
previousCurrentPageIndex.current = getPreviousCurrentPageIndex(resumeMode);
}
const pagesIndexesToRender = useMemo(
() =>
getPageIndexesToLoad(
currentPageIndex,
pages,
previousCurrentPageIndex.current,
imagePreLoadAmount,
readingMode,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
),
[
currentPageIndex,
pages,
imagePreLoadAmount,
readingMode,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
],
);
useEffect(() => {
if (isCurrentChapter) {
previousCurrentPageIndex.current = currentPageIndex;
}
}, [pagesIndexesToRender, isCurrentChapter]);
const setRef = useCallback(
(pagesIndex: number, element: HTMLElement | null) => {
// eslint-disable-next-line no-param-reassign
imageRefs.current[pagesIndex] = element;
},
[imageRefs],
);
return (
<Box
ref={ref}
{...slots?.boxProps}
sx={[
{
width: 'fit-content',
height: 'fit-content',
},
...(Array.isArray(slots?.boxProps?.sx) ? (slots?.boxProps?.sx ?? []) : [slots?.boxProps?.sx]),
// hide pager, without actually unmounting it to prevent re-renders, while a chapter transition page is taking up the full screen
applyStyles(
!isContinuousReadingMode(readingMode) &&
isATransitionPageVisible(transitionPageMode, readingMode),
{
visibility: 'hidden',
width: 0,
height: 0,
m: 0,
p: 0,
},
),
]}
>
{pages.map((page, pagesIndex) =>
createPage(
page,
pagesIndex,
pagesIndexesToRender.includes(pagesIndex),
[ReaderTransitionPageMode.NONE, ReaderTransitionPageMode.BOTH].includes(transitionPageMode),
setRef,
readingMode,
customFilter,
pageScaleMode,
shouldStretchPage,
readerWidth,
scrollbarXSize,
scrollbarYSize,
readerNavBarWidth,
),
)}
</Box>
);
},
);
export const BasePager = memo(BaseBasePager);

View File

@@ -0,0 +1,134 @@
/*
* 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 { Direction, useTheme } from '@mui/material/styles';
import { forwardRef, Fragment, memo, useMemo } from 'react';
import { BasePager } from '@/features/reader/viewer/pager/components/BasePager.tsx';
import {
IReaderSettings,
ReaderPagerProps,
ReaderPageScaleMode,
ReadingDirection,
} from '@/features/reader/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { createReaderPage } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { getNextIndexFromPage, getPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
const getPagePosition = (
pageType: 'first' | 'second',
themeDirection: Direction,
readingDirection: ReadingDirection,
): 'left' | 'right' => {
const isLtrReadingDirection = readingDirection === ReadingDirection.LTR;
if (pageType === 'first') {
if (themeDirection === 'ltr') {
return isLtrReadingDirection ? 'right' : 'left';
}
return isLtrReadingDirection ? 'left' : 'right';
}
if (themeDirection === 'ltr') {
return isLtrReadingDirection ? 'left' : 'right';
}
return isLtrReadingDirection ? 'right' : 'left';
};
const BaseReaderDoublePagedPager = forwardRef<
HTMLDivElement,
ReaderPagerProps & Pick<IReaderSettings, 'readingDirection' | 'pageScaleMode'>
>(({ onLoad, onError, pageLoadStates, retryFailedPagesKeyPrefix, isPreloadMode, ...props }, ref) => {
const { currentPageIndex, pages, totalPages, readingDirection, pageScaleMode } = props;
const { direction: themeDirection } = useTheme();
const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]);
const isLtrReadingDirection = readingDirection === ReadingDirection.LTR;
return (
<BasePager
ref={ref}
{...props}
createPage={(page, pagesIndex, shouldLoad, shouldDisplay, _setRef, ...baseProps) => {
const { primary, secondary } = page;
const currentSecondaryPageIndex = getNextIndexFromPage(currentPage);
const hasSecondaryPage = !!secondary;
const isPrimaryPage = currentPage.primary.index === primary.index;
const isSecondaryPage = !!secondary && currentSecondaryPageIndex === secondary.index;
return (
<Fragment key={`${primary.url}_${secondary?.url}`}>
{createReaderPage(
page,
pagesIndex,
true,
pageLoadStates[primary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
shouldDisplay && isPrimaryPage && shouldLoad,
currentPage.primary.index,
totalPages,
...baseProps,
pageLoadStates[primary.index].error ? retryFailedPagesKeyPrefix : undefined,
hasSecondaryPage ? getPagePosition('first', themeDirection, readingDirection) : undefined,
hasSecondaryPage,
)}
{hasSecondaryPage &&
createReaderPage(
{ ...page, primary: { ...page.secondary! } },
pagesIndex,
false,
pageLoadStates[secondary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
shouldDisplay && isSecondaryPage && shouldLoad,
currentSecondaryPageIndex,
totalPages,
...baseProps,
pageLoadStates[secondary.index].error ? retryFailedPagesKeyPrefix : undefined,
getPagePosition('second', themeDirection, readingDirection),
true,
)}
</Fragment>
);
}}
slots={{
boxProps: {
sx: {
...applyStyles(pageScaleMode === ReaderPageScaleMode.ORIGINAL, {
margin: 'auto',
}),
width: '100%',
minWidth: 'fit-content',
height: '100%',
minHeight: 'fit-content',
display: 'flex',
flexDirection: 'row',
flexWrap: 'nowrap',
...applyStyles(themeDirection === 'ltr', {
flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse',
}),
...applyStyles(themeDirection === 'rtl', {
flexDirection: isLtrReadingDirection ? 'row-reverse' : 'row',
}),
},
},
}}
/>
);
});
export const ReaderDoublePagedPager = memo(BaseReaderDoublePagedPager);

View File

@@ -0,0 +1,74 @@
/*
* 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 { useTheme } from '@mui/material/styles';
import { forwardRef, memo } from 'react';
import { BasePager } from '@/features/reader/viewer/pager/components/BasePager.tsx';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { IReaderSettings, ReaderPagerProps, ReadingDirection } from '@/features/reader/Reader.types.ts';
import { createReaderPage } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
const BaseReaderHorizontalPager = forwardRef<
HTMLDivElement,
ReaderPagerProps & Pick<IReaderSettings, 'pageGap' | 'readingDirection'>
>(({ onLoad, onError, pageLoadStates, retryFailedPagesKeyPrefix, isPreloadMode, ...props }, ref) => {
const { currentPageIndex, totalPages, pageGap, readingDirection } = props;
const { direction: themeDirection } = useTheme();
const isLtrReadingDirection = readingDirection === ReadingDirection.LTR;
return (
<BasePager
ref={ref}
{...props}
createPage={(page, pagesIndex, shouldLoad, _, setRef, ...baseProps) =>
createReaderPage(
page,
pagesIndex,
true,
pageLoadStates[page.primary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
!isPreloadMode,
currentPageIndex,
totalPages,
...baseProps,
pageLoadStates[page.primary.index].error ? retryFailedPagesKeyPrefix : undefined,
undefined,
undefined,
undefined,
setRef,
)
}
slots={{
boxProps: {
sx: {
my: 'auto',
display: 'flex',
flexWrap: 'nowrap',
alignItems: 'center',
gap: `${pageGap}px`,
...applyStyles(themeDirection === 'ltr', {
flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse',
justifyContent: isLtrReadingDirection ? 'flex-start' : 'flex-end',
}),
...applyStyles(themeDirection === 'rtl', {
flexDirection: isLtrReadingDirection ? 'row-reverse' : 'row',
justifyContent: isLtrReadingDirection ? 'flex-end' : 'flex-start',
}),
},
},
}}
/>
);
});
export const ReaderHorizontalPager = memo(BaseReaderHorizontalPager);

View File

@@ -0,0 +1,51 @@
/*
* 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 { forwardRef, memo } from 'react';
import { BasePager } from '@/features/reader/viewer/pager/components/BasePager.tsx';
import { ReaderPagerProps } from '@/features/reader/Reader.types.ts';
import { createReaderPage } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
const BaseReaderPagedPager = forwardRef<HTMLDivElement, ReaderPagerProps>(
({ onLoad, onError, pageLoadStates, retryFailedPagesKeyPrefix, isPreloadMode, ...props }, ref) => {
const { currentPageIndex, totalPages } = props;
return (
<BasePager
ref={ref}
{...props}
createPage={(page, pagesIndex, shouldLoad, shouldDisplay, _setRef, ...baseProps) =>
createReaderPage(
page,
pagesIndex,
true,
pageLoadStates[page.primary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
shouldDisplay && shouldLoad && currentPageIndex === page.primary.index,
currentPageIndex,
totalPages,
...baseProps,
pageLoadStates[page.primary.index].error ? retryFailedPagesKeyPrefix : undefined,
)
}
slots={{
boxProps: {
sx: {
margin: 'auto',
},
},
}}
/>
);
},
);
export const ReaderPagedPager = memo(BaseReaderPagedPager);

View File

@@ -0,0 +1,52 @@
/*
* 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 { forwardRef, memo } from 'react';
import { BasePager } from '@/features/reader/viewer/pager/components/BasePager.tsx';
import { ReaderPagerProps, ReadingMode } from '@/features/reader/Reader.types.ts';
import { createReaderPage } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
const BaseReaderVerticalPager = forwardRef<HTMLDivElement, ReaderPagerProps>(
({ onLoad, onError, pageLoadStates, retryFailedPagesKeyPrefix, isPreloadMode, ...props }, ref) => {
const { currentPageIndex, totalPages, readingMode, pageGap } = props;
const isWebtoonMode = readingMode === ReadingMode.WEBTOON;
const actualPageGap = isWebtoonMode ? 0 : pageGap;
return (
<BasePager
ref={ref}
{...props}
createPage={(page, pagesIndex, shouldLoad, _, setRef, ...baseProps) =>
createReaderPage(
page,
pagesIndex,
true,
pageLoadStates[page.primary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
!isPreloadMode,
currentPageIndex,
totalPages,
...baseProps,
pageLoadStates[page.primary.index].error ? retryFailedPagesKeyPrefix : undefined,
undefined,
undefined,
page.primary.index !== 0 ? actualPageGap : 0,
setRef,
)
}
slots={{ boxProps: { sx: { margin: 'auto' } } }}
/>
);
},
);
export const ReaderVerticalPager = memo(BaseReaderVerticalPager);