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:
Huang Xin
2026-06-22 01:03:24 +08:00
committed by GitHub
parent 9735f497db
commit c781aeddaa
13 changed files with 402 additions and 7 deletions
@@ -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;