From 7cf78e771a6d325b51eec5c6b8fdd9d556e99d73 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Wed, 6 Nov 2024 13:00:36 +0100 Subject: [PATCH] Reader page rendering --- public/locales/en.json | 1 + src/modules/core/components/SpinnerImage.tsx | 89 +++-- src/modules/core/utils/MediaQuery.tsx | 12 +- src/modules/manga/components/MangaDetails.tsx | 2 +- src/modules/metadata/Metadata.constants.ts | 1 + .../reader/components/ReaderPageNumber.tsx | 17 +- .../desktop/ReaderNavBarDesktop.tsx | 4 +- .../ReaderNavBarDesktopPageNavigation.tsx | 6 +- .../progress-bar/ReaderProgressBar.tsx | 12 +- .../behaviour/ReaderBehaviourSettings.tsx | 21 + .../settings/layout/ReaderSettingPageGap.tsx | 7 +- .../reader/components/viewer/ReaderPage.tsx | 115 ++++++ .../reader/components/viewer/ReaderViewer.tsx | 208 ++++++++++ .../components/viewer/pager/BasePager.tsx | 75 ++++ .../viewer/pager/ReaderDoublePagedPager.tsx | 108 +++++ .../viewer/pager/ReaderHorizontalPager.tsx | 60 +++ .../viewer/pager/ReaderPagedPager.tsx | 38 ++ .../viewer/pager/ReaderVerticalPager.tsx | 47 +++ .../constants/ReaderSettings.constants.tsx | 2 + .../state/ReaderStatePagesContext.tsx | 2 + .../state/ReaderStatePagesContextProvider.tsx | 5 +- src/modules/reader/services/ReaderControls.ts | 81 +++- src/modules/reader/types/Reader.types.ts | 13 + .../reader/types/ReaderProgressBar.types.ts | 2 + .../reader/utils/ReaderPager.utils.tsx | 369 ++++++++++++++++++ .../reader/utils/ReaderProgressBar.utils.tsx | 4 +- .../reader/utils/ReaderSettings.utils.tsx | 8 +- src/util/Strings.ts | 3 + 28 files changed, 1243 insertions(+), 69 deletions(-) create mode 100644 src/modules/reader/components/viewer/ReaderPage.tsx create mode 100644 src/modules/reader/components/viewer/ReaderViewer.tsx create mode 100644 src/modules/reader/components/viewer/pager/BasePager.tsx create mode 100644 src/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx create mode 100644 src/modules/reader/components/viewer/pager/ReaderHorizontalPager.tsx create mode 100644 src/modules/reader/components/viewer/pager/ReaderPagedPager.tsx create mode 100644 src/modules/reader/components/viewer/pager/ReaderVerticalPager.tsx create mode 100644 src/modules/reader/utils/ReaderPager.utils.tsx diff --git a/public/locales/en.json b/public/locales/en.json index eda6b6a7..7c88ccbe 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -867,6 +867,7 @@ "scroll_forward": "Scroll forward", "stretch_image": "Toggle stretch image" }, + "image_preload_amount": "Preload images", "label": { "behaviour": "Behaviour", "fit_page_to_window": "Fit page to window", diff --git a/src/modules/core/components/SpinnerImage.tsx b/src/modules/core/components/SpinnerImage.tsx index e5b42205..df7453c5 100644 --- a/src/modules/core/components/SpinnerImage.tsx +++ b/src/modules/core/components/SpinnerImage.tsx @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useState, CSSProperties, useEffect, forwardRef, ForwardedRef } from 'react'; +import { useState, useEffect, forwardRef, ForwardedRef } from 'react'; import CircularProgress from '@mui/material/CircularProgress'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; @@ -18,15 +18,18 @@ import ImageIcon from '@mui/icons-material/Image'; import { SxProps, Theme } from '@mui/material/styles'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { Priority } from '@/lib/Queue.ts'; +import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; interface IProps { + shouldLoad?: boolean; + src: string; alt: string; spinnerStyle?: SxProps & { small?: boolean }; - imgStyle?: CSSProperties; + imgStyle?: SxProps; - onImageLoad?: () => void; + onLoad?: () => void; shouldDecode?: boolean; useFetchApi?: boolean; @@ -36,11 +39,12 @@ interface IProps { export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef) => { const { + shouldLoad = true, shouldDecode, useFetchApi, src, alt, - onImageLoad, + onLoad, spinnerStyle: { small, ...spinnerStyle } = {}, imgStyle, priority, @@ -60,12 +64,12 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef { - if (showMissingImageIcon) { + if (showMissingImageIcon || !shouldLoad) { return () => {}; } @@ -110,12 +114,51 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef - {(isLoading || hasError) && ( - + {showMissingImageIcon ? ( + theme.palette.background.default, + ...spinnerStyle, + }} + > + + + ) : ( + + )} + + {(isLoading || (src && !imageSourceUrl) || hasError) && ( + - {isLoading && } + {(isLoading || (src && !imageSourceUrl && !hasError)) && } {hasError && isLoading === false && ( <> @@ -143,32 +186,6 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef )} - - {showMissingImageIcon ? ( - theme.palette.background.default, - ...spinnerStyle, - }} - > - - - ) : ( - {alt} - )} ); }); diff --git a/src/modules/core/utils/MediaQuery.tsx b/src/modules/core/utils/MediaQuery.tsx index ceedc673..a8aa7a32 100644 --- a/src/modules/core/utils/MediaQuery.tsx +++ b/src/modules/core/utils/MediaQuery.tsx @@ -15,16 +15,24 @@ import { AppStorage } from '@/lib/storage/AppStorage.ts'; import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx'; export class MediaQuery { + static readonly MOBILE_WIDTH: Breakpoint | number = 'sm'; + + static readonly TABLET_WIDTH: Breakpoint | number = 1025; + static useIsTouchDevice(): boolean { return useMediaQuery('not (pointer: fine)'); } - static useIsBelowWidth(breakpoint: Breakpoint): boolean { + static useIsBelowWidth(breakpoint: Breakpoint | number): boolean { return useMediaQuery(getCurrentTheme().breakpoints.down(breakpoint)); } static useIsMobileWidth(): boolean { - return this.useIsBelowWidth('sm'); + return this.useIsBelowWidth(this.MOBILE_WIDTH); + } + + static useIsTabletWidth(): boolean { + return this.useIsBelowWidth(this.TABLET_WIDTH); } private static getScrollbarSize(type: 'height' | 'width'): number { diff --git a/src/modules/manga/components/MangaDetails.tsx b/src/modules/manga/components/MangaDetails.tsx index f2f5b798..03723a24 100644 --- a/src/modules/manga/components/MangaDetails.tsx +++ b/src/modules/manga/components/MangaDetails.tsx @@ -164,7 +164,7 @@ const Thumbnail = ({ manga }: { manga: Partial }) => { setIsImageReady(true)} + onLoad={() => setIsImageReady(true)} imgStyle={{ width: '100%', height: '100%', objectFit: 'cover' }} /> {isImageReady && ( diff --git a/src/modules/metadata/Metadata.constants.ts b/src/modules/metadata/Metadata.constants.ts index 499327b4..e9861833 100644 --- a/src/modules/metadata/Metadata.constants.ts +++ b/src/modules/metadata/Metadata.constants.ts @@ -70,6 +70,7 @@ const APP_METADATA_OBJECT: Record = { readingModesDefaultProfile: undefined, defaultProfile: undefined, hotkeys: undefined, + imagePreLoadAmount: undefined, }; export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_OBJECT); diff --git a/src/modules/reader/components/ReaderPageNumber.tsx b/src/modules/reader/components/ReaderPageNumber.tsx index 25138a0b..c35853fb 100644 --- a/src/modules/reader/components/ReaderPageNumber.tsx +++ b/src/modules/reader/components/ReaderPageNumber.tsx @@ -11,12 +11,13 @@ import Typography from '@mui/material/Typography'; import Stack from '@mui/material/Stack'; import Box from '@mui/material/Box'; import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; -import { ProgressBarType } from '@/modules/reader/types/Reader.types.ts'; +import { ProgressBarType, ReadingDirection } from '@/modules/reader/types/Reader.types.ts'; import { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx'; import { getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { useReaderProgressBarContext } from '@/modules/reader/contexts/ReaderProgressBarContext.tsx'; import { useReaderScrollbarContext } from '@/modules/reader/contexts/ReaderScrollbarContext.tsx'; +import { reverseString } from '@/util/Strings.ts'; export const ReaderPageNumber = () => { const { isDesktop } = ReaderService.useOverlayMode(); @@ -24,9 +25,15 @@ export const ReaderPageNumber = () => { const { readerNavBarWidth } = useNavBarContext(); const { isMaximized } = useReaderProgressBarContext(); const { currentPageIndex, pages, totalPages } = userReaderStatePagesContext(); - const { progressBarType, shouldShowPageNumber } = ReaderService.useSettings(); + const { progressBarType, shouldShowPageNumber, readingDirection } = ReaderService.useSettings(); - const pageName = useMemo(() => getPage(currentPageIndex, pages).name, [currentPageIndex, pages]); + const pageName = useMemo(() => { + const currentPageName = getPage(currentPageIndex, pages).name; + const SEPARATOR = '/'; + const tmpPageName = `${currentPageName}${SEPARATOR}${totalPages}`; + + return readingDirection.value === ReadingDirection.LTR ? tmpPageName : reverseString(tmpPageName, SEPARATOR); + }, [currentPageIndex, pages, totalPages, readingDirection.value]); if (!shouldShowPageNumber) { return null; @@ -62,9 +69,7 @@ export const ReaderPageNumber = () => { backgroundColor: 'rgba(0, 0, 0, 0.3)', }} > - - {pageName}/{totalPages} - + {pageName} ); diff --git a/src/modules/reader/components/overlay/navigation/desktop/ReaderNavBarDesktop.tsx b/src/modules/reader/components/overlay/navigation/desktop/ReaderNavBarDesktop.tsx index 2a7b88fe..a79221a8 100644 --- a/src/modules/reader/components/overlay/navigation/desktop/ReaderNavBarDesktop.tsx +++ b/src/modules/reader/components/overlay/navigation/desktop/ReaderNavBarDesktop.tsx @@ -57,7 +57,7 @@ export const ReaderNavBarDesktop = ({ isVisible, openSettings }: ReaderNavBarDes const { setReaderNavBarWidth } = useNavBarContext(); const { manga } = useReaderStateMangaContext(); const { chapters, currentChapter, nextChapter, previousChapter } = useReaderStateChaptersContext(); - const { pages, currentPageIndex, setCurrentPageIndex } = userReaderStatePagesContext(); + const { pages, currentPageIndex, setPageToScrollToIndex } = userReaderStatePagesContext(); const getOptionForDirection = useGetOptionForDirection(); @@ -128,7 +128,7 @@ export const ReaderNavBarDesktop = ({ isVisible, openSettings }: ReaderNavBarDes ) => { +}: Pick) => { const { t } = useTranslation(); const openPage = ReaderControls.useOpenPage(); const { readingDirection } = ReaderService.useSettings(); @@ -52,7 +52,7 @@ export const ReaderNavBarDesktopPageNavigation = ({ labelId="reader-nav-bar-desktop-page-select" label={t('reader.page_info.label.page')} value={currentPage.primary.index} - onChange={(e) => setCurrentPageIndex(e.target.value as number)} + onChange={(e) => setPageToScrollToIndex(e.target.value as number)} > {pages.map(({ primary: { index }, name }) => ( diff --git a/src/modules/reader/components/overlay/progress-bar/ReaderProgressBar.tsx b/src/modules/reader/components/overlay/progress-bar/ReaderProgressBar.tsx index fb0fbfe7..0a53611b 100644 --- a/src/modules/reader/components/overlay/progress-bar/ReaderProgressBar.tsx +++ b/src/modules/reader/components/overlay/progress-bar/ReaderProgressBar.tsx @@ -35,7 +35,7 @@ export const ReaderProgressBar = ({ pages, pageLoadStates, currentPageIndex, - setCurrentPageIndex, + setPageToScrollToIndex, slotProps, slots, createProgressBarSlot, @@ -81,7 +81,7 @@ export const ReaderProgressBar = ({ progressBarRef, isDragging, currentPage, - setCurrentPageIndex, + setPageToScrollToIndex, pages, progressBarPosition, getOptionForDirection, @@ -101,7 +101,7 @@ export const ReaderProgressBar = ({ ? (slotProps?.progressBarPageTexts?.current?.sx ?? []) : [slotProps?.progressBarPageTexts?.current?.sx]), ]} - onClick={() => setCurrentPageIndex(getNextPageIndex('previous', currentPage.pagesIndex, pages))} + onClick={() => setPageToScrollToIndex(getNextPageIndex('previous', currentPage.pagesIndex, pages))} > {currentPage.name} @@ -115,7 +115,7 @@ export const ReaderProgressBar = ({ return; } - setCurrentPageIndex( + setPageToScrollToIndex( getPageForMousePos( event.touches[0], progressBarRef.current.getBoundingClientRect(), @@ -132,7 +132,7 @@ export const ReaderProgressBar = ({ return; } - setCurrentPageIndex( + setPageToScrollToIndex( getPageForMousePos( event, progressBarRef.current.getBoundingClientRect(), @@ -195,7 +195,7 @@ export const ReaderProgressBar = ({ ? (slotProps?.progressBarPageTexts?.total?.sx ?? []) : [slotProps?.progressBarPageTexts?.total?.sx]), ]} - onClick={() => setCurrentPageIndex(getNextPageIndex('next', currentPage.pagesIndex, pages))} + onClick={() => setPageToScrollToIndex(getNextPageIndex('next', currentPage.pagesIndex, pages))} > {totalPages} diff --git a/src/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx b/src/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx index 4739f25f..b6bb4bf4 100644 --- a/src/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx +++ b/src/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx @@ -13,6 +13,8 @@ import { IReaderSettingsWithDefaultFlag } from '@/modules/reader/types/Reader.ty import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx'; import { ReaderSettingExitMode } from '@/modules/reader/components/settings/behaviour/ReaderSettingExitMode.tsx'; import { isOffsetDoubleSpreadPagesEditable } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; +import { SliderInput } from '@/modules/core/components/inputs/SliderInput.tsx'; +import { DEFAULT_READER_SETTINGS } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; export const ReaderBehaviourSettings = ({ settings, @@ -43,6 +45,25 @@ export const ReaderBehaviourSettings = ({ onChange={(_, checked) => updateSetting('shouldOffsetDoubleSpreads', checked)} /> )} + { + updateSetting('imagePreLoadAmount', value as number, false); + }, + onChangeCommitted: (_, value) => { + updateSetting('imagePreLoadAmount', value as number, true); + }, + }, + }} + /> ); }; diff --git a/src/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx b/src/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx index c6b7e168..ef273d3a 100644 --- a/src/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx +++ b/src/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx @@ -7,9 +7,10 @@ */ import { useTranslation } from 'react-i18next'; -import { IReaderSettingsWithDefaultFlag, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; +import { IReaderSettingsWithDefaultFlag } from '@/modules/reader/types/Reader.types.ts'; import { SliderInput } from '@/modules/core/components/inputs/SliderInput.tsx'; import { DEFAULT_READER_SETTINGS } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; +import { isContinuousReadingMode } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; export const ReaderSettingPageGap = ({ pageGap, @@ -20,9 +21,7 @@ export const ReaderSettingPageGap = ({ }) => { const { t } = useTranslation(); - const isChangeable = [ReadingMode.CONTINUOUS_HORIZONTAL, ReadingMode.CONTINUOUS_VERTICAL].includes( - readingMode.value, - ); + const isChangeable = isContinuousReadingMode(readingMode.value); if (!isChangeable) { return null; } diff --git a/src/modules/reader/components/viewer/ReaderPage.tsx b/src/modules/reader/components/viewer/ReaderPage.tsx new file mode 100644 index 00000000..28d3db14 --- /dev/null +++ b/src/modules/reader/components/viewer/ReaderPage.tsx @@ -0,0 +1,115 @@ +/* + * 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 { ComponentProps, ForwardedRef, forwardRef } from 'react'; +import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { ReaderCustomFilter } from '@/modules/reader/types/Reader.types.ts'; +import { getImagePlaceholderStyling, getImageWidthStyling } from '@/modules/reader/utils/ReaderPager.utils.tsx'; +import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; +import { useReaderScrollbarContext } from '@/modules/reader/contexts/ReaderScrollbarContext.tsx'; +import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx'; + +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(' '); + +export const ReaderPage = forwardRef( + ( + { + display, + doublePage = false, + position, + shouldLoad, + ...props + }: Omit, 'ref' | 'spinnerStyle' | 'imgStyle'> & { + display: boolean; + doublePage?: boolean; + position?: 'left' | 'right'; + onLoad?: () => void; + }, + ref: ForwardedRef, + ) => { + const { readingMode, customFilter, pageScaleMode, shouldStretchPage, readerWidth } = + ReaderService.useSettings(); + const { scrollbarXSize, scrollbarYSize } = useReaderScrollbarContext(); + const isTabletWidth = MediaQuery.useIsTabletWidth(); + + if (!display && !shouldLoad) { + return null; + } + + return ( + + ); + }, +); diff --git a/src/modules/reader/components/viewer/ReaderViewer.tsx b/src/modules/reader/components/viewer/ReaderViewer.tsx new file mode 100644 index 00000000..e8f60f2c --- /dev/null +++ b/src/modules/reader/components/viewer/ReaderViewer.tsx @@ -0,0 +1,208 @@ +/* + * 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, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import Stack from '@mui/material/Stack'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { PageInViewportType, ReadingDirection, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; +import { ReaderPagedPager } from '@/modules/reader/components/viewer/pager/ReaderPagedPager.tsx'; +import { ReaderDoublePagedPager } from '@/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx'; +import { ReaderHorizontalPager } from '@/modules/reader/components/viewer/pager/ReaderHorizontalPager.tsx'; +import { ReaderVerticalPager } from '@/modules/reader/components/viewer/pager/ReaderVerticalPager.tsx'; +import { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx'; +import { createPagesData, getDoublePageModePages, isSpreadPage } from '@/modules/reader/utils/ReaderPager.utils.tsx'; +import { useReaderScrollbarContext } from '@/modules/reader/contexts/ReaderScrollbarContext.tsx'; +import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx'; +import { ReaderControls } from '@/modules/reader/services/ReaderControls.ts'; +import { getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx'; +import { isContinuousReadingMode } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; + +const READING_MODE_TO_IN_VIEWPORT_TYPE: Record = { + [ReadingMode.SINGLE_PAGE]: PageInViewportType.X, + [ReadingMode.DOUBLE_PAGE]: PageInViewportType.X, + [ReadingMode.CONTINUOUS_VERTICAL]: PageInViewportType.Y, + [ReadingMode.CONTINUOUS_HORIZONTAL]: PageInViewportType.X, +}; + +export const ReaderViewer = forwardRef((_, ref: ForwardedRef) => { + const { + currentPageIndex, + setCurrentPageIndex, + pageToScrollToIndex, + pages, + setPages, + totalPages, + pageUrls, + setPageLoadStates, + } = userReaderStatePagesContext(); + const { readingMode, shouldOffsetDoubleSpreads, readingDirection } = ReaderService.useSettings(); + const { setScrollbarXSize, setScrollbarYSize } = useReaderScrollbarContext(); + + const scrollElementRef = useRef(null); + useImperativeHandle(ref, () => scrollElementRef.current!); + + 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 [wasDoublePageMode, setWasDoublePageMode] = useState(readingMode.value === ReadingMode.DOUBLE_PAGE); + const [pagesToSpreadState, setPagesToSpreadState] = useState(Array(totalPages).fill(false)); + + const imageRefs = useRef<(HTMLElement | null)[]>(pages.map(() => null)); + + const actualPages = useMemo(() => { + const arePagesLoaded = pageUrls.length; + if (!arePagesLoaded) { + return pages; + } + + if (readingMode.value === ReadingMode.DOUBLE_PAGE) { + return getDoublePageModePages( + pageUrls, + pagesToSpreadState, + shouldOffsetDoubleSpreads.value, + readingDirection.value, + ); + } + + return pages; + }, [pagesToSpreadState, readingMode.value, shouldOffsetDoubleSpreads.value, readingDirection.value]); + + const Pager = useMemo(() => { + switch (readingMode.value) { + case ReadingMode.SINGLE_PAGE: + return ReaderPagedPager; + case ReadingMode.DOUBLE_PAGE: + return ReaderDoublePagedPager; + case ReadingMode.CONTINUOUS_VERTICAL: + return ReaderVerticalPager; + case ReadingMode.CONTINUOUS_HORIZONTAL: + return ReaderHorizontalPager; + default: + throw new Error(`Unexpected "ReadingMode" (${readingMode.value})`); + } + }, [readingMode.value]); + + const inViewportType = READING_MODE_TO_IN_VIEWPORT_TYPE[readingMode.value]; + + // reset spread state + useLayoutEffect(() => { + setPagesToSpreadState(Array(totalPages).fill(false)); + }, [totalPages]); + + // optionally convert pages to normal or double page mode + useLayoutEffect(() => { + const convertPagesToNormalPageMode = wasDoublePageMode && readingMode.value !== ReadingMode.DOUBLE_PAGE; + if (convertPagesToNormalPageMode) { + setWasDoublePageMode(false); + setPages(createPagesData(pageUrls)); + setPagesToSpreadState(Array(totalPages).fill(false)); + + return; + } + + const convertPagesToDoublePageMode = readingMode.value === ReadingMode.DOUBLE_PAGE; + if (convertPagesToDoublePageMode) { + setPages(actualPages); + setWasDoublePageMode(readingMode.value === ReadingMode.DOUBLE_PAGE); + } + }, [actualPages, readingMode.value]); + + // optionally scroll selected page into view in continues reading modes + useLayoutEffect(() => { + if (isContinuousReadingMode(readingMode.value)) { + const pageToScrollToPagesIndex = getPage(pageToScrollToIndex, pages).pagesIndex; + const imageRef = imageRefs.current[pageToScrollToPagesIndex]; + imageRef?.scrollIntoView({ + block: 'start', + inline: 'start', + }); + setCurrentPageIndex(pageToScrollToPagesIndex); + } + }, [pageToScrollToIndex]); + + // invert x and y scrolling for the continuous horizontal reading mode + useEffect(() => { + if (readingMode.value !== ReadingMode.CONTINUOUS_HORIZONTAL) { + return () => {}; + } + + if (!scrollElementRef.current) { + return () => {}; + } + + const handleScroll = (e: WheelEvent) => { + e.preventDefault(); + + if (e.shiftKey) { + scrollElementRef.current?.scrollBy({ + top: e.deltaY, + }); + return; + } + + scrollElementRef.current?.scrollBy({ + left: readingDirection.value === ReadingDirection.LTR ? e.deltaY : e.deltaY * -1, + }); + }; + + scrollElementRef.current.addEventListener('wheel', handleScroll); + return () => scrollElementRef.current?.removeEventListener('wheel', handleScroll); + }, [readingMode.value, readingDirection.value]); + + return ( + + ReaderControls.updateCurrentPageOnScroll( + imageRefs, + currentPageIndex, + setCurrentPageIndex, + inViewportType, + ) + } + > + { + const page = actualPages[pagesIndex]; + const { index, url } = isPrimary ? page.primary : page.secondary!; + + if (readingMode.value === ReadingMode.DOUBLE_PAGE) { + const img = new Image(); + img.onload = () => { + setPagesToSpreadState((prevState) => prevState.toSpliced(index, 1, isSpreadPage(img))); + }; + img.src = url; + } + + setPageLoadStates((pageLoadStates) => pageLoadStates.toSpliced(index, 1, true)); + }} + /> + + ); +}); diff --git a/src/modules/reader/components/viewer/pager/BasePager.tsx b/src/modules/reader/components/viewer/pager/BasePager.tsx new file mode 100644 index 00000000..f5ab8ecb --- /dev/null +++ b/src/modules/reader/components/viewer/pager/BasePager.tsx @@ -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 Stack, { StackProps } from '@mui/material/Stack'; +import { ReactNode, useEffect, useMemo, useRef } from 'react'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { getImageWidthStyling, getPageIndexesToLoad } from '@/modules/reader/utils/ReaderPager.utils.tsx'; +import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts'; +import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; +import { isContinuousReadingMode, isReaderWidthEditable } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; +import { ReaderPagerProps } from '@/modules/reader/types/Reader.types.ts'; + +export const BasePager = ({ + currentPageIndex, + pages, + imageRefs, + createPage, + slots, +}: ReaderPagerProps & { + createPage: ( + page: ReaderStatePages['pages'][number], + pagesIndex: number, + shouldLoad: boolean, + setRef: (element: HTMLElement | null) => void, + ) => ReactNode; + slots?: { stackProps?: StackProps }; +}) => { + const { readingMode, pageScaleMode, shouldStretchPage, readerWidth, imagePreLoadAmount } = + ReaderService.useSettings(); + + const previousCurrentPageIndex = useRef(-1); + const pagesIndexesToRender = useMemo( + () => getPageIndexesToLoad(currentPageIndex, pages, previousCurrentPageIndex.current, imagePreLoadAmount), + [currentPageIndex, pages, imagePreLoadAmount], + ); + useEffect(() => { + previousCurrentPageIndex.current = currentPageIndex; + }, [pagesIndexesToRender]); + + return ( + + {pages.map((page, pagesIndex) => + createPage(page, pagesIndex, pagesIndexesToRender.includes(pagesIndex), (element) => { + // eslint-disable-next-line no-param-reassign + imageRefs.current[pagesIndex] = element; + }), + )} + + ); +}; diff --git a/src/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx b/src/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx new file mode 100644 index 00000000..1ce63d9f --- /dev/null +++ b/src/modules/reader/components/viewer/pager/ReaderDoublePagedPager.tsx @@ -0,0 +1,108 @@ +/* + * 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 { Fragment, useMemo } from 'react'; +import { BasePager } from '@/modules/reader/components/viewer/pager/BasePager.tsx'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { ReaderPagerProps, ReadingDirection } from '@/modules/reader/types/Reader.types.ts'; +import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; +import { createReaderPage } from '@/modules/reader/utils/ReaderPager.utils.tsx'; +import { getPage } from '@/modules/reader/utils/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'; +}; + +export const ReaderDoublePagedPager = ({ onLoad, ...props }: ReaderPagerProps) => { + const { currentPageIndex, pages, totalPages } = props; + + const { readingDirection } = ReaderService.useSettings(); + const { direction: themeDirection } = useTheme(); + + const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]); + const isLtrReadingDirection = readingDirection.value === ReadingDirection.LTR; + + return ( + { + const { primary, secondary } = page; + + const currentSecondaryPageIndex = currentPage.secondary?.index ?? currentPage.primary.index; + + const hasSecondaryPage = !!secondary; + const isPrimaryPage = currentPage.primary.index === primary.index; + const isSecondaryPage = !!secondary && currentSecondaryPageIndex === secondary.index; + + return ( + + {createReaderPage( + page, + () => onLoad?.(pagesIndex), + shouldLoad, + isPrimaryPage, + currentPage.primary.index, + totalPages, + hasSecondaryPage + ? getPagePosition('first', themeDirection, readingDirection.value) + : undefined, + hasSecondaryPage, + )} + {hasSecondaryPage && + createReaderPage( + { ...page, primary: { ...page.secondary! } }, + () => onLoad?.(pagesIndex, false), + shouldLoad, + isSecondaryPage, + currentSecondaryPageIndex, + totalPages, + getPagePosition('second', themeDirection, readingDirection.value), + true, + )} + + ); + }} + slots={{ + stackProps: { + sx: { + margin: 'auto', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + ...applyStyles(themeDirection === 'ltr', { + flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse', + }), + ...applyStyles(themeDirection === 'rtl', { + flexDirection: isLtrReadingDirection ? 'row-reverse' : 'row', + }), + flexWrap: 'nowrap', + }, + }, + }} + /> + ); +}; diff --git a/src/modules/reader/components/viewer/pager/ReaderHorizontalPager.tsx b/src/modules/reader/components/viewer/pager/ReaderHorizontalPager.tsx new file mode 100644 index 00000000..e8ce8725 --- /dev/null +++ b/src/modules/reader/components/viewer/pager/ReaderHorizontalPager.tsx @@ -0,0 +1,60 @@ +/* + * 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 { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { BasePager } from '@/modules/reader/components/viewer/pager/BasePager.tsx'; +import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; +import { ReaderPagerProps, ReadingDirection } from '@/modules/reader/types/Reader.types.ts'; +import { createReaderPage } from '@/modules/reader/utils/ReaderPager.utils.tsx'; + +export const ReaderHorizontalPager = ({ onLoad, ...props }: ReaderPagerProps) => { + const { currentPageIndex, totalPages } = props; + + const { pageGap, readingDirection } = ReaderService.useSettings(); + const { direction: themeDirection } = useTheme(); + + const isLtrReadingDirection = readingDirection.value === ReadingDirection.LTR; + + return ( + + createReaderPage( + page, + () => onLoad?.(pagesIndex), + shouldLoad, + true, + currentPageIndex, + totalPages, + undefined, + undefined, + setRef, + ) + } + slots={{ + stackProps: { + sx: { + my: 'auto', + ...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', + }), + flexWrap: 'nowrap', + alignItems: 'center', + gap: `${pageGap.value}px`, + }, + }, + }} + /> + ); +}; diff --git a/src/modules/reader/components/viewer/pager/ReaderPagedPager.tsx b/src/modules/reader/components/viewer/pager/ReaderPagedPager.tsx new file mode 100644 index 00000000..f3ebede4 --- /dev/null +++ b/src/modules/reader/components/viewer/pager/ReaderPagedPager.tsx @@ -0,0 +1,38 @@ +/* + * 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 { BasePager } from '@/modules/reader/components/viewer/pager/BasePager.tsx'; +import { ReaderPagerProps } from '@/modules/reader/types/Reader.types.ts'; +import { createReaderPage } from '@/modules/reader/utils/ReaderPager.utils.tsx'; + +export const ReaderPagedPager = ({ onLoad, ...props }: ReaderPagerProps) => { + const { currentPageIndex, totalPages } = props; + + return ( + + createReaderPage( + page, + () => onLoad?.(pagesIndex), + shouldLoad, + currentPageIndex === page.primary.index, + currentPageIndex, + totalPages, + ) + } + slots={{ + stackProps: { + sx: { + margin: 'auto', + }, + }, + }} + /> + ); +}; diff --git a/src/modules/reader/components/viewer/pager/ReaderVerticalPager.tsx b/src/modules/reader/components/viewer/pager/ReaderVerticalPager.tsx new file mode 100644 index 00000000..ebd6218c --- /dev/null +++ b/src/modules/reader/components/viewer/pager/ReaderVerticalPager.tsx @@ -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 { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { BasePager } from '@/modules/reader/components/viewer/pager/BasePager.tsx'; +import { ReaderPagerProps } from '@/modules/reader/types/Reader.types.ts'; +import { createReaderPage } from '@/modules/reader/utils/ReaderPager.utils.tsx'; + +export const ReaderVerticalPager = ({ onLoad, ...props }: ReaderPagerProps) => { + const { currentPageIndex, totalPages } = props; + + const { pageGap } = ReaderService.useSettings(); + + return ( + + createReaderPage( + page, + () => onLoad?.(pagesIndex), + shouldLoad, + true, + currentPageIndex, + totalPages, + undefined, + undefined, + setRef, + ) + } + slots={{ + stackProps: { + sx: { + margin: 'auto', + flexWrap: 'nowrap', + alignItems: 'center', + gap: `${pageGap.value}px`, + }, + }, + }} + /> + ); +}; diff --git a/src/modules/reader/constants/ReaderSettings.constants.tsx b/src/modules/reader/constants/ReaderSettings.constants.tsx index 5d578673..bb558c28 100644 --- a/src/modules/reader/constants/ReaderSettings.constants.tsx +++ b/src/modules/reader/constants/ReaderSettings.constants.tsx @@ -55,6 +55,7 @@ const GLOBAL_READER_SETTING_OBJECT: Record = { diff --git a/src/modules/reader/contexts/state/ReaderStatePagesContext.tsx b/src/modules/reader/contexts/state/ReaderStatePagesContext.tsx index d2bc8f50..d2922c0b 100644 --- a/src/modules/reader/contexts/state/ReaderStatePagesContext.tsx +++ b/src/modules/reader/contexts/state/ReaderStatePagesContext.tsx @@ -13,6 +13,8 @@ export const ReaderStatePagesContext = createContext({ totalPages: 0, currentPageIndex: 0, setCurrentPageIndex: () => undefined, + pageToScrollToIndex: 0, + setPageToScrollToIndex: () => undefined, setTotalPages: () => undefined, pageUrls: [], setPageUrls: () => undefined, diff --git a/src/modules/reader/contexts/state/ReaderStatePagesContextProvider.tsx b/src/modules/reader/contexts/state/ReaderStatePagesContextProvider.tsx index cf89818a..41b65d30 100644 --- a/src/modules/reader/contexts/state/ReaderStatePagesContextProvider.tsx +++ b/src/modules/reader/contexts/state/ReaderStatePagesContextProvider.tsx @@ -15,6 +15,7 @@ type TContext = ContextType; export const ReaderStatePagesContextProvider = ({ children }: { children: ReactNode }) => { const [totalPages, setTotalPages] = useState(0); const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [pageToScrollToIndex, setPageToScrollToIndex] = useState(0); const [pageUrls, setPageUrls] = useState([]); const [pageLoadStates, setPageLoadStates] = useState([]); const [pages, setPages] = useState([createPageData('', 0)]); @@ -25,6 +26,8 @@ export const ReaderStatePagesContextProvider = ({ children }: { children: ReactN setTotalPages, currentPageIndex, setCurrentPageIndex, + pageToScrollToIndex, + setPageToScrollToIndex, pageUrls, setPageUrls, pageLoadStates, @@ -32,7 +35,7 @@ export const ReaderStatePagesContextProvider = ({ children }: { children: ReactN pages, setPages, }), - [totalPages, pages, currentPageIndex, pageUrls, pageLoadStates], + [totalPages, pages, currentPageIndex, pageToScrollToIndex, pageUrls, pageLoadStates], ); return {children}; diff --git a/src/modules/reader/services/ReaderControls.ts b/src/modules/reader/services/ReaderControls.ts index 44079131..facbf1c1 100644 --- a/src/modules/reader/services/ReaderControls.ts +++ b/src/modules/reader/services/ReaderControls.ts @@ -6,16 +6,22 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useCallback, useMemo } from 'react'; +import { MutableRefObject, useCallback, useMemo } from 'react'; import { Direction } from '@mui/material/styles'; import { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx'; import { getNextPageIndex, getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx'; import { getOptionForDirection } from '@/theme.tsx'; import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; import { useReaderStateChaptersContext } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; -import { ReadingDirection } from '@/modules/reader/types/Reader.types.ts'; +import { PageInViewportType, ReadingDirection, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; import { ScrollDirection, ScrollOffset } from '@/modules/core/Core.types.ts'; import { ReaderScrollAmount } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; +import { isPageInViewport } from '@/modules/reader/utils/ReaderPager.utils.tsx'; +import { useReaderOverlayContext } from '@/modules/reader/contexts/ReaderOverlayContext.tsx'; +import { useReaderTapZoneContext } from '@/modules/reader/contexts/ReaderTapZoneContext.tsx'; +import { TapZoneRegionType } from '@/modules/reader/types/TapZoneLayout.types.ts'; +import { ReaderTapZoneService } from '@/modules/reader/services/ReaderTapZoneService.ts'; +import { isContinuousReadingMode } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; const READING_DIRECTION_TO_DIRECTION: Record = { [ReadingDirection.LTR]: 'ltr', @@ -95,7 +101,7 @@ export class ReaderControls { } static useOpenPage(): (offset: 'previous' | 'next') => void { - const { currentPageIndex, setCurrentPageIndex, pages } = userReaderStatePagesContext(); + const { currentPageIndex, setPageToScrollToIndex, pages } = userReaderStatePagesContext(); const { readingDirection } = ReaderService.useSettings(); const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]); @@ -113,10 +119,10 @@ export class ReaderControls { (offset) => { switch (offset) { case 'previous': - setCurrentPageIndex(getOptionForDirection(previousPageIndex, nextPageIndex, direction)); + setPageToScrollToIndex(getOptionForDirection(previousPageIndex, nextPageIndex, direction)); break; case 'next': - setCurrentPageIndex(getOptionForDirection(nextPageIndex, previousPageIndex, direction)); + setPageToScrollToIndex(getOptionForDirection(nextPageIndex, previousPageIndex, direction)); break; default: throw new Error(`Unexpected "offset" (${offset})`); @@ -125,4 +131,69 @@ export class ReaderControls { [direction, previousPageIndex, nextPageIndex], ); } + + static updateCurrentPageOnScroll( + imageRefs: MutableRefObject<(HTMLElement | null)[]>, + currentPageIndex: number, + setCurrentPageIndex: (index: number) => void, + type: PageInViewportType, + ) { + const firstVisibleImageIndex = imageRefs.current.findIndex((image) => image && isPageInViewport(image, type)); + + if (firstVisibleImageIndex !== -1 && firstVisibleImageIndex !== currentPageIndex) { + setCurrentPageIndex(firstVisibleImageIndex); + } + } + + static useHandleClick( + scrollElement: HTMLElement | null, + ): (event: React.MouseEvent) => void { + const { setIsVisible: setIsOverlayVisible } = useReaderOverlayContext(); + const { currentPageIndex, pages } = userReaderStatePagesContext(); + const { setShowPreview } = useReaderTapZoneContext(); + const { readingMode, readingDirection, isStaticNav } = ReaderService.useSettings(); + const openPage = ReaderControls.useOpenPage(); + + return useCallback( + (e) => { + if (!scrollElement) { + return; + } + + const rect = e.currentTarget.getBoundingClientRect(); + const rectRelativeX = e.clientX - rect.left; + const rectRelativeY = e.clientY - rect.top; + const action = ReaderTapZoneService.getAction(rectRelativeX, rectRelativeY); + + setShowPreview(false); + + const isContinuousReadingModeActive = isContinuousReadingMode(readingMode.value); + const scrollDirection = + readingMode.value === ReadingMode.CONTINUOUS_HORIZONTAL ? ScrollDirection.X : ScrollDirection.Y; + + switch (action) { + case TapZoneRegionType.MENU: + setIsOverlayVisible((isVisible) => isStaticNav || !isVisible); + break; + case TapZoneRegionType.PREVIOUS: + if (isContinuousReadingModeActive) { + this.scroll(ScrollOffset.BACKWARD, scrollDirection, readingDirection.value, scrollElement); + } else { + openPage('previous'); + } + break; + case TapZoneRegionType.NEXT: + if (isContinuousReadingModeActive) { + this.scroll(ScrollOffset.FORWARD, scrollDirection, readingDirection.value, scrollElement); + } else { + openPage('next'); + } + break; + default: + throw new Error(`Unexpected "TapZoneRegionType" (${action})`); + } + }, + [scrollElement, currentPageIndex, pages, readingMode.value, openPage, readingDirection.value], + ); + } } diff --git a/src/modules/reader/types/Reader.types.ts b/src/modules/reader/types/Reader.types.ts index e61a107b..3dee06d8 100644 --- a/src/modules/reader/types/Reader.types.ts +++ b/src/modules/reader/types/Reader.types.ts @@ -6,9 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { MutableRefObject } from 'react'; import { TapZoneInvertMode, TapZoneLayouts } from '@/modules/reader/types/TapZoneLayout.types.ts'; import { TChapterReader } from '@/modules/chapter/Chapter.types.ts'; import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts'; export enum ProgressBarType { HIDDEN, @@ -120,6 +122,7 @@ export interface IReaderSettingsGlobal { profiles: string[]; readingModesDefaultProfile: Record; hotkeys: Record; + imagePreLoadAmount: number; } export interface IReaderSettingsManga { @@ -193,3 +196,13 @@ export enum ReaderHotkey { STRETCH_IMAGE, OFFSET_SPREAD_PAGES, } + +export interface ReaderPagerProps extends Pick { + imageRefs: MutableRefObject<(HTMLElement | null)[]>; + onLoad?: (pagesIndex: number, isPrimary?: boolean) => void; +} + +export enum PageInViewportType { + X, + Y, +} diff --git a/src/modules/reader/types/ReaderProgressBar.types.ts b/src/modules/reader/types/ReaderProgressBar.types.ts index 0ea96bad..34e8af15 100644 --- a/src/modules/reader/types/ReaderProgressBar.types.ts +++ b/src/modules/reader/types/ReaderProgressBar.types.ts @@ -27,6 +27,8 @@ export interface ReaderStatePages { setTotalPages: React.Dispatch>; currentPageIndex: number; setCurrentPageIndex: React.Dispatch>; + pageToScrollToIndex: number; + setPageToScrollToIndex: React.Dispatch>; pageUrls: string[]; setPageUrls: React.Dispatch>; pageLoadStates: boolean[]; diff --git a/src/modules/reader/utils/ReaderPager.utils.tsx b/src/modules/reader/utils/ReaderPager.utils.tsx new file mode 100644 index 00000000..e87433cc --- /dev/null +++ b/src/modules/reader/utils/ReaderPager.utils.tsx @@ -0,0 +1,369 @@ +/* + * 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 { Theme } from '@mui/material/styles'; +import { ReactNode } from 'react'; +import { + IReaderSettings, + PageInViewportType, + ReaderPageScaleMode, + ReadingDirection, + ReadingMode, +} from '@/modules/reader/types/Reader.types.ts'; +import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; +import { isReaderWidthEditable } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; +import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { ReaderPage } from '@/modules/reader/components/viewer/ReaderPage.tsx'; +import { reverseString } from '@/util/Strings.ts'; +import { getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx'; + +type CSSObject = ReturnType; + +const getPageWidth = ( + pageScaleMode: IReaderSettings['pageScaleMode'], + isDoublePage?: boolean, + readerWidth?: IReaderSettings['readerWidth'], + isImage?: boolean, +): string => { + if (isImage && isDoublePage) { + return '50%'; + } + + // the image wrapper gets applied the reader width limit and therefore the images can take up 100% + if (!isImage && readerWidth?.enabled && isReaderWidthEditable(pageScaleMode)) { + return `${readerWidth.value}%`; + } + + return '100%'; +}; + +export const getImagePlaceholderStyling = ( + readingMode: IReaderSettings['readingMode'], + shouldStretchPage: IReaderSettings['shouldStretchPage'], + pageScaleMode: IReaderSettings['pageScaleMode'], + readerWidth: IReaderSettings['readerWidth'], + scrollbarXSize: number, + scrollbarYSize: number, + isDoublePage?: boolean, + isTabletWidth?: boolean, +): CSSObject => { + const DEFAULT_SINGLE_PAGE_WIDTH = isTabletWidth ? '100vw' : '40vw'; + const DEFAULT_SINGLE_PAGE_HEIGHT = isTabletWidth ? '100vh' : '85vh'; + + const READING_MODE_TO_PLACEHOLDER_SIZE: Record = { + [ReadingMode.SINGLE_PAGE]: { minWidth: DEFAULT_SINGLE_PAGE_WIDTH, minHeight: DEFAULT_SINGLE_PAGE_HEIGHT }, + [ReadingMode.DOUBLE_PAGE]: { minWidth: isTabletWidth ? '50vw' : '35vw', minHeight: DEFAULT_SINGLE_PAGE_HEIGHT }, + [ReadingMode.CONTINUOUS_VERTICAL]: { + minWidth: `calc(${DEFAULT_SINGLE_PAGE_WIDTH} - ${scrollbarYSize}px)`, + minHeight: '100vh', + }, + [ReadingMode.CONTINUOUS_HORIZONTAL]: { + minWidth: DEFAULT_SINGLE_PAGE_WIDTH, + minHeight: `calc(${DEFAULT_SINGLE_PAGE_HEIGHT} - ${scrollbarXSize}px)`, + }, + }; + const defaultStyling = READING_MODE_TO_PLACEHOLDER_SIZE[readingMode]; + const minWidthForStretch = isDoublePage ? '50%' : '100%'; + + switch (pageScaleMode) { + case ReaderPageScaleMode.WIDTH: + return { + ...defaultStyling, + ...applyStyles(readerWidth.enabled && isReaderWidthEditable(pageScaleMode), { + minWidth: minWidthForStretch, + }), + ...applyStyles(shouldStretchPage, { + minWidth: minWidthForStretch, + }), + }; + case ReaderPageScaleMode.HEIGHT: + return { + ...defaultStyling, + ...applyStyles(shouldStretchPage, { + minHeight: `calc(100vh - ${scrollbarXSize}px)`, + ...applyStyles(readingMode === ReadingMode.CONTINUOUS_VERTICAL, { + minHeight: '100vh', + }), + }), + }; + case ReaderPageScaleMode.SCREEN: + return { + ...defaultStyling, + ...applyStyles(readerWidth.enabled && isReaderWidthEditable(pageScaleMode), { + minWidth: minWidthForStretch, + }), + ...applyStyles(shouldStretchPage, { + minWidth: minWidthForStretch, + minHeight: `calc(100vh - ${scrollbarXSize}px)`, + ...applyStyles(readingMode === ReadingMode.CONTINUOUS_VERTICAL, { + minHeight: '100vh', + }), + }), + }; + case ReaderPageScaleMode.ORIGINAL: + return defaultStyling; + default: + throw new Error(`Unexpected "PageScaleMode" (${pageScaleMode})`); + } +}; + +export const getImageWidthStyling = ( + readingMode: IReaderSettings['readingMode'], + shouldStretchPage: IReaderSettings['shouldStretchPage'], + pageScaleMode: IReaderSettings['pageScaleMode'], + isDoublePage?: boolean, + readerWidth?: IReaderSettings['readerWidth'], + isImage?: boolean, +): CSSObject => { + const width = getPageWidth(pageScaleMode, isDoublePage, readerWidth, isImage); + + // setting the "width" of the wrapper is required for being able to properly size the image placeholders + const staticReaderWidthForWrapper = applyStyles( + !isImage && !!readerWidth?.enabled && isReaderWidthEditable(pageScaleMode), + { width }, + ); + const readerWidthStretchForWrapper = applyStyles(!isImage && shouldStretchPage, { width: '100%' }); + + switch (pageScaleMode) { + case ReaderPageScaleMode.WIDTH: + return { + ...applyStyles(shouldStretchPage, { + minWidth: width, + ...readerWidthStretchForWrapper, + }), + ...staticReaderWidthForWrapper, + maxWidth: width, + }; + case ReaderPageScaleMode.HEIGHT: + return { + ...applyStyles(shouldStretchPage, { + minHeight: '100%', + }), + maxHeight: `100%`, + }; + case ReaderPageScaleMode.SCREEN: + return { + ...applyStyles(shouldStretchPage, { + minWidth: width, + minHeight: '100%', + ...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, { + minWidth: 'unset', + minHeight: '100%', + }), + ...applyStyles(readingMode === ReadingMode.CONTINUOUS_VERTICAL, { + minWidth: width, + minHeight: 'unset', + }), + ...readerWidthStretchForWrapper, + }), + ...staticReaderWidthForWrapper, + maxWidth: width, + maxHeight: `100%`, + }; + case ReaderPageScaleMode.ORIGINAL: + return {}; + default: + throw new Error(`Unexpected "PageScaleMode" (${pageScaleMode})`); + } +}; + +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): number => { + 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], + onLoad: () => void, + shouldLoad: boolean, + display: boolean, + currentPageIndex: number, + totalPages: number, + position?: 'left' | 'right', + isDoublePage?: boolean, + setRef?: (ref: HTMLElement | null) => void, +): ReactNode => ( + +); + +export const isPageInViewport = (element: HTMLElement, type: PageInViewportType): boolean => { + const { top, bottom, left, right } = element.getBoundingClientRect(); + + // for some reason using "scrollIntoView" to scroll a page into view does not always result in the page to be at top, + // left/right 0px and instead at something like -0.25px + const MIN_VISIBLE_PX = 1; + + const isLeftInViewport = left >= MIN_VISIBLE_PX && left <= window.innerWidth; + const isRightInViewport = right >= MIN_VISIBLE_PX && right <= window.innerWidth; + const isFillingWidthViewportCompletely = left <= MIN_VISIBLE_PX && right >= window.innerWidth; + + const isTopInViewport = top >= MIN_VISIBLE_PX && top <= window.innerHeight; + const isBottomInViewport = bottom >= MIN_VISIBLE_PX && bottom <= window.innerHeight; + const isFillingHeightViewportCompletely = top <= MIN_VISIBLE_PX && bottom >= window.innerHeight; + + 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 getDoublePageModePages = ( + pageUrls: ReaderStatePages['pageUrls'], + pagesToSpreadState: boolean[], + 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(Boolean).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]; + const isSecondPageSpreadPage = pagesToSpreadState[secondPageIndex]; + 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 PREVIOUS_IMAGE_LOAD_AMOUNT = 2; +export const getPageIndexesToLoad = ( + currentPageIndex: number, + pages: ReaderStatePages['pages'], + previousCurrentPageIndex: number, + imagePreLoadAmount: number, +): number[] => { + const currentPagesIndex = getPage(currentPageIndex, pages).pagesIndex; + + const directionInvert = previousCurrentPageIndex <= currentPageIndex ? 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 startPagesIndex = Math.max( + 0, + currentPagesIndex - Math.min(PREVIOUS_IMAGE_LOAD_AMOUNT, imagePreLoadAmount) * directionInvert, + ); + const endPagesIndex = currentPagesIndex + imagePreLoadAmount * directionInvert; + const pagesToRenderLength = Math.abs(endPagesIndex - startPagesIndex) + 1; + + return Array(pagesToRenderLength) + .fill(1) + .map((_, index) => startPagesIndex + index * directionInvert); +}; diff --git a/src/modules/reader/utils/ReaderProgressBar.utils.tsx b/src/modules/reader/utils/ReaderProgressBar.utils.tsx index 8d05b2a7..ffb28da9 100644 --- a/src/modules/reader/utils/ReaderProgressBar.utils.tsx +++ b/src/modules/reader/utils/ReaderProgressBar.utils.tsx @@ -85,7 +85,7 @@ export const useHandleProgressDragging = ( progressBarRef: RefObject, isDragging: boolean, currentPage: TReaderProgressCurrentPage, - setCurrentPageIndex: (pageIndex: number) => void, + setPageToScrollToIndex: (pageIndex: number) => void, pages: ReaderProgressBarProps['pages'], progressBarPosition: ProgressBarPosition, getOptionForDirection: typeof getOptionForDirectionImpl, @@ -115,7 +115,7 @@ export const useHandleProgressDragging = ( return; } - setCurrentPageIndex(newPageIndex); + setPageToScrollToIndex(newPageIndex); }; const handleMouseMove = (e: MouseEvent) => { diff --git a/src/modules/reader/utils/ReaderSettings.utils.tsx b/src/modules/reader/utils/ReaderSettings.utils.tsx index de741d33..623e9483 100644 --- a/src/modules/reader/utils/ReaderSettings.utils.tsx +++ b/src/modules/reader/utils/ReaderSettings.utils.tsx @@ -7,7 +7,7 @@ */ import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; -import { IReaderSettings, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; +import { IReaderSettings, ReaderPageScaleMode, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; import { DEFAULT_READER_PROFILE } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; import { ValueToDisplayData } from '@/modules/core/Core.types.ts'; @@ -31,3 +31,9 @@ export const createProfileValueToDisplayData = ( export const getValidReaderProfile = (profile: string, profiles: string[]): string => profiles.includes(profile) ? profile : DEFAULT_READER_PROFILE; + +export const isReaderWidthEditable = (pageScaleMode: IReaderSettings['pageScaleMode']): boolean => + [ReaderPageScaleMode.WIDTH, ReaderPageScaleMode.SCREEN].includes(pageScaleMode); + +export const isContinuousReadingMode = (readingMode: IReaderSettings['readingMode']): boolean => + [ReadingMode.CONTINUOUS_VERTICAL, ReadingMode.CONTINUOUS_HORIZONTAL].includes(readingMode); diff --git a/src/util/Strings.ts b/src/util/Strings.ts index b06b4e63..f366c7e2 100644 --- a/src/util/Strings.ts +++ b/src/util/Strings.ts @@ -12,3 +12,6 @@ export const enhancedCleanup = (str: string): string => baseCleanup(str) .replace(/[^a-zA-Z0-9\\s]+/g, ' ') .trim(); + +export const reverseString = (str: string, separator: string = ''): string => + str.split(separator).reverse().join(separator);