Delete deprecated reader files

This commit is contained in:
schroda
2024-10-26 16:07:14 +02:00
parent 484d6cdac4
commit 8e59763bba
16 changed files with 3 additions and 2251 deletions

View File

@@ -12,7 +12,7 @@ import { SelectableCollectionReturnType } from '@/modules/collection/hooks/useSe
import { useManageMangaLibraryState } from '@/modules/manga/hooks/useManageMangaLibraryState.tsx';
import { ChapterType, MangaReaderFieldsFragment, MangaType, TrackRecordType } from '@/lib/graphql/generated/graphql.ts';
import { SingleModeProps } from '@/modules/manga/components/MangaActionMenuItems.tsx';
import { IReaderSettings } from '@/modules/reader/Reader.types';
import { IReaderSettings } from '@/modules/reader/Reader.types.ts';
import { GridLayout } from '@/modules/core/Core.types.ts';
export type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select' | 'duplicate';

View File

@@ -17,7 +17,6 @@ const APP_METADATA_OBJECT: Record<AppMetadataKeys, undefined> = {
skipDupChapters: undefined,
fitPageToWindow: undefined,
scalePage: undefined,
readerType: undefined,
offsetFirstPage: undefined,
migration: undefined,
deleteChaptersManuallyMarkedRead: undefined,

View File

@@ -15,7 +15,6 @@ export const DEFAULT_READER_SETTINGS: IReaderSettings = {
skipDupChapters: true,
fitPageToWindow: true,
scalePage: false,
readerType: 'ContinuesVertical',
offsetFirstPage: false,
readerWidth: 50,
};

View File

@@ -6,20 +6,6 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { GetChaptersReaderQuery, MangaReaderFieldsFragment } from '@/lib/graphql/generated/graphql.ts';
export type ReaderType =
| 'ContinuesVertical'
| 'Webtoon'
| 'SingleVertical'
| 'SingleRTL'
| 'SingleLTR'
| 'DoubleVertical'
| 'DoubleRTL'
| 'DoubleLTR'
| 'ContinuesHorizontalLTR'
| 'ContinuesHorizontalRTL';
export interface IReaderSettings {
staticNav: boolean;
showPageNumber: boolean;
@@ -27,25 +13,6 @@ export interface IReaderSettings {
skipDupChapters: boolean;
fitPageToWindow: boolean;
scalePage: boolean;
readerType: ReaderType;
offsetFirstPage: boolean;
readerWidth: number;
}
export interface IReaderPage {
index: number;
src: string;
}
export interface IReaderProps {
pages: Array<IReaderPage>;
pageCount: number;
setCurPage: React.Dispatch<React.SetStateAction<number>>;
curPage: number;
initialPage: number;
settings: IReaderSettings;
manga: MangaReaderFieldsFragment;
chapter: GetChaptersReaderQuery['chapters']['nodes'][number];
nextChapter: () => void;
prevChapter: () => void;
}

View File

@@ -1,398 +0,0 @@
/*
* 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 IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import Typography from '@mui/material/Typography';
import { useLocation, useNavigate } from 'react-router-dom';
import Slide from '@mui/material/Slide';
import Fade from '@mui/material/Fade';
import Zoom from '@mui/material/Zoom';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import MenuItem from '@mui/material/MenuItem';
import Tooltip from '@mui/material/Tooltip';
import { styled, useTheme } from '@mui/material/styles';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import Collapse from '@mui/material/Collapse';
import { useTranslation } from 'react-i18next';
import { ReaderSettingsOptions } from '@/modules/reader/components/ReaderSettingsOptions.tsx';
import { useBackButton } from '@/modules/core/hooks/useBackButton.ts';
import { Select } from '@/modules/core/components/inputs/Select.tsx';
import { useGetOptionForDirection } from '@/theme.tsx';
import { ChapterType } from '@/lib/graphql/generated/graphql.ts';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
import { CustomIconButton } from '@/modules/core/components/buttons/CustomIconButton.tsx';
import { IReaderSettings } from '@/modules/reader/Reader.types.ts';
import { DirectionOffset } from '@/Base.types.ts';
import { AllowedMetadataValueTypes } from '@/modules/metadata/Metadata.types.ts';
import { MangaChapterCountInfo, MangaIdInfo } from '@/modules/manga/Manga.types.ts';
const Root = styled('div')({
zIndex: 10,
});
const NavContainer = styled('div')(({ theme }) => ({
position: 'fixed',
top: 0,
left: 0,
minWidth: '240px',
maxWidth: '400px',
height: '100vh',
overflowY: 'auto',
backgroundColor: theme.palette.background.default,
'& header': {
backgroundColor: theme.palette.action.hover,
display: 'flex',
alignItems: 'center',
padding: `${theme.spacing(1)} ${theme.spacing(3)}`,
transition: 'left 2s ease',
},
}));
const Navigation = styled('div')(({ theme }) => ({
margin: `0 ${theme.spacing(2)}`,
}));
const PageNavigation = styled('div')({
display: 'flex',
flexWrap: 'wrap',
alignContent: 'center',
alignItems: 'center',
justifyContent: 'center',
});
const ChapterNavigation = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateRows: 'auto auto auto',
gridTemplateColumns: '0fr 1fr 0fr',
gridTemplateAreas: '"pre current next"',
gridColumnGap: theme.spacing(0.5),
margin: `${theme.spacing(1)} 0`,
'& a': {
textDecoration: 'none',
color: 'inherit',
display: 'flex',
flexWrap: 'wrap',
alignContent: 'center',
},
}));
interface IProps {
settings: IReaderSettings;
setSettingValue: (key: keyof IReaderSettings, value: AllowedMetadataValueTypes, persist?: boolean) => void;
manga: MangaIdInfo & MangaChapterCountInfo;
chapter: Pick<ChapterType, 'name' | 'sourceOrder' | 'pageCount'>;
chapters: Pick<ChapterType, 'id' | 'sourceOrder' | 'name' | 'chapterNumber' | 'scanlator'>[];
curPage: number;
scrollToPage: (page: number) => void;
openNextChapter: (offset: DirectionOffset) => void;
retrievingNextChapter: boolean;
}
export function ReaderNavBar(props: IProps) {
const { t } = useTranslation();
const { setReaderNavBarWidth } = useNavBarContext();
const theme = useTheme();
const getOptionForDirection = useGetOptionForDirection();
const navigate = useNavigate();
const location = useLocation<{
prevDrawerOpen?: boolean;
prevSettingsCollapseOpen?: boolean;
}>();
const { prevDrawerOpen, prevSettingsCollapseOpen } = location.state ?? {};
const navBarRef = useRef<HTMLDivElement | null>(null);
useResizeObserver(
navBarRef,
useCallback(() => {
if (navBarRef.current?.offsetWidth === undefined) {
return;
}
setReaderNavBarWidth(navBarRef.current.offsetWidth);
}, [navBarRef.current]),
);
useLayoutEffect(() => () => setReaderNavBarWidth(0), [navBarRef]);
const {
settings,
setSettingValue,
manga,
chapter,
chapters,
curPage,
scrollToPage,
openNextChapter,
retrievingNextChapter,
} = props;
const handleBack = useBackButton();
const hasMultipleScanlators = useMemo(
() => new Set(chapters.map(({ scanlator }) => scanlator)).size > 1,
[chapters],
);
const [drawerOpen, setDrawerOpen] = useState(settings.staticNav || prevDrawerOpen);
const [updateDrawerOnRender, setUpdateDrawerOnRender] = useState(true);
const [hideOpenButton, setHideOpenButton] = useState(settings.staticNav || prevDrawerOpen);
const [prevScrollPos, setPrevScrollPos] = useState(0);
const [settingsCollapseOpen, setSettingsCollapseOpen] = useState(prevSettingsCollapseOpen ?? true);
const disableChapterNavButtons = retrievingNextChapter;
const updateSettingValue = (key: keyof IReaderSettings, value: AllowedMetadataValueTypes, persist?: boolean) => {
// prevent closing the navBar when updating the "staticNav" setting
setUpdateDrawerOnRender(key !== 'staticNav');
setSettingValue(key, value, persist);
};
const updateDrawer = (open: boolean) => {
setDrawerOpen(open);
setHideOpenButton(open);
};
const handleScroll = () => {
const currentScrollPos = window.pageYOffset;
if (Math.abs(currentScrollPos - prevScrollPos) > 20) {
setHideOpenButton(currentScrollPos > prevScrollPos);
setPrevScrollPos(currentScrollPos);
}
};
useEffect(() => {
if (updateDrawerOnRender) {
updateDrawer(settings.staticNav);
}
}, [settings.staticNav]);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
const rootEl: HTMLDivElement = document.querySelector('#root')!;
const mainContainer: HTMLDivElement = document.querySelector('#appMainContainer')!;
// main container and root div need to change styles...
rootEl.style.display = 'flex';
rootEl.style.flexDirection = 'column';
mainContainer.style.display = 'none';
return () => {
rootEl.style.display = 'block';
mainContainer.style.display = 'block';
window.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]); // handleScroll changes on every render
return (
<Root>
<Slide
direction={getOptionForDirection('right', 'left')}
in={drawerOpen}
timeout={200}
appear={false}
mountOnEnter
unmountOnExit
>
<NavContainer ref={navBarRef}>
<header>
{!settings.staticNav && (
<Tooltip title={t('reader.button.close_menu')}>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
onClick={() => updateDrawer(false)}
size="large"
>
{getOptionForDirection(<KeyboardArrowLeftIcon />, <KeyboardArrowRightIcon />)}
</IconButton>
</Tooltip>
)}
<Typography
variant="h6"
component="h1"
sx={{
textOverflow: 'ellipsis',
overflow: 'hidden',
py: 1,
flexGrow: 1,
}}
>
{chapter.name}
</Typography>
<Tooltip title={t('reader.button.exit')}>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
onClick={handleBack}
size="large"
sx={{ mr: -1 }}
>
<CloseIcon />
</IconButton>
</Tooltip>
</header>
<ListItem
ContainerComponent="div"
sx={{
'& span': {
fontWeight: 'bold',
},
}}
>
<ListItemText primary={t('reader.settings.title.reader_settings')} />
<IconButton
edge="start"
color="inherit"
aria-label="menu"
disableRipple
disableFocusRipple
onClick={() => setSettingsCollapseOpen(!settingsCollapseOpen)}
size="large"
>
{settingsCollapseOpen && <KeyboardArrowUpIcon />}
{!settingsCollapseOpen && <KeyboardArrowDownIcon />}
</IconButton>
</ListItem>
<Collapse in={settingsCollapseOpen} timeout="auto" unmountOnExit>
<ReaderSettingsOptions
setSettingValue={updateSettingValue}
staticNav={settings.staticNav}
showPageNumber={settings.showPageNumber}
loadNextOnEnding={settings.loadNextOnEnding}
skipDupChapters={settings.skipDupChapters}
fitPageToWindow={settings.fitPageToWindow}
scalePage={settings.scalePage}
readerType={settings.readerType}
offsetFirstPage={settings.offsetFirstPage}
readerWidth={settings.readerWidth}
/>
</Collapse>
<Divider sx={{ my: 1, mx: 2 }} />
<Navigation>
<PageNavigation>
<span>{t('reader.page_info.label.currently_on_page')}</span>
<FormControl
size="small"
sx={{ mx: 0.5 }}
disabled={disableChapterNavButtons || chapter.pageCount === -1}
>
<Select
value={chapter.pageCount > -1 ? `${curPage}` : ''}
displayEmpty
onChange={({ target: { value: selectedPage } }) => {
scrollToPage(Number(selectedPage));
}}
>
{Array(Math.max(0, chapter.pageCount))
.fill(1)
.map((ignoreValue, index) => (
// eslint-disable-next-line react/no-array-index-key
<MenuItem key={`Page#${index}`} value={index}>
{index + 1}
</MenuItem>
))}
</Select>
</FormControl>
<span>{t('reader.page_info.label.of_max_pages', { maxPages: chapter.pageCount })}</span>
</PageNavigation>
<ChapterNavigation>
<Tooltip title={t('reader.button.previous_chapter')}>
<IconButton
sx={{ gridArea: 'pre' }}
disabled={disableChapterNavButtons || chapter.sourceOrder <= 1}
onClick={() => openNextChapter(DirectionOffset.PREVIOUS)}
>
{getOptionForDirection(<KeyboardArrowLeftIcon />, <KeyboardArrowRightIcon />)}
</IconButton>
</Tooltip>
<FormControl
sx={{ gridArea: 'current' }}
size="small"
disabled={disableChapterNavButtons || chapter.sourceOrder < 1}
>
<Select
value={chapter.sourceOrder >= 1 ? `${chapter.sourceOrder}` : ''}
displayEmpty
onChange={({ target: { value: selectedChapter } }) => {
navigate(`/manga/${manga.id}/chapter/${selectedChapter}`, {
replace: true,
state: {
prevDrawerOpen: drawerOpen,
prevSettingsCollapseOpen: settingsCollapseOpen,
},
});
}}
>
{chapters.map(({ id, sourceOrder, name, chapterNumber, scanlator }) => (
<MenuItem
key={id}
value={sourceOrder}
>{`#${chapterNumber}${hasMultipleScanlators && scanlator != null ? ` (${scanlator})` : ''} | ${name}`}</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title={t('reader.button.next_chapter')}>
<IconButton
sx={{ gridArea: 'next' }}
disabled={
disableChapterNavButtons ||
chapter.sourceOrder < 1 ||
chapter.sourceOrder >= manga.chapters.totalCount
}
onClick={() => openNextChapter(DirectionOffset.NEXT)}
>
{getOptionForDirection(<KeyboardArrowRightIcon />, <KeyboardArrowLeftIcon />)}
</IconButton>
</Tooltip>
</ChapterNavigation>
</Navigation>
</NavContainer>
</Slide>
<Zoom in={!drawerOpen}>
<Fade in={!hideOpenButton}>
<Tooltip title={t('reader.button.open_menu')}>
<CustomIconButton
sx={{
position: 'fixed',
top: 20,
left: 20,
backgroundColor: 'rgba(255, 255, 255, 0.75);',
color: 'black',
...theme.applyStyles('dark', {
backgroundColor: 'rgba(0, 0, 0, 0.75);',
color: 'white',
}),
}}
size="large"
variant="contained"
onClick={() => updateDrawer(true)}
>
{getOptionForDirection(<KeyboardArrowRightIcon />, <KeyboardArrowLeftIcon />)}
</CustomIconButton>
</Tooltip>
</Fade>
</Zoom>
</Root>
);
}

View File

@@ -1,147 +0,0 @@
/*
* 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 List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import Switch from '@mui/material/Switch';
import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
import { NumberSetting } from '@/modules/core/components/settings/NumberSetting.tsx';
import { isHorizontalReaderType } from '@/modules/reader/components/page/Page.tsx';
import { Select } from '@/modules/core/components/inputs/Select.tsx';
import { IReaderSettings } from '@/modules/reader/Reader.types.ts';
import { AllowedMetadataValueTypes } from '@/modules/metadata/Metadata.types.ts';
interface IProps extends IReaderSettings {
setSettingValue: (key: keyof IReaderSettings, value: AllowedMetadataValueTypes, persist?: boolean) => void;
}
export function ReaderSettingsOptions({
staticNav,
loadNextOnEnding,
readerType,
showPageNumber,
skipDupChapters,
setSettingValue,
fitPageToWindow,
scalePage,
offsetFirstPage,
readerWidth,
}: IProps) {
const { t } = useTranslation();
const fitPageToWindowEligible = !isHorizontalReaderType(readerType);
return (
<List sx={{ pt: 0 }}>
<ListItem>
<ListItemText primary={t('reader.settings.label.static_navigation')} />
<Switch
edge="end"
checked={staticNav}
onChange={(e) => setSettingValue('staticNav', e.target.checked)}
/>
</ListItem>
<ListItem>
<ListItemText primary={t('reader.settings.label.show_page_number')} />
<Switch
edge="end"
checked={showPageNumber}
onChange={(e) => setSettingValue('showPageNumber', e.target.checked)}
/>
</ListItem>
<ListItem>
<ListItemText primary={t('reader.settings.label.load_next_chapter')} />
<Switch
edge="end"
checked={loadNextOnEnding}
onChange={(e) => setSettingValue('loadNextOnEnding', e.target.checked)}
/>
</ListItem>
<ListItem>
<ListItemText primary={t('reader.settings.label.skip_dup_chapters')} />
<Switch
edge="end"
checked={skipDupChapters}
onChange={(e) => setSettingValue('skipDupChapters', e.target.checked)}
/>
</ListItem>
{fitPageToWindowEligible && (
<ListItem>
<ListItemText primary={t('reader.settings.label.fit_page_to_window')} />
<Switch
edge="end"
checked={fitPageToWindow}
onChange={(e) => setSettingValue('fitPageToWindow', e.target.checked)}
/>
</ListItem>
)}
{fitPageToWindowEligible && fitPageToWindow && (
<ListItem>
<ListItemText primary={t('reader.settings.label.scale_page')} />
<Switch
edge="end"
checked={scalePage}
onChange={(e) => setSettingValue('scalePage', e.target.checked)}
/>
</ListItem>
)}
{(readerType === 'DoubleLTR' || readerType === 'DoubleRTL') && (
<ListItem>
<ListItemText primary={t('reader.settings.label.offset_first_page')} />
<Switch
edge="end"
checked={offsetFirstPage}
onChange={(e) => setSettingValue('offsetFirstPage', e.target.checked)}
/>
</ListItem>
)}
{fitPageToWindowEligible && !fitPageToWindow && (
<NumberSetting
settingTitle={t('reader.settings.label.reader_width')}
settingValue={`${readerWidth}%`}
value={readerWidth}
minValue={10}
maxValue={100}
defaultValue={50}
valueUnit="%"
showSlider
handleUpdate={(width: number) => setSettingValue('readerWidth', width)}
handleLiveUpdate={(width: number) => setSettingValue('readerWidth', width, false)}
listItemTextSx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
/>
)}
<ListItem>
<ListItemText primary={t('reader.settings.label.reader_type')} />
<Select
variant="standard"
value={readerType}
onChange={(e) => setSettingValue('readerType', e.target.value)}
sx={{ p: 0 }}
>
<MenuItem value="SingleLTR">{t('reader.settings.reader_type.label.single_page_ltr')}</MenuItem>
<MenuItem value="SingleRTL">{t('reader.settings.reader_type.label.single_page_rtl')}</MenuItem>
{/* <MenuItem value="SingleVertical">
Vertical(WIP)
</MenuItem> */}
<MenuItem value="DoubleLTR">{t('reader.settings.reader_type.label.double_page_ltr')}</MenuItem>
<MenuItem value="DoubleRTL">{t('reader.settings.reader_type.label.double_page_rtl')}</MenuItem>
<MenuItem value="Webtoon">{t('reader.settings.reader_type.label.webtoon')}</MenuItem>
<MenuItem value="ContinuesVertical">
{t('reader.settings.reader_type.label.continuous_vertical')}
</MenuItem>
<MenuItem value="ContinuesHorizontalLTR">
{t('reader.settings.reader_type.label.continuous_horizontal_ltr')}
</MenuItem>
<MenuItem value="ContinuesHorizontalRTL">
{t('reader.settings.reader_type.label.continuous_horizontal_rtl')}
</MenuItem>
</Select>
</ListItem>
</List>
);
}

