Rename folder "modules" to "features"

This commit is contained in:
schroda
2025-08-15 22:02:58 +02:00
parent 7e6ced1d09
commit 1b4bf22542
415 changed files with 1859 additions and 1852 deletions

View File

@@ -0,0 +1,261 @@
/*
* 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 { useHotkeys as useHotKeysHook, useHotkeysContext } from 'react-hotkeys-hook';
import { useEffect } from 'react';
import { useTheme } from '@mui/material/styles';
import { HOTKEY_SCOPES } from '@/features/hotkeys/Hotkeys.constants.ts';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { IReaderSettings, ReaderHotkey } from '@/features/reader/types/Reader.types.ts';
import { useReaderOverlayContext } from '@/features/reader/contexts/ReaderOverlayContext.tsx';
import { getNextRotationValue } from '@/features/core/utils/ValueRotationButton.utils.ts';
import {
AUTO_SCROLL_SPEED,
CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION,
READER_PAGE_SCALE_MODE_VALUES,
READING_DIRECTION_VALUES,
READING_MODE_VALUES,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
import { HotkeyScope } from '@/features/hotkeys/Hotkeys.types.ts';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
import { ScrollOffset } from '@/features/core/Core.types.ts';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { FALLBACK_MANGA } from '@/features/manga/Manga.constants.ts';
import { useReaderAutoScrollContext } from '@/features/reader/contexts/ReaderAutoScrollContext.tsx';
import { useReaderTapZoneContext } from '@/features/reader/contexts/ReaderTapZoneContext.tsx';
const useHotkeys = (...args: Parameters<typeof useHotKeysHook>): ReturnType<typeof useHotKeysHook> => {
const [keys, callback, options, dependencies] = args;
return useHotKeysHook(keys, callback, { ...options, ...HOTKEY_SCOPES.reader }, dependencies);
};
const updateSettingCycleThrough = <Setting extends keyof IReaderSettings>(
updateSetting: ReturnType<typeof ReaderService.useCreateUpdateSetting>,
deleteSetting: ReturnType<typeof ReaderService.useCreateDeleteSetting>,
setting: Setting,
value: IReaderSettings[Setting],
values: IReaderSettings[Setting][],
isDefault: boolean,
isDefaultable: boolean,
) => {
if (isDefault) {
updateSetting(setting, values[0]);
return;
}
const nextValue = getNextRotationValue(values.indexOf(value), values, isDefaultable);
const isDefaultNextValue = nextValue === undefined;
if (isDefaultNextValue) {
deleteSetting(setting);
return;
}
updateSetting(setting, nextValue);
};
export const ReaderHotkeys = ({
scrollElementRef,
}: {
scrollElementRef: React.MutableRefObject<HTMLElement | null>;
}) => {
const { direction: themeDirection } = useTheme();
const readerThemeDirection = ReaderService.useGetThemeDirection();
const { enableScope, disableScope } = useHotkeysContext();
const { manga } = useReaderStateMangaContext();
const { isVisible, setIsVisible: setIsOverlayVisible } = useReaderOverlayContext();
const {
hotkeys,
pageScaleMode,
shouldStretchPage,
shouldOffsetDoubleSpreads,
readingMode,
readingDirection,
autoScroll,
scrollAmount,
} = ReaderService.useSettings();
const automaticScrolling = useReaderAutoScrollContext();
const { setShowPreview } = useReaderTapZoneContext();
const exitReader = ReaderService.useExit();
const openChapter = ReaderControls.useOpenChapter();
const openPage = ReaderControls.useOpenPage();
const updateSetting = ReaderService.useCreateUpdateSetting(manga ?? FALLBACK_MANGA);
const deleteSetting = ReaderService.useCreateDeleteSetting(manga ?? FALLBACK_MANGA);
useHotkeys(hotkeys[ReaderHotkey.PREVIOUS_PAGE], () => openPage('previous'), [openPage]);
useHotkeys(hotkeys[ReaderHotkey.NEXT_PAGE], () => openPage('next'), [openPage]);
useHotkeys(
hotkeys[ReaderHotkey.SCROLL_BACKWARD],
() => {
if (automaticScrolling.isActive) {
automaticScrolling.setDirection(ScrollOffset.BACKWARD);
return;
}
if (!scrollElementRef.current) {
return;
}
ReaderControls.scroll(
ScrollOffset.BACKWARD,
CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION[readingMode.value],
readingMode.value,
readingDirection.value,
themeDirection,
scrollElementRef.current,
openChapter,
setIsOverlayVisible,
setShowPreview,
scrollAmount,
);
},
{ preventDefault: true },
[
readingMode.value,
readingDirection.value,
themeDirection,
openChapter,
scrollAmount,
automaticScrolling.isActive,
],
);
useHotkeys(
hotkeys[ReaderHotkey.SCROLL_FORWARD],
() => {
if (automaticScrolling.isActive) {
automaticScrolling.setDirection(ScrollOffset.FORWARD);
return;
}
if (!scrollElementRef.current) {
return;
}
ReaderControls.scroll(
ScrollOffset.FORWARD,
CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION[readingMode.value],
readingMode.value,
readingDirection.value,
themeDirection,
scrollElementRef.current,
openChapter,
setIsOverlayVisible,
setShowPreview,
scrollAmount,
);
},
{ preventDefault: true },
[
readingMode.value,
readingDirection.value,
themeDirection,
openChapter,
scrollAmount,
automaticScrolling.isActive,
],
);
useHotkeys(
hotkeys[ReaderHotkey.PREVIOUS_CHAPTER],
() => openChapter(getOptionForDirection('previous', 'next', readerThemeDirection)),
[openChapter, readerThemeDirection],
);
useHotkeys(
hotkeys[ReaderHotkey.NEXT_CHAPTER],
() => openChapter(getOptionForDirection('next', 'previous', readerThemeDirection)),
[openChapter, readerThemeDirection],
);
useHotkeys(hotkeys[ReaderHotkey.TOGGLE_MENU], () => setIsOverlayVisible(!isVisible), [isVisible]);
useHotkeys(
hotkeys[ReaderHotkey.CYCLE_SCALE_TYPE],
() => {
updateSettingCycleThrough(
updateSetting,
deleteSetting,
'pageScaleMode',
pageScaleMode.value,
READER_PAGE_SCALE_MODE_VALUES,
pageScaleMode.isDefault,
true,
);
},
[updateSetting, deleteSetting, pageScaleMode.value, pageScaleMode.isDefault],
);
useHotkeys(
hotkeys[ReaderHotkey.STRETCH_IMAGE],
() => updateSetting('shouldStretchPage', !shouldStretchPage.value),
[updateSetting, shouldStretchPage.value],
);
useHotkeys(
hotkeys[ReaderHotkey.OFFSET_SPREAD_PAGES],
() => updateSetting('shouldOffsetDoubleSpreads', !shouldOffsetDoubleSpreads.value),
[updateSetting, shouldOffsetDoubleSpreads.value],
);
useHotkeys(
hotkeys[ReaderHotkey.CYCLE_READING_MODE],
() => {
updateSettingCycleThrough(
updateSetting,
deleteSetting,
'readingMode',
readingMode.value,
READING_MODE_VALUES,
readingMode.isDefault,
true,
);
},
[updateSetting, deleteSetting, readingMode.value, readingMode.isDefault],
);
useHotkeys(
hotkeys[ReaderHotkey.CYCLE_READING_DIRECTION],
() => {
updateSettingCycleThrough(
updateSetting,
deleteSetting,
'readingDirection',
readingDirection.value,
READING_DIRECTION_VALUES,
readingDirection.isDefault,
true,
);
},
[updateSetting, deleteSetting, readingDirection.value, readingDirection.isDefault],
);
useHotkeys(hotkeys[ReaderHotkey.TOGGLE_AUTO_SCROLL], automaticScrolling.toggleActive, { preventDefault: true }, [
automaticScrolling.toggleActive,
]);
useHotkeys(
hotkeys[ReaderHotkey.AUTO_SCROLL_SPEED_DECREASE],
() =>
updateSetting('autoScroll', {
...autoScroll,
value: Math.min(AUTO_SCROLL_SPEED.max, autoScroll.value + AUTO_SCROLL_SPEED.step),
}),
[updateSetting, autoScroll.value],
);
useHotkeys(
hotkeys[ReaderHotkey.AUTO_SCROLL_SPEED_INCREASE],
() =>
updateSetting('autoScroll', {
...autoScroll,
value: Math.max(AUTO_SCROLL_SPEED.min, autoScroll.value - AUTO_SCROLL_SPEED.step),
}),
[updateSetting, autoScroll.value],
);
useHotkeys(hotkeys[ReaderHotkey.EXIT_READER], exitReader, [exitReader]);
useEffect(() => {
enableScope(HotkeyScope.READER);
return () => disableScope(HotkeyScope.READER);
}, []);
return null;
};

View File

@@ -0,0 +1,117 @@
/*
* 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 { useMemo } from 'react';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import {
IReaderSettings,
ProgressBarType,
ReadingDirection,
TReaderScrollbarContext,
} from '@/features/reader/types/Reader.types.ts';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { getPage } from '@/features/reader/utils/ReaderProgressBar.utils.tsx';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { useReaderProgressBarContext } from '@/features/reader/contexts/ReaderProgressBarContext.tsx';
import { useReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
import { reverseString } from '@/util/Strings.ts';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
import { ReaderStatePages, TReaderProgressBarContext } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
const BaseReaderPageNumber = ({
isDesktop,
scrollbarXSize,
readerNavBarWidth,
isMaximized,
currentPageIndex,
pages,
totalPages,
progressBarType,
shouldShowPageNumber,
readingDirection,
}: Pick<TReaderScrollbarContext, 'scrollbarXSize'> &
Pick<ReturnType<typeof ReaderService.useOverlayMode>, 'isDesktop'> &
Pick<NavbarContextType, 'readerNavBarWidth'> &
Pick<TReaderProgressBarContext, 'isMaximized'> &
Pick<ReaderStatePages, 'currentPageIndex' | 'pages' | 'totalPages'> &
Pick<IReaderSettings, 'progressBarType' | 'shouldShowPageNumber' | 'readingDirection'>) => {
const pageName = useMemo(() => {
const currentPageName = getPage(currentPageIndex, pages).name;
const SEPARATOR = '/';
const tmpPageName = `${currentPageName}${SEPARATOR}${totalPages}`;
return readingDirection === ReadingDirection.LTR ? tmpPageName : reverseString(tmpPageName, SEPARATOR);
}, [currentPageIndex, pages, totalPages, readingDirection]);
if (!shouldShowPageNumber) {
return null;
}
if (isMaximized) {
return null;
}
if (isDesktop && progressBarType === ProgressBarType.STANDARD) {
return null;
}
if (isMaximized && progressBarType === ProgressBarType.HIDDEN) {
return null;
}
return (
<Stack
sx={{
position: 'fixed',
left: readerNavBarWidth,
right: 0,
bottom: (theme) => `max(calc(${theme.spacing(1)} + ${scrollbarXSize}px), env(safe-area-inset-bottom))`,
alignItems: 'center',
transition: (theme) => `left 0.${theme.transitions.duration.shortest}s`,
}}
>
<Box
sx={{
p: 0.5,
borderRadius: 1,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
}}
>
<Typography sx={{ color: 'white' }}>{pageName}</Typography>
</Box>
</Stack>
);
};
export const ReaderPageNumber = withPropsFrom(
BaseReaderPageNumber,
[
ReaderService.useOverlayMode,
useReaderScrollbarContext,
useNavBarContext,
useReaderProgressBarContext,
userReaderStatePagesContext,
ReaderService.useSettingsWithoutDefaultFlag,
],
[
'isDesktop',
'scrollbarXSize',
'readerNavBarWidth',
'isMaximized',
'currentPageIndex',
'pages',
'totalPages',
'progressBarType',
'shouldShowPageNumber',
'readingDirection',
],
);

View File

@@ -0,0 +1,49 @@
/*
* 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 { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
const BaseReaderRGBAFilter = ({
readerNavBarWidth,
customFilter: {
rgba: {
value: { red, green, blue, alpha, blendMode },
enabled,
},
},
}: Pick<NavbarContextType, 'readerNavBarWidth'> & Pick<IReaderSettings, 'customFilter'>) => {
if (!enabled) {
return null;
}
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: readerNavBarWidth,
right: 0,
bottom: 0,
pointerEvents: 'none',
background: `rgba(${red} ${green} ${blue} / ${alpha}%)`,
mixBlendMode: `${blendMode}`,
}}
/>
);
};
export const ReaderRGBAFilter = withPropsFrom(
BaseReaderRGBAFilter,
[useNavBarContext, ReaderService.useSettingsWithoutDefaultFlag],
['readerNavBarWidth', 'customFilter'],
);

View File

@@ -0,0 +1,78 @@
/*
* 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 { useCallback, useLayoutEffect, useState } from 'react';
import { useTheme } from '@mui/material/styles';
import { useReaderTapZoneContext } from '@/features/reader/contexts/ReaderTapZoneContext.tsx';
import { ReaderTapZoneService } from '@/features/reader/services/ReaderTapZoneService.ts';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { IReaderSettings, ReadingDirection } from '@/features/reader/types/Reader.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
import { TReaderTapZoneContext } from '@/features/reader/types/TapZoneLayout.types.ts';
const CANVAS_ID = 'reader-tap-zone-layout-canvas';
const BaseTapZoneLayout = ({
readerNavBarWidth,
showPreview,
tapZoneLayout,
tapZoneInvertMode,
readingDirection,
}: Pick<NavbarContextType, 'readerNavBarWidth'> &
Pick<TReaderTapZoneContext, 'showPreview'> &
Pick<IReaderSettings, 'tapZoneLayout' | 'tapZoneInvertMode' | 'readingDirection'>) => {
const theme = useTheme();
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [tapZoneLayoutElement, setTapZoneLayoutElement] = useState<HTMLDivElement | null>(null);
useResizeObserver(
tapZoneLayoutElement,
useCallback(() => {
setWidth(tapZoneLayoutElement?.clientWidth ?? 0);
setHeight(tapZoneLayoutElement?.clientHeight ?? 0);
}, [tapZoneLayoutElement]),
);
const canvas = ReaderTapZoneService.getOrCreateCanvas(tapZoneLayout, width, height, theme.typography.h3, {
vertical: tapZoneInvertMode.vertical,
horizontal: tapZoneInvertMode.horizontal,
isRTL: readingDirection === ReadingDirection.RTL,
});
useLayoutEffect(() => {
const canvasContainerElement = document.getElementById(CANVAS_ID);
canvasContainerElement?.replaceChildren(canvas);
}, [canvas, showPreview]);
return (
<Box
ref={setTapZoneLayoutElement}
sx={{
position: 'fixed',
top: 0,
left: readerNavBarWidth,
right: 0,
bottom: 0,
pointerEvents: 'none',
}}
>
{showPreview && <div id={CANVAS_ID} />}
</Box>
);
};
export const TapZoneLayout = withPropsFrom(
BaseTapZoneLayout,
[useNavBarContext, useReaderTapZoneContext, ReaderService.useSettingsWithoutDefaultFlag],
['readerNavBarWidth', 'showPreview', 'tapZoneLayout', 'tapZoneInvertMode', 'readingDirection'],
);

View File

@@ -0,0 +1,69 @@
/*
* 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 { memo, useCallback, useRef, useState } from 'react';
import { BaseReaderOverlayProps, MobileHeaderProps } from '@/features/reader/types/ReaderOverlay.types.ts';
import { ReaderSettings } from '@/features/reader/components/settings/ReaderSettings.tsx';
import { ReaderPageNumber } from '@/features/reader/components/ReaderPageNumber.tsx';
import { StandardReaderProgressBar } from '@/features/reader/components/overlay/progress-bar/variants/StandardReaderProgressBar.tsx';
import { ReaderNavBarDesktop } from '@/features/reader/components/overlay/navigation/desktop/ReaderNavBarDesktop.tsx';
import { ReaderOverlayHeaderMobile } from '@/features/reader/components/overlay/ReaderOverlayHeaderMobile.tsx';
import { ReaderBottomBarMobile } from '@/features/reader/components/overlay/navigation/mobile/ReaderBottomBarMobile.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
const BaseReaderOverlay = ({
isVisible,
isDesktop,
isMobile,
}: BaseReaderOverlayProps &
MobileHeaderProps &
Pick<ReturnType<typeof ReaderService.useOverlayMode>, 'isDesktop' | 'isMobile'>) => {
const [areSettingsOpen, setAreSettingsOpen] = useState(false);
const [mobileHeaderHeight, setMobileHeaderHeight] = useState(0);
const mobileHeaderRef = useRef<HTMLDivElement>(null);
useResizeObserver(
mobileHeaderRef,
useCallback(() => setMobileHeaderHeight(mobileHeaderRef.current?.clientHeight ?? 0), [isMobile]),
);
return (
<Box sx={{ position: 'absolute', width: '100%', height: '100%', pointerEvents: 'none', zIndex: 1 }}>
{isDesktop && (
<>
<StandardReaderProgressBar />
<ReaderNavBarDesktop isVisible={isVisible} openSettings={() => setAreSettingsOpen(true)} />
</>
)}
{isMobile && (
<>
<ReaderOverlayHeaderMobile ref={mobileHeaderRef} isVisible={isVisible} />
<ReaderBottomBarMobile
openSettings={() => setAreSettingsOpen(true)}
isVisible={isVisible}
topOffset={mobileHeaderHeight}
/>
</>
)}
<ReaderSettings isOpen={areSettingsOpen} close={() => setAreSettingsOpen(false)} />
<ReaderPageNumber />
</Box>
);
};
export const ReaderOverlay = withPropsFrom(
memo(BaseReaderOverlay),
[ReaderService.useOverlayMode],
['isDesktop', 'isMobile'],
);

View File

@@ -0,0 +1,141 @@
/*
* 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 Stack from '@mui/material/Stack';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { bindMenu, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
import Menu from '@mui/material/Menu';
import Link from '@mui/material/Link';
import { Link as RouterLink } from 'react-router-dom';
import { alpha } from '@mui/material/styles';
import Slide from '@mui/material/Slide';
import { forwardRef, memo } from 'react';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { MobileHeaderProps } from '@/features/reader/types/ReaderOverlay.types.ts';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import {
ReaderStateChapters,
TReaderScrollbarContext,
TReaderStateMangaContext,
} from '@/features/reader/types/Reader.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
import { useReaderStateChaptersContext } from '@/features/reader/contexts/state/ReaderStateChaptersContext.tsx';
import { useReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
import { ReaderLibraryButton } from '@/features/reader/components/overlay/navigation/ReaderLibraryButton.tsx';
import { ReaderBookmarkButton } from '@/features/reader/components/overlay/navigation/ReaderBookmarkButton.tsx';
import { FALLBACK_CHAPTER } from '@/features/chapter/Chapter.constants.ts';
import { FALLBACK_MANGA } from '@/features/manga/Manga.constants.ts';
import { ReaderExitButton } from '@/features/reader/components/overlay/navigation/ReaderExitButton.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
const DEFAULT_MANGA = { ...FALLBACK_MANGA, title: '' };
const BaseReaderOverlayHeaderMobile = forwardRef<
HTMLDivElement,
MobileHeaderProps &
Pick<TReaderStateMangaContext, 'manga'> &
Pick<ReaderStateChapters, 'currentChapter'> &
Pick<TReaderScrollbarContext, 'scrollbarYSize'>
>(({ isVisible, manga, currentChapter, scrollbarYSize }, ref) => {
const { t } = useTranslation();
const popupState = usePopupState({ popupId: 'reader-overlay-more-menu', variant: 'popover' });
const { id: mangaId, title } = manga ?? DEFAULT_MANGA;
const { id: chapterId, name, realUrl, isBookmarked } = currentChapter ?? FALLBACK_CHAPTER;
return (
<Slide direction="down" in={isVisible} ref={ref}>
<Stack
sx={{
flexDirection: 'row',
alignItems: 'center',
position: 'fixed',
top: 0,
left: 0,
right: `${scrollbarYSize}px`,
p: 2,
pt: (theme) => `max(env(safe-area-inset-top), ${theme.spacing(2)})`,
backgroundColor: (theme) => alpha(theme.palette.background.paper, 0.95),
pointerEvents: 'all',
boxShadow: 2,
}}
>
<ReaderExitButton />
<Stack sx={{ flexGrow: 1 }}>
{manga && currentChapter ? (
<>
<CustomTooltip title={title}>
<TypographyMaxLines lines={1} component="h1" variant="h5">
<Link
component={RouterLink}
to={AppRoutes.manga.path(mangaId)}
sx={{ textDecoration: 'none', color: 'inherit' }}
>
{title}
</Link>
</TypographyMaxLines>
</CustomTooltip>
<CustomTooltip title={name}>
<TypographyMaxLines lines={1}>{name}</TypographyMaxLines>
</CustomTooltip>
</>
) : (
<LoadingPlaceholder />
)}
</Stack>
<ReaderLibraryButton />
<ReaderBookmarkButton id={chapterId} isBookmarked={isBookmarked} />
<IconButton {...bindTrigger(popupState)} color="inherit">
<MoreVertIcon />
</IconButton>
<Menu {...bindMenu(popupState)}>
<MenuItem
component={Link}
disabled={!realUrl}
href={realUrl ?? ''}
rel="noreferrer"
target="_blank"
>
{t('global.button.open_browser')}
</MenuItem>
<MenuItem
component={Link}
disabled={!realUrl}
href={realUrl ? requestManager.getWebviewUrl(realUrl) : ''}
rel="noreferrer"
target="_blank"
>
{t('global.button.open_webview')}
</MenuItem>
<MenuItem
disabled={!realUrl}
onClick={async () => {
await navigator.clipboard.writeText(title);
makeToast(t('global.label.copied_clipboard'), 'info');
}}
>
{t('global.label.share')}
</MenuItem>
</Menu>
</Stack>
</Slide>
);
});
export const ReaderOverlayHeaderMobile = withPropsFrom(
memo(BaseReaderOverlayHeaderMobile),
[useReaderStateMangaContext, useReaderStateChaptersContext, useReaderScrollbarContext],
['manga', 'currentChapter', 'scrollbarYSize'],
);

View File

@@ -0,0 +1,33 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { memo } from 'react';
import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { ChapterAction, TChapterReader } from '@/features/chapter/Chapter.types.ts';
import { CHAPTER_ACTION_TO_TRANSLATION } from '@/features/chapter/Chapter.constants.ts';
const BaseReaderBookmarkButton = ({ id, isBookmarked }: Pick<TChapterReader, 'id' | 'isBookmarked'>) => {
const { t } = useTranslation();
const bookmarkAction: Extract<ChapterAction, 'unbookmark' | 'bookmark'> = isBookmarked ? 'unbookmark' : 'bookmark';
return (
<CustomTooltip title={t(CHAPTER_ACTION_TO_TRANSLATION[bookmarkAction].action.single)}>
<IconButton onClick={() => Chapters.performAction(bookmarkAction, [id], {})} color="inherit">
{isBookmarked ? <BookmarkIcon /> : <BookmarkBorderIcon />}
</IconButton>
</CustomTooltip>
);
};
export const ReaderBookmarkButton = memo(BaseReaderBookmarkButton);

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Virtuoso, VirtuosoProps } from 'react-virtuoso';
import { useMemo } from 'react';
import { ReaderStateChapters } from '@/features/reader/types/Reader.types.ts';
import { ChapterListCard } from '@/features/chapter/components/cards/ChapterListCard.tsx';
const onSelectNoop = () => {};
export const ReaderChapterList = ({
currentChapter,
chapters,
style,
}: Pick<ReaderStateChapters, 'chapters' | 'currentChapter'> & Pick<VirtuosoProps<any, any>, 'style'>) => {
const currentChapterIndex = useMemo(
() => currentChapter && chapters.findIndex((chapter) => chapter.id === currentChapter.id),
[currentChapter, chapters],
);
return (
<Virtuoso
style={{
height: `calc(${chapters.length} * 100px)`,
...style,
}}
initialTopMostItemIndex={currentChapterIndex ?? 0}
totalCount={chapters.length}
computeItemKey={(index) => chapters[index].id}
itemContent={(index) => (
<ChapterListCard
index={index}
chapters={chapters}
isSortDesc
mode="reader"
showChapterNumber={false}
selected={null}
onSelect={onSelectNoop}
selectable={false}
isActiveChapter={index === currentChapterIndex}
/>
)}
increaseViewportBy={400}
/>
);
};

View File

@@ -0,0 +1,36 @@
/*
* 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 { useTranslation } from 'react-i18next';
import ArrowBack from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import { memo } from 'react';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { useGetOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
const BaseReaderExitButton = ({ exit }: { exit: ReturnType<typeof ReaderService.useExit> }) => {
const { t } = useTranslation();
const getOptionForDirection = useGetOptionForDirection();
return (
<CustomTooltip title={t('reader.button.exit')}>
<IconButton sx={{ marginRight: 2 }} onClick={exit} color="inherit">
{getOptionForDirection(<ArrowBack />, <ArrowForwardIcon />)}
</IconButton>
</CustomTooltip>
);
};
export const ReaderExitButton = withPropsFrom(
memo(BaseReaderExitButton),
[() => ({ exit: ReaderService.useExit() })],
['exit'],
);

View File

@@ -0,0 +1,55 @@
/*
* 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 { useTranslation } from 'react-i18next';
import FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import { memo } from 'react';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx';
import { FALLBACK_MANGA } from '@/features/manga/Manga.constants.ts';
import { TReaderStateMangaContext } from '@/features/reader/types/Reader.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
const ACTION_FALLBACK_MANGA = {
...FALLBACK_MANGA,
title: 'Fallback',
inLibrary: false,
};
const BaseReaderLibraryButton = ({ manga }: Pick<TReaderStateMangaContext, 'manga'>) => {
const { inLibrary } = manga ?? ACTION_FALLBACK_MANGA;
const { t } = useTranslation();
const { CategorySelectComponent, updateLibraryState } = useManageMangaLibraryState(
manga ?? ACTION_FALLBACK_MANGA,
true,
);
return (
<>
<CustomTooltip
title={inLibrary ? t('manga.action.library.remove.label.action') : t('manga.button.add_to_library')}
>
<IconButton onClick={updateLibraryState} color="inherit">
{inLibrary ? <FavoriteIcon /> : <FavoriteBorderIcon />}
</IconButton>
</CustomTooltip>
{CategorySelectComponent}
</>
);
};
export const ReaderLibraryButton = withPropsFrom(
memo(BaseReaderLibraryButton),
[useReaderStateMangaContext],
['manga'],
);

View File

@@ -0,0 +1,166 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import IconButton from '@mui/material/IconButton';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import Divider from '@mui/material/Divider';
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
import Drawer from '@mui/material/Drawer';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { ReaderNavBarDesktopProps } from '@/features/reader/types/ReaderOverlay.types.ts';
import { ReaderNavContainer } from '@/features/reader/components/overlay/navigation/desktop/ReaderNavContainer.tsx';
import { ReaderNavBarDesktopMetadata } from '@/features/reader/components/overlay/navigation/desktop/ReaderNavBarDesktopMetadata.tsx';
import { ReaderNavBarDesktopPageNavigation } from '@/features/reader/components/overlay/navigation/desktop/ReaderNavBarDesktopPageNavigation.tsx';
import { ReaderNavBarDesktopChapterNavigation } from '@/features/reader/components/overlay/navigation/desktop/ReaderNavBarDesktopChapterNavigation.tsx';
import { ReaderNavBarDesktopQuickSettings } from '@/features/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx';
import { ReaderNavBarDesktopActions } from '@/features/reader/components/overlay/navigation/desktop/ReaderNavBarDesktopActions.tsx';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { useReaderStateChaptersContext } from '@/features/reader/contexts/state/ReaderStateChaptersContext.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
import {
IReaderSettings,
ReaderStateChapters,
TReaderStateMangaContext,
} from '@/features/reader/types/Reader.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { FALLBACK_MANGA } from '@/features/manga/Manga.constants.ts';
import { ReaderExitButton } from '@/features/reader/components/overlay/navigation/ReaderExitButton.tsx';
const useGetPreviousNavBarStaticValue = (isVisible: boolean, isStaticNav: boolean) => {
const wasNavBarStaticRef = useRef(isStaticNav);
const wasNavBarStaticPreviousRef = useRef(isStaticNav);
const resetWasNavBarStaticValue = wasNavBarStaticPreviousRef.current !== wasNavBarStaticRef.current && !isVisible;
if (resetWasNavBarStaticValue) {
wasNavBarStaticRef.current = false;
}
const didNavBarStaticValueChange = wasNavBarStaticPreviousRef.current !== isStaticNav;
if (didNavBarStaticValueChange) {
wasNavBarStaticRef.current = wasNavBarStaticPreviousRef.current;
wasNavBarStaticPreviousRef.current = isStaticNav;
}
return wasNavBarStaticRef.current;
};
const BaseReaderNavBarDesktop = ({
isVisible,
openSettings,
setReaderNavBarWidth,
manga,
chapters,
currentChapter,
previousChapter,
nextChapter,
isStaticNav,
}: ReaderNavBarDesktopProps &
Pick<NavbarContextType, 'setReaderNavBarWidth'> &
Pick<TReaderStateMangaContext, 'manga'> &
Pick<ReaderStateChapters, 'currentChapter' | 'previousChapter' | 'nextChapter' | 'chapters'> &
Pick<IReaderSettings, 'isStaticNav'>) => {
const { t } = useTranslation();
const updateReaderSettings = ReaderService.useCreateUpdateSetting(manga ?? FALLBACK_MANGA);
const [navBarElement, setNavBarElement] = useState<HTMLDivElement | null>();
useResizeObserver(
navBarElement,
useCallback(() => {
if (!isStaticNav) {
return;
}
setReaderNavBarWidth(navBarElement!.offsetWidth);
}, [navBarElement, isStaticNav]),
);
useLayoutEffect(() => () => setReaderNavBarWidth(0), []);
const wasNavBarStatic = useGetPreviousNavBarStaticValue(isVisible, isStaticNav);
const changedNavBarStaticValue = wasNavBarStatic && isVisible;
const drawerTransitionDuration = changedNavBarStaticValue ? 0 : undefined;
return (
<Drawer
variant={isStaticNav ? 'permanent' : 'persistent'}
open={isVisible || isStaticNav}
transitionDuration={drawerTransitionDuration}
SlideProps={{
unmountOnExit: true,
}}
PaperProps={{
ref: (ref: HTMLDivElement | null) => setNavBarElement(ref),
}}
>
<ReaderNavContainer sx={{ backgroundColor: 'background.paper', pointerEvents: 'all' }}>
<Stack sx={{ p: 2, gap: 2, backgroundColor: 'action.hover' }}>
<Stack sx={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<ReaderExitButton />
<CustomTooltip title={t('reader.settings.label.static_navigation')}>
<IconButton
onClick={() => {
setReaderNavBarWidth(0);
updateReaderSettings('isStaticNav', !isStaticNav);
}}
color={isStaticNav ? 'primary' : 'inherit'}
>
{isStaticNav ? <PushPinIcon /> : <PushPinOutlinedIcon />}
</IconButton>
</CustomTooltip>
</Stack>
{manga && currentChapter ? (
<>
<ReaderNavBarDesktopMetadata
mangaId={manga.id}
mangaTitle={manga.title}
chapterTitle={currentChapter.name}
scanlator={currentChapter.scanlator}
/>
<ReaderNavBarDesktopActions />
</>
) : (
<LoadingPlaceholder />
)}
</Stack>
<Stack sx={{ p: 2, gap: 2 }}>
<Stack sx={{ gap: 1 }}>
<ReaderNavBarDesktopPageNavigation />
<ReaderNavBarDesktopChapterNavigation
chapters={chapters}
currentChapter={currentChapter}
nextChapter={nextChapter}
previousChapter={previousChapter}
/>
</Stack>
<Divider />
<ReaderNavBarDesktopQuickSettings openSettings={openSettings} />
</Stack>
</ReaderNavContainer>
</Drawer>
);
};
export const ReaderNavBarDesktop = withPropsFrom(
memo(BaseReaderNavBarDesktop),
[
useNavBarContext,
useReaderStateMangaContext,
useReaderStateChaptersContext,
userReaderStatePagesContext,
ReaderService.useSettingsWithoutDefaultFlag,
],
['setReaderNavBarWidth', 'manga', 'chapters', 'currentChapter', 'previousChapter', 'nextChapter', 'isStaticNav'],
);

View File

@@ -0,0 +1,136 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import IconButton from '@mui/material/IconButton';
import DownloadIcon from '@mui/icons-material/Download';
import ReplayIcon from '@mui/icons-material/Replay';
import { memo, useMemo, useRef } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { ReaderStateChapters } from '@/features/reader/types/Reader.types.ts';
import { DownloadStateIndicator } from '@/features/core/components/downloads/DownloadStateIndicator.tsx';
import { ReaderStatePages } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useReaderStateChaptersContext } from '@/features/reader/contexts/state/ReaderStateChaptersContext.tsx';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { ReaderLibraryButton } from '@/features/reader/components/overlay/navigation/ReaderLibraryButton.tsx';
import { ReaderBookmarkButton } from '@/features/reader/components/overlay/navigation/ReaderBookmarkButton.tsx';
import { CHAPTER_ACTION_TO_TRANSLATION, FALLBACK_CHAPTER } from '@/features/chapter/Chapter.constants.ts';
import { IconBrowser } from '@/assets/icons/IconBrowser.tsx';
import { IconWebView } from '@/assets/icons/IconWebView.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
const DownloadButton = ({ currentChapter }: Required<Pick<ReaderStateChapters, 'currentChapter'>>) => {
const { t } = useTranslation();
const downloadStatus = Chapters.useDownloadStatusFromCache(currentChapter?.id ?? -1);
if (currentChapter && Chapters.isDownloaded(currentChapter)) {
return (
<CustomTooltip title={t(CHAPTER_ACTION_TO_TRANSLATION.delete.action.single)}>
<IconButton onClick={() => Chapters.performAction('delete', [currentChapter.id], {})} color="inherit">
<DeleteIcon />
</IconButton>
</CustomTooltip>
);
}
if (downloadStatus) {
return <DownloadStateIndicator chapterId={downloadStatus.chapter.id} />;
}
return (
<CustomTooltip title={t(CHAPTER_ACTION_TO_TRANSLATION.download.action.single)} disabled={!currentChapter}>
<IconButton
disabled={!currentChapter}
onClick={() => Chapters.performAction('download', [currentChapter?.id ?? -1], {})}
color="inherit"
>
<DownloadIcon />
</IconButton>
</CustomTooltip>
);
};
const BaseReaderNavBarDesktopActions = memo(
({
currentChapter,
pageLoadStates,
setPageLoadStates,
setRetryFailedPagesKeyPrefix,
}: Required<Pick<ReaderStateChapters, 'currentChapter'>> &
Pick<ReaderStatePages, 'pageLoadStates' | 'setPageLoadStates' | 'setRetryFailedPagesKeyPrefix'>) => {
const { id, isBookmarked, realUrl } = currentChapter ?? FALLBACK_CHAPTER;
const { t } = useTranslation();
const pageRetryKeyPrefix = useRef<number>(0);
const haveSomePagesFailedToLoad = useMemo(
() => pageLoadStates.some((pageLoadState) => pageLoadState.error),
[pageLoadStates],
);
return (
<Stack sx={{ flexDirection: 'row', justifyContent: 'center', gap: 1 }}>
<ReaderLibraryButton />
<ReaderBookmarkButton id={id} isBookmarked={isBookmarked} />
<CustomTooltip title={t('reader.button.retry_load_pages')} disabled={!haveSomePagesFailedToLoad}>
<IconButton
onClick={() => {
setPageLoadStates((statePageLoadStates) =>
statePageLoadStates.map((pageLoadState) => ({
url: pageLoadState.url,
loaded: pageLoadState.loaded,
})),
);
setRetryFailedPagesKeyPrefix(`${pageRetryKeyPrefix.current}`);
pageRetryKeyPrefix.current = (pageRetryKeyPrefix.current + 1) % 1000;
}}
disabled={!haveSomePagesFailedToLoad}
color="inherit"
>
<ReplayIcon />
</IconButton>
</CustomTooltip>
<DownloadButton currentChapter={currentChapter} />
<CustomTooltip title={t('global.button.open_browser')} disabled={!realUrl}>
<IconButton
disabled={!realUrl}
href={realUrl ?? ''}
rel="noreferrer"
target="_blank"
color="inherit"
>
<IconBrowser />
</IconButton>
</CustomTooltip>
<CustomTooltip title={t('global.button.open_webview')} disabled={!realUrl}>
<IconButton
disabled={!realUrl}
href={realUrl ? requestManager.getWebviewUrl(realUrl) : ''}
rel="noreferrer"
target="_blank"
color="inherit"
>
<IconWebView />
</IconButton>
</CustomTooltip>
</Stack>
);
},
);
export const ReaderNavBarDesktopActions = withPropsFrom(
BaseReaderNavBarDesktopActions,
[useReaderStateChaptersContext, userReaderStatePagesContext],
['currentChapter', 'pageLoadStates', 'setPageLoadStates', 'setRetryFailedPagesKeyPrefix'],
);

View File

@@ -0,0 +1,130 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import MenuItem from '@mui/material/MenuItem';
import { memo, useLayoutEffect } from 'react';
import Popover from '@mui/material/Popover';
import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import { Link } from 'react-router-dom';
import { Select } from '@/features/core/components/inputs/Select.tsx';
import { ReaderChapterList } from '@/features/reader/components/overlay/navigation/ReaderChapterList.tsx';
import { ReaderNavBarDesktopNextPreviousButton } from '@/features/reader/components/overlay/navigation/desktop/ReaderNavBarDesktopNextPreviousButton.tsx';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
import { ReaderStateChapters } from '@/features/reader/types/Reader.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
const BaseReaderNavBarDesktopChapterNavigation = ({
currentChapter,
previousChapter,
nextChapter,
chapters = [],
readerThemeDirection,
openChapter,
}: Pick<ReaderStateChapters, 'chapters' | 'currentChapter' | 'previousChapter' | 'nextChapter'> & {
readerThemeDirection: ReturnType<typeof ReaderService.useGetThemeDirection>;
openChapter: ReturnType<typeof ReaderControls.useOpenChapter>;
}) => {
const { t } = useTranslation();
const popupState = usePopupState({ variant: 'popover', popupId: 'reader-nav-bar-desktop-chapter-list' });
useLayoutEffect(() => {
popupState.close();
}, [currentChapter?.id]);
return (
<Stack sx={{ flexDirection: 'row', gap: 1 }} dir="ltr">
<ReaderNavBarDesktopNextPreviousButton
type="previous"
title={t(
getOptionForDirection(
'reader.button.previous_chapter',
'reader.button.next_chapter',
readerThemeDirection,
),
)}
onClick={() => {
openChapter(getOptionForDirection('previous', 'next', readerThemeDirection));
}}
disabled={getOptionForDirection(!previousChapter, !nextChapter, readerThemeDirection)}
/>
<FormControl sx={{ flexBasis: '70%', flexGrow: 0, flexShrink: 0 }}>
<InputLabel id="reader-nav-bar-desktop-chapter-select">{t('chapter.title_one')}</InputLabel>
<Select
{...bindTrigger(popupState)}
open={popupState.isOpen}
value={currentChapter?.id ?? 0}
// hide actual select menu
MenuProps={{ sx: { visibility: 'hidden' } }}
label={t('chapter.title_one')}
labelId="reader-nav-bar-desktop-chapter-select"
>
{/* hacky way to use the select component with a custom menu, the only possible value that is needed is the current chapter */}
<MenuItem key={currentChapter?.id} value={currentChapter?.id ?? 0}>
{currentChapter ? `#${currentChapter.chapterNumber} ${currentChapter.name}` : ''}
</MenuItem>
</Select>
</FormControl>
<ReaderNavBarDesktopNextPreviousButton
component={Link}
type="next"
title={t(
getOptionForDirection(
'reader.button.next_chapter',
'reader.button.previous_chapter',
readerThemeDirection,
),
)}
onClick={() => {
openChapter(getOptionForDirection('next', 'previous', readerThemeDirection));
}}
disabled={getOptionForDirection(!nextChapter, !previousChapter, readerThemeDirection)}
/>
<Popover
{...bindPopover(popupState)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Box sx={{ mb: 1 }}>
<ReaderChapterList
style={{
width: '500px',
maxWidth: '90vw',
minHeight: '150px',
maxHeight: '300px',
}}
currentChapter={currentChapter}
chapters={chapters}
/>
</Box>
</Popover>
</Stack>
);
};
export const ReaderNavBarDesktopChapterNavigation = withPropsFrom(
memo(BaseReaderNavBarDesktopChapterNavigation),
[
() => ({
readerThemeDirection: ReaderService.useGetThemeDirection(),
}),
() => ({
openChapter: ReaderControls.useOpenChapter(),
}),
],
['readerThemeDirection', 'openChapter'],
);

