feat(rsvp): configurable start delay, word stepping, context dictionary lookup, and keyboard shortcut (#4541)

- #4478: add a configurable pre-start countdown (Off / 1s / 2s / 3s, default
  3s) that now ticks at honest one-second intervals and applies to start,
  resume, and page loads; Off starts instantly.
- #4476: add manual next/previous word controls (buttons flanking Play plus the
  "," / "." keys) that pause playback and step exactly one word.
- #4475: allow selecting text in the context panel to look it up in the
  dictionary (anchored popup on desktop, bottom sheet on small screens),
  reusing the reader's dictionary view; auto-scroll/seek are suppressed during
  selection and an outside click dismisses the popup.
- #4473: add a Speed Reading keyboard shortcut (Shift+V), shown on the View
  menu item; ignore repeat triggers while a session is active.

Adds i18n strings for the new UI across all locales.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-12 02:52:27 +08:00
committed by GitHub
parent d6e981e568
commit 390c711070
43 changed files with 865 additions and 41 deletions
@@ -1832,5 +1832,12 @@
"Failed to send book": "فشل إرسال الكتاب",
"Send": "إرسال",
"Highlight Current Sentence": "تمييز الجملة الحالية",
"File Path": "مسار الملف"
"File Path": "مسار الملف",
"Previous word": "الكلمة السابقة",
"Previous word (,)": "الكلمة السابقة (,)",
"Next word": "الكلمة التالية",
"Next word (.)": "الكلمة التالية (.)",
"Start Delay": "تأخير البدء",
"Off": "إيقاف",
"Look up": "بحث"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "বই পাঠাতে ব্যর্থ হয়েছে",
"Send": "পাঠান",
"Highlight Current Sentence": "বর্তমান বাক্য হাইলাইট করুন",
"File Path": "ফাইলের পথ"
"File Path": "ফাইলের পথ",
"Previous word": "পূর্ববর্তী শব্দ",
"Previous word (,)": "পূর্ববর্তী শব্দ (,)",
"Next word": "পরবর্তী শব্দ",
"Next word (.)": "পরবর্তী শব্দ (.)",
"Start Delay": "শুরুর বিলম্ব",
"Off": "বন্ধ",
"Look up": "খুঁজুন"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "དེབ་སྐུར་གཏོང་མ་ཐུབ།",
"Send": "སྐུར་གཏོང་།",
"Highlight Current Sentence": "ད་ལྟའི་ཚིག་གྲུབ་ལ་འོག་ཐིག་གཏོང་།",
"File Path": "ཡིག་ཆའི་ལམ་ཐིག"
"File Path": "ཡིག་ཆའི་ལམ་ཐིག",
"Previous word": "ཚིག་སྔོན་མ།",
"Previous word (,)": "ཚིག་སྔོན་མ། (,)",
"Next word": "ཚིག་རྗེས་མ།",
"Next word (.)": "ཚིག་རྗེས་མ། (.)",
"Start Delay": "འགོ་འཛུགས་ཕྱིར་འགྱངས།",
"Off": "ཁ་རྒྱག",
"Look up": "འཚོལ་བ།"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "Senden des Buchs fehlgeschlagen",
"Send": "Senden",
"Highlight Current Sentence": "Aktuellen Satz hervorheben",
"File Path": "Dateipfad"
"File Path": "Dateipfad",
"Previous word": "Vorheriges Wort",
"Previous word (,)": "Vorheriges Wort (,)",
"Next word": "Nächstes Wort",
"Next word (.)": "Nächstes Wort (.)",
"Start Delay": "Startverzögerung",
"Off": "Aus",
"Look up": "Nachschlagen"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "Αποτυχία αποστολής βιβλίου",
"Send": "Αποστολή",
"Highlight Current Sentence": "Επισήμανση τρέχουσας πρότασης",
"File Path": "Διαδρομή αρχείου"
"File Path": "Διαδρομή αρχείου",
"Previous word": "Προηγούμενη λέξη",
"Previous word (,)": "Προηγούμενη λέξη (,)",
"Next word": "Επόμενη λέξη",
"Next word (.)": "Επόμενη λέξη (.)",
"Start Delay": "Καθυστέρηση έναρξης",
"Off": "Ανενεργό",
"Look up": "Αναζήτηση"
}
@@ -1733,5 +1733,12 @@
"Failed to send book": "No se pudo enviar el libro",
"Send": "Enviar",
"Highlight Current Sentence": "Resaltar la oración actual",
"File Path": "Ruta del archivo"
"File Path": "Ruta del archivo",
"Previous word": "Palabra anterior",
"Previous word (,)": "Palabra anterior (,)",
"Next word": "Palabra siguiente",
"Next word (.)": "Palabra siguiente (.)",
"Start Delay": "Retardo de inicio",
"Off": "Desactivado",
"Look up": "Buscar"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "ارسال کتاب ناموفق بود",
"Send": "ارسال",
"Highlight Current Sentence": "برجسته‌سازی جملهٔ فعلی",
"File Path": "مسیر فایل"
"File Path": "مسیر فایل",
"Previous word": "واژهٔ قبلی",
"Previous word (,)": "واژهٔ قبلی (,)",
"Next word": "واژهٔ بعدی",
"Next word (.)": "واژهٔ بعدی (.)",
"Start Delay": "تأخیر شروع",
"Off": "خاموش",
"Look up": "جستجو"
}
@@ -1733,5 +1733,12 @@
"Failed to send book": "Échec de l'envoi du livre",
"Send": "Envoyer",
"Highlight Current Sentence": "Surligner la phrase actuelle",
"File Path": "Chemin du fichier"
"File Path": "Chemin du fichier",
"Previous word": "Mot précédent",
"Previous word (,)": "Mot précédent (,)",
"Next word": "Mot suivant",
"Next word (.)": "Mot suivant (.)",
"Start Delay": "Délai de démarrage",
"Off": "Désactivé",
"Look up": "Rechercher"
}
@@ -1733,5 +1733,12 @@
"Failed to send book": "שליחת הספר נכשלה",
"Send": "שליחה",
"Highlight Current Sentence": "הדגשת המשפט הנוכחי",
"File Path": "נתיב הקובץ"
"File Path": "נתיב הקובץ",
"Previous word": "מילה קודמת",
"Previous word (,)": "מילה קודמת (,)",
"Next word": "מילה הבאה",
"Next word (.)": "מילה הבאה (.)",
"Start Delay": "השהיית התחלה",
"Off": "כבוי",
"Look up": "חיפוש"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "पुस्तक भेजने में विफल",
"Send": "भेजें",
"Highlight Current Sentence": "वर्तमान वाक्य को हाइलाइट करें",
"File Path": "फाइल पथ"
"File Path": "फाइल पथ",
"Previous word": "पिछला शब्द",
"Previous word (,)": "पिछला शब्द (,)",
"Next word": "अगला शब्द",
"Next word (.)": "अगला शब्द (.)",
"Start Delay": "आरंभ विलंब",
"Off": "बंद",
"Look up": "खोजें"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "A könyv küldése sikertelen",
"Send": "Küldés",
"Highlight Current Sentence": "Aktuális mondat kiemelése",
"File Path": "Fájl elérési útja"
"File Path": "Fájl elérési útja",
"Previous word": "Előző szó",
"Previous word (,)": "Előző szó (,)",
"Next word": "Következő szó",
"Next word (.)": "Következő szó (.)",
"Start Delay": "Indítási késleltetés",
"Off": "Ki",
"Look up": "Keresés"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "Gagal mengirim buku",
"Send": "Kirim",
"Highlight Current Sentence": "Sorot kalimat saat ini",
"File Path": "Jalur File"
"File Path": "Jalur File",
"Previous word": "Kata sebelumnya",
"Previous word (,)": "Kata sebelumnya (,)",
"Next word": "Kata berikutnya",
"Next word (.)": "Kata berikutnya (.)",
"Start Delay": "Tunda mulai",
"Off": "Mati",
"Look up": "Cari"
}
@@ -1733,5 +1733,12 @@
"Failed to send book": "Invio del libro non riuscito",
"Send": "Invia",
"Highlight Current Sentence": "Evidenzia la frase corrente",
"File Path": "Percorso del file"
"File Path": "Percorso del file",
"Previous word": "Parola precedente",
"Previous word (,)": "Parola precedente (,)",
"Next word": "Parola successiva",
"Next word (.)": "Parola successiva (.)",
"Start Delay": "Ritardo di avvio",
"Off": "Disattivato",
"Look up": "Cerca"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "書籍の送信に失敗しました",
"Send": "送信",
"Highlight Current Sentence": "現在の文をハイライト",
"File Path": "ファイルパス"
"File Path": "ファイルパス",
"Previous word": "前の単語",
"Previous word (,)": "前の単語 (,)",
"Next word": "次の単語",
"Next word (.)": "次の単語 (.)",
"Start Delay": "開始遅延",
"Off": "オフ",
"Look up": "調べる"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "책 보내기에 실패했습니다",
"Send": "보내기",
"Highlight Current Sentence": "현재 문장 강조",
"File Path": "파일 경로"
"File Path": "파일 경로",
"Previous word": "이전 단어",
"Previous word (,)": "이전 단어 (,)",
"Next word": "다음 단어",
"Next word (.)": "다음 단어 (.)",
"Start Delay": "시작 지연",
"Off": "끄기",
"Look up": "찾기"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "Gagal menghantar buku",
"Send": "Hantar",
"Highlight Current Sentence": "Serlahkan ayat semasa",
"File Path": "Laluan Fail"
"File Path": "Laluan Fail",
"Previous word": "Perkataan sebelumnya",
"Previous word (,)": "Perkataan sebelumnya (,)",
"Next word": "Perkataan seterusnya",
"Next word (.)": "Perkataan seterusnya (.)",
"Start Delay": "Lengah mula",
"Off": "Mati",
"Look up": "Cari"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "Verzenden van boek mislukt",
"Send": "Verzenden",
"Highlight Current Sentence": "Huidige zin markeren",
"File Path": "Bestandspad"
"File Path": "Bestandspad",
"Previous word": "Vorig woord",
"Previous word (,)": "Vorig woord (,)",
"Next word": "Volgend woord",
"Next word (.)": "Volgend woord (.)",
"Start Delay": "Startvertraging",
"Off": "Uit",
"Look up": "Opzoeken"
}
@@ -1766,5 +1766,12 @@
"Failed to send book": "Nie udało się wysłać książki",
"Send": "Wyślij",
"Highlight Current Sentence": "Podświetl bieżące zdanie",
"File Path": "Ścieżka pliku"
"File Path": "Ścieżka pliku",
"Previous word": "Poprzednie słowo",
"Previous word (,)": "Poprzednie słowo (,)",
"Next word": "Następne słowo",
"Next word (.)": "Następne słowo (.)",
"Start Delay": "Opóźnienie startu",
"Off": "Wył.",
"Look up": "Wyszukaj"
}
@@ -1733,5 +1733,12 @@
"Failed to send book": "Falha ao enviar o livro",
"Send": "Enviar",
"Highlight Current Sentence": "Destacar a frase atual",
"File Path": "Caminho do arquivo"
"File Path": "Caminho do arquivo",
"Previous word": "Palavra anterior",
"Previous word (,)": "Palavra anterior (,)",
"Next word": "Próxima palavra",
"Next word (.)": "Próxima palavra (.)",
"Start Delay": "Atraso de início",
"Off": "Desligado",
"Look up": "Pesquisar"
}
@@ -1733,5 +1733,12 @@
"Failed to send book": "Falha ao enviar o livro",
"Send": "Enviar",
"Highlight Current Sentence": "Destacar a frase atual",
"File Path": "Caminho do Arquivo"
"File Path": "Caminho do Arquivo",
"Previous word": "Palavra anterior",
"Previous word (,)": "Palavra anterior (,)",
"Next word": "Próxima palavra",
"Next word (.)": "Próxima palavra (.)",
"Start Delay": "Atraso de início",
"Off": "Desligado",
"Look up": "Pesquisar"
}
@@ -1733,5 +1733,12 @@
"Failed to send book": "Trimiterea cărții a eșuat",
"Send": "Trimite",
"Highlight Current Sentence": "Evidențiază propoziția curentă",
"File Path": "Calea fișierului"
"File Path": "Calea fișierului",
"Previous word": "Cuvântul anterior",
"Previous word (,)": "Cuvântul anterior (,)",
"Next word": "Cuvântul următor",
"Next word (.)": "Cuvântul următor (.)",
"Start Delay": "Întârziere la pornire",
"Off": "Dezactivat",
"Look up": "Caută"
}
@@ -1766,5 +1766,12 @@
"Failed to send book": "Не удалось отправить книгу",
"Send": "Отправить",
"Highlight Current Sentence": "Выделять текущее предложение",
"File Path": "Путь к файлу"
"File Path": "Путь к файлу",
"Previous word": "Предыдущее слово",
"Previous word (,)": "Предыдущее слово (,)",
"Next word": "Следующее слово",
"Next word (.)": "Следующее слово (.)",
"Start Delay": "Задержка старта",
"Off": "Выкл.",
"Look up": "Найти"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "පොත යැවීමට අසමත් විය",
"Send": "යවන්න",
"Highlight Current Sentence": "වර්තමාන වාක්‍යය ඉස්මතු කරන්න",
"File Path": "ගොනු මාර්ගය"
"File Path": "ගොනු මාර්ගය",
"Previous word": "පෙර වචනය",
"Previous word (,)": "පෙර වචනය (,)",
"Next word": "ඊළඟ වචනය",
"Next word (.)": "ඊළඟ වචනය (.)",
"Start Delay": "ආරම්භක ප්‍රමාදය",
"Off": "අක්‍රිය",
"Look up": "සොයන්න"
}
@@ -1766,5 +1766,12 @@
"Failed to send book": "Pošiljanje knjige ni uspelo",
"Send": "Pošlji",
"Highlight Current Sentence": "Označi trenutni stavek",
"File Path": "Pot do datoteke"
"File Path": "Pot do datoteke",
"Previous word": "Prejšnja beseda",
"Previous word (,)": "Prejšnja beseda (,)",
"Next word": "Naslednja beseda",
"Next word (.)": "Naslednja beseda (.)",
"Start Delay": "Zakasnitev začetka",
"Off": "Izklopljeno",
"Look up": "Poišči"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "Det gick inte att skicka boken",
"Send": "Skicka",
"Highlight Current Sentence": "Markera aktuell mening",
"File Path": "Filsökväg"
"File Path": "Filsökväg",
"Previous word": "Föregående ord",
"Previous word (,)": "Föregående ord (,)",
"Next word": "Nästa ord",
"Next word (.)": "Nästa ord (.)",
"Start Delay": "Startfördröjning",
"Off": "Av",
"Look up": "Slå upp"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "புத்தகத்தை அனுப்ப முடியவில்லை",
"Send": "அனுப்பு",
"Highlight Current Sentence": "தற்போதைய வாக்கியத்தை தனிப்படுத்து",
"File Path": "கோப்பு பாதை"
"File Path": "கோப்பு பாதை",
"Previous word": "முந்தைய சொல்",
"Previous word (,)": "முந்தைய சொல் (,)",
"Next word": "அடுத்த சொல்",
"Next word (.)": "அடுத்த சொல் (.)",
"Start Delay": "தொடக்க தாமதம்",
"Off": "அணை",
"Look up": "தேடு"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "ส่งหนังสือไม่สำเร็จ",
"Send": "ส่ง",
"Highlight Current Sentence": "ไฮไลต์ประโยคปัจจุบัน",
"File Path": "เส้นทางไฟล์"
"File Path": "เส้นทางไฟล์",
"Previous word": "คำก่อนหน้า",
"Previous word (,)": "คำก่อนหน้า (,)",
"Next word": "คำถัดไป",
"Next word (.)": "คำถัดไป (.)",
"Start Delay": "หน่วงเวลาเริ่มต้น",
"Off": "ปิด",
"Look up": "ค้นหา"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "Kitap gönderilemedi",
"Send": "Gönder",
"Highlight Current Sentence": "Geçerli cümleyi vurgula",
"File Path": "Dosya Yolu"
"File Path": "Dosya Yolu",
"Previous word": "Önceki kelime",
"Previous word (,)": "Önceki kelime (,)",
"Next word": "Sonraki kelime",
"Next word (.)": "Sonraki kelime (.)",
"Start Delay": "Başlangıç gecikmesi",
"Off": "Kapalı",
"Look up": "Ara"
}
@@ -1766,5 +1766,12 @@
"Failed to send book": "Не вдалося надіслати книгу",
"Send": "Надіслати",
"Highlight Current Sentence": "Виділяти поточне речення",
"File Path": "Шлях до файлу"
"File Path": "Шлях до файлу",
"Previous word": "Попереднє слово",
"Previous word (,)": "Попереднє слово (,)",
"Next word": "Наступне слово",
"Next word (.)": "Наступне слово (.)",
"Start Delay": "Затримка старту",
"Off": "Вимк.",
"Look up": "Знайти"
}
@@ -1700,5 +1700,12 @@
"Failed to send book": "Kitobni yuborib bo'lmadi",
"Send": "Yuborish",
"Highlight Current Sentence": "Joriy jumlani ajratib ko'rsatish",
"File Path": "Fayl yo'li"
"File Path": "Fayl yo'li",
"Previous word": "Oldingi soʻz",
"Previous word (,)": "Oldingi soʻz (,)",
"Next word": "Keyingi soʻz",
"Next word (.)": "Keyingi soʻz (.)",
"Start Delay": "Boshlash kechikishi",
"Off": "Oʻchiq",
"Look up": "Qidirish"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "Gửi sách thất bại",
"Send": "Gửi",
"Highlight Current Sentence": "Tô sáng câu hiện tại",
"File Path": "Đường dẫn tệp"
"File Path": "Đường dẫn tệp",
"Previous word": "Từ trước",
"Previous word (,)": "Từ trước (,)",
"Next word": "Từ tiếp theo",
"Next word (.)": "Từ tiếp theo (.)",
"Start Delay": "Độ trễ bắt đầu",
"Off": "Tắt",
"Look up": "Tra cứu"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "发送图书失败",
"Send": "发送",
"Highlight Current Sentence": "高亮当前句子",
"File Path": "文件路径"
"File Path": "文件路径",
"Previous word": "上一个词",
"Previous word (,)": "上一个词 (,)",
"Next word": "下一个词",
"Next word (.)": "下一个词 (.)",
"Start Delay": "开始延迟",
"Off": "关闭",
"Look up": "查询"
}
@@ -1667,5 +1667,12 @@
"Failed to send book": "傳送書籍失敗",
"Send": "傳送",
"Highlight Current Sentence": "醒目標示目前句子",
"File Path": "檔案路徑"
"File Path": "檔案路徑",
"Previous word": "上一個詞",
"Previous word (,)": "上一個詞 (,)",
"Next word": "下一個詞",
"Next word (.)": "下一個詞 (.)",
"Start Delay": "開始延遲",
"Off": "關閉",
"Look up": "查詢"
}
@@ -23,6 +23,29 @@ vi.mock('@/store/themeStore', () => ({
}),
}));
// Stub the dictionary popup/sheet so the overlay test does not pull in the
// whole dictionary provider/registry stack — we only assert it opens with the
// word. The overlay uses the sheet below `sm` and the popup otherwise.
vi.mock('@/app/reader/components/annotator/DictionarySheet', () => ({
default: ({ word, onDismiss }: { word: string; onDismiss: () => void }) => (
<div data-testid='rsvp-dict-sheet' data-word={word}>
<button aria-label='close-dict' onClick={onDismiss}>
x
</button>
</div>
),
}));
vi.mock('@/app/reader/components/annotator/DictionaryPopup', () => ({
default: ({ word, onDismiss }: { word: string; onDismiss: () => void }) => (
<div data-testid='rsvp-dict-popup' data-word={word}>
<button aria-label='close-dict' onClick={onDismiss}>
x
</button>
</div>
),
}));
const buildState = (overrides: Partial<RsvpState> = {}): RsvpState => ({
active: true,
playing: false,
@@ -33,6 +56,7 @@ const buildState = (overrides: Partial<RsvpState> = {}): RsvpState => ({
punctuationPauseMs: 100,
splitHyphens: false,
cjkCharMode: false,
startDelaySeconds: 3,
hasCJK: false,
progress: 0,
...overrides,
@@ -54,6 +78,8 @@ const buildController = (state: RsvpState) => {
seekToPosition: vi.fn(),
skipBackward: vi.fn(),
skipForward: vi.fn(),
nextWord: vi.fn(),
prevWord: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
togglePlayPause: vi.fn(),
@@ -63,8 +89,10 @@ const buildController = (state: RsvpState) => {
setPunctuationPause: vi.fn(),
setSplitHyphens: vi.fn(),
setCjkCharMode: vi.fn(),
setStartDelay: vi.fn(),
getWpmOptions: vi.fn(() => [100, 200, 300]),
getPunctuationPauseOptions: vi.fn(() => [25, 50, 100]),
getStartDelayOptions: vi.fn(() => [0, 1, 2, 3]),
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type)!.push(listener);
@@ -314,3 +342,168 @@ describe('RSVPOverlay — CJK reading options', () => {
expect(container.querySelector('.rsvp-word-whole')).toBeNull();
});
});
describe('RSVPOverlay — manual word stepping (#4476)', () => {
afterEach(() => cleanup());
const wordsState = () =>
buildState({
words: Array.from({ length: 10 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
})),
currentIndex: 5,
playing: true,
});
test('the next-word button calls controller.nextWord', () => {
const { container, controller } = renderOverlay(wordsState());
const button = container.querySelector('[aria-label="Next word"]') as HTMLElement;
expect(button).not.toBeNull();
fireEvent.click(button);
expect(controller.nextWord).toHaveBeenCalledTimes(1);
});
test('the previous-word button calls controller.prevWord', () => {
const { container, controller } = renderOverlay(wordsState());
const button = container.querySelector('[aria-label="Previous word"]') as HTMLElement;
expect(button).not.toBeNull();
fireEvent.click(button);
expect(controller.prevWord).toHaveBeenCalledTimes(1);
});
test('the "." key steps to the next word and "," to the previous word', () => {
const { controller } = renderOverlay(wordsState());
fireEvent.keyDown(document, { key: '.' });
expect(controller.nextWord).toHaveBeenCalledTimes(1);
fireEvent.keyDown(document, { key: ',' });
expect(controller.prevWord).toHaveBeenCalledTimes(1);
});
});
describe('RSVPOverlay — dictionary lookup (#4475)', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const wordsState = () =>
buildState({
words: Array.from({ length: 10 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
})),
currentIndex: 5,
playing: true,
});
const mockSelection = (text: string, node: Node | null) => {
const rect = { left: 20, top: 30, right: 60, bottom: 44, width: 40, height: 14 };
const range = {
getBoundingClientRect: () => rect,
cloneRange() {
return range;
},
};
vi.spyOn(window, 'getSelection').mockReturnValue({
isCollapsed: text.length === 0,
anchorNode: node,
rangeCount: 1,
toString: () => text,
getRangeAt: () => range,
removeAllRanges: vi.fn(),
} as unknown as Selection);
};
test('the context panel is selectable', () => {
const { container } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
expect(panel.className).toContain('select-text');
});
test('selecting text in the context panel reveals a Look up action', () => {
const { container } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('serendipity', panel);
fireEvent.mouseUp(panel);
expect(container.querySelector('[aria-label="Look up"]')).not.toBeNull();
});
test('tapping Look up pauses playback and opens the dictionary with the selected text', () => {
const { container, controller } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('serendipity', panel);
fireEvent.mouseUp(panel);
fireEvent.click(container.querySelector('[aria-label="Look up"]') as HTMLElement);
expect(controller.pause).toHaveBeenCalled();
// jsdom's default viewport is desktop-sized, so the anchored popup is used.
const popup = container.querySelector('[data-testid="rsvp-dict-popup"]');
expect(popup).not.toBeNull();
expect(popup!.getAttribute('data-word')).toBe('serendipity');
});
test('clicking outside the popup dismisses it', () => {
const { container } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('serendipity', panel);
fireEvent.mouseUp(panel);
fireEvent.click(container.querySelector('[aria-label="Look up"]') as HTMLElement);
expect(container.querySelector('[data-testid="rsvp-dict-popup"]')).not.toBeNull();
// The transparent full-screen catcher behind the popup dismisses on click.
fireEvent.click(container.querySelector('.overlay') as HTMLElement);
expect(container.querySelector('[data-testid="rsvp-dict-popup"]')).toBeNull();
});
test('uses the bottom sheet on small screens', () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(420);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(720);
const { container } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('serendipity', panel);
fireEvent.mouseUp(panel);
fireEvent.click(container.querySelector('[aria-label="Look up"]') as HTMLElement);
expect(container.querySelector('[data-testid="rsvp-dict-sheet"]')).not.toBeNull();
expect(container.querySelector('[data-testid="rsvp-dict-popup"]')).toBeNull();
});
test('an active selection suppresses word-click seeking', () => {
const { container, controller } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('w3 w4', panel);
fireEvent.click(container.querySelector('[data-rsvp-word-index="3"]') as HTMLElement);
expect(controller.seekToIndex).not.toHaveBeenCalled();
});
});
describe('RSVPOverlay — start delay setting (#4478)', () => {
afterEach(() => {
cleanup();
localStorage.clear();
});
const openSettings = (container: HTMLElement) => {
fireEvent.click(container.querySelector('[aria-label="Settings"]') as HTMLElement);
};
test('changing the Start Delay select calls controller.setStartDelay', () => {
const state = buildState({
words: [{ text: 'a', orpIndex: 0, pauseMultiplier: 1 }],
currentIndex: 0,
startDelaySeconds: 3,
});
const { container, controller } = renderOverlay(state);
openSettings(container);
const select = container.querySelector(
'[data-testid="rsvp-start-delay-select"]',
) as HTMLSelectElement;
expect(select).not.toBeNull();
fireEvent.change(select, { target: { value: '0' } });
expect(controller.setStartDelay).toHaveBeenCalledWith(0);
});
});
@@ -177,4 +177,13 @@ describe('useBookShortcuts', () => {
expect(mockView.next).toHaveBeenCalledWith(72);
});
it('dispatches rsvp-start for the current book when the RSVP shortcut fires', () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
render(<Harness />);
shortcutState.actions?.['onStartRSVP']?.();
expect(dispatchSpy).toHaveBeenCalledWith('rsvp-start', { bookKey: 'book-1' });
});
});
@@ -437,4 +437,98 @@ describe('RSVPController', () => {
]);
});
});
describe('start delay (#4478)', () => {
const START_DELAY_KEY = 'readest_rsvp_start_delay';
beforeEach(() => {
localStorage.removeItem(START_DELAY_KEY);
});
test('defaults to a 3 second delay', () => {
const view = createMockView(0, [makeDoc('one two three')]);
const controller = new RSVPController(view, 'test-book-abc123');
expect(controller.currentState.startDelaySeconds).toBe(3);
expect(controller.getStartDelayOptions()).toEqual([0, 1, 2, 3]);
});
test('setStartDelay persists and is restored on construction', () => {
const view = createMockView(0, [makeDoc('one two')]);
const first = new RSVPController(view, 'test-book-abc123');
first.setStartDelay(1);
expect(first.currentState.startDelaySeconds).toBe(1);
const second = new RSVPController(view, 'test-book-abc123');
expect(second.currentState.startDelaySeconds).toBe(1);
});
test('counts down from N at one-second ticks before playing', () => {
const view = createMockView(0, [makeDoc('one two three')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.setStartDelay(3);
controller.start();
expect(controller.currentCountdown).toBe(3);
vi.advanceTimersByTime(1000);
expect(controller.currentCountdown).toBe(2);
vi.advanceTimersByTime(1000);
expect(controller.currentCountdown).toBe(1);
vi.advanceTimersByTime(1000);
expect(controller.currentCountdown).toBeNull();
});
test('a delay of 0 skips the countdown entirely and starts playing', () => {
const view = createMockView(0, [makeDoc('one two three')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.setStartDelay(0);
controller.start();
expect(controller.currentCountdown).toBeNull();
expect(controller.currentState.playing).toBe(true);
});
});
describe('nextWord / prevWord (#4476)', () => {
test('nextWord advances one word and pauses playback', () => {
const view = createMockView(0, [makeDoc('one two three')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
expect(controller.currentState.currentIndex).toBe(0);
controller.nextWord();
expect(controller.currentState.currentIndex).toBe(1);
expect(controller.currentState.playing).toBe(false);
});
test('prevWord steps back one word', () => {
const view = createMockView(0, [makeDoc('one two three')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
controller.nextWord();
controller.nextWord();
controller.prevWord();
expect(controller.currentState.currentIndex).toBe(1);
});
test('nextWord clamps at the last word', () => {
const view = createMockView(0, [makeDoc('one two')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
controller.nextWord();
controller.nextWord();
expect(controller.currentState.currentIndex).toBe(1);
});
test('prevWord clamps at the first word', () => {
const view = createMockView(0, [makeDoc('one two')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
controller.prevWord();
expect(controller.currentState.currentIndex).toBe(0);
});
});
});
@@ -316,6 +316,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<MenuItem
label={_('Speed Reading Mode')}
shortcut='Shift+V'
onClick={handleStartRSVP}
disabled={bookData.isFixedLayout}
/>
@@ -114,7 +114,8 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => {
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { settings, setSettingsDialogOpen, setSettingsDialogBookKey, setActiveSettingsItemId } =
useSettingsStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { getBookData, getConfig, setConfig, saveConfig } = useBookDataStore();
const { themeCode } = useThemeStore();
@@ -185,6 +186,11 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const handleStart = useCallback(
(selectionText?: string) => {
// RSVP can be started from the menu or the keyboard shortcut (#4473);
// ignore a repeat trigger while a session is already active so it does
// not re-open the start dialog over the running overlay.
if (controllerRef.current?.currentState.active) return;
const view = getView(bookKey);
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
@@ -528,6 +534,14 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const viewSettings = getViewSettings(bookKey);
const fontFamily = viewSettings ? getBaseFontFamily(viewSettings) : undefined;
// Book language drives dictionary provider selection for context lookups (#4475).
const dictionaryLang = bookData?.bookDoc?.metadata?.language as string | undefined;
const handleManageDictionary = useCallback(() => {
setSettingsDialogBookKey(bookKey);
setActiveSettingsItemId('settings.language.dictionaries.manage');
setSettingsDialogOpen(true);
}, [bookKey, setActiveSettingsItemId, setSettingsDialogBookKey, setSettingsDialogOpen]);
// Use portal to render overlay at body level to avoid stacking context issues
const portalContainer = typeof document !== 'undefined' ? document.body : null;
@@ -557,9 +571,11 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
chapters={chapters}
currentChapterHref={currentChapterHref}
fontFamily={fontFamily}
lang={dictionaryLang}
onClose={handleClose}
onChapterSelect={handleChapterSelect}
onRequestNextPage={handleRequestNextPage}
onManageDictionary={handleManageDictionary}
/>,
portalContainer,
)}
@@ -13,13 +13,19 @@ import {
IoPause,
IoPlaySkipBack,
IoPlaySkipForward,
IoCaretBack,
IoCaretForward,
IoRemove,
IoAdd,
IoChevronDown,
IoSettingsSharp,
IoSearch,
} from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { getPopupPosition, Position } from '@/utils/sel';
import { Overlay } from '@/components/Overlay';
import DictionarySheet from '@/app/reader/components/annotator/DictionarySheet';
import DictionaryPopup from '@/app/reader/components/annotator/DictionaryPopup';
interface FlatChapter {
label: string;
@@ -73,6 +79,11 @@ const CONTEXT_CHUNK_SIZE = 50;
const CONTEXT_WINDOW_BEFORE = 200;
const CONTEXT_WINDOW_AFTER = 1000;
// Dictionary lookup popup sizing (mirrors the reader's Annotator popup).
const DICT_POPUP_PADDING = 10;
const DICT_POPUP_MAX_WIDTH = 480;
const DICT_POPUP_MAX_HEIGHT = 360;
interface RSVPOverlayProps {
gridInsets: Insets;
controller: RSVPController;
@@ -84,9 +95,13 @@ interface RSVPOverlayProps {
* fallback. See getBaseFontFamily in utils/style.
*/
fontFamily?: string;
/** Book language, used to pick dictionary providers for context lookups. */
lang?: string;
onClose: () => void;
onChapterSelect: (href: string) => void;
onRequestNextPage: () => void;
/** Opens the dictionary management settings from the lookup header gear. */
onManageDictionary?: () => void;
}
const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
@@ -95,9 +110,11 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
chapters,
currentChapterHref,
fontFamily,
lang,
onClose,
onChapterSelect,
onRequestNextPage,
onManageDictionary,
}) => {
const _ = useTranslation();
const { themeCode, isDarkMode: _isDarkMode } = useThemeStore();
@@ -147,6 +164,21 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
});
const contextWordRef = useRef<HTMLSpanElement>(null);
const contextPanelRef = useRef<HTMLDivElement>(null);
// Dictionary lookup from a context-panel selection (#4475). `lookup` is the
// pending selection (drives the "Look up" pill); `dict` holds the resolved
// word + popup placement once the dictionary is open.
const [lookup, setLookup] = useState<{
text: string;
range: Range;
left: number;
top: number;
} | null>(null);
const [dict, setDict] = useState<{
word: string;
position: Position;
trianglePosition: Position;
} | null>(null);
const touchStartX = useRef(0);
const touchStartY = useRef(0);
const touchStartTime = useRef(0);
@@ -201,6 +233,9 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
useEffect(() => {
const handleKeyboard = (event: KeyboardEvent) => {
if (!state.active) return;
// While the dictionary is open it owns the keyboard (e.g. Escape closes
// the dictionary, not the whole RSVP session).
if (dict) return;
switch (event.key) {
case ' ':
@@ -241,13 +276,23 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
event.stopPropagation();
controller.decreaseSpeed();
break;
case '.':
event.preventDefault();
event.stopPropagation();
controller.nextWord();
break;
case ',':
event.preventDefault();
event.stopPropagation();
controller.prevWord();
break;
}
};
// Use capture phase to handle events before they reach dropdown/select elements
document.addEventListener('keydown', handleKeyboard, { capture: true });
return () => document.removeEventListener('keydown', handleKeyboard, { capture: true });
}, [state.active, controller, onClose]);
}, [state.active, controller, onClose, dict]);
const effectiveChapterHref = currentChapterHref;
@@ -279,11 +324,13 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
}, [state]);
// Auto-scroll: keep highlighted word in view
// Auto-scroll: keep highlighted word in view. Suppressed while the user is
// selecting text or has the dictionary open, so the panel does not yank the
// selection out from under them (#4475).
useEffect(() => {
if (contextCollapsed) return;
if (contextCollapsed || lookup || dict) return;
contextWordRef.current?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
}, [state.currentIndex, contextCollapsed]);
}, [state.currentIndex, contextCollapsed, lookup, dict]);
useEffect(() => {
if (!showChapterDropdown) return;
@@ -422,6 +469,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const handleContextClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
// A drag that selects text also ends in a click; don't seek then, so the
// user can select words for dictionary lookup (#4475).
const selection = window.getSelection();
if (selection && !selection.isCollapsed && selection.toString().trim()) return;
const target = (event.target as HTMLElement).closest<HTMLElement>('[data-rsvp-word-index]');
if (!target) return;
if (target.getAttribute('role') !== 'button') return;
@@ -432,6 +483,68 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
[handleWordClick],
);
// Detect a selection inside the context panel and surface a "Look up" pill.
const handleContextSelection = useCallback(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
setLookup(null);
return;
}
const text = selection.toString().trim();
const anchor = selection.anchorNode;
if (!text || !anchor || !contextPanelRef.current?.contains(anchor)) {
setLookup(null);
return;
}
// Clone the range so the placement survives the selection being collapsed
// when the user taps the "Look up" pill.
const range = selection.getRangeAt(0).cloneRange();
const rect = range.getBoundingClientRect();
const left = Math.min(window.innerWidth - 8, Math.max(8, rect.left + rect.width / 2));
setLookup({ text, range, left, top: rect.top });
}, []);
const openLookup = useCallback(() => {
if (!lookup) return;
if (state.playing) controller.pause();
// Anchor the popup to the selection: prefer below it, flip above when the
// lower half of the screen is too short. The whole-window rect keeps the
// popup clamped on-screen (the overlay root sits at the viewport origin).
const rect = lookup.range.getBoundingClientRect();
const windowRect = { top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight };
const popupWidth = Math.min(DICT_POPUP_MAX_WIDTH, window.innerWidth - 2 * DICT_POPUP_PADDING);
const popupHeight = Math.min(
DICT_POPUP_MAX_HEIGHT,
window.innerHeight - 2 * DICT_POPUP_PADDING,
);
const dir: Position['dir'] =
window.innerHeight - rect.bottom > popupHeight + DICT_POPUP_PADDING ? 'down' : 'up';
const trianglePosition: Position = {
point: { x: rect.left + rect.width / 2, y: dir === 'down' ? rect.bottom + 6 : rect.top - 12 },
dir,
};
const position = getPopupPosition(
trianglePosition,
windowRect,
popupWidth,
popupHeight,
DICT_POPUP_PADDING,
);
setDict({ word: lookup.text, position, trianglePosition });
setLookup(null);
}, [lookup, state.playing, controller]);
const closeLookup = useCallback(() => {
setDict(null);
try {
window.getSelection()?.removeAllRanges();
} catch {
/* ignore */
}
}, []);
const handleContextKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
@@ -668,10 +781,13 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
onTouchEnd={(e) => e.stopPropagation()}
>
<div
ref={contextPanelRef}
data-testid='rsvp-context-panel'
className='text-left text-base leading-relaxed md:text-lg'
className='select-text text-left text-base leading-relaxed md:text-lg'
onClick={handleContextClick}
onKeyDown={handleContextKeyDown}
onMouseUp={handleContextSelection}
onTouchEnd={handleContextSelection}
>
{hasMoreBefore && <span className='opacity-30'> </span>}
{state.words.slice(contextWindow.start, contextWindow.end).map((w, i) => {
@@ -848,6 +964,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<IoRemove className='h-4 w-4 md:h-5 md:w-5' />
</button>
<button
aria-label={_('Previous word')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
onClick={() => controller.prevWord()}
title={_('Previous word (,)')}
>
<IoCaretBack className='h-4 w-4 md:h-5 md:w-5' />
</button>
<button
aria-label={state.playing ? _('Pause') : _('Play')}
className={clsx(
@@ -864,6 +989,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
)}
</button>
<button
aria-label={_('Next word')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
onClick={() => controller.nextWord()}
title={_('Next word (.)')}
>
<IoCaretForward className='h-4 w-4 md:h-5 md:w-5' />
</button>
<button
aria-label={_('Increase speed')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
@@ -916,6 +1050,24 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
</select>
</label>
{/* Pre-start countdown delay */}
<label className='flex cursor-pointer items-center gap-1.5 font-medium opacity-80'>
<span className='mr-0.5 font-medium opacity-50'>{_('Start Delay')}</span>
<select
data-testid='rsvp-start-delay-select'
className='cursor-pointer rounded border border-gray-500/30 bg-gray-500/20 px-1.5 py-1 text-xs font-medium transition-colors hover:border-gray-500/40 hover:bg-gray-500/30'
style={{ color: 'inherit' }}
value={state.startDelaySeconds}
onChange={(e) => controller.setStartDelay(parseInt(e.target.value, 10))}
>
{controller.getStartDelayOptions().map((option) => (
<option key={option} value={option}>
{option === 0 ? _('Off') : `${option}s`}
</option>
))}
</select>
</label>
{/* Font size */}
<div className='flex items-center gap-0.5'>
<span className='mr-0.5 font-medium opacity-50'>{_('Font')}</span>
@@ -1001,6 +1153,57 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
</div>
)}
</div>
{/* Dictionary lookup from a context selection (#4475) */}
{lookup && (
<button
aria-label={_('Look up')}
className='eink-bordered fixed z-[10001] flex -translate-x-1/2 -translate-y-full items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold shadow-lg'
style={{
left: `${lookup.left}px`,
top: `${lookup.top}px`,
backgroundColor: accentColor,
color: bgColor,
}}
onClick={openLookup}
>
<IoSearch className='h-4 w-4' />
{_('Look up')}
</button>
)}
{dict &&
// Below `sm` (or short landscape) present a bottom sheet; otherwise an
// anchored popup — mirroring the reader's selection dictionary.
(window.innerWidth < 640 || window.innerHeight < 640 ? (
<DictionarySheet
word={dict.word}
lang={lang}
onDismiss={closeLookup}
onManage={onManageDictionary}
/>
) : (
// Transparent full-screen catcher so a click outside the popup
// dismisses it (the popup container sits above it at z-50).
<>
<Overlay onDismiss={closeLookup} />
<DictionaryPopup
word={dict.word}
lang={lang}
position={dict.position}
trianglePosition={dict.trianglePosition}
popupWidth={Math.min(
DICT_POPUP_MAX_WIDTH,
window.innerWidth - 2 * DICT_POPUP_PADDING,
)}
popupHeight={Math.min(
DICT_POPUP_MAX_HEIGHT,
window.innerHeight - 2 * DICT_POPUP_PADDING,
)}
onDismiss={closeLookup}
onManage={onManageDictionary}
/>
</>
))}
</div>
);
};
@@ -322,6 +322,11 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
return true;
};
const startRSVP = () => {
if (!sideBarBookKey) return;
eventDispatcher.dispatch('rsvp-start', { bookKey: sideBarBookKey });
};
const handlePinchZoom = (event: CustomEvent) => {
const zoomLevel = event.detail?.zoomLevel;
if (zoomLevel != null) {
@@ -351,6 +356,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onToggleScrollMode: toggleScrollMode,
onToggleBookmark: toggleBookmark,
onToggleParagraphMode: toggleParagraphMode,
onStartRSVP: startRSVP,
onToggleToolbar: toggleToolbar,
onOpenFontLayoutSettings: () => setSettingsDialogOpen(true),
onShowSearchBar: showSearchBar,
@@ -83,6 +83,11 @@ const DEFAULT_SHORTCUTS = {
description: _('Toggle Paragraph Mode'),
section: 'General',
},
onStartRSVP: {
keys: ['shift+v'],
description: _('Speed Reading Mode'),
section: 'General',
},
onToggleToolbar: {
keys: ['Enter'],
description: _('Toggle Toolbar'),
@@ -12,11 +12,14 @@ const DEFAULT_PUNCTUATION_PAUSE_MS = 100;
const PUNCTUATION_PAUSE_OPTIONS = [25, 50, 75, 100, 125, 150, 175, 200];
const DEFAULT_SPLIT_HYPHENS = false;
const DEFAULT_CJK_CHAR_MODE = false;
const DEFAULT_START_DELAY_SECONDS = 3;
const START_DELAY_OPTIONS = [0, 1, 2, 3];
const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_';
const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
const SPLIT_HYPHENS_KEY = 'readest_rsvp_split_hyphens';
const CJK_CHAR_MODE_KEY = 'readest_rsvp_cjk_char_mode';
const START_DELAY_KEY = 'readest_rsvp_start_delay';
// Section-only CFI (no '!') sorts before any word CFI in that section.
const stripCfiPath = (cfi: string): string => cfi.replace(/!.*\)$/, ')');
@@ -37,6 +40,7 @@ export class RSVPController extends EventTarget {
punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS,
splitHyphens: DEFAULT_SPLIT_HYPHENS,
cjkCharMode: DEFAULT_CJK_CHAR_MODE,
startDelaySeconds: DEFAULT_START_DELAY_SECONDS,
hasCJK: false,
progress: 0,
};
@@ -81,6 +85,10 @@ export class RSVPController extends EventTarget {
if (savedCjkCharMode !== null) {
this.state.cjkCharMode = savedCjkCharMode;
}
const savedStartDelay = this.loadStartDelayFromStorage();
if (savedStartDelay !== null) {
this.state.startDelaySeconds = savedStartDelay;
}
}
get currentState(): RsvpState {
@@ -221,6 +229,34 @@ export class RSVPController extends EventTarget {
return null;
}
getStartDelayOptions(): number[] {
return START_DELAY_OPTIONS;
}
setStartDelay(seconds: number): void {
if (!START_DELAY_OPTIONS.includes(seconds)) return;
this.state.startDelaySeconds = seconds;
try {
localStorage.setItem(START_DELAY_KEY, seconds.toString());
} catch {
/* ignore */
}
this.emitStateChange();
}
private loadStartDelayFromStorage(): number | null {
try {
const stored = localStorage.getItem(START_DELAY_KEY);
if (stored !== null) {
const parsed = parseInt(stored, 10);
if (START_DELAY_OPTIONS.includes(parsed)) return parsed;
}
} catch {
/* ignore */
}
return null;
}
setCurrentCfi(cfi: string | null): void {
this.currentCfi = cfi;
}
@@ -446,10 +482,19 @@ export class RSVPController extends EventTarget {
private startCountdown(onComplete: () => void): void {
this.clearCountdown();
let count = 3;
// A delay of 0 means instant start — skip the countdown entirely.
let count = this.state.startDelaySeconds;
if (count <= 0) {
onComplete();
return;
}
this.countdown = count;
this.emitCountdownChange();
// Tick at real one-second intervals so the displayed number matches the
// configured delay in seconds.
this.countdownTimer = setInterval(() => {
count--;
if (count > 0) {
@@ -459,7 +504,7 @@ export class RSVPController extends EventTarget {
this.clearCountdown();
onComplete();
}
}, 500);
}, 1000);
}
private clearCountdown(): void {
@@ -668,6 +713,26 @@ export class RSVPController extends EventTarget {
this.emitStateChange();
}
// Manual single-word stepping for self-paced reading (#4476). Pauses
// playback first so repeated presses advance exactly one word at a time;
// resume is left to the user.
nextWord(): void {
if (this.state.playing) this.pause();
this.state.currentIndex = Math.min(
Math.max(0, this.state.words.length - 1),
this.state.currentIndex + 1,
);
this.state.currentPartIndex = 0;
this.emitStateChange();
}
prevWord(): void {
if (this.state.playing) this.pause();
this.state.currentIndex = Math.max(0, this.state.currentIndex - 1);
this.state.currentPartIndex = 0;
this.emitStateChange();
}
seekToPosition(percentage: number): void {
if (this.state.words.length === 0) return;
const newIndex = Math.floor((percentage / 100) * this.state.words.length);
@@ -17,6 +17,7 @@ export interface RsvpState {
punctuationPauseMs: number;
splitHyphens: boolean;
cjkCharMode: boolean;
startDelaySeconds: number;
hasCJK: boolean;
progress: number;
}