View File

@@ -1,87 +0,0 @@
/*
* 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 { CSSProperties, forwardRef } from 'react';
import Box from '@mui/material/Box';
import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
import { imageStyle } from '@/modules/reader/components/page/Page.tsx';
import { getOptionForDirection } from '@/theme.tsx';
import { IReaderSettings } from '@/modules/reader/Reader.types.ts';
interface IProps {
index: number;
image1src: string;
image2src: string;
onImageLoad?: () => void;
settings: IReaderSettings;
}
export const DoublePage = forwardRef((props: IProps, ref: any) => {
const { image1src, image2src, index, onImageLoad, settings } = props;
const baseImgStyle = imageStyle(settings);
const imgStyle = {
...baseImgStyle,
width: settings.fitPageToWindow ? baseImgStyle.width : `calc(${baseImgStyle.width} * 0.5)`,
minWidth:
settings.fitPageToWindow && settings.scalePage
? `calc(${baseImgStyle.minWidth} * 0.5)`
: baseImgStyle.minWidth,
maxWidth: settings.fitPageToWindow ? `calc(${baseImgStyle.maxWidth} * 0.5)` : baseImgStyle.maxWidth,
};
const spinnerStyle: CSSProperties = {
...imgStyle,
height: '100vh',
width: '50%',
backgroundColor: '#525252',
};
return (
<Box
ref={ref}
sx={{
display: 'flex',
flexDirection: settings.readerType === 'DoubleLTR' ? 'row' : 'row-reverse',
justifyContent: 'center',
width: '100%',
}}
>
<SpinnerImage
src={image1src}
onImageLoad={onImageLoad}
alt={`Page #${index}`}
spinnerStyle={spinnerStyle}
imgStyle={{
...imgStyle,
objectPosition:
settings.readerType === 'DoubleLTR'
? getOptionForDirection('right', 'left')
: getOptionForDirection('left', 'right'),
}}
/>
<SpinnerImage
src={image2src}
onImageLoad={onImageLoad}
alt={`Page #${index + 1}`}
spinnerStyle={{
...spinnerStyle,
width: 'calc(50% - 5px)',
marginLeft: '5px',
}}
imgStyle={{
...imgStyle,
objectPosition:
settings.readerType === 'DoubleLTR'
? getOptionForDirection('left', 'right')
: getOptionForDirection('right', 'left'),
}}
/>
</Box>
);
});

View File

@@ -1,113 +0,0 @@
/*
* 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 { CSSProperties, forwardRef } from 'react';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx';
import { IReaderSettings, ReaderType } from '@/modules/reader/Reader.types.ts';
import { Priority } from '@/lib/Queue.ts';
export const isHorizontalReaderType = (readerType: ReaderType): boolean =>
['ContinuesHorizontalLTR', 'ContinuesHorizontalRTL'].includes(readerType);
export function imageStyle(settings: IReaderSettings): CSSProperties {
const isDoublePageReader = ['DoubleRTL', 'DoubleLTR'].includes(settings.readerType);
const isVertical = settings.readerType === 'ContinuesVertical';
const isHorizontal = isHorizontalReaderType(settings.readerType);
const scrollbarHeight = MediaQuery.useGetScrollbarSize('height');
const baseStyling: CSSProperties = {
margin: 0,
width: `${settings.readerWidth}%`,
objectFit: 'contain',
};
const doublePageStyling: CSSProperties = {};
const continuesVerticalStyling: CSSProperties = {
marginBottom: '15px',
};
const continuesHorizontalStyling: CSSProperties = {
width: undefined,
minHeight: `calc(100vh - ${scrollbarHeight}px)`,
maxHeight: `calc(100vh - ${scrollbarHeight}px)`,
marginLeft: '7px',
marginRight: '7px',
};
const fitToPageStyling: CSSProperties = {
width: undefined,
height: undefined,
minWidth: settings.scalePage ? 'calc(100vw - (100vw - 100%))' : undefined,
maxWidth: 'calc(100vw - (100vw - 100%))',
minHeight: settings.scalePage ? `calc(100vh - ${scrollbarHeight}px)` : undefined,
maxHeight: `calc(100vh - ${scrollbarHeight}px)`,
};
return {
...baseStyling,
...(isDoublePageReader ? doublePageStyling : undefined),
...(isHorizontal ? continuesHorizontalStyling : undefined),
...(isVertical ? continuesVerticalStyling : undefined),
...(settings.fitPageToWindow && !isHorizontal ? fitToPageStyling : undefined),
};
}
interface IProps {
src: string;
index: number;
onImageLoad: () => void;
settings: IReaderSettings;
display?: boolean;
priority?: Priority;
}
export const Page = forwardRef((props: IProps, ref: any) => {
const { src, index, onImageLoad, settings, display = true, priority } = props;
const theme = useTheme();
const isMobileWidth = useMediaQuery(theme.breakpoints.down('md'));
const imgStyle = imageStyle(settings);
return (
<Box
ref={ref}
sx={{
display: display ? 'flex' : 'none',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<SpinnerImage
priority={priority}
src={src}
onImageLoad={onImageLoad}
alt={`Page #${index}`}
spinnerStyle={{
...imgStyle,
height: '100vh',
// eslint-disable-next-line no-nested-ternary
width: isMobileWidth
? '100vw'
: isHorizontalReaderType(settings.readerType)
? '50vw'
: 'calc(100% * 0.5)',
backgroundColor: 'background.paper',
}}
imgStyle={imgStyle}
/>
</Box>
);
});

View File

@@ -1,39 +0,0 @@
/*
* 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 Box from '@mui/material/Box';
import { IReaderSettings } from '@/modules/reader/Reader.types.ts';
interface IProps {
settings: IReaderSettings;
curPage: number;
pageCount: number;
}
export function PageNumber(props: IProps) {
const { settings, curPage, pageCount } = props;
return (
<Box
sx={{
display: settings.showPageNumber ? 'block' : 'none',
position: 'fixed',
bottom: '50px',
padding: '2px',
paddingLeft: '4px',
paddingRight: '4px',
textAlign: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: '10px',
}}
>
{`${curPage + 1} / ${pageCount}`}
</Box>
);
}

View File

@@ -1,219 +0,0 @@
/*
* 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 { MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import { Page } from '@/modules/reader/components/page/Page.tsx';
import { IReaderProps } from '@/modules/reader/Reader.types.ts';
import { Priority } from '@/lib/Queue.ts';
const isSpreadPage = (image: HTMLImageElement): boolean => {
const aspectRatio = image.height / image.width;
return aspectRatio < 1;
};
export function DoublePagedPager(props: IReaderProps) {
const { pages, settings, setCurPage, initialPage, curPage, nextChapter, prevChapter } = props;
const selfRef = useRef<HTMLDivElement>(null);
const pagesToDisplayStateRef = useRef<boolean[]>([]);
const [pagesToSpreadState, setPagesToSpreadState] = useState(Array(pages.length).fill(false));
const [pagesLoadState, setPagesLoadState] = useState<boolean[]>(Array(pages.length).fill(false));
// 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 = useMemo(
() => pagesToSpreadState.map((_, page) => page + pagesToSpreadState.slice(0, page).filter(Boolean).length),
[pagesToSpreadState],
);
function nextPage() {
const setNextPage = (page: number) => setCurPage(page === -1 ? pages.length - 1 : page);
const isLastPageDisplayed = pagesToDisplayStateRef.current[pages.length - 1];
if (isLastPageDisplayed && settings.loadNextOnEnding) {
// make sure to set last page as current page so that the chapter gets marked as read before opening the next chapter
const isLastPage = curPage === pages.length - 1;
if (!isLastPage) {
setNextPage(pages.length - 1);
}
nextChapter();
return;
}
const page = pagesToDisplayStateRef.current.findIndex((displayed, index) => !displayed && index > curPage);
setNextPage(page);
}
function prevPage() {
const setPrevPage = (page: number) => setCurPage(Math.max(page, 0));
const isFirstPageDisplayed = pagesToDisplayStateRef.current[0];
if (isFirstPageDisplayed) {
// not important, but for consistency make sure that first page gets set as current page in case it was displayed
const isFirstPage = !curPage;
if (!isFirstPage) {
setPrevPage(0);
}
prevChapter();
return;
}
const page = [...pagesToDisplayStateRef.current].slice(0, curPage).findLastIndex((displayed) => !displayed);
setPrevPage(page);
}
function goLeft() {
if (settings.readerType === 'DoubleLTR') {
prevPage();
} else {
nextPage();
}
}
function goRight() {
if (settings.readerType === 'DoubleLTR') {
nextPage();
} else {
prevPage();
}
}
function keyboardControl(e: KeyboardEvent) {
switch (e.key) {
case 'Space':
e.preventDefault();
nextPage();
break;
case 'ArrowRight':
goRight();
break;
case 'ArrowLeft':
goLeft();
break;
default:
break;
}
}
function clickControl(e: MouseEvent) {
if (e.clientX > window.innerWidth / 2) {
goRight();
} else {
goLeft();
}
}
useEffect(() => {
document.addEventListener('keydown', keyboardControl);
return () => {
document.removeEventListener('keydown', keyboardControl);
};
}, [selfRef, curPage, settings.readerType, prevChapter, nextChapter, pagesLoadState, pagesToSpreadState]);
useEffect(() => {
setCurPage(initialPage);
}, [initialPage]);
return (
<Box ref={selfRef} onClick={clickControl}>
<Box
id="display"
sx={{
display: 'flex',
flexDirection: settings.readerType === 'DoubleLTR' ? 'row' : 'row-reverse',
justifyContent: 'center',
width: 'auto',
height: 'auto',
}}
>
{pages.map(({ index, src }) => {
/*
| = 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 normalizedCurPage = pageToActualPageIndex[curPage];
const isCurPageEven = !(normalizedCurPage % 2);
const secondPageOffset = (() => {
const invert = settings.offsetFirstPage ? -1 : 1;
const offset = isCurPageEven ? 1 : -1;
return offset * invert;
})();
const secondPage = curPage + secondPageOffset;
const isFirstPage = curPage === 0;
const isCurPage = index === curPage;
const isSecondPage = index === secondPage;
const isCurrentPageSpreadPage = pagesToSpreadState[curPage];
const isSecondPageSpreadPage = pagesToSpreadState[secondPage];
const hasSpreadPage = isCurrentPageSpreadPage || isSecondPageSpreadPage;
const ignoreSecondPageDueToOffset = isFirstPage && settings.offsetFirstPage;
const displaySecondPage = isSecondPage && !hasSpreadPage && !ignoreSecondPageDueToOffset;
const displayPage = isCurPage || displaySecondPage;
pagesToDisplayStateRef.current[index] = displayPage;
return (
<Page
key={src}
index={index}
src={src}
onImageLoad={() => {
const img = new Image();
img.onload = () => {
setPagesLoadState((prevState) => prevState.toSpliced(index, 1, true));
setPagesToSpreadState((prevState) =>
prevState.toSpliced(index, 1, isSpreadPage(img)),
);
};
img.src = src;
}}
settings={settings}
display={displayPage}
priority={displayPage ? Priority.HIGH : undefined}
/>
);
})}
</Box>
</Box>
);
}

View File

@@ -1,208 +0,0 @@
/*
* 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 { MouseEvent as ReactMouseEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import { Page } from '@/modules/reader/components/page/Page.tsx';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
import { IReaderProps } from '@/modules/reader/Reader.types.ts';
import { Priority } from '@/lib/Queue.ts';
const findCurrentPageIndex = (wrapper: HTMLDivElement): number => {
for (let i = 0; i < wrapper.children.length; i++) {
const child = wrapper.children.item(i);
if (child) {
const { left, right } = child.getBoundingClientRect();
if (left <= window.innerWidth / 2 && right > window.innerWidth / 2) return i;
}
}
return -1;
};
const SCROLL_SAFE_ZONE = 5; // px
const isAtEnd = () => {
const visibleEnd = window.innerWidth + window.scrollX;
// SCROLL_SAFE_ZONE is here for special cases when window might be .5px shorter
// and math just dont add up correctly
return visibleEnd >= document.body.scrollWidth - SCROLL_SAFE_ZONE;
};
const isAtStart = () => window.scrollX <= 0;
export function HorizontalPager(props: IReaderProps) {
const { pages, curPage, initialPage, settings, setCurPage, prevChapter, nextChapter } = props;
const currentPageRef = useRef(initialPage);
const selfRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement[]>([]);
function nextPage() {
if (curPage < pages.length - 1) {
pagesRef.current[curPage + 1]?.scrollIntoView({ inline: 'center' });
setCurPage((page) => page + 1);
} else if (settings.loadNextOnEnding) {
nextChapter();
}
}
function prevPage() {
if (curPage > 0) {
pagesRef.current[curPage - 1]?.scrollIntoView({ inline: 'center' });
setCurPage(curPage - 1);
} else if (curPage === 0) {
prevChapter();
}
}
function goLeft() {
if (settings.readerType === 'ContinuesHorizontalLTR') {
prevPage();
} else {
nextPage();
}
}
function goRight() {
if (settings.readerType === 'ContinuesHorizontalLTR') {
nextPage();
} else {
prevPage();
}
}
const mouseXPos = useRef<number>(0);
function dragScreen(e: MouseEvent) {
window.scrollBy(mouseXPos.current - e.pageX, 0);
}
function dragControl(e: MouseEvent) {
mouseXPos.current = e.pageX;
selfRef.current?.addEventListener('mousemove', dragScreen);
}
function removeDragControl() {
selfRef.current?.removeEventListener('mousemove', dragScreen);
}
function clickControl(e: ReactMouseEvent) {
if (e.clientX >= window.innerWidth * 0.85) {
goRight();
} else if (e.clientX <= window.innerWidth * 0.15) {
goLeft();
}
}
function horizontalScroll(e: ReactWheelEvent) {
window.scrollBy({
left: settings.readerType === 'ContinuesHorizontalLTR' ? e.deltaY : e.deltaY * -1,
});
}
const handleLoadNextonEnding = () => {
if (settings.readerType === 'ContinuesHorizontalLTR') {
if (window.scrollX + window.innerWidth >= document.body.scrollWidth) {
nextChapter();
}
} else if (settings.readerType === 'ContinuesHorizontalRTL') {
if (window.scrollX <= window.innerWidth) {
nextChapter();
}
}
};
useResizeObserver(
pagesRef.current[initialPage],
useCallback(
(_, resizeObserver) => {
const initialPageElement = pagesRef.current[initialPage];
if (!initialPageElement?.offsetHeight) {
return;
}
initialPageElement.scrollIntoView({ inline: 'center' });
resizeObserver.disconnect();
},
[pagesRef.current[initialPage], initialPage],
),
);
useEffect(() => {
selfRef.current?.addEventListener('mousedown', dragControl);
selfRef.current?.addEventListener('mouseup', removeDragControl);
return () => {
selfRef.current?.removeEventListener('mousedown', dragControl);
selfRef.current?.removeEventListener('mouseup', removeDragControl);
};
}, [selfRef]);
useEffect(() => {
if (settings.loadNextOnEnding) {
document.addEventListener('scroll', handleLoadNextonEnding);
}
return () => {
document.removeEventListener('scroll', handleLoadNextonEnding);
};
}, [selfRef, curPage, prevChapter, nextChapter]);
useEffect(() => {
const handleScroll = () => {
if (!selfRef.current) return;
// Update current page in parent
const currentPage = findCurrentPageIndex(selfRef.current);
if (currentPage !== currentPageRef.current) {
currentPageRef.current = currentPage;
setCurPage(currentPage);
}
// Special case if scroll is moved all the way to the edge
// This handles cases when last page is show, but is smaller then
// window, in which case it would never get marked as read.
// See https://github.com/Suwayomi/Suwayomi-WebUI/issues/14 for more info
if (settings.readerType === 'ContinuesHorizontalLTR' ? isAtEnd() : isAtStart()) {
currentPageRef.current = pages.length - 1;
setCurPage(currentPageRef.current);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [settings.readerType]);
return (
<Box
ref={selfRef}
sx={{
display: 'flex',
flexDirection: settings.readerType === 'ContinuesHorizontalLTR' ? 'row' : 'row-reverse',
justifyContent: settings.readerType === 'ContinuesHorizontalLTR' ? 'flex-start' : 'flex-end',
width: 'auto',
height: 'auto',
overflowX: 'visible',
userSelect: 'none',
}}
onClick={clickControl}
onWheel={horizontalScroll}
>
{pages.map((page) => (
<Page
key={page.index}
index={page.index}
src={page.src}
onImageLoad={() => {}}
settings={settings}
ref={(e: HTMLDivElement) => {
pagesRef.current[page.index] = e;
}}
priority={page.index === curPage ? Priority.HIGH : undefined}
/>
))}
</Box>
);
}

View File

@@ -1,123 +0,0 @@
/*
* 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 { MouseEvent, useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import { Page } from '@/modules/reader/components/page/Page.tsx';
import { IReaderProps } from '@/modules/reader/Reader.types.ts';
import { Priority } from '@/lib/Queue.ts';
export function PagedPager(props: IReaderProps) {
const { pages, settings, setCurPage, initialPage, curPage, nextChapter, prevChapter } = props;
const selfRef = useRef<HTMLDivElement>(null);
const changePage = (newPage: number) => {
setCurPage(newPage);
window.scroll({ top: 0 });
};
function nextPage() {
if (curPage < pages.length - 1) {
changePage(curPage + 1);
} else if (settings.loadNextOnEnding) {
nextChapter();
}
}
function prevPage() {
if (curPage > 0) {
changePage(curPage - 1);
} else {
prevChapter();
}
}
function goLeft() {
if (settings.readerType === 'SingleLTR') {
prevPage();
} else if (settings.readerType === 'SingleRTL') {
nextPage();
}
}
function goRight() {
if (settings.readerType === 'SingleLTR') {
nextPage();
} else if (settings.readerType === 'SingleRTL') {
prevPage();
}
}
function keyboardControl(e: KeyboardEvent) {
switch (e.key) {
case 'Space':
e.preventDefault();
nextPage();
break;
case 'ArrowRight':
goRight();
break;
case 'ArrowLeft':
goLeft();
break;
default:
break;
}
}
function clickControl(e: MouseEvent) {
if (e.clientX > window.innerWidth / 2) {
goRight();
} else {
goLeft();
}
}
useEffect(() => {
document.addEventListener('keydown', keyboardControl);
return () => {
document.removeEventListener('keydown', keyboardControl);
};
}, [selfRef, curPage, settings.readerType, prevChapter, nextChapter]);
useEffect(() => {
// Delay scrolling to next cycle
setTimeout(() => {
// scroll last read page into view when initialPage changes
changePage(initialPage);
}, 0);
}, [initialPage]);
return (
<Box
ref={selfRef}
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: 'auto',
height: 'auto',
}}
onClick={clickControl}
>
{pages.map(({ index, src }) => (
<Page
key={src}
index={index}
onImageLoad={() => {}}
src={src}
settings={settings}
display={index === curPage}
priority={index === curPage ? Priority.HIGH : undefined}
/>
))}
</Box>
);
}

View File

@@ -1,221 +0,0 @@
/*
* 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 { useCallback, useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import { Page } from '@/modules/reader/components/page/Page.tsx';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
import { IReaderProps } from '@/modules/reader/Reader.types.ts';
import { Priority } from '@/lib/Queue.ts';
const findCurrentPageIndex = (wrapper: HTMLDivElement): number => {
for (let i = 0; i < wrapper.children.length; i++) {
const child = wrapper.children.item(i);
if (child) {
const { top, bottom } = child.getBoundingClientRect();
if (top <= window.innerHeight && bottom > 1) return i;
}
}
return -1;
};
// TODO: make configurable?
const SCROLL_SAFE_ZONE = 5; // px
const SCROLL_OFFSET = 0.95;
const SCROLL_OFFSET_SLIGHT = 0.25;
const SCROLL_BEHAVIOR: ScrollBehavior = 'smooth';
const isAtBottom = () => {
const visibleBottom = window.innerHeight + window.scrollY;
// SCROLL_SAFE_ZONE is here for special cases when window might be .5px shorter
// and math just dont add up correctly
return visibleBottom >= document.body.offsetHeight - SCROLL_SAFE_ZONE;
};
const isAtTop = () => window.scrollY <= 0;
export function VerticalPager(props: IReaderProps) {
const { curPage, pages, settings, setCurPage, initialPage, nextChapter, prevChapter } = props;
const currentPageRef = useRef(initialPage);
const selfRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement[]>([]);
useEffect(() => {
let handlingEndOfPage = false;
const handleScroll = () => {
if (!selfRef.current) return;
if (isAtBottom()) {
if (handlingEndOfPage) {
return;
}
handlingEndOfPage = true;
// If scroll is moved all the way to the bottom
// This handles cases when last page is show, but is smaller then
// window, in which case it would never get marked as read.
// See https://github.com/Suwayomi/Suwayomi-WebUI/issues/14 for more info
currentPageRef.current = pages.length - 1;
setCurPage(currentPageRef.current);
// Go to next chapter if configured to and at bottom
if (settings.loadNextOnEnding) {
nextChapter();
}
} else {
handlingEndOfPage = false;
// Update current page in parent
const currentPage = findCurrentPageIndex(selfRef.current);
if (currentPage !== currentPageRef.current) {
currentPageRef.current = currentPage;
setCurPage(currentPage);
}
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [settings.loadNextOnEnding, nextChapter]);
const mouseYPos = useRef<number>(0);
const didMouseMove = useRef(false);
function dragScreen(e: MouseEvent) {
didMouseMove.current = true;
window.scrollBy(0, mouseYPos.current - e.pageY);
}
function dragControl(e: MouseEvent) {
mouseYPos.current = e.pageY;
selfRef.current?.addEventListener('mousemove', dragScreen);
}
function removeDragControl() {
selfRef.current?.removeEventListener('mousemove', dragScreen);
}
useEffect(() => {
selfRef.current?.addEventListener('mousedown', dragControl);
selfRef.current?.addEventListener('mouseup', removeDragControl);
return () => {
selfRef.current?.removeEventListener('mousedown', dragControl);
selfRef.current?.removeEventListener('mouseup', removeDragControl);
};
}, [selfRef]);
const go = useCallback(
(direction: 'up' | 'down', offset: number = SCROLL_OFFSET) => {
if (direction === 'down' && isAtBottom()) {
nextChapter();
return;
}
if (direction === 'up' && isAtTop()) {
prevChapter();
return;
}
window.scroll({
top: window.scrollY + window.innerHeight * offset * (direction === 'up' ? -1 : 1),
behavior: SCROLL_BEHAVIOR,
});
},
[nextChapter, prevChapter],
);
useEffect(() => {
const handleKeyboard = (e: KeyboardEvent) => {
switch (e.key) {
case 'Space':
e.preventDefault();
go(e.shiftKey ? 'up' : 'down');
break;
case 'ArrowDown':
e.preventDefault();
go(e.shiftKey ? 'up' : 'down', SCROLL_OFFSET_SLIGHT);
break;
case 'ArrowRight':
e.preventDefault();
go(e.shiftKey ? 'up' : 'down');
break;
case 'ArrowUp':
e.preventDefault();
go(e.shiftKey ? 'down' : 'up', SCROLL_OFFSET_SLIGHT);
break;
case 'ArrowLeft':
e.preventDefault();
go(e.shiftKey ? 'down' : 'up');
break;
default:
break;
}
};
document.addEventListener('keydown', handleKeyboard, false);
return () => {
document.removeEventListener('keydown', handleKeyboard);
};
}, [go]);
useResizeObserver(
pagesRef.current[initialPage],
useCallback(
(_, resizeObserver) => {
const initialPageElement = pagesRef.current[initialPage];
if (!initialPageElement?.offsetHeight) {
return;
}
initialPageElement.scrollIntoView();
resizeObserver.disconnect();
},
[pagesRef.current[initialPage], initialPage],
),
);
return (
<Box
ref={selfRef}
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: 'auto',
height: 'auto',
userSelect: 'none',
}}
onClick={(e) => {
if (didMouseMove.current) {
didMouseMove.current = false;
return;
}
go(e.clientX > window.innerWidth / 2 ? 'down' : 'up');
}}
>
{pages.map((page) => (
<Page
key={page.index}
index={page.index}
src={page.src}
onImageLoad={() => {}}
settings={settings}
ref={(e: HTMLDivElement) => {
pagesRef.current[page.index] = e;
}}
priority={page.index === curPage ? Priority.HIGH : undefined}
/>
))}
</Box>
);
}

View File

@@ -1,77 +0,0 @@
/*
* 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 { useContext, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { requestUpdateServerMetadata } from '@/modules/metadata/services/MetadataUpdater.ts';
import { useDefaultReaderSettings } from '@/modules/reader/services/ReaderSettingsMetadata.ts';
import { ReaderSettingsOptions } from '@/modules/reader/components/ReaderSettingsOptions.tsx';
import { makeToast } from '@/modules/core/utils/Toast.ts';
import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { IReaderSettings } from '@/modules/reader/Reader.types.ts';
import { AllowedMetadataValueTypes } from '@/modules/metadata/Metadata.types.ts';
export function DefaultReaderSettings() {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavBarContext);
useLayoutEffect(() => {
setTitle(t('reader.settings.title.default_reader_settings'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]);
const {
settings,
loading,
request: { error, refetch },
} = useDefaultReaderSettings();
const setSettingValue = (key: keyof IReaderSettings, value: AllowedMetadataValueTypes, persist: boolean = true) => {
if (persist) {
requestUpdateServerMetadata([[key, value]]).catch(() =>
makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'),
);
}
};
if (loading) {
return <LoadingPlaceholder />;
}
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={error.message}
retry={() => refetch().catch(defaultPromiseErrorHandler('DefaultReaderSettings::refetch'))}
/>
);
}
return (
<ReaderSettingsOptions
setSettingValue={setSettingValue}
staticNav={settings.staticNav}
showPageNumber={settings.showPageNumber}
loadNextOnEnding={settings.loadNextOnEnding}
skipDupChapters={settings.skipDupChapters}
fitPageToWindow={settings.fitPageToWindow}
scalePage={settings.scalePage}
readerType={settings.readerType}
offsetFirstPage={settings.offsetFirstPage}
readerWidth={settings.readerWidth}
/>
);
}

View File

@@ -1,576 +0,0 @@
/*
* 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 CircularProgress from '@mui/material/CircularProgress';
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { getReaderSettingsFor, useDefaultReaderSettings } from '@/modules/reader/services/ReaderSettingsMetadata.ts';
import { requestUpdateMangaMetadata } from '@/modules/metadata/services/MetadataUpdater.ts';
import { HorizontalPager } from '@/modules/reader/components/pager/HorizontalPager.tsx';
import { PageNumber } from '@/modules/reader/components/page/PageNumber.tsx';
import { PagedPager } from '@/modules/reader/components/pager/PagedPager.tsx';
import { DoublePagedPager } from '@/modules/reader/components/pager/DoublePagedPager.tsx';
import { VerticalPager } from '@/modules/reader/components/pager/VerticalPager.tsx';
import { ReaderNavBar } from '@/modules/reader/components/ReaderNavBar.tsx';
import { makeToast } from '@/modules/core/utils/Toast.ts';
import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { useDebounce } from '@/modules/core/hooks/useDebounce.ts';
import {
GetChaptersReaderQuery,
GetChaptersReaderQueryVariables,
GetMangaReaderQuery,
UpdateChapterPatchInput,
} from '@/lib/graphql/generated/graphql.ts';
import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { ChapterIdInfo, Chapters } from '@/modules/chapter/services/Chapters.ts';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { GET_CHAPTERS_READER } from '@/lib/graphql/queries/ChapterQuery.ts';
import { GET_MANGA_READER } from '@/lib/graphql/queries/MangaQuery.ts';
import { CHAPTER_READER_FIELDS } from '@/lib/graphql/fragments/ChapterFragments.ts';
import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx';
import { IReaderSettings, ReaderType } from '@/modules/reader/Reader.types.ts';
import { DirectionOffset } from '@/Base.types.ts';
import { AllowedMetadataValueTypes } from '@/modules/metadata/Metadata.types.ts';
import { TMangaReader } from '@/modules/manga/Manga.types.ts';
type TChapter = GetChaptersReaderQuery['chapters']['nodes'][number];
const getChapterFromCache = (id: ChapterIdInfo['id']): TChapter | null =>
Chapters.getFromCache<TChapter>(id, CHAPTER_READER_FIELDS, 'CHAPTER_READER_FIELDS')!;
const getReaderComponent = (readerType: ReaderType) => {
switch (readerType) {
case 'ContinuesVertical':
case 'Webtoon':
return VerticalPager;
break;
case 'SingleVertical':
case 'SingleRTL':
case 'SingleLTR':
return PagedPager;
break;
case 'DoubleVertical':
case 'DoubleRTL':
case 'DoubleLTR':
return DoublePagedPager;
break;
case 'ContinuesHorizontalLTR':
case 'ContinuesHorizontalRTL':
return HorizontalPager;
default:
return VerticalPager;
break;
}
};
const range = (n: number) => Array.from({ length: n }, (value, key) => key);
const fallbackChapter = {
pageCount: -1,
sourceOrder: -1,
chapterCount: 0,
lastPageRead: 0,
name: 'Loading...',
manga: { id: -1 },
} as unknown as TChapter;
export function Reader() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { chapterIndex, mangaId } = useParams<{ chapterIndex: string; mangaId: string }>();
const fallbackManga = useMemo(
() =>
({
id: +mangaId,
title: '',
thumbnailUrl: '',
genre: [],
inLibraryAt: 0,
lastReadAt: 0,
chapters: { totalCount: 0 },
trackRecords: { totalCount: 0 },
}) as unknown as TMangaReader,
[mangaId],
);
const {
data,
loading: isMangaLoading,
error: mangaError,
refetch: refetchManga,
} = requestManager.useGetManga<GetMangaReaderQuery>(GET_MANGA_READER, mangaId);
const loadedChapter = useRef<TChapter | null>(null);
const isChapterLoaded =
Number(mangaId) === loadedChapter.current?.mangaId &&
Number(chapterIndex) === loadedChapter.current?.sourceOrder &&
loadedChapter.current?.pageCount !== -1;
const manga = data?.manga ?? fallbackManga;
const {
data: chaptersData,
loading: isChapterLoading,
error: chapterError,
refetch: fetchChapter,
} = requestManager.useGetChapters<GetChaptersReaderQuery, GetChaptersReaderQueryVariables>(
GET_CHAPTERS_READER,
{
condition: { mangaId: Number(mangaId), sourceOrder: Number(chapterIndex) },
},
{
notifyOnNetworkStatusChange: true,
},
);
const chapterData = chaptersData?.chapters.nodes[0];
const arePagesUpdatedRef = useRef(false);
const {
settings: { downloadAheadLimit },
} = useMetadataServerSettings();
const isDownloadAheadEnabled = !!downloadAheadLimit;
const getLoadedChapter = () => {
const isAChapterLoaded = loadedChapter.current;
const isSameAsLoadedChapter = isAChapterLoaded && isChapterLoaded;
if (isSameAsLoadedChapter) {
const didPageCountChange = chapterData && loadedChapter.current?.pageCount !== chapterData.pageCount;
return didPageCountChange ? chapterData! : loadedChapter.current;
}
arePagesUpdatedRef.current = false;
if (chapterData) {
return chapterData;
}
return null;
};
loadedChapter.current = getLoadedChapter();
const chapter = loadedChapter.current ?? fallbackChapter;
const initialChapterRef = useRef<TChapter>(fallbackChapter);
if (initialChapterRef.current === fallbackChapter) {
initialChapterRef.current = chapter;
}
if (chapter.mangaId !== initialChapterRef.current?.mangaId) {
initialChapterRef.current = fallbackChapter;
}
const [fetchPages, { loading: arePagesLoading, error: pagesError }] = requestManager.useGetChapterPagesFetch(
chapter.id,
);
const doFetchPages = () => {
if (isChapterLoading) {
return;
}
fetchPages()
.then(() => {
arePagesUpdatedRef.current = true;
})
.catch(defaultPromiseErrorHandler('Reader::fetchPages'));
};
useEffect(() => {
doFetchPages();
}, [chapter.id]);
const [wasLastPageReadSet, setWasLastPageReadSet] = useState(false);
const [curPage, setCurPage] = useState<number>(0);
const isLastPage = curPage === chapter.pageCount - 1;
const curPageDebounced = useDebounce(curPage, isLastPage ? 0 : 1000);
const [pageToScrollTo, setPageToScrollTo] = useState<number | undefined>(undefined);
const { setOverride, setTitle, readerNavBarWidth: currentNavBarWidth } = useContext(NavBarContext);
const [retrievingNextChapter, setRetrievingNextChapter] = useState(false);
const {
data: mangaChaptersData,
loading: areChaptersLoading,
error: chaptersError,
refetch: refetchChapters,
} = requestManager.useGetMangaChapters<GetChaptersReaderQuery, GetChaptersReaderQueryVariables>(
GET_CHAPTERS_READER,
mangaId,
{ nextFetchPolicy: 'standby' },
);
const mangaChapters = mangaChaptersData?.chapters.nodes;
const isLoading =
isMangaLoading ||
isChapterLoading ||
areChaptersLoading ||
arePagesLoading ||
(!arePagesUpdatedRef.current && !chapterError && !pagesError);
const error = mangaError ?? chapterError ?? chaptersError ?? pagesError;
const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings();
const [settings, setSettings] = useState(defaultSettings);
const { settings: metadataSettings } = useMetadataServerSettings();
const uniqueChapters = useMemo(
() =>
settings.skipDupChapters
? Chapters.removeDuplicates(initialChapterRef.current, mangaChapters ?? [])
: (mangaChapters ?? []),
[initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const prevChapters = useMemo(
() =>
Chapters.getNextChapters(chapter, mangaChapters ?? [], {
offset: DirectionOffset.PREVIOUS,
skipDupe: settings.skipDupChapters,
skipDupeChapter: initialChapterRef.current,
}),
[chapter, initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const nextChapters = useMemo(
() =>
Chapters.getNextChapters(chapter, mangaChapters ?? [], {
skipDupe: settings.skipDupChapters,
skipDupeChapter: initialChapterRef.current,
}),
[chapter, initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const prevChapter = useMemo(
() =>
Chapters.getNextChapter(chapter, mangaChapters ?? [], {
offset: DirectionOffset.PREVIOUS,
skipDupe: settings.skipDupChapters,
skipDupeChapter: initialChapterRef.current,
}),
[chapter, initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const nextChapter = useMemo(
() =>
Chapters.getNextChapter(chapter, mangaChapters ?? [], {
skipDupe: settings.skipDupChapters,
skipDupeChapter: initialChapterRef.current,
}),
[chapter, initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const updateChapter = (patch: UpdateChapterPatchInput) => {
if (chapter === fallbackChapter) {
return;
}
const getChapterIdsToDelete = () => {
const isAutoDeletionEnabled = !!patch.isRead && !!metadataSettings.deleteChaptersWhileReading;
if (!isAutoDeletionEnabled || !mangaChapters) {
return [];
}
const chapterToDelete = [chapter, ...prevChapters][metadataSettings.deleteChaptersWhileReading - 1];
if (!chapterToDelete) {
return [];
}
// chapter has to exist in the cache since the reader fetches all chapters of the manga
const chapterToDeleteUpToDateData = getChapterFromCache(chapterToDelete.id)!;
const shouldDeleteChapter =
chapterToDeleteUpToDateData.isRead &&
Chapters.isDeletable(chapterToDeleteUpToDateData, metadataSettings.deleteChaptersWithBookmark);
if (!shouldDeleteChapter) {
return [];
}
if (!settings.skipDupChapters) {
return Chapters.getIds([chapterToDelete]);
}
return Chapters.getIds(Chapters.addDuplicates([chapterToDelete], mangaChapters));
};
const downloadAhead = () => {
const currentChapter = getChapterFromCache(chapter.id);
const inDownloadRange = (patch.lastPageRead ?? 0) / chapter.pageCount > 0.25;
const shouldCheckDownloadAhead =
isDownloadAheadEnabled && manga.inLibrary && !!currentChapter?.isDownloaded && inDownloadRange;
if (shouldCheckDownloadAhead) {
const nextChapterUpToDate = nextChapter ? getChapterFromCache(nextChapter.id) : null;
if (!nextChapterUpToDate?.isDownloaded) {
return;
}
const nextChaptersUpToDate = Chapters.getNonRead(nextChapters).map(
// the chapters have to be in the cache since the reader fetches the whole chapter list of the manga
(mangaChapter) => getChapterFromCache(mangaChapter.id)!,
);
const chapterIdsToDownload = nextChaptersUpToDate
// "settingsData" can't be undefined since this would not get executed otherwise
.slice(-downloadAheadLimit)
.filter((mangaChapter) => !mangaChapter.isDownloaded)
.map((mangaChapter) => mangaChapter.id)
.filter((id) => !Chapters.isDownloading(id));
if (!chapterIdsToDownload.length) {
return;
}
Chapters.download(chapterIdsToDownload).catch(
defaultPromiseErrorHandler('Reader::updateChapter: shouldDownloadAhead'),
);
}
};
downloadAhead();
const chapterIds = settings.skipDupChapters
? Chapters.getIds(Chapters.addDuplicates([chapter], mangaChapters ?? [chapter]))
: [chapter.id];
requestManager
.updateChapters(
chapterIds,
{
...patch,
chapterIdsToDelete: getChapterIdsToDelete(),
trackProgressMangaId:
metadataSettings.updateProgressAfterReading && patch.isRead && manga.trackRecords.totalCount
? manga.id
: undefined,
},
{ errorPolicy: 'all' },
)
.response.catch(defaultPromiseErrorHandler('Reader::updateChapter'));
};
const setSettingValue = (key: keyof IReaderSettings, value: AllowedMetadataValueTypes, persist: boolean = true) => {
setSettings({ ...settings, [key]: value });
if (persist) {
requestUpdateMangaMetadata(manga, [[key, value]]).catch(() =>
makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'),
);
}
};
const openNextChapter = useCallback(
(offset: DirectionOffset) => {
const isOpenNextChapter = offset === DirectionOffset.NEXT;
const chapterToOpen = isOpenNextChapter ? nextChapter : prevChapter;
if (!chapterToOpen) {
makeToast(
t(
isOpenNextChapter
? 'reader.error.label.next_chapter_does_not_exist'
: 'reader.error.label.prev_chapter_does_not_exist',
),
'error',
);
return;
}
setRetrievingNextChapter(true);
setCurPage(0);
navigate(`/manga/${manga.id}/chapter/${chapterToOpen.sourceOrder}`, {
replace: true,
state: location.state,
});
setRetrievingNextChapter(false);
},
[manga.id, prevChapter?.id, nextChapter?.id],
);
useEffect(() => {
if (isLoading || !chapter) {
setCurPage(0);
return;
}
setWasLastPageReadSet(true);
if (chapter.lastPageRead === chapter.pageCount - 1) {
// last page, also probably read = true, we will load the first page.
setCurPage(0);
} else setCurPage(chapter.lastPageRead);
}, [chapter, isLoading]);
useLayoutEffect(() => {
if (!manga?.title || chapter.name === t('global.label.loading')) {
setTitle(t('reader.title'));
} else {
setTitle(`${manga.title}: ${chapter.name}`);
}
}, [t, manga, chapter]);
useEffect(() => {
if (!areDefaultSettingsLoading && !isMangaLoading) {
setSettings(getReaderSettingsFor(manga, defaultSettings));
}
}, [areDefaultSettingsLoading, isMangaLoading]);
useLayoutEffect(() => {
// set the custom navbar
setOverride({
status: true,
value: (
<ReaderNavBar
settings={settings}
setSettingValue={setSettingValue}
manga={manga}
chapter={chapter}
chapters={uniqueChapters}
curPage={curPage}
scrollToPage={setPageToScrollTo}
openNextChapter={openNextChapter}
retrievingNextChapter={retrievingNextChapter}
/>
),
});
// clean up for when we leave the reader
return () => setOverride({ status: false, value: <div /> });
}, [manga, chapter, settings, curPage, chapterIndex, retrievingNextChapter, openNextChapter, uniqueChapters]);
useEffect(() => {
if (!wasLastPageReadSet) {
return;
}
if (chapter === fallbackChapter) {
return;
}
const chapterUpToDate = getChapterFromCache(chapter.id);
if (curPageDebounced === chapterUpToDate?.lastPageRead) {
return;
}
// do not mutate the chapter, this will cause the page to jump around due to always scrolling to the last read page
const updateLastPageRead = curPageDebounced !== -1;
const updateIsRead = curPageDebounced === chapter.pageCount - 1;
const shouldUpdateChapter = updateLastPageRead || updateIsRead;
if (!shouldUpdateChapter) {
return;
}
updateChapter({
lastPageRead: updateLastPageRead ? curPageDebounced : undefined,
isRead: updateIsRead ? true : undefined,
});
}, [curPageDebounced, isDownloadAheadEnabled]);
const loadNextChapter = useCallback(() => {
updateChapter({
lastPageRead: chapter.pageCount - 1,
isRead: true,
});
openNextChapter(DirectionOffset.NEXT);
}, [chapter.pageCount, openNextChapter, chapter, manga]);
const loadPrevChapter = useCallback(() => {
openNextChapter(DirectionOffset.PREVIOUS);
}, [openNextChapter]);
const scrollbarHeight = MediaQuery.useGetScrollbarSize('height');
if (isLoading) {
return (
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'grid',
placeItems: 'center',
}}
>
<CircularProgress thickness={5} />
</Box>
);
}
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={error.message}
retry={() => {
if (mangaError) {
refetchManga().catch(defaultPromiseErrorHandler('Reader::refetchManga'));
}
if (chapterError) {
fetchChapter().catch(defaultPromiseErrorHandler('Reader::refetchChapter'));
}
if (chaptersError) {
refetchChapters().catch(defaultPromiseErrorHandler('Reader::refetchChapters'));
}
if (pagesError) {
doFetchPages();
}
}}
/>
);
}
if (!chapter.pageCount) {
return <EmptyViewAbsoluteCentered message={t('reader.error.label.no_pages_found')} retry={doFetchPages} />;
}
const pages = range(chapter.pageCount).map((index) => ({
index,
src: requestManager.getChapterPageUrl(mangaId, chapterIndex, index),
}));
const ReaderComponent = getReaderComponent(settings.readerType);
// last page, also probably read = true, we will load the first page.
const initialPage = pageToScrollTo ?? (chapter.lastPageRead === chapter.pageCount - 1 ? 0 : chapter.lastPageRead);
const navBarWidth = settings.staticNav ? currentNavBarWidth : 0;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignContent: 'center',
alignItems: 'center',
justifyContent: 'center',
minWidth: `calc((100vw - (100vw - 100%)) - ${navBarWidth}px)`, // 100vw = width excluding scrollbar; 100% = width including scrollbar
minHeight: `calc(100vh - ${scrollbarHeight}px)`,
marginLeft: `${navBarWidth}px`,
}}
>
<PageNumber settings={settings} curPage={curPage} pageCount={chapter.pageCount} />
<Box sx={{ alignSelf: 'stretch' }}>
<ReaderComponent
key={chapter.id}
pages={pages}
pageCount={chapter.pageCount}
setCurPage={setCurPage}
initialPage={initialPage}
curPage={curPage}
settings={settings}
manga={manga}
chapter={chapter}
nextChapter={loadNextChapter}
prevChapter={loadPrevChapter}
/>
</Box>
</Box>
);
}