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
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ProgressBar from '@/app/reader/components/ProgressBar';
import { DEFAULT_VIEW_CONFIG } from '@/services/constants';
import type { BookProgress, ViewSettings } from '@/types/book';
import type { TOCItem } from '@/libs/document';
const saveViewSettings = vi.fn();
@@ -11,9 +12,11 @@ let currentViewSettings: ViewSettings;
let currentProgress: BookProgress | null;
let currentBookData: {
isFixedLayout: boolean;
bookDoc?: { metadata?: Record<string, unknown> };
bookDoc?: { metadata?: Record<string, unknown>; toc?: TOCItem[] };
} | null;
let currentRenderer: { page: number; pages: number };
let currentSectionFractions: number[] = [];
let currentTocHrefIndex: Record<string, number> = {};
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string, values?: Record<string, unknown>) =>
@@ -34,7 +37,12 @@ vi.mock('@/store/readerStore', () => {
const state = {
getProgress: () => currentProgress,
getViewSettings: () => currentViewSettings,
getView: () => ({ renderer: currentRenderer }),
getView: () => ({
renderer: currentRenderer,
getSectionFractions: () => currentSectionFractions,
resolveNavigation: (href: string) =>
href in currentTocHrefIndex ? { index: currentTocHrefIndex[href]! } : null,
}),
};
return {
useReaderStore: <R,>(selector?: (s: typeof state) => R) => (selector ? selector(state) : state),
@@ -92,6 +100,8 @@ beforeEach(() => {
currentProgress = null;
currentBookData = { isFixedLayout: false };
currentRenderer = { page: 0, pages: 0 };
currentSectionFractions = [];
currentTocHrefIndex = {};
});
const makeProgress = (current: number, total: number): BookProgress =>
@@ -217,3 +227,62 @@ describe('ProgressBar — decorative footer is not focusable', () => {
expect(progressInfo!.hasAttribute('tabindex')).toBe(false);
});
});
describe('ProgressBar — sticky progress bar', () => {
const tocItem = (href: string): TOCItem => ({ id: 0, label: href, href, index: 0 }) as TOCItem;
const enableStickyBar = (overrides?: Partial<ViewSettings>) => {
currentViewSettings = {
...baseSettings,
showStickyProgressBar: true,
progressInfoMode: 'all',
...overrides,
} as ViewSettings;
// fraction (0.5) deliberately differs from the page fraction
// ((2+1)/5 = 0.6) so the test proves the fill uses progress.fraction.
currentProgress = { ...makeProgress(2, 5), fraction: 0.5 } as BookProgress;
currentBookData = {
isFixedLayout: false,
bookDoc: {
toc: [
tocItem('ch1.xhtml'),
tocItem('ch2.xhtml'),
tocItem('ch3.xhtml'),
tocItem('ch4.xhtml'),
],
},
};
// 5 sections; chapter starts [0.2, 0.4, 0.6, 0.8]; first & last dropped -> 2 ticks.
currentSectionFractions = [0, 0.2, 0.4, 0.6, 0.8, 1];
currentTocHrefIndex = { 'ch1.xhtml': 1, 'ch2.xhtml': 2, 'ch3.xhtml': 3, 'ch4.xhtml': 4 };
currentRenderer = { page: 1, pages: 4 };
};
it('renders the sticky bar with chapter ticks and a fill from progress.fraction', () => {
enableStickyBar();
const { container } = renderProgressBar();
const bar = container.querySelector('.sticky-progress-bar');
expect(bar).not.toBeNull();
expect(bar!.querySelectorAll('.sticky-progress-tick').length).toBe(2);
const fill = bar!.querySelector('.sticky-progress-fill') as HTMLElement;
expect(fill.style.width).toBe('50%');
});
it('does not render the sticky bar when the setting is off', () => {
enableStickyBar({ showStickyProgressBar: false });
const { container } = renderProgressBar();
expect(container.querySelector('.sticky-progress-bar')).toBeNull();
});
it('does not render the sticky bar in vertical writing mode', () => {
enableStickyBar({ vertical: true });
const { container } = renderProgressBar();
expect(container.querySelector('.sticky-progress-bar')).toBeNull();
});
});
@@ -0,0 +1,61 @@
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import StickyProgressBar from '@/app/reader/components/StickyProgressBar';
afterEach(cleanup);
describe('StickyProgressBar', () => {
it('renders the fill width proportional to the reading fraction', () => {
const { container } = render(<StickyProgressBar fraction={0.5} tickFractions={[]} />);
const fill = container.querySelector('.sticky-progress-fill') as HTMLElement;
expect(fill).not.toBeNull();
expect(fill.style.width).toBe('50%');
});
it('clamps the fraction to the 0..1 range', () => {
const over = render(<StickyProgressBar fraction={1.8} tickFractions={[]} />);
expect((over.container.querySelector('.sticky-progress-fill') as HTMLElement).style.width).toBe(
'100%',
);
cleanup();
const under = render(<StickyProgressBar fraction={-0.3} tickFractions={[]} />);
expect(
(under.container.querySelector('.sticky-progress-fill') as HTMLElement).style.width,
).toBe('0%');
});
it('outlines the bar with a thin (1px) rounded border', () => {
const { container } = render(<StickyProgressBar fraction={0.5} tickFractions={[]} />);
const track = container.querySelector('.sticky-progress-track') as HTMLElement;
expect(track).not.toBeNull();
expect(track.classList.contains('border')).toBe(true);
expect(track.classList.contains('rounded-full')).toBe(true);
});
it('renders the ticks inside the clipping track so the rounded border crops them', () => {
const { container } = render(<StickyProgressBar fraction={0} tickFractions={[0.25, 0.75]} />);
const track = container.querySelector('.sticky-progress-track') as HTMLElement;
expect(track.classList.contains('overflow-hidden')).toBe(true);
// Ticks must be descendants of the rounded, clipped track — not siblings —
// so ticks near the rounded ends are cropped and never exceed the border.
expect(track.querySelectorAll('.sticky-progress-tick').length).toBe(2);
});
it('renders one tick per chapter boundary at its start-edge position (LTR)', () => {
const { container } = render(<StickyProgressBar fraction={0} tickFractions={[0.25, 0.75]} />);
const ticks = container.querySelectorAll('.sticky-progress-tick');
expect(ticks.length).toBe(2);
expect((ticks[0] as HTMLElement).style.left).toBe('25%');
expect((ticks[1] as HTMLElement).style.left).toBe('75%');
});
it('positions fill and ticks from the right edge in RTL', () => {
const { container } = render(<StickyProgressBar fraction={0.5} tickFractions={[0.25]} rtl />);
const fill = container.querySelector('.sticky-progress-fill') as HTMLElement;
expect(fill.style.right).toBe('0px');
expect(fill.style.left).toBe('');
const tick = container.querySelector('.sticky-progress-tick') as HTMLElement;
expect(tick.style.right).toBe('25%');
});
});
@@ -123,6 +123,7 @@ interface RelocateDetail {
location: PageInfo;
time: { section: number; total: number };
range: Range;
fraction: number;
}
type BookProgressPageItem = { label?: string; href?: string } | null;
@@ -226,6 +227,7 @@ const wireRelocate = (view: FoliateView) => {
pageInfo,
detail.time,
detail.range,
detail.fraction,
);
});
};
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { getChapterTickFractions } from '@/utils/progress';
import type { TOCItem } from '@/libs/document';
const toc = (href: string, subitems?: TOCItem[]): TOCItem =>
({ id: 0, label: href, href, index: 0, subitems }) as TOCItem;
// 5 spine sections -> sectionFractions has length 6 ([0, .2, .4, .6, .8, 1]),
// matching foliate's `getSectionFractions()` (boundaries incl. start and end).
const sectionFractions = [0, 0.2, 0.4, 0.6, 0.8, 1];
const makeView = (fractions: number[], hrefToIndex: Record<string, number>) => ({
getSectionFractions: () => fractions,
resolveNavigation: (href: string) => (href in hrefToIndex ? { index: hrefToIndex[href]! } : null),
});
describe('getChapterTickFractions', () => {
it('returns chapter-start fractions sorted, excluding the first and last tick', () => {
const view = makeView(sectionFractions, {
'ch4.xhtml': 4,
'ch1.xhtml': 1,
'ch3.xhtml': 3,
'ch2.xhtml': 2,
});
const ticks = getChapterTickFractions(view, [
toc('ch4.xhtml'),
toc('ch1.xhtml'),
toc('ch3.xhtml'),
toc('ch2.xhtml'),
]);
// chapter starts = [0.2, 0.4, 0.6, 0.8]; the first (0.2) and last (0.8) are
// dropped so ticks never crowd the bar's rounded ends.
expect(ticks).toEqual([0.4, 0.6]);
});
it('drops the book-start chapter (section 0) and unresolved hrefs before trimming', () => {
const view = makeView(sectionFractions, {
'intro.xhtml': 0,
'ch1.xhtml': 1,
'ch2.xhtml': 2,
'ch3.xhtml': 3,
});
const ticks = getChapterTickFractions(view, [
toc('intro.xhtml'),
toc('ch1.xhtml'),
toc('ch2.xhtml'),
toc('ch3.xhtml'),
toc('missing.xhtml'),
]);
// interior starts = [0.2, 0.4, 0.6]; first (0.2) and last (0.6) dropped.
expect(ticks).toEqual([0.4]);
});
it('collapses TOC entries (including nested subitems) in one section before trimming', () => {
const view = makeView(sectionFractions, {
'ch1.xhtml': 1,
'ch2.xhtml': 2,
'ch2.xhtml#sec-a': 2,
'ch3.xhtml': 3,
'ch4.xhtml': 4,
});
const ticks = getChapterTickFractions(view, [
toc('ch1.xhtml'),
toc('ch2.xhtml', [toc('ch2.xhtml#sec-a')]),
toc('ch3.xhtml'),
toc('ch4.xhtml'),
]);
// unique starts = [0.2, 0.4, 0.6, 0.8]; first and last dropped.
expect(ticks).toEqual([0.4, 0.6]);
});
it('returns an empty array when there are too few chapters or data is missing', () => {
const view = makeView(sectionFractions, { 'ch1.xhtml': 1, 'ch2.xhtml': 2 });
// only 2 interior ticks -> trimming the first and last leaves none
expect(getChapterTickFractions(view, [toc('ch1.xhtml'), toc('ch2.xhtml')])).toEqual([]);
expect(getChapterTickFractions(null, [toc('ch1.xhtml')])).toEqual([]);
expect(getChapterTickFractions(view, [])).toEqual([]);
expect(getChapterTickFractions(view, null)).toEqual([]);
expect(getChapterTickFractions(makeView([], {}), [toc('ch1.xhtml')])).toEqual([]);
});
});