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