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;
}