diff --git a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx index a41a0b87..653eb147 100644 --- a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx @@ -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 }; + bookDoc?: { metadata?: Record; toc?: TOCItem[] }; } | null; let currentRenderer: { page: number; pages: number }; +let currentSectionFractions: number[] = []; +let currentTocHrefIndex: Record = {}; vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (s: string, values?: Record) => @@ -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: (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) => { + 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(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/StickyProgressBar.test.tsx b/apps/readest-app/src/__tests__/components/StickyProgressBar.test.tsx new file mode 100644 index 00000000..8f14283d --- /dev/null +++ b/apps/readest-app/src/__tests__/components/StickyProgressBar.test.tsx @@ -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(); + 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(); + expect((over.container.querySelector('.sticky-progress-fill') as HTMLElement).style.width).toBe( + '100%', + ); + cleanup(); + const under = render(); + 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(); + 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(); + 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(); + 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(); + 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%'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx b/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx index 3cb99f85..d6543920 100644 --- a/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx +++ b/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx @@ -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, ); }); }; diff --git a/apps/readest-app/src/__tests__/utils/progress.test.ts b/apps/readest-app/src/__tests__/utils/progress.test.ts new file mode 100644 index 00000000..1eb37083 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/progress.test.ts @@ -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) => ({ + 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([]); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 7d32ac52..a70582b5 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -195,6 +195,7 @@ const FoliateViewer: React.FC<{ pageInfo, detail.time, detail.range, + detail.fraction, ); }, [bookKey, setProgress]); diff --git a/apps/readest-app/src/app/reader/components/ProgressBar.tsx b/apps/readest-app/src/app/reader/components/ProgressBar.tsx index 4fa4ff3d..40ed2b21 100644 --- a/apps/readest-app/src/app/reader/components/ProgressBar.tsx +++ b/apps/readest-app/src/app/reader/components/ProgressBar.tsx @@ -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 = ({ ? `${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 = ({ >