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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -75,6 +75,9 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(viewSettings.showRemainingTime);
|
||||
const [showRemainingPages, setShowRemainingPages] = useState(viewSettings.showRemainingPages);
|
||||
const [showProgressInfo, setShowProgressInfo] = useState(viewSettings.showProgressInfo);
|
||||
const [showStickyProgressBar, setShowStickyProgressBar] = useState(
|
||||
viewSettings.showStickyProgressBar,
|
||||
);
|
||||
const [showCurrentTime, setShowCurrentTime] = useState(viewSettings.showCurrentTime);
|
||||
const [use24HourClock, setUse24HourClock] = useState(viewSettings.use24HourClock);
|
||||
const [showCurrentBatteryStatus, setShowCurrentBatteryStatus] = useState(
|
||||
@@ -120,6 +123,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
|
||||
showRemainingTime: setShowRemainingTime,
|
||||
showRemainingPages: setShowRemainingPages,
|
||||
showProgressInfo: setShowProgressInfo,
|
||||
showStickyProgressBar: setShowStickyProgressBar,
|
||||
showCurrentTime: setShowCurrentTime,
|
||||
use24HourClock: setUse24HourClock,
|
||||
showCurrentBatteryStatus: setShowCurrentBatteryStatus,
|
||||
@@ -352,6 +356,18 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showProgressInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(
|
||||
envConfig,
|
||||
bookKey,
|
||||
'showStickyProgressBar',
|
||||
showStickyProgressBar,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showStickyProgressBar]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showCurrentTime', showCurrentTime, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -740,6 +756,13 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
|
||||
data-setting-id='settings.layout.referencePageCount'
|
||||
/>
|
||||
)}
|
||||
<SettingsSwitchRow
|
||||
label={_('Show Progress Bar')}
|
||||
checked={showStickyProgressBar}
|
||||
disabled={!showFooter}
|
||||
onChange={() => setShowStickyProgressBar(!showStickyProgressBar)}
|
||||
data-setting-id='settings.layout.showStickyProgressBar'
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={_('Show Current Time')}
|
||||
checked={showCurrentTime}
|
||||
|
||||
@@ -338,6 +338,7 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
showRemainingTime: false,
|
||||
showRemainingPages: false,
|
||||
showProgressInfo: true,
|
||||
showStickyProgressBar: false,
|
||||
showCurrentTime: false,
|
||||
showCurrentBatteryStatus: false,
|
||||
showBatteryPercentage: true,
|
||||
|
||||
@@ -76,6 +76,7 @@ interface ReaderStore {
|
||||
pageinfo: PageInfo,
|
||||
timeinfo: TimeInfo,
|
||||
range: Range,
|
||||
fraction: number,
|
||||
) => void;
|
||||
getProgress: (key: string) => BookProgress | null;
|
||||
setView: (key: string, view: FoliateView) => void;
|
||||
@@ -375,6 +376,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
pageinfo: PageInfo,
|
||||
timeinfo: TimeInfo,
|
||||
range: Range,
|
||||
fraction: number,
|
||||
) => {
|
||||
const id = key.split('-')[0]!;
|
||||
const bookData = useBookDataStore.getState().booksData[id];
|
||||
@@ -436,6 +438,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
section,
|
||||
pageinfo,
|
||||
timeinfo,
|
||||
fraction,
|
||||
index: section.current,
|
||||
range,
|
||||
page: pageInfo.current + 1,
|
||||
|
||||
@@ -276,6 +276,7 @@ export interface ViewConfig {
|
||||
showRemainingTime: boolean;
|
||||
showRemainingPages: boolean;
|
||||
showProgressInfo: boolean;
|
||||
showStickyProgressBar: boolean;
|
||||
showCurrentTime: boolean;
|
||||
use24HourClock: boolean;
|
||||
showCurrentBatteryStatus: boolean;
|
||||
@@ -400,6 +401,9 @@ export interface BookProgress {
|
||||
pageinfo: PageInfo;
|
||||
pageItem?: { label?: string; href?: string } | null;
|
||||
timeinfo: TimeInfo;
|
||||
// Overall reading position in foliate's size-domain (0..1), matching the
|
||||
// domain used by the sticky progress bar's chapter ticks.
|
||||
fraction: number;
|
||||
index: number;
|
||||
range: Range;
|
||||
page: number;
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface FoliateView extends HTMLElement {
|
||||
init: (options: { lastLocation: string }) => void;
|
||||
goTo: (href: string) => void;
|
||||
goToFraction: (fraction: number) => void;
|
||||
getSectionFractions: () => number[];
|
||||
prev: (distance?: number) => void;
|
||||
next: (distance?: number) => void;
|
||||
pan: (dx: number, dy: number) => void;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user