View File

@@ -0,0 +1,62 @@
/*
* 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 from '@mui/material/Stack';
import Link from '@mui/material/Link';
import { Link as RouterLink } from 'react-router-dom';
import { memo } from 'react';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
export const ReaderNavBarDesktopMetadata = memo(
({
mangaId,
mangaTitle,
chapterTitle,
scanlator,
}: {
mangaId: number;
mangaTitle: string;
chapterTitle: string;
scanlator?: string | null;
}) => (
<Stack>
<CustomTooltip title={mangaTitle} placement="right">
<TypographyMaxLines lines={3} variant="h6" component="h1" sx={{ textAlign: 'center' }}>
<Link
component={RouterLink}
to={AppRoutes.manga.path(mangaId)}
sx={{ textDecoration: 'none', color: 'inherit' }}
>
{mangaTitle}
</Link>
</TypographyMaxLines>
</CustomTooltip>
<CustomTooltip title={chapterTitle} placement="right">
<TypographyMaxLines lines={4} variant="body1" component="h2" sx={{ textAlign: 'center' }}>
{chapterTitle}
</TypographyMaxLines>
</CustomTooltip>
{scanlator && (
<CustomTooltip title={scanlator} placement="right">
<TypographyMaxLines
lines={4}
variant="body2"
component="h3"
color="textDisabled"
sx={{ textAlign: 'center' }}
>
{scanlator}
</TypographyMaxLines>
</CustomTooltip>
)}
</Stack>
),
);

View File

@@ -0,0 +1,29 @@
/*
* 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 } from 'react';
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { CustomButtonIcon } from '@/features/core/components/buttons/CustomButtonIcon.tsx';
export const ReaderNavBarDesktopNextPreviousButton = ({
title,
type,
disabled,
...customIconButtonProps
}: Omit<ComponentProps<typeof CustomButtonIcon>, 'children'> & {
title: string;
type: 'previous' | 'next';
}) => (
<CustomTooltip title={title} disabled={disabled}>
<CustomButtonIcon sx={{ flexBasis: '15%' }} variant="contained" disabled={disabled} {...customIconButtonProps}>
{type === 'previous' ? <KeyboardArrowLeftIcon /> : <KeyboardArrowRightIcon />}
</CustomButtonIcon>
</CustomTooltip>
);

View File

@@ -0,0 +1,91 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import MenuItem from '@mui/material/MenuItem';
import { memo, useMemo } from 'react';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import { Select } from '@/features/core/components/inputs/Select.tsx';
import { getNextIndexFromPage, getPage } from '@/features/reader/utils/ReaderProgressBar.utils.tsx';
import { ReaderStatePages } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
import { useGetOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { ReaderNavBarDesktopNextPreviousButton } from '@/features/reader/components/overlay/navigation/desktop/ReaderNavBarDesktopNextPreviousButton.tsx';
import { READING_DIRECTION_TO_THEME_DIRECTION } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
const BaseReaderNavBarDesktopPageNavigation = ({
currentPageIndex,
pages,
readingDirection,
openPage,
}: Pick<ReaderStatePages, 'currentPageIndex' | 'pages'> &
Pick<IReaderSettings, 'readingDirection'> & {
openPage: ReturnType<typeof ReaderControls.useOpenPage>;
}) => {
const { t } = useTranslation();
const getOptionForDirection = useGetOptionForDirection();
const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]);
const direction = READING_DIRECTION_TO_THEME_DIRECTION[readingDirection];
return (
<Stack sx={{ flexDirection: 'row', gap: 1 }} dir="ltr">
<ReaderNavBarDesktopNextPreviousButton
type="previous"
title={t(getOptionForDirection('reader.button.previous_page', 'reader.button.next_page', direction))}
disabled={getOptionForDirection(
!currentPage.primary.index,
getNextIndexFromPage(currentPage) === getNextIndexFromPage(pages.slice(-1)[0]),
direction,
)}
onClick={() => openPage('previous', undefined, false)}
/>
<FormControl sx={{ flexBasis: '70%', flexGrow: 0, flexShrink: 0 }}>
<InputLabel id="reader-nav-bar-desktop-page-select">{t('reader.page_info.label.page')}</InputLabel>
<Select
labelId="reader-nav-bar-desktop-page-select"
label={t('reader.page_info.label.page')}
value={getNextIndexFromPage(currentPage)}
onChange={(e) => openPage(e.target.value as number, undefined, false)}
>
{pages.map((page) => (
<MenuItem key={getNextIndexFromPage(page)} value={getNextIndexFromPage(page)}>
{page.name}
</MenuItem>
))}
</Select>
</FormControl>
<ReaderNavBarDesktopNextPreviousButton
type="next"
title={t(getOptionForDirection('reader.button.next_page', 'reader.button.previous_page', direction))}
disabled={getOptionForDirection(
getNextIndexFromPage(currentPage) === getNextIndexFromPage(pages.slice(-1)[0]),
!currentPage.primary.index,
direction,
)}
onClick={() => openPage('next', undefined, false)}
/>
</Stack>
);
};
export const ReaderNavBarDesktopPageNavigation = withPropsFrom(
memo(BaseReaderNavBarDesktopPageNavigation),
[
userReaderStatePagesContext,
() => ({ openPage: ReaderControls.useOpenPage() }),
ReaderService.useSettingsWithoutDefaultFlag,
],
['currentPageIndex', 'pages', 'readingDirection', 'openPage'],
);

View File

@@ -0,0 +1,18 @@
/*
* 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 from '@mui/material/Stack';
import { styled } from '@mui/material/styles';
export const ReaderNavContainer = styled(Stack)({
width: '400px',
minWidth: '400px',
maxWidth: '400px',
height: '100vh',
overflowY: 'auto',
});

View File

@@ -0,0 +1,77 @@
/*
* 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 { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import PauseCircleFilledIcon from '@mui/icons-material/PauseCircleFilled';
import PlayCircleFilledIcon from '@mui/icons-material/PlayCircleFilled';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import { useRef } from 'react';
import { useReaderAutoScrollContext } from '@/features/reader/contexts/ReaderAutoScrollContext.tsx';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { AUTO_SCROLL_SPEED } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { coerceIn } from '@/lib/HelperFunctions.ts';
export const ReaderNavBarDesktopAutoScroll = ({
autoScroll,
setAutoScroll,
}: Pick<IReaderSettings, 'autoScroll'> & {
setAutoScroll: (newAutoScroll: IReaderSettings['autoScroll'], commit: boolean) => void;
}) => {
const { t } = useTranslation();
const { isActive, toggleActive } = useReaderAutoScrollContext();
const updateTimeout = useRef<NodeJS.Timeout>(undefined);
return (
<Stack sx={{ flexDirection: 'row', gap: 1 }}>
<Button
sx={{ justifyContent: 'start', textTransform: 'unset', flexGrow: 1 }}
size="large"
onClick={toggleActive}
color={isActive ? 'secondary' : 'primary'}
variant="contained"
startIcon={isActive ? <PauseCircleFilledIcon /> : <PlayCircleFilledIcon />}
>
{t('reader.settings.auto_scroll.title')}
</Button>
<TextField
value={autoScroll.value}
type="number"
size="small"
onBlur={(e) => {
const value = coerceIn(+e.target.value, AUTO_SCROLL_SPEED.min, AUTO_SCROLL_SPEED.max);
if (value !== autoScroll.value) {
clearTimeout(updateTimeout.current);
setAutoScroll({ ...autoScroll, value }, true);
}
}}
onChange={(e) => {
const value = coerceIn(+e.target.value, AUTO_SCROLL_SPEED.min, AUTO_SCROLL_SPEED.max);
setAutoScroll({ ...autoScroll, value }, false);
clearTimeout(updateTimeout.current);
updateTimeout.current = setTimeout(() => setAutoScroll({ ...autoScroll, value }, true), 1000);
}}
slotProps={{
input: {
inputProps: AUTO_SCROLL_SPEED,
endAdornment: (
<InputAdornment position="end">
{t('global.time.seconds.second', { count: autoScroll.value })}
</InputAdornment>
),
},
}}
/>
</Stack>
);
};

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import { OffsetDoubleSpreadIcon } from '@/assets/icons/svg/OffsetDoubleSpreadIcon.tsx';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { isOffsetDoubleSpreadPagesEditable } from '@/features/reader/utils/ReaderSettings.utils.tsx';
export const ReaderNavBarDesktopOffsetDoubleSpread = ({
readingMode,
shouldOffsetDoubleSpreads,
setShouldOffsetDoubleSpreads,
}: Pick<IReaderSettings, 'readingMode' | 'shouldOffsetDoubleSpreads'> & {
setShouldOffsetDoubleSpreads: (offset: boolean) => void;
}) => {
const { t } = useTranslation();
if (!isOffsetDoubleSpreadPagesEditable(readingMode)) {
return null;
}
return (
<Button
sx={{ justifyContent: 'start', textTransform: 'unset' }}
size="large"
onClick={() => setShouldOffsetDoubleSpreads(!shouldOffsetDoubleSpreads)}
color={shouldOffsetDoubleSpreads ? 'secondary' : 'primary'}
variant="contained"
startIcon={<OffsetDoubleSpreadIcon />}
>
{t('reader.settings.label.offset_double_spread')}
</Button>
);
};

View File

@@ -0,0 +1,67 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import FitScreenIcon from '@mui/icons-material/FitScreen';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { ValueRotationButton } from '@/features/core/components/buttons/ValueRotationButton.tsx';
import {
IReaderSettings,
IReaderSettingsWithDefaultFlag,
ReaderPageScaleMode,
} from '@/features/reader/types/Reader.types.ts';
import {
PAGE_SCALE_VALUE_TO_DISPLAY_DATA,
READER_PAGE_SCALE_MODE_TO_SCALING_ALLOWED,
READER_PAGE_SCALE_MODE_VALUES,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { MultiValueButtonDefaultableProps } from '@/features/core/Core.types.ts';
import { CustomButtonIcon } from '@/features/core/components/buttons/CustomButtonIcon.tsx';
export const ReaderNavBarDesktopPageScale = ({
pageScaleMode,
shouldStretchPage,
updateSetting,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'pageScaleMode' | 'shouldStretchPage'> &
Pick<MultiValueButtonDefaultableProps<ReaderPageScaleMode>, 'isDefaultable' | 'onDefault'> & {
updateSetting: <Setting extends keyof Pick<IReaderSettings, 'pageScaleMode' | 'shouldStretchPage'>>(
setting: Setting,
value: IReaderSettings[Setting],
) => void;
}) => {
const { t } = useTranslation();
return (
<Stack sx={{ flexDirection: 'row', gap: 1 }}>
<ValueRotationButton
{...buttonSelectInputProps}
tooltip={t('reader.settings.page_scale.title')}
value={pageScaleMode.isDefault ? undefined : pageScaleMode.value}
defaultValue={pageScaleMode.isDefault ? pageScaleMode.value : undefined}
values={READER_PAGE_SCALE_MODE_VALUES}
setValue={(value) => updateSetting('pageScaleMode', value)}
valueToDisplayData={PAGE_SCALE_VALUE_TO_DISPLAY_DATA}
defaultIcon={PAGE_SCALE_VALUE_TO_DISPLAY_DATA[pageScaleMode.value].icon}
/>
{READER_PAGE_SCALE_MODE_TO_SCALING_ALLOWED[pageScaleMode.value] && (
<CustomTooltip title={t('reader.settings.page_scale.stretch')}>
<CustomButtonIcon
onClick={() => updateSetting('shouldStretchPage', !shouldStretchPage.value)}
sx={{ px: undefined }}
variant="contained"
color={shouldStretchPage.value ? 'secondary' : 'primary'}
>
<FitScreenIcon />
</CustomButtonIcon>
</CustomTooltip>
)}
</Stack>
);
};

View File

@@ -0,0 +1,105 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import SettingsIcon from '@mui/icons-material/Settings';
import { ReaderNavBarDesktopPageScale } from '@/features/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopPageScale.tsx';
import { ReaderNavBarDesktopReadingMode } from '@/features/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopReadingMode.tsx';
import { ReaderNavBarDesktopOffsetDoubleSpread } from '@/features/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopOffsetDoubleSpread.tsx';
import { ReaderNavBarDesktopReadingDirection } from '@/features/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopReadingDirection.tsx';
import { IReaderSettingsWithDefaultFlag, TReaderStateMangaContext } from '@/features/reader/types/Reader.types.ts';
import { ReaderNavBarDesktopProps } from '@/features/reader/types/ReaderOverlay.types.ts';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
import { FALLBACK_MANGA } from '@/features/manga/Manga.constants.ts';
import { ReaderNavBarDesktopAutoScroll } from '@/features/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopAutoScroll.tsx';
const BaseReaderNavBarDesktopQuickSettings = ({
manga,
readingMode,
shouldOffsetDoubleSpreads,
pageScaleMode,
shouldStretchPage,
readingDirection,
autoScroll,
openSettings,
}: Pick<TReaderStateMangaContext, 'manga'> &
Pick<ReaderNavBarDesktopProps, 'openSettings'> &
Pick<
IReaderSettingsWithDefaultFlag,
| 'readingMode'
| 'shouldOffsetDoubleSpreads'
| 'pageScaleMode'
| 'shouldStretchPage'
| 'readingDirection'
| 'autoScroll'
>) => {
const { t } = useTranslation();
const updateSetting = ReaderService.useCreateUpdateSetting(manga ?? FALLBACK_MANGA);
const deleteSetting = ReaderService.useCreateDeleteSetting(manga ?? FALLBACK_MANGA);
return (
<Stack sx={{ gap: 1 }}>
<ReaderNavBarDesktopReadingMode
readingMode={readingMode}
setReadingMode={(value) => updateSetting('readingMode', value)}
isDefaultable
onDefault={() => deleteSetting('readingMode')}
/>
<ReaderNavBarDesktopOffsetDoubleSpread
readingMode={readingMode.value}
shouldOffsetDoubleSpreads={shouldOffsetDoubleSpreads.value}
setShouldOffsetDoubleSpreads={(value) => updateSetting('shouldOffsetDoubleSpreads', value)}
/>
<ReaderNavBarDesktopPageScale
pageScaleMode={pageScaleMode}
shouldStretchPage={shouldStretchPage}
updateSetting={updateSetting}
isDefaultable
onDefault={() => deleteSetting('pageScaleMode')}
/>
<ReaderNavBarDesktopReadingDirection
readingDirection={readingDirection}
setReadingDirection={(value) => updateSetting('readingDirection', value)}
isDefaultable
onDefault={() => deleteSetting('readingDirection')}
/>
<ReaderNavBarDesktopAutoScroll
autoScroll={autoScroll}
setAutoScroll={(...args) => updateSetting('autoScroll', ...args)}
/>
<Button
onClick={() => openSettings()}
size="large"
sx={{ justifyContent: 'start', textTransform: 'none' }}
variant="contained"
startIcon={<SettingsIcon />}
>
{t('settings.title')}
</Button>
</Stack>
);
};
export const ReaderNavBarDesktopQuickSettings = withPropsFrom(
BaseReaderNavBarDesktopQuickSettings,
[useReaderStateMangaContext, ReaderService.useSettings],
[
'manga',
'readingMode',
'shouldOffsetDoubleSpreads',
'pageScaleMode',
'shouldStretchPage',
'readingDirection',
'autoScroll',
],
);

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { ValueRotationButton } from '@/features/core/components/buttons/ValueRotationButton.tsx';
import { IReaderSettingsWithDefaultFlag, ReadingDirection } from '@/features/reader/types/Reader.types.ts';
import {
READING_DIRECTION_VALUES,
READING_DIRECTION_VALUE_TO_DISPLAY_DATA,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { MultiValueButtonDefaultableProps } from '@/features/core/Core.types.ts';
export const ReaderNavBarDesktopReadingDirection = ({
readingDirection,
setReadingDirection,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'readingDirection'> &
Pick<MultiValueButtonDefaultableProps<ReadingDirection>, 'isDefaultable' | 'onDefault'> & {
setReadingDirection: (readingDirection: ReadingDirection) => void;
}) => {
const { t } = useTranslation();
return (
<ValueRotationButton
{...buttonSelectInputProps}
tooltip={t('reader.settings.label.reading_direction')}
value={readingDirection.isDefault ? undefined : readingDirection.value}
defaultValue={readingDirection.isDefault ? readingDirection.value : undefined}
values={READING_DIRECTION_VALUES}
setValue={setReadingDirection}
valueToDisplayData={READING_DIRECTION_VALUE_TO_DISPLAY_DATA}
defaultIcon={READING_DIRECTION_VALUE_TO_DISPLAY_DATA[readingDirection.value].icon}
/>
);
};

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { ValueRotationButton } from '@/features/core/components/buttons/ValueRotationButton.tsx';
import { IReaderSettingsWithDefaultFlag, ReadingMode } from '@/features/reader/types/Reader.types.ts';
import {
READING_MODE_VALUE_TO_DISPLAY_DATA,
READING_MODE_VALUES,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { MultiValueButtonDefaultableProps } from '@/features/core/Core.types.ts';
export const ReaderNavBarDesktopReadingMode = ({
readingMode,
setReadingMode,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'readingMode'> &
Pick<MultiValueButtonDefaultableProps<ReadingMode>, 'isDefaultable' | 'onDefault'> & {
setReadingMode: (mode: ReadingMode) => void;
}) => {
const { t } = useTranslation();
return (
<ValueRotationButton
{...buttonSelectInputProps}
tooltip={t('reader.settings.label.reading_mode')}
value={readingMode.isDefault ? undefined : readingMode.value}
defaultValue={readingMode.isDefault ? readingMode.value : undefined}
values={READING_MODE_VALUES}
setValue={setReadingMode}
valueToDisplayData={READING_MODE_VALUE_TO_DISPLAY_DATA}
defaultIcon={READING_MODE_VALUE_TO_DISPLAY_DATA[readingMode.value].icon}
/>
);
};

View File

@@ -0,0 +1,140 @@
/*
* 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 SettingsIcon from '@mui/icons-material/Settings';
import Stack from '@mui/material/Stack';
import AppSettingsAltIcon from '@mui/icons-material/AppSettingsAlt';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import { alpha } from '@mui/material/styles';
import { bindDialog, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import { useTranslation } from 'react-i18next';
import Slide from '@mui/material/Slide';
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { ReaderBottomBarMobileProps } from '@/features/reader/types/ReaderOverlay.types.ts';
import { MobileReaderProgressBar } from '@/features/reader/components/overlay/progress-bar/variants/MobileReaderProgressBar.tsx';
import { ReaderChapterList } from '@/features/reader/components/overlay/navigation/ReaderChapterList.tsx';
import { ReaderBottomBarMobileQuickSettings } from '@/features/reader/components/overlay/navigation/mobile/ReaderBottomBarMobileQuickSettings.tsx';
import { useReaderStateChaptersContext } from '@/features/reader/contexts/state/ReaderStateChaptersContext.tsx';
import { useReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
import { ReaderStateChapters, TReaderScrollbarContext } from '@/features/reader/types/Reader.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
const BaseReaderBottomBarMobile = ({
openSettings,
isVisible,
currentChapter,
chapters,
scrollbarXSize,
scrollbarYSize,
topOffset = 0,
}: ReaderBottomBarMobileProps &
Pick<ReaderStateChapters, 'currentChapter' | 'chapters'> &
Pick<TReaderScrollbarContext, 'scrollbarXSize' | 'scrollbarYSize'> & { topOffset?: number }) => {
const { t } = useTranslation();
const chapterListPopupState = usePopupState({ variant: 'dialog', popupId: 'reader-chapter-list-dialog' });
const quickSettingsPopupState = usePopupState({ variant: 'dialog', popupId: 'reader-quick-settings-dialog' });
const [bottomBarRefHeight, setBottomBarRefHeight] = useState(0);
const bottomBarRef = useRef<HTMLDivElement>(null);
useResizeObserver(
bottomBarRef,
useCallback(() => setBottomBarRefHeight(bottomBarRef.current?.clientHeight ?? 0), [bottomBarRefHeight]),
);
useLayoutEffect(() => {
chapterListPopupState.close();
}, [currentChapter?.id]);
return (
<>
<Stack
sx={{
position: 'fixed',
right: `${scrollbarYSize}px`,
bottom: 0,
left: 0,
height: `calc(100% - ${topOffset}px)`,
}}
>
<MobileReaderProgressBar topOffset={topOffset} bottomOffset={bottomBarRefHeight} />
<Slide direction="up" in={isVisible}>
<Stack
ref={bottomBarRef}
sx={{
alignItems: 'center',
backgroundColor: (theme) => alpha(theme.palette.background.paper, 0.95),
pb: `max(${scrollbarXSize}px, env(safe-area-inset-bottom))`,
boxShadow: 2,
pointerEvents: 'all',
}}
>
<Stack
sx={{
width: '50%',
flexDirection: 'row',
p: 2,
gap: 1,
justifyContent: 'space-evenly',
alignItems: 'center',
}}
>
<CustomTooltip title={t('reader.button.chapter_list')}>
<IconButton {...bindTrigger(chapterListPopupState)} color="inherit">
<FormatListBulletedIcon />
</IconButton>
</CustomTooltip>
<CustomTooltip title={t('reader.settings.title.quick_settings')}>
<IconButton {...bindTrigger(quickSettingsPopupState)} color="inherit">
<AppSettingsAltIcon />
</IconButton>
</CustomTooltip>
<CustomTooltip title={t('settings.title')}>
<IconButton onClick={openSettings} color="inherit">
<SettingsIcon />
</IconButton>
</CustomTooltip>
</Stack>
</Stack>
</Slide>
</Stack>
{chapterListPopupState.isOpen && (
<Dialog {...bindDialog(chapterListPopupState)} fullWidth maxWidth="md" scroll="paper">
<DialogContent sx={{ p: 0, pb: 1 }}>
<ReaderChapterList
style={{
minHeight: '15vh',
maxHeight: '75vh',
}}
currentChapter={currentChapter}
chapters={chapters}
/>
</DialogContent>
</Dialog>
)}
{quickSettingsPopupState.isOpen && (
<Dialog {...bindDialog(quickSettingsPopupState)} fullWidth maxWidth="md" scroll="paper">
<DialogContent>
<ReaderBottomBarMobileQuickSettings />
</DialogContent>
</Dialog>
)}
</>
);
};
export const ReaderBottomBarMobile = withPropsFrom(
memo(BaseReaderBottomBarMobile),
[useReaderStateChaptersContext, useReaderScrollbarContext],
['currentChapter', 'chapters', 'scrollbarXSize', 'scrollbarYSize'],
);

View File

@@ -0,0 +1,77 @@
/*
* 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 from '@mui/material/Stack';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReaderSettingReadingMode } from '@/features/reader/components/settings/layout/ReaderSettingReadingMode.tsx';
import { ReaderSettingReadingDirection } from '@/features/reader/components/settings/layout/ReaderSettingReadingDirection.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
import { DefaultSettingFootnote } from '@/features/reader/components/settings/DefaultSettingFootnote.tsx';
import {
IReaderSettingsWithDefaultFlag,
TReaderAutoScrollContext,
TReaderStateMangaContext,
} from '@/features/reader/types/Reader.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { FALLBACK_MANGA } from '@/features/manga/Manga.constants.ts';
import { ReaderSettingAutoScroll } from '@/features/reader/components/settings/behaviour/ReaderSettingAutoScroll.tsx';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { useReaderAutoScrollContext } from '@/features/reader/contexts/ReaderAutoScrollContext.tsx';
const BaseReaderBottomBarMobileQuickSettings = ({
manga,
readingMode,
readingDirection,
autoScroll,
isActive,
toggleActive,
}: Pick<TReaderStateMangaContext, 'manga'> &
Pick<IReaderSettingsWithDefaultFlag, 'readingMode' | 'readingDirection' | 'autoScroll'> &
Pick<TReaderAutoScrollContext, 'isActive' | 'toggleActive'>) => {
const { t } = useTranslation();
const deleteSetting = ReaderService.useCreateDeleteSetting(manga ?? FALLBACK_MANGA);
if (!manga) {
return null;
}
return (
<Stack sx={{ gap: 2 }}>
<DefaultSettingFootnote />
<ReaderSettingReadingMode
readingMode={readingMode}
setReadingMode={(value) => ReaderService.updateSetting(manga, 'readingMode', value)}
isDefaultable
onDefault={() => deleteSetting('readingMode')}
/>
<ReaderSettingReadingDirection
readingDirection={readingDirection}
setReadingDirection={(value) => ReaderService.updateSetting(manga, 'readingDirection', value)}
isDefaultable
onDefault={() => deleteSetting('readingDirection')}
/>
<CheckboxInput
label={t('reader.settings.auto_scroll.title')}
checked={isActive}
onChange={() => toggleActive()}
/>
<ReaderSettingAutoScroll
autoScroll={autoScroll}
setAutoScroll={(...args) => ReaderService.updateSetting(manga, 'autoScroll', ...args)}
/>
</Stack>
);
};
export const ReaderBottomBarMobileQuickSettings = withPropsFrom(
memo(BaseReaderBottomBarMobileQuickSettings),
[useReaderStateMangaContext, ReaderService.useSettings, useReaderAutoScrollContext],
['manga', 'readingMode', 'readingDirection', 'autoScroll', 'isActive', 'toggleActive'],
);

View File

@@ -0,0 +1,39 @@
/*
* 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 { styled } from '@mui/material/styles';
import { shouldForwardProp } from '@/features/core/utils/ShouldForwardProp.ts';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { getProgressBarPositionInfo } from '@/features/reader/utils/ReaderProgressBar.utils.tsx';
type ProgressBarHighlightReadPagesProps = Pick<IReaderSettings, 'progressBarPosition'> & {
currentPagesIndex: number;
pagesLength: number;
};
export const ProgressBarHighlightReadPages = styled(Box, {
shouldForwardProp: shouldForwardProp<ProgressBarHighlightReadPagesProps>([
'currentPagesIndex',
'pagesLength',
'progressBarPosition',
]),
})<ProgressBarHighlightReadPagesProps>(({ currentPagesIndex, pagesLength, progressBarPosition }) => ({
position: 'absolute',
pointerEvents: 'none',
...applyStyles(getProgressBarPositionInfo(progressBarPosition).isHorizontal, {
left: 0,
width: `${((currentPagesIndex + 1) / pagesLength) * 100}%`,
height: '100%',
}),
...applyStyles(getProgressBarPositionInfo(progressBarPosition).isVertical, {
top: 0,
width: '100%',
height: `${((currentPagesIndex + 1) / pagesLength) * 100}%`,
}),
}));

View File

@@ -0,0 +1,263 @@
/*
* 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 { BoxProps } from '@mui/material/Box';
import { ComponentProps, memo, ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import { TypographyProps } from '@mui/material/Typography';
import { StackProps } from '@mui/material/Stack';
import { ReaderProgressBarProps, TReaderProgressBarContext } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { ReaderProgressBarPageNumber } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarPageNumber.tsx';
import { ReaderProgressBarContainer } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarContainer.tsx';
import { ReaderProgressBarRoot } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarRoot.tsx';
import { ReaderProgressBarSlotsContainer } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarSlotsContainer.tsx';
import { ProgressBarHighlightReadPages } from '@/features/reader/components/overlay/progress-bar/ProgressBarHighlightReadPages.tsx';
import { ReaderProgressBarCurrentPageSlot } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarCurrentPageSlot.tsx';
import {
getNextIndexFromPage,
getPage,
getPageForMousePos,
getProgressBarPositionInfo,
} from '@/features/reader/utils/ReaderProgressBar.utils.tsx';
import { getOptionForDirection as getOptionForDirectionImpl } from '@/features/theme/services/ThemeCreator.ts';
import { ReaderProgressBarSlotsActionArea } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarSlotsActionArea.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useReaderProgressBarContext } from '@/features/reader/contexts/ReaderProgressBarContext.tsx';
import { ReaderProgressBarSlotWrapper } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarSlotWrapper.tsx';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { IReaderSettings, ReadingMode } from '@/features/reader/types/Reader.types.ts';
const BaseReaderProgressBar = ({
totalPages,
pages,
pageLoadStates,
currentPageIndex,
slotProps,
slots,
createProgressBarSlot,
progressBarPosition,
readingMode,
isDragging,
setIsDragging,
openPage,
direction,
fullSegmentClicks,
}: ReaderProgressBarProps &
Pick<IReaderSettings, 'readingMode'> &
Pick<TReaderProgressBarContext, 'isDragging' | 'setIsDragging'> &
Pick<ComponentProps<typeof ReaderProgressBarSlotWrapper>, 'createProgressBarSlot'> & {
slotProps?: {
container?: StackProps;
progressBarRoot?: StackProps;
progressBarSlotsActionArea?: StackProps;
progressBarSlotsContainer?: StackProps;
progressBarSlot?: BoxProps;
progressBarReadPages?: BoxProps;
progressBarCurrentPageSlot?: BoxProps;
progressBarPageTexts?: {
base?: TypographyProps;
current?: TypographyProps;
total?: TypographyProps;
};
};
slots?: {
progressBarCurrentPage?: ReactNode;
};
openPage: ReturnType<typeof ReaderControls.useOpenPage>;
direction: ReturnType<typeof ReaderService.useGetThemeDirection>;
fullSegmentClicks: boolean;
}) => {
const progressBarRef = useRef<HTMLDivElement | null>(null);
const draggingDetectionTimeout = useRef<NodeJS.Timeout>(undefined);
const [totalPagesTextWidth, setTotalPagesTextWidth] = useState(0);
const totalPagesTextRef = useRef<HTMLSpanElement | null>(null);
useResizeObserver(
totalPagesTextRef,
useCallback(() => {
const element = totalPagesTextRef.current;
if (!element) {
return;
}
const { paddingLeft, paddingRight } = getComputedStyle(element);
const PAGE_NAME_SEPARATOR_LENGTH = 6; // <primaryPage>-<secondaryPage>
const widthOfTotalPageText = element.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight);
const newWidth =
readingMode === ReadingMode.DOUBLE_PAGE
? widthOfTotalPageText * 2 + PAGE_NAME_SEPARATOR_LENGTH
: widthOfTotalPageText;
setTotalPagesTextWidth(newWidth);
}, [readingMode]),
);
const currentPagesIndex = useMemo(() => getPage(currentPageIndex, pages).pagesIndex, [currentPageIndex, pages]);
const isHorizontalPosition = getProgressBarPositionInfo(progressBarPosition).isHorizontal;
const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]);
const getOptionForDirection = useCallback(
<T,>(...args: Parameters<typeof getOptionForDirectionImpl<T>>) =>
getOptionForDirectionImpl(args[0], args[1], direction),
[direction],
);
ReaderControls.useHandleProgressDragging(
openPage,
progressBarRef,
isDragging,
currentPage,
pages,
progressBarPosition,
getOptionForDirection,
fullSegmentClicks,
);
const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
if (!progressBarRef.current) {
return;
}
const isTouchEvent = 'touches' in e;
openPage(
getNextIndexFromPage(
getPageForMousePos(
isTouchEvent ? e.touches[0] : e,
progressBarRef.current,
pages,
isHorizontalPosition,
fullSegmentClicks,
getOptionForDirection,
),
),
undefined,
false,
);
clearTimeout(draggingDetectionTimeout.current);
draggingDetectionTimeout.current = setTimeout(() => setIsDragging(true), 250);
};
const handleMouseUp = () => {
setIsDragging(false);
clearTimeout(draggingDetectionTimeout.current);
};
return (
<ReaderProgressBarContainer {...slotProps?.container} progressBarPosition={progressBarPosition}>
<ReaderProgressBarRoot {...slotProps?.progressBarRoot}>
<ReaderProgressBarPageNumber
{...slotProps?.progressBarPageTexts?.base}
{...slotProps?.progressBarPageTexts?.current}
sx={[
{ width: `${totalPagesTextWidth}px` },
...(Array.isArray(slotProps?.progressBarPageTexts?.base?.sx)
? (slotProps?.progressBarPageTexts?.base?.sx ?? [])
: [slotProps?.progressBarPageTexts?.base?.sx]),
...(Array.isArray(slotProps?.progressBarPageTexts?.current?.sx)
? (slotProps?.progressBarPageTexts?.current?.sx ?? [])
: [slotProps?.progressBarPageTexts?.current?.sx]),
]}
onClick={() => openPage('previous', 'ltr', false)}
>
{currentPage.name}
</ReaderProgressBarPageNumber>
<ClickAwayListener onClickAway={() => setIsDragging(false)}>
<ReaderProgressBarSlotsActionArea
{...slotProps?.progressBarSlotsActionArea}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onTouchStart={handleMouseDown}
onTouchEnd={handleMouseUp}
>
<ReaderProgressBarSlotsContainer ref={progressBarRef} {...slotProps?.progressBarSlotsContainer}>
{pages.map((page, pagesIndex) => (
<ReaderProgressBarSlotWrapper
{...slotProps?.progressBarSlot}
key={page.primary.index}
page={page}
pagesIndex={pagesIndex}
isCurrentPage={pagesIndex === currentPagesIndex}
isLeadingPage={pagesIndex < currentPagesIndex}
isTrailingPage={pagesIndex > currentPagesIndex}
totalPages={pages.length}
primaryPageLoadState={pageLoadStates[page.primary.index].loaded}
secondaryPageLoadState={
page.secondary ? pageLoadStates[page.secondary.index].loaded : undefined
}
createProgressBarSlot={createProgressBarSlot}
showDraggingStyle={pagesIndex === currentPagesIndex && isDragging}
/>
))}
<ProgressBarHighlightReadPages
{...slotProps?.progressBarReadPages}
currentPagesIndex={currentPage.pagesIndex}
pagesLength={pages.length}
progressBarPosition={progressBarPosition}
/>
</ReaderProgressBarSlotsContainer>
<ReaderProgressBarCurrentPageSlot
boxProps={slotProps?.progressBarCurrentPageSlot}
pageName={currentPage.name}
currentPagesIndex={currentPage.pagesIndex}
pagesLength={pages.length}
isDragging={isDragging}
progressBarPosition={progressBarPosition}
>
{slots?.progressBarCurrentPage}
</ReaderProgressBarCurrentPageSlot>
</ReaderProgressBarSlotsActionArea>
</ClickAwayListener>
<ReaderProgressBarPageNumber
ref={totalPagesTextRef}
{...slotProps?.progressBarPageTexts?.base}
{...slotProps?.progressBarPageTexts?.total}
sx={[
{
minWidth: 'unset',
},
...(Array.isArray(slotProps?.progressBarPageTexts?.base?.sx)
? (slotProps?.progressBarPageTexts?.base?.sx ?? [])
: [slotProps?.progressBarPageTexts?.base?.sx]),
...(Array.isArray(slotProps?.progressBarPageTexts?.total?.sx)
? (slotProps?.progressBarPageTexts?.total?.sx ?? [])
: [slotProps?.progressBarPageTexts?.total?.sx]),
]}
onClick={() => openPage('next', 'ltr', false)}
>
{totalPages}
</ReaderProgressBarPageNumber>
</ReaderProgressBarRoot>
</ReaderProgressBarContainer>
);
};
export const ReaderProgressBar = withPropsFrom(
memo(BaseReaderProgressBar),
[
useReaderProgressBarContext,
() => ({ openPage: ReaderControls.useOpenPage() }),
userReaderStatePagesContext,
ReaderService.useSettingsWithoutDefaultFlag,
],
[
'isDragging',
'setIsDragging',
'openPage',
'pages',
'pageLoadStates',
'totalPages',
'currentPageIndex',
'readingMode',
],
);

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 Stack from '@mui/material/Stack';
import { styled } from '@mui/material/styles';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { shouldForwardProp } from '@/features/core/utils/ShouldForwardProp.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { getProgressBarPositionInfo } from '@/features/reader/utils/ReaderProgressBar.utils.tsx';
type ReaderProgressBarContainerProps = Pick<IReaderSettings, 'progressBarPosition'>;
export const ReaderProgressBarContainer = styled(Stack, {
shouldForwardProp: shouldForwardProp<ReaderProgressBarContainerProps>(['progressBarPosition']),
})<ReaderProgressBarContainerProps>(({ theme, progressBarPosition }) => ({
position: 'fixed',
pointerEvents: 'all',
touchAction: 'none',
...applyStyles(getProgressBarPositionInfo(progressBarPosition).isHorizontal, {
justifyContent: 'flex-end',
bottom: 0,
right: 0,
}),
...applyStyles(getProgressBarPositionInfo(progressBarPosition).isVertical, {
top: 0,
bottom: 0,
}),
...applyStyles(getProgressBarPositionInfo(progressBarPosition).isLeft, {
alignItems: theme.direction === 'ltr' ? 'flex-start' : 'flex-end',
}),
...applyStyles(getProgressBarPositionInfo(progressBarPosition).isRight, {
alignItems: theme.direction === 'ltr' ? 'flex-end' : 'flex-start',
}),
}));

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import Box from '@mui/material/Box';
import { ReactNode } from 'react';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { CurrentPageSlotProps } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { getProgressBarPositionInfo } from '@/features/reader/utils/ReaderProgressBar.utils.tsx';
import { READER_PROGRESS_BAR_POSITION_TO_PLACEMENT } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderProgressBarCurrentPageSlot = ({
pageName,
currentPagesIndex,
pagesLength,
isDragging,
boxProps,
children,
progressBarPosition,
}: CurrentPageSlotProps & { children?: ReactNode }) => (
<CustomTooltip
title={pageName}
slotProps={{
tooltip: { sx: { backgroundColor: 'primary.main', color: 'primary.contrastText' } },
}}
placement={READER_PROGRESS_BAR_POSITION_TO_PLACEMENT[progressBarPosition]}
>
<Box
{...boxProps}
sx={{
position: 'absolute',
cursor: isDragging ? 'grabbing' : 'grab',
...applyStyles(getProgressBarPositionInfo(progressBarPosition).isHorizontal, {
left: `${(Math.max(0, currentPagesIndex - 1) / (pagesLength - 1)) * 100}%`,
width: `calc(100% / ${pagesLength - 1})`,
height: '100%',
}),
...applyStyles(getProgressBarPositionInfo(progressBarPosition).isVertical, {
top: `${(Math.max(0, currentPagesIndex - 1) / (pagesLength - 1)) * 100}%`,
width: '100%',
height: `calc(100% / ${pagesLength - 1})`,
}),
...boxProps?.sx,
}}
>
{children}
</Box>
</CustomTooltip>
);

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { forwardRef, useMemo } from 'react';
import { CacheProvider } from '@emotion/react';
import { ThemeProvider } from '@mui/material/styles';
import Box, { BoxProps } from '@mui/material/Box';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { getTheme } from '@/features/theme/services/AppThemes.ts';
import { createTheme } from '@/features/theme/services/ThemeCreator.ts';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { DIRECTION_TO_CACHE } from '@/features/theme/ThemeDirectionCache.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
const BaseReaderProgressBarDirectionWrapper = forwardRef<
HTMLElement,
BoxProps & {
direction: ReturnType<typeof ReaderService.useGetThemeDirection>;
}
>(({ direction, ...boxProps }, ref) => {
const {
settings: { customThemes, appTheme, themeMode, shouldUsePureBlackMode },
} = useMetadataServerSettings();
const readerTheme = useMemo(
() => createTheme(themeMode, getTheme(appTheme, customThemes), shouldUsePureBlackMode, direction),
[themeMode, appTheme, customThemes, shouldUsePureBlackMode, direction],
);
return (
<CacheProvider value={DIRECTION_TO_CACHE[direction]}>
<ThemeProvider theme={readerTheme}>
<Box {...boxProps} ref={ref} dir={direction} />
</ThemeProvider>
</CacheProvider>
);
});
export const ReaderProgressBarDirectionWrapper = withPropsFrom(
BaseReaderProgressBarDirectionWrapper,
[() => ({ direction: ReaderService.useGetThemeDirection() })],
['direction'],
);

View File

@@ -0,0 +1,21 @@
/*
* 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 { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
export const ReaderProgressBarPageNumber = styled(Typography)({
boxSizing: 'content-box',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
textAlign: 'center',
userSelect: 'none',
cursor: 'pointer',
});

View File

@@ -0,0 +1,17 @@
/*
* 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 { styled } from '@mui/material/styles';
import Stack from '@mui/material/Stack';
export const ReaderProgressBarRoot = styled(Stack)(({ theme }) => ({
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: theme.spacing(0.5),
}));

View File

@@ -0,0 +1,28 @@
/*
* 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 { memo, ReactNode } from 'react';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { ReaderProgressBarSlotProps } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { READER_PROGRESS_BAR_POSITION_TO_PLACEMENT } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderProgressBarSlot = memo(
({ pageName, progressBarPosition, slotProps, children }: ReaderProgressBarSlotProps & { children?: ReactNode }) => (
<CustomTooltip
{...slotProps?.tooltip}
title={pageName}
placement={READER_PROGRESS_BAR_POSITION_TO_PLACEMENT[progressBarPosition]}
>
<Box {...slotProps?.box} sx={{ width: '100%', height: '100%', ...slotProps?.box?.sx }}>
{children}
</Box>
</CustomTooltip>
),
);

View File

@@ -0,0 +1,99 @@
/*
* 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, { BoxProps } from '@mui/material/Box';
import { memo, ReactNode, useMemo } from 'react';
import { styled } from '@mui/material/styles';
import { ReaderProgressBarProps } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { shouldForwardProp } from '@/features/core/utils/ShouldForwardProp.ts';
type StyledWrapperProps = {
isFirstPage: boolean;
isLastPage: boolean;
};
const StyledWrapper = memo(
styled(Box, {
shouldForwardProp: shouldForwardProp<StyledWrapperProps>(['isFirstPage', 'isLastPage']),
})<BoxProps & StyledWrapperProps>(({ isFirstPage, isLastPage }) => ({
flexGrow: 1,
height: '100%',
cursor: 'pointer',
borderLeftWidth: isFirstPage ? 0 : undefined,
borderRightWidth: isLastPage ? 0 : undefined,
})),
);
export const ReaderProgressBarSlotWrapper = memo(
({
page,
pagesIndex,
isCurrentPage,
isLeadingPage,
isTrailingPage,
totalPages,
showDraggingStyle,
primaryPageLoadState,
secondaryPageLoadState,
createProgressBarSlot,
...boxProps
}: {
page: ReaderProgressBarProps['pages'][number];
pagesIndex: number;
isCurrentPage: boolean;
isLeadingPage: boolean;
isTrailingPage: boolean;
totalPages: number;
showDraggingStyle: boolean;
primaryPageLoadState: ReaderProgressBarProps['pageLoadStates'][number]['loaded'];
secondaryPageLoadState: ReaderProgressBarProps['pageLoadStates'][number]['loaded'] | undefined;
createProgressBarSlot: (
page: ReaderProgressBarProps['pages'][number],
pagesIndex: number,
primaryPageLoadState: ReaderProgressBarProps['pageLoadStates'][number]['loaded'],
secondaryPageLoadState: ReaderProgressBarProps['pageLoadStates'][number]['loaded'] | undefined,
isCurrentPage: boolean,
isLeadingPage: boolean,
isTrailingPage: boolean,
totalPages: number,
handleDragging: boolean,
) => ReactNode;
} & BoxProps) => {
const slot = useMemo(
() =>
createProgressBarSlot(
page,
pagesIndex,
primaryPageLoadState,
secondaryPageLoadState,
isCurrentPage,
isLeadingPage,
isTrailingPage,
totalPages,
showDraggingStyle,
),
[
createProgressBarSlot,
page,
pagesIndex,
primaryPageLoadState,
secondaryPageLoadState,
isCurrentPage,
isLeadingPage,
isTrailingPage,
totalPages,
showDraggingStyle,
],
);
return (
<StyledWrapper {...boxProps} isFirstPage={pagesIndex === 0} isLastPage={pagesIndex === totalPages - 1}>
{slot}
</StyledWrapper>
);
},
);

View File

@@ -0,0 +1,16 @@
/*
* 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 from '@mui/material/Stack';
import { styled } from '@mui/material/styles';
export const ReaderProgressBarSlotsActionArea = styled(Stack)({
position: 'relative',
flexDirection: 'row',
flexGrow: 1,
});

View File

@@ -0,0 +1,16 @@
/*
* 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 { styled } from '@mui/material/styles';
import Stack from '@mui/material/Stack';
export const ReaderProgressBarSlotsContainer = styled(Stack)({
flexDirection: 'row',
flexGrow: 1,
overflow: 'hidden',
});

View File

@@ -0,0 +1,93 @@
/*
* 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 { alpha, darken, lighten, useTheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import { memo } from 'react';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { ReaderProgressBarSlot } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarSlot.tsx';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
export const ReaderProgressBarSlotDesktop = memo(
({
pageName,
primaryPageLoadState,
secondaryPageLoadState,
progressBarPosition,
isCurrentPage,
isLeadingPage,
showDraggingStyle,
}: Pick<IReaderSettings, 'progressBarPosition'> & {
pageName: string;
primaryPageLoadState: boolean;
secondaryPageLoadState?: boolean;
isCurrentPage: boolean;
isLeadingPage: boolean;
showDraggingStyle: boolean;
}) => {
const theme = useTheme();
return (
<ReaderProgressBarSlot
pageName={pageName}
progressBarPosition={progressBarPosition}
slotProps={{
box: {
sx: {
cursor: 'pointer',
backgroundColor: darken(theme.palette.background.paper, 0.2),
...theme.applyStyles('dark', {
backgroundColor: lighten(theme.palette.background.paper, 0.1),
}),
...applyStyles(
primaryPageLoadState && (secondaryPageLoadState == null || secondaryPageLoadState),
{
backgroundColor: darken(theme.palette.background.paper, 0.35),
...theme.applyStyles('dark', {
backgroundColor: lighten(theme.palette.background.paper, 0.25),
}),
},
),
},
},
tooltip: {
slotProps: isCurrentPage
? {
tooltip: {
sx: {
backgroundColor: 'primary.main',
color: 'primary.contrastText',
},
},
}
: undefined,
},
}}
>
<Box
sx={{
width: '100%',
height: '100%',
...applyStyles(isLeadingPage, {
backgroundColor: alpha(theme.palette.primary.main, 0.5),
}),
...applyStyles(isCurrentPage, {
cursor: showDraggingStyle ? 'grabbing' : 'grab',
pointer: 'grabbing',
borderRadius: 2,
backgroundColor: 'primary.dark',
...theme.applyStyles('dark', {
backgroundColor: 'primary.light',
}),
}),
}}
/>
</ReaderProgressBarSlot>
);
},
);

View File

@@ -0,0 +1,101 @@
/*
* 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 { ComponentProps, memo } from 'react';
import { ProgressBarPosition } from '@/features/reader/types/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { ReaderProgressBarSlot } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarSlot';
const SLOT_SX_PROP: NonNullable<NonNullable<ComponentProps<typeof ReaderProgressBarSlot>['slotProps']>['box']>['sx'] = {
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
position: 'relative',
backgroundColor: 'background.default',
};
const POINT_SIZE = '3px';
const ProgressBarPagePoint = memo(
({
isTrailingPage,
pagesIndex,
totalPages,
isVertical,
isHorizontal,
}: {
isTrailingPage: boolean;
pagesIndex: number;
totalPages: number;
isVertical: boolean;
isHorizontal: boolean;
}) => {
const position = `${(pagesIndex / (totalPages - 1)) * 100}%`;
return (
<Box
sx={{
position: 'absolute',
...applyStyles(isVertical, {
top: position,
left: '50%',
}),
...applyStyles(isHorizontal, {
top: '50%',
left: position,
}),
transform: 'translate(-50%, -50%)',
width: POINT_SIZE,
height: POINT_SIZE,
borderRadius: 100,
backgroundColor: 'background.paper',
...applyStyles(isTrailingPage, {
backgroundColor: 'primary.main',
}),
zIndex: 1,
}}
/>
);
},
);
export const ReaderProgressBarSlotMobile = memo(
({
pageName,
isTrailingPage,
isCurrentPage,
pagesIndex,
totalPages,
isVertical,
isHorizontal,
}: {
pageName: string;
isTrailingPage: boolean;
isCurrentPage: boolean;
pagesIndex: number;
totalPages: number;
isVertical: boolean;
isHorizontal: boolean;
}) => (
<ReaderProgressBarSlot
pageName={pageName}
progressBarPosition={ProgressBarPosition.BOTTOM}
slotProps={{ box: { sx: SLOT_SX_PROP } }}
>
{!isCurrentPage && (
<ProgressBarPagePoint
isTrailingPage={isTrailingPage}
pagesIndex={pagesIndex}
totalPages={totalPages}
isVertical={isVertical}
isHorizontal={isHorizontal}
/>
)}
</ReaderProgressBarSlot>
),
);

View File

@@ -0,0 +1,384 @@
/*
* 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 SkipPreviousIcon from '@mui/icons-material/SkipPrevious';
import SkipNextIcon from '@mui/icons-material/SkipNext';
import Stack from '@mui/material/Stack';
import { alpha } from '@mui/material/styles';
import Box from '@mui/material/Box';
import { ComponentProps, memo, useCallback, useLayoutEffect, useMemo, useState } from 'react';
import Slide, { SlideProps } from '@mui/material/Slide';
import { ReaderProgressBar } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBar.tsx';
import { useReaderStateChaptersContext } from '@/features/reader/contexts/state/ReaderStateChaptersContext.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { getPage, getProgressBarPositionInfo } from '@/features/reader/utils/ReaderProgressBar.utils.tsx';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import {
IReaderSettings,
ProgressBarPosition,
ReaderStateChapters,
TReaderScrollbarContext,
} from '@/features/reader/types/Reader.types.ts';
import { ReaderProgressBarDirectionWrapper } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarDirectionWrapper.tsx';
import { useReaderProgressBarContext } from '@/features/reader/contexts/ReaderProgressBarContext.tsx';
import { useReaderOverlayContext } from '@/features/reader/contexts/ReaderOverlayContext.tsx';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { TReaderOverlayContext } from '@/features/reader/types/ReaderOverlay.types.ts';
import { ReaderProgressBarProps, TReaderProgressBarContext } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { ReaderProgressBarSlotMobile } from '@/features/reader/components/overlay/progress-bar/mobile/ReaderProgressBarSlotMobile.tsx';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { getProgressBarPosition } from '@/features/reader/utils/ReaderSettings.utils.tsx';
import { useReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
const PROGRESS_BAR_POSITION_TO_SLIDE_DIRECTION: Record<ProgressBarPosition, SlideProps['direction']> = {
[ProgressBarPosition.BOTTOM]: 'up',
[ProgressBarPosition.LEFT]: 'right',
[ProgressBarPosition.RIGHT]: 'left',
// should never get accessed
[ProgressBarPosition.AUTO]: 'left',
};
const BaseMobileReaderProgressBar = ({
previousChapter,
nextChapter,
isVisible,
setIsMaximized,
isDragging,
currentPageIndex,
pages,
direction: readerDirection,
progressBarPosition,
progressBarPositionAutoVertical,
topOffset = 0,
bottomOffset = 0,
scrollbarXSize,
}: Pick<ReaderStateChapters, 'previousChapter' | 'nextChapter'> &
Pick<TReaderOverlayContext, 'isVisible'> &
Pick<TReaderProgressBarContext, 'setIsMaximized' | 'isDragging'> &
Pick<ReaderProgressBarProps, 'currentPageIndex' | 'pages'> &
Pick<IReaderSettings, 'progressBarPosition' | 'progressBarPositionAutoVertical'> &
Pick<TReaderScrollbarContext, 'scrollbarXSize'> & {
direction: ReturnType<typeof ReaderService.useGetThemeDirection>;
topOffset?: number;
bottomOffset?: number;
}) => {
const openChapter = ReaderControls.useOpenChapter();
const [, setRefreshProgressBarPosition] = useState({});
useResizeObserver(
window.document.documentElement,
useCallback(() => setRefreshProgressBarPosition({}), []),
);
const finalProgressBarPosition = getProgressBarPosition(
progressBarPosition,
progressBarPositionAutoVertical,
// scrollbar x size is already included in the top/bottom offset due to the progress bar being placed in the reader mobile bottom bar
topOffset + bottomOffset,
scrollbarXSize,
);
const { isLeft, isRight, isVertical, isHorizontal } = getProgressBarPositionInfo(finalProgressBarPosition);
const finalReaderDirection = isHorizontal ? readerDirection : 'ltr';
const currentPagesIndex = useMemo(() => getPage(currentPageIndex, pages).pagesIndex, [currentPageIndex, pages]);
const progressBarSlotProps: ComponentProps<typeof ReaderProgressBar>['slotProps'] = useMemo(
() => ({
container: {
sx: {
flexGrow: 1,
position: 'relative',
display: 'flex',
justifyItems: 'center',
alignItems: 'stretch',
backgroundColor: (theme) => alpha(theme.palette.background.paper, 0.85),
borderRadius: 100,
boxShadow: 2,
},
},
progressBarRoot: {
sx: {
flexGrow: 1,
gap: 0,
...applyStyles(isVertical, {
flexDirection: 'column',
}),
...applyStyles(isHorizontal, {
alignItems: 'stretch',
}),
},
},
progressBarSlotsActionArea: {
sx: {
alignItems: 'center',
cursor: 'pointer',
...applyStyles(isVertical, {
width: '100%',
flexDirection: 'column',
px: 2,
}),
...applyStyles(isHorizontal, {
height: '100%',
py: 2,
}),
},
},
progressBarSlotsContainer: {
sx: {
borderRadius: 100,
backgroundColor: 'background.default',
...applyStyles(isVertical, {
flexDirection: 'column',
py: 1,
}),
...applyStyles(isHorizontal, {
px: 1,
}),
},
},
progressBarSlot: {
sx: {
...applyStyles(isVertical, {
width: '20px',
}),
...applyStyles(isHorizontal, {
height: '20px',
}),
},
},
progressBarCurrentPageSlot: {
sx: {
display: 'flex',
zIndex: 1,
cursor: 'inherit',
...applyStyles(isVertical, {
justifyContent: 'center',
}),
...applyStyles(isHorizontal, {
alignItems: 'center',
}),
},
},
progressBarPageTexts: {
base: {
sx: {
...applyStyles(isVertical, {
py: 1,
}),
...applyStyles(isHorizontal, {
px: 1,
}),
},
},
},
}),
[isVertical, isHorizontal],
);
const progressBarCurrentPage = useMemo(
() => ({
progressBarCurrentPage: (
<Box
sx={{
position: 'absolute',
...applyStyles(isVertical, {
top: 'calc(100% - 6px)',
...applyStyles(currentPagesIndex === 0, {
top: '0',
}),
width: '75%',
height: '6px',
}),
...applyStyles(isHorizontal, {
left: 'calc(100% - 0px)',
...applyStyles(currentPagesIndex === 0, {
left: '0',
}),
width: '6px',
height: '75%',
}),
backgroundColor: 'primary.main',
borderRadius: 100,
cursor: isDragging ? 'grabbing' : 'grab',
}}
/>
),
}),
[currentPagesIndex, pages.length, isDragging, isVertical, isHorizontal],
);
useLayoutEffect(() => {
setIsMaximized(isVisible);
return () => setIsMaximized(false);
}, [isVisible]);
return (
<Slide direction={PROGRESS_BAR_POSITION_TO_SLIDE_DIRECTION[finalProgressBarPosition]} in={isVisible}>
<ReaderProgressBarDirectionWrapper
sx={{
position: 'relative',
display: 'flex',
alignItems: 'end',
height: '100%',
pointerEvents: 'none',
...applyStyles(isLeft, {
justifyContent: readerDirection === 'ltr' ? 'start' : 'end',
}),
...applyStyles(isRight, {
justifyContent: readerDirection === 'ltr' ? 'end' : 'start',
}),
}}
>
<Stack
sx={{
p: 2,
gap: 1,
pointerEvents: 'all',
alignItems: 'center',
...applyStyles(isVertical, {
height: '100%',
flexDirection: 'column',
}),
...applyStyles(isHorizontal, {
width: '100%',
flexDirection: 'row',
}),
}}
>
<IconButton
onClick={() => openChapter('previous')}
disabled={!previousChapter}
sx={{
backgroundColor: (theme) => alpha(theme.palette.background.paper, 0.85),
boxShadow: 2,
}}
>
{getOptionForDirection(
<SkipPreviousIcon
sx={{
...applyStyles(isVertical, {
transform: 'rotate(90deg)',
}),
}}
/>,
<SkipNextIcon
sx={{
...applyStyles(isVertical, {
transform: 'rotate(90deg)',
}),
}}
/>,
finalReaderDirection,
)}
</IconButton>
<ReaderProgressBar
direction={finalReaderDirection}
progressBarPosition={finalProgressBarPosition}
fullSegmentClicks={false}
createProgressBarSlot={useCallback(
(
page,
pagesIndex,
_primaryPageLoadState,
_secondaryPageLoadState,
isCurrentPage,
_isLeadingPage,
isTrailingPage,
totalPages,
) => (
<ReaderProgressBarSlotMobile
pageName={page.name}
isTrailingPage={isTrailingPage}
isCurrentPage={isCurrentPage}
pagesIndex={pagesIndex}
totalPages={totalPages}
isVertical={isVertical}
isHorizontal={isHorizontal}
/>
),
[isVertical, isHorizontal],
)}
slotProps={{
...progressBarSlotProps,
progressBarReadPages: {
sx: {
backgroundColor: 'primary.main',
...applyStyles(isVertical, {
width: '20px',
height: `${(Math.max(0, getPage(currentPageIndex, pages).pagesIndex) / (pages.length - 1)) * 100}%`,
borderRadius: '400px 400px 0 0',
}),
...applyStyles(isHorizontal, {
width: `${(Math.max(0, getPage(currentPageIndex, pages).pagesIndex) / (pages.length - 1)) * 100}%`,
height: '20px',
borderRadius: '400px 0 0 400px',
}),
},
},
}}
slots={progressBarCurrentPage}
/>
<IconButton
onClick={() => openChapter('next')}
disabled={!nextChapter}
sx={{ backgroundColor: (theme) => alpha(theme.palette.background.paper, 0.85), boxShadow: 2 }}
>
{getOptionForDirection(
<SkipNextIcon
sx={{
...applyStyles(isVertical, {
transform: 'rotate(90deg)',
}),
}}
/>,
<SkipPreviousIcon
sx={{
...applyStyles(isVertical, {
transform: 'rotate(90deg)',
}),
}}
/>,
finalReaderDirection,
)}
</IconButton>
</Stack>
</ReaderProgressBarDirectionWrapper>
</Slide>
);
};
export const MobileReaderProgressBar = withPropsFrom(
memo(BaseMobileReaderProgressBar),
[
useReaderStateChaptersContext,
useReaderOverlayContext,
useReaderProgressBarContext,
userReaderStatePagesContext,
() => ({ direction: ReaderService.useGetThemeDirection() }),
ReaderService.useSettingsWithoutDefaultFlag,
useReaderScrollbarContext,
],
[
'previousChapter',
'nextChapter',
'isVisible',
'setIsMaximized',
'isDragging',
'currentPageIndex',
'pages',
'direction',
'progressBarPosition',
'progressBarPositionAutoVertical',
'scrollbarXSize',
],
);

View File

@@ -0,0 +1,272 @@
/*
* 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 { memo, useCallback, useState } from 'react';
import { ReaderProgressBar } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBar.tsx';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { IReaderSettings, ProgressBarType, TReaderScrollbarContext } from '@/features/reader/types/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { getProgressBarPositionInfo } from '@/features/reader/utils/ReaderProgressBar.utils.tsx';
import { ReaderProgressBarDirectionWrapper } from '@/features/reader/components/overlay/progress-bar/ReaderProgressBarDirectionWrapper.tsx';
import { ReaderProgressBarProps, TReaderProgressBarContext } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { useReaderProgressBarContext } from '@/features/reader/contexts/ReaderProgressBarContext.tsx';
import { useReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
import { ReaderProgressBarSlotDesktop } from '@/features/reader/components/overlay/progress-bar/desktop/ReaderProgressBarSlotDesktop.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { getProgressBarPosition } from '@/features/reader/utils/ReaderSettings.utils.tsx';
const BaseStandardReaderProgressBar = ({
readerNavBarWidth,
isMaximized,
setIsMaximized,
isDragging,
progressBarType,
progressBarSize,
progressBarPosition,
progressBarPositionAutoVertical,
readerDirection,
scrollbarXSize,
scrollbarYSize,
totalPages,
}: Pick<NavbarContextType, 'readerNavBarWidth'> &
Pick<TReaderProgressBarContext, 'isMaximized' | 'setIsMaximized' | 'isDragging'> &
Pick<
IReaderSettings,
'progressBarType' | 'progressBarSize' | 'progressBarPosition' | 'progressBarPositionAutoVertical'
> &
Pick<TReaderScrollbarContext, 'scrollbarXSize' | 'scrollbarYSize'> &
Pick<ReaderProgressBarProps, 'totalPages'> & {
readerDirection: ReturnType<typeof ReaderService.useGetThemeDirection>;
}) => {
const theme = useTheme();
const [, setRefreshProgressBarPosition] = useState({});
useResizeObserver(
window.document.documentElement,
useCallback(() => setRefreshProgressBarPosition({}), []),
);
const finalProgressBarPosition = getProgressBarPosition(
progressBarPosition,
progressBarPositionAutoVertical,
scrollbarYSize,
readerNavBarWidth + scrollbarXSize,
);
const { isBottom, isLeft, isRight, isVertical, isHorizontal } =
getProgressBarPositionInfo(finalProgressBarPosition);
const arePagesLoaded = !!totalPages;
const isHidden = progressBarType === ProgressBarType.HIDDEN;
const isMinimized = !isMaximized && !isDragging;
// the progress bar uses the reading direction to set the themes direction, thus, stuff has to be adjusted to still be correctly positioned
// depending on which combination of theme direction and reading direction is currently active
return (
<ReaderProgressBarDirectionWrapper>
<ReaderProgressBar
direction={isHorizontal ? readerDirection : 'ltr'}
progressBarPosition={finalProgressBarPosition}
fullSegmentClicks
createProgressBarSlot={useCallback(
(
page,
_pagesIndex,
primaryPageLoadState,
secondaryPageLoadState,
isCurrentPage,
isLeadingPage,
_isTrailingPage,
_totalPages,
showDraggingStyle,
) => (
<ReaderProgressBarSlotDesktop
pageName={page.name}
primaryPageLoadState={primaryPageLoadState}
secondaryPageLoadState={secondaryPageLoadState}
progressBarPosition={finalProgressBarPosition}
isCurrentPage={isCurrentPage}
isLeadingPage={isLeadingPage}
showDraggingStyle={showDraggingStyle}
/>
),
[finalProgressBarPosition],
)}
slotProps={{
container: {
sx: {
overflow: 'hidden',
transition: `all 0.${theme.transitions.duration.shortest}s`,
...applyStyles(isHorizontal, {
minHeight: '100px',
...applyStyles(theme.direction === 'ltr', {
left: readerDirection === 'ltr' ? readerNavBarWidth : scrollbarYSize,
right: readerDirection === 'rtl' ? readerNavBarWidth : scrollbarYSize,
}),
...applyStyles(theme.direction === 'rtl', {
left: readerDirection === 'rtl' ? readerNavBarWidth : scrollbarYSize,
right: readerDirection === 'ltr' ? readerNavBarWidth : scrollbarYSize,
}),
}),
...applyStyles(isVertical, {
minWidth: '100px',
bottom: `${scrollbarXSize}px`,
}),
...applyStyles(isBottom, {
bottom: `${scrollbarXSize}px`,
}),
...applyStyles(isLeft, {
...applyStyles(theme.direction === 'ltr', {
left: readerDirection === 'ltr' ? readerNavBarWidth : 'unset',
right: readerDirection === 'rtl' ? readerNavBarWidth : 'unset',
}),
...applyStyles(theme.direction === 'rtl', {
right: readerDirection === 'rtl' ? scrollbarYSize : 'unset',
left: readerDirection === 'ltr' ? scrollbarYSize : 'unset',
}),
}),
...applyStyles(isRight, {
...applyStyles(theme.direction === 'ltr', {
right: readerDirection === 'ltr' ? scrollbarYSize : 'unset',
left: readerDirection === 'rtl' ? scrollbarYSize : 'unset',
}),
...applyStyles(theme.direction === 'rtl', {
left: readerDirection === 'rtl' ? readerNavBarWidth : 'unset',
right: readerDirection === 'ltr' ? readerNavBarWidth : 'unset',
}),
}),
...applyStyles(isMinimized, {
opacity: !isHidden ? 0.85 : 0,
}),
...applyStyles(!arePagesLoaded, {
pointerEvents: 'none',
}),
},
onMouseEnter: () => setIsMaximized(true),
onMouseLeave: () => setIsMaximized(false),
},
progressBarRoot: {
sx: {
gap: 0,
transition: 'all 0.1s ease-in-out',
backgroundColor: 'background.paper',
...applyStyles(isVertical, {
flexDirection: 'column',
height: '100%',
}),
...applyStyles(isMinimized, {
backgroundColor: 'unset',
pointerEvents: 'none',
...applyStyles(isHorizontal, {
px: 2,
}),
...applyStyles(isVertical, {
py: 2,
}),
...applyStyles(isBottom, {
pb: 0.25,
}),
...applyStyles(isLeft, {
...applyStyles(theme.direction === 'ltr', {
pl: readerDirection === 'ltr' ? 0.25 : 0,
pr: readerDirection === 'rtl' ? 0.25 : 0,
}),
...applyStyles(theme.direction === 'rtl', {
pr: readerDirection === 'rtl' ? 0.25 : 0,
pl: readerDirection === 'ltr' ? 0.25 : 0,
}),
}),
...applyStyles(isRight, {
...applyStyles(theme.direction === 'ltr', {
pr: readerDirection === 'ltr' ? 0.25 : 0,
pl: readerDirection === 'rtl' ? 0.25 : 0,
}),
...applyStyles(theme.direction === 'rtl', {
pl: readerDirection === 'rtl' ? 0.25 : 0,
pr: readerDirection === 'ltr' ? 0.25 : 0,
}),
}),
}),
},
},
progressBarSlotsContainer: {
sx: {
gap: 0.25,
borderRadius: 2,
transition: 'height 0.1s ease-in-out',
...applyStyles(isHorizontal, {
height: '20px',
}),
...applyStyles(isVertical, {
flexDirection: 'column',
width: '20px',
}),
...applyStyles(isMinimized, {
...applyStyles(isHorizontal, {
height: `${progressBarSize}px`,
}),
...applyStyles(isVertical, {
width: `${progressBarSize}px`,
}),
}),
},
},
progressBarReadPages: { sx: { display: 'none' } },
progressBarCurrentPageSlot: { sx: { display: 'none' } },
progressBarPageTexts: {
base: {
sx: {
px: 1,
py: 1.5,
transition: 'all 0.1s ease-in-out',
...applyStyles(isMinimized, {
transform: 'scale(0)',
p: 0,
minWidth: 0,
maxWidth: 0,
minHeight: 0,
maxHeight: 0,
}),
},
},
},
}}
/>
</ReaderProgressBarDirectionWrapper>
);
};
export const StandardReaderProgressBar = withPropsFrom(
memo(BaseStandardReaderProgressBar),
[
useNavBarContext,
useReaderProgressBarContext,
ReaderService.useSettingsWithoutDefaultFlag,
() => ({ readerDirection: ReaderService.useGetThemeDirection() }),
useReaderScrollbarContext,
userReaderStatePagesContext,
],
[
'readerNavBarWidth',
'isMaximized',
'setIsMaximized',
'isDragging',
'progressBarType',
'progressBarSize',
'progressBarPosition',
'progressBarPositionAutoVertical',
'readerDirection',
'scrollbarXSize',
'scrollbarYSize',
'totalPages',
],
);

View File

@@ -0,0 +1,27 @@
/*
* 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 from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
export const DefaultSettingFootnote = ({ areDefaultSettings }: { areDefaultSettings?: boolean }) => {
const { t } = useTranslation();
const isTouchDevice = MediaQuery.useIsTouchDevice();
if (!isTouchDevice || areDefaultSettings) {
return null;
}
return (
<Stack sx={{ alignItems: 'end' }}>
<Typography variant="caption">{t('reader.settings.default_setting_footnote')}</Typography>
</Stack>
);
};

View File

@@ -0,0 +1,62 @@
/*
* 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 Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import { useState } from 'react';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { ReaderSettingsTabs } from '@/features/reader/components/settings/ReaderSettingsTabs.tsx';
import { ReaderSettingTab } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { useDisableAllHotkeysWhileMounted } from '@/features/hotkeys/Hotkeys.utils.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
export const ReaderSettings = ({ isOpen, close }: { isOpen: boolean; close: () => void }) => {
const { manga } = useReaderStateMangaContext();
const settings = ReaderService.useSettings();
useDisableAllHotkeysWhileMounted(isOpen);
const [activeTab, setActiveTab] = useState(0);
const [isTransparent, setIsTransparent] = useState(false);
if (!manga) {
return null;
}
if (!isOpen) {
return null;
}
return (
<Dialog
open={isOpen}
maxWidth="md"
fullWidth
onClose={close}
hideBackdrop={activeTab === ReaderSettingTab.FILTER}
sx={{
...applyStyles(isTransparent, {
opacity: 0.75,
}),
}}
>
<DialogContent sx={{ p: 0 }}>
<ReaderSettingsTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
settings={settings}
updateSetting={(...args) => ReaderService.updateSetting(manga, ...args)}
deleteSetting={(...args) => ReaderService.deleteSetting(manga, ...args)}
setTransparent={setIsTransparent}
/>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,193 @@
/*
* 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 Tab from '@mui/material/Tab';
import { useTranslation } from 'react-i18next';
import { TabsMenu } from '@/features/core/components/tabs/TabsMenu.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
import { IReaderSettings, IReaderSettingsWithDefaultFlag } from '@/features/reader/types/Reader.types.ts';
import { useReaderTapZoneContext } from '@/features/reader/contexts/ReaderTapZoneContext.tsx';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { READER_SETTING_TABS, ReaderSettingTab } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { TabPanel } from '@/features/core/components/tabs/TabPanel.tsx';
import { ReaderLayoutSettings } from '@/features/reader/components/settings/layout/ReaderLayoutSettings.tsx';
import { ReaderGeneralSettings } from '@/features/reader/components/settings/general/ReaderGeneralSettings.tsx';
import { ReaderFilterSettings } from '@/features/reader/components/settings/filters/ReaderFilterSettings.tsx';
import { ReaderBehaviourSettings } from '@/features/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx';
import { ReaderDefaultLayoutSettings } from '@/features/reader/components/settings/layout/ReaderDefaultLayoutSettings.tsx';
import { ReaderHotkeysSettings } from '@/features/reader/components/settings/hotkeys/ReaderHotkeysSettings.tsx';
import { TReaderTapZoneContext } from '@/features/reader/types/TapZoneLayout.types.ts';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
const BaseReaderSettingsTabs = ({
activeTab,
setActiveTab,
areDefaultSettings,
settings,
updateSetting,
deleteSetting,
setShowPreview,
mode: overlayMode,
setTransparent,
}: Pick<TReaderTapZoneContext, 'setShowPreview'> &
Pick<ReturnType<typeof ReaderService.useOverlayMode>, 'mode'> & {
activeTab: number;
setActiveTab: (tab: number) => void;
settings: IReaderSettingsWithDefaultFlag;
updateSetting: (...args: OmitFirst<Parameters<typeof ReaderService.updateSetting>>) => void;
areDefaultSettings?: boolean;
deleteSetting: (setting: keyof IReaderSettings) => void;
setTransparent?: (transparent: boolean) => void;
}) => {
const { t } = useTranslation();
const isTouchDevice = MediaQuery.useIsTouchDevice();
return (
<>
<TabsMenu
value={activeTab}
onChange={(_, newTab) => setActiveTab(newTab)}
sx={{
...applyStyles(!!areDefaultSettings, { zIndex: 2 }),
...applyStyles(!areDefaultSettings, {
backgroundColor: 'background.paper',
backgroundImage: 'var(--Paper-overlay)',
}),
}}
>
{Object.values(READER_SETTING_TABS).map(({ id, label, supportsTouchDevices }) => {
if (!supportsTouchDevices && isTouchDevice) {
return null;
}
return (
<Tab
key={id}
value={id}
label={t(label)}
sx={{ flexGrow: 1, maxWidth: 'unset', textTransform: 'none' }}
/>
);
})}
</TabsMenu>
<Box sx={{ p: areDefaultSettings ? undefined : 2, overflowX: 'hidden' }}>
{Object.values(READER_SETTING_TABS).map(({ id, supportsTouchDevices }) => {
if (!supportsTouchDevices && isTouchDevice) {
return null;
}
switch (id as ReaderSettingTab) {
case ReaderSettingTab.LAYOUT:
if (areDefaultSettings) {
return (
<TabPanel key={id} index={id} currentIndex={activeTab}>
<ReaderDefaultLayoutSettings
readingMode={settings.readingMode}
updateSetting={(...args) => updateSetting(...args)}
/>
</TabPanel>
);
}
return (
<TabPanel key={id} index={id} currentIndex={activeTab}>
<ReaderLayoutSettings
settings={settings}
updateSetting={(...args) => updateSetting(...args)}
setShowPreview={setShowPreview!}
isDefaultable={!areDefaultSettings}
onDefault={(...args) => deleteSetting?.(...args)}
isSeriesMode
setTransparent={setTransparent}
/>
</TabPanel>
);
case ReaderSettingTab.GENERAL:
return (
<TabPanel
key={id}
index={id}
currentIndex={activeTab}
sx={{ p: areDefaultSettings ? 2 : undefined }}
>
<ReaderGeneralSettings
overlayMode={overlayMode}
settings={settings}
updateSetting={(...args) => updateSetting(...args)}
// @ts-expect-error - TS2322: Type boolean is not assignable to type true
isDefaultable={!areDefaultSettings}
onDefault={(...args) => deleteSetting?.(...args)}
/>
</TabPanel>
);
case ReaderSettingTab.FILTER:
return (
<TabPanel
key={id}
index={id}
currentIndex={activeTab}
sx={{ p: areDefaultSettings ? 2 : undefined }}
>
<ReaderFilterSettings
settings={settings}
updateSetting={(...args) => updateSetting(...args)}
isDefaultable
onDefault={(...args) => deleteSetting?.(...args)}
setTransparent={setTransparent}
/>
</TabPanel>
);
case ReaderSettingTab.BEHAVIOUR:
return (
<TabPanel
key={id}
index={id}
currentIndex={activeTab}
sx={{ p: areDefaultSettings ? 2 : undefined }}
>
<ReaderBehaviourSettings
settings={settings}
updateSetting={(...args) => updateSetting(...args)}
// @ts-expect-error - TS2322: Type boolean is not assignable to type true
isDefaultable={!areDefaultSettings}
onDefault={(...args) => deleteSetting?.(...args)}
/>
</TabPanel>
);
case ReaderSettingTab.HOTKEYS:
return (
<TabPanel
key={id}
index={id}
currentIndex={activeTab}
sx={{ p: areDefaultSettings ? 2 : undefined }}
>
<ReaderHotkeysSettings
settings={settings}
updateSetting={(...args) => updateSetting(...args)}
isDefaultable
onDefault={(...args) => deleteSetting?.(...args)}
/>
</TabPanel>
);
default:
throw new Error(`Unexpected "ReaderSettingTab" (${id})`);
}
})}
</Box>
</>
);
};
export const ReaderSettingsTabs = withPropsFrom(
BaseReaderSettingsTabs,
[useReaderTapZoneContext, ReaderService.useOverlayMode],
['setShowPreview', 'mode'],
);

View File

@@ -0,0 +1,165 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { IReaderSettingsWithDefaultFlag, ReaderSettingsTypeProps } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { ReaderSettingExitMode } from '@/features/reader/components/settings/behaviour/ReaderSettingExitMode.tsx';
import { isOffsetDoubleSpreadPagesEditable } from '@/features/reader/utils/ReaderSettings.utils.tsx';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import {
DEFAULT_READER_SETTINGS,
IMAGE_PRE_LOAD_AMOUNT,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { ReaderSettingAutoScroll } from '@/features/reader/components/settings/behaviour/ReaderSettingAutoScroll.tsx';
import { ReaderSettingScrollAmount } from '@/features/reader/components/settings/behaviour/ReaderSettingScrollAmount.tsx';
export const ReaderBehaviourSettings = ({
settings,
updateSetting,
onDefault,
isDefaultable,
}: {
settings: IReaderSettingsWithDefaultFlag;
updateSetting: (
...args: OmitFirst<Parameters<typeof ReaderService.updateSetting>>
) => ReturnType<typeof ReaderService.updateSetting>;
} & ReaderSettingsTypeProps) => {
const { t } = useTranslation();
return (
<Stack sx={{ gap: 2 }}>
<ReaderSettingExitMode
exitMode={settings.exitMode}
setExitMode={(value) => updateSetting('exitMode', value)}
/>
<ReaderSettingScrollAmount
scrollAmount={settings.scrollAmount}
setScrollAmount={(value, commit) => updateSetting('scrollAmount', value, commit)}
/>
<CheckboxInput
label={t('reader.settings.label.skip_dup_chapters')}
checked={settings.shouldSkipDupChapters}
onChange={(_, checked) => updateSetting('shouldSkipDupChapters', checked)}
/>
<CheckboxInput
label={
<Box>
<Typography>{t('reader.settings.label.skip_filtered_chapters')}</Typography>
{isDefaultable && (
<Typography variant="body2" color="textDisabled">
{t('reader.settings.label.unchangeable_in_reader')}
</Typography>
)}
</Box>
}
checked={settings.shouldSkipFilteredChapters}
onChange={(_, checked) => updateSetting('shouldSkipFilteredChapters', checked)}
disabled={isDefaultable}
/>
{isOffsetDoubleSpreadPagesEditable(settings.readingMode.value) && (
<CheckboxInput
label={t('reader.settings.label.offset_double_spread')}
checked={settings.shouldOffsetDoubleSpreads.value}
onChange={(_, checked) => updateSetting('shouldOffsetDoubleSpreads', checked)}
/>
)}
<CheckboxInput
label={
<Box>
<Typography>{t('reader.settings.infinite_scroll.title')}</Typography>
<Typography variant="body2" color="textDisabled">
{t('reader.settings.infinite_scroll.description')}
</Typography>
</Box>
}
checked={settings.shouldUseInfiniteScroll}
onChange={(_, checked) => updateSetting('shouldUseInfiniteScroll', checked)}
/>
<CheckboxInput
label={
<Box>
<Typography>{t('reader.settings.preview.reading_mode.title')}</Typography>
<Typography variant="body2" color="textDisabled">
{t('reader.settings.preview.reading_mode.description')}
</Typography>
</Box>
}
checked={settings.shouldShowReadingModePreview}
onChange={(_, checked) => updateSetting('shouldShowReadingModePreview', checked)}
/>
<CheckboxInput
label={
<Box>
<Typography>{t('reader.settings.preview.tap_zones.title')}</Typography>
<Typography variant="body2" color="textDisabled">
{t('reader.settings.preview.tap_zones.description')}
</Typography>
</Box>
}
checked={settings.shouldShowTapZoneLayoutPreview}
onChange={(_, checked) => updateSetting('shouldShowTapZoneLayoutPreview', checked)}
/>
<CheckboxInput
label={
<Box>
<Typography>{t('reader.settings.auto_webtoon_mode.title')}</Typography>
<Typography variant="body2" color="textDisabled">
{t('reader.settings.auto_webtoon_mode.description')}
</Typography>
</Box>
}
checked={settings.shouldUseAutoWebtoonMode}
onChange={(_, checked) => updateSetting('shouldUseAutoWebtoonMode', checked)}
/>
<CheckboxInput
label={t('reader.settings.show_transition_page')}
checked={settings.shouldShowTransitionPage}
onChange={(_, checked) => updateSetting('shouldShowTransitionPage', checked)}
/>
<CheckboxInput
label={t('reader.settings.chapter_transition.warning.missing_chapter')}
checked={settings.shouldInformAboutMissingChapter}
onChange={(_, checked) => updateSetting('shouldInformAboutMissingChapter', checked)}
/>
<CheckboxInput
label={t('reader.settings.chapter_transition.warning.scanlator_change')}
checked={settings.shouldInformAboutScanlatorChange}
onChange={(_, checked) => updateSetting('shouldInformAboutScanlatorChange', checked)}
/>
<ReaderSettingAutoScroll
autoScroll={settings.autoScroll}
setAutoScroll={(...args) => updateSetting('autoScroll', ...args)}
/>
<SliderInput
label={t('reader.settings.image_preload_amount')}
value={settings.imagePreLoadAmount}
onDefault={() => onDefault?.('imagePreLoadAmount')}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.imagePreLoadAmount,
value: settings.imagePreLoadAmount,
step: IMAGE_PRE_LOAD_AMOUNT.step,
min: IMAGE_PRE_LOAD_AMOUNT.min,
max: IMAGE_PRE_LOAD_AMOUNT.max,
onChange: (_, value) => {
updateSetting('imagePreLoadAmount', value as number, false);
},
onChangeCommitted: (_, value) => {
updateSetting('imagePreLoadAmount', value as number, true);
},
},
}}
/>
</Stack>
);
};

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { AUTO_SCROLL_SPEED, DEFAULT_READER_SETTINGS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingAutoScroll = ({
autoScroll,
setAutoScroll,
}: Pick<IReaderSettings, 'autoScroll'> & {
setAutoScroll: (updatedAutoScroll: IReaderSettings['autoScroll'], commit: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<>
<CheckboxInput
label={t('reader.settings.auto_scroll.smooth')}
checked={autoScroll.smooth}
onChange={(_, checked) => setAutoScroll({ ...autoScroll, smooth: checked }, true)}
/>
<SliderInput
label={t('reader.settings.auto_scroll.speed')}
value={t('global.time.seconds.value', { count: autoScroll.value })}
onDefault={() =>
setAutoScroll({ ...autoScroll, value: DEFAULT_READER_SETTINGS.autoScroll.value }, true)
}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.customFilter.saturate.value,
value: autoScroll.value,
...AUTO_SCROLL_SPEED,
onChange: (_, value) => {
setAutoScroll({ ...autoScroll, value: value as number }, false);
},
onChangeCommitted: (_, value) => {
setAutoScroll({ ...autoScroll, value: value as number }, true);
},
},
}}
/>
</>
);
};

View File

@@ -0,0 +1,44 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { IReaderSettings, ReaderExitMode } from '@/features/reader/types/Reader.types.ts';
import { ValueToDisplayData } from '@/features/core/Core.types.ts';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReaderExitMode> = {
[ReaderExitMode.PREVIOUS]: {
title: 'global.label.previous',
icon: null,
},
[ReaderExitMode.MANGA]: {
title: 'manga.title_one',
icon: null,
},
};
const READER_EXIT_MODE_VALUES = Object.values(ReaderExitMode).filter((value) => typeof value === 'number');
export const ReaderSettingExitMode = ({
exitMode,
setExitMode,
}: Pick<IReaderSettings, 'exitMode'> & {
setExitMode: (mode: ReaderExitMode) => void;
}) => {
const { t } = useTranslation();
return (
<ButtonSelectInput
label={t('reader.settings.exit_mode')}
value={exitMode}
values={READER_EXIT_MODE_VALUES}
setValue={setExitMode}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -0,0 +1,74 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { IReaderSettings, ReaderScrollAmount } from '@/features/reader/types/Reader.types.ts';
import { ValueToDisplayData } from '@/features/core/Core.types.ts';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { DEFAULT_READER_SETTINGS, SCROLL_AMOUNT } from '@/features/reader/constants/ReaderSettings.constants.tsx';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReaderScrollAmount> = {
[ReaderScrollAmount.TINY]: {
title: 'global.label.tiny',
icon: null,
},
[ReaderScrollAmount.SMALL]: {
title: 'global.label.small',
icon: null,
},
[ReaderScrollAmount.MEDIUM]: {
title: 'global.label.medium',
icon: null,
},
[ReaderScrollAmount.LARGE]: {
title: 'global.label.large',
icon: null,
},
};
const READER_SCROLL_AMOUNT_VALUES = Object.values(ReaderScrollAmount).filter((value) => typeof value === 'number');
export const ReaderSettingScrollAmount = ({
scrollAmount,
setScrollAmount,
}: Pick<IReaderSettings, 'scrollAmount'> & {
setScrollAmount: (amount: ReaderScrollAmount, commit: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<>
<ButtonSelectInput
label={t('reader.settings.scroll_amount')}
value={scrollAmount}
values={READER_SCROLL_AMOUNT_VALUES}
setValue={(value) => setScrollAmount(value as number, true)}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
<SliderInput
label={t('reader.settings.label.custom_scroll_amount')}
value={t('global.value', { value: scrollAmount, unit: '%' })}
onDefault={() => setScrollAmount(DEFAULT_READER_SETTINGS.scrollAmount, true)}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.scrollAmount,
value: scrollAmount,
...SCROLL_AMOUNT,
onChange: (_, value) => {
setScrollAmount(value as number, false);
},
onChangeCommitted: (_, value) => {
setScrollAmount(value as number, true);
},
},
}}
/>
</>
);
};

View File

@@ -0,0 +1,126 @@
/*
* 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 from '@mui/material/Stack';
import { ReaderSettingBrightness } from '@/features/reader/components/settings/filters/ReaderSettingBrightness.tsx';
import { ReaderSettingContrast } from '@/features/reader/components/settings/filters/ReaderSettingContrast.tsx';
import { ReaderSettingSaturate } from '@/features/reader/components/settings/filters/ReaderSettingSaturate.tsx';
import { ReaderSettingHue } from '@/features/reader/components/settings/filters/ReaderSettingHue.tsx';
import { ReaderSettingRGBA } from '@/features/reader/components/settings/filters/ReaderSettingRGBA.tsx';
import { ReaderSettingSepia } from '@/features/reader/components/settings/filters/ReaderSettingSepia.tsx';
import { ReaderSettingGrayscale } from '@/features/reader/components/settings/filters/ReaderSettingGrayscale.tsx';
import { ReaderSettingInvert } from '@/features/reader/components/settings/filters/ReaderSettingInvert.tsx';
import { ReaderSettingsTypeProps } from '@/features/reader/types/Reader.types.ts';
import { ResetButton } from '@/features/core/components/buttons/ResetButton.tsx';
export const ReaderFilterSettings = ({
settings: { customFilter },
updateSetting,
onDefault,
setTransparent,
}: ReaderSettingsTypeProps) => {
const update = (value: any, commit?: boolean) => {
updateSetting('customFilter', value, commit);
setTransparent?.(commit === undefined ? false : !commit);
};
return (
<Stack sx={{ gap: 2 }}>
<ReaderSettingBrightness
brightness={customFilter.brightness}
updateSetting={(key, value, commit) =>
update(
{
...customFilter,
[key]: value,
},
commit,
)
}
/>
<ReaderSettingContrast
contrast={customFilter.contrast}
updateSetting={(key, value, commit) =>
update(
{
...customFilter,
[key]: value,
},
commit,
)
}
/>
<ReaderSettingSaturate
saturate={customFilter.saturate}
updateSetting={(key, value, commit) =>
update(
{
...customFilter,
[key]: value,
},
commit,
)
}
/>
<ReaderSettingHue
hue={customFilter.hue}
updateSetting={(key, value, commit) =>
update(
{
...customFilter,
[key]: value,
},
commit,
)
}
/>
<ReaderSettingRGBA
rgba={customFilter.rgba}
updateSetting={(key, value, commit) =>
update(
{
...customFilter,
[key]: value,
},
commit,
)
}
/>
<ReaderSettingSepia
sepia={customFilter.sepia}
updateSetting={(value) =>
update({
...customFilter,
sepia: value,
})
}
/>
<ReaderSettingGrayscale
grayscale={customFilter.grayscale}
updateSetting={(value) =>
update({
...customFilter,
grayscale: value,
})
}
/>
<ReaderSettingInvert
invert={customFilter.invert}
updateSetting={(value) =>
update({
...customFilter,
invert: value,
})
}
/>
<Stack sx={{ alignItems: 'end' }}>
<ResetButton onClick={() => onDefault?.('customFilter')} variant="outlined" />
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,66 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { CUSTOM_FILTER, DEFAULT_READER_SETTINGS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingBrightness = ({
brightness,
updateSetting,
}: Pick<IReaderSettings['customFilter'], 'brightness'> & {
updateSetting: <Filter extends keyof IReaderSettings['customFilter']>(
filter: Filter,
value: IReaderSettings['customFilter'][Filter],
commit: boolean,
) => void;
}) => {
const { t } = useTranslation();
return (
<Stack>
<CheckboxInput
label={t('reader.settings.custom_filter.brightness')}
checked={brightness.enabled}
onChange={(_, checked) => updateSetting('brightness', { ...brightness, enabled: checked }, true)}
/>
{brightness.enabled && (
<SliderInput
label={t('reader.settings.custom_filter.brightness')}
value={brightness.value}
onDefault={() =>
updateSetting(
'brightness',
{ ...brightness, value: DEFAULT_READER_SETTINGS.customFilter.brightness.value },
true,
)
}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.customFilter.brightness.value,
value: brightness.value,
step: CUSTOM_FILTER.brightness.step,
min: CUSTOM_FILTER.brightness.min,
max: CUSTOM_FILTER.brightness.max,
onChange: (_, value) => {
updateSetting('brightness', { ...brightness, value: value as number }, false);
},
onChangeCommitted: (_, value) => {
updateSetting('brightness', { ...brightness, value: value as number }, true);
},
},
}}
/>
)}
</Stack>
);
};

View File

@@ -0,0 +1,65 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { CUSTOM_FILTER, DEFAULT_READER_SETTINGS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingContrast = ({
contrast,
updateSetting,
}: Pick<IReaderSettings['customFilter'], 'contrast'> & {
updateSetting: <Filter extends keyof IReaderSettings['customFilter']>(
filter: Filter,
value: IReaderSettings['customFilter'][Filter],
commit: boolean,
) => void;
}) => {
const { t } = useTranslation();
return (
<Stack>
<CheckboxInput
label={t('reader.settings.custom_filter.contrast')}
checked={contrast.enabled}
onChange={(_, checked) => updateSetting('contrast', { ...contrast, enabled: checked }, true)}
/>
{contrast.enabled && (
<SliderInput
label={t('reader.settings.custom_filter.contrast')}
value={contrast.value}
onDefault={() =>
updateSetting(
'contrast',
{ ...contrast, value: DEFAULT_READER_SETTINGS.customFilter.contrast.value },
true,
)
}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.customFilter.contrast.value,
value: contrast.value,
step: CUSTOM_FILTER.contrast.step,
min: CUSTOM_FILTER.contrast.min,
max: CUSTOM_FILTER.contrast.max,
onChange: (_, newValue) => {
updateSetting('contrast', { ...contrast, value: newValue as number }, false);
},
onChangeCommitted: (_, newValue) => {
updateSetting('contrast', { ...contrast, value: newValue as number }, true);
},
},
}}
/>
)}
</Stack>
);
};

View File

@@ -0,0 +1,31 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
export const ReaderSettingGrayscale = ({
grayscale,
updateSetting,
}: Pick<IReaderSettings['customFilter'], 'grayscale'> & {
updateSetting: (grayscale: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<Stack>
<CheckboxInput
label={t('reader.settings.custom_filter.grayscale')}
checked={grayscale}
onChange={(_, checked) => updateSetting(checked)}
/>
</Stack>
);
};

View File

@@ -0,0 +1,61 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { CUSTOM_FILTER, DEFAULT_READER_SETTINGS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingHue = ({
hue,
updateSetting,
}: Pick<IReaderSettings['customFilter'], 'hue'> & {
updateSetting: <Filter extends keyof IReaderSettings['customFilter']>(
filter: Filter,
value: IReaderSettings['customFilter'][Filter],
commit: boolean,
) => void;
}) => {
const { t } = useTranslation();
return (
<Stack>
<CheckboxInput
label={t('reader.settings.custom_filter.hue')}
checked={hue.enabled}
onChange={(_, checked) => updateSetting('hue', { ...hue, enabled: checked }, true)}
/>
{hue.enabled && (
<SliderInput
label={t('reader.settings.custom_filter.hue')}
value={hue.value}
onDefault={() =>
updateSetting('hue', { ...hue, value: DEFAULT_READER_SETTINGS.customFilter.hue.value }, true)
}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.customFilter.hue.value,
value: hue.value,
step: CUSTOM_FILTER.hue.step,
min: CUSTOM_FILTER.hue.min,
max: CUSTOM_FILTER.hue.max,
onChange: (_, newValue) => {
updateSetting('hue', { ...hue, value: newValue as number }, false);
},
onChangeCommitted: (_, newValue) => {
updateSetting('hue', { ...hue, value: newValue as number }, true);
},
},
}}
/>
)}
</Stack>
);
};

View File

@@ -0,0 +1,31 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
export const ReaderSettingInvert = ({
invert,
updateSetting,
}: Pick<IReaderSettings['customFilter'], 'invert'> & {
updateSetting: (invert: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<Stack>
<CheckboxInput
label={t('reader.settings.custom_filter.invert')}
checked={invert}
onChange={(_, checked) => updateSetting(checked)}
/>
</Stack>
);
};

View File

@@ -0,0 +1,123 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { TranslationKey } from '@/Base.types.ts';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import {
CUSTOM_FILTER,
DEFAULT_READER_SETTINGS,
READER_BLEND_MODE_VALUE_TO_DISPLAY_DATA,
READER_BLEND_MODE_VALUES,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
type RGBAType = Exclude<keyof IReaderSettings['customFilter']['rgba']['value'], 'blendMode'>;
const RGBA_TYPE_TO_TRANSLATION_KEY: Record<RGBAType, TranslationKey> = {
red: 'reader.settings.custom_filter.rgba.red',
green: 'reader.settings.custom_filter.rgba.green',
blue: 'reader.settings.custom_filter.rgba.blue',
alpha: 'reader.settings.custom_filter.rgba.alpha',
};
export const ReaderSettingRGBA = ({
rgba,
updateSetting,
}: Pick<IReaderSettings['customFilter'], 'rgba'> & {
updateSetting: <Filter extends keyof IReaderSettings['customFilter']>(
filter: Filter,
value: IReaderSettings['customFilter'][Filter],
commit: boolean,
) => void;
}) => {
const { t } = useTranslation();
return (
<Stack>
<CheckboxInput
label={t('reader.settings.custom_filter.rgba.title')}
checked={rgba.enabled}
onChange={(_, checked) => updateSetting('rgba', { ...rgba, enabled: checked }, true)}
/>
{rgba.enabled && (
<Stack sx={{ gap: 1 }}>
{Object.entries(rgba.value).map(([key, value]) => {
if (key === 'blendMode') {
return null;
}
return (
<SliderInput
key={key}
label={t(RGBA_TYPE_TO_TRANSLATION_KEY[key as RGBAType])}
value={value}
onDefault={() =>
updateSetting(
'rgba',
{
...rgba,
value: {
...rgba.value,
[key]: DEFAULT_READER_SETTINGS.customFilter.rgba.value[key as RGBAType],
},
},
true,
)
}
slotProps={{
slider: {
value,
defaultValue: DEFAULT_READER_SETTINGS.customFilter.rgba.value[key as RGBAType],
step: CUSTOM_FILTER.rgba[key as RGBAType].step,
min: CUSTOM_FILTER.rgba[key as RGBAType].min,
max: CUSTOM_FILTER.rgba[key as RGBAType].max,
onChange: (_, newValue) => {
updateSetting(
'rgba',
{ ...rgba, value: { ...rgba.value, [key]: newValue } },
false,
);
},
onChangeCommitted: (_, newValue) => {
updateSetting(
'rgba',
{ ...rgba, value: { ...rgba.value, [key]: newValue } },
true,
);
},
},
}}
/>
);
})}
<ButtonSelectInput
label={t('reader.settings.custom_filter.rgba.blend_mode.title')}
value={rgba.value.blendMode}
values={READER_BLEND_MODE_VALUES}
setValue={(value) =>
updateSetting(
'rgba',
{
...rgba,
value: { ...rgba.value, blendMode: value },
},
true,
)
}
valueToDisplayData={READER_BLEND_MODE_VALUE_TO_DISPLAY_DATA}
/>
</Stack>
)}
</Stack>
);
};

View File

@@ -0,0 +1,65 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { CUSTOM_FILTER, DEFAULT_READER_SETTINGS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingSaturate = ({
saturate,
updateSetting,
}: Pick<IReaderSettings['customFilter'], 'saturate'> & {
updateSetting: <Filter extends keyof IReaderSettings['customFilter']>(
filter: Filter,
value: IReaderSettings['customFilter'][Filter],
commit: boolean,
) => void;
}) => {
const { t } = useTranslation();
return (
<Stack>
<CheckboxInput
label={t('reader.settings.custom_filter.saturate')}
checked={saturate.enabled}
onChange={(_, checked) => updateSetting('saturate', { ...saturate, enabled: checked }, true)}
/>
{saturate.enabled && (
<SliderInput
label={t('reader.settings.custom_filter.saturate')}
value={saturate.value}
onDefault={() =>
updateSetting(
'saturate',
{ ...saturate, value: DEFAULT_READER_SETTINGS.customFilter.saturate.value },
true,
)
}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.customFilter.saturate.value,
value: saturate.value,
step: CUSTOM_FILTER.saturate.step,
min: CUSTOM_FILTER.saturate.min,
max: CUSTOM_FILTER.saturate.max,
onChange: (_, value) => {
updateSetting('saturate', { ...saturate, value: value as number }, false);
},
onChangeCommitted: (_, value) => {
updateSetting('saturate', { ...saturate, value: value as number }, true);
},
},
}}
/>
)}
</Stack>
);
};

View File

@@ -0,0 +1,31 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
export const ReaderSettingSepia = ({
sepia,
updateSetting,
}: Pick<IReaderSettings['customFilter'], 'sepia'> & {
updateSetting: (sepia: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<Stack>
<CheckboxInput
label={t('reader.settings.custom_filter.sepia')}
checked={sepia}
onChange={(_, checked) => updateSetting(checked)}
/>
</Stack>
);
};

View File

@@ -0,0 +1,68 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { ReaderSettingProgressBarType } from '@/features/reader/components/settings/general/ReaderSettingProgressBarType.tsx';
import { ReaderSettingProgressBarSize } from '@/features/reader/components/settings/general/ReaderSettingProgressBarSize.tsx';
import { ReaderSettingProgressBarPosition } from '@/features/reader/components/settings/general/ReaderSettingProgressBarPosition.tsx';
import {
IReaderSettings,
ProgressBarType,
ReaderOverlayMode,
ReaderSettingsTypeProps,
} from '@/features/reader/types/Reader.types.ts';
import { ReaderSettingOverlayMode } from '@/features/reader/components/settings/general/ReaderSettingOverlayMode.tsx';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { ReaderSettingBackgroundColor } from '@/features/reader/components/settings/general/ReaderSettingBackgroundColor.tsx';
export const ReaderGeneralSettings = ({
overlayMode,
settings,
updateSetting,
onDefault,
}: Pick<IReaderSettings, 'overlayMode'> & ReaderSettingsTypeProps) => {
const { t } = useTranslation();
return (
<Stack sx={{ gap: 2 }}>
<ReaderSettingOverlayMode
overlayMode={settings.overlayMode}
setOverlayMode={(value) => updateSetting('overlayMode', value)}
/>
<ReaderSettingBackgroundColor
backgroundColor={settings.backgroundColor}
updateSetting={(value) => updateSetting('backgroundColor', value)}
/>
<ReaderSettingProgressBarType
overlayMode={overlayMode}
progressBarType={settings.progressBarType}
setProgressBarType={(value) => updateSetting('progressBarType', value)}
/>
<ReaderSettingProgressBarSize
overlayMode={overlayMode}
progressBarType={settings.progressBarType}
progressBarSize={settings.progressBarSize}
setProgressBarSize={(...args) => updateSetting('progressBarSize', ...args)}
onDefault={() => onDefault?.('progressBarSize')}
/>
<ReaderSettingProgressBarPosition
progressBarPosition={settings.progressBarPosition}
progressBarPositionAutoVertical={settings.progressBarPositionAutoVertical}
updateSetting={updateSetting}
/>
{(settings.progressBarType === ProgressBarType.HIDDEN || overlayMode === ReaderOverlayMode.MOBILE) && (
<CheckboxInput
label={t('reader.settings.label.show_page_number')}
checked={settings.shouldShowPageNumber}
onChange={(_, checked) => updateSetting('shouldShowPageNumber', checked)}
/>
)}
</Stack>
);
};

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { ValueToDisplayData } from '@/features/core/Core.types.ts';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
import { IReaderSettingsWithDefaultFlag, ReaderBackgroundColor } from '@/features/reader/types/Reader.types.ts';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReaderBackgroundColor> = {
[ReaderBackgroundColor.THEME]: {
title: 'settings.appearance.theme.title',
icon: null,
},
[ReaderBackgroundColor.BLACK]: {
title: 'global.colors.black',
icon: null,
},
[ReaderBackgroundColor.GRAY]: {
title: 'global.colors.gray',
icon: null,
},
[ReaderBackgroundColor.WHITE]: {
title: 'global.colors.white',
icon: null,
},
};
const READER_BACKGROUND_COLOR_VALUES = Object.values(ReaderBackgroundColor).filter(
(value) => typeof value === 'number',
);
export const ReaderSettingBackgroundColor = ({
backgroundColor,
updateSetting,
}: Pick<IReaderSettingsWithDefaultFlag, 'backgroundColor'> & {
updateSetting: (color: ReaderBackgroundColor) => void;
}) => {
const { t } = useTranslation();
return (
<ButtonSelectInput
label={t('reader.settings.background_color')}
value={backgroundColor}
values={READER_BACKGROUND_COLOR_VALUES}
setValue={updateSetting}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import PhoneIphoneIcon from '@mui/icons-material/PhoneIphone';
import ComputerIcon from '@mui/icons-material/Computer';
import AutoModeIcon from '@mui/icons-material/AutoMode';
import { IReaderSettings, ReaderOverlayMode } from '@/features/reader/types/Reader.types.ts';
import { ValueToDisplayData } from '@/features/core/Core.types.ts';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReaderOverlayMode> = {
[ReaderOverlayMode.AUTO]: {
title: 'global.label.auto',
icon: <AutoModeIcon />,
},
[ReaderOverlayMode.DESKTOP]: {
title: 'global.label.desktop',
icon: <ComputerIcon />,
},
[ReaderOverlayMode.MOBILE]: {
title: 'global.label.mobile',
icon: <PhoneIphoneIcon />,
},
};
const READING_MODE_VALUES = Object.values(ReaderOverlayMode).filter((value) => typeof value === 'number');
export const ReaderSettingOverlayMode = ({
overlayMode,
setOverlayMode,
}: Pick<IReaderSettings, 'overlayMode'> & {
setOverlayMode: (mode: ReaderOverlayMode) => void;
}) => {
const { t } = useTranslation();
return (
<ButtonSelectInput
label={t('reader.settings.overlay_mode')}
value={overlayMode}
values={READING_MODE_VALUES}
setValue={setOverlayMode}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -0,0 +1,82 @@
/*
* 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 { useTranslation } from 'react-i18next';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import AutoModeIcon from '@mui/icons-material/AutoMode';
import {
IReaderSettings,
ProgressBarPosition,
ProgressBarPositionAutoVertical,
} from '@/features/reader/types/Reader.types.ts';
import { ValueToDisplayData } from '@/features/core/Core.types.ts';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ProgressBarPosition> = {
[ProgressBarPosition.AUTO]: {
title: 'global.label.auto',
icon: <AutoModeIcon />,
},
[ProgressBarPosition.BOTTOM]: {
title: 'global.label.bottom',
icon: <ArrowBackIosNewIcon sx={{ transform: 'rotate(90deg)' }} />,
},
[ProgressBarPosition.LEFT]: {
title: 'global.label.left',
icon: <ArrowForwardIosIcon />,
},
[ProgressBarPosition.RIGHT]: {
title: 'global.label.right',
icon: <ArrowBackIosNewIcon />,
},
};
const PROGRESS_BAR_POSITION_VALUES = Object.values(ProgressBarPosition).filter((value) => typeof value === 'number');
const PROGRESS_BAR_AUTO_VERTICAL_POSITION_VALUES = Object.values(
ProgressBarPositionAutoVertical,
) as unknown as TupleUnion<keyof typeof ProgressBarPositionAutoVertical>;
export const ReaderSettingProgressBarPosition = ({
progressBarPosition,
progressBarPositionAutoVertical,
updateSetting,
}: Pick<IReaderSettings, 'progressBarPosition' | 'progressBarPositionAutoVertical'> & {
updateSetting: <
Position extends keyof Pick<IReaderSettings, 'progressBarPosition' | 'progressBarPositionAutoVertical'>,
>(
filter: Position,
value: IReaderSettings[Position],
) => void;
}) => {
const { t } = useTranslation();
const isAutoPosition = progressBarPosition === ProgressBarPosition.AUTO;
return (
<>
<ButtonSelectInput
label={t('reader.settings.progress_bar.position')}
value={progressBarPosition}
values={PROGRESS_BAR_POSITION_VALUES}
setValue={(position) => updateSetting('progressBarPosition', position)}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
{isAutoPosition && (
<ButtonSelectInput
label={t('reader.settings.progress_bar.auto_vertical_position.title')}
value={progressBarPositionAutoVertical}
values={PROGRESS_BAR_AUTO_VERTICAL_POSITION_VALUES}
setValue={(position) => updateSetting('progressBarPositionAutoVertical', position)}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
)}
</>
);
};

View File

@@ -0,0 +1,53 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { IReaderSettings, ProgressBarType, ReaderOverlayMode } from '@/features/reader/types/Reader.types.ts';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { AUTO_SCROLL_SPEED, DEFAULT_READER_SETTINGS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingProgressBarSize = ({
overlayMode,
progressBarType,
progressBarSize,
setProgressBarSize,
onDefault,
}: Pick<IReaderSettings, 'progressBarType' | 'progressBarSize' | 'overlayMode'> & {
setProgressBarSize: (size: number, commit: boolean) => void;
onDefault: () => void;
}) => {
const { t } = useTranslation();
const isChangeable = overlayMode === ReaderOverlayMode.DESKTOP && progressBarType === ProgressBarType.STANDARD;
if (!isChangeable) {
return null;
}
return (
<SliderInput
label={t('reader.settings.progress_bar.size')}
value={t('global.value', { value: progressBarSize, unit: t('global.unit.px') })}
onDefault={onDefault}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.progressBarSize,
value: progressBarSize,
step: AUTO_SCROLL_SPEED.step,
min: AUTO_SCROLL_SPEED.min,
max: AUTO_SCROLL_SPEED.max,
onChange: (_, value) => {
setProgressBarSize(value as number, false);
},
onChangeCommitted: (_, value) => {
setProgressBarSize(value as number, true);
},
},
}}
/>
);
};

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTranslation } from 'react-i18next';
import { IReaderSettings, ProgressBarType, ReaderOverlayMode } from '@/features/reader/types/Reader.types.ts';
import { ValueToDisplayData } from '@/features/core/Core.types.ts';
import { HiddenProgressBarIcon } from '@/assets/icons/svg/HiddenProgressBarIcon.tsx';
import { StandardProgressBarIcon } from '@/assets/icons/svg/StandardProgressBarIcon.tsx';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ProgressBarType> = {
[ProgressBarType.HIDDEN]: {
title: 'global.label.hidden',
icon: <HiddenProgressBarIcon />,
},
[ProgressBarType.STANDARD]: {
title: 'global.label.standard',
icon: <StandardProgressBarIcon />,
},
};
const PROGRESS_BAR_TYPE_VALUES = Object.values(ProgressBarType).filter((value) => typeof value === 'number');
export const ReaderSettingProgressBarType = ({
overlayMode,
progressBarType,
setProgressBarType,
}: Pick<IReaderSettings, 'progressBarType' | 'overlayMode'> & {
setProgressBarType: (progressBarType: ProgressBarType) => void;
}) => {
const { t } = useTranslation();
if (overlayMode !== ReaderOverlayMode.DESKTOP) {
return null;
}
return (
<ButtonSelectInput
label={t('reader.settings.progress_bar.style')}
value={progressBarType}
values={PROGRESS_BAR_TYPE_VALUES}
setValue={setProgressBarType}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -0,0 +1,45 @@
/*
* 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 { Fragment } from 'react';
import Stack from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { Kbd } from '@/features/core/components/texts/Kbd.tsx';
export const Hotkey = ({ keys, removeKey }: { keys: string[]; removeKey?: (key: string) => void }) => {
const { t } = useTranslation();
return (
<Stack sx={{ flexDirection: 'row', flexWrap: 'wrap', gap: 1 }}>
{keys.map((key, index) => (
<Fragment key={key}>
<CustomTooltip title={t('global.button.delete')} hidden={!removeKey}>
<Stack
sx={{
flexDirection: 'row',
flexWrap: 'wrap',
gap: 0.5,
cursor: removeKey ? 'pointer' : undefined,
}}
onClick={() => removeKey?.(key)}
>
{key.split('+').map((splitKey, splitIndex, splitKeys) => (
<Fragment key={splitKey}>
<Kbd>{splitKey}</Kbd>
{splitIndex === splitKeys.length - 1 ? '' : '+'}
</Fragment>
))}
</Stack>
</CustomTooltip>
{index === keys.length - 1 ? '' : ','}
</Fragment>
))}
</Stack>
);
};

View File

@@ -0,0 +1,39 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import Typography from '@mui/material/Typography';
import { ReaderSettingsTypeProps } from '@/features/reader/types/Reader.types.ts';
import { ReaderSettingHotkey } from '@/features/reader/components/settings/hotkeys/ReaderSettingHotkey.tsx';
import { READER_HOTKEYS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { ResetButton } from '@/features/core/components/buttons/ResetButton.tsx';
export const ReaderHotkeysSettings = ({ settings, updateSetting, onDefault }: ReaderSettingsTypeProps) => {
const { t } = useTranslation();
return (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ alignItems: 'end' }}>
<Typography variant="caption">{t('hotkeys.info.delete')}</Typography>
</Stack>
{READER_HOTKEYS.map((hotkey) => (
<ReaderSettingHotkey
key={hotkey}
hotkey={hotkey}
keys={settings.hotkeys[hotkey]}
existingKeys={Object.values(settings.hotkeys).flat()}
updateSetting={(keys) => updateSetting('hotkeys', { ...settings.hotkeys, [hotkey]: keys })}
/>
))}
<Stack sx={{ alignItems: 'end' }}>
<ResetButton onClick={() => onDefault?.('hotkeys')} variant="outlined" />
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,80 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import Typography from '@mui/material/Typography';
import { bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
import IconButton from '@mui/material/IconButton';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { ReaderHotkey } from '@/features/reader/types/Reader.types.ts';
import { DEFAULT_READER_SETTINGS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { TranslationKey } from '@/Base.types.ts';
import { RecordHotkey } from '@/features/reader/components/settings/hotkeys/RecordHotkey.tsx';
import { Hotkey } from '@/features/reader/components/settings/hotkeys/Hotkey.tsx';
import { ResetButton } from '@/features/core/components/buttons/ResetButton.tsx';
const READER_HOTKEY_TO_TITLE: Record<ReaderHotkey, TranslationKey> = {
[ReaderHotkey.PREVIOUS_PAGE]: 'reader.settings.hotkey.previous_page',
[ReaderHotkey.NEXT_PAGE]: 'reader.settings.hotkey.next_page',
[ReaderHotkey.SCROLL_BACKWARD]: 'reader.settings.hotkey.scroll_backward',
[ReaderHotkey.SCROLL_FORWARD]: 'reader.settings.hotkey.scroll_forward',
[ReaderHotkey.PREVIOUS_CHAPTER]: 'reader.settings.hotkey.previous_chapter',
[ReaderHotkey.NEXT_CHAPTER]: 'reader.settings.hotkey.next_chapter',
[ReaderHotkey.TOGGLE_MENU]: 'reader.settings.hotkey.menu',
[ReaderHotkey.CYCLE_SCALE_TYPE]: 'reader.settings.hotkey.scale_type',
[ReaderHotkey.STRETCH_IMAGE]: 'reader.settings.hotkey.stretch_image',
[ReaderHotkey.OFFSET_SPREAD_PAGES]: 'reader.settings.hotkey.offset_spread_pages',
[ReaderHotkey.CYCLE_READING_MODE]: 'reader.settings.hotkey.reading_mode',
[ReaderHotkey.CYCLE_READING_DIRECTION]: 'reader.settings.hotkey.reading_direction',
[ReaderHotkey.TOGGLE_AUTO_SCROLL]: 'reader.settings.hotkey.auto_scroll',
[ReaderHotkey.AUTO_SCROLL_SPEED_INCREASE]: 'reader.settings.hotkey.auto_scroll_speed_increase',
[ReaderHotkey.AUTO_SCROLL_SPEED_DECREASE]: 'reader.settings.hotkey.auto_scroll_speed_decrease',
[ReaderHotkey.EXIT_READER]: 'reader.button.exit',
};
export const ReaderSettingHotkey = ({
hotkey,
keys,
existingKeys,
updateSetting,
}: {
hotkey: ReaderHotkey;
keys: string[];
existingKeys: string[];
updateSetting: (keys: string[]) => void;
}) => {
const { t } = useTranslation();
const popupState = usePopupState({ popupId: 'reader-setting-record-hotkey', variant: 'dialog' });
return (
<>
<Stack sx={{ flexDirection: 'row', alignItems: 'center', gap: 1 }}>
<Typography sx={{ flexGrow: 1 }}>{t(READER_HOTKEY_TO_TITLE[hotkey])}</Typography>
<Hotkey
keys={keys}
removeKey={(keyToRemove) => updateSetting(keys.filter((key) => key !== keyToRemove))}
/>
<CustomTooltip title={t('global.button.add')}>
<IconButton {...bindTrigger(popupState)} color="inherit">
<AddIcon />
</IconButton>
</CustomTooltip>
<ResetButton asIconButton onClick={() => updateSetting(DEFAULT_READER_SETTINGS.hotkeys[hotkey])} />
</Stack>
{popupState.isOpen && (
<RecordHotkey
onClose={popupState.close}
onCreate={(recordedKeys) => updateSetting([...keys, ...recordedKeys])}
existingKeys={existingKeys}
/>
)}
</>
);
};

View File

@@ -0,0 +1,91 @@
/*
* 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 { useEffect } from 'react';
import Stack from '@mui/material/Stack';
import { Trans, useTranslation } from 'react-i18next';
import { useRecordHotkeys } from 'react-hotkeys-hook';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { Hotkey } from '@/features/reader/components/settings/hotkeys/Hotkey.tsx';
export const RecordHotkey = ({
onClose,
onCreate,
existingKeys,
}: {
onClose: () => void;
onCreate: (keys: string[]) => void;
existingKeys: string[];
}) => {
const { t } = useTranslation();
const [recordedKeys, { start, stop, resetKeys }] = useRecordHotkeys();
const keys = [[...recordedKeys].join('+')];
const isExistingKey = keys.some((key) =>
existingKeys.map((existingKey) => existingKey.toLowerCase()).includes(key.toLowerCase()),
);
useEffect(() => {
start();
return () => {
stop();
};
}, []);
return (
<Dialog open onClose={onClose} fullWidth>
<DialogTitle>{t('hotkeys.create.dialog.title')}</DialogTitle>
<DialogContent>
<Stack sx={{ flexDirection: 'row', gap: 1 }}>
<Trans
i18nKey="hotkeys.create.dialog.label"
components={{
Keys: recordedKeys.size ? (
<Hotkey keys={keys} />
) : (
<Typography>{t('hotkeys.create.dialog.placeholder')}</Typography>
),
}}
>
Recorded keys:
</Trans>
</Stack>
{isExistingKey && <Typography color="error">{t('hotkeys.create.error.exists')}</Typography>}
</DialogContent>
<DialogActions>
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
width: '100%',
}}
>
<Button onClick={resetKeys}>{t('global.button.reset')}</Button>
<Stack direction="row">
<Button onClick={onClose}>{t('global.button.cancel')}</Button>
<Button
disabled={isExistingKey}
onClick={() => {
onClose();
onCreate(keys);
}}
>
{t('global.button.create')}
</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,53 @@
/*
* 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 from '@mui/material/Stack';
import { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { ReaderLayoutSettings } from '@/features/reader/components/settings/layout/ReaderLayoutSettings.tsx';
import { ReaderSettingProfileSettings } from '@/features/reader/components/settings/layout/profiles/ReaderSettingProfileSettings.tsx';
import {
READING_MODE_VALUE_TO_DISPLAY_DATA,
READING_MODE_VALUES,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { ReaderSettingReadingMode } from '@/features/reader/components/settings/layout/ReaderSettingReadingMode.tsx';
import { IReaderSettingsWithDefaultFlag, ReadingMode } from '@/features/reader/types/Reader.types.ts';
export const ReaderDefaultLayoutSettings = ({
profiles = READING_MODE_VALUES,
readingMode,
...props
}: Omit<ComponentProps<typeof ReaderLayoutSettings>, 'setShowPreview' | 'settings'> & {
profiles?: ReadingMode[];
readingMode?: IReaderSettingsWithDefaultFlag['readingMode'];
}) => {
const { updateSetting, isSeriesMode } = props;
const { t } = useTranslation();
return (
<Stack sx={{ gap: 2, pb: Number(!isSeriesMode) * 2 }}>
{readingMode !== undefined && (
<Stack sx={{ pt: 2, px: 2 }}>
<ReaderSettingReadingMode
readingMode={readingMode}
setReadingMode={(value) => updateSetting('readingMode', value)}
/>
</Stack>
)}
{profiles.map((profile) => (
<ReaderSettingProfileSettings
key={profile}
profile={profile}
title={t(READING_MODE_VALUE_TO_DISPLAY_DATA[profile].title)}
{...props}
/>
))}
</Stack>
);
};

View File

@@ -0,0 +1,123 @@
/*
* 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 from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
import { useTranslation } from 'react-i18next';
import { ReaderSettingReadingMode } from '@/features/reader/components/settings/layout/ReaderSettingReadingMode.tsx';
import { ReaderSettingReadingDirection } from '@/features/reader/components/settings/layout/ReaderSettingReadingDirection.tsx';
import { ReaderSettingTapZoneLayout } from '@/features/reader/components/settings/layout/ReaderSettingTapZoneLayout.tsx';
import { ReaderSettingTapZoneInvertMode } from '@/features/reader/components/settings/layout/ReaderSettingTapZoneInvertMode.tsx';
import { ReaderSettingPageScaleMode } from '@/features/reader/components/settings/layout/ReaderSettingPageScaleMode.tsx';
import { ReaderSettingStretchPage } from '@/features/reader/components/settings/layout/ReaderSettingStretchPage.tsx';
import { ReaderSettingsTypeProps } from '@/features/reader/types/Reader.types.ts';
import { ReaderSettingPageGap } from '@/features/reader/components/settings/layout/ReaderSettingPageGap.tsx';
import { ReaderSettingWidth } from '@/features/reader/components/settings/layout/ReaderSettingWidth.tsx';
import { DefaultSettingFootnote } from '@/features/reader/components/settings/DefaultSettingFootnote.tsx';
import { TReaderTapZoneContext } from '@/features/reader/types/TapZoneLayout.types.ts';
import { ReaderDefaultLayoutSettings } from '@/features/reader/components/settings/layout/ReaderDefaultLayoutSettings.tsx';
export const ReaderLayoutSettings = ({
setShowPreview,
settings,
updateSetting,
isDefaultable,
onDefault,
isSeriesMode,
setTransparent,
}: ReaderSettingsTypeProps & {
setShowPreview: TReaderTapZoneContext['setShowPreview'];
isSeriesMode?: boolean;
}) => {
const { t } = useTranslation();
return (
<Stack sx={{ gap: 2 }}>
<DefaultSettingFootnote areDefaultSettings={!isDefaultable} />
{isSeriesMode && (
<>
<Typography>{t('reader.settings.source_series')}</Typography>
<ReaderSettingReadingMode
readingMode={settings.readingMode}
setReadingMode={(value) => updateSetting('readingMode', value)}
isDefaultable={isDefaultable}
onDefault={() => onDefault?.('readingMode')}
/>
</>
)}
<ReaderSettingPageGap
pageGap={settings.pageGap}
readingMode={settings.readingMode}
isDefaultable={isDefaultable}
onDefault={() => onDefault?.('pageGap')}
updateSetting={(...args) => updateSetting('pageGap', ...args)}
/>
<ReaderSettingReadingDirection
readingDirection={settings.readingDirection}
setReadingDirection={(value) => updateSetting('readingDirection', value)}
isDefaultable={isDefaultable}
onDefault={() => onDefault?.('readingDirection')}
/>
<ReaderSettingTapZoneLayout
tapZoneLayout={settings.tapZoneLayout}
setTapZoneLayout={(value) => {
setShowPreview(true);
updateSetting('tapZoneLayout', value);
}}
isDefaultable={isDefaultable}
onDefault={() => {
setShowPreview(true);
onDefault?.('tapZoneLayout');
}}
/>
<ReaderSettingTapZoneInvertMode
tapZoneInvertMode={settings.tapZoneInvertMode}
setTapZoneInvertMode={(value) => {
setShowPreview(true);
updateSetting('tapZoneInvertMode', value);
}}
isDefaultable={isDefaultable}
onDefault={() => {
setShowPreview(true);
onDefault?.('tapZoneInvertMode');
}}
/>
<ReaderSettingPageScaleMode
pageScaleMode={settings.pageScaleMode}
setPageScaleMode={(value) => updateSetting('pageScaleMode', value)}
isDefaultable={isDefaultable}
onDefault={() => onDefault?.('pageScaleMode')}
/>
<ReaderSettingStretchPage
pageScaleMode={settings.pageScaleMode.value}
shouldStretchPage={settings.shouldStretchPage.value}
setShouldStretchPage={(value) => updateSetting('shouldStretchPage', value)}
/>
<ReaderSettingWidth
readerWidth={settings.readerWidth.value}
pageScaleMode={settings.pageScaleMode.value}
isDefaultable={isDefaultable}
onDefault={() => onDefault?.('readerWidth')}
updateSetting={(...args) => updateSetting(...args)}
setTransparent={setTransparent}
/>
{isSeriesMode && (
<>
<Divider />
<ReaderDefaultLayoutSettings
profiles={[settings.readingMode.value]}
updateSetting={updateSetting}
isSeriesMode={isSeriesMode}
setTransparent={setTransparent}
/>
</>
)}
</Stack>
);
};

View File

@@ -0,0 +1,55 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { IReaderSettings, IReaderSettingsWithDefaultFlag, ReadingMode } from '@/features/reader/types/Reader.types.ts';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { DEFAULT_READER_SETTINGS, PAGE_GAP } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { isContinuousReadingMode } from '@/features/reader/utils/ReaderSettings.utils.tsx';
import { MultiValueButtonDefaultableProps } from '@/features/core/Core.types.ts';
export const ReaderSettingPageGap = ({
pageGap,
readingMode,
isDefaultable,
onDefault,
updateSetting,
}: Pick<IReaderSettingsWithDefaultFlag, 'pageGap' | 'readingMode'> &
Pick<MultiValueButtonDefaultableProps<IReaderSettings['pageGap']>, 'isDefaultable' | 'onDefault'> & {
updateSetting: (gap: number, commit: boolean) => void;
}) => {
const { t } = useTranslation();
const isChangeable = readingMode.value !== ReadingMode.WEBTOON && isContinuousReadingMode(readingMode.value);
if (!isChangeable) {
return null;
}
return (
<SliderInput
label={t('reader.settings.label.page_gap')}
value={t('global.value', { value: pageGap.value, unit: t('global.unit.px') })}
onDefault={isDefaultable ? onDefault : undefined}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.pageGap,
value: pageGap.value,
step: PAGE_GAP.step,
min: PAGE_GAP.min,
max: PAGE_GAP.max,
onChange: (_, value) => {
updateSetting(value as number, false);
},
onChangeCommitted: (_, value) => {
updateSetting(value as number, true);
},
},
}}
/>
);
};

View File

@@ -0,0 +1,43 @@
/*
* 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 { useTranslation } from 'react-i18next';
import {
IReaderSettings,
IReaderSettingsWithDefaultFlag,
ReadingDirection,
} from '@/features/reader/types/Reader.types.ts';
import {
PAGE_SCALE_VALUE_TO_DISPLAY_DATA,
READER_PAGE_SCALE_MODE_VALUES,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
import { MultiValueButtonDefaultableProps } from '@/features/core/Core.types.ts';
export const ReaderSettingPageScaleMode = ({
pageScaleMode,
setPageScaleMode,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'pageScaleMode'> &
Pick<MultiValueButtonDefaultableProps<ReadingDirection>, 'isDefaultable' | 'onDefault'> & {
setPageScaleMode: (mode: IReaderSettings['pageScaleMode']) => void;
}) => {
const { t } = useTranslation();
return (
<ButtonSelectInput
{...buttonSelectInputProps}
label={t('reader.settings.page_scale.title')}
value={pageScaleMode.isDefault ? undefined : pageScaleMode.value}
defaultValue={pageScaleMode.isDefault ? pageScaleMode.value : undefined}
values={READER_PAGE_SCALE_MODE_VALUES}
setValue={setPageScaleMode}
valueToDisplayData={PAGE_SCALE_VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -0,0 +1,50 @@
/*
* 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 ArrowCircleLeftIcon from '@mui/icons-material/ArrowCircleLeft';
import ArrowCircleRightIcon from '@mui/icons-material/ArrowCircleRight';
import { useTranslation } from 'react-i18next';
import { IReaderSettingsWithDefaultFlag, ReadingDirection } from '@/features/reader/types/Reader.types.ts';
import { MultiValueButtonDefaultableProps, ValueToDisplayData } from '@/features/core/Core.types.ts';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReadingDirection> = {
[ReadingDirection.LTR]: {
title: 'reader.settings.reading_direction.ltr',
icon: <ArrowCircleRightIcon />,
},
[ReadingDirection.RTL]: {
title: 'reader.settings.reading_direction.rtl',
icon: <ArrowCircleLeftIcon />,
},
};
const READING_DIRECTION_VALUES = Object.values(ReadingDirection).filter((value) => typeof value === 'number');
export const ReaderSettingReadingDirection = ({
readingDirection,
setReadingDirection,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'readingDirection'> &
Pick<MultiValueButtonDefaultableProps<ReadingDirection>, 'isDefaultable' | 'onDefault'> & {
setReadingDirection: (readingDirection: ReadingDirection) => void;
}) => {
const { t } = useTranslation();
return (
<ButtonSelectInput
{...buttonSelectInputProps}
label={t('reader.settings.label.reading_direction')}
value={readingDirection.isDefault ? undefined : readingDirection.value}
defaultValue={readingDirection.isDefault ? readingDirection.value : undefined}
values={READING_DIRECTION_VALUES}
setValue={setReadingDirection}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -0,0 +1,39 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { IReaderSettingsWithDefaultFlag, ReadingMode } from '@/features/reader/types/Reader.types.ts';
import {
READING_MODE_VALUE_TO_DISPLAY_DATA,
READING_MODE_VALUES,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
import { MultiValueButtonDefaultableProps } from '@/features/core/Core.types.ts';
export const ReaderSettingReadingMode = ({
readingMode,
setReadingMode,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'readingMode'> &
Pick<MultiValueButtonDefaultableProps<ReadingMode>, 'isDefaultable' | 'onDefault'> & {
setReadingMode: (mode: ReadingMode) => void;
}) => {
const { t } = useTranslation();
return (
<ButtonSelectInput
{...buttonSelectInputProps}
label={t('reader.settings.label.reading_mode')}
value={readingMode.isDefault ? undefined : readingMode.value}
defaultValue={readingMode.isDefault ? readingMode.value : undefined}
values={READING_MODE_VALUES}
setValue={setReadingMode}
valueToDisplayData={READING_MODE_VALUE_TO_DISPLAY_DATA}
/>
);
};

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 { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { READER_PAGE_SCALE_MODE_TO_SCALING_ALLOWED } from '@/features/reader/constants/ReaderSettings.constants.tsx';
export const ReaderSettingStretchPage = ({
pageScaleMode,
shouldStretchPage,
setShouldStretchPage,
}: Pick<IReaderSettings, 'pageScaleMode' | 'shouldStretchPage'> & {
setShouldStretchPage: (mode: IReaderSettings['shouldStretchPage']) => void;
}) => {
const { t } = useTranslation();
if (!READER_PAGE_SCALE_MODE_TO_SCALING_ALLOWED[pageScaleMode]) {
return null;
}
return (
<Box>
<Button
onClick={() => setShouldStretchPage(!shouldStretchPage)}
variant={shouldStretchPage ? 'contained' : 'outlined'}
>
{t('reader.settings.page_scale.stretch')}
</Button>
</Box>
);
};

View File

@@ -0,0 +1,105 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { MultiValueButtonDefaultableProps, ValueToDisplayData } from '@/features/core/Core.types.ts';
import {
IReaderSettings,
IReaderSettingsWithDefaultFlag,
ReadingDirection,
} from '@/features/reader/types/Reader.types.ts';
import { TapZoneInvertMode } from '@/features/reader/types/TapZoneLayout.types.ts';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
enum TapZonesInvertOption {
NONE,
HORIZONTAL,
VERTICAL,
BOTH,
}
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<TapZonesInvertOption> = {
[TapZonesInvertOption.NONE]: {
title: 'global.label.none',
icon: null,
},
[TapZonesInvertOption.HORIZONTAL]: {
title: 'global.label.horizontal',
icon: null,
},
[TapZonesInvertOption.VERTICAL]: {
title: 'global.label.vertical',
icon: null,
},
[TapZonesInvertOption.BOTH]: {
title: 'global.label.both',
icon: null,
},
};
const TAP_ZONES_INVERT_OPTION_VALUES = Object.values(TapZonesInvertOption).filter((value) => typeof value === 'number');
const TAP_ZONES_INVERT_OPTION_TO_SETTING: Record<TapZonesInvertOption, TapZoneInvertMode> = {
[TapZonesInvertOption.NONE]: {
vertical: false,
horizontal: false,
},
[TapZonesInvertOption.HORIZONTAL]: {
vertical: false,
horizontal: true,
},
[TapZonesInvertOption.VERTICAL]: {
vertical: true,
horizontal: false,
},
[TapZonesInvertOption.BOTH]: {
vertical: true,
horizontal: true,
},
};
const convertTapZoneInvertModeToOption = ({ vertical, horizontal }: TapZoneInvertMode): TapZonesInvertOption => {
if (vertical && horizontal) {
return TapZonesInvertOption.BOTH;
}
if (vertical) {
return TapZonesInvertOption.VERTICAL;
}
if (horizontal) {
return TapZonesInvertOption.HORIZONTAL;
}
return TapZonesInvertOption.NONE;
};
export const ReaderSettingTapZoneInvertMode = ({
tapZoneInvertMode,
setTapZoneInvertMode,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'tapZoneInvertMode'> &
Pick<MultiValueButtonDefaultableProps<ReadingDirection>, 'isDefaultable' | 'onDefault'> & {
setTapZoneInvertMode: (invert: IReaderSettings['tapZoneInvertMode']) => void;
}) => {
const { t } = useTranslation();
const tapZonesInvertOption = convertTapZoneInvertModeToOption(tapZoneInvertMode.value);
return (
<ButtonSelectInput
{...buttonSelectInputProps}
label={t('reader.settings.tap_zones.invert')}
value={tapZoneInvertMode.isDefault ? undefined : tapZonesInvertOption}
defaultValue={tapZoneInvertMode.isDefault ? tapZonesInvertOption : undefined}
values={TAP_ZONES_INVERT_OPTION_VALUES}
setValue={(value) => setTapZoneInvertMode(TAP_ZONES_INVERT_OPTION_TO_SETTING[value])}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -0,0 +1,61 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { TapZoneLayouts } from '@/features/reader/types/TapZoneLayout.types.ts';
import { MultiValueButtonDefaultableProps, ValueToDisplayData } from '@/features/core/Core.types.ts';
import { IReaderSettingsWithDefaultFlag, ReadingDirection } from '@/features/reader/types/Reader.types.ts';
import { ButtonSelectInput } from '@/features/core/components/inputs/ButtonSelectInput.tsx';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<TapZoneLayouts> = {
[TapZoneLayouts.EDGE]: {
title: 'reader.settings.tap_zones.edge',
icon: null,
},
[TapZoneLayouts.KINDLE]: {
title: 'reader.settings.tap_zones.kindle',
icon: null,
},
[TapZoneLayouts.L_SHAPE]: {
title: 'reader.settings.tap_zones.l_shape',
icon: null,
},
[TapZoneLayouts.RIGHT_LEFT]: {
title: 'reader.settings.tap_zones.right_left',
icon: null,
},
[TapZoneLayouts.DISABLED]: {
title: 'global.label.disabled',
icon: null,
},
};
const READER_TAP_ZONE_LAYOUT_VALUES = Object.values(TapZoneLayouts).filter((value) => typeof value === 'number');
export const ReaderSettingTapZoneLayout = ({
tapZoneLayout,
setTapZoneLayout,
...buttonSelectInputProps
}: Pick<IReaderSettingsWithDefaultFlag, 'tapZoneLayout'> &
Pick<MultiValueButtonDefaultableProps<ReadingDirection>, 'isDefaultable' | 'onDefault'> & {
setTapZoneLayout: (layout: TapZoneLayouts) => void;
}) => {
const { t } = useTranslation();
return (
<ButtonSelectInput
{...buttonSelectInputProps}
label={t('reader.settings.tap_zones.title')}
value={tapZoneLayout.isDefault ? undefined : tapZoneLayout.value}
defaultValue={tapZoneLayout.isDefault ? tapZoneLayout.value : undefined}
values={READER_TAP_ZONE_LAYOUT_VALUES}
setValue={setTapZoneLayout}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -0,0 +1,73 @@
/*
* 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 from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { DEFAULT_READER_SETTINGS } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { isReaderWidthEditable } from '@/features/reader/utils/ReaderSettings.utils.tsx';
import { MultiValueButtonDefaultableProps } from '@/features/core/Core.types.ts';
import { ResetButton } from '@/features/core/components/buttons/ResetButton.tsx';
export const ReaderSettingWidth = ({
readerWidth,
pageScaleMode,
isDefaultable,
onDefault,
updateSetting,
setTransparent,
}: Pick<IReaderSettings, 'readerWidth' | 'pageScaleMode'> &
Pick<MultiValueButtonDefaultableProps<IReaderSettings['readerWidth']['value']>, 'isDefaultable' | 'onDefault'> & {
updateSetting: (...args: OmitFirst<Parameters<typeof ReaderService.updateSetting>>) => void;
setTransparent?: (transparent: boolean) => void;
}) => {
const { t } = useTranslation();
if (!isReaderWidthEditable(pageScaleMode)) {
return null;
}
return (
<Stack>
<Stack sx={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<CheckboxInput
label={t('reader.settings.label.limit_reader_width')}
checked={readerWidth.enabled}
onChange={(_, checked) => updateSetting('readerWidth', { ...readerWidth, enabled: checked }, true)}
/>
{isDefaultable && <ResetButton onClick={onDefault} variant="outlined" />}
</Stack>
{readerWidth.enabled && (
<SliderInput
label={t('reader.settings.label.reader_width')}
value={t('global.value', { value: readerWidth.value, unit: '%' })}
slotProps={{
slider: {
defaultValue: DEFAULT_READER_SETTINGS.readerWidth.value,
value: readerWidth.value,
step: 1,
min: 10,
max: 100,
onChange: (_, value) => {
setTransparent?.(true);
updateSetting('readerWidth', { ...readerWidth, value: value as number }, false);
},
onChangeCommitted: (_, value) => {
setTransparent?.(false);
updateSetting('readerWidth', { ...readerWidth, value: value as number }, true);
},
},
}}
/>
)}
</Stack>
);
};

View File

@@ -0,0 +1,59 @@
/*
* 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 from '@mui/material/Stack';
import ListSubheader from '@mui/material/ListSubheader';
import { ComponentProps, useMemo } from 'react';
import Typography from '@mui/material/Typography';
import { ReaderLayoutSettings } from '@/features/reader/components/settings/layout/ReaderLayoutSettings.tsx';
import { useDefaultReaderSettingsWithDefaultFlag } from '@/features/reader/services/ReaderSettingsMetadata.ts';
import { ReadingMode } from '@/features/reader/types/Reader.types.ts';
export const ReaderSettingProfileSettings = ({
profile,
title,
updateSetting,
isSeriesMode,
...props
}: Pick<ComponentProps<typeof ReaderLayoutSettings>, 'updateSetting' | 'isSeriesMode'> & {
profile: ReadingMode;
title: string;
}) => {
const { settings } = useDefaultReaderSettingsWithDefaultFlag(profile);
const adjustedSettings = useMemo(
() => ({
...settings,
readingMode: {
value: profile,
isDefault: true,
},
}),
[settings, profile],
);
return (
<Stack sx={{ gap: 2 }}>
{!isSeriesMode ? (
<ListSubheader component="div" id={`${profile}-settings`}>
{title}
</ListSubheader>
) : (
<Typography>{title}</Typography>
)}
<Stack sx={{ px: Number(!isSeriesMode) * 2 }}>
<ReaderLayoutSettings
{...props}
setShowPreview={() => {}}
settings={adjustedSettings}
updateSetting={(setting, value, commit) => updateSetting(setting, value, commit, true, profile)}
/>
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,469 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { memo, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Stack from '@mui/material/Stack';
import { useTheme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import {
IReaderSettings,
ReaderPagerProps,
ReaderPageSpreadState,
ReaderResumeMode,
ReaderStateChapters,
ReaderTransitionPageMode,
ReadingDirection,
ReadingMode,
} from '@/features/reader/types/Reader.types.ts';
import { getDoublePageModePages } from '@/features/reader/utils/ReaderPager.utils.tsx';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
import {
getPagerForReadingMode,
isContinuousReadingMode,
isContinuousVerticalReadingMode,
shouldApplyReaderWidth,
} from '@/features/reader/utils/ReaderSettings.utils.tsx';
import { ReaderStatePages } from '@/features/reader/types/ReaderProgressBar.types.ts';
import {
createHandleReaderPageLoadError,
createUpdateReaderPageLoadState,
} from '@/features/reader/utils/Reader.utils.ts';
import { useReaderConvertPagesForReadingMode } from '@/features/reader/hooks/useReaderConvertPagesForReadingMode.ts';
import { ReaderTransitionPage } from '@/features/reader/components/viewer/ReaderTransitionPage.tsx';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { READER_STATE_PAGES_DEFAULTS } from '@/features/reader/constants/ReaderContext.constants.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { useReaderSetPagesState } from '@/features/reader/hooks/useReaderSetPagesState.ts';
import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { ReaderInfiniteScrollUpdateChapter } from '@/features/reader/components/viewer/ReaderInfiniteScrollUpdateChapter.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
const BaseReaderChapterViewer = ({
currentPageIndex,
setPages: setContextPages,
setPageLoadStates: setContextPageLoadStates,
setTotalPages: setContextTotalPages,
setCurrentPageIndex: setContextCurrentPageIndex,
updateCurrentPageIndex,
setPageToScrollToIndex,
transitionPageMode,
retryFailedPagesKeyPrefix,
setTransitionPageMode,
readingMode,
readerWidth,
pageScaleMode,
shouldOffsetDoubleSpreads,
readingDirection,
shouldUseInfiniteScroll,
imagePreLoadAmount,
pageGap,
chapterId,
previousChapterId,
nextChapterId,
isPreviousChapterVisible,
isNextChapterVisible,
lastPageRead,
isInitialChapter,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
isLeadingChapter,
isTrailingChapter,
isPreloadMode,
imageRefs: globalImageRefs,
scrollIntoView,
setReaderStateChapters,
resumeMode,
customFilter,
shouldStretchPage,
scrollbarXSize,
scrollbarYSize,
readerNavBarWidth,
onSizeChange,
minWidth,
minHeight,
scrollElement,
}: Pick<
ReaderStatePages,
| 'currentPageIndex'
| 'setPages'
| 'setPageLoadStates'
| 'setTotalPages'
| 'setCurrentPageIndex'
| 'setPageToScrollToIndex'
| 'transitionPageMode'
| 'retryFailedPagesKeyPrefix'
| 'setTransitionPageMode'
> &
Omit<ReaderPagerProps, 'pages' | 'totalPages' | 'pageLoadStates' | 'handleAsInitialRender' | 'resumeMode'> &
Pick<
IReaderSettings,
| 'readingMode'
| 'shouldOffsetDoubleSpreads'
| 'readingDirection'
| 'readerWidth'
| 'pageScaleMode'
| 'shouldUseInfiniteScroll'
> &
Pick<ReaderStateChapters, 'setReaderStateChapters'> & {
updateCurrentPageIndex: ReturnType<typeof ReaderControls.useUpdateCurrentPageIndex>;
chapterId: ChapterIdInfo['id'];
previousChapterId?: ChapterIdInfo['id'];
nextChapterId?: ChapterIdInfo['id'];
isPreviousChapterVisible: boolean;
isNextChapterVisible: boolean;
lastPageRead: number;
isInitialChapter: boolean;
isCurrentChapter: boolean;
isPreviousChapter: boolean;
isNextChapter: boolean;
isLeadingChapter: boolean;
isTrailingChapter: boolean;
imageRefs: MutableRefObject<(HTMLElement | null)[]>;
scrollIntoView: boolean;
resumeMode: ReaderResumeMode;
onSizeChange: (width: number, height: number, chapterId: ChapterIdInfo['id']) => void;
minWidth: number;
minHeight: number;
scrollElement: HTMLElement | null;
}) => {
const { t } = useTranslation();
const { direction: themeDirection } = useTheme();
const [fetchPages, pagesResponse] = requestManager.useGetChapterPagesFetch(chapterId ?? -1);
const [arePagesFetched, setArePagesFetched] = useState(false);
const [totalPages, setTotalPages] = useState<ReaderStatePages['totalPages']>(
READER_STATE_PAGES_DEFAULTS.totalPages,
);
const [pageUrls, setPageUrls] = useState<ReaderStatePages['pageUrls']>(READER_STATE_PAGES_DEFAULTS.pageUrls);
const [pages, setPages] = useState<ReaderStatePages['pages']>(READER_STATE_PAGES_DEFAULTS.pages);
const [pageLoadStates, setPageLoadStates] = useState<ReaderStatePages['pageLoadStates']>(
READER_STATE_PAGES_DEFAULTS.pageLoadStates,
);
const [pagesToSpreadState, setPagesToSpreadState] = useState<ReaderPageSpreadState[]>(
pageLoadStates.map(({ url }) => ({ url, isSpread: false })),
);
const ref = useRef<HTMLDivElement>(null);
const isCurrentChapterRef = useRef(isCurrentChapter);
const imageRefs = useRef<(HTMLElement | null)[]>(pages.map(() => null));
const pagerRef = useRef<HTMLDivElement>(null);
const actualPages = useMemo(() => {
const arePagesLoaded = !!totalPages;
if (!arePagesLoaded) {
return pages;
}
const isSpreadStateUpdated = totalPages === pagesToSpreadState.length;
if (!isSpreadStateUpdated) {
return pages;
}
if (readingMode === ReadingMode.DOUBLE_PAGE) {
return getDoublePageModePages(pageUrls, pagesToSpreadState, shouldOffsetDoubleSpreads, readingDirection);
}
return pages;
}, [pagesToSpreadState, readingMode, shouldOffsetDoubleSpreads, readingDirection, totalPages]);
const Pager = useMemo(() => getPagerForReadingMode(readingMode), [readingMode]);
const isLtrReadingDirection = readingDirection === ReadingDirection.LTR;
const isContinuousReadingModeActive = isContinuousReadingMode(readingMode);
const shouldHideChapter = (!isContinuousReadingModeActive && !isCurrentChapter) || isPreloadMode;
const isCurrentChapterInSinglePager = !isContinuousReadingModeActive && isCurrentChapter;
const showPreviousTransitionPage =
!shouldHideChapter &&
(isCurrentChapterInSinglePager || (isContinuousReadingModeActive && (isInitialChapter || isLeadingChapter)));
const showNextTransitionPage =
!shouldHideChapter &&
(isCurrentChapterInSinglePager || (isContinuousReadingModeActive && (isInitialChapter || isTrailingChapter)));
isCurrentChapterRef.current = isCurrentChapter;
if (isCurrentChapter) {
// eslint-disable-next-line no-param-reassign
globalImageRefs.current = imageRefs.current;
}
const doFetchPages = useCallback(() => {
if (!chapterId) {
return;
}
setArePagesFetched(false);
fetchPages({ variables: { input: { chapterId } } }).catch(
defaultPromiseErrorHandler(`ReaderChapterViewer(${chapterId})::fetchPages`),
);
}, [fetchPages, chapterId]);
const onLoad = useMemo(
() =>
createUpdateReaderPageLoadState(
actualPages,
setPagesToSpreadState,
(value) => {
if (isCurrentChapterRef.current) {
setContextPageLoadStates(value);
}
setPageLoadStates(value);
},
readingMode,
),
[actualPages, readingMode],
);
const onError = useMemo(
() =>
createHandleReaderPageLoadError((value) => {
if (isCurrentChapterRef.current) {
setContextPageLoadStates(value);
}
setPageLoadStates(value);
}),
[],
);
useEffect(() => {
doFetchPages();
}, [chapterId]);
useResizeObserver(
ref,
useCallback(
(entries) => {
const { clientWidth, clientHeight } = entries[0].target;
onSizeChange(clientWidth, clientHeight, chapterId);
},
[onSizeChange, ref.current, chapterId],
),
);
const updateState = <T,>(
value: T,
setLocalState: (value: T) => void,
setGlobalState: (value: T) => void,
forceLocal: boolean = false,
) => {
if (forceLocal || !arePagesFetched) {
setLocalState(value);
}
if (isCurrentChapter) {
setGlobalState(value);
}
};
useReaderSetPagesState(
isCurrentChapter,
pagesResponse,
resumeMode,
lastPageRead,
actualPages,
pageLoadStates,
pagesToSpreadState,
arePagesFetched,
setArePagesFetched,
(value) => updateState(value, noOp, setReaderStateChapters),
(value) => updateState(value, setTotalPages, setContextTotalPages),
(value) => updateState(value, setPages, setContextPages),
(value) => updateState(value, setPageUrls, noOp),
(value) => updateState(value, setPageLoadStates, setContextPageLoadStates),
(value) => updateState(value, setPagesToSpreadState, noOp),
(value) => updateState(value, noOp, setContextCurrentPageIndex),
(value) => {
if ((isInitialChapter && !arePagesFetched) || scrollIntoView) {
setPageToScrollToIndex(value);
setReaderStateChapters((prevState) => ({
...prevState,
visibleChapters: { ...prevState.visibleChapters, scrollIntoView: false, resumeMode: undefined },
}));
}
},
(value) => updateState(value, noOp, setTransitionPageMode),
);
useReaderConvertPagesForReadingMode(
currentPageIndex,
actualPages,
pageUrls,
(value) => updateState(value, setPages, setContextPages, true),
(value) => updateState(value, setPagesToSpreadState, noOp, true),
(value) => updateState(value, noOp, updateCurrentPageIndex),
readingMode,
);
// for non-continuous reading modes, only the current, previous and next chapter are relevant
// every other chapter does not need to be rendered all the time since it's not affecting the
// visible content anyway
// the previous and next chapter are rendered so that going to the previous/next chapter feels smoother
// since the relevant pages are already rendered
const shouldRenderChapterViewer =
isContinuousReadingModeActive || isCurrentChapter || isPreviousChapter || isNextChapter;
if (!shouldRenderChapterViewer) {
return null;
}
if (pagesResponse.error) {
if (shouldHideChapter) {
return null;
}
return (
<Box
sx={{
minHeight: '100%',
minWidth: '100%',
display: 'grid',
placeItems: 'center',
position: 'relative',
}}
>
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(pagesResponse.error)}
retry={() => {
doFetchPages();
}}
/>
</Box>
);
}
if (pagesResponse.loading || !arePagesFetched) {
if (shouldHideChapter) {
return null;
}
return (
<Box
sx={{
minHeight: '100%',
minWidth: '100%',
display: 'grid',
placeItems: 'center',
}}
>
<LoadingPlaceholder />
</Box>
);
}
if (chapterId != null && !totalPages) {
if (shouldHideChapter) {
return null;
}
return (
<Box sx={{ minWidth: '100%', minHeight: '100%', position: 'relative' }}>
<EmptyViewAbsoluteCentered message={t('reader.error.label.no_pages_found')} retry={doFetchPages} />
</Box>
);
}
return (
<Stack
ref={ref}
sx={{
width: 'fit-content',
height: 'fit-content',
margin: 'auto',
flexWrap: 'nowrap',
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
minHeight,
}),
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
minWidth,
}),
...applyStyles(shouldHideChapter, {
maxWidth: 0,
maxHeight: 0,
minWidth: 'unset',
minHeight: 'unset',
overflow: 'hidden',
margin: 'unset',
}),
...applyStyles(
isContinuousVerticalReadingMode(readingMode) && shouldApplyReaderWidth(readerWidth, pageScaleMode),
{ alignItems: 'center' },
),
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
...applyStyles(themeDirection === 'ltr', {
flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse',
}),
...applyStyles(themeDirection === 'rtl', {
flexDirection: isLtrReadingDirection ? 'row-reverse' : 'row',
}),
}),
}}
>
{!isPreloadMode && (
<ReaderInfiniteScrollUpdateChapter
readingMode={readingMode}
readingDirection={readingDirection}
shouldUseInfiniteScroll={shouldUseInfiniteScroll}
chapterId={chapterId}
previousChapterId={previousChapterId}
nextChapterId={nextChapterId}
isCurrentChapter={isCurrentChapter}
isPreviousChapterVisible={isPreviousChapterVisible}
isNextChapterVisible={isNextChapterVisible}
imageWrapper={pagerRef.current}
scrollbarXSize={scrollbarXSize}
scrollbarYSize={scrollbarYSize}
scrollElement={scrollElement}
/>
)}
{showPreviousTransitionPage && (
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.PREVIOUS} />
)}
<Pager
ref={pagerRef}
totalPages={totalPages}
currentPageIndex={currentPageIndex}
pages={actualPages}
transitionPageMode={transitionPageMode}
pageLoadStates={pageLoadStates}
retryFailedPagesKeyPrefix={retryFailedPagesKeyPrefix}
imageRefs={imageRefs}
onLoad={onLoad}
onError={onError}
isCurrentChapter={isCurrentChapter}
isPreviousChapter={isPreviousChapter}
isNextChapter={isNextChapter}
readingMode={readingMode}
imagePreLoadAmount={imagePreLoadAmount}
readingDirection={readingDirection}
pageScaleMode={pageScaleMode}
pageGap={pageGap}
customFilter={customFilter}
shouldStretchPage={shouldStretchPage}
readerWidth={readerWidth}
scrollbarXSize={scrollbarXSize}
scrollbarYSize={scrollbarYSize}
readerNavBarWidth={readerNavBarWidth}
isPreloadMode={isPreloadMode}
resumeMode={resumeMode}
handleAsInitialRender={scrollIntoView}
/>
{showNextTransitionPage && (
<ReaderTransitionPage chapterId={chapterId} type={ReaderTransitionPageMode.NEXT} />
)}
</Stack>
);
};
export const ReaderChapterViewer = memo(BaseReaderChapterViewer);

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { memo } from 'react';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { useReaderInfiniteScrollUpdateChapter } from '@/features/reader/hooks/useReaderInfiniteScrollUpdateChapter.ts';
import { IReaderSettings, TReaderScrollbarContext } from '@/features/reader/types/Reader.types.ts';
import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
const BaseReaderInfiniteScrollUpdateChapter = ({
readingMode,
readingDirection,
shouldUseInfiniteScroll,
chapterId,
previousChapterId,
nextChapterId,
isPreviousChapterVisible,
isCurrentChapter,
isNextChapterVisible,
imageWrapper,
openChapter,
scrollbarXSize,
scrollbarYSize,
scrollElement,
shouldShowTransitionPage,
}: Pick<IReaderSettings, 'shouldShowTransitionPage'> &
Pick<TReaderScrollbarContext, 'scrollbarXSize' | 'scrollbarYSize'> &
Pick<IReaderSettings, 'readingMode' | 'readingDirection' | 'shouldUseInfiniteScroll'> & {
chapterId: ChapterIdInfo['id'];
previousChapterId?: ChapterIdInfo['id'];
nextChapterId?: ChapterIdInfo['id'];
isPreviousChapterVisible: boolean;
isCurrentChapter: boolean;
isNextChapterVisible: boolean;
imageWrapper: HTMLElement | null;
openChapter: ReturnType<typeof ReaderControls.useOpenChapter>;
scrollElement: HTMLElement | null;
}) => {
useReaderInfiniteScrollUpdateChapter(
'first',
chapterId,
previousChapterId,
isCurrentChapter,
isPreviousChapterVisible,
readingMode,
readingDirection,
shouldUseInfiniteScroll,
openChapter,
imageWrapper,
scrollbarXSize,
scrollbarYSize,
scrollElement,
shouldShowTransitionPage,
);
useReaderInfiniteScrollUpdateChapter(
'last',
chapterId,
nextChapterId,
isCurrentChapter,
isNextChapterVisible,
readingMode,
readingDirection,
shouldUseInfiniteScroll,
openChapter,
imageWrapper,
scrollbarXSize,
scrollbarYSize,
scrollElement,
shouldShowTransitionPage,
);
return null;
};
export const ReaderInfiniteScrollUpdateChapter = withPropsFrom(
memo(BaseReaderInfiniteScrollUpdateChapter),
[() => ({ openChapter: ReaderControls.useOpenChapter() }), ReaderService.useSettingsWithoutDefaultFlag],
['openChapter', 'shouldShowTransitionPage'],
);

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { memo, useCallback } from 'react';
import { SpinnerImage, SpinnerImageProps } from '@/features/core/components/SpinnerImage.tsx';
import {
IReaderSettings,
ReaderCustomFilter,
ReaderPagerProps,
TReaderScrollbarContext,
} from '@/features/reader/types/Reader.types.ts';
import {
getImageMarginStyling,
getImagePlaceholderStyling,
getReaderImageStyling,
} from '@/features/reader/utils/ReaderPager.utils.tsx';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
import { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
const getCustomFilterString = (customFilter: ReaderCustomFilter): string =>
Object.keys(customFilter)
.map((key) => {
const filter = key as keyof ReaderCustomFilter;
const value = customFilter[filter];
switch (filter) {
case 'brightness':
case 'contrast':
case 'saturate':
return (value as ReaderCustomFilter['brightness' | 'contrast' | 'saturate']).enabled
? `${filter}(${(value as ReaderCustomFilter['brightness' | 'contrast' | 'saturate']).value / 100})`
: '';
case 'hue':
return (value as ReaderCustomFilter['hue']).enabled
? `hue-rotate(${(value as ReaderCustomFilter['brightness' | 'contrast' | 'saturate']).value}deg)`
: '';
case 'rgba':
return '';
case 'sepia':
case 'grayscale':
case 'invert':
return value ? `${filter}(${Number(value)})` : '';
default:
throw new Error(`Unexpected "CustomFilter" (${filter})`);
}
})
.join(' ');
const BaseReaderPage = ({
pageIndex,
pagesIndex,
isPrimaryPage,
display,
doublePage = false,
position,
marginTop,
shouldLoad,
readingMode,
customFilter,
pageScaleMode,
shouldStretchPage,
readerWidth,
scrollbarXSize,
scrollbarYSize,
onLoad,
onError,
setRef,
readerNavBarWidth,
isLoaded,
...props
}: Omit<SpinnerImageProps, 'spinnerStyle' | 'imgStyle' | 'onLoad' | 'onError'> &
Pick<IReaderSettings, 'readingMode' | 'customFilter' | 'pageScaleMode' | 'shouldStretchPage' | 'readerWidth'> &
Pick<TReaderScrollbarContext, 'scrollbarXSize' | 'scrollbarYSize'> &
Pick<NavbarContextType, 'readerNavBarWidth'> & {
pageIndex: number;
pagesIndex: number;
isPrimaryPage: boolean;
display: boolean;
doublePage?: boolean;
position?: 'left' | 'right';
marginTop?: number;
onLoad: ReaderPagerProps['onLoad'];
onError: ReaderPagerProps['onError'];
setRef?: (pagesIndex: number, ref: HTMLElement | null) => void;
isLoaded?: boolean;
}) => {
const { src } = props;
const isTabletWidth = MediaQuery.useIsTabletWidth();
const handleLoad = useCallback(
() => onLoad?.(pagesIndex, src, isPrimaryPage),
[onLoad, pagesIndex, src, isPrimaryPage],
);
const handleError = useCallback(() => onError?.(pageIndex, src), [onError, pageIndex, src]);
const updateRef = useCallback((element: HTMLElement | null) => setRef?.(pagesIndex, element), [pagesIndex, setRef]);
if (!display && !shouldLoad) {
return null;
}
return (
<SpinnerImage
{...props}
onLoad={handleLoad}
onError={handleError}
shouldLoad={shouldLoad}
shouldDecode
ref={updateRef}
spinnerStyle={{
backgroundColor: 'background.paper',
...getImagePlaceholderStyling(
readingMode,
shouldStretchPage,
pageScaleMode,
readerWidth,
readerNavBarWidth + scrollbarYSize,
scrollbarXSize,
doublePage,
isTabletWidth,
),
...applyStyles(!display, {
display: 'none',
}),
...getImageMarginStyling(doublePage, position),
}}
imgStyle={{
...getReaderImageStyling(
readingMode,
shouldStretchPage,
pageScaleMode,
doublePage,
readerWidth,
readerNavBarWidth + scrollbarYSize,
scrollbarXSize,
),
filter: getCustomFilterString(customFilter),
objectFit: 'contain',
objectPosition: position,
userSelect: 'none',
...getImageMarginStyling(doublePage, position),
...applyStyles(marginTop !== undefined, {
mt: `${marginTop}px`,
}),
display: 'block',
...applyStyles(!display, {
display: 'none',
}),
...applyStyles(!isLoaded, {
margin: 'unset',
}),
}}
hideImgStyle={{
visibility: 'hidden',
minWidth: 0,
minHeight: 0,
width: 0,
height: 0,
}}
/>
);
};
export const ReaderPage = memo(BaseReaderPage);

View File

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

View File

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

View File

@@ -0,0 +1,160 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { forwardRef, memo, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import Box, { BoxProps } from '@mui/material/Box';
import { getPageIndexesToLoad, isATransitionPageVisible } from '@/features/reader/utils/ReaderPager.utils.tsx';
import { ReaderStatePages } from '@/features/reader/types/ReaderProgressBar.types.ts';
import {
IReaderSettings,
ReaderPagerProps,
ReaderResumeMode,
ReaderTransitionPageMode,
} from '@/features/reader/types/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { isContinuousReadingMode } from '@/features/reader/utils/ReaderSettings.utils.tsx';
const getPreviousCurrentPageIndex = (resumeMode: ReaderResumeMode): number =>
resumeMode === ReaderResumeMode.END ? Number.MAX_SAFE_INTEGER : -1;
const BaseBasePager = forwardRef<
HTMLDivElement,
Omit<ReaderPagerProps, 'pageLoadStates' | 'retryFailedPagesKeyPrefix' | 'isPreloadMode'> &
Pick<IReaderSettings, 'readingMode' | 'imagePreLoadAmount'> & {
createPage: (
page: ReaderStatePages['pages'][number],
pagesIndex: number,
shouldLoad: boolean,
shouldDisplay: boolean,
setRef: (pagesIndex: number, element: HTMLElement | null) => void,
readingMode: ReaderPagerProps['readingMode'],
customFilter: ReaderPagerProps['customFilter'],
pageScaleMode: ReaderPagerProps['pageScaleMode'],
shouldStretchPage: ReaderPagerProps['shouldStretchPage'],
readerWidth: ReaderPagerProps['readerWidth'],
scrollbarXSize: ReaderPagerProps['scrollbarXSize'],
scrollbarYSize: ReaderPagerProps['scrollbarYSize'],
readerNavBarWidth: ReaderPagerProps['readerNavBarWidth'],
) => ReactNode;
slots?: { boxProps?: BoxProps };
}
>(
(
{
currentPageIndex,
pages,
transitionPageMode,
imageRefs,
createPage,
slots,
readingMode,
imagePreLoadAmount,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
customFilter,
pageScaleMode,
shouldStretchPage,
readerWidth,
scrollbarXSize,
scrollbarYSize,
readerNavBarWidth,
resumeMode,
handleAsInitialRender,
},
ref,
) => {
const previousCurrentPageIndex = useRef(getPreviousCurrentPageIndex(resumeMode));
if (handleAsInitialRender) {
previousCurrentPageIndex.current = getPreviousCurrentPageIndex(resumeMode);
}
const pagesIndexesToRender = useMemo(
() =>
getPageIndexesToLoad(
currentPageIndex,
pages,
previousCurrentPageIndex.current,
imagePreLoadAmount,
readingMode,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
),
[
currentPageIndex,
pages,
imagePreLoadAmount,
readingMode,
isCurrentChapter,
isPreviousChapter,
isNextChapter,
],
);
useEffect(() => {
if (isCurrentChapter) {
previousCurrentPageIndex.current = currentPageIndex;
}
}, [pagesIndexesToRender, isCurrentChapter]);
const setRef = useCallback(
(pagesIndex: number, element: HTMLElement | null) => {
// eslint-disable-next-line no-param-reassign
imageRefs.current[pagesIndex] = element;
},
[imageRefs],
);
return (
<Box
ref={ref}
{...slots?.boxProps}
sx={[
{
width: 'fit-content',
height: 'fit-content',
},
...(Array.isArray(slots?.boxProps?.sx) ? (slots?.boxProps?.sx ?? []) : [slots?.boxProps?.sx]),
// hide pager, without actually unmounting it to prevent re-renders, while a chapter transition page is taking up the full screen
applyStyles(
!isContinuousReadingMode(readingMode) &&
isATransitionPageVisible(transitionPageMode, readingMode),
{
visibility: 'hidden',
width: 0,
height: 0,
m: 0,
p: 0,
},
),
]}
>
{pages.map((page, pagesIndex) =>
createPage(
page,
pagesIndex,
pagesIndexesToRender.includes(pagesIndex),
[ReaderTransitionPageMode.NONE, ReaderTransitionPageMode.BOTH].includes(transitionPageMode),
setRef,
readingMode,
customFilter,
pageScaleMode,
shouldStretchPage,
readerWidth,
scrollbarXSize,
scrollbarYSize,
readerNavBarWidth,
),
)}
</Box>
);
},
);
export const BasePager = memo(BaseBasePager);

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Direction, useTheme } from '@mui/material/styles';
import { forwardRef, Fragment, memo, useMemo } from 'react';
import { BasePager } from '@/features/reader/components/viewer/pager/BasePager.tsx';
import {
IReaderSettings,
ReaderPagerProps,
ReaderPageScaleMode,
ReadingDirection,
} from '@/features/reader/types/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { createReaderPage } from '@/features/reader/utils/ReaderPager.utils.tsx';
import { getNextIndexFromPage, getPage } from '@/features/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';
};
const BaseReaderDoublePagedPager = forwardRef<
HTMLDivElement,
ReaderPagerProps & Pick<IReaderSettings, 'readingDirection' | 'pageScaleMode'>
>(({ onLoad, onError, pageLoadStates, retryFailedPagesKeyPrefix, isPreloadMode, ...props }, ref) => {
const { currentPageIndex, pages, totalPages, readingDirection, pageScaleMode } = props;
const { direction: themeDirection } = useTheme();
const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]);
const isLtrReadingDirection = readingDirection === ReadingDirection.LTR;
return (
<BasePager
ref={ref}
{...props}
createPage={(page, pagesIndex, shouldLoad, shouldDisplay, _setRef, ...baseProps) => {
const { primary, secondary } = page;
const currentSecondaryPageIndex = getNextIndexFromPage(currentPage);
const hasSecondaryPage = !!secondary;
const isPrimaryPage = currentPage.primary.index === primary.index;
const isSecondaryPage = !!secondary && currentSecondaryPageIndex === secondary.index;
return (
<Fragment key={`${primary.url}_${secondary?.url}`}>
{createReaderPage(
page,
pagesIndex,
true,
pageLoadStates[primary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
shouldDisplay && isPrimaryPage && shouldLoad,
currentPage.primary.index,
totalPages,
...baseProps,
pageLoadStates[primary.index].error ? retryFailedPagesKeyPrefix : undefined,
hasSecondaryPage ? getPagePosition('first', themeDirection, readingDirection) : undefined,
hasSecondaryPage,
)}
{hasSecondaryPage &&
createReaderPage(
{ ...page, primary: { ...page.secondary! } },
pagesIndex,
false,
pageLoadStates[secondary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
shouldDisplay && isSecondaryPage && shouldLoad,
currentSecondaryPageIndex,
totalPages,
...baseProps,
pageLoadStates[secondary.index].error ? retryFailedPagesKeyPrefix : undefined,
getPagePosition('second', themeDirection, readingDirection),
true,
)}
</Fragment>
);
}}
slots={{
boxProps: {
sx: {
...applyStyles(pageScaleMode === ReaderPageScaleMode.ORIGINAL, {
margin: 'auto',
}),
width: '100%',
minWidth: 'fit-content',
height: '100%',
minHeight: 'fit-content',
display: 'flex',
flexDirection: 'row',
flexWrap: 'nowrap',
...applyStyles(themeDirection === 'ltr', {
flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse',
}),
...applyStyles(themeDirection === 'rtl', {
flexDirection: isLtrReadingDirection ? 'row-reverse' : 'row',
}),
},
},
}}
/>
);
});
export const ReaderDoublePagedPager = memo(BaseReaderDoublePagedPager);

View File

@@ -0,0 +1,74 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useTheme } from '@mui/material/styles';
import { forwardRef, memo } from 'react';
import { BasePager } from '@/features/reader/components/viewer/pager/BasePager.tsx';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { IReaderSettings, ReaderPagerProps, ReadingDirection } from '@/features/reader/types/Reader.types.ts';
import { createReaderPage } from '@/features/reader/utils/ReaderPager.utils.tsx';
const BaseReaderHorizontalPager = forwardRef<
HTMLDivElement,
ReaderPagerProps & Pick<IReaderSettings, 'pageGap' | 'readingDirection'>
>(({ onLoad, onError, pageLoadStates, retryFailedPagesKeyPrefix, isPreloadMode, ...props }, ref) => {
const { currentPageIndex, totalPages, pageGap, readingDirection } = props;
const { direction: themeDirection } = useTheme();
const isLtrReadingDirection = readingDirection === ReadingDirection.LTR;
return (
<BasePager
ref={ref}
{...props}
createPage={(page, pagesIndex, shouldLoad, _, setRef, ...baseProps) =>
createReaderPage(
page,
pagesIndex,
true,
pageLoadStates[page.primary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
!isPreloadMode,
currentPageIndex,
totalPages,
...baseProps,
pageLoadStates[page.primary.index].error ? retryFailedPagesKeyPrefix : undefined,
undefined,
undefined,
undefined,
setRef,
)
}
slots={{
boxProps: {
sx: {
my: 'auto',
display: 'flex',
flexWrap: 'nowrap',
alignItems: 'center',
gap: `${pageGap}px`,
...applyStyles(themeDirection === 'ltr', {
flexDirection: isLtrReadingDirection ? 'row' : 'row-reverse',
justifyContent: isLtrReadingDirection ? 'flex-start' : 'flex-end',
}),
...applyStyles(themeDirection === 'rtl', {
flexDirection: isLtrReadingDirection ? 'row-reverse' : 'row',
justifyContent: isLtrReadingDirection ? 'flex-end' : 'flex-start',
}),
},
},
}}
/>
);
});
export const ReaderHorizontalPager = memo(BaseReaderHorizontalPager);

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { forwardRef, memo } from 'react';
import { BasePager } from '@/features/reader/components/viewer/pager/BasePager.tsx';
import { ReaderPagerProps } from '@/features/reader/types/Reader.types.ts';
import { createReaderPage } from '@/features/reader/utils/ReaderPager.utils.tsx';
const BaseReaderPagedPager = forwardRef<HTMLDivElement, ReaderPagerProps>(
({ onLoad, onError, pageLoadStates, retryFailedPagesKeyPrefix, isPreloadMode, ...props }, ref) => {
const { currentPageIndex, totalPages } = props;
return (
<BasePager
ref={ref}
{...props}
createPage={(page, pagesIndex, shouldLoad, shouldDisplay, _setRef, ...baseProps) =>
createReaderPage(
page,
pagesIndex,
true,
pageLoadStates[page.primary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
shouldDisplay && shouldLoad && currentPageIndex === page.primary.index,
currentPageIndex,
totalPages,
...baseProps,
pageLoadStates[page.primary.index].error ? retryFailedPagesKeyPrefix : undefined,
)
}
slots={{
boxProps: {
sx: {
margin: 'auto',
},
},
}}
/>
);
},
);
export const ReaderPagedPager = memo(BaseReaderPagedPager);

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { forwardRef, memo } from 'react';
import { BasePager } from '@/features/reader/components/viewer/pager/BasePager.tsx';
import { ReaderPagerProps, ReadingMode } from '@/features/reader/types/Reader.types.ts';
import { createReaderPage } from '@/features/reader/utils/ReaderPager.utils.tsx';
const BaseReaderVerticalPager = forwardRef<HTMLDivElement, ReaderPagerProps>(
({ onLoad, onError, pageLoadStates, retryFailedPagesKeyPrefix, isPreloadMode, ...props }, ref) => {
const { currentPageIndex, totalPages, readingMode, pageGap } = props;
const isWebtoonMode = readingMode === ReadingMode.WEBTOON;
const actualPageGap = isWebtoonMode ? 0 : pageGap;
return (
<BasePager
ref={ref}
{...props}
createPage={(page, pagesIndex, shouldLoad, _, setRef, ...baseProps) =>
createReaderPage(
page,
pagesIndex,
true,
pageLoadStates[page.primary.index].loaded,
isPreloadMode,
onLoad,
onError,
shouldLoad,
!isPreloadMode,
currentPageIndex,
totalPages,
...baseProps,
pageLoadStates[page.primary.index].error ? retryFailedPagesKeyPrefix : undefined,
undefined,
undefined,
page.primary.index !== 0 ? actualPageGap : 0,
setRef,
)
}
slots={{ boxProps: { sx: { margin: 'auto' } } }}
/>
);
},
);
export const ReaderVerticalPager = memo(BaseReaderVerticalPager);

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { ReaderStatePages } from '@/features/reader/types/ReaderProgressBar.types.ts';
import { ReaderTransitionPageMode } from '@/features/reader/types/Reader.types.ts';
import { createPageData } from '@/features/reader/utils/ReaderPager.utils.tsx';
export const READER_STATE_PAGES_DEFAULTS: ReaderStatePages = {
totalPages: 0,
setTotalPages: () => undefined,
currentPageIndex: 0,
setCurrentPageIndex: () => undefined,
pageToScrollToIndex: null,
setPageToScrollToIndex: () => undefined,
pageUrls: [],
setPageUrls: () => undefined,
pageLoadStates: [{ url: '', loaded: false }],
setPageLoadStates: () => undefined,
pages: [createPageData('', 0)],
setPages: () => undefined,
transitionPageMode: ReaderTransitionPageMode.NONE,
setTransitionPageMode: () => undefined,
retryFailedPagesKeyPrefix: '',
setRetryFailedPagesKeyPrefix: () => undefined,
};

View File

@@ -0,0 +1,414 @@
/*
* 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 ArrowCircleLeftIcon from '@mui/icons-material/ArrowCircleLeft';
import ArrowCircleRightIcon from '@mui/icons-material/ArrowCircleRight';
import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
import ExpandIcon from '@mui/icons-material/Expand';
import CropOriginalIcon from '@mui/icons-material/CropOriginal';
import { TooltipProps } from '@mui/material/Tooltip';
import { Direction } from '@mui/material/styles';
import { ScrollDirection, ValueToDisplayData } from '@/features/core/Core.types.ts';
import {
IReaderSettings,
IReaderSettingsGlobal,
ProgressBarPosition,
ProgressBarType,
ReaderBackgroundColor,
ReaderBlendMode,
ReaderExitMode,
ReaderHotkey,
ReaderOverlayMode,
ReaderPageScaleMode,
ReaderScrollAmount,
ReadingDirection,
ReadingMode,
} from '@/features/reader/types/Reader.types.ts';
import { SinglePageIcon } from '@/assets/icons/svg/SinglePageIcon.tsx';
import { DoublePageIcon } from '@/assets/icons/svg/DoublePageIcon.tsx';
import { ContinuousVerticalPageIcon } from '@/assets/icons/svg/ContinuousVerticalPageIcon.tsx';
import { ContinuousHorizontalPageIcon } from '@/assets/icons/svg/ContinuousHorizontalPageIcon.tsx';
import { TranslationKey } from '@/Base.types.ts';
import { TapZoneLayouts } from '@/features/reader/types/TapZoneLayout.types.ts';
import { WebtoonPageIcon } from '@/assets/icons/svg/WebtoonPageIcon.tsx';
export const AUTO_SCROLL_SPEED = {
min: 0.5,
max: 60,
step: 0.5,
default: 5,
};
export const SCROLL_AMOUNT = {
min: 5,
max: 100,
default: ReaderScrollAmount.LARGE,
step: 5,
};
export const PROGRESS_BAR_SIZE = {
min: 2,
max: 20,
step: 1,
default: 4,
};
export const IMAGE_PRE_LOAD_AMOUNT = {
min: 1,
max: 20,
default: 5,
step: 1,
};
export const PAGE_GAP = {
min: 0,
max: 20,
default: 5,
step: 1,
};
export const CUSTOM_FILTER = {
brightness: {
min: 5,
max: 200,
step: 1,
default: 100,
},
contrast: {
min: 5,
max: 200,
step: 1,
default: 100,
},
saturate: {
min: 0,
max: 200,
step: 1,
default: 100,
},
hue: {
min: 0,
max: 200,
step: 1,
default: 0,
},
rgba: {
red: {
min: 0,
max: 255,
step: 1,
default: 0,
},
green: {
min: 0,
max: 255,
step: 1,
default: 0,
},
blue: {
min: 0,
max: 255,
step: 1,
default: 0,
},
alpha: {
min: 0,
max: 100,
step: 1,
default: 0,
},
},
} as const;
export const READING_DIRECTION_TO_THEME_DIRECTION: Record<ReadingDirection, Direction> = {
[ReadingDirection.LTR]: 'ltr',
[ReadingDirection.RTL]: 'rtl',
};
const GLOBAL_READER_SETTING_OBJECT: Record<keyof IReaderSettingsGlobal, undefined> = {
overlayMode: undefined,
exitMode: undefined,
customFilter: undefined,
shouldSkipDupChapters: undefined,
shouldSkipFilteredChapters: undefined,
progressBarType: undefined,
progressBarSize: undefined,
progressBarPosition: undefined,
progressBarPositionAutoVertical: undefined,
shouldShowPageNumber: undefined,
isStaticNav: undefined,
backgroundColor: undefined,
hotkeys: undefined,
imagePreLoadAmount: undefined,
shouldUseAutoWebtoonMode: undefined,
autoScroll: undefined,
shouldShowReadingModePreview: undefined,
shouldShowTapZoneLayoutPreview: undefined,
shouldInformAboutMissingChapter: undefined,
shouldInformAboutScanlatorChange: undefined,
scrollAmount: undefined,
shouldUseInfiniteScroll: undefined,
shouldShowTransitionPage: undefined,
};
export const GLOBAL_READER_SETTING_KEYS = Object.keys(GLOBAL_READER_SETTING_OBJECT);
export const DEFAULT_READER_SETTINGS: IReaderSettings = {
readerWidth: { value: 50, enabled: false },
overlayMode: ReaderOverlayMode.AUTO,
tapZoneLayout: TapZoneLayouts.RIGHT_LEFT,
tapZoneInvertMode: { vertical: false, horizontal: false },
progressBarType: ProgressBarType.STANDARD,
progressBarSize: PROGRESS_BAR_SIZE.default,
progressBarPosition: ProgressBarPosition.AUTO,
progressBarPositionAutoVertical: ProgressBarPosition.RIGHT,
pageScaleMode: ReaderPageScaleMode.ORIGINAL,
shouldStretchPage: false,
shouldOffsetDoubleSpreads: false,
shouldSkipDupChapters: true,
shouldSkipFilteredChapters: false,
shouldShowPageNumber: true,
isStaticNav: false,
readingDirection: ReadingDirection.LTR,
readingMode: ReadingMode.SINGLE_PAGE,
exitMode: ReaderExitMode.PREVIOUS,
backgroundColor: ReaderBackgroundColor.THEME,
customFilter: {
brightness: {
value: CUSTOM_FILTER.brightness.default,
enabled: false,
},
contrast: {
value: CUSTOM_FILTER.contrast.default,
enabled: false,
},
saturate: {
value: CUSTOM_FILTER.saturate.default,
enabled: false,
},
hue: {
value: CUSTOM_FILTER.hue.default,
enabled: false,
},
rgba: {
value: {
red: CUSTOM_FILTER.rgba.red.default,
green: CUSTOM_FILTER.rgba.green.default,
blue: CUSTOM_FILTER.rgba.blue.default,
alpha: CUSTOM_FILTER.rgba.alpha.default,
blendMode: ReaderBlendMode.DEFAULT,
},
enabled: false,
},
sepia: false,
grayscale: false,
invert: false,
},
pageGap: PAGE_GAP.default,
hotkeys: {
[ReaderHotkey.PREVIOUS_PAGE]: ['arrowleft', 'a'],
[ReaderHotkey.NEXT_PAGE]: ['arrowright', 'd'],
[ReaderHotkey.SCROLL_BACKWARD]: ['arrowup', 'w'],
[ReaderHotkey.SCROLL_FORWARD]: ['arrowdown', 's'],
[ReaderHotkey.PREVIOUS_CHAPTER]: ['comma'],
[ReaderHotkey.NEXT_CHAPTER]: ['period'],
[ReaderHotkey.TOGGLE_MENU]: ['m'],
[ReaderHotkey.CYCLE_SCALE_TYPE]: ['i'],
[ReaderHotkey.STRETCH_IMAGE]: ['f'],
[ReaderHotkey.OFFSET_SPREAD_PAGES]: ['o'],
[ReaderHotkey.CYCLE_READING_MODE]: ['r'],
[ReaderHotkey.CYCLE_READING_DIRECTION]: ['t'],
[ReaderHotkey.TOGGLE_AUTO_SCROLL]: ['space'],
[ReaderHotkey.AUTO_SCROLL_SPEED_INCREASE]: ['b'],
[ReaderHotkey.AUTO_SCROLL_SPEED_DECREASE]: ['v'],
[ReaderHotkey.EXIT_READER]: ['c'],
},
imagePreLoadAmount: IMAGE_PRE_LOAD_AMOUNT.default,
shouldUseAutoWebtoonMode: true,
autoScroll: {
value: AUTO_SCROLL_SPEED.default,
smooth: true,
},
shouldShowReadingModePreview: true,
shouldShowTapZoneLayoutPreview: true,
shouldInformAboutMissingChapter: true,
shouldInformAboutScanlatorChange: true,
scrollAmount: ReaderScrollAmount.LARGE,
shouldUseInfiniteScroll: true,
shouldShowTransitionPage: true,
};
export const READER_PROGRESS_BAR_POSITION_TO_PLACEMENT: Record<ProgressBarPosition, TooltipProps['placement']> = {
[ProgressBarPosition.BOTTOM]: 'top',
[ProgressBarPosition.LEFT]: 'right',
[ProgressBarPosition.RIGHT]: 'left',
// should never get accessed
[ProgressBarPosition.AUTO]: 'left',
};
export const READING_DIRECTION_VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReadingDirection> = {
[ReadingDirection.LTR]: {
title: 'reader.settings.reading_direction.ltr',
icon: <ArrowCircleRightIcon />,
},
[ReadingDirection.RTL]: {
title: 'reader.settings.reading_direction.rtl',
icon: <ArrowCircleLeftIcon />,
},
};
export const READING_DIRECTION_VALUES = Object.values(ReadingDirection).filter((value) => typeof value === 'number');
export const PAGE_SCALE_VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReaderPageScaleMode> = {
[ReaderPageScaleMode.WIDTH]: {
title: 'reader.settings.page_scale.width',
icon: <ExpandIcon sx={{ transform: 'rotate(90deg)' }} />,
},
[ReaderPageScaleMode.HEIGHT]: {
title: 'reader.settings.page_scale.height',
icon: <ExpandIcon />,
},
[ReaderPageScaleMode.SCREEN]: {
title: 'reader.settings.page_scale.screen',
icon: <ZoomOutMapIcon />,
},
[ReaderPageScaleMode.ORIGINAL]: {
title: 'reader.settings.page_scale.original',
icon: <CropOriginalIcon />,
},
};
export const READER_PAGE_SCALE_MODE_VALUES = Object.values(ReaderPageScaleMode).filter(
(value) => typeof value === 'number',
);
export const READER_PAGE_SCALE_MODE_TO_SCALING_ALLOWED: Record<ReaderPageScaleMode, boolean> = {
[ReaderPageScaleMode.WIDTH]: true,
[ReaderPageScaleMode.HEIGHT]: true,
[ReaderPageScaleMode.SCREEN]: true,
[ReaderPageScaleMode.ORIGINAL]: false,
};
export const READING_MODE_VALUE_TO_DISPLAY_DATA = {
[ReadingMode.SINGLE_PAGE]: {
title: 'reader.settings.reader_type.label.single_page',
icon: <SinglePageIcon />,
},
[ReadingMode.DOUBLE_PAGE]: {
title: 'reader.settings.reader_type.label.double_page',
icon: <DoublePageIcon />,
},
[ReadingMode.CONTINUOUS_VERTICAL]: {
title: 'reader.settings.reader_type.label.continuous_vertical',
icon: <ContinuousVerticalPageIcon />,
},
[ReadingMode.CONTINUOUS_HORIZONTAL]: {
title: 'reader.settings.reader_type.label.continuous_horizontal',
icon: <ContinuousHorizontalPageIcon />,
},
[ReadingMode.WEBTOON]: {
title: 'reader.settings.reader_type.label.webtoon',
icon: <WebtoonPageIcon />,
},
} satisfies ValueToDisplayData<ReadingMode>;
export const READING_MODE_VALUES = Object.values(ReadingMode).filter((value) => typeof value === 'number');
export enum ReaderSettingTab {
LAYOUT,
GENERAL,
FILTER,
BEHAVIOUR,
HOTKEYS,
}
export const READER_SETTING_TABS: Record<
ReaderSettingTab,
{
id: ReaderSettingTab;
label: TranslationKey;
supportsTouchDevices: boolean;
}
> = {
[ReaderSettingTab.LAYOUT]: {
id: ReaderSettingTab.LAYOUT,
label: 'reader.settings.label.layout',
supportsTouchDevices: true,
},
[ReaderSettingTab.GENERAL]: {
id: ReaderSettingTab.GENERAL,
label: 'global.label.general',
supportsTouchDevices: true,
},
[ReaderSettingTab.FILTER]: {
id: ReaderSettingTab.FILTER,
label: 'reader.settings.custom_filter.title',
supportsTouchDevices: true,
},
[ReaderSettingTab.BEHAVIOUR]: {
id: ReaderSettingTab.BEHAVIOUR,
label: 'reader.settings.label.behaviour',
supportsTouchDevices: true,
},
[ReaderSettingTab.HOTKEYS]: {
id: ReaderSettingTab.HOTKEYS,
label: 'hotkeys.title_other',
supportsTouchDevices: false,
},
};
export const READER_HOTKEYS = Object.values(ReaderHotkey).filter(
(hotkey) => typeof hotkey === 'number',
) as ReaderHotkey[];
export const READER_BACKGROUND_TO_COLOR = {
[ReaderBackgroundColor.THEME]: 'background.default',
[ReaderBackgroundColor.BLACK]: 'common.black',
[ReaderBackgroundColor.GRAY]: 'grey.200',
[ReaderBackgroundColor.WHITE]: 'common.white',
} as const satisfies Record<ReaderBackgroundColor, string>;
export const CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION: Record<
ReadingMode,
Exclude<ScrollDirection, ScrollDirection.XY>
> = {
[ReadingMode.SINGLE_PAGE]: ScrollDirection.Y,
[ReadingMode.DOUBLE_PAGE]: ScrollDirection.Y,
[ReadingMode.CONTINUOUS_VERTICAL]: ScrollDirection.Y,
[ReadingMode.CONTINUOUS_HORIZONTAL]: ScrollDirection.X,
[ReadingMode.WEBTOON]: ScrollDirection.Y,
};
export const READER_BLEND_MODE_VALUE_TO_DISPLAY_DATA = {
[ReaderBlendMode.DEFAULT]: {
title: 'reader.settings.custom_filter.rgba.blend_mode.default',
icon: null,
},
[ReaderBlendMode.MULTIPLY]: {
title: 'reader.settings.custom_filter.rgba.blend_mode.multiply',
icon: null,
},
[ReaderBlendMode.SCREEN]: {
title: 'reader.settings.custom_filter.rgba.blend_mode.screen',
icon: null,
},
[ReaderBlendMode.OVERLAY]: {
title: 'reader.settings.custom_filter.rgba.blend_mode.overlay',
icon: null,
},
[ReaderBlendMode.DARKEN]: {
title: 'reader.settings.custom_filter.rgba.blend_mode.darken',
icon: null,
},
[ReaderBlendMode.LIGHTEN]: {
title: 'reader.settings.custom_filter.rgba.blend_mode.lighten',
icon: null,
},
} satisfies ValueToDisplayData<ReaderBlendMode>;
export const READER_BLEND_MODE_VALUES = Object.values(ReaderBlendMode);

View File

@@ -0,0 +1,151 @@
/*
* 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 { TapZoneLayouts, TapZoneRegion, TapZoneRegionType } from '@/features/reader/types/TapZoneLayout.types.ts';
import { TranslationKey } from '@/Base.types.ts';
export const READER_TAP_ZONE_LAYOUTS: Record<TapZoneLayouts, TapZoneRegion[]> = {
/**
* +---+---+---+
* | N | N | N | P: Previous
* +---+---+---+
* | N | M | N | M: Menu
* +---+---+---+
* | N | P | N | N: Next
* +---+---+---+
*/
[TapZoneLayouts.EDGE]: [
{
rect: [0, 0, 100, 33.33],
type: TapZoneRegionType.NEXT,
},
{
rect: [0, 33.33, 33.33, 66.66],
type: TapZoneRegionType.NEXT,
},
{
rect: [66.66, 33.33, 33.33, 66.66],
type: TapZoneRegionType.NEXT,
},
{
rect: [33.33, 33.33, 33.33, 33.33],
type: TapZoneRegionType.MENU,
},
{
rect: [33.33, 66.66, 33.33, 33.33],
type: TapZoneRegionType.PREVIOUS,
},
],
/**
* +---+---+---+
* | M | M | M | P: Previous
* +---+---+---+
* | P | N | N | M: Menu
* +---+---+---+
* | P | N | N | N: Next
* +---+---+---+
*/
[TapZoneLayouts.KINDLE]: [
{
rect: [0, 0, 100, 33.33],
type: TapZoneRegionType.MENU,
},
{
rect: [0, 33.33, 33.33, 66.66],
type: TapZoneRegionType.PREVIOUS,
},
{
rect: [33.33, 33.33, 66.66, 66.66],
type: TapZoneRegionType.NEXT,
},
],
/**
* +---+---+---+
* | P | P | P | P: Previous
* +---+---+---+
* | P | M | N | M: Menu
* +---+---+---+
* | N | N | N | N: Next
* +---+---+---+
*/
[TapZoneLayouts.L_SHAPE]: [
{
rect: [0, 0, 100, 33.33],
type: TapZoneRegionType.PREVIOUS,
},
{
rect: [0, 33.33, 33.33, 33.33],
type: TapZoneRegionType.PREVIOUS,
},
{
rect: [33.33, 33.33, 33.33, 33.33],
type: TapZoneRegionType.MENU,
},
{
rect: [66.66, 33.33, 33.33, 33.33],
type: TapZoneRegionType.NEXT,
},
{
rect: [0, 66.66, 100, 33.33],
type: TapZoneRegionType.NEXT,
},
],
/**
* +---+---+---+
* | P | M | N | P: Previous
* +---+---+---+
* | P | M | N | M: Menu
* +---+---+---+
* | P | M | N | N: Next
* +---+---+---+
*/
[TapZoneLayouts.RIGHT_LEFT]: [
{
rect: [0, 0, 33.33, 100],
type: TapZoneRegionType.PREVIOUS,
},
{
rect: [33.33, 0, 33.33, 100],
type: TapZoneRegionType.MENU,
},
{
rect: [66.66, 0, 33.33, 100],
type: TapZoneRegionType.NEXT,
},
],
/**
* +---+---+---+
* | M | M | M | P: Previous
* +---+---+---+
* | M | M | M | M: Menu
* +---+---+---+
* | M | M | M | N: Next
* +---+---+---+
*/
[TapZoneLayouts.DISABLED]: [
{
rect: [0, 0, 100, 100],
type: TapZoneRegionType.MENU,
},
],
};
export const TAP_ZONE_REGION_TYPE_DATA: Record<TapZoneRegionType, { text: TranslationKey; color: string }> = {
[TapZoneRegionType.PREVIOUS]: {
text: 'global.label.previous',
color: 'rgba(255, 114, 118, .5)',
},
[TapZoneRegionType.NEXT]: {
text: 'global.label.next',
color: 'rgba(144, 238, 144, .5)',
},
[TapZoneRegionType.MENU]: {
text: 'global.label.menu',
color: 'rgba(0, 0, 0, .5)',
},
};

