Structure "reader" in sub-features

This commit is contained in:
schroda
2025-08-16 02:02:13 +02:00
parent fc12f5dfd3
commit f548ba9502
140 changed files with 435 additions and 429 deletions

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/overlay/ReaderOverlay.types.ts';
import { ReaderSettings } from '@/features/reader/settings/screens/ReaderSettings.tsx';
import { ReaderPageNumber } from '@/features/reader/overlay/components/ReaderPageNumber.tsx';
import { StandardReaderProgressBar } from '@/features/reader/overlay/progress-bar/desktop/StandardReaderProgressBar.tsx';
import { ReaderNavBarDesktop } from '@/features/reader/overlay/navigation/desktop/ReaderNavBarDesktop.tsx';
import { ReaderOverlayHeaderMobile } from '@/features/reader/overlay/mobile/ReaderOverlayHeaderMobile.tsx';
import { ReaderBottomBarMobile } from '@/features/reader/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,26 @@
/*
* 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/.
*/
export interface BaseReaderOverlayProps {
isVisible: boolean;
}
export interface MobileHeaderProps extends BaseReaderOverlayProps {}
interface ReaderNavBarBaseProps extends BaseReaderOverlayProps {
openSettings: () => void;
}
export interface ReaderBottomBarMobileProps extends ReaderNavBarBaseProps {}
export interface ReaderNavBarDesktopProps extends ReaderNavBarBaseProps {}
export type TReaderOverlayContext = {
isVisible: boolean;
setIsVisible: React.Dispatch<React.SetStateAction<boolean>>;
};

View File

