From 390c711070e8bd5e58229839feba5d169e55fe40 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 12 Jun 2026 02:52:27 +0800 Subject: [PATCH] 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) --- .../public/locales/ar/translation.json | 9 +- .../public/locales/bn/translation.json | 9 +- .../public/locales/bo/translation.json | 9 +- .../public/locales/de/translation.json | 9 +- .../public/locales/el/translation.json | 9 +- .../public/locales/es/translation.json | 9 +- .../public/locales/fa/translation.json | 9 +- .../public/locales/fr/translation.json | 9 +- .../public/locales/he/translation.json | 9 +- .../public/locales/hi/translation.json | 9 +- .../public/locales/hu/translation.json | 9 +- .../public/locales/id/translation.json | 9 +- .../public/locales/it/translation.json | 9 +- .../public/locales/ja/translation.json | 9 +- .../public/locales/ko/translation.json | 9 +- .../public/locales/ms/translation.json | 9 +- .../public/locales/nl/translation.json | 9 +- .../public/locales/pl/translation.json | 9 +- .../public/locales/pt-BR/translation.json | 9 +- .../public/locales/pt/translation.json | 9 +- .../public/locales/ro/translation.json | 9 +- .../public/locales/ru/translation.json | 9 +- .../public/locales/si/translation.json | 9 +- .../public/locales/sl/translation.json | 9 +- .../public/locales/sv/translation.json | 9 +- .../public/locales/ta/translation.json | 9 +- .../public/locales/th/translation.json | 9 +- .../public/locales/tr/translation.json | 9 +- .../public/locales/uk/translation.json | 9 +- .../public/locales/uz/translation.json | 9 +- .../public/locales/vi/translation.json | 9 +- .../public/locales/zh-CN/translation.json | 9 +- .../public/locales/zh-TW/translation.json | 9 +- .../components/rsvp-overlay-context.test.tsx | 193 ++++++++++++++++ .../components/useBookShortcuts.test.tsx | 9 + .../services/rsvp-controller.test.ts | 94 ++++++++ .../src/app/reader/components/ViewMenu.tsx | 1 + .../reader/components/rsvp/RSVPControl.tsx | 18 +- .../reader/components/rsvp/RSVPOverlay.tsx | 213 +++++++++++++++++- .../src/app/reader/hooks/useBookShortcuts.ts | 6 + apps/readest-app/src/helpers/shortcuts.ts | 5 + .../src/services/rsvp/RSVPController.ts | 69 +++++- apps/readest-app/src/services/rsvp/types.ts | 1 + 43 files changed, 865 insertions(+), 41 deletions(-) diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 79228e91..c8e52778 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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": "بحث" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 5d4b168d..d8d39c72 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -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": "খুঁজুন" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 2cd073f9..e5658bac 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -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": "འཚོལ་བ།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index be63055c..727e4352 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 0cdcf73f..458d737f 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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": "Αναζήτηση" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 30031a69..5fac2599 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 8903eed2..c03da60f 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -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": "جستجو" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 3619afd7..31a08c1e 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 232f39f0..9fb0abec 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -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": "חיפוש" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index ec8babbe..93abb797 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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": "खोजें" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 1c075308..710a0351 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index ad7aa46b..914252a6 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 02e6e3e0..c820c8cc 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index a41fe8cd..54a2e304 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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": "調べる" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index eeeff46c..43cb4a29 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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": "찾기" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index b82ba499..29431cc5 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 35511f6e..901a8929 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index c35b67b3..aaae873b 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index e69567d3..f6f53d1c 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index c66d5eaf..41b5efdd 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index b74fecca..03e308d2 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -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ă" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 9814b3cf..1d1af1b4 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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": "Найти" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 9015831b..c229fa79 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -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": "සොයන්න" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 0c0a09a8..4d7ba101 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 56eff1d8..9e37cfbb 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index f28a74ec..e1c64414 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -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": "தேடு" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index ee8689f2..8591878b 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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": "ค้นหา" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index d9d5c6ce..1bd89ff6 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 7b5d6b5b..83e1214b 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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": "Знайти" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 1b7e1949..55069f19 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 62d20512..e59c387b 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 5c1fd367..d33bdde6 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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": "查询" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index eda56a88..ed4a5ae0 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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": "查詢" } diff --git a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx index d320c3c0..e4eac9ae 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx @@ -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 }) => ( +
+ +
+ ), +})); + +vi.mock('@/app/reader/components/annotator/DictionaryPopup', () => ({ + default: ({ word, onDismiss }: { word: string; onDismiss: () => void }) => ( +
+ +
+ ), +})); + const buildState = (overrides: Partial = {}): RsvpState => ({ active: true, playing: false, @@ -33,6 +56,7 @@ const buildState = (overrides: Partial = {}): 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); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx b/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx index 44d924a3..0ffbe509 100644 --- a/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx +++ b/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx @@ -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(); + shortcutState.actions?.['onStartRSVP']?.(); + + expect(dispatchSpy).toHaveBeenCalledWith('rsvp-start', { bookKey: 'book-1' }); + }); }); diff --git a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts index b6b66f90..68e8d136 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts @@ -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); + }); + }); }); diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx index 69154239..4ded805f 100644 --- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx @@ -316,6 +316,7 @@ const ViewMenu: React.FC = ({ diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx index c4d7324a..3726cba1 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx @@ -114,7 +114,8 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => { const RSVPControl: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ bookKey, gridInsets }) => { chapters={chapters} currentChapterHref={currentChapterHref} fontFamily={fontFamily} + lang={dictionaryLang} onClose={handleClose} onChapterSelect={handleChapterSelect} onRequestNextPage={handleRequestNextPage} + onManageDictionary={handleManageDictionary} />, portalContainer, )} diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx index 2248d4b1..e73f22d5 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -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 = ({ @@ -95,9 +110,11 @@ const RSVPOverlay: React.FC = ({ chapters, currentChapterHref, fontFamily, + lang, onClose, onChapterSelect, onRequestNextPage, + onManageDictionary, }) => { const _ = useTranslation(); const { themeCode, isDarkMode: _isDarkMode } = useThemeStore(); @@ -147,6 +164,21 @@ const RSVPOverlay: React.FC = ({ } }); const contextWordRef = useRef(null); + const contextPanelRef = useRef(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 = ({ 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 = ({ 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 = ({ } }, [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 = ({ const handleContextClick = useCallback( (event: React.MouseEvent) => { + // 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('[data-rsvp-word-index]'); if (!target) return; if (target.getAttribute('role') !== 'button') return; @@ -432,6 +483,68 @@ const RSVPOverlay: React.FC = ({ [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) => { if (event.key !== 'Enter' && event.key !== ' ') return; @@ -668,10 +781,13 @@ const RSVPOverlay: React.FC = ({ onTouchEnd={(e) => e.stopPropagation()} >
{hasMoreBefore && } {state.words.slice(contextWindow.start, contextWindow.end).map((w, i) => { @@ -848,6 +964,15 @@ const RSVPOverlay: React.FC = ({ + + + +
+ + {/* Dictionary lookup from a context selection (#4475) */} + {lookup && ( + + )} + {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 ? ( + + ) : ( + // Transparent full-screen catcher so a click outside the popup + // dismisses it (the popup container sits above it at z-50). + <> + + + + ))} ); }; diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index e81c08c6..62e1022b 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -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, diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index 62395149..201baaf7 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -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'), diff --git a/apps/readest-app/src/services/rsvp/RSVPController.ts b/apps/readest-app/src/services/rsvp/RSVPController.ts index 1a603563..d9e838a9 100644 --- a/apps/readest-app/src/services/rsvp/RSVPController.ts +++ b/apps/readest-app/src/services/rsvp/RSVPController.ts @@ -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); diff --git a/apps/readest-app/src/services/rsvp/types.ts b/apps/readest-app/src/services/rsvp/types.ts index d59def51..6d7f2a68 100644 --- a/apps/readest-app/src/services/rsvp/types.ts +++ b/apps/readest-app/src/services/rsvp/types.ts @@ -17,6 +17,7 @@ export interface RsvpState { punctuationPauseMs: number; splitHyphens: boolean; cjkCharMode: boolean; + startDelaySeconds: number; hasCJK: boolean; progress: number; }