Teleprompter-style continuous scrolling toggled from the View menu (Shift+A), available only in scrolled mode. A PacedScroller drives whole-pixel forward steps at a constant, user-adjustable velocity; the speed (25-500 percent, persisted as autoScrollSpeed) is tuned from a floating control pill that also offers pause/resume and exit, and fades away while scrolling to keep the mode immersive. Tapping the page pauses and resumes instead of turning pages or toggling the bars; manual wheel or drag input simply composes with the paced scrolling. Escape or leaving scrolled mode ends the session. When forward progress stalls the session hops to the next section (single-section scroll mode) or stops with a toast at the end of the book. Vertical-writing books scroll along the horizontal axis with the sign convention foliate uses for scrolled offsets. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2009,5 +2009,11 @@
|
||||
"Open Read Aloud player": "فتح مشغل القراءة بصوت عالٍ",
|
||||
"Read Aloud": "القراءة بصوت عالٍ",
|
||||
"Voice": "الصوت",
|
||||
"Sleep Timer": "مؤقت النوم"
|
||||
"Sleep Timer": "مؤقت النوم",
|
||||
"Slower": "أبطأ",
|
||||
"Faster": "أسرع",
|
||||
"Exit Auto Scroll": "الخروج من التمرير التلقائي",
|
||||
"Auto Scroll": "التمرير التلقائي",
|
||||
"End of book": "نهاية الكتاب",
|
||||
"Toggle Auto Scroll": "تبديل التمرير التلقائي"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "জোরে পড়ার প্লেয়ার খুলুন",
|
||||
"Read Aloud": "জোরে পড়ুন",
|
||||
"Voice": "কণ্ঠস্বর",
|
||||
"Sleep Timer": "স্লিপ টাইমার"
|
||||
"Sleep Timer": "স্লিপ টাইমার",
|
||||
"Slower": "ধীর",
|
||||
"Faster": "দ্রুত",
|
||||
"Exit Auto Scroll": "স্বয়ংক্রিয় স্ক্রল থেকে প্রস্থান",
|
||||
"Auto Scroll": "স্বয়ংক্রিয় স্ক্রল",
|
||||
"End of book": "বইয়ের শেষ",
|
||||
"Toggle Auto Scroll": "স্বয়ংক্রিয় স্ক্রল টগল করুন"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "སྐད་སྒྲས་ཀློག་པའི་གཏོང་ཆས་ཁ་ཕྱེ།",
|
||||
"Read Aloud": "སྐད་སྒྲས་ཀློག་པ།",
|
||||
"Voice": "སྐད་སྒྲ།",
|
||||
"Sleep Timer": "གཉིད་ཀྱི་དུས་ཚོད།"
|
||||
"Sleep Timer": "གཉིད་ཀྱི་དུས་ཚོད།",
|
||||
"Slower": "འགོར་བ།",
|
||||
"Faster": "མྱུར་བ།",
|
||||
"Exit Auto Scroll": "རང་འགུལ་འཁོར་རྒྱུག་མཚམས་འཇོག",
|
||||
"Auto Scroll": "རང་འགུལ་འཁོར་རྒྱུག",
|
||||
"End of book": "དཔེ་དེབ་ཀྱི་མཇུག་མཐའ།",
|
||||
"Toggle Auto Scroll": "རང་འགུལ་འཁོར་རྒྱུག་བརྗེ་བ།"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "Vorlese-Player öffnen",
|
||||
"Read Aloud": "Vorlesen",
|
||||
"Voice": "Stimme",
|
||||
"Sleep Timer": "Einschlaftimer"
|
||||
"Sleep Timer": "Einschlaftimer",
|
||||
"Slower": "Langsamer",
|
||||
"Faster": "Schneller",
|
||||
"Exit Auto Scroll": "Automatisches Scrollen beenden",
|
||||
"Auto Scroll": "Automatisches Scrollen",
|
||||
"End of book": "Ende des Buches",
|
||||
"Toggle Auto Scroll": "Automatisches Scrollen umschalten"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "Άνοιγμα προγράμματος αναπαραγωγής εκφώνησης",
|
||||
"Read Aloud": "Εκφώνηση",
|
||||
"Voice": "Φωνή",
|
||||
"Sleep Timer": "Χρονοδιακόπτης ύπνου"
|
||||
"Sleep Timer": "Χρονοδιακόπτης ύπνου",
|
||||
"Slower": "Πιο αργά",
|
||||
"Faster": "Πιο γρήγορα",
|
||||
"Exit Auto Scroll": "Έξοδος από την αυτόματη κύλιση",
|
||||
"Auto Scroll": "Αυτόματη κύλιση",
|
||||
"End of book": "Τέλος του βιβλίου",
|
||||
"Toggle Auto Scroll": "Εναλλαγή αυτόματης κύλισης"
|
||||
}
|
||||
|
||||
@@ -1904,5 +1904,11 @@
|
||||
"Open Read Aloud player": "Abrir el reproductor de lectura en voz alta",
|
||||
"Read Aloud": "Lectura en voz alta",
|
||||
"Voice": "Voz",
|
||||
"Sleep Timer": "Temporizador de apagado"
|
||||
"Sleep Timer": "Temporizador de apagado",
|
||||
"Slower": "Más lento",
|
||||
"Faster": "Más rápido",
|
||||
"Exit Auto Scroll": "Salir del desplazamiento automático",
|
||||
"Auto Scroll": "Desplazamiento automático",
|
||||
"End of book": "Fin del libro",
|
||||
"Toggle Auto Scroll": "Alternar desplazamiento automático"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "باز کردن پخشکننده خواندن با صدای بلند",
|
||||
"Read Aloud": "خواندن با صدای بلند",
|
||||
"Voice": "صدا",
|
||||
"Sleep Timer": "تایمر خواب"
|
||||
"Sleep Timer": "تایمر خواب",
|
||||
"Slower": "کندتر",
|
||||
"Faster": "سریعتر",
|
||||
"Exit Auto Scroll": "خروج از پیمایش خودکار",
|
||||
"Auto Scroll": "پیمایش خودکار",
|
||||
"End of book": "پایان کتاب",
|
||||
"Toggle Auto Scroll": "تغییر پیمایش خودکار"
|
||||
}
|
||||
|
||||
@@ -1904,5 +1904,11 @@
|
||||
"Open Read Aloud player": "Ouvrir le lecteur de lecture à voix haute",
|
||||
"Read Aloud": "Lecture à voix haute",
|
||||
"Voice": "Voix",
|
||||
"Sleep Timer": "Minuteur de veille"
|
||||
"Sleep Timer": "Minuteur de veille",
|
||||
"Slower": "Plus lent",
|
||||
"Faster": "Plus rapide",
|
||||
"Exit Auto Scroll": "Quitter le défilement automatique",
|
||||
"Auto Scroll": "Défilement automatique",
|
||||
"End of book": "Fin du livre",
|
||||
"Toggle Auto Scroll": "Basculer le défilement automatique"
|
||||
}
|
||||
|
||||
@@ -1904,5 +1904,11 @@
|
||||
"Open Read Aloud player": "פתיחת נגן ההקראה",
|
||||
"Read Aloud": "הקראה",
|
||||
"Voice": "קול",
|
||||
"Sleep Timer": "טיימר שינה"
|
||||
"Sleep Timer": "טיימר שינה",
|
||||
"Slower": "איטי יותר",
|
||||
"Faster": "מהיר יותר",
|
||||
"Exit Auto Scroll": "צא מגלילה אוטומטית",
|
||||
"Auto Scroll": "גלילה אוטומטית",
|
||||
"End of book": "סוף הספר",
|
||||
"Toggle Auto Scroll": "החלף גלילה אוטומטית"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "ज़ोर से पढ़ें प्लेयर खोलें",
|
||||
"Read Aloud": "ज़ोर से पढ़ें",
|
||||
"Voice": "आवाज़",
|
||||
"Sleep Timer": "स्लीप टाइमर"
|
||||
"Sleep Timer": "स्लीप टाइमर",
|
||||
"Slower": "धीमा",
|
||||
"Faster": "तेज़",
|
||||
"Exit Auto Scroll": "स्वचालित स्क्रॉल से बाहर निकलें",
|
||||
"Auto Scroll": "स्वचालित स्क्रॉल",
|
||||
"End of book": "पुस्तक का अंत",
|
||||
"Toggle Auto Scroll": "स्वचालित स्क्रॉल टॉगल करें"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "Felolvasó lejátszó megnyitása",
|
||||
"Read Aloud": "Felolvasás",
|
||||
"Voice": "Hang",
|
||||
"Sleep Timer": "Elalvási időzítő"
|
||||
"Sleep Timer": "Elalvási időzítő",
|
||||
"Slower": "Lassabb",
|
||||
"Faster": "Gyorsabb",
|
||||
"Exit Auto Scroll": "Kilépés az automatikus görgetésből",
|
||||
"Auto Scroll": "Automatikus görgetés",
|
||||
"End of book": "A könyv vége",
|
||||
"Toggle Auto Scroll": "Automatikus görgetés be/ki"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "Buka pemutar baca nyaring",
|
||||
"Read Aloud": "Baca Nyaring",
|
||||
"Voice": "Suara",
|
||||
"Sleep Timer": "Timer Tidur"
|
||||
"Sleep Timer": "Timer Tidur",
|
||||
"Slower": "Lebih lambat",
|
||||
"Faster": "Lebih cepat",
|
||||
"Exit Auto Scroll": "Keluar dari gulir otomatis",
|
||||
"Auto Scroll": "Gulir Otomatis",
|
||||
"End of book": "Akhir buku",
|
||||
"Toggle Auto Scroll": "Alihkan gulir otomatis"
|
||||
}
|
||||
|
||||
@@ -1904,5 +1904,11 @@
|
||||
"Open Read Aloud player": "Apri il lettore di lettura ad alta voce",
|
||||
"Read Aloud": "Lettura ad alta voce",
|
||||
"Voice": "Voce",
|
||||
"Sleep Timer": "Timer di spegnimento"
|
||||
"Sleep Timer": "Timer di spegnimento",
|
||||
"Slower": "Più lento",
|
||||
"Faster": "Più veloce",
|
||||
"Exit Auto Scroll": "Esci dallo scorrimento automatico",
|
||||
"Auto Scroll": "Scorrimento automatico",
|
||||
"End of book": "Fine del libro",
|
||||
"Toggle Auto Scroll": "Attiva/disattiva scorrimento automatico"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "読み上げプレーヤーを開く",
|
||||
"Read Aloud": "読み上げ",
|
||||
"Voice": "音声",
|
||||
"Sleep Timer": "スリープタイマー"
|
||||
"Sleep Timer": "スリープタイマー",
|
||||
"Slower": "遅く",
|
||||
"Faster": "速く",
|
||||
"Exit Auto Scroll": "自動スクロールを終了",
|
||||
"Auto Scroll": "自動スクロール",
|
||||
"End of book": "本の終わり",
|
||||
"Toggle Auto Scroll": "自動スクロールの切り替え"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "소리 내어 읽기 플레이어 열기",
|
||||
"Read Aloud": "소리 내어 읽기",
|
||||
"Voice": "음성",
|
||||
"Sleep Timer": "수면 타이머"
|
||||
"Sleep Timer": "수면 타이머",
|
||||
"Slower": "느리게",
|
||||
"Faster": "빠르게",
|
||||
"Exit Auto Scroll": "자동 스크롤 종료",
|
||||
"Auto Scroll": "자동 스크롤",
|
||||
"End of book": "책의 끝",
|
||||
"Toggle Auto Scroll": "자동 스크롤 전환"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "Buka pemain baca lantang",
|
||||
"Read Aloud": "Baca Lantang",
|
||||
"Voice": "Suara",
|
||||
"Sleep Timer": "Pemasa Tidur"
|
||||
"Sleep Timer": "Pemasa Tidur",
|
||||
"Slower": "Lebih perlahan",
|
||||
"Faster": "Lebih cepat",
|
||||
"Exit Auto Scroll": "Keluar daripada tatal automatik",
|
||||
"Auto Scroll": "Tatal Automatik",
|
||||
"End of book": "Penghujung buku",
|
||||
"Toggle Auto Scroll": "Togol tatal automatik"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "Voorleesspeler openen",
|
||||
"Read Aloud": "Voorlezen",
|
||||
"Voice": "Stem",
|
||||
"Sleep Timer": "Slaaptimer"
|
||||
"Sleep Timer": "Slaaptimer",
|
||||
"Slower": "Langzamer",
|
||||
"Faster": "Sneller",
|
||||
"Exit Auto Scroll": "Automatisch scrollen afsluiten",
|
||||
"Auto Scroll": "Automatisch scrollen",
|
||||
"End of book": "Einde van het boek",
|
||||
"Toggle Auto Scroll": "Automatisch scrollen wisselen"
|
||||
}
|
||||
|
||||
@@ -1939,5 +1939,11 @@
|
||||
"Open Read Aloud player": "Otwórz odtwarzacz czytania na głos",
|
||||
"Read Aloud": "Czytanie na głos",
|
||||
"Voice": "Głos",
|
||||
"Sleep Timer": "Minutnik snu"
|
||||
"Sleep Timer": "Minutnik snu",
|
||||
"Slower": "Wolniej",
|
||||
"Faster": "Szybciej",
|
||||
"Exit Auto Scroll": "Zakończ automatyczne przewijanie",
|
||||
"Auto Scroll": "Automatyczne przewijanie",
|
||||
"End of book": "Koniec książki",
|
||||
"Toggle Auto Scroll": "Przełącz automatyczne przewijanie"
|
||||
}
|
||||
|
||||
@@ -1904,5 +1904,11 @@
|
||||
"Open Read Aloud player": "Abrir o player de leitura em voz alta",
|
||||
"Read Aloud": "Leitura em voz alta",
|
||||
"Voice": "Voz",
|
||||
"Sleep Timer": "Timer de sono"
|
||||
"Sleep Timer": "Timer de sono",
|
||||
"Slower": "Mais lento",
|
||||
"Faster": "Mais rápido",
|
||||
"Exit Auto Scroll": "Sair da rolagem automática",
|
||||
"Auto Scroll": "Rolagem automática",
|
||||
"End of book": "Fim do livro",
|
||||
"Toggle Auto Scroll": "Alternar rolagem automática"
|
||||
}
|
||||
|
||||
@@ -1904,5 +1904,11 @@
|
||||
"Open Read Aloud player": "Abrir o leitor de leitura em voz alta",
|
||||
"Read Aloud": "Leitura em voz alta",
|
||||
"Voice": "Voz",
|
||||
"Sleep Timer": "Temporizador de sono"
|
||||
"Sleep Timer": "Temporizador de sono",
|
||||
"Slower": "Mais lento",
|
||||
"Faster": "Mais rápido",
|
||||
"Exit Auto Scroll": "Sair da rolagem automática",
|
||||
"Auto Scroll": "Rolagem automática",
|
||||
"End of book": "Fim do livro",
|
||||
"Toggle Auto Scroll": "Alternar rolagem automática"
|
||||
}
|
||||
|
||||
@@ -1904,5 +1904,11 @@
|
||||
"Open Read Aloud player": "Deschide playerul de citire cu voce tare",
|
||||
"Read Aloud": "Citire cu voce tare",
|
||||
"Voice": "Voce",
|
||||
"Sleep Timer": "Temporizator de somn"
|
||||
"Sleep Timer": "Temporizator de somn",
|
||||
"Slower": "Mai lent",
|
||||
"Faster": "Mai rapid",
|
||||
"Exit Auto Scroll": "Ieșire din derularea automată",
|
||||
"Auto Scroll": "Derulare automată",
|
||||
"End of book": "Sfârșitul cărții",
|
||||
"Toggle Auto Scroll": "Comută derularea automată"
|
||||
}
|
||||
|
||||
@@ -1939,5 +1939,11 @@
|
||||
"Open Read Aloud player": "Открыть плеер чтения вслух",
|
||||
"Read Aloud": "Чтение вслух",
|
||||
"Voice": "Голос",
|
||||
"Sleep Timer": "Таймер сна"
|
||||
"Sleep Timer": "Таймер сна",
|
||||
"Slower": "Медленнее",
|
||||
"Faster": "Быстрее",
|
||||
"Exit Auto Scroll": "Выйти из автопрокрутки",
|
||||
"Auto Scroll": "Автопрокрутка",
|
||||
"End of book": "Конец книги",
|
||||
"Toggle Auto Scroll": "Переключить автопрокрутку"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "ශබ්ද නඟා කියවීමේ ධාවකය විවෘත කරන්න",
|
||||
"Read Aloud": "ශබ්ද නඟා කියවීම",
|
||||
"Voice": "හඬ",
|
||||
"Sleep Timer": "නින්ද කාල ගණකය"
|
||||
"Sleep Timer": "නින්ද කාල ගණකය",
|
||||
"Slower": "මන්දගාමී",
|
||||
"Faster": "වේගවත්",
|
||||
"Exit Auto Scroll": "ස්වයංක්රීය අනුචලනයෙන් පිටවන්න",
|
||||
"Auto Scroll": "ස්වයංක්රීය අනුචලනය",
|
||||
"End of book": "පොතේ අවසානය",
|
||||
"Toggle Auto Scroll": "ස්වයංක්රීය අනුචලනය මාරු කරන්න"
|
||||
}
|
||||
|
||||
@@ -1939,5 +1939,11 @@
|
||||
"Open Read Aloud player": "Odpri predvajalnik glasnega branja",
|
||||
"Read Aloud": "Glasno branje",
|
||||
"Voice": "Glas",
|
||||
"Sleep Timer": "Časovnik spanja"
|
||||
"Sleep Timer": "Časovnik spanja",
|
||||
"Slower": "Počasneje",
|
||||
"Faster": "Hitreje",
|
||||
"Exit Auto Scroll": "Izhod iz samodejnega pomikanja",
|
||||
"Auto Scroll": "Samodejno pomikanje",
|
||||
"End of book": "Konec knjige",
|
||||
"Toggle Auto Scroll": "Preklopi samodejno pomikanje"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "Öppna uppläsningsspelaren",
|
||||
"Read Aloud": "Uppläsning",
|
||||
"Voice": "Röst",
|
||||
"Sleep Timer": "Sovtimer"
|
||||
"Sleep Timer": "Sovtimer",
|
||||
"Slower": "Långsammare",
|
||||
"Faster": "Snabbare",
|
||||
"Exit Auto Scroll": "Avsluta automatisk rullning",
|
||||
"Auto Scroll": "Automatisk rullning",
|
||||
"End of book": "Slutet av boken",
|
||||
"Toggle Auto Scroll": "Växla automatisk rullning"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "உரக்க வாசித்தல் பிளேயரைத் திற",
|
||||
"Read Aloud": "உரக்க வாசித்தல்",
|
||||
"Voice": "குரல்",
|
||||
"Sleep Timer": "ஸ்லீப் டைமர்"
|
||||
"Sleep Timer": "ஸ்லீப் டைமர்",
|
||||
"Slower": "மெதுவாக",
|
||||
"Faster": "வேகமாக",
|
||||
"Exit Auto Scroll": "தானியங்கி உருட்டலில் இருந்து வெளியேறு",
|
||||
"Auto Scroll": "தானியங்கி உருட்டல்",
|
||||
"End of book": "புத்தகத்தின் முடிவு",
|
||||
"Toggle Auto Scroll": "தானியங்கி உருட்டலை மாற்று"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "เปิดเครื่องเล่นอ่านออกเสียง",
|
||||
"Read Aloud": "อ่านออกเสียง",
|
||||
"Voice": "เสียง",
|
||||
"Sleep Timer": "ตั้งเวลาปิด"
|
||||
"Sleep Timer": "ตั้งเวลาปิด",
|
||||
"Slower": "ช้าลง",
|
||||
"Faster": "เร็วขึ้น",
|
||||
"Exit Auto Scroll": "ออกจากการเลื่อนอัตโนมัติ",
|
||||
"Auto Scroll": "เลื่อนอัตโนมัติ",
|
||||
"End of book": "จุดสิ้นสุดของหนังสือ",
|
||||
"Toggle Auto Scroll": "สลับการเลื่อนอัตโนมัติ"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "Sesli okuma oynatıcısını aç",
|
||||
"Read Aloud": "Sesli Okuma",
|
||||
"Voice": "Ses",
|
||||
"Sleep Timer": "Uyku Zamanlayıcısı"
|
||||
"Sleep Timer": "Uyku Zamanlayıcısı",
|
||||
"Slower": "Daha yavaş",
|
||||
"Faster": "Daha hızlı",
|
||||
"Exit Auto Scroll": "Otomatik kaydırmadan çık",
|
||||
"Auto Scroll": "Otomatik Kaydırma",
|
||||
"End of book": "Kitabın sonu",
|
||||
"Toggle Auto Scroll": "Otomatik kaydırmayı aç/kapat"
|
||||
}
|
||||
|
||||
@@ -1939,5 +1939,11 @@
|
||||
"Open Read Aloud player": "Відкрити програвач читання вголос",
|
||||
"Read Aloud": "Читання вголос",
|
||||
"Voice": "Голос",
|
||||
"Sleep Timer": "Таймер сну"
|
||||
"Sleep Timer": "Таймер сну",
|
||||
"Slower": "Повільніше",
|
||||
"Faster": "Швидше",
|
||||
"Exit Auto Scroll": "Вийти з автоматичної прокрутки",
|
||||
"Auto Scroll": "Автоматична прокрутка",
|
||||
"End of book": "Кінець книги",
|
||||
"Toggle Auto Scroll": "Перемкнути автоматичну прокрутку"
|
||||
}
|
||||
|
||||
@@ -1869,5 +1869,11 @@
|
||||
"Open Read Aloud player": "Ovoz chiqarib oʻqish pleyerini ochish",
|
||||
"Read Aloud": "Ovoz chiqarib oʻqish",
|
||||
"Voice": "Ovoz",
|
||||
"Sleep Timer": "Uyqu taymeri"
|
||||
"Sleep Timer": "Uyqu taymeri",
|
||||
"Slower": "Sekinroq",
|
||||
"Faster": "Tezroq",
|
||||
"Exit Auto Scroll": "Avtomatik aylantirishdan chiqish",
|
||||
"Auto Scroll": "Avtomatik aylantirish",
|
||||
"End of book": "Kitob oxiri",
|
||||
"Toggle Auto Scroll": "Avtomatik aylantirishni ochish/yopish"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "Mở trình phát đọc to",
|
||||
"Read Aloud": "Đọc to",
|
||||
"Voice": "Giọng đọc",
|
||||
"Sleep Timer": "Hẹn giờ ngủ"
|
||||
"Sleep Timer": "Hẹn giờ ngủ",
|
||||
"Slower": "Chậm hơn",
|
||||
"Faster": "Nhanh hơn",
|
||||
"Exit Auto Scroll": "Thoát tự động cuộn",
|
||||
"Auto Scroll": "Tự động cuộn",
|
||||
"End of book": "Cuối sách",
|
||||
"Toggle Auto Scroll": "Chuyển chế độ tự động cuộn"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "打开朗读播放器",
|
||||
"Read Aloud": "朗读",
|
||||
"Voice": "语音",
|
||||
"Sleep Timer": "定时关闭"
|
||||
"Sleep Timer": "定时关闭",
|
||||
"Slower": "减慢",
|
||||
"Faster": "加快",
|
||||
"Exit Auto Scroll": "退出自动滚动",
|
||||
"Auto Scroll": "自动滚动",
|
||||
"End of book": "已到达书末",
|
||||
"Toggle Auto Scroll": "切换自动滚动"
|
||||
}
|
||||
|
||||
@@ -1834,5 +1834,11 @@
|
||||
"Open Read Aloud player": "開啟朗讀播放器",
|
||||
"Read Aloud": "朗讀",
|
||||
"Voice": "語音",
|
||||
"Sleep Timer": "定時關閉"
|
||||
"Sleep Timer": "定時關閉",
|
||||
"Slower": "減慢",
|
||||
"Faster": "加快",
|
||||
"Exit Auto Scroll": "退出自動捲動",
|
||||
"Auto Scroll": "自動捲動",
|
||||
"End of book": "已到達書末",
|
||||
"Toggle Auto Scroll": "切換自動捲動"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import {
|
||||
Autoscroller,
|
||||
PacedScroller,
|
||||
AUTOSCROLL_DEAD_ZONE_PX,
|
||||
AUTOSCROLL_SPEED_PER_PX,
|
||||
AUTOSCROLL_MAX_VELOCITY,
|
||||
PACED_SCROLL_MAX_FRAME_MS,
|
||||
} from '@/app/reader/utils/autoscroller';
|
||||
|
||||
// Middle-click autoscroll core (#4951). The controller is pure logic driven by
|
||||
@@ -143,3 +145,169 @@ describe('Autoscroller press/release state machine', () => {
|
||||
expect(hasPendingFrame()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto Scroll reading mode core (#4998): constant-velocity teleprompter
|
||||
// scrolling, same injected rAF/clock testing approach as the middle-click core.
|
||||
const createPacedHarness = () => {
|
||||
let frameCb: FrameRequestCallback | null = null;
|
||||
let time = 0;
|
||||
const deltas: number[] = [];
|
||||
const onStop = vi.fn();
|
||||
const scroller = new PacedScroller({
|
||||
scrollBy: (delta) => deltas.push(delta),
|
||||
onStop,
|
||||
raf: (cb) => {
|
||||
frameCb = cb;
|
||||
return 1;
|
||||
},
|
||||
caf: () => {
|
||||
frameCb = null;
|
||||
},
|
||||
now: () => time,
|
||||
});
|
||||
const step = (ms: number) => {
|
||||
time += ms;
|
||||
const cb = frameCb;
|
||||
frameCb = null;
|
||||
cb?.(time);
|
||||
};
|
||||
const advanceIdle = (ms: number) => {
|
||||
time += ms;
|
||||
};
|
||||
const scrolledTotal = () => deltas.reduce((a, b) => a + b, 0);
|
||||
const hasPendingFrame = () => frameCb !== null;
|
||||
return { scroller, deltas, onStop, step, advanceIdle, scrolledTotal, hasPendingFrame };
|
||||
};
|
||||
|
||||
describe('PacedScroller', () => {
|
||||
test('is inactive until started', () => {
|
||||
const { scroller, hasPendingFrame } = createPacedHarness();
|
||||
expect(scroller.active).toBe(false);
|
||||
expect(hasPendingFrame()).toBe(false);
|
||||
scroller.start(100);
|
||||
expect(scroller.active).toBe(true);
|
||||
expect(scroller.paused).toBe(false);
|
||||
expect(hasPendingFrame()).toBe(true);
|
||||
});
|
||||
|
||||
test('scrolls at the configured velocity', () => {
|
||||
const { scroller, step, scrolledTotal } = createPacedHarness();
|
||||
scroller.start(100);
|
||||
step(100);
|
||||
expect(scrolledTotal()).toBe(10);
|
||||
step(100);
|
||||
expect(scrolledTotal()).toBe(20);
|
||||
});
|
||||
|
||||
test('emits whole pixels and carries the fractional remainder', () => {
|
||||
const { scroller, deltas, step, scrolledTotal } = createPacedHarness();
|
||||
// 25 px/s over 10ms frames yields 0.25px per frame; only whole pixels may
|
||||
// be emitted, with the remainder carried so slow speeds still progress.
|
||||
scroller.start(25);
|
||||
for (let i = 0; i < 8; i++) step(10);
|
||||
expect(scrolledTotal()).toBe(2);
|
||||
expect(deltas.every((d) => Number.isInteger(d))).toBe(true);
|
||||
});
|
||||
|
||||
test('setVelocity takes effect immediately', () => {
|
||||
const { scroller, step, scrolledTotal } = createPacedHarness();
|
||||
scroller.start(100);
|
||||
step(100);
|
||||
expect(scrolledTotal()).toBe(10);
|
||||
scroller.setVelocity(200);
|
||||
step(100);
|
||||
expect(scrolledTotal()).toBe(30);
|
||||
});
|
||||
|
||||
test('pause halts emission and resume continues without a jump', () => {
|
||||
const { scroller, step, advanceIdle, scrolledTotal, hasPendingFrame } = createPacedHarness();
|
||||
scroller.start(100);
|
||||
step(100);
|
||||
expect(scrolledTotal()).toBe(10);
|
||||
scroller.pause();
|
||||
expect(scroller.active).toBe(true);
|
||||
expect(scroller.paused).toBe(true);
|
||||
expect(hasPendingFrame()).toBe(false);
|
||||
// Time spent paused must not be scrolled through on resume.
|
||||
advanceIdle(5000);
|
||||
scroller.resume();
|
||||
expect(scroller.paused).toBe(false);
|
||||
step(100);
|
||||
expect(scrolledTotal()).toBe(20);
|
||||
});
|
||||
|
||||
test('clamps oversized frame gaps so a background tab does not jump', () => {
|
||||
const { scroller, step, scrolledTotal } = createPacedHarness();
|
||||
scroller.start(100);
|
||||
step(10 * PACED_SCROLL_MAX_FRAME_MS);
|
||||
expect(scrolledTotal()).toBe((100 * PACED_SCROLL_MAX_FRAME_MS) / 1000);
|
||||
});
|
||||
|
||||
test('pause and resume are no-ops while inactive', () => {
|
||||
const { scroller, onStop, hasPendingFrame } = createPacedHarness();
|
||||
scroller.pause();
|
||||
scroller.resume();
|
||||
expect(scroller.active).toBe(false);
|
||||
expect(hasPendingFrame()).toBe(false);
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('stop is idempotent, cancels the frame loop, and fires onStop once', () => {
|
||||
const { scroller, onStop, hasPendingFrame } = createPacedHarness();
|
||||
scroller.stop();
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
scroller.start(100);
|
||||
scroller.stop();
|
||||
scroller.stop();
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
expect(hasPendingFrame()).toBe(false);
|
||||
});
|
||||
|
||||
test('stop while paused fires onStop and stays stopped', () => {
|
||||
const { scroller, onStop, step, scrolledTotal } = createPacedHarness();
|
||||
scroller.start(100);
|
||||
scroller.pause();
|
||||
scroller.stop();
|
||||
expect(scroller.active).toBe(false);
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
step(100);
|
||||
expect(scrolledTotal()).toBe(0);
|
||||
});
|
||||
|
||||
test('restart after stop scrolls again', () => {
|
||||
const { scroller, step, scrolledTotal } = createPacedHarness();
|
||||
scroller.start(100);
|
||||
step(100);
|
||||
scroller.stop();
|
||||
scroller.start(50);
|
||||
step(100);
|
||||
expect(scrolledTotal()).toBe(15);
|
||||
});
|
||||
|
||||
test('a scrollBy callback that stops the scroller does not re-arm the frame loop', () => {
|
||||
let frameCb: FrameRequestCallback | null = null;
|
||||
let time = 0;
|
||||
const onStop = vi.fn();
|
||||
const scroller: PacedScroller = new PacedScroller({
|
||||
// The session auto-stops from inside a tick when the book ends.
|
||||
scrollBy: () => scroller.stop(),
|
||||
onStop,
|
||||
raf: (cb) => {
|
||||
frameCb = cb;
|
||||
return 1;
|
||||
},
|
||||
caf: () => {
|
||||
frameCb = null;
|
||||
},
|
||||
now: () => time,
|
||||
});
|
||||
scroller.start(100);
|
||||
time += 100;
|
||||
const cb = frameCb!;
|
||||
frameCb = null;
|
||||
cb(time);
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
expect(scroller.active).toBe(false);
|
||||
expect(frameCb).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,6 +198,7 @@ const seedStores = (view: FoliateView, viewSettings: ViewSettings) => {
|
||||
error: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
autoScrollEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
|
||||
@@ -81,6 +81,7 @@ function seedViewState(key: string, overrides: Record<string, unknown> = {}) {
|
||||
error: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
autoScrollEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MdAdd, MdClose, MdPause, MdPlayArrow, MdRemove } from 'react-icons/md';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { MAX_AUTO_SCROLL_SPEED, MIN_AUTO_SCROLL_SPEED } from '@/services/constants';
|
||||
|
||||
const INITIAL_SHOW_DURATION = 2500;
|
||||
const HIDE_DELAY = 2000;
|
||||
|
||||
interface AutoScrollControlProps {
|
||||
bookKey: string;
|
||||
paused: boolean;
|
||||
speed: number;
|
||||
onTogglePause: () => void;
|
||||
onAdjustSpeed: (dir: 1 | -1) => void;
|
||||
onStop: () => void;
|
||||
gridInsets: Insets;
|
||||
}
|
||||
|
||||
// The Auto Scroll session pill (#4998): speed −/+ around the current
|
||||
// percentage, pause/resume, and exit. Fades away while scrolling so the mode
|
||||
// stays immersive; any mouse movement or pausing (tap on the page) brings it
|
||||
// back. Same floating-pill chassis as ParagraphBar.
|
||||
const AutoScrollControl: React.FC<AutoScrollControlProps> = ({
|
||||
bookKey,
|
||||
paused,
|
||||
speed,
|
||||
onTogglePause,
|
||||
onAdjustSpeed,
|
||||
onStop,
|
||||
gridInsets,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
const iconSize = useResponsiveSize(18);
|
||||
|
||||
const [isBarVisible, setIsBarVisible] = useState(true);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isHoveredRef = useRef(false);
|
||||
const pausedRef = useRef(paused);
|
||||
pausedRef.current = paused;
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startHideTimer = useCallback(
|
||||
(delay: number = HIDE_DELAY) => {
|
||||
clearHideTimer();
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (!isHoveredRef.current && !pausedRef.current) {
|
||||
setIsBarVisible(false);
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
[clearHideTimer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) {
|
||||
clearHideTimer();
|
||||
setIsBarVisible(true);
|
||||
} else {
|
||||
startHideTimer(INITIAL_SHOW_DURATION);
|
||||
}
|
||||
return clearHideTimer;
|
||||
}, [paused, startHideTimer, clearHideTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
let lastMoveTime = 0;
|
||||
const handleMouseMove = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastMoveTime < 100) return;
|
||||
lastMoveTime = now;
|
||||
setIsBarVisible(true);
|
||||
startHideTimer();
|
||||
};
|
||||
window.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||
}, [startHideTimer]);
|
||||
|
||||
const isVisible = isBarVisible && hoveredBookKey !== bookKey;
|
||||
|
||||
const buttonClass = clsx(
|
||||
'flex items-center justify-center rounded-full p-1.5',
|
||||
'transition-all duration-200 ease-out',
|
||||
'not-eink:hover:bg-base-200 active:scale-90',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
// `absolute` so the pill centers on the book's grid cell rather than
|
||||
// the viewport: with a pinned sidebar (or a split view) the reading
|
||||
// column is off the window center and the pill must stay under the
|
||||
// text it controls.
|
||||
'absolute bottom-6 left-1/2 z-50 -translate-x-1/2',
|
||||
'transition-[opacity,filter,transform] duration-200 ease-out',
|
||||
isVisible
|
||||
? 'pointer-events-auto translate-y-0 scale-100 opacity-100 blur-0'
|
||||
: 'pointer-events-none translate-y-4 scale-90 opacity-0 blur-sm',
|
||||
)}
|
||||
style={{
|
||||
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
isHoveredRef.current = true;
|
||||
clearHideTimer();
|
||||
setIsBarVisible(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
isHoveredRef.current = false;
|
||||
startHideTimer();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'text-base-content flex items-center gap-1 rounded-full px-3 py-1.5',
|
||||
'not-eink:bg-base-300 eink-bordered',
|
||||
'not-eink:border-base-content/10 not-eink:border',
|
||||
'shadow-sm backdrop-blur-md',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => onAdjustSpeed(-1)}
|
||||
disabled={speed <= MIN_AUTO_SCROLL_SPEED}
|
||||
className={clsx(buttonClass, speed <= MIN_AUTO_SCROLL_SPEED && 'opacity-30')}
|
||||
title={_('Slower')}
|
||||
aria-label={_('Slower')}
|
||||
>
|
||||
<MdRemove size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div className='flex min-w-[3.5rem] items-center justify-center'>
|
||||
<span className='text-sm font-medium tabular-nums'>{speed}%</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onAdjustSpeed(1)}
|
||||
disabled={speed >= MAX_AUTO_SCROLL_SPEED}
|
||||
className={clsx(buttonClass, speed >= MAX_AUTO_SCROLL_SPEED && 'opacity-30')}
|
||||
title={_('Faster')}
|
||||
aria-label={_('Faster')}
|
||||
>
|
||||
<MdAdd size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div className='bg-base-content/10 mx-1 h-4 w-px' />
|
||||
|
||||
<button
|
||||
onClick={onTogglePause}
|
||||
className={buttonClass}
|
||||
title={paused ? _('Play') : _('Pause')}
|
||||
aria-label={paused ? _('Play') : _('Pause')}
|
||||
>
|
||||
{paused ? <MdPlayArrow size={iconSize} /> : <MdPause size={iconSize} />}
|
||||
</button>
|
||||
|
||||
<div className='bg-base-content/10 mx-1 h-4 w-px' />
|
||||
|
||||
<button
|
||||
onClick={onStop}
|
||||
className={buttonClass}
|
||||
title={_('Exit Auto Scroll')}
|
||||
aria-label={_('Exit Auto Scroll')}
|
||||
>
|
||||
<MdClose size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoScrollControl;
|
||||
@@ -79,8 +79,10 @@ import { eventDispatcher } from '@/utils/event';
|
||||
import { isFontType } from '@/utils/font';
|
||||
import { getScrollGapAttr } from '@/utils/webtoon';
|
||||
import { useMiddleClickAutoscroll } from '../hooks/useMiddleClickAutoscroll';
|
||||
import { useAutoScroll } from '../hooks/useAutoScroll';
|
||||
import { ParagraphControl } from './paragraph';
|
||||
import AutoscrollIndicator from './AutoscrollIndicator';
|
||||
import AutoScrollControl from './AutoScrollControl';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import KOSyncConflictResolver from './KOSyncResolver';
|
||||
import ImageViewer from './ImageViewer';
|
||||
@@ -517,6 +519,7 @@ const FoliateViewer: React.FC<{
|
||||
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip);
|
||||
const touchHandlers = useTouchEvent(bookKey);
|
||||
const autoscrollAnchor = useMiddleClickAutoscroll(bookKey, viewRef, containerRef);
|
||||
const autoScroll = useAutoScroll(bookKey, viewRef);
|
||||
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
|
||||
@@ -963,6 +966,17 @@ const FoliateViewer: React.FC<{
|
||||
{...touchHandlers}
|
||||
/>
|
||||
{autoscrollAnchor && <AutoscrollIndicator anchor={autoscrollAnchor} />}
|
||||
{autoScroll.active && (
|
||||
<AutoScrollControl
|
||||
bookKey={bookKey}
|
||||
paused={autoScroll.paused}
|
||||
speed={autoScroll.speed}
|
||||
onTogglePause={autoScroll.togglePause}
|
||||
onAdjustSpeed={autoScroll.adjustSpeed}
|
||||
onStop={autoScroll.stop}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
<BrightnessOverlay visible={overlayVisible} level={overlayLevel} />
|
||||
<ParagraphControl bookKey={bookKey} viewRef={viewRef} gridInsets={gridInsets} />
|
||||
{((!docLoaded.current && loading) || viewState?.loading) && (
|
||||
|
||||
@@ -123,6 +123,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
eventDispatcher.dispatch('rsvp-start', { bookKey });
|
||||
};
|
||||
|
||||
const toggleAutoScroll = () => {
|
||||
setIsDropdownOpen?.(false);
|
||||
eventDispatcher.dispatch('autoscroll-toggle', { bookKey });
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
setIsDropdownOpen?.(false);
|
||||
if (!bookData?.book) return;
|
||||
@@ -382,6 +387,14 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
onClick={toggleScrolledMode}
|
||||
/>
|
||||
|
||||
<MenuItem
|
||||
label={_('Auto Scroll')}
|
||||
shortcut='Shift+A'
|
||||
Icon={viewState?.autoScrollEnabled ? MdCheck : undefined}
|
||||
onClick={toggleAutoScroll}
|
||||
disabled={!isScrolledMode}
|
||||
/>
|
||||
|
||||
<hr aria-hidden='true' className='border-base-300 my-1' />
|
||||
|
||||
<MenuItem
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
import {
|
||||
AUTO_SCROLL_BASE_PX_PER_SEC,
|
||||
AUTO_SCROLL_SPEED_STEP,
|
||||
MAX_AUTO_SCROLL_SPEED,
|
||||
MIN_AUTO_SCROLL_SPEED,
|
||||
} from '@/services/constants';
|
||||
import { PacedScroller } from '../utils/autoscroller';
|
||||
|
||||
// A stalled forward tick must persist this long before the session reacts
|
||||
// (section hop, or stop at the end of the book) — brief stalls can happen
|
||||
// while an adjacent section is still loading.
|
||||
const AUTO_SCROLL_STALL_MS = 800;
|
||||
|
||||
export interface AutoScrollState {
|
||||
active: boolean;
|
||||
paused: boolean;
|
||||
speed: number;
|
||||
togglePause: () => void;
|
||||
adjustSpeed: (dir: 1 | -1) => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
// Auto Scroll reading mode (#4998): teleprompter-style continuous scrolling in
|
||||
// scrolled mode, toggled via the 'autoscroll-toggle' event (View menu, Shift+A).
|
||||
// A tap on the page pauses/resumes instead of turning the page or toggling the
|
||||
// bars; manual wheel/drag input simply composes with the paced scrolling since
|
||||
// every frame is a relative containerPosition step. Escape or leaving scrolled
|
||||
// mode stops the session; the session state is never persisted.
|
||||
export const useAutoScroll = (
|
||||
bookKey: string,
|
||||
viewRef: React.RefObject<FoliateView | null>,
|
||||
): AutoScrollState => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { getViewSettings, setAutoScrollEnabled } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const [active, setActive] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [speed, setSpeed] = useState(viewSettings?.autoScrollSpeed ?? 100);
|
||||
|
||||
// Undoes the per-session window listeners; set on start.
|
||||
const sessionCleanupRef = useRef<(() => void) | null>(null);
|
||||
const stallStartRef = useRef<number | null>(null);
|
||||
|
||||
const scrollerRef = useRef<PacedScroller | null>(null);
|
||||
if (!scrollerRef.current) {
|
||||
scrollerRef.current = new PacedScroller({
|
||||
scrollBy: (delta) => {
|
||||
const view = viewRef.current;
|
||||
const renderer = view?.renderer;
|
||||
if (!renderer) return;
|
||||
// Vertical-writing books scroll along the horizontal axis in scrolled
|
||||
// mode, where reading forward means going more negative (foliate
|
||||
// paginator negates scrolled-mode offsets for vertical writing).
|
||||
const sign = renderer.scrollProp === 'scrollLeft' ? -1 : 1;
|
||||
const before = renderer.containerPosition;
|
||||
renderer.containerPosition = before + sign * delta;
|
||||
if (renderer.containerPosition === before) {
|
||||
// Clamped by the scroll bounds: at the end of the rendered content.
|
||||
const now = Date.now();
|
||||
if (stallStartRef.current === null) {
|
||||
stallStartRef.current = now;
|
||||
} else if (now - stallStartRef.current >= AUTO_SCROLL_STALL_MS) {
|
||||
stallStartRef.current = now;
|
||||
if (renderer.atEnd) {
|
||||
scrollerRef.current?.stop();
|
||||
eventDispatcher.dispatch('toast', { message: _('End of book'), type: 'info' });
|
||||
} else {
|
||||
// Single-section scroll mode parks at the section end; hop to
|
||||
// the next section and keep going.
|
||||
view?.next();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stallStartRef.current = null;
|
||||
}
|
||||
},
|
||||
onStop: () => {
|
||||
setActive(false);
|
||||
setPaused(false);
|
||||
setAutoScrollEnabled(bookKey, false);
|
||||
sessionCleanupRef.current?.();
|
||||
sessionCleanupRef.current = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const togglePause = () => {
|
||||
const scroller = scrollerRef.current!;
|
||||
if (!scroller.active) return;
|
||||
if (scroller.paused) scroller.resume();
|
||||
else scroller.pause();
|
||||
setPaused(scroller.paused);
|
||||
};
|
||||
|
||||
const adjustSpeed = (dir: 1 | -1) => {
|
||||
const current = getViewSettings(bookKey)?.autoScrollSpeed ?? speed;
|
||||
const next = Math.min(
|
||||
MAX_AUTO_SCROLL_SPEED,
|
||||
Math.max(MIN_AUTO_SCROLL_SPEED, current + dir * AUTO_SCROLL_SPEED_STEP),
|
||||
);
|
||||
setSpeed(next);
|
||||
scrollerRef.current!.setVelocity((AUTO_SCROLL_BASE_PX_PER_SEC * next) / 100);
|
||||
saveViewSettings(envConfig, bookKey, 'autoScrollSpeed', next, false, false);
|
||||
};
|
||||
|
||||
const startSession = () => {
|
||||
const scroller = scrollerRef.current!;
|
||||
const renderer = viewRef.current?.renderer;
|
||||
if (!renderer?.scrolled) return;
|
||||
const speedNow = getViewSettings(bookKey)?.autoScrollSpeed ?? 100;
|
||||
setSpeed(speedNow);
|
||||
stallStartRef.current = null;
|
||||
scroller.start((AUTO_SCROLL_BASE_PX_PER_SEC * speedNow) / 100);
|
||||
setActive(true);
|
||||
setPaused(false);
|
||||
setAutoScrollEnabled(bookKey, true);
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') scroller.stop();
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown, true);
|
||||
sessionCleanupRef.current = () => {
|
||||
window.removeEventListener('keydown', onKeydown, true);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scroller = scrollerRef.current!;
|
||||
const onToggle = (event: CustomEvent) => {
|
||||
if (event.detail?.bookKey !== bookKey) return;
|
||||
if (scroller.active) scroller.stop();
|
||||
else startSession();
|
||||
};
|
||||
// Escape typed inside the book iframe arrives as a forwarded message.
|
||||
const onMessage = (msg: MessageEvent) => {
|
||||
if (msg.data?.type === 'iframe-keydown' && msg.data.key === 'Escape') scroller.stop();
|
||||
};
|
||||
// A tap on the page pauses/resumes the session; consuming the click keeps
|
||||
// it from also turning the page or toggling the header/footer bars.
|
||||
const onSingleClick = () => {
|
||||
if (!scroller.active) return false;
|
||||
togglePause();
|
||||
return true;
|
||||
};
|
||||
eventDispatcher.on('autoscroll-toggle', onToggle);
|
||||
eventDispatcher.onSync('iframe-single-click', onSingleClick);
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => {
|
||||
eventDispatcher.off('autoscroll-toggle', onToggle);
|
||||
eventDispatcher.offSync('iframe-single-click', onSingleClick);
|
||||
window.removeEventListener('message', onMessage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey]);
|
||||
|
||||
// Auto Scroll only exists in scrolled mode; leaving it ends the session.
|
||||
const scrolled = !!viewSettings?.scrolled;
|
||||
useEffect(() => {
|
||||
if (!scrolled) scrollerRef.current?.stop();
|
||||
}, [scrolled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => scrollerRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
active,
|
||||
paused,
|
||||
speed,
|
||||
togglePause,
|
||||
adjustSpeed,
|
||||
stop: () => scrollerRef.current?.stop(),
|
||||
};
|
||||
};
|
||||
@@ -360,6 +360,14 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
eventDispatcher.dispatch('rsvp-start', { bookKey: sideBarBookKey });
|
||||
};
|
||||
|
||||
const toggleAutoScroll = () => {
|
||||
if (!sideBarBookKey) return;
|
||||
// Auto Scroll only exists in scrolled mode (#4998); the View menu item is
|
||||
// disabled outside it, so the shortcut silently no-ops there too.
|
||||
if (!getViewSettings(sideBarBookKey)?.scrolled) return;
|
||||
eventDispatcher.dispatch('autoscroll-toggle', { bookKey: sideBarBookKey });
|
||||
};
|
||||
|
||||
const handlePinchZoom = (event: CustomEvent) => {
|
||||
const zoomLevel = event.detail?.zoomLevel;
|
||||
if (zoomLevel != null) {
|
||||
@@ -393,6 +401,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
onToggleBookmark: toggleBookmark,
|
||||
onToggleParagraphMode: toggleParagraphMode,
|
||||
onStartRSVP: startRSVP,
|
||||
onToggleAutoScroll: toggleAutoScroll,
|
||||
onToggleToolbar: toggleToolbar,
|
||||
onOpenFontLayoutSettings: () => setSettingsDialogOpen(true),
|
||||
onShowSearchBar: showSearchBar,
|
||||
|
||||
@@ -108,3 +108,94 @@ export class Autoscroller {
|
||||
this.#requestFrame();
|
||||
}
|
||||
}
|
||||
|
||||
// Frame gaps above this are treated as this long: rAF stops in background tabs,
|
||||
// and scrolling through the whole gap on the resume frame would jump the text.
|
||||
export const PACED_SCROLL_MAX_FRAME_MS = 100;
|
||||
|
||||
// Auto Scroll reading mode core (#4998): teleprompter-style scrolling at a
|
||||
// constant caller-set velocity, with pause/resume. Emits whole-pixel forward
|
||||
// deltas with fractional carry like Autoscroller; the caller owns the scroll
|
||||
// direction sign. Pure logic with an injectable rAF/clock for tests.
|
||||
export class PacedScroller {
|
||||
#opts: AutoscrollerOptions;
|
||||
#active = false;
|
||||
#paused = false;
|
||||
#velocity = 0;
|
||||
#lastTime = 0;
|
||||
#residual = 0;
|
||||
#frameId: number | null = null;
|
||||
|
||||
constructor(opts: AutoscrollerOptions) {
|
||||
this.#opts = opts;
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this.#active;
|
||||
}
|
||||
|
||||
get paused() {
|
||||
return this.#paused;
|
||||
}
|
||||
|
||||
start(velocity: number) {
|
||||
this.stop();
|
||||
this.#active = true;
|
||||
this.#paused = false;
|
||||
this.#velocity = velocity;
|
||||
this.#residual = 0;
|
||||
this.#resetClockAndRequestFrame();
|
||||
}
|
||||
|
||||
setVelocity(velocity: number) {
|
||||
this.#velocity = velocity;
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (!this.#active || this.#paused) return;
|
||||
this.#paused = true;
|
||||
this.#cancelFrame();
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (!this.#active || !this.#paused) return;
|
||||
this.#paused = false;
|
||||
this.#resetClockAndRequestFrame();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.#active) return;
|
||||
this.#active = false;
|
||||
this.#paused = false;
|
||||
this.#cancelFrame();
|
||||
this.#opts.onStop?.();
|
||||
}
|
||||
|
||||
#cancelFrame() {
|
||||
if (this.#frameId !== null) {
|
||||
(this.#opts.caf ?? cancelAnimationFrame)(this.#frameId);
|
||||
this.#frameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
#resetClockAndRequestFrame() {
|
||||
this.#lastTime = (this.#opts.now ?? performance.now.bind(performance))();
|
||||
this.#frameId = (this.#opts.raf ?? requestAnimationFrame)((time) => this.#tick(time));
|
||||
}
|
||||
|
||||
#tick(time: number) {
|
||||
this.#frameId = null;
|
||||
if (!this.#active || this.#paused) return;
|
||||
const dt = Math.min(Math.max(0, time - this.#lastTime), PACED_SCROLL_MAX_FRAME_MS);
|
||||
this.#lastTime = time;
|
||||
this.#residual += this.#velocity * (dt / 1000);
|
||||
const whole = Math.trunc(this.#residual);
|
||||
if (whole !== 0) {
|
||||
this.#residual -= whole;
|
||||
this.#opts.scrollBy(whole);
|
||||
// scrollBy may stop the session (e.g. the book ended); don't re-arm then.
|
||||
if (!this.#active || this.#paused) return;
|
||||
}
|
||||
this.#frameId = (this.#opts.raf ?? requestAnimationFrame)((t) => this.#tick(t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ const DEFAULT_SHORTCUTS = {
|
||||
description: _('Toggle Paragraph Mode'),
|
||||
section: 'General',
|
||||
},
|
||||
onToggleAutoScroll: {
|
||||
keys: ['shift+a'],
|
||||
description: _('Toggle Auto Scroll'),
|
||||
section: 'General',
|
||||
},
|
||||
onStartRSVP: {
|
||||
keys: ['shift+v'],
|
||||
description: _('Speed Reading Mode'),
|
||||
|
||||
@@ -297,6 +297,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
scrollingOverlap: 0,
|
||||
allowScript: false,
|
||||
hideScrollbar: false,
|
||||
autoScrollSpeed: 100,
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_LANGUAGE: BookLanguage = {
|
||||
@@ -882,6 +883,12 @@ export const MAX_CONTRAST = 300;
|
||||
export const MIN_CONTRAST = 50;
|
||||
export const CONTRAST_STEP = 10;
|
||||
|
||||
// Auto Scroll (#4998): speed is stored as a percentage of the base velocity.
|
||||
export const AUTO_SCROLL_BASE_PX_PER_SEC = 20;
|
||||
export const MAX_AUTO_SCROLL_SPEED = 500;
|
||||
export const MIN_AUTO_SCROLL_SPEED = 25;
|
||||
export const AUTO_SCROLL_SPEED_STEP = 25;
|
||||
|
||||
export const SHOW_UNREAD_STATUS_BADGE = false;
|
||||
|
||||
export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
|
||||
@@ -42,6 +42,9 @@ interface ViewState {
|
||||
`getBookProgress(key)` for one-shot reads. */
|
||||
ribbonVisible: boolean;
|
||||
ttsEnabled: boolean;
|
||||
/* True while an Auto Scroll session (#4998) is engaged for this view;
|
||||
session-only, never persisted. Drives the View menu checkmark. */
|
||||
autoScrollEnabled: boolean;
|
||||
syncing: boolean;
|
||||
gridInsets: Insets | null;
|
||||
/* True while the reader is showing a position requested by an external
|
||||
@@ -65,6 +68,7 @@ interface ReaderStore {
|
||||
setHoveredBookKey: (key: string | null) => void;
|
||||
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
|
||||
setTTSEnabled: (key: string, enabled: boolean) => void;
|
||||
setAutoScrollEnabled: (key: string, enabled: boolean) => void;
|
||||
setIsLoading: (key: string, loading: boolean) => void;
|
||||
setIsSyncing: (key: string, syncing: boolean) => void;
|
||||
setProgress: (
|
||||
@@ -157,6 +161,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
autoScrollEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
@@ -296,6 +301,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
autoScrollEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
@@ -319,6 +325,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: 'Failed to load book.',
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
autoScrollEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
@@ -466,6 +473,17 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
},
|
||||
})),
|
||||
|
||||
setAutoScrollEnabled: (key: string, enabled: boolean) =>
|
||||
set((state) => ({
|
||||
viewStates: {
|
||||
...state.viewStates,
|
||||
[key]: {
|
||||
...state.viewStates[key]!,
|
||||
autoScrollEnabled: enabled,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setIsLoading: (key: string, loading: boolean) =>
|
||||
set((state) => ({
|
||||
viewStates: {
|
||||
|
||||
@@ -209,6 +209,8 @@ export interface BookLayout {
|
||||
scrollingOverlap: number;
|
||||
allowScript: boolean;
|
||||
hideScrollbar: boolean;
|
||||
/* Auto Scroll (#4998) speed as a percentage; 100 = AUTO_SCROLL_BASE_PX_PER_SEC. */
|
||||
autoScrollSpeed: number;
|
||||
}
|
||||
|
||||
export interface BookStyle {
|
||||
|
||||
Reference in New Issue
Block a user