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
+41
View File
@@ -1,4 +1,45 @@
import { localizeNumber } from './number';
import type { TOCItem } from '@/libs/document';
interface ChapterTickSource {
getSectionFractions: () => number[];
resolveNavigation: (href: string) => { index?: number } | null | undefined;
}
const flattenTOCHrefs = (items: TOCItem[]): string[] =>
items.flatMap((item) => [
...(item.href ? [item.href] : []),
...(item.subitems?.length ? flattenTOCHrefs(item.subitems) : []),
]);
/**
* Reading-fraction positions (0..1) of chapter boundaries for the sticky
* progress bar's tick marks. Each TOC entry's href is resolved to its spine
* section index, then mapped to that section's start fraction (the same
* size-domain as the bar fill). Ticks at the book start/end are dropped and
* duplicates collapsed, so multiple TOC entries inside one spine file yield a
* single tick. The first and last remaining ticks are also dropped so they
* never crowd the bar's rounded ends.
*/
export function getChapterTickFractions(
view: ChapterTickSource | null | undefined,
toc: TOCItem[] | null | undefined,
): number[] {
if (!view || !toc?.length) return [];
const sectionFractions = view.getSectionFractions();
if (!sectionFractions?.length) return [];
// sectionFractions = [0, ...interior boundaries..., 1]; section `i` starts at
// sectionFractions[i]. Keep interior starts only (1 .. length-2).
const lastIndex = sectionFractions.length - 1;
const ticks = new Set<number>();
for (const href of flattenTOCHrefs(toc)) {
const index = view.resolveNavigation(href)?.index;
if (typeof index === 'number' && index >= 1 && index < lastIndex) {
ticks.add(sectionFractions[index]!);
}
}
return [...ticks].sort((a, b) => a - b).slice(1, -1);
}
export function formatProgress(
current: number | undefined,