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,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}
/>
);
};