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

424 lines
18 KiB
TypeScript

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { ForwardedRef, 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 {
PageInViewportType,
ReaderOpenChapterLocationState,
ReaderResumeMode,
ReadingDirection,
ReadingMode,
} from '@/features/reader/Reader.types.ts';
import { MediaQuery } from '@/base/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 '@/base/hooks/useMouseDragScroll.tsx';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { withPropsFrom } from '@/base/hoc/withPropsFrom.tsx';
import { useReaderAutoScroll } from '@/features/reader/auto-scroll/hooks/useReaderAutoScroll.ts';
import { useReaderHideOverlayOnUserScroll } from '@/features/reader/overlay/hooks/useReaderHideOverlayOnUserScroll.ts';
import { useReaderHorizontalModeRevampScrolling } from '@/features/reader/viewer/hooks/useReaderHorizontalModeRevampScrolling.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 { 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/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';
import {
getReaderStore,
useReaderAutoScrollStore,
useReaderChaptersStore,
useReaderOverlayStore,
useReaderPagesStore,
useReaderSettingsStore,
useReaderTapZoneStore,
} from '@/features/reader/stores/ReaderStore.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 = ({
readerNavBarWidth,
updateCurrentPageIndex,
ref,
}: Pick<NavbarContextType, 'readerNavBarWidth'> & {
updateCurrentPageIndex: ReturnType<typeof ReaderControls.useUpdateCurrentPageIndex>;
ref?: ForwardedRef<HTMLDivElement | null>;
}) => {
const { direction: themeDirection } = useTheme();
const isOverlayVisible = useReaderOverlayStore((state) => state.overlay.isVisible);
const {
currentPageIndex,
pageToScrollToIndex,
setPageToScrollToIndex,
pages,
totalPages,
setPages,
setPageLoadStates,
setTotalPages,
setCurrentPageIndex,
transitionPageMode,
retryFailedPagesKeyPrefix,
setTransitionPageMode,
} = useReaderPagesStore((state) => ({
currentPageIndex: state.pages.currentPageIndex,
pageToScrollToIndex: state.pages.pageToScrollToIndex,
setPageToScrollToIndex: state.pages.setPageToScrollToIndex,
pages: state.pages.pages,
totalPages: state.pages.totalPages,
setPages: state.pages.setPages,
setPageLoadStates: state.pages.setPageLoadStates,
setTotalPages: state.pages.setTotalPages,
setCurrentPageIndex: state.pages.setCurrentPageIndex,
transitionPageMode: state.pages.transitionPageMode,
retryFailedPagesKeyPrefix: state.pages.retryFailedPagesKeyPrefix,
setTransitionPageMode: state.pages.setTransitionPageMode,
}));
const { initialChapter, currentChapter, chapters, visibleChapters, isCurrentChapterReady } = useReaderChaptersStore(
(state) => ({
initialChapter: state.chapters.initialChapter,
currentChapter: state.chapters.currentChapter,
chapters: state.chapters.chapters,
visibleChapters: state.chapters.visibleChapters,
setReaderStateChapters: state.chapters.setReaderStateChapters,
isCurrentChapterReady: state.chapters.isCurrentChapterReady,
}),
);
const {
readingMode,
readingDirection,
readerWidth,
pageScaleMode,
shouldOffsetDoubleSpreads,
imagePreLoadAmount,
pageGap,
customFilter,
shouldStretchPage,
isStaticNav,
} = useReaderSettingsStore((state) => ({
readingMode: state.settings.readingMode.value,
readingDirection: state.settings.readingDirection.value,
readerWidth: state.settings.readerWidth.value,
pageScaleMode: state.settings.pageScaleMode.value,
shouldOffsetDoubleSpreads: state.settings.shouldOffsetDoubleSpreads.value,
imagePreLoadAmount: state.settings.imagePreLoadAmount,
pageGap: state.settings.pageGap.value,
customFilter: state.settings.customFilter,
shouldStretchPage: state.settings.shouldStretchPage.value,
isStaticNav: state.settings.isStaticNav,
}));
const { showPreview, setShowPreview } = useReaderTapZoneStore((state) => ({
showPreview: state.tapZone.showPreview,
setShowPreview: state.tapZone.setShowPreview,
}));
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 = useReaderAutoScrollStore((state) => ({
isPaused: state.autoScroll.isPaused,
pause: state.autoScroll.pause,
resume: state.autoScroll.resume,
setScrollRef: state.autoScroll.setScrollRef,
}));
useEffect(() => automaticScrolling.setScrollRef(scrollElementRef.current), []);
const scrollbarXSize = MediaQuery.useGetScrollbarSize('width', scrollElementRef.current);
const scrollbarYSize = MediaQuery.useGetScrollbarSize('height', scrollElementRef.current);
useLayoutEffect(() => {
const { scrollbar } = getReaderStore();
scrollbar.setXSize(scrollbarXSize);
scrollbar.setYSize(scrollbarYSize);
}, [scrollbarXSize, scrollbarYSize]);
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,
pages,
totalPages,
setPageToScrollToIndex,
updateCurrentPageIndex,
isContinuousReadingModeActive,
imageRefs,
themeDirection,
readingDirection,
);
useReaderScrollToStartOnPageChange(
currentPageIndex,
isContinuousReadingModeActive,
themeDirection,
readingDirection,
scrollElementRef,
);
useReaderHideCursorOnInactivity(scrollElementRef);
useReaderHorizontalModeRevampScrolling(readingMode, readingDirection, scrollElementRef);
useReaderHideOverlayOnUserScroll(isOverlayVisible, showPreview, setShowPreview, scrollElementRef);
useReaderAutoScroll(
isOverlayVisible,
automaticScrolling.isPaused,
automaticScrolling.pause,
automaticScrolling.resume,
isStaticNav,
);
useReaderPreserveScrollPosition(
scrollElementRef,
currentChapter?.id,
currentChapterIndex,
currentPageIndex,
chaptersToRender,
visibleChapters,
readingMode,
readingDirection,
setPageToScrollToIndex,
pageScaleMode,
shouldStretchPage,
readerWidth,
);
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 && ReaderControls.handleClick(scrollElementRef.current, 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}
updateCurrentPageIndex={isCurrentChapter ? updateCurrentPageIndex : noOp}
scrollIntoView={isCurrentChapter && visibleChapters.scrollIntoView}
resumeMode={getReaderChapterViewResumeMode(
isCurrentChapter,
isInitialChapter,
isLeadingChapter,
isTrailingChapter,
visibleChapters.resumeMode,
resumeMode,
)}
setTransitionPageMode={setTransitionPageMode}
pageGap={pageGap}
imagePreLoadAmount={imagePreLoadAmount}
customFilter={customFilter}
shouldStretchPage={shouldStretchPage}
readerNavBarWidth={readerNavBarWidth}
onSizeChange={onChapterViewSizeChange}
minWidth={isChapterSizeSourceChapter ? 0 : minChapterViewWidth}
minHeight={isChapterSizeSourceChapter ? 0 : minChapterViewHeight}
scrollElement={scrollElementRef.current}
/>
);
})}
</Stack>
);
};
export const ReaderViewer = withPropsFrom(
memo(BaseReaderViewer),
[() => ({ updateCurrentPageIndex: ReaderControls.useUpdateCurrentPageIndex() }), useNavBarContext],
['readerNavBarWidth', 'updateCurrentPageIndex'],
);