feat(reader): add sticky progress bar with chapter ticks (#4707)
Add an always-visible, opt-in progress bar with chapter tick marks in the persistent footer, so reading progress no longer disappears like the hover footer slider does. - New StickyProgressBar: a 1px rounded-border capsule with a fill and chapter tick marks; display-only, e-ink aware, and RTL safe. Ticks render inside the clipped track so the rounded ends crop them and they never exceed the border. - Chapter ticks come from the TOC, mapped to spine-section start fractions (getChapterTickFractions); the first and last ticks are trimmed so they do not crowd the rounded ends. - Thread the overall size-domain reading fraction through setProgress so the bar fill aligns with the tick domain. - Footer layout: when enabled the bar grows on the left and the info widgets group to the right with even spacing; otherwise the existing layout is unchanged. - Horizontal writing mode only; vertical keeps the current footer. - Add the showStickyProgressBar view setting, a LayoutPanel toggle, and i18n. Closes #1616. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,7 @@ const FoliateViewer: React.FC<{
|
||||
pageInfo,
|
||||
detail.time,
|
||||
detail.range,
|
||||
detail.fraction,
|
||||
);
|
||||
}, [bookKey, setProgress]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import type { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -7,12 +7,18 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookProgress } from '@/store/readerProgressStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { formatNumber, formatProgress, getReferencePageInfo } from '@/utils/progress';
|
||||
import {
|
||||
formatNumber,
|
||||
formatProgress,
|
||||
getChapterTickFractions,
|
||||
getReferencePageInfo,
|
||||
} from '@/utils/progress';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { SIZE_PER_LOC, SIZE_PER_TIME_UNIT } from '@/services/constants';
|
||||
import type { ProgressBarMode } from '@/types/book.ts';
|
||||
import StatusInfo from './StatusInfo.tsx';
|
||||
import StickyProgressBar from './StickyProgressBar.tsx';
|
||||
|
||||
interface ProgressBarProps {
|
||||
bookKey: string;
|
||||
@@ -68,6 +74,18 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
? `${referenceInfo.current}${isVertical ? ' · ' : ' / '}${referenceInfo.total}`
|
||||
: formatProgress(pageInfo?.current, pageInfo?.total, template, localize, lang);
|
||||
|
||||
// Sticky progress bar is horizontal-only; vertical mode keeps its side footer.
|
||||
const stickyBarActive = viewSettings.showStickyProgressBar && !isVertical;
|
||||
const tickFractions = useMemo(
|
||||
() => (stickyBarActive ? getChapterTickFractions(view, bookData?.bookDoc?.toc) : []),
|
||||
[stickyBarActive, view, bookData?.bookDoc?.toc],
|
||||
);
|
||||
// Same size-domain as the chapter ticks; falls back to the page fraction
|
||||
// before the first relocate has populated progress.fraction.
|
||||
const fillFraction =
|
||||
progress?.fraction ??
|
||||
(pageInfo && pageInfo.total > 0 ? (pageInfo.current + 1) / pageInfo.total : 0);
|
||||
|
||||
const { page: current = 0, pages: total = 0 } = view?.renderer || {};
|
||||
const pagesLeft = bookData?.isFixedLayout
|
||||
? pageInfo
|
||||
@@ -244,14 +262,30 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={clsx('flex items-center justify-between', isVertical ? 'h-full' : 'w-full')}
|
||||
className={clsx(
|
||||
'flex items-center',
|
||||
isVertical ? 'h-full' : 'w-full',
|
||||
// Sticky bar grows on the left; the info widgets pack to the right
|
||||
// with even gaps. Without it, keep the 3-zone left/center/right row.
|
||||
stickyBarActive ? 'gap-x-3' : 'justify-between',
|
||||
)}
|
||||
style={isVertical ? {} : { height: `${viewSettings.marginBottomPx}px` }}
|
||||
>
|
||||
{stickyBarActive && (
|
||||
<StickyProgressBar
|
||||
className='h-3 flex-1'
|
||||
fraction={fillFraction}
|
||||
tickFractions={tickFractions}
|
||||
rtl={viewSettings.rtl}
|
||||
isEink={isEink}
|
||||
/>
|
||||
)}
|
||||
{(progressBarMode === 'all' || progressBarMode.includes('remaining')) &&
|
||||
hasRemainingInfo && (
|
||||
<div
|
||||
className={clsx(
|
||||
'remaining-info flex-1 whitespace-nowrap text-start',
|
||||
'remaining-info whitespace-nowrap text-start',
|
||||
!stickyBarActive && 'flex-1',
|
||||
showStatusInfo && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
@@ -308,7 +342,12 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='progress-info flex-1 items-center overflow-hidden whitespace-nowrap text-end tabular-nums'>
|
||||
<div
|
||||
className={clsx(
|
||||
'progress-info items-center overflow-hidden whitespace-nowrap text-end tabular-nums',
|
||||
!stickyBarActive && 'flex-1',
|
||||
)}
|
||||
>
|
||||
{(progressBarMode === 'all' || progressBarMode.includes('progress')) && (
|
||||
<>
|
||||
{viewSettings.showProgressInfo && (
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
interface StickyProgressBarProps {
|
||||
fraction: number;
|
||||
tickFractions: number[];
|
||||
rtl?: boolean;
|
||||
isEink?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Always-visible, display-only reading progress bar with chapter tick marks.
|
||||
// Positions are expressed from the reading-start edge so the fill grows and the
|
||||
// ticks sit correctly in both LTR and RTL.
|
||||
const StickyProgressBar: React.FC<StickyProgressBarProps> = ({
|
||||
fraction,
|
||||
tickFractions,
|
||||
rtl = false,
|
||||
isEink = false,
|
||||
className,
|
||||
}) => {
|
||||
const pct = Math.max(0, Math.min(1, fraction)) * 100;
|
||||
const startEdge = rtl ? 'right' : 'left';
|
||||
|
||||
return (
|
||||
<div
|
||||
role='presentation'
|
||||
aria-hidden='true'
|
||||
className={clsx('sticky-progress-bar relative flex items-center', className)}
|
||||
>
|
||||
{/* A thin 1px rounded border outlines the whole bar; the fill sits inside it. */}
|
||||
<div
|
||||
className={clsx(
|
||||
'sticky-progress-track absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 overflow-hidden rounded-full border',
|
||||
isEink ? 'border-base-content' : 'border-base-content/40',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'sticky-progress-fill absolute inset-y-0 rounded-full',
|
||||
isEink ? 'bg-base-content' : 'bg-base-content/50',
|
||||
)}
|
||||
style={{ width: `${pct}%`, [startEdge]: 0 }}
|
||||
/>
|
||||
{/* Ticks live inside the clipped, rounded track so the border crops any
|
||||
that land near the rounded ends — they never exceed the outline. */}
|
||||
{tickFractions.map((tick, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx(
|
||||
'sticky-progress-tick absolute inset-y-0 w-px',
|
||||
isEink ? 'bg-base-content' : 'bg-base-content/40',
|
||||
)}
|
||||
style={{ [startEdge]: `${tick * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StickyProgressBar;
|
||||
Reference in New Issue
Block a user