Reader page rendering

This commit is contained in:
schroda
2024-11-06 13:00:36 +01:00
parent 0b1c67ce9f
commit 7cf78e771a
28 changed files with 1243 additions and 69 deletions

View File

@@ -867,6 +867,7 @@
"scroll_forward": "Scroll forward", "scroll_forward": "Scroll forward",
"stretch_image": "Toggle stretch image" "stretch_image": "Toggle stretch image"
}, },
"image_preload_amount": "Preload images",
"label": { "label": {
"behaviour": "Behaviour", "behaviour": "Behaviour",
"fit_page_to_window": "Fit page to window", "fit_page_to_window": "Fit page to window",

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * 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 CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack'; 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 { SxProps, Theme } from '@mui/material/styles';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Priority } from '@/lib/Queue.ts'; import { Priority } from '@/lib/Queue.ts';
import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
interface IProps { interface IProps {
shouldLoad?: boolean;
src: string; src: string;
alt: string; alt: string;
spinnerStyle?: SxProps<Theme> & { small?: boolean }; spinnerStyle?: SxProps<Theme> & { small?: boolean };
imgStyle?: CSSProperties; imgStyle?: SxProps<Theme>;
onImageLoad?: () => void; onLoad?: () => void;
shouldDecode?: boolean; shouldDecode?: boolean;
useFetchApi?: boolean; useFetchApi?: boolean;
@@ -36,11 +39,12 @@ interface IProps {
export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef<HTMLImageElement | null>) => { export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef<HTMLImageElement | null>) => {
const { const {
shouldLoad = true,
shouldDecode, shouldDecode,
useFetchApi, useFetchApi,
src, src,
alt, alt,
onImageLoad, onLoad,
spinnerStyle: { small, ...spinnerStyle } = {}, spinnerStyle: { small, ...spinnerStyle } = {},
imgStyle, imgStyle,
priority, priority,
@@ -60,12 +64,12 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef<HTML
setHasError(error); setHasError(error);
if (!loading && !error && !aborted) { if (!loading && !error && !aborted) {
onImageLoad?.(); onLoad?.();
} }
}; };
useEffect(() => { useEffect(() => {
if (showMissingImageIcon) { if (showMissingImageIcon || !shouldLoad) {
return () => {}; return () => {};
} }
@@ -110,12 +114,51 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef<HTML
clearTimeout(cacheTimeout); clearTimeout(cacheTimeout);
imageRequest.abortRequest(new Error('Component was unmounted')); imageRequest.abortRequest(new Error('Component was unmounted'));
}; };
}, [src, imgLoadRetryKey]); }, [src, imgLoadRetryKey, showMissingImageIcon, shouldLoad]);
return ( return (
<> <>
{(isLoading || hasError) && ( {showMissingImageIcon ? (
<Box sx={{ height: '100%', ...spinnerStyle }}> <Stack
ref={imgRef}
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
background: (theme) => theme.palette.background.default,
...spinnerStyle,
}}
>
<ImageIcon fontSize="large" />
</Stack>
) : (
<Box
component="img"
key={`${src}_${imgLoadRetryKey}`}
sx={[
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]),
applyStyles(!imageSourceUrl || isLoading || hasError, {
visibility: 'hidden',
minWidth: 0,
minHeight: 0,
width: 0,
height: 0,
}),
]}
ref={imgRef}
src={imageSourceUrl}
alt={alt}
draggable={false}
/>
)}
{(isLoading || (src && !imageSourceUrl) || hasError) && (
<Box
sx={{
height: '100%',
...spinnerStyle,
}}
>
<Stack <Stack
sx={{ sx={{
height: '100%', height: '100%',
@@ -123,7 +166,7 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef<HTML
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
{isLoading && <CircularProgress thickness={5} />} {(isLoading || (src && !imageSourceUrl && !hasError)) && <CircularProgress thickness={5} />}
{hasError && isLoading === false && ( {hasError && isLoading === false && (
<> <>
<BrokenImageIcon /> <BrokenImageIcon />
@@ -143,32 +186,6 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef<HTML
</Stack> </Stack>
</Box> </Box>
)} )}
{showMissingImageIcon ? (
<Stack
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
background: (theme) => theme.palette.background.default,
...spinnerStyle,
}}
>
<ImageIcon fontSize="large" />
</Stack>
) : (
<img
key={`${src}_${imgLoadRetryKey}`}
style={{
...imgStyle,
display: !imageSourceUrl || isLoading || hasError ? 'none' : imgStyle?.display,
}}
ref={imgRef}
src={imageSourceUrl}
alt={alt}
draggable={false}
/>
)}
</> </>
); );
}); });

View File

@@ -15,16 +15,24 @@ import { AppStorage } from '@/lib/storage/AppStorage.ts';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx'; import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
export class MediaQuery { export class MediaQuery {
static readonly MOBILE_WIDTH: Breakpoint | number = 'sm';
static readonly TABLET_WIDTH: Breakpoint | number = 1025;
static useIsTouchDevice(): boolean { static useIsTouchDevice(): boolean {
return useMediaQuery('not (pointer: fine)'); return useMediaQuery('not (pointer: fine)');
} }
static useIsBelowWidth(breakpoint: Breakpoint): boolean { static useIsBelowWidth(breakpoint: Breakpoint | number): boolean {
return useMediaQuery(getCurrentTheme().breakpoints.down(breakpoint)); return useMediaQuery(getCurrentTheme().breakpoints.down(breakpoint));
} }
static useIsMobileWidth(): boolean { 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 { private static getScrollbarSize(type: 'height' | 'width'): number {

View File

@@ -164,7 +164,7 @@ const Thumbnail = ({ manga }: { manga: Partial<MangaThumbnailInfo> }) => {
<SpinnerImage <SpinnerImage
src={Mangas.getThumbnailUrl(manga)} src={Mangas.getThumbnailUrl(manga)}
alt="Manga Thumbnail" alt="Manga Thumbnail"
onImageLoad={() => setIsImageReady(true)} onLoad={() => setIsImageReady(true)}
imgStyle={{ width: '100%', height: '100%', objectFit: 'cover' }} imgStyle={{ width: '100%', height: '100%', objectFit: 'cover' }}
/> />
{isImageReady && ( {isImageReady && (

View File

@@ -70,6 +70,7 @@ const APP_METADATA_OBJECT: Record<AppMetadataKeys, undefined> = {
readingModesDefaultProfile: undefined, readingModesDefaultProfile: undefined,
defaultProfile: undefined, defaultProfile: undefined,
hotkeys: undefined, hotkeys: undefined,
imagePreLoadAmount: undefined,
}; };
export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_OBJECT); export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_OBJECT);

View File

@@ -11,12 +11,13 @@ import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; 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 { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx';
import { getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx'; import { getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { useReaderProgressBarContext } from '@/modules/reader/contexts/ReaderProgressBarContext.tsx'; import { useReaderProgressBarContext } from '@/modules/reader/contexts/ReaderProgressBarContext.tsx';
import { useReaderScrollbarContext } from '@/modules/reader/contexts/ReaderScrollbarContext.tsx'; import { useReaderScrollbarContext } from '@/modules/reader/contexts/ReaderScrollbarContext.tsx';
import { reverseString } from '@/util/Strings.ts';
export const ReaderPageNumber = () => { export const ReaderPageNumber = () => {
const { isDesktop } = ReaderService.useOverlayMode(); const { isDesktop } = ReaderService.useOverlayMode();
@@ -24,9 +25,15 @@ export const ReaderPageNumber = () => {
const { readerNavBarWidth } = useNavBarContext(); const { readerNavBarWidth } = useNavBarContext();
const { isMaximized } = useReaderProgressBarContext(); const { isMaximized } = useReaderProgressBarContext();
const { currentPageIndex, pages, totalPages } = userReaderStatePagesContext(); 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) { if (!shouldShowPageNumber) {
return null; return null;
@@ -62,9 +69,7 @@ export const ReaderPageNumber = () => {
backgroundColor: 'rgba(0, 0, 0, 0.3)', backgroundColor: 'rgba(0, 0, 0, 0.3)',
}} }}
> >
<Typography sx={{ color: 'white' }}> <Typography sx={{ color: 'white' }}>{pageName}</Typography>
{pageName}/{totalPages}
</Typography>
</Box> </Box>
</Stack> </Stack>
); );

View File

@@ -57,7 +57,7 @@ export const ReaderNavBarDesktop = ({ isVisible, openSettings }: ReaderNavBarDes
const { setReaderNavBarWidth } = useNavBarContext(); const { setReaderNavBarWidth } = useNavBarContext();
const { manga } = useReaderStateMangaContext(); const { manga } = useReaderStateMangaContext();
const { chapters, currentChapter, nextChapter, previousChapter } = useReaderStateChaptersContext(); const { chapters, currentChapter, nextChapter, previousChapter } = useReaderStateChaptersContext();
const { pages, currentPageIndex, setCurrentPageIndex } = userReaderStatePagesContext(); const { pages, currentPageIndex, setPageToScrollToIndex } = userReaderStatePagesContext();
const getOptionForDirection = useGetOptionForDirection(); const getOptionForDirection = useGetOptionForDirection();
@@ -128,7 +128,7 @@ export const ReaderNavBarDesktop = ({ isVisible, openSettings }: ReaderNavBarDes
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
<ReaderNavBarDesktopPageNavigation <ReaderNavBarDesktopPageNavigation
currentPageIndex={currentPageIndex} currentPageIndex={currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex} setPageToScrollToIndex={setPageToScrollToIndex}
pages={pages} pages={pages}
/> />
<ReaderNavBarDesktopChapterNavigation <ReaderNavBarDesktopChapterNavigation

View File

@@ -23,9 +23,9 @@ import { READING_DIRECTION_TO_THEME_DIRECTION } from '@/modules/reader/constants
export const ReaderNavBarDesktopPageNavigation = ({ export const ReaderNavBarDesktopPageNavigation = ({
currentPageIndex, currentPageIndex,
setCurrentPageIndex, setPageToScrollToIndex,
pages, pages,
}: Pick<ReaderStatePages, 'currentPageIndex' | 'setCurrentPageIndex' | 'pages'>) => { }: Pick<ReaderStatePages, 'currentPageIndex' | 'setPageToScrollToIndex' | 'pages'>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const openPage = ReaderControls.useOpenPage(); const openPage = ReaderControls.useOpenPage();
const { readingDirection } = ReaderService.useSettings(); const { readingDirection } = ReaderService.useSettings();
@@ -52,7 +52,7 @@ export const ReaderNavBarDesktopPageNavigation = ({
labelId="reader-nav-bar-desktop-page-select" labelId="reader-nav-bar-desktop-page-select"
label={t('reader.page_info.label.page')} label={t('reader.page_info.label.page')}
value={currentPage.primary.index} 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 }) => ( {pages.map(({ primary: { index }, name }) => (
<MenuItem key={index} value={index}> <MenuItem key={index} value={index}>

View File

@@ -35,7 +35,7 @@ export const ReaderProgressBar = ({
pages, pages,
pageLoadStates, pageLoadStates,
currentPageIndex, currentPageIndex,
setCurrentPageIndex, setPageToScrollToIndex,
slotProps, slotProps,
slots, slots,
createProgressBarSlot, createProgressBarSlot,
@@ -81,7 +81,7 @@ export const ReaderProgressBar = ({
progressBarRef, progressBarRef,
isDragging, isDragging,
currentPage, currentPage,
setCurrentPageIndex, setPageToScrollToIndex,
pages, pages,
progressBarPosition, progressBarPosition,
getOptionForDirection, getOptionForDirection,
@@ -101,7 +101,7 @@ export const ReaderProgressBar = ({
? (slotProps?.progressBarPageTexts?.current?.sx ?? []) ? (slotProps?.progressBarPageTexts?.current?.sx ?? [])
: [slotProps?.progressBarPageTexts?.current?.sx]), : [slotProps?.progressBarPageTexts?.current?.sx]),
]} ]}
onClick={() => setCurrentPageIndex(getNextPageIndex('previous', currentPage.pagesIndex, pages))} onClick={() => setPageToScrollToIndex(getNextPageIndex('previous', currentPage.pagesIndex, pages))}
> >
{currentPage.name} {currentPage.name}
</ReaderProgressBarPageNumber> </ReaderProgressBarPageNumber>
@@ -115,7 +115,7 @@ export const ReaderProgressBar = ({
return; return;
} }
setCurrentPageIndex( setPageToScrollToIndex(
getPageForMousePos( getPageForMousePos(
event.touches[0], event.touches[0],
progressBarRef.current.getBoundingClientRect(), progressBarRef.current.getBoundingClientRect(),
@@ -132,7 +132,7 @@ export const ReaderProgressBar = ({
return; return;
} }
setCurrentPageIndex( setPageToScrollToIndex(
getPageForMousePos( getPageForMousePos(
event, event,
progressBarRef.current.getBoundingClientRect(), progressBarRef.current.getBoundingClientRect(),
@@ -195,7 +195,7 @@ export const ReaderProgressBar = ({
? (slotProps?.progressBarPageTexts?.total?.sx ?? []) ? (slotProps?.progressBarPageTexts?.total?.sx ?? [])
: [slotProps?.progressBarPageTexts?.total?.sx]), : [slotProps?.progressBarPageTexts?.total?.sx]),
]} ]}
onClick={() => setCurrentPageIndex(getNextPageIndex('next', currentPage.pagesIndex, pages))} onClick={() => setPageToScrollToIndex(getNextPageIndex('next', currentPage.pagesIndex, pages))}
> >
{totalPages} {totalPages}
</ReaderProgressBarPageNumber> </ReaderProgressBarPageNumber>

View File

@@ -13,6 +13,8 @@ import { IReaderSettingsWithDefaultFlag } from '@/modules/reader/types/Reader.ty
import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx'; import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx';
import { ReaderSettingExitMode } from '@/modules/reader/components/settings/behaviour/ReaderSettingExitMode.tsx'; import { ReaderSettingExitMode } from '@/modules/reader/components/settings/behaviour/ReaderSettingExitMode.tsx';
import { isOffsetDoubleSpreadPagesEditable } from '@/modules/reader/utils/ReaderSettings.utils.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 = ({ export const ReaderBehaviourSettings = ({
settings, settings,
@@ -43,6 +45,25 @@ export const ReaderBehaviourSettings = ({
onChange={(_, checked) => updateSetting('shouldOffsetDoubleSpreads', checked)} onChange={(_, checked) => updateSetting('shouldOffsetDoubleSpreads', checked)}
/> />
)} )}
<SliderInput
label={t('reader.settings.image_preload_amount')}
value={settings.imagePreLoadAmount}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.imagePreLoadAmount,
value: settings.imagePreLoadAmount,
step: 1,
min: 1,
max: 20,
onChange: (_, value) => {
updateSetting('imagePreLoadAmount', value as number, false);
},
onChangeCommitted: (_, value) => {
updateSetting('imagePreLoadAmount', value as number, true);
},
},
}}
/>
</Stack> </Stack>
); );
}; };

View File

@@ -7,9 +7,10 @@
*/ */
import { useTranslation } from 'react-i18next'; 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 { SliderInput } from '@/modules/core/components/inputs/SliderInput.tsx';
import { DEFAULT_READER_SETTINGS } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; import { DEFAULT_READER_SETTINGS } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { isContinuousReadingMode } from '@/modules/reader/utils/ReaderSettings.utils.tsx';
export const ReaderSettingPageGap = ({ export const ReaderSettingPageGap = ({
pageGap, pageGap,
@@ -20,9 +21,7 @@ export const ReaderSettingPageGap = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isChangeable = [ReadingMode.CONTINUOUS_HORIZONTAL, ReadingMode.CONTINUOUS_VERTICAL].includes( const isChangeable = isContinuousReadingMode(readingMode.value);
readingMode.value,
);
if (!isChangeable) { if (!isChangeable) {
return null; return null;
} }

View File

@@ -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<ComponentProps<typeof SpinnerImage>, 'ref' | 'spinnerStyle' | 'imgStyle'> & {
display: boolean;
doublePage?: boolean;
position?: 'left' | 'right';
onLoad?: () => void;
},
ref: ForwardedRef<HTMLImageElement | null>,
) => {
const { readingMode, customFilter, pageScaleMode, shouldStretchPage, readerWidth } =
ReaderService.useSettings();
const { scrollbarXSize, scrollbarYSize } = useReaderScrollbarContext();
const isTabletWidth = MediaQuery.useIsTabletWidth();
if (!display && !shouldLoad) {
return null;
}
return (
<SpinnerImage
{...props}
shouldLoad={shouldLoad}
shouldDecode
ref={ref}
spinnerStyle={{
backgroundColor: 'background.paper',
...getImagePlaceholderStyling(
readingMode.value,
shouldStretchPage.value,
pageScaleMode.value,
readerWidth.value,
scrollbarXSize,
scrollbarYSize,
doublePage,
isTabletWidth,
),
...applyStyles(!display, {
display: 'none',
}),
}}
imgStyle={{
...getImageWidthStyling(
readingMode.value,
shouldStretchPage.value,
pageScaleMode.value,
doublePage,
readerWidth.value,
true,
),
display: 'block',
...applyStyles(!display, {
display: 'none',
}),
filter: getCustomFilterString(customFilter),
objectFit: 'contain',
objectPosition: position,
userSelect: 'none',
}}
/>
);
},
);

View File

@@ -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, PageInViewportType> = {
[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<HTMLDivElement | null>) => {
const {
currentPageIndex,
setCurrentPageIndex,
pageToScrollToIndex,
pages,
setPages,
totalPages,
pageUrls,
setPageLoadStates,
} = userReaderStatePagesContext();
const { readingMode, shouldOffsetDoubleSpreads, readingDirection } = ReaderService.useSettings();
const { setScrollbarXSize, setScrollbarYSize } = useReaderScrollbarContext();
const scrollElementRef = useRef<HTMLDivElement | null>(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 (
<Stack
ref={scrollElementRef}
sx={{ width: '100%', height: '100%', overflow: 'auto' }}
onClick={handleClick}
onScroll={() =>
ReaderControls.updateCurrentPageOnScroll(
imageRefs,
currentPageIndex,
setCurrentPageIndex,
inViewportType,
)
}
>
<Pager
totalPages={totalPages}
currentPageIndex={currentPageIndex}
pages={actualPages}
imageRefs={imageRefs}
onLoad={(pagesIndex, isPrimary = true) => {
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));
}}
/>
</Stack>
);
});

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 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 (
<Stack
{...slots?.stackProps}
sx={[
...(Array.isArray(slots?.stackProps?.sx) ? (slots?.stackProps?.sx ?? []) : [slots?.stackProps?.sx]),
getImageWidthStyling(
readingMode.value,
shouldStretchPage.value,
pageScaleMode.value,
false,
readerWidth.value,
),
applyStyles(!!readerWidth?.value.enabled && isReaderWidthEditable(pageScaleMode.value), {
alignItems: 'center',
// both continuous pagers have content that causes scrollbars, centering this content causes content
// to be cut off
...applyStyles(!isContinuousReadingMode(readingMode.value), {
justifyContent: 'center',
}),
}),
]}
>
{pages.map((page, pagesIndex) =>
createPage(page, pagesIndex, pagesIndexesToRender.includes(pagesIndex), (element) => {
// eslint-disable-next-line no-param-reassign
imageRefs.current[pagesIndex] = element;
}),
)}
</Stack>
);
};

View File

@@ -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 (
<BasePager
{...props}
createPage={(page, pagesIndex, shouldLoad) => {
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 (
<Fragment key={`${primary.url}_${secondary?.url}`}>
{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,
)}
</Fragment>
);
}}
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',
},
},
}}
/>
);
};

View File

@@ -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 (
<BasePager
{...props}
createPage={(page, pagesIndex, shouldLoad, setRef) =>
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`,
},
},
}}
/>
);
};

View File

@@ -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 (
<BasePager
{...props}
createPage={(page, pagesIndex, shouldLoad) =>
createReaderPage(
page,
() => onLoad?.(pagesIndex),
shouldLoad,
currentPageIndex === page.primary.index,
currentPageIndex,
totalPages,
)
}
slots={{
stackProps: {
sx: {
margin: 'auto',
},
},
}}
/>
);
};

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 { 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 (
<BasePager
{...props}
createPage={(page, pagesIndex, shouldLoad, setRef) =>
createReaderPage(
page,
() => onLoad?.(pagesIndex),
shouldLoad,
true,
currentPageIndex,
totalPages,
undefined,
undefined,
setRef,
)
}
slots={{
stackProps: {
sx: {
margin: 'auto',
flexWrap: 'nowrap',
alignItems: 'center',
gap: `${pageGap.value}px`,
},
},
}}
/>
);
};

View File

@@ -55,6 +55,7 @@ const GLOBAL_READER_SETTING_OBJECT: Record<keyof IReaderSettingsGlobal, undefine
profiles: undefined, profiles: undefined,
readingModesDefaultProfile: undefined, readingModesDefaultProfile: undefined,
hotkeys: undefined, hotkeys: undefined,
imagePreLoadAmount: undefined,
}; };
export const GLOBAL_READER_SETTING_KEYS = Object.keys(GLOBAL_READER_SETTING_OBJECT); export const GLOBAL_READER_SETTING_KEYS = Object.keys(GLOBAL_READER_SETTING_OBJECT);
@@ -128,6 +129,7 @@ export const DEFAULT_READER_SETTINGS: IReaderSettings = {
[ReaderHotkey.STRETCH_IMAGE]: ['f'], [ReaderHotkey.STRETCH_IMAGE]: ['f'],
[ReaderHotkey.OFFSET_SPREAD_PAGES]: ['o'], [ReaderHotkey.OFFSET_SPREAD_PAGES]: ['o'],
}, },
imagePreLoadAmount: 5,
}; };
export const READER_PROGRESS_BAR_POSITION_TO_PLACEMENT: Record<ProgressBarPosition, TooltipProps['placement']> = { export const READER_PROGRESS_BAR_POSITION_TO_PLACEMENT: Record<ProgressBarPosition, TooltipProps['placement']> = {

View File

@@ -13,6 +13,8 @@ export const ReaderStatePagesContext = createContext<ReaderStatePages>({
totalPages: 0, totalPages: 0,
currentPageIndex: 0, currentPageIndex: 0,
setCurrentPageIndex: () => undefined, setCurrentPageIndex: () => undefined,
pageToScrollToIndex: 0,
setPageToScrollToIndex: () => undefined,
setTotalPages: () => undefined, setTotalPages: () => undefined,
pageUrls: [], pageUrls: [],
setPageUrls: () => undefined, setPageUrls: () => undefined,

View File

@@ -15,6 +15,7 @@ type TContext = ContextType<typeof ReaderStatePagesContext>;
export const ReaderStatePagesContextProvider = ({ children }: { children: ReactNode }) => { export const ReaderStatePagesContextProvider = ({ children }: { children: ReactNode }) => {
const [totalPages, setTotalPages] = useState<TContext['totalPages']>(0); const [totalPages, setTotalPages] = useState<TContext['totalPages']>(0);
const [currentPageIndex, setCurrentPageIndex] = useState<TContext['currentPageIndex']>(0); const [currentPageIndex, setCurrentPageIndex] = useState<TContext['currentPageIndex']>(0);
const [pageToScrollToIndex, setPageToScrollToIndex] = useState<TContext['pageToScrollToIndex']>(0);
const [pageUrls, setPageUrls] = useState<TContext['pageUrls']>([]); const [pageUrls, setPageUrls] = useState<TContext['pageUrls']>([]);
const [pageLoadStates, setPageLoadStates] = useState<TContext['pageLoadStates']>([]); const [pageLoadStates, setPageLoadStates] = useState<TContext['pageLoadStates']>([]);
const [pages, setPages] = useState<TContext['pages']>([createPageData('', 0)]); const [pages, setPages] = useState<TContext['pages']>([createPageData('', 0)]);
@@ -25,6 +26,8 @@ export const ReaderStatePagesContextProvider = ({ children }: { children: ReactN
setTotalPages, setTotalPages,
currentPageIndex, currentPageIndex,
setCurrentPageIndex, setCurrentPageIndex,
pageToScrollToIndex,
setPageToScrollToIndex,
pageUrls, pageUrls,
setPageUrls, setPageUrls,
pageLoadStates, pageLoadStates,
@@ -32,7 +35,7 @@ export const ReaderStatePagesContextProvider = ({ children }: { children: ReactN
pages, pages,
setPages, setPages,
}), }),
[totalPages, pages, currentPageIndex, pageUrls, pageLoadStates], [totalPages, pages, currentPageIndex, pageToScrollToIndex, pageUrls, pageLoadStates],
); );
return <ReaderStatePagesContext.Provider value={value}>{children}</ReaderStatePagesContext.Provider>; return <ReaderStatePagesContext.Provider value={value}>{children}</ReaderStatePagesContext.Provider>;

View File

@@ -6,16 +6,22 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * 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 { Direction } from '@mui/material/styles';
import { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx'; import { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx';
import { getNextPageIndex, getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx'; import { getNextPageIndex, getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx';
import { getOptionForDirection } from '@/theme.tsx'; import { getOptionForDirection } from '@/theme.tsx';
import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; import { ReaderService } from '@/modules/reader/services/ReaderService.ts';
import { useReaderStateChaptersContext } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; 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 { ScrollDirection, ScrollOffset } from '@/modules/core/Core.types.ts';
import { ReaderScrollAmount } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; 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, Direction> = { const READING_DIRECTION_TO_DIRECTION: Record<ReadingDirection, Direction> = {
[ReadingDirection.LTR]: 'ltr', [ReadingDirection.LTR]: 'ltr',
@@ -95,7 +101,7 @@ export class ReaderControls {
} }
static useOpenPage(): (offset: 'previous' | 'next') => void { static useOpenPage(): (offset: 'previous' | 'next') => void {
const { currentPageIndex, setCurrentPageIndex, pages } = userReaderStatePagesContext(); const { currentPageIndex, setPageToScrollToIndex, pages } = userReaderStatePagesContext();
const { readingDirection } = ReaderService.useSettings(); const { readingDirection } = ReaderService.useSettings();
const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]); const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]);
@@ -113,10 +119,10 @@ export class ReaderControls {
(offset) => { (offset) => {
switch (offset) { switch (offset) {
case 'previous': case 'previous':
setCurrentPageIndex(getOptionForDirection(previousPageIndex, nextPageIndex, direction)); setPageToScrollToIndex(getOptionForDirection(previousPageIndex, nextPageIndex, direction));
break; break;
case 'next': case 'next':
setCurrentPageIndex(getOptionForDirection(nextPageIndex, previousPageIndex, direction)); setPageToScrollToIndex(getOptionForDirection(nextPageIndex, previousPageIndex, direction));
break; break;
default: default:
throw new Error(`Unexpected "offset" (${offset})`); throw new Error(`Unexpected "offset" (${offset})`);
@@ -125,4 +131,69 @@ export class ReaderControls {
[direction, previousPageIndex, nextPageIndex], [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<HTMLDivElement, 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],
);
}
} }

View File

@@ -6,9 +6,11 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * 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 { TapZoneInvertMode, TapZoneLayouts } from '@/modules/reader/types/TapZoneLayout.types.ts';
import { TChapterReader } from '@/modules/chapter/Chapter.types.ts'; import { TChapterReader } from '@/modules/chapter/Chapter.types.ts';
import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; import { ReaderService } from '@/modules/reader/services/ReaderService.ts';
import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts';
export enum ProgressBarType { export enum ProgressBarType {
HIDDEN, HIDDEN,
@@ -120,6 +122,7 @@ export interface IReaderSettingsGlobal {
profiles: string[]; profiles: string[];
readingModesDefaultProfile: Record<ReadingMode, string>; readingModesDefaultProfile: Record<ReadingMode, string>;
hotkeys: Record<ReaderHotkey, string[]>; hotkeys: Record<ReaderHotkey, string[]>;
imagePreLoadAmount: number;
} }
export interface IReaderSettingsManga { export interface IReaderSettingsManga {
@@ -193,3 +196,13 @@ export enum ReaderHotkey {
STRETCH_IMAGE, STRETCH_IMAGE,
OFFSET_SPREAD_PAGES, OFFSET_SPREAD_PAGES,
} }
export interface ReaderPagerProps extends Pick<ReaderStatePages, 'currentPageIndex' | 'pages' | 'totalPages'> {
imageRefs: MutableRefObject<(HTMLElement | null)[]>;
onLoad?: (pagesIndex: number, isPrimary?: boolean) => void;
}
export enum PageInViewportType {
X,
Y,
}

View File

@@ -27,6 +27,8 @@ export interface ReaderStatePages {
setTotalPages: React.Dispatch<React.SetStateAction<number>>; setTotalPages: React.Dispatch<React.SetStateAction<number>>;
currentPageIndex: number; currentPageIndex: number;
setCurrentPageIndex: React.Dispatch<React.SetStateAction<number>>; setCurrentPageIndex: React.Dispatch<React.SetStateAction<number>>;
pageToScrollToIndex: number;
setPageToScrollToIndex: React.Dispatch<React.SetStateAction<number>>;
pageUrls: string[]; pageUrls: string[];
setPageUrls: React.Dispatch<React.SetStateAction<string[]>>; setPageUrls: React.Dispatch<React.SetStateAction<string[]>>;
pageLoadStates: boolean[]; pageLoadStates: boolean[];

View File

@@ -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<Theme['applyStyles']>;
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, { minWidth: string; minHeight: string }> = {
[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 => (
<ReaderPage
ref={setRef}
key={url}
src={url}
alt={alt}
display={display}
priority={getPageDownloadPriority(currentPageIndex, index, totalPages)}
position={position}
onLoad={onLoad}
doublePage={isDoublePage}
shouldLoad={shouldLoad}
/>
);
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);
};

View File

@@ -85,7 +85,7 @@ export const useHandleProgressDragging = (
progressBarRef: RefObject<HTMLDivElement | null>, progressBarRef: RefObject<HTMLDivElement | null>,
isDragging: boolean, isDragging: boolean,
currentPage: TReaderProgressCurrentPage, currentPage: TReaderProgressCurrentPage,
setCurrentPageIndex: (pageIndex: number) => void, setPageToScrollToIndex: (pageIndex: number) => void,
pages: ReaderProgressBarProps['pages'], pages: ReaderProgressBarProps['pages'],
progressBarPosition: ProgressBarPosition, progressBarPosition: ProgressBarPosition,
getOptionForDirection: typeof getOptionForDirectionImpl, getOptionForDirection: typeof getOptionForDirectionImpl,
@@ -115,7 +115,7 @@ export const useHandleProgressDragging = (
return; return;
} }
setCurrentPageIndex(newPageIndex); setPageToScrollToIndex(newPageIndex);
}; };
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {

View File

@@ -7,7 +7,7 @@
*/ */
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; 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 { DEFAULT_READER_PROFILE } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { ValueToDisplayData } from '@/modules/core/Core.types.ts'; import { ValueToDisplayData } from '@/modules/core/Core.types.ts';
@@ -31,3 +31,9 @@ export const createProfileValueToDisplayData = (
export const getValidReaderProfile = (profile: string, profiles: string[]): string => export const getValidReaderProfile = (profile: string, profiles: string[]): string =>
profiles.includes(profile) ? profile : DEFAULT_READER_PROFILE; 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);

View File

@@ -12,3 +12,6 @@ export const enhancedCleanup = (str: string): string =>
baseCleanup(str) baseCleanup(str)
.replace(/[^a-zA-Z0-9\\s]+/g, ' ') .replace(/[^a-zA-Z0-9\\s]+/g, ' ')
.trim(); .trim();
export const reverseString = (str: string, separator: string = ''): string =>
str.split(separator).reverse().join(separator);