View File

@@ -0,0 +1,24 @@
/*
* 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 { createContext, useContext } from 'react';
import { TReaderAutoScrollContext } from '@/features/reader/types/Reader.types.ts';
export const ReaderAutoScrollContext = createContext<TReaderAutoScrollContext>({
isActive: false,
isPaused: false,
setScrollRef: () => {},
start: () => {},
cancel: () => {},
toggleActive: () => {},
pause: () => {},
resume: () => {},
setDirection: () => {},
});
export const useReaderAutoScrollContext = () => useContext(ReaderAutoScrollContext);

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { memo, ReactNode, useCallback, useMemo, useState } from 'react';
import { Direction, useTheme } from '@mui/material/styles';
import { useAutomaticScrolling } from '@/features/core/hooks/useAutomaticScrolling.ts';
import {
IReaderSettings,
ReaderScrollAmount,
ReadingMode,
TReaderAutoScrollContext,
} from '@/features/reader/types/Reader.types.ts';
import { ReaderAutoScrollContext } from '@/features/reader/contexts/ReaderAutoScrollContext.tsx';
import { ReaderControls } from '@/features/reader/services/ReaderControls';
import { isContinuousReadingMode } from '@/features/reader/utils/ReaderSettings.utils.tsx';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION } from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { ScrollOffset } from '@/features/core/Core.types.ts';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
const BaseReaderAutoScrollContextProvider = ({
children,
openPage,
readingMode,
autoScroll,
themeDirection,
combinedDirection,
}: Pick<IReaderSettings, 'readingMode' | 'autoScroll'> & {
children: ReactNode;
openPage: ReturnType<typeof ReaderControls.useOpenPage>;
themeDirection: Direction;
combinedDirection: Direction;
}) => {
const [scrollRef, setScrollRef] = useState<TReaderAutoScrollContext['scrollRef']>();
const [direction, setDirection] = useState<ScrollOffset>(ScrollOffset.FORWARD);
const isScrollingInvertedBasedOnReadingDirection =
readingMode === ReadingMode.CONTINUOUS_HORIZONTAL && themeDirection !== combinedDirection;
const invertScrolling = isScrollingInvertedBasedOnReadingDirection
? getOptionForDirection(
direction === ScrollOffset.BACKWARD,
direction === ScrollOffset.FORWARD,
combinedDirection,
)
: direction === ScrollOffset.BACKWARD;
const isContinuousReadingModeActive = isContinuousReadingMode(readingMode);
const changePage = useCallback(() => {
openPage('next', 'ltr');
}, [openPage]);
const automaticScrolling = useAutomaticScrolling(
isContinuousReadingModeActive ? scrollRef : changePage,
autoScroll.value,
CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION[readingMode],
ReaderScrollAmount.MEDIUM,
invertScrolling,
autoScroll.smooth,
);
const value = useMemo(
() => ({
scrollRef,
setScrollRef,
setDirection,
...automaticScrolling,
}),
[scrollRef, automaticScrolling],
);
return <ReaderAutoScrollContext.Provider value={value}>{children}</ReaderAutoScrollContext.Provider>;
};
export const ReaderAutoScrollContextProvider = withPropsFrom(
memo(BaseReaderAutoScrollContextProvider),
[
ReaderService.useSettingsWithoutDefaultFlag,
() => ({ openPage: ReaderControls.useOpenPage() }),
() => ({ themeDirection: useTheme().direction }),
() => ({ combinedDirection: ReaderService.useGetThemeDirection() }),
],
['openPage', 'readingMode', 'themeDirection', 'combinedDirection', 'autoScroll'],
);

View File

@@ -0,0 +1,29 @@
/*
* 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 { ReactNode } from 'react';
import { ReaderTapZoneContextProvider } from '@/features/reader/contexts/ReaderTapZoneContextProvider.tsx';
import { ReaderProgressBarContextProvider } from '@/features/reader/contexts/ReaderProgressBarContextProvider.tsx';
import { ReaderOverlayContextProvider } from '@/features/reader/contexts/ReaderOverlayContextProvider.tsx';
import { ReaderStateContextProvider } from '@/features/reader/contexts/state/ReaderStateContextProvider.tsx';
import { ReaderScrollbarContextProvider } from '@/features/reader/contexts/ReaderScrollbarContextProvider.tsx';
import { ReaderAutoScrollContextProvider } from '@/features/reader/contexts/ReaderAutoScrollContextProvider.tsx';
export const ReaderContextProvider = ({ children }: { children?: ReactNode }) => (
<ReaderStateContextProvider>
<ReaderTapZoneContextProvider>
<ReaderOverlayContextProvider>
<ReaderProgressBarContextProvider>
<ReaderScrollbarContextProvider>
<ReaderAutoScrollContextProvider>{children}</ReaderAutoScrollContextProvider>
</ReaderScrollbarContextProvider>
</ReaderProgressBarContextProvider>
</ReaderOverlayContextProvider>
</ReaderTapZoneContextProvider>
</ReaderStateContextProvider>
);

View File

@@ -0,0 +1,17 @@
/*
* 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 { createContext, useContext } from 'react';
import { TReaderOverlayContext } from '@/features/reader/types/ReaderOverlay.types.ts';
export const ReaderOverlayContext = createContext<TReaderOverlayContext>({
isVisible: false,
setIsVisible: () => {},
});
export const useReaderOverlayContext = () => useContext(ReaderOverlayContext);

View File

@@ -0,0 +1,18 @@
/*
* 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 { ReactNode, useMemo, useState } from 'react';
import { ReaderOverlayContext } from '@/features/reader/contexts/ReaderOverlayContext.tsx';
export const ReaderOverlayContextProvider = ({ children }: { children: ReactNode }) => {
const [isVisible, setIsVisible] = useState(false);
const value = useMemo(() => ({ isVisible, setIsVisible }), [isVisible]);
return <ReaderOverlayContext.Provider value={value}>{children}</ReaderOverlayContext.Provider>;
};

View File

@@ -0,0 +1,19 @@
/*
* 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 { createContext, useContext } from 'react';
import { TReaderProgressBarContext } from '@/features/reader/types/ReaderProgressBar.types.ts';
export const ReaderProgressBarContext = createContext<TReaderProgressBarContext>({
isMaximized: false,
setIsMaximized: () => undefined,
isDragging: false,
setIsDragging: () => undefined,
});
export const useReaderProgressBarContext = () => useContext(ReaderProgressBarContext);

View File

@@ -0,0 +1,22 @@
/*
* 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 { ReactNode, useMemo, useState } from 'react';
import { ReaderProgressBarContext } from '@/features/reader/contexts/ReaderProgressBarContext.tsx';
export const ReaderProgressBarContextProvider = ({ children }: { children: ReactNode }) => {
const [isMaximized, setIsMaximized] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const value = useMemo(
() => ({ isMaximized, setIsMaximized, isDragging, setIsDragging }),
[isMaximized, isDragging],
);
return <ReaderProgressBarContext.Provider value={value}>{children}</ReaderProgressBarContext.Provider>;
};

View File

@@ -0,0 +1,19 @@
/*
* 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 { createContext, useContext } from 'react';
import { TReaderScrollbarContext } from '@/features/reader/types/Reader.types.ts';
export const ReaderScrollbarContext = createContext<TReaderScrollbarContext>({
scrollbarXSize: 0,
setScrollbarXSize: () => undefined,
scrollbarYSize: 0,
setScrollbarYSize: () => undefined,
});
export const useReaderScrollbarContext = () => useContext(ReaderScrollbarContext);

View File

@@ -0,0 +1,22 @@
/*
* 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 { ReactNode, useMemo, useState } from 'react';
import { ReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
export const ReaderScrollbarContextProvider = ({ children }: { children: ReactNode }) => {
const [scrollbarXSize, setScrollbarXSize] = useState(0);
const [scrollbarYSize, setScrollbarYSize] = useState(0);
const value = useMemo(
() => ({ scrollbarXSize, setScrollbarXSize, scrollbarYSize, setScrollbarYSize }),
[scrollbarXSize, scrollbarYSize],
);
return <ReaderScrollbarContext.Provider value={value}>{children}</ReaderScrollbarContext.Provider>;
};

View File

@@ -0,0 +1,17 @@
/*
* 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 { createContext, useContext } from 'react';
import { TReaderTapZoneContext } from '@/features/reader/types/TapZoneLayout.types.ts';
export const ReaderTapZoneContext = createContext<TReaderTapZoneContext>({
showPreview: false,
setShowPreview: () => undefined,
});
export const useReaderTapZoneContext = () => useContext(ReaderTapZoneContext);

Some files were not shown because too many files have changed in this diff Show More