forked from akai/readest
bfb85c2f68
* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235) Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tts): emit canonical tts-position event from TTSController (#3235) Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers (paragraph mode, RSVP — later slices) drop out-of-order positions. Additive; existing events untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235) RSVPController.syncToCfi + setExternallyDriven: containment match (fixes mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank, no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer suspension while externally driven. ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper returning -1 on no match (never first()). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235) useTTSControl republishes the controller's canonical tts-position (tagged with bookKey) via a dedicated listener — NOT inside the suppression-gated highlight handlers, so page-follow suppression can't silently desync the modes. Adds tts-playback-state (playing/paused/stopped) so RSVP can track playback without the hook-local isPlaying. Verified by extending the real-foliate-view browser harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): paragraph mode follows TTS playback (#3235) When paragraph mode + TTS are both active, the focused paragraph follows the spoken position (sentence granularity, all engines). Section-generation contract (stash cross-section position, apply after the iterator re-inits); sync-focus path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next playing. Start-alignment + visible indicator deferred to later slices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rsvp): speed reader follows TTS playback (#3235) Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge (sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice rate, hold at +60 words cap, snap to first word on each new sentence mark). RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a rsvp-manual-nav signal; re-engage on next playing. Cross-section positions re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235) Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and never engages. Both modes expose ttsSyncStatus (idle/following/syncing/ decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl now forwardRef-exposes the status via an imperative handle. Cross-bookKey events ignored (regression-tested). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): 'following audio' indicator for TTS sync (#3235) 5-state pill (following/syncing/decoupled, idle+unsupported render null) shown top-center in the paragraph overlay and as a status row in the RSVP overlay. Decoupled state is the tap-to-resume control; first decouple fires a one-time toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch targets, safe-area top inset. RSVP 'plain' variant matches its themed surface; non-Edge shows '· estimated'. New i18n keys need extraction before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235) Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/ stops read-along from inside the full-screen overlay, start-aligned to the current word (range validated against the live doc). While TTS-driven, the WPM control shows a locked 'Audio pace' affordance that opens a compact rate picker; rate changes go through a new tts-set-rate bus event reusing the existing throttled setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(reader): e2e paragraph mode follows TTS across a section boundary (#3235) Real <foliate-view> browser e2e: with paragraph mode active, the focused paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5 boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap). Asserts on the owning section of the current range. Test-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(reader): translate TTS-sync strings across 33 locales (#3235) Following audio / · estimated / Resume audio / Stopped following audio / Play audio / Pause audio / Audio pace / Speed follows audio. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): resolve TTS CFI anchors across iframe realms (#3235) RSVP and paragraph follow silently failed to track the spoken word: the CFI anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book iframe's realm, so 'anchor instanceof Range' (top realm) was always false (cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null -> syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before = syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at ~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is single-realm so the old code passed there but died in the app). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235) Two read-along refinements (verified live via CDP with Edge TTS): 1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit BOTH sentence marks and word boundaries; RSVP was routing sentence -> the estimator (self-paces ~190xrate, up to +60 words ahead) while word positions snapped it back. Now once a word position is seen, sentence positions are ignored and any running estimator is stopped, so words alone drive RSVP. 2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full stop releases it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235) - Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio pace' lock (layout shift). New 'paused' sync status keeps the indicator row and WPM lock present while TTS is engaged-but-paused; only a full stop clears them. Verified live via CDP: pause keeps the layout, no shift. - Dict management is reachable from RSVP: the settings dialog is z-50, far below the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it. handleManageDictionary now exits RSVP first (position saved/resumable) so management shows over the reader. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): show dict management over RSVP instead of exiting it (#3235) Per feedback: opening dictionary management from the RSVP lookup popup no longer closes the speed reader. The settings dialog is raised above the full-screen RSVP overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's capture-phase keyboard handler bails while the settings dialog is open so its inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP: management opens over RSVP, RSVP stays active behind it, Escape returns to it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dict): only apply drag-handle margin compensation when the handle shows (#3235) The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle, but that handle is sm:hidden (shown only below sm). On sm+ the handle is display:none, so -mt-4 pulled the header up into the top edge (broken layout when the lookup renders as a sheet on a short/wide window). Mirror the handle's breakpoint: -mt-4 sm:mt-0. Verified live via CDP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): in-mode TTS audio toggle for paragraph mode (#3235) Paragraph mode already follows TTS, but there was no way to start read-along from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it starts TTS start-aligned to the focused paragraph (range validated live, + section index) and stops it. Track session-active vs playing so a pause keeps the indicator ('paused' status) instead of collapsing to idle. Pure buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping the icon starts audio from the focused paragraph and the focus follows speech. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): highlight current TTS word/sentence in paragraph mode (#3235) Paragraph mode follows TTS by advancing the focused paragraph, but the spoken word wasn't highlighted within it like normal mode. The overlay renders a CLONE of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline boundaries natively, leaves the fade-in animation untouched). - TTSController already tags tts-position with kind word|sentence. The hook decides granularity: word boundaries (Edge) drive a per-word highlight; once seen, the coarse sentence event is skipped so the whole sentence doesn't flicker over the current word. Engines without word boundaries (WebSpeech/Native) fall back to the sentence highlight. - Offsets are computed relative to the paragraph start (so they map 1:1 onto the clone's text) and tagged with the paragraph index so a stale highlight never paints the wrong paragraph. Cleared on stop / section change / disabled. - The ::highlight() style mirrors the user's ttsHighlightOptions color+style. Pure helpers (offset math, word/sentence decision, css builder) unit-tested. Verified live via CDP: word highlight tracks Edge word-by-word and follows across paragraph boundaries (news -> ... -> ladies), matching the TTS color. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
891 lines
29 KiB
TypeScript
891 lines
29 KiB
TypeScript
import React from 'react';
|
|
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import ParagraphOverlay from '@/app/reader/components/paragraph/ParagraphOverlay';
|
|
import { useParagraphMode } from '@/app/reader/hooks/useParagraphMode';
|
|
import type { FoliateView } from '@/types/view';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import {
|
|
getParagraphActionForKey,
|
|
getParagraphActionForZone,
|
|
getParagraphPresentation,
|
|
} from '@/utils/paragraphPresentation';
|
|
|
|
const currentViewSettings = {
|
|
paragraphMode: { enabled: true },
|
|
writingMode: 'horizontal-tb',
|
|
vertical: false,
|
|
rtl: false,
|
|
};
|
|
|
|
const mockGetViewSettings = vi.fn(() => currentViewSettings);
|
|
const mockSetViewSettings = vi.fn();
|
|
const mockGetProgress = vi.fn(() => null);
|
|
|
|
let mockIsFixedLayout = false;
|
|
|
|
vi.mock('@/store/bookDataStore', () => ({
|
|
useBookDataStore: () => ({
|
|
getBookData: () => ({ isFixedLayout: mockIsFixedLayout }),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@/context/EnvContext', () => ({
|
|
useEnv: () => ({ envConfig: {}, appService: { hasSafeAreaInset: false } }),
|
|
}));
|
|
|
|
vi.mock('@/helpers/settings', () => ({
|
|
saveViewSettings: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/store/readerStore', () => ({
|
|
useReaderStore: () => ({
|
|
getViewSettings: mockGetViewSettings,
|
|
setViewSettings: mockSetViewSettings,
|
|
getProgress: mockGetProgress,
|
|
}),
|
|
}));
|
|
|
|
global.ResizeObserver = class ResizeObserver {
|
|
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
|
|
observe(target: Element) {
|
|
this.callback([{ target } as ResizeObserverEntry], this);
|
|
}
|
|
|
|
disconnect() {}
|
|
|
|
unobserve() {}
|
|
} as typeof ResizeObserver;
|
|
|
|
const createDoc = (body: string): Document =>
|
|
new DOMParser().parseFromString(`<html><body>${body}</body></html>`, 'text/html');
|
|
|
|
const attachDefaultView = (
|
|
doc: Document,
|
|
getComputedStyle: (element: Element) => CSSStyleDeclaration,
|
|
) => {
|
|
Object.defineProperty(doc, 'defaultView', {
|
|
value: { getComputedStyle },
|
|
configurable: true,
|
|
});
|
|
};
|
|
|
|
function createMockView(docs: Document[], initialPrimaryIndex: number) {
|
|
const contents = docs.map((doc, index) => ({ doc, index }));
|
|
|
|
const renderer = {
|
|
primaryIndex: initialPrimaryIndex,
|
|
getContents: vi.fn(() => contents),
|
|
nextSection: vi.fn(async () => {
|
|
renderer.primaryIndex = Math.min(renderer.primaryIndex + 1, contents.length - 1);
|
|
}),
|
|
prevSection: vi.fn(async () => {
|
|
renderer.primaryIndex = Math.max(renderer.primaryIndex - 1, 0);
|
|
}),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
goTo: vi.fn(),
|
|
scrollToAnchor: vi.fn(),
|
|
};
|
|
|
|
const view = {
|
|
renderer,
|
|
resolveCFI: vi.fn(),
|
|
getCFI: vi.fn(() => 'epubcfi(/6/4!/4/2/1:0)'),
|
|
} as unknown as FoliateView;
|
|
|
|
return { view, renderer };
|
|
}
|
|
|
|
let hookApi: ReturnType<typeof useParagraphMode> | null = null;
|
|
|
|
const HookHarness = ({ view }: { view: React.RefObject<FoliateView | null> }) => {
|
|
hookApi = useParagraphMode({ bookKey: 'book-1', viewRef: view });
|
|
return null;
|
|
};
|
|
|
|
describe('paragraph mode', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
hookApi = null;
|
|
mockIsFixedLayout = false;
|
|
currentViewSettings.writingMode = 'horizontal-tb';
|
|
currentViewSettings.vertical = false;
|
|
currentViewSettings.rtl = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it('preserves source presentation and navigation rules', () => {
|
|
const verticalDoc = createDoc('<p lang="ja">縦書きの段落です。</p>');
|
|
const verticalParagraph = verticalDoc.querySelector('p')!;
|
|
const verticalRange = verticalDoc.createRange();
|
|
verticalRange.selectNodeContents(verticalParagraph);
|
|
|
|
attachDefaultView(verticalDoc, (element: Element) => {
|
|
if (element === verticalParagraph || element === verticalDoc.body) {
|
|
return {
|
|
writingMode: 'vertical-rl',
|
|
direction: 'ltr',
|
|
textOrientation: 'upright',
|
|
unicodeBidi: 'plaintext',
|
|
textAlign: 'start',
|
|
} as CSSStyleDeclaration;
|
|
}
|
|
|
|
return {
|
|
writingMode: 'horizontal-tb',
|
|
direction: 'ltr',
|
|
} as CSSStyleDeclaration;
|
|
});
|
|
|
|
const arabicDoc = createDoc('<p dir="rtl">هذا نص عربي</p>');
|
|
const arabicParagraph = arabicDoc.querySelector('p')!;
|
|
const arabicRange = arabicDoc.createRange();
|
|
arabicRange.selectNodeContents(arabicParagraph);
|
|
attachDefaultView(
|
|
arabicDoc,
|
|
() =>
|
|
({
|
|
writingMode: 'horizontal-tb',
|
|
direction: 'rtl',
|
|
textAlign: 'start',
|
|
}) as CSSStyleDeclaration,
|
|
);
|
|
|
|
expect(getParagraphPresentation(verticalDoc, verticalRange)).toEqual(
|
|
expect.objectContaining({
|
|
lang: 'ja',
|
|
dir: 'ltr',
|
|
writingMode: 'vertical-rl',
|
|
vertical: true,
|
|
}),
|
|
);
|
|
expect(getParagraphPresentation(arabicDoc, arabicRange)).toEqual(
|
|
expect.objectContaining({
|
|
dir: 'rtl',
|
|
rtl: true,
|
|
}),
|
|
);
|
|
|
|
expect(getParagraphActionForZone('left', { rtl: true, vertical: false })).toBe('next');
|
|
expect(getParagraphActionForZone('top', { vertical: true, writingMode: 'vertical-rl' })).toBe(
|
|
'prev',
|
|
);
|
|
expect(getParagraphActionForKey('ArrowLeft', { rtl: true, vertical: false })).toBe('next');
|
|
expect(
|
|
getParagraphActionForKey('ArrowLeft', { vertical: true, writingMode: 'vertical-rl' }),
|
|
).toBe('next');
|
|
});
|
|
|
|
it('uses the active primary section when moving across chapter boundaries', async () => {
|
|
const previousChapterDoc = createDoc('<p>Old chapter ending</p>');
|
|
const nextChapterDoc = createDoc('<h1>Chapter 2</h1><p>First paragraph</p>');
|
|
const { view, renderer } = createMockView([previousChapterDoc, nextChapterDoc], 0);
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Old chapter ending');
|
|
});
|
|
|
|
await act(async () => {
|
|
await hookApi?.goToNextParagraph();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Chapter 2');
|
|
});
|
|
|
|
expect(renderer.nextSection).toHaveBeenCalledTimes(1);
|
|
await waitFor(() => {
|
|
expect(renderer.goTo).toHaveBeenLastCalledWith(expect.objectContaining({ index: 1 }));
|
|
});
|
|
});
|
|
|
|
it('renders preserved presentation and layout-aware click zones in the overlay', async () => {
|
|
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
|
|
const overlayBookKey = 'overlay-book';
|
|
const doc = createDoc('<p>مرحبا بالعالم</p>');
|
|
const paragraph = doc.querySelector('p')!;
|
|
const range = doc.createRange();
|
|
range.selectNodeContents(paragraph);
|
|
|
|
const { container } = render(
|
|
<ParagraphOverlay
|
|
bookKey={overlayBookKey}
|
|
dimOpacity={0.3}
|
|
viewSettings={{ writingMode: 'horizontal-tb', vertical: false, rtl: true } as never}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('paragraph-focus', {
|
|
bookKey: overlayBookKey,
|
|
range,
|
|
presentation: {
|
|
lang: 'ja',
|
|
dir: 'ltr',
|
|
writingMode: 'vertical-rl',
|
|
textOrientation: 'upright',
|
|
vertical: true,
|
|
rtl: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
const paragraphContent = await waitFor(() => {
|
|
const node = container.querySelector('.paragraph-content') as HTMLDivElement | null;
|
|
expect(node).not.toBeNull();
|
|
return node!;
|
|
});
|
|
expect(paragraphContent.getAttribute('lang')).toBe('ja');
|
|
expect(paragraphContent.style.writingMode).toBe('vertical-rl');
|
|
dispatchSpy.mockClear();
|
|
|
|
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
|
|
vi.spyOn(contentArea, 'getBoundingClientRect').mockReturnValue({
|
|
width: 300,
|
|
height: 300,
|
|
top: 0,
|
|
left: 0,
|
|
right: 300,
|
|
bottom: 300,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
} as DOMRect);
|
|
|
|
fireEvent.click(contentArea, { clientX: 150, clientY: 20 });
|
|
await waitFor(() => {
|
|
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-prev', { bookKey: overlayBookKey });
|
|
});
|
|
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('paragraph-focus', {
|
|
bookKey: overlayBookKey,
|
|
range,
|
|
presentation: {
|
|
dir: 'rtl',
|
|
writingMode: 'horizontal-tb',
|
|
vertical: false,
|
|
rtl: true,
|
|
},
|
|
});
|
|
});
|
|
dispatchSpy.mockClear();
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 320));
|
|
});
|
|
|
|
fireEvent.click(contentArea, { clientX: 40, clientY: 150 });
|
|
await waitFor(() => {
|
|
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-next', { bookKey: overlayBookKey });
|
|
});
|
|
});
|
|
|
|
const renderVisibleOverlay = async (onClose: () => void) => {
|
|
const overlayBookKey = 'overlay-book';
|
|
const doc = createDoc('<p>Hello world</p>');
|
|
const paragraph = doc.querySelector('p')!;
|
|
const range = doc.createRange();
|
|
range.selectNodeContents(paragraph);
|
|
|
|
const { container } = render(
|
|
<ParagraphOverlay
|
|
bookKey={overlayBookKey}
|
|
dimOpacity={0.3}
|
|
viewSettings={{ writingMode: 'horizontal-tb', vertical: false, rtl: false } as never}
|
|
onClose={onClose}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('paragraph-focus', {
|
|
bookKey: overlayBookKey,
|
|
range,
|
|
presentation: { dir: 'ltr', writingMode: 'horizontal-tb', vertical: false, rtl: false },
|
|
});
|
|
});
|
|
|
|
return { container, overlayBookKey };
|
|
};
|
|
|
|
const mockContentRect = (contentArea: HTMLElement) =>
|
|
vi.spyOn(contentArea, 'getBoundingClientRect').mockReturnValue({
|
|
width: 300,
|
|
height: 300,
|
|
top: 0,
|
|
left: 0,
|
|
right: 300,
|
|
bottom: 300,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
} as DOMRect);
|
|
|
|
it('reveals the controls instead of exiting when the backdrop is tapped', async () => {
|
|
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
|
|
const onClose = vi.fn();
|
|
const { container, overlayBookKey } = await renderVisibleOverlay(onClose);
|
|
|
|
const dialog = await waitFor(() => {
|
|
const node = container.querySelector('[role="dialog"]') as HTMLDivElement | null;
|
|
expect(node).not.toBeNull();
|
|
return node!;
|
|
});
|
|
dispatchSpy.mockClear();
|
|
|
|
fireEvent.click(dialog);
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-show-controls', {
|
|
bookKey: overlayBookKey,
|
|
});
|
|
});
|
|
|
|
it('reveals the controls instead of exiting when the center zone is tapped', async () => {
|
|
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
|
|
const onClose = vi.fn();
|
|
const { container, overlayBookKey } = await renderVisibleOverlay(onClose);
|
|
|
|
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
|
|
mockContentRect(contentArea);
|
|
dispatchSpy.mockClear();
|
|
|
|
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-show-controls', {
|
|
bookKey: overlayBookKey,
|
|
});
|
|
expect(dispatchSpy).not.toHaveBeenCalledWith('paragraph-next', { bookKey: overlayBookKey });
|
|
expect(dispatchSpy).not.toHaveBeenCalledWith('paragraph-prev', { bookKey: overlayBookKey });
|
|
});
|
|
|
|
it('still exits on a double-tap of the paragraph', async () => {
|
|
const onClose = vi.fn();
|
|
const { container } = await renderVisibleOverlay(onClose);
|
|
|
|
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
|
|
mockContentRect(contentArea);
|
|
|
|
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
|
|
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('paragraph mode TTS sync', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
hookApi = null;
|
|
mockIsFixedLayout = false;
|
|
currentViewSettings.writingMode = 'horizontal-tb';
|
|
currentViewSettings.vertical = false;
|
|
currentViewSettings.rtl = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
// Multi-paragraph doc: ParagraphIterator turns each <p> into one block, so
|
|
// block N corresponds to the Nth <p>.
|
|
const createMultiParagraphDoc = () =>
|
|
createDoc('<p>Block zero</p><p>Block one</p><p>Block two</p>');
|
|
|
|
// Mock view.resolveCFI so a given cfi resolves into the Nth <p> of the doc at
|
|
// `sectionIndex`. The hook anchors the current section's doc, so `anchor(doc)`
|
|
// returns a Range selecting the target paragraph's contents.
|
|
const stubResolveCFI = (
|
|
view: FoliateView,
|
|
mapping: Record<string, { sectionIndex: number; paragraphIndex: number }>,
|
|
) => {
|
|
(view.resolveCFI as ReturnType<typeof vi.fn>).mockImplementation((cfi: string) => {
|
|
const target = mapping[cfi];
|
|
if (!target) return null;
|
|
return {
|
|
index: target.sectionIndex,
|
|
anchor: (doc: Document) => {
|
|
const paragraph = doc.querySelectorAll('p')[target.paragraphIndex];
|
|
if (!paragraph) return null;
|
|
const range = doc.createRange();
|
|
range.selectNodeContents(paragraph);
|
|
return range;
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const dispatchPlaying = async (bookKey: string) => {
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-playback-state', { bookKey, state: 'playing' });
|
|
});
|
|
};
|
|
|
|
const dispatchPosition = async (detail: {
|
|
bookKey: string;
|
|
cfi: string;
|
|
kind: 'word' | 'sentence';
|
|
sectionIndex: number;
|
|
sequence: number;
|
|
}) => {
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-position', detail);
|
|
});
|
|
};
|
|
|
|
it('follows TTS to the spoken paragraph in the same section', async () => {
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc], 0);
|
|
stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } });
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero');
|
|
});
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
|
|
await dispatchPlaying('book-1');
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-block-2',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 1,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(2);
|
|
});
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block two');
|
|
});
|
|
|
|
it('ignores tts-position events with a stale (<=) sequence', async () => {
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc], 0);
|
|
stubResolveCFI(view, {
|
|
'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 },
|
|
'cfi-block-1': { sectionIndex: 0, paragraphIndex: 1 },
|
|
});
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
});
|
|
|
|
await dispatchPlaying('book-1');
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-block-2',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 5,
|
|
});
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(2);
|
|
});
|
|
|
|
// A later-arriving but older-sequence event must be dropped.
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-block-1',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 3,
|
|
});
|
|
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(2);
|
|
|
|
// An equal sequence is also stale.
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-block-1',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 5,
|
|
});
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(2);
|
|
});
|
|
|
|
it('decouples on manual nav and re-engages on the next playing state', async () => {
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc], 0);
|
|
stubResolveCFI(view, {
|
|
'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 },
|
|
'cfi-block-0': { sectionIndex: 0, paragraphIndex: 0 },
|
|
});
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
});
|
|
|
|
await dispatchPlaying('book-1');
|
|
|
|
// Manual nav decouples: paragraph stops following TTS.
|
|
await act(async () => {
|
|
await hookApi?.goToNextParagraph();
|
|
});
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(1);
|
|
|
|
// While decoupled, tts-position is ignored (no focus change).
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-block-2',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 10,
|
|
});
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(1);
|
|
|
|
// Re-engage via a fresh 'playing' state, then follow again.
|
|
await dispatchPlaying('book-1');
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-block-2',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 11,
|
|
});
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(2);
|
|
});
|
|
});
|
|
|
|
it('stashes a cross-section tts-position and applies it after the iterator re-inits', async () => {
|
|
const sectionZeroDoc = createDoc('<p>S0 first</p><p>S0 second</p>');
|
|
const sectionOneDoc = createDoc('<p>S1 first</p><p>S1 second</p><p>S1 third</p>');
|
|
const { view, renderer } = createMockView([sectionZeroDoc, sectionOneDoc], 0);
|
|
stubResolveCFI(view, { 'cfi-s1-block-2': { sectionIndex: 1, paragraphIndex: 2 } });
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S0 first');
|
|
});
|
|
|
|
// Capture the relocate handler the hook registered with the renderer.
|
|
const relocateCall = (renderer.addEventListener as ReturnType<typeof vi.fn>).mock.calls.find(
|
|
([eventName]) => eventName === 'relocate',
|
|
);
|
|
const handleRelocate = relocateCall?.[1] as () => void;
|
|
expect(handleRelocate).toBeTypeOf('function');
|
|
|
|
await dispatchPlaying('book-1');
|
|
|
|
// A tts-position for section 1 while we are on section 0: must NOT map yet.
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-s1-block-2',
|
|
kind: 'sentence',
|
|
sectionIndex: 1,
|
|
sequence: 20,
|
|
});
|
|
// Still on section 0, focus unchanged (no cross-section mapping).
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S0 first');
|
|
|
|
// Let the initial mount-focus isFocusingRef window (200ms) expire so the
|
|
// relocate below isn't eaten by an unrelated guard.
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
});
|
|
|
|
// TTS drives the view into section 1; the existing relocate handler re-inits
|
|
// the iterator for the new section, after which the stashed CFI applies.
|
|
renderer.primaryIndex = 1;
|
|
await act(async () => {
|
|
handleRelocate();
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(2);
|
|
});
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S1 third');
|
|
});
|
|
|
|
it('does not arm the isFocusingRef guard on a TTS-driven sync focus', async () => {
|
|
const sectionZeroDoc = createDoc('<p>S0 first</p><p>S0 second</p><p>S0 third</p>');
|
|
const sectionOneDoc = createDoc('<p>S1 first</p><p>S1 second</p>');
|
|
const { view, renderer } = createMockView([sectionZeroDoc, sectionOneDoc], 0);
|
|
stubResolveCFI(view, {
|
|
'cfi-s0-block-2': { sectionIndex: 0, paragraphIndex: 2 },
|
|
'cfi-s1-block-1': { sectionIndex: 1, paragraphIndex: 1 },
|
|
});
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
});
|
|
|
|
const relocateCall = (renderer.addEventListener as ReturnType<typeof vi.fn>).mock.calls.find(
|
|
([eventName]) => eventName === 'relocate',
|
|
);
|
|
const handleRelocate = relocateCall?.[1] as () => void;
|
|
|
|
// Drain the initial mount-focus isFocusingRef window (200ms) so only the
|
|
// sync focus under test can possibly arm the guard.
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
});
|
|
|
|
await dispatchPlaying('book-1');
|
|
|
|
// A TTS-driven sync focus within the same section must NOT arm the focusing
|
|
// guard; otherwise the next relocate (a TTS-driven section change) would be
|
|
// swallowed and the iterator would never re-init for the new section.
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-s0-block-2',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 30,
|
|
});
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(2);
|
|
});
|
|
// Let the sync focus fully settle (its scroll runs after a rAF) so that IF
|
|
// it (wrongly) armed isFocusingRef, the guard would be set and persist by
|
|
// the time the relocate below fires.
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
});
|
|
|
|
// A cross-section tts-position stashes; the following relocate for section 1
|
|
// must still re-init. If the sync focus above had armed isFocusingRef, this
|
|
// relocate would be eaten and the iterator would never re-init -> still
|
|
// section 0.
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-s1-block-1',
|
|
kind: 'sentence',
|
|
sectionIndex: 1,
|
|
sequence: 31,
|
|
});
|
|
renderer.primaryIndex = 1;
|
|
await act(async () => {
|
|
handleRelocate();
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S1 second');
|
|
});
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(1);
|
|
});
|
|
|
|
it('does not follow TTS and reports unsupported for a fixed-layout book', async () => {
|
|
mockIsFixedLayout = true;
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc], 0);
|
|
stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } });
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
});
|
|
expect(hookApi?.ttsSyncStatus).toBe('unsupported');
|
|
|
|
await dispatchPlaying('book-1');
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-block-2',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 1,
|
|
});
|
|
|
|
// Fixed-layout never follows: focus stays on the first paragraph.
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero');
|
|
expect(hookApi?.ttsSyncStatus).toBe('unsupported');
|
|
});
|
|
|
|
it('derives ttsSyncStatus through the follow lifecycle (reflowable)', async () => {
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc, createMultiParagraphDoc()], 0);
|
|
stubResolveCFI(view, {
|
|
'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 },
|
|
'cfi-s1-block-1': { sectionIndex: 1, paragraphIndex: 1 },
|
|
});
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
});
|
|
|
|
// Initial: idle (TTS not engaged).
|
|
expect(hookApi?.ttsSyncStatus).toBe('idle');
|
|
|
|
// Playing -> following.
|
|
await dispatchPlaying('book-1');
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsSyncStatus).toBe('following');
|
|
});
|
|
|
|
// Manual nav -> decoupled (TTS still playing).
|
|
await act(async () => {
|
|
await hookApi?.goToNextParagraph();
|
|
});
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsSyncStatus).toBe('decoupled');
|
|
});
|
|
|
|
// Re-engage, then a cross-section position (before re-init) -> syncing.
|
|
await dispatchPlaying('book-1');
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsSyncStatus).toBe('following');
|
|
});
|
|
await dispatchPosition({
|
|
bookKey: 'book-1',
|
|
cfi: 'cfi-s1-block-1',
|
|
kind: 'sentence',
|
|
sectionIndex: 1,
|
|
sequence: 40,
|
|
});
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsSyncStatus).toBe('syncing');
|
|
});
|
|
|
|
// Stopped -> idle.
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-playback-state', {
|
|
bookKey: 'book-1',
|
|
state: 'stopped',
|
|
});
|
|
});
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsSyncStatus).toBe('idle');
|
|
});
|
|
});
|
|
|
|
it('toggleTtsAudio starts TTS aligned to the focused paragraph when idle', async () => {
|
|
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc], 0);
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero');
|
|
});
|
|
expect(hookApi?.ttsActive).toBe(false);
|
|
|
|
dispatchSpy.mockClear();
|
|
act(() => {
|
|
hookApi?.toggleTtsAudio();
|
|
});
|
|
|
|
const speakCall = dispatchSpy.mock.calls.find(([name]) => name === 'tts-speak');
|
|
expect(speakCall).toBeDefined();
|
|
const detail = speakCall![1] as { bookKey: string; index?: number; range?: Range };
|
|
expect(detail.bookKey).toBe('book-1');
|
|
// Start-aligned to the focused paragraph: section index + live range.
|
|
expect(detail.index).toBe(0);
|
|
expect(detail.range?.toString()).toContain('Block zero');
|
|
});
|
|
|
|
it('toggleTtsAudio stops TTS when a session is active', async () => {
|
|
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc], 0);
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
});
|
|
|
|
// A playing session makes the toggle a stop.
|
|
await dispatchPlaying('book-1');
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsActive).toBe(true);
|
|
});
|
|
|
|
dispatchSpy.mockClear();
|
|
act(() => {
|
|
hookApi?.toggleTtsAudio();
|
|
});
|
|
|
|
expect(dispatchSpy).toHaveBeenCalledWith('tts-stop', { bookKey: 'book-1' });
|
|
expect(dispatchSpy).not.toHaveBeenCalledWith('tts-speak', expect.anything());
|
|
});
|
|
|
|
it('keeps ttsActive and reports paused on a TTS pause; clears on stop', async () => {
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc], 0);
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
});
|
|
|
|
await dispatchPlaying('book-1');
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsActive).toBe(true);
|
|
expect(hookApi?.ttsSyncStatus).toBe('following');
|
|
});
|
|
|
|
// Pause keeps the session active and persists the indicator as 'paused'.
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-playback-state', { bookKey: 'book-1', state: 'paused' });
|
|
});
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsActive).toBe(true);
|
|
expect(hookApi?.ttsSyncStatus).toBe('paused');
|
|
});
|
|
|
|
// A full stop clears the session and returns to idle.
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-playback-state', { bookKey: 'book-1', state: 'stopped' });
|
|
});
|
|
await waitFor(() => {
|
|
expect(hookApi?.ttsActive).toBe(false);
|
|
expect(hookApi?.ttsSyncStatus).toBe('idle');
|
|
});
|
|
});
|
|
|
|
it('ignores tts events for a different bookKey and keeps status unchanged', async () => {
|
|
const doc = createMultiParagraphDoc();
|
|
const { view } = createMockView([doc], 0);
|
|
stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } });
|
|
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
|
|
|
|
render(<HookHarness view={viewRef} />);
|
|
await waitFor(() => {
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
});
|
|
expect(hookApi?.ttsSyncStatus).toBe('idle');
|
|
|
|
// Playback + position for a DIFFERENT book: must be ignored.
|
|
await dispatchPlaying('other-book');
|
|
await dispatchPosition({
|
|
bookKey: 'other-book',
|
|
cfi: 'cfi-block-2',
|
|
kind: 'sentence',
|
|
sectionIndex: 0,
|
|
sequence: 1,
|
|
});
|
|
|
|
expect(hookApi?.paragraphState.currentIndex).toBe(0);
|
|
expect(hookApi?.ttsSyncStatus).toBe('idle');
|
|
});
|
|
});
|