@@ -0,0 +1,120 @@
/*
* 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/Reader.types.ts';
import { userReaderStatePagesContext } from '@/features/reader/contexts/state/ReaderStatePagesContext.tsx';
import { getPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { useReaderProgressBarContext } from '@/features/reader/overlay/progress-bar/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/overlay/progress-bar/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,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/overlay/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/overlay/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,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 { MutableRefObject, useEffect } from 'react';
import { TReaderOverlayContext } from '@/features/reader/overlay/ReaderOverlay.types.ts';
import { TReaderTapZoneContext } from '@/features/reader/tap-zones/TapZoneLayout.types.ts';
export const useReaderHideOverlayOnUserScroll = (
isOverlayVisible: boolean,
setIsOverlayVisible: TReaderOverlayContext['setIsVisible'],
showPreview: TReaderTapZoneContext['showPreview'],
setShowPreview: TReaderTapZoneContext['setShowPreview'],
scrollElementRef: MutableRefObject<HTMLDivElement | null>,
) => {
useEffect(() => {
const handleScroll = () => {
if (isOverlayVisible) {
setIsOverlayVisible(false);
}
if (showPreview) {
setShowPreview(false);
}
};
scrollElementRef.current?.addEventListener('wheel', handleScroll);
scrollElementRef.current?.addEventListener('touchmove', handleScroll);
return () => {
scrollElementRef.current?.removeEventListener('wheel', handleScroll);
scrollElementRef.current?.removeEventListener('touchmove', handleScroll);
};
}, [isOverlayVisible, showPreview]);
};

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/overlay/ReaderOverlay.types.ts';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import {
ReaderStateChapters,
TReaderScrollbarContext,
TReaderStateMangaContext,
} from '@/features/reader/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/overlay/navigation/components/ReaderLibraryButton.tsx';
import { ReaderBookmarkButton } from '@/features/reader/overlay/navigation/components/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/overlay/navigation/components/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/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/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,162 @@
/*
* 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/overlay/ReaderOverlay.types.ts';
import { ReaderNavContainer } from '@/features/reader/overlay/navigation/desktop/components/ReaderNavContainer.tsx';
import { ReaderNavBarDesktopMetadata } from '@/features/reader/overlay/navigation/desktop/components/ReaderNavBarDesktopMetadata.tsx';
import { ReaderNavBarDesktopPageNavigation } from '@/features/reader/overlay/navigation/desktop/components/ReaderNavBarDesktopPageNavigation.tsx';
import { ReaderNavBarDesktopChapterNavigation } from '@/features/reader/overlay/navigation/desktop/components/ReaderNavBarDesktopChapterNavigation.tsx';
import { ReaderNavBarDesktopQuickSettings } from '@/features/reader/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx';
import { ReaderNavBarDesktopActions } from '@/features/reader/overlay/navigation/desktop/components/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/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/overlay/navigation/components/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/Reader.types.ts';
import { DownloadStateIndicator } from '@/features/core/components/downloads/DownloadStateIndicator.tsx';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/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/overlay/navigation/components/ReaderLibraryButton.tsx';
import { ReaderBookmarkButton } from '@/features/reader/overlay/navigation/components/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/overlay/navigation/components/ReaderChapterList.tsx';
import { ReaderNavBarDesktopNextPreviousButton } from '@/features/reader/overlay/navigation/desktop/components/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/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/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { ReaderStatePages } from '@/features/reader/overlay/progress-bar/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/overlay/navigation/desktop/components/ReaderNavBarDesktopNextPreviousButton.tsx';
import { READING_DIRECTION_TO_THEME_DIRECTION } from '@/features/reader/settings/ReaderSettings.constants.tsx';
import { IReaderSettings } from '@/features/reader/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,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/overlay/navigation/desktop/quick-settings/components/ReaderNavBarDesktopPageScale.tsx';
import { ReaderNavBarDesktopReadingMode } from '@/features/reader/overlay/navigation/desktop/quick-settings/components/ReaderNavBarDesktopReadingMode.tsx';
import { ReaderNavBarDesktopOffsetDoubleSpread } from '@/features/reader/overlay/navigation/desktop/quick-settings/components/ReaderNavBarDesktopOffsetDoubleSpread.tsx';
import { ReaderNavBarDesktopReadingDirection } from '@/features/reader/overlay/navigation/desktop/quick-settings/components/ReaderNavBarDesktopReadingDirection.tsx';
import { IReaderSettingsWithDefaultFlag, TReaderStateMangaContext } from '@/features/reader/Reader.types.ts';
import { ReaderNavBarDesktopProps } from '@/features/reader/overlay/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/auto-scroll/settings/quick-setting/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 Button from '@mui/material/Button';
import { OffsetDoubleSpreadIcon } from '@/assets/icons/svg/OffsetDoubleSpreadIcon.tsx';
import { IReaderSettings } from '@/features/reader/Reader.types.ts';
import { isOffsetDoubleSpreadPagesEditable } from '@/features/reader/settings/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/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/settings/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,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/Reader.types.ts';
import {
READING_DIRECTION_VALUES,
READING_DIRECTION_VALUE_TO_DISPLAY_DATA,
} from '@/features/reader/settings/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/Reader.types.ts';
import {
READING_MODE_VALUE_TO_DISPLAY_DATA,
READING_MODE_VALUES,
} from '@/features/reader/settings/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/overlay/ReaderOverlay.types.ts';
import { MobileReaderProgressBar } from '@/features/reader/overlay/progress-bar/mobile/MobileReaderProgressBar.tsx';
import { ReaderChapterList } from '@/features/reader/overlay/navigation/components/ReaderChapterList.tsx';
import { ReaderBottomBarMobileQuickSettings } from '@/features/reader/overlay/navigation/mobile/quick-settings/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/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/settings/layout/components/ReaderSettingReadingMode.tsx';
import { ReaderSettingReadingDirection } from '@/features/reader/settings/layout/components/ReaderSettingReadingDirection.tsx';
import { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { useReaderStateMangaContext } from '@/features/reader/contexts/state/ReaderStateMangaContext.tsx';
import { DefaultSettingFootnote } from '@/features/reader/settings/components/DefaultSettingFootnote.tsx';
import {
IReaderSettingsWithDefaultFlag,
TReaderAutoScrollContext,
TReaderStateMangaContext,
} from '@/features/reader/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/auto-scroll/settings/ReaderSettingAutoScroll.tsx';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { useReaderAutoScrollContext } from '@/features/reader/auto-scroll/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,266 @@
/*
* 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/overlay/progress-bar/ReaderProgressBar.types.ts';
import { ReaderProgressBarPageNumber } from '@/features/reader/overlay/progress-bar/components/ReaderProgressBarPageNumber.tsx';
import { ReaderProgressBarContainer } from '@/features/reader/overlay/progress-bar/components/ReaderProgressBarContainer.tsx';
import { ReaderProgressBarRoot } from '@/features/reader/overlay/progress-bar/components/ReaderProgressBarRoot.tsx';
import { ReaderProgressBarSlotsContainer } from '@/features/reader/overlay/progress-bar/components/ReaderProgressBarSlotsContainer.tsx';
import { ProgressBarHighlightReadPages } from '@/features/reader/overlay/progress-bar/components/ProgressBarHighlightReadPages.tsx';
import { ReaderProgressBarCurrentPageSlot } from '@/features/reader/overlay/progress-bar/components/ReaderProgressBarCurrentPageSlot.tsx';
import {
getNextIndexFromPage,
getPage,
getPageForMousePos,
getProgressBarPositionInfo,
} from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { getOptionForDirection as getOptionForDirectionImpl } from '@/features/theme/services/ThemeCreator.ts';
import { ReaderProgressBarSlotsActionArea } from '@/features/reader/overlay/progress-bar/components/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/overlay/progress-bar/contexts/ReaderProgressBarContext.tsx';
import { ReaderProgressBarSlotWrapper } from '@/features/reader/overlay/progress-bar/components/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/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,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 { BoxProps } from '@mui/material/Box';
import { TooltipProps } from '@mui/material/Tooltip';
import { IReaderSettings, ReaderTransitionPageMode } from '@/features/reader/Reader.types.ts';
interface SinglePageData {
index: number;
alt: string;
url: string;
}
interface PageData {
name: string;
primary: SinglePageData;
secondary?: SinglePageData;
}
export interface ReaderStatePages {
totalPages: number;
setTotalPages: React.Dispatch<React.SetStateAction<number>>;
currentPageIndex: number;
setCurrentPageIndex: React.Dispatch<React.SetStateAction<number>>;
pageToScrollToIndex: number | null;
setPageToScrollToIndex: React.Dispatch<React.SetStateAction<number | null>>;
pageUrls: string[];
setPageUrls: React.Dispatch<React.SetStateAction<string[]>>;
pageLoadStates: { url: string; loaded: boolean; error?: boolean }[];
setPageLoadStates: React.Dispatch<React.SetStateAction<{ url: string; loaded: boolean; error?: boolean }[]>>;
pages: PageData[];
setPages: React.Dispatch<React.SetStateAction<PageData[]>>;
transitionPageMode: ReaderTransitionPageMode;
setTransitionPageMode: React.Dispatch<React.SetStateAction<ReaderTransitionPageMode>>;
retryFailedPagesKeyPrefix: string;
setRetryFailedPagesKeyPrefix: React.Dispatch<React.SetStateAction<string>>;
}
export interface ReaderProgressBarProps
extends Pick<ReaderStatePages, 'totalPages' | 'pages' | 'pageLoadStates' | 'currentPageIndex'>,
Pick<IReaderSettings, 'progressBarPosition'> {}
export interface TReaderProgressCurrentPage extends PageData {
pagesIndex: number;
}
export interface ReaderProgressBarSlotProps extends Pick<IReaderSettings, 'progressBarPosition'> {
pageName: string;
slotProps?: {
box?: BoxProps;
tooltip?: Omit<TooltipProps, 'title' | 'children'>;
};
}
export interface CurrentPageSlotProps extends Pick<IReaderSettings, 'progressBarPosition'> {
pageName: string;
currentPagesIndex: number;
pagesLength: number;
isDragging: boolean;
boxProps?: BoxProps;
}
export type TReaderProgressBarContext = {
isMaximized: boolean;
setIsMaximized: (visible: boolean) => void;
isDragging: boolean;
setIsDragging: (isDragging: boolean) => void;
};

View File

@@ -0,0 +1,109 @@
/*
* 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 {
ReaderProgressBarProps,
TReaderProgressCurrentPage,
} from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { getOptionForDirection as getOptionForDirectionImpl } from '@/features/theme/services/ThemeCreator.ts';
import { ProgressBarPosition } from '@/features/reader/Reader.types.ts';
import { coerceIn } from '@/lib/HelperFunctions.ts';
export const getPage = (pageIndex: number, pages: ReaderProgressBarProps['pages']): TReaderProgressCurrentPage => {
const pagesIndex = pages.findIndex(({ primary, secondary }) =>
[primary.index, secondary?.index].includes(pageIndex),
);
const page = pages[coerceIn(pagesIndex, 0, pages.length - 1)];
return {
...page,
pagesIndex,
};
};
/**
* for the double page mode the secondary page index has to be used to be able to correctly detect if the last page is visible
*
*/
export const getNextIndexFromPage = (page: ReaderProgressBarProps['pages'][number]) =>
page.secondary?.index ?? page.primary.index;
export const getNextPageIndex = (
offset: 'previous' | 'next',
pagesIndex: number,
pages: ReaderProgressBarProps['pages'],
): number => {
switch (offset) {
case 'previous':
return getNextIndexFromPage(pages[Math.max(0, pagesIndex - 1)]);
case 'next':
return getNextIndexFromPage(pages[Math.min(pages.length - 1, pagesIndex + 1)]);
default:
throw new Error(`Unexpected offset "${offset}"`);
}
};
export const getPageForMousePos = (
coordinates: { clientX: number; clientY: number },
element: HTMLElement,
pages: ReaderProgressBarProps['pages'],
isHorizontalPosition: boolean,
fullSegmentClicks: boolean,
getOptionForDirection: typeof getOptionForDirectionImpl,
): ReaderProgressBarProps['pages'][number] => {
const pos = isHorizontalPosition ? coordinates.clientX : coordinates.clientY;
const { paddingTop, paddingBottom, paddingLeft, paddingRight } = getComputedStyle(element);
const elementRect = element.getBoundingClientRect();
const padding = isHorizontalPosition
? parseFloat(paddingLeft) + parseFloat(paddingRight)
: parseFloat(paddingTop) + parseFloat(paddingBottom);
const rectPos = isHorizontalPosition ? elementRect.left : elementRect.top;
const rectSizeWithPadding = isHorizontalPosition ? elementRect.width : elementRect.height;
const rectSize = rectSizeWithPadding - padding;
const mousePosRelativeToProgressBar = pos - rectPos - padding / 2;
const totalPages = pages.length - Number(!fullSegmentClicks);
const segmentWidth = rectSize / totalPages;
const clickedSegmentIndex = Math.floor(mousePosRelativeToProgressBar / segmentWidth);
const segmentMiddlePoint = (clickedSegmentIndex + (fullSegmentClicks ? 1 : 0.5)) * segmentWidth;
const actualClickedSegmentIndex =
mousePosRelativeToProgressBar <= segmentMiddlePoint ? clickedSegmentIndex : clickedSegmentIndex + 1;
const minPageIndex = Math.max(0, actualClickedSegmentIndex);
const maxPageIndex = Math.min(minPageIndex, pages.length - 1);
const newPageIndex = getOptionForDirection(maxPageIndex, pages.length - 1 - maxPageIndex);
return pages[newPageIndex];
};
export const getProgressBarPositionInfo = (
position: ProgressBarPosition,
): {
isBottom: boolean;
isLeft: boolean;
isRight: boolean;
isHorizontal: boolean;
isVertical: boolean;
} => {
const isBottom = position === ProgressBarPosition.BOTTOM;
const isLeft = position === ProgressBarPosition.LEFT;
const isRight = position === ProgressBarPosition.RIGHT;
const isHorizontal = isBottom;
const isVertical = isLeft || isRight;
return {
isBottom,
isLeft,
isRight,
isHorizontal,
isVertical,
};
};

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/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { getProgressBarPositionInfo } from '@/features/reader/overlay/progress-bar/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,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/Reader.types.ts';
import { shouldForwardProp } from '@/features/core/utils/ShouldForwardProp.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { getProgressBarPositionInfo } from '@/features/reader/overlay/progress-bar/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/overlay/progress-bar/ReaderProgressBar.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { getProgressBarPositionInfo } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { READER_PROGRESS_BAR_POSITION_TO_PLACEMENT } from '@/features/reader/settings/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/overlay/progress-bar/ReaderProgressBar.types.ts';
import { READER_PROGRESS_BAR_POSITION_TO_PLACEMENT } from '@/features/reader/settings/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/overlay/progress-bar/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,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/overlay/progress-bar/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/overlay/progress-bar/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,275 @@
/*
* 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/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/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { getProgressBarPositionInfo } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { ReaderProgressBarDirectionWrapper } from '@/features/reader/overlay/progress-bar/components/ReaderProgressBarDirectionWrapper.tsx';
import {
ReaderProgressBarProps,
TReaderProgressBarContext,
} from '@/features/reader/overlay/progress-bar/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/overlay/progress-bar/contexts/ReaderProgressBarContext.tsx';
import { useReaderScrollbarContext } from '@/features/reader/contexts/ReaderScrollbarContext.tsx';
import { ReaderProgressBarSlotDesktop } from '@/features/reader/overlay/progress-bar/desktop/components/ReaderProgressBarSlotDesktop.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { getProgressBarPosition } from '@/features/reader/settings/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,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/overlay/progress-bar/components/ReaderProgressBarSlot.tsx';
import { IReaderSettings } from '@/features/reader/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,390 @@
/*
* 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/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/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import {
IReaderSettings,
ProgressBarPosition,
ReaderStateChapters,
TReaderScrollbarContext,
} from '@/features/reader/Reader.types.ts';
import { ReaderProgressBarDirectionWrapper } from '@/features/reader/overlay/progress-bar/components/ReaderProgressBarDirectionWrapper.tsx';
import { useReaderProgressBarContext } from '@/features/reader/overlay/progress-bar/contexts/ReaderProgressBarContext.tsx';
import { useReaderOverlayContext } from '@/features/reader/overlay/contexts/ReaderOverlayContext.tsx';
import { withPropsFrom } from '@/features/core/hoc/withPropsFrom.tsx';
import { TReaderOverlayContext } from '@/features/reader/overlay/ReaderOverlay.types.ts';
import {
ReaderProgressBarProps,
TReaderProgressBarContext,
} from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { ReaderProgressBarSlotMobile } from '@/features/reader/overlay/progress-bar/mobile/components/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/settings/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,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/Reader.types.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { ReaderProgressBarSlot } from '@/features/reader/overlay/progress-bar/components/ReaderProgressBarSlot.tsx';
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,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/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/Reader.types.ts';
import { SliderInput } from '@/features/core/components/inputs/SliderInput.tsx';
import { AUTO_SCROLL_SPEED, DEFAULT_READER_SETTINGS } from '@/features/reader/settings/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/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,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/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}
/>
);
};