perf(rsvp): windowed context, extraction caching and lazy CFI for sections with thousands of words, closes #3953 (#3984)

* perf(rsvp): windowed context, extraction caching and lazy CFI for sections with thousands of words, closes #3953

* i18n: update translations
This commit is contained in:
Huang Xin
2026-04-28 23:29:33 +08:00
committed by GitHub
parent 3b03b2c8d5
commit 4b0720a3e3
34 changed files with 436 additions and 84 deletions
@@ -1282,5 +1282,7 @@
"Metadata Hash": "تجزئة البيانات الوصفية",
"Meta Hash": "تجزئة البيانات الوصفية",
"Computed Hash": "التجزئة المحسوبة",
"Identifiers": "المعرفات"
"Identifiers": "المعرفات",
"Read (Stream)": "قراءة (بث)",
"Failed to start stream": "فشل بدء البث"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "মেটাডেটা হ্যাশ",
"Meta Hash": "মেটা হ্যাশ",
"Computed Hash": "গণিত হ্যাশ",
"Identifiers": "শনাক্তকারী"
"Identifiers": "শনাক্তকারী",
"Read (Stream)": "পড়ুন (স্ট্রিম)",
"Failed to start stream": "স্ট্রিম শুরু করতে ব্যর্থ"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "དཔྱད་གཞིའི་ཧེཤི",
"Meta Hash": "དཔྱད་གཞིའི་ཧེཤི",
"Computed Hash": "རྩིས་པའི་ཧེཤི",
"Identifiers": "ངོས་འཛིན་བྱེད་མཁན"
"Identifiers": "ངོས་འཛིན་བྱེད་མཁན",
"Read (Stream)": "ཀློག་པ། (རྒྱུན་སྤེལ།)",
"Failed to start stream": "རྒྱུན་སྤེལ་འགོ་འཛུགས་མ་ཐུབ།"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "Metadaten-Hash",
"Meta Hash": "Meta-Hash",
"Computed Hash": "Berechneter Hash",
"Identifiers": "Bezeichner"
"Identifiers": "Bezeichner",
"Read (Stream)": "Lesen (Stream)",
"Failed to start stream": "Stream konnte nicht gestartet werden"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "Hash Μεταδεδομένων",
"Meta Hash": "Meta Hash",
"Computed Hash": "Υπολογισμένο Hash",
"Identifiers": "Αναγνωριστικά"
"Identifiers": "Αναγνωριστικά",
"Read (Stream)": "Ανάγνωση (Ροή)",
"Failed to start stream": "Αποτυχία έναρξης ροής"
}
@@ -1231,5 +1231,7 @@
"Metadata Hash": "Hash de Metadatos",
"Meta Hash": "Meta Hash",
"Computed Hash": "Hash Calculado",
"Identifiers": "Identificadores"
"Identifiers": "Identificadores",
"Read (Stream)": "Leer (Streaming)",
"Failed to start stream": "No se pudo iniciar el streaming"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "هش متادیتا",
"Meta Hash": "متا هش",
"Computed Hash": "هش محاسبه‌شده",
"Identifiers": "شناسه‌ها"
"Identifiers": "شناسه‌ها",
"Read (Stream)": "خواندن (پخش)",
"Failed to start stream": "شروع پخش ناموفق بود"
}
@@ -1231,5 +1231,7 @@
"Metadata Hash": "Hachage des métadonnées",
"Meta Hash": "Hachage Meta",
"Computed Hash": "Hachage calculé",
"Identifiers": "Identifiants"
"Identifiers": "Identifiants",
"Read (Stream)": "Lire (Flux)",
"Failed to start stream": "Échec du démarrage du flux"
}
@@ -1231,5 +1231,7 @@
"Metadata Hash": "גיבוב מטא-נתונים",
"Meta Hash": "גיבוב מטא",
"Computed Hash": "גיבוב מחושב",
"Identifiers": "מזהים"
"Identifiers": "מזהים",
"Read (Stream)": "קרא (זרם)",
"Failed to start stream": "הפעלת הזרם נכשלה"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "मेटाडेटा हैश",
"Meta Hash": "मेटा हैश",
"Computed Hash": "परिकलित हैश",
"Identifiers": "पहचानकर्ता"
"Identifiers": "पहचानकर्ता",
"Read (Stream)": "पढ़ें (स्ट्रीम)",
"Failed to start stream": "स्ट्रीम प्रारंभ करने में विफल"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "Metaadat-hash",
"Meta Hash": "Meta-hash",
"Computed Hash": "Számított hash",
"Identifiers": "Azonosítók"
"Identifiers": "Azonosítók",
"Read (Stream)": "Olvasás (Stream)",
"Failed to start stream": "Nem sikerült elindítani a streamet"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "Hash Metadata",
"Meta Hash": "Meta Hash",
"Computed Hash": "Hash Terhitung",
"Identifiers": "Pengenal"
"Identifiers": "Pengenal",
"Read (Stream)": "Baca (Stream)",
"Failed to start stream": "Gagal memulai stream"
}
@@ -1231,5 +1231,7 @@
"Metadata Hash": "Hash Metadati",
"Meta Hash": "Meta Hash",
"Computed Hash": "Hash Calcolato",
"Identifiers": "Identificatori"
"Identifiers": "Identificatori",
"Read (Stream)": "Leggi (Stream)",
"Failed to start stream": "Avvio dello stream non riuscito"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "メタデータハッシュ",
"Meta Hash": "メタハッシュ",
"Computed Hash": "計算されたハッシュ",
"Identifiers": "識別子"
"Identifiers": "識別子",
"Read (Stream)": "読む(ストリーム)",
"Failed to start stream": "ストリームの開始に失敗しました"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "메타데이터 해시",
"Meta Hash": "메타 해시",
"Computed Hash": "계산된 해시",
"Identifiers": "식별자"
"Identifiers": "식별자",
"Read (Stream)": "읽기 (스트림)",
"Failed to start stream": "스트림을 시작할 수 없습니다"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "Cincang Metadata",
"Meta Hash": "Cincang Meta",
"Computed Hash": "Cincang Dikira",
"Identifiers": "Pengenal"
"Identifiers": "Pengenal",
"Read (Stream)": "Baca (Strim)",
"Failed to start stream": "Gagal memulakan strim"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "Metadata-hash",
"Meta Hash": "Meta-hash",
"Computed Hash": "Berekende hash",
"Identifiers": "Identificatoren"
"Identifiers": "Identificatoren",
"Read (Stream)": "Lezen (Stream)",
"Failed to start stream": "Kan stream niet starten"
}
@@ -1248,5 +1248,7 @@
"Metadata Hash": "Skrót metadanych",
"Meta Hash": "Meta-skrót",
"Computed Hash": "Obliczony skrót",
"Identifiers": "Identyfikatory"
"Identifiers": "Identyfikatory",
"Read (Stream)": "Czytaj (Strumień)",
"Failed to start stream": "Nie udało się uruchomić strumienia"
}
@@ -1231,5 +1231,7 @@
"Metadata Hash": "Hash de Metadados",
"Meta Hash": "Meta Hash",
"Computed Hash": "Hash Calculado",
"Identifiers": "Identificadores"
"Identifiers": "Identificadores",
"Read (Stream)": "Ler (Streaming)",
"Failed to start stream": "Falha ao iniciar o streaming"
}
@@ -1231,5 +1231,7 @@
"Metadata Hash": "Hash metadate",
"Meta Hash": "Meta hash",
"Computed Hash": "Hash calculat",
"Identifiers": "Identificatori"
"Identifiers": "Identificatori",
"Read (Stream)": "Citește (Flux)",
"Failed to start stream": "Eroare la pornirea fluxului"
}
@@ -1248,5 +1248,7 @@
"Metadata Hash": "Хеш метаданных",
"Meta Hash": "Мета-хеш",
"Computed Hash": "Вычисленный хеш",
"Identifiers": "Идентификаторы"
"Identifiers": "Идентификаторы",
"Read (Stream)": "Читать (Поток)",
"Failed to start stream": "Не удалось запустить поток"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "පාරදත්ත හෑෂ්",
"Meta Hash": "මෙටා හෑෂ්",
"Computed Hash": "ගණනය කළ හෑෂ්",
"Identifiers": "හඳුනාගැනීම්"
"Identifiers": "හඳුනාගැනීම්",
"Read (Stream)": "කියවන්න (ස්ට්‍රීම්)",
"Failed to start stream": "ස්ට්‍රීම් ආරම්භ කිරීම අසාර්ථකයි"
}
@@ -1248,5 +1248,7 @@
"Metadata Hash": "Hash metapodatkov",
"Meta Hash": "Meta hash",
"Computed Hash": "Izračunani hash",
"Identifiers": "Identifikatorji"
"Identifiers": "Identifikatorji",
"Read (Stream)": "Beri (pretok)",
"Failed to start stream": "Pretoka ni bilo mogoče zagnati"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "Metadata-hash",
"Meta Hash": "Meta-hash",
"Computed Hash": "Beräknat hash",
"Identifiers": "Identifierare"
"Identifiers": "Identifierare",
"Read (Stream)": "Läs (ström)",
"Failed to start stream": "Det gick inte att starta strömmen"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "மெட்டாடேட்டா ஹாஷ்",
"Meta Hash": "மெட்டா ஹாஷ்",
"Computed Hash": "கணக்கிடப்பட்ட ஹாஷ்",
"Identifiers": "அடையாளங்காட்டிகள்"
"Identifiers": "அடையாளங்காட்டிகள்",
"Read (Stream)": "படிக்க (ஸ்ட்ரீம்)",
"Failed to start stream": "ஸ்ட்ரீமைத் தொடங்க முடியவில்லை"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "แฮชเมตาดาต้า",
"Meta Hash": "แฮชเมตา",
"Computed Hash": "แฮชที่คำนวณ",
"Identifiers": "ตัวระบุ"
"Identifiers": "ตัวระบุ",
"Read (Stream)": "อ่าน (สตรีม)",
"Failed to start stream": "ไม่สามารถเริ่มสตรีมได้"
}
@@ -1214,5 +1214,7 @@
"Metadata Hash": "Üst Veri Hash'i",
"Meta Hash": "Meta Hash",
"Computed Hash": "Hesaplanan Hash",
"Identifiers": "Tanımlayıcılar"
"Identifiers": "Tanımlayıcılar",
"Read (Stream)": "Oku (Akış)",
"Failed to start stream": "Akış başlatılamadı"
}
@@ -1248,5 +1248,7 @@
"Metadata Hash": "Хеш метаданих",
"Meta Hash": "Мета-хеш",
"Computed Hash": "Обчислений хеш",
"Identifiers": "Ідентифікатори"
"Identifiers": "Ідентифікатори",
"Read (Stream)": "Читати (Потік)",
"Failed to start stream": "Не вдалося запустити потік"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "Hash Siêu Dữ Liệu",
"Meta Hash": "Meta Hash",
"Computed Hash": "Hash Đã Tính",
"Identifiers": "Định Danh"
"Identifiers": "Định Danh",
"Read (Stream)": "Đọc (Stream)",
"Failed to start stream": "Không thể bắt đầu stream"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "元数据哈希",
"Meta Hash": "元哈希",
"Computed Hash": "计算的哈希",
"Identifiers": "标识符"
"Identifiers": "标识符",
"Read (Stream)": "阅读(流式)",
"Failed to start stream": "启动流式传输失败"
}
@@ -1197,5 +1197,7 @@
"Metadata Hash": "中繼資料雜湊",
"Meta Hash": "中繼雜湊",
"Computed Hash": "計算所得雜湊",
"Identifiers": "識別碼"
"Identifiers": "識別碼",
"Read (Stream)": "閱讀(串流)",
"Failed to start stream": "無法啟動串流"
}
@@ -0,0 +1,140 @@
'use client';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import RSVPOverlay from '@/app/reader/components/rsvp/RSVPOverlay';
import type { RSVPController, RsvpState } from '@/services/rsvp';
beforeAll(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn();
}
});
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({
themeCode: { primary: '#000', bg: '#fff', fg: '#111' },
isDarkMode: false,
}),
}));
const buildState = (overrides: Partial<RsvpState> = {}): RsvpState => ({
active: true,
playing: false,
words: [],
currentIndex: 0,
currentPartIndex: 0,
wpm: 300,
punctuationPauseMs: 100,
splitHyphens: false,
progress: 0,
...overrides,
});
const buildController = (state: RsvpState) => {
const listeners = new Map<string, EventListener[]>();
const controller = {
get currentState() {
return state;
},
get currentDisplayWord() {
return state.words[state.currentIndex] ?? null;
},
get currentCountdown() {
return null;
},
seekToIndex: vi.fn(),
seekToPosition: vi.fn(),
skipBackward: vi.fn(),
skipForward: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
togglePlayPause: vi.fn(),
decreaseSpeed: vi.fn(),
increaseSpeed: vi.fn(),
setWpm: vi.fn(),
setPunctuationPause: vi.fn(),
setSplitHyphens: vi.fn(),
getWpmOptions: vi.fn(() => [100, 200, 300]),
getPunctuationPauseOptions: vi.fn(() => [25, 50, 100]),
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type)!.push(listener);
}),
removeEventListener: vi.fn(),
};
return controller;
};
const renderOverlay = (state: RsvpState) => {
const controller = buildController(state);
const result = render(
<RSVPOverlay
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
controller={controller as unknown as RSVPController}
chapters={[]}
currentChapterHref={null}
onClose={vi.fn()}
onChapterSelect={vi.fn()}
onRequestNextPage={vi.fn()}
/>,
);
return { ...result, controller };
};
describe('RSVPOverlay — context panel performance', () => {
afterEach(() => cleanup());
test('renders a bounded number of word buttons for a large word list', () => {
const words = Array.from({ length: 5000 }, (_, i) => ({
text: `word${i}`,
orpIndex: 0,
pauseMultiplier: 1,
}));
const state = buildState({ words, currentIndex: 2500 });
const { container } = renderOverlay(state);
const buttons = container.querySelectorAll('[data-rsvp-word-button]');
// Without windowing this would be 5000; with windowing it should be far fewer.
expect(buttons.length).toBeLessThan(2000);
expect(buttons.length).toBeGreaterThan(0);
});
test('clicking a windowed word seeks to that word', () => {
const words = Array.from({ length: 200 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
}));
const state = buildState({ words, currentIndex: 100 });
const { container, controller } = renderOverlay(state);
const target = container.querySelector('[data-rsvp-word-index="90"]');
expect(target).not.toBeNull();
fireEvent.click(target!);
expect(controller.seekToIndex).toHaveBeenCalledWith(90);
});
test('the current word is rendered with the highlight ref', () => {
const words = Array.from({ length: 100 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
}));
const state = buildState({ words, currentIndex: 42 });
const { container } = renderOverlay(state);
const current = container.querySelector('[data-rsvp-word-index="42"]');
expect(current).not.toBeNull();
// current word should not be a button (not clickable)
expect(current!.getAttribute('role')).toBeNull();
});
});
@@ -4,6 +4,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import clsx from 'clsx';
import { Insets } from '@/types/misc';
import { RsvpState, RSVPController } from '@/services/rsvp';
import { containsCJK } from '@/services/rsvp/utils';
import { useThemeStore } from '@/store/themeStore';
import { TOCItem } from '@/libs/document';
import {
@@ -26,6 +27,36 @@ interface FlatChapter {
level: number;
}
interface ContextWordProps {
text: string;
wordIndex: number;
isCurrent: boolean;
currentRef?: React.Ref<HTMLSpanElement>;
orpColor?: string;
}
const ContextWord = React.memo(function ContextWord({
text,
wordIndex,
isCurrent,
currentRef,
orpColor,
}: ContextWordProps) {
return (
<span
ref={currentRef}
data-rsvp-word-button=''
data-rsvp-word-index={wordIndex}
role={isCurrent ? undefined : 'button'}
tabIndex={isCurrent ? undefined : 0}
className={isCurrent ? undefined : 'cursor-pointer opacity-70 hover:opacity-100'}
style={isCurrent && orpColor ? { color: orpColor } : undefined}
>
{text}{' '}
</span>
);
});
// Display settings
const FONT_SIZE_OPTIONS = [1.25, 1.5, 1.875, 2.25, 3, 3.75, 4.25, 5, 6, 8];
const DEFAULT_FONT_SIZE_INDEX = 4;
@@ -34,6 +65,13 @@ const STORAGE_KEY_FONT_SIZE = 'readest_rsvp_fontsize';
const STORAGE_KEY_ORP_COLOR = 'readest_rsvp_orp_color';
const STORAGE_KEY_CONTEXT = 'readest_rsvp_context';
// Context panel windowing — long sections (e.g. AZW3 chapters with 40k+ words)
// would otherwise render tens of thousands of <span> elements and freeze the UI
// for many seconds on each section load.
const CONTEXT_CHUNK_SIZE = 50;
const CONTEXT_WINDOW_BEFORE = 200;
const CONTEXT_WINDOW_AFTER = 1000;
interface RSVPOverlayProps {
gridInsets: Insets;
controller: RSVPController;
@@ -202,6 +240,9 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const wordBefore = currentWord ? currentWord.text.substring(0, currentWord.orpIndex) : '';
const orpChar = currentWord ? currentWord.text.charAt(currentWord.orpIndex) : '';
const wordAfter = currentWord ? currentWord.text.substring(currentWord.orpIndex + 1) : '';
const isCJKWord = currentWord ? containsCJK(currentWord.text) : false;
const wordLetterSpacing = undefined;
const wordSideOffset = isCJKWord ? '0.5em' : '0.3em';
// Time remaining calculation
const getTimeRemaining = useCallback((): string | null => {
@@ -342,12 +383,53 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
};
const handleWordClick = (wordIndex: number) => {
const wasPlaying = state.playing;
if (wasPlaying) controller.pause();
controller.seekToIndex(wordIndex);
if (wasPlaying) setTimeout(() => controller.resume(), 50);
};
const handleWordClick = useCallback(
(wordIndex: number) => {
const wasPlaying = state.playing;
if (wasPlaying) controller.pause();
controller.seekToIndex(wordIndex);
if (wasPlaying) setTimeout(() => controller.resume(), 50);
},
[state.playing, controller],
);
const handleContextClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const target = (event.target as HTMLElement).closest<HTMLElement>('[data-rsvp-word-index]');
if (!target) return;
if (target.getAttribute('role') !== 'button') return;
const idx = parseInt(target.getAttribute('data-rsvp-word-index') || '', 10);
if (Number.isNaN(idx)) return;
handleWordClick(idx);
},
[handleWordClick],
);
const handleContextKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const target = (event.target as HTMLElement).closest<HTMLElement>('[data-rsvp-word-index]');
if (!target) return;
if (target.getAttribute('role') !== 'button') return;
const idx = parseInt(target.getAttribute('data-rsvp-word-index') || '', 10);
if (Number.isNaN(idx)) return;
event.preventDefault();
handleWordClick(idx);
},
[handleWordClick],
);
const contextWindow = useMemo(() => {
const len = state.words.length;
if (len === 0) return { start: 0, end: 0 };
const chunkStart = Math.floor(state.currentIndex / CONTEXT_CHUNK_SIZE) * CONTEXT_CHUNK_SIZE;
const start = Math.max(0, chunkStart - CONTEXT_WINDOW_BEFORE);
const end = Math.min(len, chunkStart + CONTEXT_CHUNK_SIZE + CONTEXT_WINDOW_AFTER);
return { start, end };
}, [state.currentIndex, state.words.length]);
const hasMoreBefore = contextWindow.start > 0;
const hasMoreAfter = contextWindow.end < state.words.length;
const getProgressBarPercentage = (clientX: number, target: HTMLElement): number => {
const rect = target.getBoundingClientRect();
@@ -553,33 +635,28 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
<div className='text-left text-base leading-relaxed md:text-lg'>
{state.words.map((w, i) => {
const wordIndex = i;
<div
data-testid='rsvp-context-panel'
className='text-left text-base leading-relaxed md:text-lg'
onClick={handleContextClick}
onKeyDown={handleContextKeyDown}
>
{hasMoreBefore && <span className='opacity-30'> </span>}
{state.words.slice(contextWindow.start, contextWindow.end).map((w, i) => {
const wordIndex = contextWindow.start + i;
const isCurrent = wordIndex === state.currentIndex;
return (
<span
<ContextWord
key={wordIndex}
ref={isCurrent ? contextWordRef : undefined}
role={isCurrent ? undefined : 'button'}
tabIndex={isCurrent ? undefined : 0}
className={
isCurrent ? undefined : 'cursor-pointer opacity-70 hover:opacity-100'
}
style={isCurrent ? { color: effectiveOrpColor } : undefined}
onClick={isCurrent ? undefined : () => handleWordClick(wordIndex)}
onKeyDown={
isCurrent
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') handleWordClick(wordIndex);
}
}
>
{w.text}{' '}
</span>
text={w.text}
wordIndex={wordIndex}
isCurrent={isCurrent}
currentRef={isCurrent ? contextWordRef : undefined}
orpColor={isCurrent ? effectiveOrpColor : undefined}
/>
);
})}
{hasMoreAfter && <span className='opacity-30'></span>}
</div>
</div>
)}
@@ -609,11 +686,14 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
{/* Word display */}
<div
className='rsvp-word relative flex min-h-16 w-full items-center justify-center whitespace-nowrap px-2 py-2 font-mono font-medium leading-none tracking-wide sm:min-h-20 sm:px-4 sm:py-4'
style={{ fontSize: `${currentFontSize}rem` }}
style={{ fontSize: `${currentFontSize}rem`, letterSpacing: wordLetterSpacing }}
>
{currentWord ? (
<>
<span className='rsvp-word-before absolute right-[calc(50%+0.3em)] text-right opacity-60'>
<span
className='rsvp-word-before absolute text-right opacity-60'
style={{ right: `calc(50% + ${wordSideOffset})` }}
>
{wordBefore}
</span>
<span
@@ -622,7 +702,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
>
{orpChar}
</span>
<span className='rsvp-word-after absolute left-[calc(50%+0.3em)] text-left opacity-60'>
<span
className='rsvp-word-after absolute text-left opacity-60'
style={{ left: `calc(50% + ${wordSideOffset})` }}
>
{wordAfter}
</span>
</>
@@ -37,6 +37,7 @@ export class RSVPController extends EventTarget {
private countdownTimer: ReturnType<typeof setInterval> | null = null;
private pendingStartWordIndex: number | null = null;
private countdown: number | null = null;
private cachedWords: { docIndex: number; doc: Document; words: RsvpWord[] } | null = null;
constructor(view: FoliateView, bookKey: string) {
super();
@@ -194,7 +195,7 @@ export class RSVPController extends EventTarget {
const currentWord = this.state.words[this.state.currentIndex];
if (!currentWord) return;
const cfi = currentWord.cfi || this.currentCfi;
const cfi = this.getCfiForWord(currentWord) || this.currentCfi;
if (!cfi) return;
const position: RsvpPosition = {
@@ -235,25 +236,87 @@ export class RSVPController extends EventTarget {
}
private findWordIndexByCfi(words: RsvpWord[], targetCfi: string): number {
// First try exact CFI match
for (let i = 0; i < words.length; i++) {
if (words[i]?.cfi === targetCfi) return i;
}
// Find the first word at or after the target position using CFI compare
const targetSpineIndex = this.getSpineIndex(targetCfi);
if (targetSpineIndex < 0) return -1;
// Resolve target CFI to a Range in the section's document so we can
// find the matching word by range comparison (O(1) per check) rather
// than by per-word CFI generation, which dominates extract cost on
// long sections.
const targetRange = this.resolveCfiToRange(targetCfi, targetSpineIndex);
if (targetRange) {
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (!word?.range) continue;
if (word.docIndex !== targetSpineIndex) continue;
try {
if (word.range.compareBoundaryPoints(Range.START_TO_START, targetRange) >= 0) {
return i;
}
} catch {
// Cross-document range compare throws; skip.
}
}
}
// Fallback: per-word CFI compare (slow path, used when the CFI cannot
// be resolved to a range — e.g. fixed-layout pages).
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (!word?.cfi) continue;
if (this.getSpineIndex(word.cfi) !== targetSpineIndex) continue;
if (compareCFI(word.cfi, targetCfi) >= 0) return i;
if (!word?.range || word.docIndex === undefined) continue;
let wordCfi: string | undefined;
try {
wordCfi = this.view.getCFI(word.docIndex, word.range);
} catch {
continue;
}
if (!wordCfi) continue;
if (this.getSpineIndex(wordCfi) !== targetSpineIndex) continue;
if (compareCFI(wordCfi, targetCfi) >= 0) return i;
}
return -1;
}
private resolveCfiToRange(cfi: string, spineIndex: number): Range | null {
try {
const renderer = this.view.renderer;
const contents = renderer?.getContents?.();
if (!contents) return null;
const target = (contents as Array<{ doc: Document; index: number }>).find(
(c) => c.index === spineIndex,
);
if (!target) return null;
const resolved = (
this.view as unknown as {
resolveCFI?: (cfi: string) => { index: number; anchor?: (doc: Document) => unknown };
}
).resolveCFI?.(cfi);
if (!resolved || resolved.index !== spineIndex || typeof resolved.anchor !== 'function') {
return null;
}
const anchor = resolved.anchor(target.doc);
if (anchor instanceof Range) return anchor;
if (anchor && anchor instanceof target.doc.defaultView!.Node) {
const range = target.doc.createRange();
range.selectNode(anchor as Node);
return range;
}
return null;
} catch {
return null;
}
}
private getCfiForWord(word: RsvpWord | undefined): string | undefined {
if (!word?.range || word.docIndex === undefined) return undefined;
try {
return this.view.getCFI(word.docIndex, word.range);
} catch {
return undefined;
}
}
start(retryCount = 0): void {
const words = this.extractWordsWithRanges();
if (words.length === 0) {
@@ -363,7 +426,7 @@ export class RSVPController extends EventTarget {
text: currentWord?.text || '',
range: currentWord?.range,
docIndex: currentWord?.docIndex,
cfi: currentWord?.cfi,
cfi: this.getCfiForWord(currentWord),
};
}
@@ -651,7 +714,17 @@ export class RSVPController extends EventTarget {
const { doc, index: docIndex } = primary as { doc: Document; index: number };
if (!doc?.body) return [];
return this.extractWordsFromElement(doc.body, doc, docIndex);
if (
this.cachedWords &&
this.cachedWords.docIndex === docIndex &&
this.cachedWords.doc === doc
) {
return this.cachedWords.words;
}
const words = this.extractWordsFromElement(doc.body, doc, docIndex);
this.cachedWords = { docIndex, doc, words };
return words;
}
private extractWordsFromElement(
@@ -677,22 +750,15 @@ export class RSVPController extends EventTarget {
range.setStart(node, wordStart);
range.setEnd(node, wordStart + word.length);
// Generate CFI for this word for position tracking
let cfi: string | undefined;
try {
cfi = this.view.getCFI(docIndex, range);
} catch {
// CFI generation failed, will fall back to word index
cfi = undefined;
}
// CFI is computed lazily — see savePositionToStorage(),
// stop(), and findWordIndexByCfi(). At 45k+ words/section,
// eager generation dominates extract time.
words.push({
text: word,
orpIndex: this.calculateORP(word),
pauseMultiplier: this.getPauseMultiplier(word),
range,
docIndex,
cfi,
});
} catch {
words.push({
@@ -790,5 +856,6 @@ export class RSVPController extends EventTarget {
this.stop();
this.clearPositionFromStorage();
this.currentCfi = null;
this.cachedWords = null;
}
}