forked from akai/readest
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 363b98dd3b | |||
| 43532a0ee8 | |||
| 03b2af1f97 | |||
| 3f8d03ae28 | |||
| 146a71fdef | |||
| 734e22d92c | |||
| a77986c0e6 | |||
| 4e7f9c49b0 | |||
| c51c95b883 | |||
| 62081bebfd | |||
| 15394d17d8 | |||
| 5d1deadc1e | |||
| b497ddacac | |||
| a9965fd52c | |||
| 87d610bb62 | |||
| dbc4651141 | |||
| dad2dfd078 | |||
| 1fe188814d | |||
| 6ec3a5b332 | |||
| 1acba2e08c | |||
| 32f9346f84 | |||
| 69c503026a | |||
| 84fc4cec5e | |||
| 5c9c11fafd | |||
| 77fa57438e | |||
| e4d217f3aa | |||
| c7a583c53a | |||
| 6c86917098 | |||
| 7c21f48d68 | |||
| 5820191c26 | |||
| b37a804192 | |||
| ebdfc39ef6 | |||
| 9f0d8b5c12 | |||
| a267671671 | |||
| aa62ebd3f0 | |||
| cc85e5c304 | |||
| b0cc0b6f1a | |||
| 6b033f7509 | |||
| 5c0670031a | |||
| 35735cd931 | |||
| 699a01c78f | |||
| 033e161455 | |||
| e6428656c2 | |||
| 68c3c45da3 | |||
| 36e87d4eb6 | |||
| 7f5059a8ea | |||
| 8dc04f4095 | |||
| 6a48f3feb4 | |||
| facf5fba75 | |||
| 9700942e24 | |||
| e238b49fbf | |||
| 32dfadf182 | |||
| 66744108e2 | |||
| 401f28847c | |||
| 9674b45cf0 | |||
| a4bc88d305 | |||
| b6b8b17760 | |||
| 14810d6737 | |||
| ed6c92a65a | |||
| 20d7a1185e | |||
| 32bcfda9ad |
@@ -48,12 +48,12 @@
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
|
||||
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
@@ -78,10 +78,10 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
@@ -253,6 +253,7 @@ The following libraries and frameworks are used in this software:
|
||||
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
|
||||
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
|
||||
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
|
||||
- [marked](https://github.com/markedjs/marked), which is MIT licensed.
|
||||
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
|
||||
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
|
||||
- [react](https://github.com/facebook/react), which is MIT licensed.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.43",
|
||||
"version": "0.9.52",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -67,8 +67,9 @@
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"next": "15.2.4",
|
||||
"posthog-js": "^1.205.0",
|
||||
"posthog-js": "^1.246.0",
|
||||
"react": "19.0.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "19.0.0",
|
||||
@@ -81,6 +82,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opennextjs/cloudflare": "^0.5.12",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.5.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "الرسوم المتحركة",
|
||||
"Annotate": "إضافة ملاحظة",
|
||||
"Apply": "تطبيق",
|
||||
"Are you sure to delete the selected books?": "هل أنت متأكد من رغبتك في حذف الكتب المحددة؟",
|
||||
"Auto Mode": "الوضع التلقائي",
|
||||
"Behavior": "السلوك",
|
||||
"Book": "الكتاب",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "مطابقة الكلمات الكاملة",
|
||||
"Maximum Number of Columns": "العدد الأقصى للأعمدة",
|
||||
"Minimum Font Size": "الحد الأدنى لحجم الخط",
|
||||
"Misc": "أخرى",
|
||||
"Monospace Font": "خط أحادي المسافة (Monospace)",
|
||||
"More Info": "مزيد من المعلومات",
|
||||
"Nord": "نورد",
|
||||
@@ -304,13 +302,55 @@
|
||||
"Checking for updates...": "جارٍ التحقق من التحديثات...",
|
||||
"Error checking for updates": "خطأ في التحقق من التحديثات",
|
||||
"Details": "التفاصيل",
|
||||
"Cancel Selection": "إلغاء التحديد",
|
||||
"File Size:": "حجم الملف:",
|
||||
"Auto Detect": "الكشف التلقائي",
|
||||
"Touch": "اللمس",
|
||||
"Scrolling Overlap (px)": "تداخل التمرير (بكسل)",
|
||||
"Next Section": "القسم التالي",
|
||||
"Previous Section": "القسم السابق",
|
||||
"Next Page": "الصفحة التالية",
|
||||
"Previous Page": "الصفحة السابقة"
|
||||
"Previous Page": "الصفحة السابقة",
|
||||
"Are you sure to delete {{count}} selected book(s)?_zero": "هل أنت متأكد من حذف الكتب المحددة؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "هل أنت متأكد من حذف الكتاب المحدد؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_two": "هل أنت متأكد من حذف الكتابين المحددين؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_few": "هل أنت متأكد من حذف {{count}} كتب محددة؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "هل أنت متأكد من حذف {{count}} كتابًا محددًا؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "هل أنت متأكد من حذف {{count}} من الكتب المحددة؟",
|
||||
"Are you sure to delete the selected book?": "هل أنت متأكد من حذف الكتاب المحدد؟",
|
||||
"Deselect": "إلغاء",
|
||||
"Select All": "الكل",
|
||||
"No translation available.": "لا توجد ترجمة متاحة.",
|
||||
"Translated by {{provider}}.": "ترجم بواسطة {{provider}}.",
|
||||
"DeepL": "ديب إل",
|
||||
"Google Translate": "ترجمة جوجل",
|
||||
"Azure Translator": "مترجم أزور",
|
||||
"Invert Image In Dark Mode": "عكس الصورة في الوضع المظلم",
|
||||
"Help improve Readest": "ساعد في تحسين ريديست",
|
||||
"Sharing anonymized statistics": "مشاركة إحصائيات مجهولة الهوية",
|
||||
"Interface Language": "لغة الواجهة",
|
||||
"Translation": "الترجمة",
|
||||
"Enable Translation": "تمكين الترجمة",
|
||||
"Translation Service": "خدمة الترجمة",
|
||||
"Translate To": "ترجمة إلى",
|
||||
"Disable Translation": "تعطيل الترجمة",
|
||||
"Scroll": "تمرير",
|
||||
"Overlap Pixels": "تداخل البكسلات",
|
||||
"Click": "نقر",
|
||||
"System Language": "لغة النظام",
|
||||
"Security": "الأمان",
|
||||
"Allow JavaScript": "السماح بـ JavaScript",
|
||||
"Enable only if you trust the file.": "تمكين فقط إذا كنت تثق في الملف.",
|
||||
"Sort TOC by Page": "ترتيب جدول المحتويات حسب الصفحة",
|
||||
"Search in {{count}} Book(s)..._zero": "بحث في الكتب...",
|
||||
"Search in {{count}} Book(s)..._one": "بحث في كتاب...",
|
||||
"Search in {{count}} Book(s)..._two": "بحث في كتابين...",
|
||||
"Search in {{count}} Book(s)..._few": "بحث في {{count}} كتب...",
|
||||
"Search in {{count}} Book(s)..._many": "بحث في {{count}} كتابًا...",
|
||||
"Search in {{count}} Book(s)..._other": "بحث في {{count}} من الكتب...",
|
||||
"No notes match your search": "لا توجد ملاحظات تطابق بحثك",
|
||||
"Search notes and excerpts...": "بحث في الملاحظات والمقتطفات...",
|
||||
"Sign in to Sync": "سجّل للمزامنة",
|
||||
"Synced at {{time}}": "تمت عند {{time}}",
|
||||
"Never synced": "لم تُزامَن",
|
||||
"Show Remaining Time": "إظهار الوقت المتبقي",
|
||||
"Show Page Number": "إظهار رقم الصفحة",
|
||||
"{{time}} min left in chapter": "{{time}} دقيقة متبقية في الفصل"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animation",
|
||||
"Annotate": "Annotieren",
|
||||
"Apply": "Anwenden",
|
||||
"Are you sure to delete the selected books?": "Möchten Sie die ausgewählten Bücher wirklich löschen?",
|
||||
"Auto Mode": "Automatischer Modus",
|
||||
"Behavior": "Verhalten",
|
||||
"Book": "Buch",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Ganze Wörter",
|
||||
"Maximum Number of Columns": "Maximale Spaltenanzahl",
|
||||
"Minimum Font Size": "Minimale Schriftgröße",
|
||||
"Misc": "Sonstiges",
|
||||
"Monospace Font": "Monospace-Schriftart",
|
||||
"More Info": "Weitere Informationen",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,47 @@
|
||||
"Checking for updates...": "Auf Updates prüfen...",
|
||||
"Error checking for updates": "Fehler beim Überprüfen auf Updates",
|
||||
"Details": "Details",
|
||||
"Cancel Selection": "Auswahl abbrechen",
|
||||
"File Size:": "Dateigröße:",
|
||||
"Auto Detect": "Automatisch erkennen",
|
||||
"Touch": "Berührung",
|
||||
"Scrolling Overlap (px)": "Scroll-Überlappung (px)",
|
||||
"Next Section": "Nächster Abschnitt",
|
||||
"Previous Section": "Vorheriger Abschnitt",
|
||||
"Next Page": "Nächste Seite",
|
||||
"Previous Page": "Vorherige Seite"
|
||||
"Previous Page": "Vorherige Seite",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Möchtest du das ausgewählte Buch wirklich löschen?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Möchtest du die {{count}} ausgewählten Bücher wirklich löschen?",
|
||||
"Are you sure to delete the selected book?": "Möchtest du das ausgewählte Buch wirklich löschen?",
|
||||
"Deselect": "Aufheben",
|
||||
"Select All": "Alle",
|
||||
"No translation available.": "Keine Übersetzung verfügbar.",
|
||||
"Translated by {{provider}}.": "Übersetzt von {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Übersetzer",
|
||||
"Azure Translator": "Azure Übersetzer",
|
||||
"Invert Image In Dark Mode": "Invertiere Bild im Dunkeln",
|
||||
"Help improve Readest": "Readest verbessern helfen",
|
||||
"Sharing anonymized statistics": "Anonymisierte Statistiken teilen",
|
||||
"Interface Language": "Benutzeroberflächensprache",
|
||||
"Translation": "Übersetzung",
|
||||
"Enable Translation": "Übersetzung aktivieren",
|
||||
"Translation Service": "Übersetzungsdienst",
|
||||
"Translate To": "Übersetzen nach",
|
||||
"Disable Translation": "Übersetzung deaktivieren",
|
||||
"Scroll": "Scrollen",
|
||||
"Overlap Pixels": "Überlappungspixel",
|
||||
"Click": "Tippen",
|
||||
"System Language": "Systemsprache",
|
||||
"Security": "Sicherheit",
|
||||
"Allow JavaScript": "JavaScript zulassen",
|
||||
"Enable only if you trust the file.": "Nur aktivieren, wenn Sie der Datei vertrauen.",
|
||||
"Sort TOC by Page": "TOC nach Seite sortieren",
|
||||
"Search in {{count}} Book(s)..._one": "Suche in {{count}} Buch...",
|
||||
"Search in {{count}} Book(s)..._other": "Suche in {{count}} Büchern...",
|
||||
"No notes match your search": "Noch keine Notizen gefunden",
|
||||
"Search notes and excerpts...": "Notizen und Auszüge suchen...",
|
||||
"Sign in to Sync": "Anmelden zum Synchronisieren",
|
||||
"Synced at {{time}}": "Synchronisiert um {{time}}",
|
||||
"Never synced": "Nie synchronisiert",
|
||||
"Show Remaining Time": "Verbleibende Zeit anzeigen",
|
||||
"Show Page Number": "Seitenzahl anzeigen",
|
||||
"{{time}} min left in chapter": "{{time}} min verbleibend im Kapitel"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Κινούμενα σχέδια",
|
||||
"Annotate": "Σχολιασμός",
|
||||
"Apply": "Εφαρμογή",
|
||||
"Are you sure to delete the selected books?": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τα επιλεγμένα βιβλία;",
|
||||
"Auto Mode": "Αυτόματη λειτουργία",
|
||||
"Behavior": "Συμπεριφορά",
|
||||
"Book": "Βιβλίο",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Ταίριασμα ολόκληρων λέξεων",
|
||||
"Maximum Number of Columns": "Μέγιστος αριθμός στηλών",
|
||||
"Minimum Font Size": "Ελάχιστο μέγεθος γραμματοσειράς",
|
||||
"Misc": "Διάφορα",
|
||||
"Monospace Font": "Γραμματοσειρά σταθερού πλάτους",
|
||||
"More Info": "Περισσότερες πληροφορίες",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,47 @@
|
||||
"Checking for updates...": "Έλεγχος για ενημερώσεις...",
|
||||
"Error checking for updates": "Σφάλμα κατά τον έλεγχο για ενημερώσεις",
|
||||
"Details": "Πληρ.",
|
||||
"Cancel Selection": "Ακύρωση επιλογής",
|
||||
"File Size:": "Μέγεθος αρχείου:",
|
||||
"Auto Detect": "Αυτόματη ανίχνευση",
|
||||
"Touch": "Αφή",
|
||||
"Scrolling Overlap (px)": "Επικάλυψη κύλισης (px)",
|
||||
"Next Section": "Επόμενη ενότητα",
|
||||
"Previous Section": "Προηγούμενη ενότητα",
|
||||
"Next Page": "Επόμενη σελίδα",
|
||||
"Previous Page": "Προηγούμενη σελίδα"
|
||||
"Previous Page": "Προηγούμενη σελίδα",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Είστε σίγουροι ότι θέλετε να διαγράψετε το επιλεγμένο βιβλίο;",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Είστε σίγουροι ότι θέλετε να διαγράψετε τα {{count}} επιλεγμένα βιβλία;",
|
||||
"Are you sure to delete the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το επιλεγμένο βιβλίο;",
|
||||
"Deselect": "Άρση",
|
||||
"Select All": "Όλα",
|
||||
"No translation available.": "Δεν είναι διαθέσιμη καμία μετάφραση.",
|
||||
"Translated by {{provider}}.": "Μεταφρασμένο από {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Μετάφραση Google",
|
||||
"Azure Translator": "Μεταφραστής Azure",
|
||||
"Invert Image In Dark Mode": "Αντιστροφή σε σκούρο",
|
||||
"Help improve Readest": "Βοηθήστε στη βελτίωση του Readest",
|
||||
"Sharing anonymized statistics": "Μοιράζομαι ανώνυμες στατιστικές",
|
||||
"Interface Language": "Γλώσσα διεπαφής",
|
||||
"Translation": "Μετάφραση",
|
||||
"Enable Translation": "Ενεργοποίηση μετάφρασης",
|
||||
"Translation Service": "Υπηρεσία μετάφρασης",
|
||||
"Translate To": "Μετάφραση σε",
|
||||
"Disable Translation": "Απενεργοποίηση μετάφρασης",
|
||||
"Scroll": "Κύλιση",
|
||||
"Overlap Pixels": "Επικάλυψη εικονοστοιχείων",
|
||||
"Click": "Κλικ",
|
||||
"System Language": "Γλώσσα συστήματος",
|
||||
"Security": "Ασφάλεια",
|
||||
"Allow JavaScript": "Επιτρέψτε JavaScript",
|
||||
"Enable only if you trust the file.": "Ενεργοποιήστε μόνο εάν εμπιστεύεστε το αρχείο.",
|
||||
"Sort TOC by Page": "Ταξινόμηση TOC κατά σελίδα",
|
||||
"Search in {{count}} Book(s)..._one": "Αναζήτηση σε {{count}} Βιβλίο...",
|
||||
"Search in {{count}} Book(s)..._other": "Αναζήτηση σε {{count}} Βιβλία...",
|
||||
"No notes match your search": "Καμία σημείωση δεν βρέθηκε",
|
||||
"Search notes and excerpts...": "Αναζήτηση σημειώσεων...",
|
||||
"Sign in to Sync": "Σύνδεση για συγχρονισμό",
|
||||
"Synced at {{time}}": "Συγχρονίστηκε στις {{time}}",
|
||||
"Never synced": "Ποτέ δεν συγχρονίστηκε",
|
||||
"Show Remaining Time": "Εμφάνιση υπολειπόμενου χρόνου",
|
||||
"Show Page Number": "Εμφάνιση αριθμού σελίδας",
|
||||
"{{time}} min left in chapter": "{{time}} λεπτά απομένουν στο κεφάλαιο"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC"
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Are you sure to delete {{count}} selected book?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Are you sure to delete {{count}} selected books?",
|
||||
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
|
||||
"Search in {{count}} Book(s)..._other": "Search in {{count}} books..."
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animación",
|
||||
"Annotate": "Anotar",
|
||||
"Apply": "Aplicar",
|
||||
"Are you sure to delete the selected books?": "¿Estás seguro de eliminar los libros seleccionados?",
|
||||
"Auto Mode": "Modo automático",
|
||||
"Behavior": "Comportamiento",
|
||||
"Book": "Libro",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Coincidir palabras completas",
|
||||
"Maximum Number of Columns": "Número máximo de columnas",
|
||||
"Minimum Font Size": "Tamaño de fuente mínimo",
|
||||
"Misc": "Varios",
|
||||
"Monospace Font": "Fuente monoespaciada",
|
||||
"More Info": "Más información",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,49 @@
|
||||
"Checking for updates...": "Comprobando actualizaciones...",
|
||||
"Error checking for updates": "Error al comprobar actualizaciones",
|
||||
"Details": "Detalles",
|
||||
"Cancel Selection": "Cancelar selección",
|
||||
"File Size:": "Peso del archivo:",
|
||||
"Auto Detect": "Detección automática",
|
||||
"Touch": "Toque",
|
||||
"Scrolling Overlap (px)": "Superposición de desplazamiento (px)",
|
||||
"Next Section": "Siguiente sección",
|
||||
"Previous Section": "Sección anterior",
|
||||
"Next Page": "Página siguiente",
|
||||
"Previous Page": "Página anterior"
|
||||
"Previous Page": "Página anterior",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "¿Seguro que quieres eliminar el libro seleccionado?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "¿Seguro que quieres eliminar los {{count}} libros seleccionados?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "¿Seguro que quieres eliminar los {{count}} libros seleccionados?",
|
||||
"Are you sure to delete the selected book?": "¿Seguro que quieres eliminar el libro seleccionado?",
|
||||
"Deselect": "Quitar",
|
||||
"Select All": "Todo",
|
||||
"No translation available.": "No hay traducción disponible.",
|
||||
"Translated by {{provider}}.": "Traducido por {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Invertir imagen en oscuro",
|
||||
"Help improve Readest": "Ayuda a mejorar Readest",
|
||||
"Sharing anonymized statistics": "Compartiendo estadísticas anónimas",
|
||||
"Interface Language": "Idioma de la interfaz",
|
||||
"Translation": "Traducción",
|
||||
"Enable Translation": "Habilitar traducción",
|
||||
"Translation Service": "Servicio de traducción",
|
||||
"Translate To": "Traducir a",
|
||||
"Disable Translation": "Deshabilitar traducción",
|
||||
"Scroll": "Desplazar",
|
||||
"Overlap Pixels": "Superponer píxeles",
|
||||
"Click": "Click",
|
||||
"System Language": "Idioma del sistema",
|
||||
"Security": "Seguridad",
|
||||
"Allow JavaScript": "Permitir JavaScript",
|
||||
"Enable only if you trust the file.": "Permitir solo si confías en el archivo.",
|
||||
"Sort TOC by Page": "Ordenar TOC por página",
|
||||
"Search in {{count}} Book(s)..._one": "Buscar en {{count}} libro...",
|
||||
"Search in {{count}} Book(s)..._many": "Buscar en {{count}} libros...",
|
||||
"Search in {{count}} Book(s)..._other": "Buscar en {{count}} libros...",
|
||||
"No notes match your search": "No se encontraron notas",
|
||||
"Search notes and excerpts...": "Buscar notas...",
|
||||
"Sign in to Sync": "Inicia sesión para sincronizar",
|
||||
"Synced at {{time}}": "Sincronizado a las {{time}}",
|
||||
"Never synced": "Nunca sincronizado",
|
||||
"Show Remaining Time": "Mostrar tiempo restante",
|
||||
"Show Page Number": "Mostrar número de página",
|
||||
"{{time}} min left in chapter": "{{time}} min restantes en el capítulo"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animation",
|
||||
"Annotate": "Annoter",
|
||||
"Apply": "Appliquer",
|
||||
"Are you sure to delete the selected books?": "Êtes-vous sûr de vouloir supprimer les livres sélectionnés ?",
|
||||
"Auto Mode": "Mode automatique",
|
||||
"Behavior": "Comportement",
|
||||
"Book": "Livre",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Mots entiers",
|
||||
"Maximum Number of Columns": "Nombre maximal de colonnes",
|
||||
"Minimum Font Size": "Taille minimale de police",
|
||||
"Misc": "Divers",
|
||||
"Monospace Font": "Police monospace",
|
||||
"More Info": "Plus d'informations",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,49 @@
|
||||
"Checking for updates...": "Vérification des mises à jour...",
|
||||
"Error checking for updates": "Erreur lors de la vérification des mises à jour",
|
||||
"Details": "Détails",
|
||||
"Cancel Selection": "Annuler la sélection",
|
||||
"File Size:": "Taille du fichier :",
|
||||
"Auto Detect": "Détection automatique",
|
||||
"Touch": "Touch",
|
||||
"Scrolling Overlap (px)": "Chevauchement de défilement (px)",
|
||||
"Next Section": "Section suivante",
|
||||
"Previous Section": "Section précédente",
|
||||
"Next Page": "Page suivante",
|
||||
"Previous Page": "Page précédente"
|
||||
"Previous Page": "Page précédente",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Supprimer le livre sélectionné ?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Supprimer les {{count}} livres sélectionnés ?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Supprimer les {{count}} livres sélectionnés ?",
|
||||
"Are you sure to delete the selected book?": "Supprimer le livre sélectionné ?",
|
||||
"Deselect": "Annuler",
|
||||
"Select All": "Tous",
|
||||
"No translation available.": "Aucune traduction disponible.",
|
||||
"Translated by {{provider}}.": "Traduit par {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Traduction",
|
||||
"Azure Translator": "Traducteur Azure",
|
||||
"Invert Image In Dark Mode": "Inverser l'image en mode sombre",
|
||||
"Help improve Readest": "Contribuer à améliorer Readest",
|
||||
"Sharing anonymized statistics": "Partage de statistiques anonymisées",
|
||||
"Interface Language": "Langue de l'interface",
|
||||
"Translation": "Traduction",
|
||||
"Enable Translation": "Activer la traduction",
|
||||
"Translation Service": "Service de traduction",
|
||||
"Translate To": "Traduire vers",
|
||||
"Disable Translation": " désactiver la traduction",
|
||||
"Scroll": "Défilement",
|
||||
"Overlap Pixels": "Pixels de chevauchement",
|
||||
"Click": "Cliquer",
|
||||
"System Language": "Langue du système",
|
||||
"Security": "Sécurité",
|
||||
"Allow JavaScript": "Autoriser JavaScript",
|
||||
"Enable only if you trust the file.": "Activer uniquement si vous faites confiance au fichier.",
|
||||
"Sort TOC by Page": "Tris la table des matières par page",
|
||||
"Search in {{count}} Book(s)..._one": "Rechercher dans {{count}} Livre...",
|
||||
"Search in {{count}} Book(s)..._many": "Rechercher dans {{count}} Livres...",
|
||||
"Search in {{count}} Book(s)..._other": "Rechercher dans {{count}} Livres...",
|
||||
"No notes match your search": "Aucune note trouvée",
|
||||
"Search notes and excerpts...": "Rechercher des notes...",
|
||||
"Sign in to Sync": "Connectez-vous pour synchroniser",
|
||||
"Synced at {{time}}": "Synchronisé à {{time}}",
|
||||
"Never synced": "Jamais synchronisé",
|
||||
"Show Remaining Time": "Afficher le temps restant",
|
||||
"Show Page Number": "Afficher le numéro de page",
|
||||
"{{time}} min left in chapter": "{{time}} min restant dans le chapitre"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "एनीमेशन",
|
||||
"Annotate": "टिप्पणी",
|
||||
"Apply": "लागू करें",
|
||||
"Are you sure to delete the selected books?": "क्या आप चयनित पुस्तकों को हटाना चाहते हैं?",
|
||||
"Auto Mode": "स्वचालित मोड",
|
||||
"Behavior": "व्यवहार",
|
||||
"Book": "पुस्तक",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "पूरे शब्द मैच करें",
|
||||
"Maximum Number of Columns": "कॉलम की अधिकतम संख्या",
|
||||
"Minimum Font Size": "न्यूनतम फ़ॉन्ट साइज़",
|
||||
"Misc": "विविध",
|
||||
"Monospace Font": "मोनोस्पेस फ़ॉन्ट",
|
||||
"More Info": "अधिक जानकारी",
|
||||
"Nord": "नॉर्ड",
|
||||
@@ -304,13 +302,47 @@
|
||||
"Checking for updates...": "अपडेट की जांच कर रहा है...",
|
||||
"Error checking for updates": "अपडेट की जांच करते समय त्रुटि",
|
||||
"Details": "विवरण",
|
||||
"Cancel Selection": "चयन रद्द करें",
|
||||
"File Size:": "फ़ाइल आकार:",
|
||||
"Auto Detect": "स्वचालित पहचान",
|
||||
"Touch": "स्पर्श",
|
||||
"Scrolling Overlap (px)": "स्क्रॉलिंग ओवरलैप (px)",
|
||||
"Next Section": "अगला अनुभाग",
|
||||
"Previous Section": "पिछला अनुभाग",
|
||||
"Next Page": "अगला पृष्ठ",
|
||||
"Previous Page": "पिछला पृष्ठ"
|
||||
"Previous Page": "पिछला पृष्ठ",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "क्या आप चयनित पुस्तक हटाना चाहते हैं?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "क्या आप चयनित {{count}} पुस्तकें हटाना चाहते हैं?",
|
||||
"Are you sure to delete the selected book?": "क्या आप चयनित पुस्तक हटाना चाहते हैं?",
|
||||
"Deselect": "रद्द करें",
|
||||
"Select All": "सब चुनें",
|
||||
"No translation available.": "कोई अनुवाद उपलब्ध नहीं है।",
|
||||
"Translated by {{provider}}.": "{{provider}} द्वारा अनुवादित।",
|
||||
"DeepL": "डीपएल",
|
||||
"Google Translate": "गूगल अनुवाद",
|
||||
"Azure Translator": "एज़्योर अनुवादक",
|
||||
"Invert Image In Dark Mode": "अंधेरे में छवि को उलटें",
|
||||
"Help improve Readest": "Readest में सुधार करने में मदद करें",
|
||||
"Sharing anonymized statistics": "सांख्यिकी साझा करना",
|
||||
"Interface Language": "इंटरफ़ेस भाषा",
|
||||
"Translation": "अनुवाद",
|
||||
"Enable Translation": "अनुवाद सक्षम करें",
|
||||
"Translation Service": "अनुवाद सेवा",
|
||||
"Translate To": "अनुवाद करें",
|
||||
"Disable Translation": "अनुवाद अक्षम करें",
|
||||
"Scroll": "स्क्रॉल",
|
||||
"Overlap Pixels": "ओवरलैप पिक्सेल",
|
||||
"Click": "क्लिक करें",
|
||||
"System Language": "सिस्टम भाषा",
|
||||
"Security": "सुरक्षा",
|
||||
"Allow JavaScript": "जावास्क्रिप्ट की अनुमति दें",
|
||||
"Enable only if you trust the file.": "केवल तभी सक्षम करें जब आप फ़ाइल पर भरोसा करते हों।",
|
||||
"Sort TOC by Page": "सामग्री को पृष्ठ द्वारा क्रमबद्ध करें",
|
||||
"Search in {{count}} Book(s)..._one": "{{count}} पुस्तक में खोजें...",
|
||||
"Search in {{count}} Book(s)..._other": "{{count}} पुस्तकों में खोजें...",
|
||||
"No notes match your search": "कोई नोट्स नहीं मिले",
|
||||
"Search notes and excerpts...": "नोट्स खोजें...",
|
||||
"Sign in to Sync": "सिंक करने के लिए साइन इन करें",
|
||||
"Synced at {{time}}": "{{time}} पर सिंक किया गया",
|
||||
"Never synced": "कभी सिंक नहीं किया गया",
|
||||
"Show Remaining Time": "शेष समय दिखाएं",
|
||||
"Show Page Number": "पृष्ठ संख्या दिखाएं",
|
||||
"{{time}} min left in chapter": "{{time}} मिनट शेष अध्याय में"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animasi",
|
||||
"Annotate": "Anotasi",
|
||||
"Apply": "Terapkan",
|
||||
"Are you sure to delete the selected books?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
|
||||
"Auto Mode": "Mode Otomatis",
|
||||
"Behavior": "Perilaku",
|
||||
"Book": "Buku",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Cocokkan Kata Utuh",
|
||||
"Maximum Number of Columns": "Jumlah Kolom Maksimum",
|
||||
"Minimum Font Size": "Ukuran Font Minimum",
|
||||
"Misc": "Lain-lain",
|
||||
"Monospace Font": "Font Monospace",
|
||||
"More Info": "Info Lebih Lanjut",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,45 @@
|
||||
"Checking for updates...": "Memeriksa pembaruan...",
|
||||
"Error checking for updates": "Kesalahan saat memeriksa pembaruan",
|
||||
"Details": "Detail",
|
||||
"Cancel Selection": "Batalkan Pilihan",
|
||||
"File Size:": "Ukuran File:",
|
||||
"Auto Detect": "Deteksi Otomatis",
|
||||
"Touch": "Sentuh",
|
||||
"Scrolling Overlap (px)": "Overlapping Gulir (px)",
|
||||
"Next Section": "Bagian Selanjutnya",
|
||||
"Previous Section": "Bagian Sebelumnya",
|
||||
"Next Page": "Halaman Berikutnya",
|
||||
"Previous Page": "Halaman Sebelumnya"
|
||||
"Previous Page": "Halaman Sebelumnya",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Apakah Anda yakin ingin menghapus {{count}} buku yang dipilih?",
|
||||
"Are you sure to delete the selected book?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
|
||||
"Deselect": "Batal",
|
||||
"Select All": "Pilih Semua",
|
||||
"No translation available.": "Tidak ada terjemahan yang tersedia.",
|
||||
"Translated by {{provider}}.": "Terjemahan oleh {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Inversi Gambar di Gelap",
|
||||
"Help improve Readest": "Bantu tingkatkan Readest",
|
||||
"Sharing anonymized statistics": "Berbagi statistik anonim",
|
||||
"Interface Language": "Bahasa Antarmuka",
|
||||
"Translation": "Terjemahan",
|
||||
"Enable Translation": "Aktifkan Terjemahan",
|
||||
"Translation Service": "Layanan Terjemahan",
|
||||
"Translate To": "Terjemahkan Ke",
|
||||
"Disable Translation": "Nonaktifkan Terjemahan",
|
||||
"Scroll": "Scroll",
|
||||
"Overlap Pixels": "Overlap Piksel",
|
||||
"Click": "Klik",
|
||||
"System Language": "Bahasa Sistem",
|
||||
"Security": "Keamanan",
|
||||
"Allow JavaScript": "Izinkan JavaScript",
|
||||
"Enable only if you trust the file.": "Aktifkan hanya jika Anda mempercayai file tersebut.",
|
||||
"Sort TOC by Page": "Urutkan TOC berdasarkan Halaman",
|
||||
"Search in {{count}} Book(s)..._other": "Cari di {{count}} Buku...",
|
||||
"No notes match your search": "Tidak ada catatan ditemukan",
|
||||
"Search notes and excerpts...": "Cari catatan...",
|
||||
"Sign in to Sync": "Masuk untuk Sinkronisasi",
|
||||
"Synced at {{time}}": "Disinkronkan pada {{time}}",
|
||||
"Never synced": "Belum pernah disinkronkan",
|
||||
"Show Remaining Time": "__STRING_NOT_TRANSLATED__",
|
||||
"Show Page Number": "__STRING_NOT_TRANSLATED__",
|
||||
"{{time}} min left in chapter": "{{time}} menit tersisa di bab"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animazione",
|
||||
"Annotate": "Annota",
|
||||
"Apply": "Applica",
|
||||
"Are you sure to delete the selected books?": "Sei sicuro di voler eliminare i libri selezionati?",
|
||||
"Auto Mode": "Modalità automatica",
|
||||
"Behavior": "Comportamento",
|
||||
"Book": "Libro",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Parole intere",
|
||||
"Maximum Number of Columns": "Numero massimo di colonne",
|
||||
"Minimum Font Size": "Dimensione minima font",
|
||||
"Misc": "Varie",
|
||||
"Monospace Font": "Font monospazio",
|
||||
"More Info": "Maggiori informazioni",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,49 @@
|
||||
"Checking for updates...": "Controllo aggiornamenti...",
|
||||
"Error checking for updates": "Errore durante il controllo degli aggiornamenti",
|
||||
"Details": "Dettagli",
|
||||
"Cancel Selection": "Annulla selezione",
|
||||
"File Size:": "Dimensione file:",
|
||||
"Auto Detect": "Rilevamento automatico",
|
||||
"Touch": "Touch",
|
||||
"Scrolling Overlap (px)": "Overlap scorrimento (px)",
|
||||
"Next Section": "Sezione successiva",
|
||||
"Previous Section": "Sezione precedente",
|
||||
"Next Page": "Pagina successiva",
|
||||
"Previous Page": "Pagina precedente"
|
||||
"Previous Page": "Pagina precedente",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Sei sicuro di voler eliminare {{count}} libro selezionato?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Sei sicuro di voler eliminare {{count}} libri selezionati?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Sei sicuro di voler eliminare {{count}} libri selezionati?",
|
||||
"Are you sure to delete the selected book?": "Sei sicuro di voler eliminare il libro selezionato?",
|
||||
"Deselect": "Annulla",
|
||||
"Select All": "Tutti",
|
||||
"No translation available.": "Nessuna traduzione disponibile.",
|
||||
"Translated by {{provider}}.": "Tradotto da {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Traduttore Azure",
|
||||
"Invert Image In Dark Mode": "Inverti immagine in scuro",
|
||||
"Help improve Readest": "Aiuta a migliorare Readest",
|
||||
"Sharing anonymized statistics": "Condivisione di statistiche anonime",
|
||||
"Interface Language": "Lingua dell'interfaccia",
|
||||
"Translation": "Traduzione",
|
||||
"Enable Translation": "Abilita traduzione",
|
||||
"Translation Service": "Servizio di traduzione",
|
||||
"Translate To": "Traduci in",
|
||||
"Disable Translation": "Disabilita traduzione",
|
||||
"Scroll": "Scorri",
|
||||
"Overlap Pixels": "Pixel di sovrapposizione",
|
||||
"Click": "Fai clic",
|
||||
"System Language": "Lingua di sistema",
|
||||
"Security": "Security",
|
||||
"Allow JavaScript": "Consenti JavaScript",
|
||||
"Enable only if you trust the file.": "Abilita solo se ti fidi del file.",
|
||||
"Sort TOC by Page": "Ordina sommario per pagina",
|
||||
"Search in {{count}} Book(s)..._one": "Cerca in {{count}} libro...",
|
||||
"Search in {{count}} Book(s)..._many": "Cerca in {{count}} libri...",
|
||||
"Search in {{count}} Book(s)..._other": "Cerca in {{count}} libri...",
|
||||
"No notes match your search": "Nessuna nota trovata",
|
||||
"Search notes and excerpts...": "Cerca nelle note...",
|
||||
"Sign in to Sync": "Accedi per sincronizzare",
|
||||
"Synced at {{time}}": "Sincronizzato alle {{time}}",
|
||||
"Never synced": "Mai sincronizzato",
|
||||
"Show Remaining Time": "Mostra tempo rimanente",
|
||||
"Show Page Number": "Mostra numero pagina",
|
||||
"{{time}} min left in chapter": "{{time}} min rimasti nel capitolo"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "アニメーション",
|
||||
"Annotate": "注釈",
|
||||
"Apply": "適用",
|
||||
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
|
||||
"Auto Mode": "自動モード",
|
||||
"Behavior": "動作",
|
||||
"Book": "書籍",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "単語全体を一致",
|
||||
"Maximum Number of Columns": "最大列数",
|
||||
"Minimum Font Size": "最小フォントサイズ",
|
||||
"Misc": "その他",
|
||||
"Monospace Font": "等幅フォント",
|
||||
"More Info": "詳細情報",
|
||||
"Nord": "ノルド",
|
||||
@@ -304,13 +302,45 @@
|
||||
"Checking for updates...": "更新を確認中...",
|
||||
"Error checking for updates": "更新の確認中にエラーが発生しました",
|
||||
"Details": "詳細",
|
||||
"Cancel Selection": "選択をキャンセル",
|
||||
"File Size:": "ファイルサイズ:",
|
||||
"Auto Detect": "自動検出",
|
||||
"Touch": "タッチ",
|
||||
"Scrolling Overlap (px)": "スクロール重複(px)",
|
||||
"Next Section": "次のセクション",
|
||||
"Previous Section": "前のセクション",
|
||||
"Next Page": "次のページ",
|
||||
"Previous Page": "前のページ"
|
||||
"Previous Page": "前のページ",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "選択した{{count}}冊の本を削除してもよろしいですか?",
|
||||
"Are you sure to delete the selected book?": "選択した本を削除してもよろしいですか?",
|
||||
"Deselect": "解除",
|
||||
"Select All": "全選択",
|
||||
"No translation available.": "翻訳はありません。",
|
||||
"Translated by {{provider}}.": "{{provider}}によって翻訳されました。",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google翻訳",
|
||||
"Azure Translator": "Azure翻訳",
|
||||
"Invert Image In Dark Mode": "ダークモードで画像を反転",
|
||||
"Help improve Readest": "Readestを改善する手助け",
|
||||
"Sharing anonymized statistics": "匿名化された統計情報を共有",
|
||||
"Interface Language": "インターフェースの言語",
|
||||
"Translation": "翻訳",
|
||||
"Enable Translation": "翻訳を有効にする",
|
||||
"Translation Service": "翻訳サービス",
|
||||
"Translate To": "翻訳先",
|
||||
"Disable Translation": "翻訳を無効にする",
|
||||
"Scroll": "スクロール",
|
||||
"Overlap Pixels": "重なりピクセル",
|
||||
"Click": "クリック",
|
||||
"System Language": "システム言語",
|
||||
"Security": "セキュリティ",
|
||||
"Allow JavaScript": "JavaScriptを許可",
|
||||
"Enable only if you trust the file.": "ファイルを信頼する場合にのみ有効にしてください。",
|
||||
"Sort TOC by Page": "目次をページで並べ替え",
|
||||
"Search in {{count}} Book(s)..._other": "{{count}}冊の本を検索...",
|
||||
"No notes match your search": "ノートが見つかりません",
|
||||
"Search notes and excerpts...": "ノートを検索...",
|
||||
"Sign in to Sync": "同期するにはサインイン",
|
||||
"Synced at {{time}}": "{{time}} に同期済み",
|
||||
"Never synced": "まだ同期されていません",
|
||||
"Show Remaining Time": "残り時間を表示",
|
||||
"Show Page Number": "ページ番号を表示",
|
||||
"{{time}} min left in chapter": "{{time}}分残り"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "애니메이션",
|
||||
"Annotate": "주석",
|
||||
"Apply": "적용",
|
||||
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
|
||||
"Auto Mode": "자동 모드",
|
||||
"Behavior": "동작",
|
||||
"Book": "책",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "전체 단어 일치",
|
||||
"Maximum Number of Columns": "최대 열 수",
|
||||
"Minimum Font Size": "최소 글꼴 크기",
|
||||
"Misc": "기타",
|
||||
"Monospace Font": "고정폭 글꼴",
|
||||
"More Info": "추가 정보",
|
||||
"Nord": "노드",
|
||||
@@ -304,13 +302,45 @@
|
||||
"Checking for updates...": "업데이트 확인 중...",
|
||||
"Error checking for updates": "업데이트 확인 중 오류 발생",
|
||||
"Details": "세부정보",
|
||||
"Cancel Selection": "선택 취소",
|
||||
"File Size:": "파일 크기:",
|
||||
"Auto Detect": "자동 감지",
|
||||
"Touch": "터치",
|
||||
"Scrolling Overlap (px)": "스크롤 겹침 (px)",
|
||||
"Next Section": "다음 섹션",
|
||||
"Previous Section": "이전 섹션",
|
||||
"Next Page": "다음 페이지",
|
||||
"Previous Page": "이전 페이지"
|
||||
"Previous Page": "이전 페이지",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "선택한 {{count}}권의 책을 삭제하시겠습니까?",
|
||||
"Are you sure to delete the selected book?": "선택한 책을 삭제하시겠습니까?",
|
||||
"Deselect": "해제",
|
||||
"Select All": "전체선택",
|
||||
"No translation available.": "번역이 없습니다.",
|
||||
"Translated by {{provider}}.": "{{provider}}에 의해 번역됨.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "구글 번역",
|
||||
"Azure Translator": "Azure 번역기",
|
||||
"Invert Image In Dark Mode": "어두운 곳에서 이미지 반전",
|
||||
"Help improve Readest": "Readest 개선에 도움을 주세요",
|
||||
"Sharing anonymized statistics": "익명화된 통계 공유",
|
||||
"Interface Language": "인터페이스 언어",
|
||||
"Translation": "번역",
|
||||
"Enable Translation": "번역 활성화",
|
||||
"Translation Service": "번역 서비스",
|
||||
"Translate To": "번역 대상 언어",
|
||||
"Disable Translation": "번역 비활성화",
|
||||
"Scroll": "스크롤",
|
||||
"Overlap Pixels": "겹치는 픽셀",
|
||||
"Click": "클릭",
|
||||
"System Language": "시스템 언어",
|
||||
"Security": "보안",
|
||||
"Allow JavaScript": "JavaScript 허용",
|
||||
"Enable only if you trust the file.": "파일을 신뢰하는 경우에만 활성화하세요.",
|
||||
"Sort TOC by Page": "목차를 페이지별로 정렬",
|
||||
"Search in {{count}} Book(s)..._other": "{{count}}권의 책에서 검색...",
|
||||
"No notes match your search": "일치하는 노트가 없습니다",
|
||||
"Search notes and excerpts...": "노트 검색...",
|
||||
"Sign in to Sync": "동기화를 위해 로그인하세요",
|
||||
"Synced at {{time}}": "{{time}}에 동기화됨",
|
||||
"Never synced": "동기화된 적 없음",
|
||||
"Show Remaining Time": "남은 시간 표시",
|
||||
"Show Page Number": "페이지 번호 표시",
|
||||
"{{time}} min left in chapter": "{{time}}분 남음"
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"Delete": "Verwijderen",
|
||||
"Cancel": "Annuleren",
|
||||
"Confirm Deletion": "Verwijdering bevestigen",
|
||||
"Are you sure to delete the selected books?": "Weet u zeker dat u de geselecteerde boeken wilt verwijderen?",
|
||||
"Deselect Book": "Boek deselecteren",
|
||||
"Select Book": "Boek selecteren",
|
||||
"Show Book Details": "Boekdetails weergeven",
|
||||
@@ -58,7 +57,6 @@
|
||||
"Import Books": "Boeken importeren",
|
||||
"Select Multiple Books": "Meerdere boeken selecteren",
|
||||
"Select Books": "Boeken selecteren",
|
||||
"Cancel Selection": "Selectie annuleren",
|
||||
"Storage": "Opslag",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% van cloudopslag gebruikt.",
|
||||
"Logged in as {{userDisplayName}}": "Ingelogd als {{userDisplayName}}",
|
||||
@@ -199,7 +197,6 @@
|
||||
"Font": "Lettertype",
|
||||
"Layout": "Opmaak",
|
||||
"Color": "Kleur",
|
||||
"Misc": "Overig",
|
||||
"(from 'As You Like It', Act II)": "(uit 'As You Like It', Akte II)",
|
||||
"Custom Theme": "Aangepast thema",
|
||||
"Theme Name": "Themanaam",
|
||||
@@ -307,10 +304,45 @@
|
||||
"Reveal in Folder": "Tonen in map",
|
||||
"Don’t have an account? Sign up": "Nog geen account? Registreer u",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "De hele wereld is een toneel,\nEn alle mannen en vrouwen slechts spelers;\nZij hebben hun uitgangen en hun entrees,\nEn één man speelt in zijn tijd vele rollen,\nZijn daden zijnde zeven leeftijden.\n\n— William Shakespeare",
|
||||
"Touch": "Raak",
|
||||
"Scrolling Overlap (px)": "Scroll-overlap (px)",
|
||||
"Next Section": "Volgende sectie",
|
||||
"Previous Section": "Vorige sectie",
|
||||
"Next Page": "Volgende pagina",
|
||||
"Previous Page": "Vorige pagina"
|
||||
"Previous Page": "Vorige pagina",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Weet je zeker dat je {{count}} geselecteerd boek wilt verwijderen?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Weet je zeker dat je {{count}} geselecteerde boeken wilt verwijderen?",
|
||||
"Are you sure to delete the selected book?": "Weet je zeker dat je het geselecteerde boek wilt verwijderen?",
|
||||
"Deselect": "Deselecteer",
|
||||
"Select All": "Alles",
|
||||
"No translation available.": "Geen vertaling beschikbaar.",
|
||||
"Translated by {{provider}}.": "Vertaald door {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Vertalen",
|
||||
"Azure Translator": "Azure Vertaler",
|
||||
"Invert Image In Dark Mode": "Inverteer afbeelding in het donker",
|
||||
"Help improve Readest": "Help Readest verbeteren",
|
||||
"Sharing anonymized statistics": "Anonieme statistieken delen",
|
||||
"Interface Language": "Interface-taal",
|
||||
"Translation": "Vertaling",
|
||||
"Enable Translation": "Vertaling inschakelen",
|
||||
"Translation Service": "Vertaalservice",
|
||||
"Translate To": "Vertalen naar",
|
||||
"Disable Translation": "Vertaling uitschakelen",
|
||||
"Scroll": "Scrollen",
|
||||
"Overlap Pixels": "Overlap pixels",
|
||||
"Click": "Klik",
|
||||
"System Language": "Systemtaal",
|
||||
"Security": "Beveiliging",
|
||||
"Allow JavaScript": "JavaScript toestaan",
|
||||
"Enable only if you trust the file.": "Schakel alleen in als u het bestand vertrouwt.",
|
||||
"Sort TOC by Page": "__STRING_NOT_TRANSLATED__",
|
||||
"Search in {{count}} Book(s)..._one": "Zoeken in {{count}} Boek...",
|
||||
"Search in {{count}} Book(s)..._other": "Zoeken in {{count}} Boeken...",
|
||||
"No notes match your search": "Geen notities gevonden",
|
||||
"Search notes and excerpts...": "Zoek notities...",
|
||||
"Sign in to Sync": "Meld je aan om te synchroniseren",
|
||||
"Synced at {{time}}": "Gesynchroniseerd om {{time}}",
|
||||
"Never synced": "Nooit gesynchroniseerd",
|
||||
"Show Remaining Time": "Toon resterende tijd",
|
||||
"Show Page Number": "Toon paginanummer",
|
||||
"{{time}} min left in chapter": "Nog {{time}} min in hoofdstuk"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animacja",
|
||||
"Annotate": "Adnotacja",
|
||||
"Apply": "Zastosuj",
|
||||
"Are you sure to delete the selected books?": "Czy na pewno chcesz usunąć wybrane książki?",
|
||||
"Auto Mode": "Tryb automatyczny",
|
||||
"Behavior": "Zachowanie",
|
||||
"Book": "Książka",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Uwzględnij całe słowa",
|
||||
"Maximum Number of Columns": "Maksymalna liczba kolumn",
|
||||
"Minimum Font Size": "Minimalny rozmiar czcionki",
|
||||
"Misc": "Różne",
|
||||
"Monospace Font": "Czcionka o stałej szerokości",
|
||||
"More Info": "Więcej informacji",
|
||||
"Nord": "Północny",
|
||||
@@ -304,13 +302,51 @@
|
||||
"Checking for updates...": "Sprawdzanie aktualizacji...",
|
||||
"Error checking for updates": "Błąd podczas sprawdzania aktualizacji",
|
||||
"Details": "Szczegóły",
|
||||
"Cancel Selection": "Anuluj zaznaczenie",
|
||||
"File Size:": "Rozmiar pliku:",
|
||||
"Auto Detect": "Automatyczne wykrywanie",
|
||||
"Touch": "Dotyk",
|
||||
"Scrolling Overlap (px)": "Nakładanie przewijania (px)",
|
||||
"Next Section": "Następna sekcja",
|
||||
"Previous Section": "Poprzednia sekcja",
|
||||
"Next Page": "Następna strona",
|
||||
"Previous Page": "Poprzednia strona"
|
||||
"Previous Page": "Poprzednia strona",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Czy na pewno chcesz usunąć {{count}} wybraną książkę?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_few": "Czy na pewno chcesz usunąć {{count}} wybrane książki?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Czy na pewno chcesz usunąć {{count}} wybranych książek?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Czy na pewno chcesz usunąć {{count}} wybranych książek?",
|
||||
"Are you sure to delete the selected book?": "Czy na pewno chcesz usunąć wybraną książkę?",
|
||||
"Deselect": "Odznacz",
|
||||
"Select All": "Zaznacz",
|
||||
"No translation available.": "Brak dostępnego tłumaczenia.",
|
||||
"Translated by {{provider}}.": "Przetłumaczone przez {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Tłumacz Google",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Odwróć obraz w ciemności",
|
||||
"Help improve Readest": "Pomóż ulepszyć Readest",
|
||||
"Sharing anonymized statistics": "Udostępnianie zanonimizowanych statystyk",
|
||||
"Interface Language": "Język interfejsu",
|
||||
"Translation": "Tłumaczenie",
|
||||
"Enable Translation": "Włącz tłumaczenie",
|
||||
"Translation Service": "Usługa tłumaczenia",
|
||||
"Translate To": "Przetłumacz na",
|
||||
"Disable Translation": "Wyłącz tłumaczenie",
|
||||
"Scroll": "Przewijaj",
|
||||
"Overlap Pixels": "Przesunięcie pikseli",
|
||||
"Click": "Kliknij",
|
||||
"System Language": "Język systemu",
|
||||
"Security": "Bezpieczeństwo",
|
||||
"Allow JavaScript": "Zezwól na JavaScript",
|
||||
"Enable only if you trust the file.": "Zezwól tylko wtedy, gdy ufasz plikowi.",
|
||||
"Sort TOC by Page": "Sortuj spis treści według strony",
|
||||
"Search in {{count}} Book(s)..._one": "Szukaj w {{count}} książce...",
|
||||
"Search in {{count}} Book(s)..._few": "Szukaj w {{count}} książkach...",
|
||||
"Search in {{count}} Book(s)..._many": "Szukaj w {{count}} książkach...",
|
||||
"Search in {{count}} Book(s)..._other": "Szukaj w {{count}} książkach...",
|
||||
"No notes match your search": "Nie znaleziono notatek",
|
||||
"Search notes and excerpts...": "Szukaj notatek...",
|
||||
"Sign in to Sync": "Zaloguj się",
|
||||
"Synced at {{time}}": "Sync: {{time}}",
|
||||
"Never synced": "Brak synchronizacji",
|
||||
"Show Remaining Time": "Pokazuj pozostały czas",
|
||||
"Show Page Number": "Pokaż numer strony",
|
||||
"{{time}} min left in chapter": "{{time}} min pozostało do końca rozdziału"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animação",
|
||||
"Annotate": "Anotar",
|
||||
"Apply": "Aplicar",
|
||||
"Are you sure to delete the selected books?": "Tem certeza de que deseja excluir os livros selecionados?",
|
||||
"Auto Mode": "Modo Automático",
|
||||
"Behavior": "Comportamento",
|
||||
"Book": "Livro",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Corresponder Palavras Inteiras",
|
||||
"Maximum Number of Columns": "Número Máximo de Colunas",
|
||||
"Minimum Font Size": "Tamanho Mínimo da Fonte",
|
||||
"Misc": "Diversos",
|
||||
"Monospace Font": "Fonte Monoespaçada",
|
||||
"More Info": "Mais Informações",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,49 @@
|
||||
"Checking for updates...": "Verificando atualizações...",
|
||||
"Error checking for updates": "Erro ao verificar atualizações",
|
||||
"Details": "Detalhes",
|
||||
"Cancel Selection": "Cancelar Seleção",
|
||||
"File Size:": "Tamanho do arquivo:",
|
||||
"Auto Detect": "Detectar Automaticamente",
|
||||
"Touch": "Toque",
|
||||
"Scrolling Overlap (px)": "Sobreposição de Rolagem (px)",
|
||||
"Next Section": "Próxima seção",
|
||||
"Previous Section": "Seção anterior",
|
||||
"Next Page": "Próxima página",
|
||||
"Previous Page": "Página anterior"
|
||||
"Previous Page": "Página anterior",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Tem certeza que deseja excluir {{count}} livro selecionado?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Tem certeza que deseja excluir {{count}} livros selecionados?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Tem certeza que deseja excluir {{count}} livros selecionados?",
|
||||
"Are you sure to delete the selected book?": "Tem certeza que deseja excluir o livro selecionado?",
|
||||
"Deselect": "Desmarcar",
|
||||
"Select All": "Marcar tudo",
|
||||
"No translation available.": "Sem tradução disponível.",
|
||||
"Translated by {{provider}}.": "Traduzido por {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Tradutor",
|
||||
"Azure Translator": "Tradutor Azure",
|
||||
"Invert Image In Dark Mode": "Inverter Imagem no Escuro",
|
||||
"Help improve Readest": "Ajude a melhorar o Readest",
|
||||
"Sharing anonymized statistics": "Compartilhando estatísticas anonimizadas",
|
||||
"Interface Language": "Idioma da interface",
|
||||
"Translation": "Tradução",
|
||||
"Enable Translation": "Ativar tradução",
|
||||
"Translation Service": "Serviço de tradução",
|
||||
"Translate To": "Traduzir para",
|
||||
"Disable Translation": "Desativar tradução",
|
||||
"Scroll": "Rolar",
|
||||
"Overlap Pixels": "Sobreposição de Pixels",
|
||||
"Click": "Clique",
|
||||
"System Language": "Idioma do Sistema",
|
||||
"Security": "Segurança",
|
||||
"Allow JavaScript": "Permitir JavaScript",
|
||||
"Enable only if you trust the file.": "Ativar apenas se você confiar no arquivo.",
|
||||
"Sort TOC by Page": "Ordenar TOC por Página",
|
||||
"Search in {{count}} Book(s)..._one": "Pesquisar em {{count}} Livro...",
|
||||
"Search in {{count}} Book(s)..._many": "Pesquisar em {{count}} Livros...",
|
||||
"Search in {{count}} Book(s)..._other": "Pesquisar em {{count}} Livros...",
|
||||
"No notes match your search": "Nenhuma nota encontrada",
|
||||
"Search notes and excerpts...": "Buscar notas...",
|
||||
"Sign in to Sync": "Entrar",
|
||||
"Synced at {{time}}": "Sincronizado às {{time}}",
|
||||
"Never synced": "Nunca sincronizado",
|
||||
"Show Remaining Time": "Mostrar Tempo Restante",
|
||||
"Show Page Number": "Mostrar Número da Página",
|
||||
"{{time}} min left in chapter": "{{time}} min restantes no capítulo"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Анимация",
|
||||
"Annotate": "Аннотация",
|
||||
"Apply": "Применить",
|
||||
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
|
||||
"Auto Mode": "Автоматический режим",
|
||||
"Behavior": "Поведение",
|
||||
"Book": "Книга",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Только целые слова",
|
||||
"Maximum Number of Columns": "Максимальное количество колонок",
|
||||
"Minimum Font Size": "Минимальный размер шрифта",
|
||||
"Misc": "Разное",
|
||||
"Monospace Font": "Моноширинный шрифт",
|
||||
"More Info": "Подробнее",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,51 @@
|
||||
"Checking for updates...": "Проверка обновлений...",
|
||||
"Error checking for updates": "Ошибка при проверке обновлений",
|
||||
"Details": "Инфо",
|
||||
"Cancel Selection": "Отменить выбор",
|
||||
"File Size:": "Размер файла:",
|
||||
"Auto Detect": "Автоопределение",
|
||||
"Touch": "Сенсорный экран",
|
||||
"Scrolling Overlap (px)": "Перекрытие прокрутки (px)",
|
||||
"Next Section": "Следующий раздел",
|
||||
"Previous Section": "Предыдущий раздел",
|
||||
"Next Page": "Следующая страница",
|
||||
"Previous Page": "Предыдущая страница"
|
||||
"Previous Page": "Предыдущая страница",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Вы уверены, что хотите удалить {{count}} выбранную книгу?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_few": "Вы уверены, что хотите удалить {{count}} выбранные книги?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Вы уверены, что хотите удалить {{count}} выбранных книг?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Вы уверены, что хотите удалить {{count}} выбранных книг?",
|
||||
"Are you sure to delete the selected book?": "Вы уверены, что хотите удалить выбранную книгу?",
|
||||
"Deselect": "Снять",
|
||||
"Select All": "Выбрать все",
|
||||
"No translation available.": "Нет доступного перевода.",
|
||||
"Translated by {{provider}}.": "Переведено {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Инвертировать в тёмной теме",
|
||||
"Help improve Readest": "Помогите улучшить Readest",
|
||||
"Sharing anonymized statistics": "Отправка анонимной статистики",
|
||||
"Interface Language": "Язык интерфейса",
|
||||
"Translation": "Перевод",
|
||||
"Enable Translation": "Включить перевод",
|
||||
"Translation Service": "Сервис перевода",
|
||||
"Translate To": "Перевести на",
|
||||
"Disable Translation": "Отключить перевод",
|
||||
"Scroll": "Прокрутка",
|
||||
"Overlap Pixels": "Перекрывающиеся пиксели",
|
||||
"Click": "Клик",
|
||||
"System Language": "Системный язык",
|
||||
"Security": "Безопасность",
|
||||
"Allow JavaScript": "Разрешить JavaScript",
|
||||
"Enable only if you trust the file.": "Включите только если доверяете файлу.",
|
||||
"Sort TOC by Page": "Сортировать содержание по странице",
|
||||
"Search in {{count}} Book(s)..._one": "Поиск в {{count}} книге...",
|
||||
"Search in {{count}} Book(s)..._few": "Поиск в {{count}} книгах...",
|
||||
"Search in {{count}} Book(s)..._many": "Поиск в {{count}} книгах...",
|
||||
"Search in {{count}} Book(s)..._other": "Поиск в {{count}} книгах...",
|
||||
"No notes match your search": "Записи не найдены",
|
||||
"Search notes and excerpts...": "Искать записи...",
|
||||
"Sign in to Sync": "Войти",
|
||||
"Synced at {{time}}": "Синхр. в {{time}}",
|
||||
"Never synced": "Не синхр.",
|
||||
"Show Remaining Time": "Показать оставшееся время",
|
||||
"Show Page Number": "Показать номер страницы",
|
||||
"{{time}} min left in chapter": "{{time}} мин осталось в главе"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animasyon",
|
||||
"Annotate": "Not Ekle",
|
||||
"Apply": "Uygula",
|
||||
"Are you sure to delete the selected books?": "Seçili kitapları silmek istediğinizden emin misiniz?",
|
||||
"Auto Mode": "Otomatik Mod",
|
||||
"Behavior": "Davranış",
|
||||
"Book": "Kitap",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Tam Kelimeleri Eşleştir",
|
||||
"Maximum Number of Columns": "Maksimum Sütun Sayısı",
|
||||
"Minimum Font Size": "Minimum Yazı Boyutu",
|
||||
"Misc": "Diğer",
|
||||
"Monospace Font": "Eş Aralıklı Yazı Tipi",
|
||||
"More Info": "Daha Fazla Bilgi",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,47 @@
|
||||
"Checking for updates...": "Yenilikler kontrol ediliyor...",
|
||||
"Error checking for updates": "Güncellemeleri kontrol ederken hata oluştu",
|
||||
"Details": "Detaylar",
|
||||
"Cancel Selection": "Işaretlemeyi İptal Et",
|
||||
"File Size:": "Dosya Boyutu:",
|
||||
"Auto Detect": "Algıla",
|
||||
"Touch": "Dokunmatik",
|
||||
"Scrolling Overlap (px)": "Kaydırma Üst Üste Binen (px)",
|
||||
"Next Section": "Sonraki Bölüm",
|
||||
"Previous Section": "Önceki Bölüm",
|
||||
"Next Page": "Sonraki Sayfa",
|
||||
"Previous Page": "Önceki Sayfa"
|
||||
"Previous Page": "Önceki Sayfa",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "{{count}} seçili kitabı silmek istediğinize emin misiniz?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "{{count}} seçili kitabı silmek istediğinize emin misiniz?",
|
||||
"Are you sure to delete the selected book?": "Seçili kitabı silmek istediğinize emin misiniz?",
|
||||
"Deselect": "Kaldır",
|
||||
"Select All": "Hepsi",
|
||||
"No translation available.": "Çeviri mevcut değil.",
|
||||
"Translated by {{provider}}.": "{{provider}} tarafından çevrildi.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Çeviri",
|
||||
"Azure Translator": "Azure Çevirmen",
|
||||
"Invert Image In Dark Mode": "Karartılmış Görüntüyü Ters Çevir",
|
||||
"Help improve Readest": "Readest’i geliştirin",
|
||||
"Sharing anonymized statistics": "Anonim verileri paylaş",
|
||||
"Interface Language": "Arayüz Dili",
|
||||
"Translation": "Çeviri",
|
||||
"Enable Translation": "Çeviriyi Etkinleştir",
|
||||
"Translation Service": "Çeviri Hizmeti",
|
||||
"Translate To": "Şuna Çevir",
|
||||
"Disable Translation": "Çeviriyi Devre Dışı Bırak",
|
||||
"Scroll": "Kaydır",
|
||||
"Overlap Pixels": "Örtüşen Pikseller",
|
||||
"Click": "Klik",
|
||||
"System Language": "System Dili",
|
||||
"Security": "Security",
|
||||
"Allow JavaScript": "JavaScript'e İzin Ver",
|
||||
"Enable only if you trust the file.": "Yalnızca dosyaya güveniyorsanız etkinleştirin.",
|
||||
"Sort TOC by Page": "İçindekileri Sayfaya Göre Sırala",
|
||||
"Search in {{count}} Book(s)..._one": "{{count}} kitapta ara...",
|
||||
"Search in {{count}} Book(s)..._other": "{{count}} kitapta ara...",
|
||||
"No notes match your search": "Eşleşen not bulunamadı",
|
||||
"Search notes and excerpts...": "Notlarda ara...",
|
||||
"Sign in to Sync": "Giriş yap",
|
||||
"Synced at {{time}}": "{{time}}'de eşzlendi",
|
||||
"Never synced": "Hiç eşzlenmedi",
|
||||
"Show Remaining Time": "Kalan Süreyi Göster",
|
||||
"Show Page Number": "Sayfa Numarasını Göster",
|
||||
"{{time}} min left in chapter": "{{time}} dakika bölümde kaldı"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Анімація",
|
||||
"Annotate": "Анотація",
|
||||
"Apply": "Застосувати",
|
||||
"Are you sure to delete the selected books?": "Ви впевнені, що хочете видалити вибрані книги?",
|
||||
"Auto Mode": "Автоматичний режим",
|
||||
"Behavior": "Поведінка",
|
||||
"Book": "Книга",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Шукати цілі слова",
|
||||
"Maximum Number of Columns": "Максимальна кількість колонок",
|
||||
"Minimum Font Size": "Мінімальний розмір шрифту",
|
||||
"Misc": "Різне",
|
||||
"Monospace Font": "Моноширинний шрифт",
|
||||
"More Info": "Більше інформації",
|
||||
"Nord": "Північний",
|
||||
@@ -304,13 +302,51 @@
|
||||
"Checking for updates...": "Перевірка наявності оновлень...",
|
||||
"Error checking for updates": "Помилка перевірки наявності оновлень",
|
||||
"Details": "Деталі",
|
||||
"Cancel Selection": "Скасувати вибір",
|
||||
"File Size:": "Розмір файлу:",
|
||||
"Auto Detect": "Авто виявлення",
|
||||
"Touch": "Дотик",
|
||||
"Scrolling Overlap (px)": "Прокрутка перекриття (px)",
|
||||
"Next Section": "Наступний розділ",
|
||||
"Previous Section": "Попередній розділ",
|
||||
"Next Page": "Наступна сторінка",
|
||||
"Previous Page": "Попередня сторінка"
|
||||
"Previous Page": "Попередня сторінка",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Ви впевнені, що хочете видалити {{count}} вибрану книгу?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_few": "Ви впевнені, що хочете видалити {{count}} вибрані книги?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Ви впевнені, що хочете видалити {{count}} вибраних книг?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Ви впевнені, що хочете видалити {{count}} вибраних книг?",
|
||||
"Are you sure to delete the selected book?": "Ви впевнені, що хочете видалити вибрану книгу?",
|
||||
"Deselect": "Зняти",
|
||||
"Select All": "Усі",
|
||||
"No translation available.": "Переклад недоступний.",
|
||||
"Translated by {{provider}}.": "Перекладено {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Інвертувати зображення в темряві",
|
||||
"Help improve Readest": "Допоможіть покращити Readest",
|
||||
"Sharing anonymized statistics": "Поділ анонімною статистикою",
|
||||
"Interface Language": "Мова інтерфейсу",
|
||||
"Translation": "Переклад",
|
||||
"Enable Translation": "Увімкнути переклад",
|
||||
"Translation Service": "Служба перекладу",
|
||||
"Translate To": "Перекласти на",
|
||||
"Disable Translation": "Вимкнути переклад",
|
||||
"Scroll": "Прокрутка",
|
||||
"Overlap Pixels": "Пікселі перекриття",
|
||||
"Click": "Клацнути",
|
||||
"System Language": "Системна мова",
|
||||
"Security": "Безпека",
|
||||
"Allow JavaScript": "Дозволити JavaScript",
|
||||
"Enable only if you trust the file.": "Увімкніть лише якщо довіряєте файлу.",
|
||||
"Sort TOC by Page": "Сортувати зміст за сторінкою",
|
||||
"Search in {{count}} Book(s)..._one": "Шукати в {{count}} книзі...",
|
||||
"Search in {{count}} Book(s)..._few": "Шукати в {{count}} книгах...",
|
||||
"Search in {{count}} Book(s)..._many": "Шукати в {{count}} книгах...",
|
||||
"Search in {{count}} Book(s)..._other": "Шукати в {{count}} книгах...",
|
||||
"No notes match your search": "Нотаток не знайдено",
|
||||
"Search notes and excerpts...": "Шукати нотатки...",
|
||||
"Sign in to Sync": "Увійти",
|
||||
"Synced at {{time}}": "Синхр. о {{time}}",
|
||||
"Never synced": "Ніколи не синхр.",
|
||||
"Show Remaining Time": "Показати залишок часу",
|
||||
"Show Page Number": "Показати номер сторінки",
|
||||
"{{time}} min left in chapter": "{{time}} хв до кінця розділу"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Hiệu ứng động",
|
||||
"Annotate": "Chú thích",
|
||||
"Apply": "Áp dụng",
|
||||
"Are you sure to delete the selected books?": "Bạn có chắc chắn muốn xóa các sách đã chọn?",
|
||||
"Auto Mode": "Chế độ tự động",
|
||||
"Behavior": "Hành vi",
|
||||
"Book": "Sách",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "Khớp toàn bộ từ",
|
||||
"Maximum Number of Columns": "Số cột tối đa",
|
||||
"Minimum Font Size": "Cỡ chữ tối thiểu",
|
||||
"Misc": "Khác",
|
||||
"Monospace Font": "Phông chữ đơn cách",
|
||||
"More Info": "Thông tin thêm",
|
||||
"Nord": "Nord",
|
||||
@@ -304,13 +302,45 @@
|
||||
"Checking for updates...": "Đang kiểm tra cập nhật...",
|
||||
"Error checking for updates": "Lỗi khi kiểm tra cập nhật",
|
||||
"Details": "Chi tiết",
|
||||
"Cancel Selection": "Hủy chọn",
|
||||
"File Size:": "Kích thước tệp:",
|
||||
"Auto Detect": "Phát hiện tự động",
|
||||
"Touch": "Chạm",
|
||||
"Scrolling Overlap (px)": "Chồng chéo cuộn (px)",
|
||||
"Next Section": "Phần tiếp theo",
|
||||
"Previous Section": "Phần trước",
|
||||
"Next Page": "Trang tiếp theo",
|
||||
"Previous Page": "Trang trước"
|
||||
"Previous Page": "Trang trước",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Bạn có chắc muốn xóa {{count}} cuốn sách đã chọn không?",
|
||||
"Are you sure to delete the selected book?": "Bạn có chắc muốn xóa cuốn sách đã chọn không?",
|
||||
"Deselect": "Bỏ chọn",
|
||||
"Select All": "Chọn tất",
|
||||
"No translation available.": "Không có bản dịch nào.",
|
||||
"Translated by {{provider}}.": "Được dịch bởi {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Dịch",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Đảo ảnh chế độ tối",
|
||||
"Help improve Readest": "Giúp cải thiện Readest",
|
||||
"Sharing anonymized statistics": "Chia sẻ thống kê ẩn danh",
|
||||
"Interface Language": "Ngôn ngữ giao diện",
|
||||
"Translation": "Dịch",
|
||||
"Enable Translation": "Bật dịch",
|
||||
"Translation Service": "Dịch vụ dịch thuật",
|
||||
"Translate To": "Dịch sang",
|
||||
"Disable Translation": "Tắt dịch",
|
||||
"Scroll": "Cuộn",
|
||||
"Overlap Pixels": "Chồng lấn pixel",
|
||||
"Click": "Nhấp",
|
||||
"System Language": "Ngôn ngữ hệ thống",
|
||||
"Security": "An ninh",
|
||||
"Allow JavaScript": "Cho phép JavaScript",
|
||||
"Enable only if you trust the file.": "Chỉ bật nếu bạn tin tưởng tệp.",
|
||||
"Sort TOC by Page": "Sort Mục lục theo Trang",
|
||||
"Search in {{count}} Book(s)..._other": "Tìm kiếm trong {{count}} cuốn sách...",
|
||||
"No notes match your search": "Không tìm thấy ghi chú nào",
|
||||
"Search notes and excerpts...": "Tìm ghi chú...",
|
||||
"Sign in to Sync": "Đăng nhập",
|
||||
"Synced at {{time}}": "Đã đồng bộ lúc {{time}}",
|
||||
"Never synced": "Chưa từng đồng bộ",
|
||||
"Show Remaining Time": "Hiển thị thời gian còn lại",
|
||||
"Show Page Number": "Hiển thị số trang",
|
||||
"{{time}} min left in chapter": "{{time}} phút còn lại trong chương"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "动画",
|
||||
"Annotate": "笔记",
|
||||
"Apply": "应用",
|
||||
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
|
||||
"Auto Mode": "自动主题",
|
||||
"Behavior": "行为",
|
||||
"Book": "书籍",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "匹配整个单词",
|
||||
"Maximum Number of Columns": "分栏数",
|
||||
"Minimum Font Size": "最小字号",
|
||||
"Misc": "杂项",
|
||||
"Monospace Font": "等宽字体",
|
||||
"More Info": "更多信息",
|
||||
"Nord": "极光",
|
||||
@@ -304,13 +302,45 @@
|
||||
"Checking for updates...": "检查更新中...",
|
||||
"Error checking for updates": "检查更新时出错",
|
||||
"Details": "详情",
|
||||
"Cancel Selection": "取消选择",
|
||||
"File Size:": "文件大小",
|
||||
"Auto Detect": "自动检测",
|
||||
"Touch": "触控",
|
||||
"Scrolling Overlap (px)": "滚动重叠(px)",
|
||||
"Next Section": "下一章",
|
||||
"Previous Section": "上一章",
|
||||
"Next Page": "下一页",
|
||||
"Previous Page": "上一页"
|
||||
"Previous Page": "上一页",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "您确定要删除 {{count}} 本选中的书籍吗?",
|
||||
"Are you sure to delete the selected book?": "您确定要删除选中的书籍吗?",
|
||||
"Deselect": "取消选择",
|
||||
"Select All": "全选",
|
||||
"No translation available.": "暂无可用的翻译",
|
||||
"Translated by {{provider}}.": "由 {{provider}} 提供翻译",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "深色主题下图片反色",
|
||||
"Help improve Readest": "帮助改进 Readest",
|
||||
"Sharing anonymized statistics": "分享匿名统计数据",
|
||||
"Interface Language": "界面语言",
|
||||
"Translation": "翻译",
|
||||
"Enable Translation": "启用翻译",
|
||||
"Translation Service": "翻译服务",
|
||||
"Translate To": "翻译到",
|
||||
"Disable Translation": "禁用翻译",
|
||||
"Scroll": "滚动",
|
||||
"Overlap Pixels": "重叠像素",
|
||||
"Click": "点击",
|
||||
"System Language": "系统语言",
|
||||
"Security": "安全",
|
||||
"Allow JavaScript": "允许 JavaScript",
|
||||
"Enable only if you trust the file.": "建议仅在您信任该文件时启用",
|
||||
"Sort TOC by Page": "按页码排序目录",
|
||||
"Search in {{count}} Book(s)..._other": "在 {{count}} 本书籍中搜索...",
|
||||
"No notes match your search": "未找到匹配的笔记",
|
||||
"Search notes and excerpts...": "搜索笔记...",
|
||||
"Sign in to Sync": "登录后同步",
|
||||
"Synced at {{time}}": "同步于:{{time}}",
|
||||
"Never synced": "从未同步",
|
||||
"Show Remaining Time": "显示剩余时间",
|
||||
"Show Page Number": "显示页码",
|
||||
"{{time}} min left in chapter": "本章剩余 {{time}} 分钟"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "動畫",
|
||||
"Annotate": "筆記",
|
||||
"Apply": "應用",
|
||||
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
|
||||
"Auto Mode": "自動主題",
|
||||
"Behavior": "行為",
|
||||
"Book": "書籍",
|
||||
@@ -60,7 +59,6 @@
|
||||
"Match Whole Words": "匹配整個單詞",
|
||||
"Maximum Number of Columns": "分欄數",
|
||||
"Minimum Font Size": "最小字號",
|
||||
"Misc": "雜項",
|
||||
"Monospace Font": "等寬字體",
|
||||
"More Info": "更多信息",
|
||||
"Nord": "極光",
|
||||
@@ -304,13 +302,45 @@
|
||||
"Checking for updates...": "檢查更新中...",
|
||||
"Error checking for updates": "檢查更新時出錯",
|
||||
"Details": "詳情",
|
||||
"Cancel Selection": "取消選擇",
|
||||
"File Size:": "檔案大小",
|
||||
"Auto Detect": "自動檢測",
|
||||
"Touch": "觸控",
|
||||
"Scrolling Overlap (px)": "滾動重疊(px)",
|
||||
"Next Section": "下一章",
|
||||
"Previous Section": "上一章",
|
||||
"Next Page": "下一頁",
|
||||
"Previous Page": "上一頁"
|
||||
"Previous Page": "上一頁",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "確定要刪除已選擇的 {{count}} 本書嗎?",
|
||||
"Are you sure to delete the selected book?": "確定要刪除已選擇的書籍嗎?",
|
||||
"Deselect": "取消選取",
|
||||
"Select All": "全選",
|
||||
"No translation available.": "暫無翻譯",
|
||||
"Translated by {{provider}}.": "由 {{provider}} 提供翻譯",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "深色主題下圖片反相",
|
||||
"Help improve Readest": "幫助改進 Readest",
|
||||
"Sharing anonymized statistics": "分享匿名統計數據",
|
||||
"Interface Language": "介面語言",
|
||||
"Translation": "翻譯",
|
||||
"Enable Translation": "啟用翻譯",
|
||||
"Translation Service": "翻譯服務",
|
||||
"Translate To": "翻譯至",
|
||||
"Disable Translation": "停用翻譯",
|
||||
"Scroll": "滾動",
|
||||
"Overlap Pixels": "重疊像素",
|
||||
"Click": "點擊",
|
||||
"System Language": "系統語言",
|
||||
"Security": "安全",
|
||||
"Allow JavaScript": "允許 JavaScript",
|
||||
"Enable only if you trust the file.": "建議僅在您信任該檔案時啟用",
|
||||
"Sort TOC by Page": "依頁碼排序目錄",
|
||||
"Search in {{count}} Book(s)..._other": "在 {{count}} 本書中搜索...",
|
||||
"No notes match your search": "找不到符合的筆記",
|
||||
"Search notes and excerpts...": "搜尋筆記...",
|
||||
"Sign in to Sync": "登入以同步",
|
||||
"Synced at {{time}}": "同步於:{{time}}",
|
||||
"Never synced": "從未同步",
|
||||
"Show Remaining Time": "顯示剩餘時間",
|
||||
"Show Page Number": "顯示頁碼",
|
||||
"{{time}} min left in chapter": "本章剩餘 {{time}} 分鐘"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.52": {
|
||||
"date": "2025-06-03",
|
||||
"notes": [
|
||||
"Library: The search bar now shows the number of books currently displayed",
|
||||
"Book Details: Book descriptions now support rich HTML formatting",
|
||||
"Notebook: Added support for writing notes using Markdown",
|
||||
"Notebook: You can now search within your notes",
|
||||
"Sync: A sync status item has been added to the View menu",
|
||||
"TOC: You can now sort the table of contents by page number",
|
||||
"Reader: Option added to show remaining time in the current chapter"
|
||||
]
|
||||
},
|
||||
"0.9.51": {
|
||||
"date": "2025-06-01",
|
||||
"notes": [
|
||||
"Translator: Table of contents is now included in translations",
|
||||
"Library Theme: Added light, dark, and system theme options to the library page",
|
||||
"TTS: Now shows the current sentence and chapter in the media session",
|
||||
"TTS: Text-to-speech continues from the last read sentence",
|
||||
"Pagination Fix: Taking notes no longer breaks page turning",
|
||||
"EPUB Scripts: Added a setting to allow JavaScript in EPUB files"
|
||||
]
|
||||
},
|
||||
"0.9.50": {
|
||||
"date": "2025-05-28",
|
||||
"notes": [
|
||||
"Bilingual TTS: You can now choose voices for each language in bilingual text-to-speech mode",
|
||||
"Settings Layout: Improved header layout in the settings dialog for different screen sizes",
|
||||
"CSS Fixes: Corrected anchor text color and fixed image dimensions within paragraphs"
|
||||
]
|
||||
},
|
||||
"0.9.49": {
|
||||
"date": "2025-05-27",
|
||||
"notes": [
|
||||
"Select All in Library: You can now select all books at once when in selection mode",
|
||||
"More Translation Options: Added support for Azure and Google Translate services",
|
||||
"Dark Mode Enhancement: New option to invert image colors for better visibility in dark mode",
|
||||
"Bilingual TTS: Enjoy bilingual text-to-speech for books with content in two languages",
|
||||
"Full Book Translation: Translate books in any language into bilingual ebooks",
|
||||
"Privacy Settings: Added a new option to opt-out of telemetry"
|
||||
]
|
||||
},
|
||||
"0.9.43": {
|
||||
"date": "2025-05-19",
|
||||
"notes": [
|
||||
|
||||
@@ -61,6 +61,15 @@
|
||||
},
|
||||
{
|
||||
"url": "https://*.cloudflarestorage.com"
|
||||
},
|
||||
{
|
||||
"url": "https://edge.microsoft.com"
|
||||
},
|
||||
{
|
||||
"url": "https://*.microsofttranslator.com"
|
||||
},
|
||||
{
|
||||
"url": "https://translate.googleapis.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 87 KiB |
+16
-13
@@ -221,6 +221,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
if (visible) {
|
||||
controller.show(WindowInsets.Type.statusBars())
|
||||
controller.hide(WindowInsets.Type.navigationBars())
|
||||
} else {
|
||||
controller.hide(WindowInsets.Type.systemBars())
|
||||
}
|
||||
@@ -236,25 +237,27 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
if (visible) {
|
||||
it.show(WindowInsetsCompat.Type.statusBars())
|
||||
it.hide(WindowInsetsCompat.Type.navigationBars())
|
||||
} else {
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
decorView.systemUiVisibility = when {
|
||||
visible && !isDarkMode -> View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
visible -> View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
else -> View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
}
|
||||
decorView.systemUiVisibility = buildList {
|
||||
add(View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
|
||||
add(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
|
||||
add(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
|
||||
add(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
|
||||
add(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
|
||||
|
||||
if (!visible) {
|
||||
add(View.SYSTEM_UI_FLAG_FULLSCREEN)
|
||||
}
|
||||
if (visible && !isDarkMode) {
|
||||
add(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)
|
||||
}
|
||||
}.reduce { acc, flag -> acc or flag }
|
||||
}
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://*.microsofttranslator.com https://edge.microsoft.com",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://fontsapi.zeoseven.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://fontsapi.zeoseven.com",
|
||||
|
||||
@@ -17,12 +17,15 @@ import { isMd5 } from '@/utils/md5';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
import BookshelfItem, { generateGridItems, generateListItems } from './BookshelfItem';
|
||||
import GroupingModal from './GroupingModal';
|
||||
|
||||
interface BookshelfProps {
|
||||
libraryBooks: Book[];
|
||||
isSelectMode: boolean;
|
||||
isSelectAll: boolean;
|
||||
isSelectNone: boolean;
|
||||
handleImportBooks: () => void;
|
||||
handleBookUpload: (book: Book) => Promise<boolean>;
|
||||
handleBookDownload: (book: Book) => Promise<boolean>;
|
||||
@@ -35,6 +38,8 @@ interface BookshelfProps {
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
libraryBooks,
|
||||
isSelectMode,
|
||||
isSelectAll,
|
||||
isSelectNone,
|
||||
handleImportBooks,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
@@ -63,19 +68,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
);
|
||||
const isImportingBook = useRef(false);
|
||||
|
||||
const { setLibrary } = useLibraryStore();
|
||||
const { currentBookshelf, setCurrentBookshelf, setLibrary } = useLibraryStore();
|
||||
const allBookshelfItems =
|
||||
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectMode) {
|
||||
setShowSelectModeActions(true);
|
||||
} else {
|
||||
setSelectedBooks([]);
|
||||
setShowSelectModeActions(false);
|
||||
}
|
||||
}, [isSelectMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isImportingBook.current) return;
|
||||
isImportingBook.current = true;
|
||||
@@ -95,6 +91,15 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [importBookUrl, appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (navBooksGroup) {
|
||||
setCurrentBookshelf(navBooksGroup.books);
|
||||
} else {
|
||||
setCurrentBookshelf(allBookshelfItems);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [libraryBooks, navBooksGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
const query = searchParams?.get('q') || '';
|
||||
@@ -169,14 +174,22 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const getBooksToDelete = () => {
|
||||
const booksToDelete: Book[] = [];
|
||||
selectedBooks.forEach((id) => {
|
||||
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
|
||||
if (book && !book.deletedAt) {
|
||||
handleBookDelete(book);
|
||||
booksToDelete.push(book);
|
||||
}
|
||||
}
|
||||
});
|
||||
return booksToDelete;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
for (const book of getBooksToDelete()) {
|
||||
handleBookDelete(book);
|
||||
}
|
||||
setSelectedBooks([]);
|
||||
setShowDeleteAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
@@ -192,7 +205,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
setShowGroupingModal(true);
|
||||
};
|
||||
|
||||
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
|
||||
const bookFilter = (item: Book, queryTerm: string) => {
|
||||
if (item.deletedAt) return false;
|
||||
const searchTerm = new RegExp(queryTerm, 'i');
|
||||
@@ -222,7 +234,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}
|
||||
};
|
||||
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
|
||||
const filteredBookshelfItems = currentBookshelfItems
|
||||
const filteredBookshelfItems = currentBookshelf
|
||||
.filter((item) => {
|
||||
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
|
||||
else if (queryTerm) return bookFilter(item, queryTerm);
|
||||
@@ -245,6 +257,23 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectMode) {
|
||||
setShowSelectModeActions(true);
|
||||
if (isSelectAll) {
|
||||
setSelectedBooks(
|
||||
filteredBookshelfItems.map((item) => ('hash' in item ? item.hash : item.id)),
|
||||
);
|
||||
} else if (isSelectNone) {
|
||||
setSelectedBooks([]);
|
||||
}
|
||||
} else {
|
||||
setSelectedBooks([]);
|
||||
setShowSelectModeActions(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSelectMode, isSelectAll, isSelectNone]);
|
||||
|
||||
return (
|
||||
<div className='bookshelf'>
|
||||
<div
|
||||
@@ -354,7 +383,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{showGroupingModal && (
|
||||
<div>
|
||||
<ModalPortal>
|
||||
<GroupingModal
|
||||
libraryBooks={libraryBooks}
|
||||
selectedBooks={selectedBooks}
|
||||
@@ -367,7 +396,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleSetSelectMode(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
)}
|
||||
{showDeleteAlert && (
|
||||
<div
|
||||
@@ -378,7 +407,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
>
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
message={_('Are you sure to delete {{count}} selected book(s)?', {
|
||||
count: getBooksToDelete().length,
|
||||
})}
|
||||
onCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
|
||||
@@ -140,7 +140,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
}, [selectedBooks]);
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'>
|
||||
<div className='fixed inset-0 flex items-center justify-center'>
|
||||
<div
|
||||
className={clsx(
|
||||
'modal-box bg-base-100 overflow-y-auto rounded-2xl shadow-xl',
|
||||
|
||||
@@ -11,10 +11,11 @@ import { IoMdCloseCircle } from 'react-icons/io';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
@@ -24,20 +25,27 @@ import SortMenu from './SortMenu';
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
isSelectAll: boolean;
|
||||
onImportBooks: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
}
|
||||
|
||||
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
isSelectMode,
|
||||
isSelectAll,
|
||||
onImportBooks,
|
||||
onToggleSelectMode,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { appService } = useEnv();
|
||||
const { statusBarHeight } = useThemeStore();
|
||||
const { currentBookshelf } = useLibraryStore();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
initializeTrafficLightStore,
|
||||
@@ -56,8 +64,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledUpdateQueryParam = useCallback(
|
||||
throttle((value: string) => {
|
||||
const debouncedUpdateQueryParam = useCallback(
|
||||
debounce((value: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (value) {
|
||||
params.set('q', value);
|
||||
@@ -65,14 +73,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
params.delete('q');
|
||||
}
|
||||
router.push(`?${params.toString()}`);
|
||||
}, 1000),
|
||||
}, 500),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newQuery = e.target.value;
|
||||
setSearchQuery(newQuery);
|
||||
throttledUpdateQueryParam(newQuery);
|
||||
debouncedUpdateQueryParam(newQuery);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,6 +97,10 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
const isInGroupView = !!searchParams?.get('group');
|
||||
const currentBooksCount = currentBookshelf.reduce(
|
||||
(acc, item) => acc + ('books' in item ? item.books.length : 1),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -124,7 +136,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
<input
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
placeholder={_('Search Books...')}
|
||||
placeholder={
|
||||
currentBooksCount > 1
|
||||
? _('Search in {{count}} Book(s)...', {
|
||||
count: currentBooksCount,
|
||||
})
|
||||
: _('Search Books...')
|
||||
}
|
||||
onChange={handleSearchChange}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
@@ -140,7 +158,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
throttledUpdateQueryParam('');
|
||||
debouncedUpdateQueryParam('');
|
||||
}}
|
||||
className='pe-1 text-gray-400 hover:text-gray-600'
|
||||
aria-label={_('Clear Search')}
|
||||
@@ -182,14 +200,21 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{appService?.isMobile && isSelectMode ? (
|
||||
<div className='w-max-[72px] w-min-[72px] flex h-full items-center'>
|
||||
{isSelectMode ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full items-center',
|
||||
'w-max-[72px] w-min-[72px] sm:w-max-[80px] sm:w-min-[80px]',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
className='btn btn-ghost text-base-content/85 h-8 min-h-8 w-[72px] p-0'
|
||||
aria-label={_('Cancel Selection')}
|
||||
onClick={isSelectAll ? onDeselectAll : onSelectAll}
|
||||
className='btn btn-ghost text-base-content/85 h-8 min-h-8 w-[72px] p-0 sm:w-[80px]'
|
||||
aria-label={isSelectAll ? _('Deselect') : _('Select All')}
|
||||
>
|
||||
{_('Cancel')}
|
||||
<span className='font-sans text-base font-normal sm:text-sm'>
|
||||
{isSelectAll ? _('Deselect') : _('Select All')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -4,18 +4,22 @@ import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { BiMoon, BiSun } from 'react-icons/bi';
|
||||
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
@@ -30,6 +34,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
@@ -40,12 +45,14 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
|
||||
settings.autoImportBooksOnOpen,
|
||||
);
|
||||
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
|
||||
const iconSize = useResponsiveSize(16);
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const downloadReadest = () => {
|
||||
window.open(DOWNLOAD_READEST_URL, '_blank');
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -61,6 +68,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
||||
setThemeMode(nextMode);
|
||||
};
|
||||
|
||||
const handleReloadPage = () => {
|
||||
window.location.reload();
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -119,6 +131,18 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsOpenLastBooks(settings.openLastBooks);
|
||||
};
|
||||
|
||||
const toggleTelemetry = () => {
|
||||
settings.telemetryEnabled = !settings.telemetryEnabled;
|
||||
if (settings.telemetryEnabled) {
|
||||
optInTelemetry();
|
||||
} else {
|
||||
optOutTelemetry();
|
||||
}
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsTelemetryEnabled(settings.telemetryEnabled);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
const storagPlan = getStoragePlanData(token);
|
||||
@@ -212,9 +236,26 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={toggleScreenWakeLock}
|
||||
/>
|
||||
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
|
||||
<MenuItem
|
||||
label={
|
||||
themeMode === 'dark'
|
||||
? _('Dark Mode')
|
||||
: themeMode === 'light'
|
||||
? _('Light Mode')
|
||||
: _('Auto Mode')
|
||||
}
|
||||
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
|
||||
onClick={cycleThemeMode}
|
||||
/>
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
|
||||
<MenuItem
|
||||
label={_('Help improve Readest')}
|
||||
description={isTelemetryEnabled ? _('Sharing anonymized statistics') : ''}
|
||||
Icon={isTelemetryEnabled ? MdCheck : undefined}
|
||||
onClick={toggleTelemetry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { mountAdditionalFonts } from '@/utils/style';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import {
|
||||
tauriHandleSetAlwaysOnTop,
|
||||
tauriHandleToggleFullScreen,
|
||||
@@ -75,6 +75,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const isInitiating = useRef(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
const [isSelectNone, setIsSelectNone] = useState(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [booksTransferProgress, setBooksTransferProgress] = useState<{
|
||||
[key: string]: number | null;
|
||||
@@ -360,10 +362,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
|
||||
['Unsupported format.', _('This book format is not supported.')],
|
||||
];
|
||||
const { library } = useLibraryStore.getState();
|
||||
for (const file of files) {
|
||||
try {
|
||||
const book = await appService?.importBook(file, libraryBooks);
|
||||
setLibrary(libraryBooks);
|
||||
const book = await appService?.importBook(file, library);
|
||||
setLibrary(library);
|
||||
if (user && book && !book.uploadedAt && settings.autoUpload) {
|
||||
console.log('Uploading book:', book.title);
|
||||
handleBookUpload(book);
|
||||
@@ -386,7 +389,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
console.error('Failed to import book:', filename, error);
|
||||
}
|
||||
}
|
||||
appService?.saveLibraryBooks(libraryBooks);
|
||||
appService?.saveLibraryBooks(library);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -526,18 +529,23 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
importBooks(files);
|
||||
};
|
||||
|
||||
const handleToggleSelectMode = () => {
|
||||
if (!isSelectMode && appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
setIsSelectMode((pre) => !pre);
|
||||
};
|
||||
|
||||
const handleSetSelectMode = (selectMode: boolean) => {
|
||||
if (selectMode && appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
setIsSelectMode(selectMode);
|
||||
setIsSelectAll(false);
|
||||
setIsSelectNone(false);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setIsSelectAll(true);
|
||||
setIsSelectNone(false);
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setIsSelectNone(true);
|
||||
setIsSelectAll(false);
|
||||
};
|
||||
|
||||
const handleShowDetailsBook = (book: Book) => {
|
||||
@@ -570,8 +578,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<div className='fixed top-0 z-40 w-full'>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onImportBooks={handleImportBooks}
|
||||
onToggleSelectMode={handleToggleSelectMode}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
/>
|
||||
</div>
|
||||
{loading && (
|
||||
@@ -599,6 +610,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
handleImportBooks={handleImportBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
|
||||
@@ -11,7 +11,7 @@ import getGridTemplate from '@/utils/grid';
|
||||
import SectionInfo from './SectionInfo';
|
||||
import HeaderBar from './HeaderBar';
|
||||
import FooterBar from './FooterBar';
|
||||
import PageInfoView from './PageInfo';
|
||||
import ProgressInfoView from './ProgressInfo';
|
||||
import Ribbon from './Ribbon';
|
||||
import SettingsDialog from './settings/SettingsDialog';
|
||||
import Annotator from './annotator/Annotator';
|
||||
@@ -57,7 +57,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const { book, bookDoc } = bookData || {};
|
||||
if (!book || !config || !bookDoc || !viewSettings) return null;
|
||||
|
||||
const { section, pageinfo, sectionLabel } = progress || {};
|
||||
const { section, pageinfo, timeinfo, sectionLabel } = progress || {};
|
||||
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
|
||||
const horizontalGapPercent = viewSettings.gapPercent;
|
||||
const verticalMarginPixels = viewSettings.marginPx;
|
||||
@@ -139,13 +139,12 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
{showFooter && (
|
||||
<PageInfoView
|
||||
<ProgressInfoView
|
||||
bookKey={bookKey}
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
isVertical={viewSettings.vertical}
|
||||
timeinfo={timeinfo}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
|
||||
@@ -5,13 +5,15 @@ import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
|
||||
import { getStyles, mountAdditionalFonts, transformStylesheet } from '@/utils/style';
|
||||
import { getStyles, transformStylesheet } from '@/utils/style';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
import {
|
||||
@@ -26,8 +28,10 @@ import {
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { useTextTranslation } from '../hooks/useTextTranslation';
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
@@ -41,6 +45,7 @@ const FoliateViewer: React.FC<{
|
||||
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { themeCode, isDarkMode } = useThemeStore();
|
||||
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
@@ -52,10 +57,19 @@ const FoliateViewer: React.FC<{
|
||||
useUICSS(bookKey);
|
||||
useProgressSync(bookKey);
|
||||
useProgressAutoSave(bookKey);
|
||||
useTextTranslation(bookKey, viewRef.current);
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
|
||||
setProgress(
|
||||
bookKey,
|
||||
detail.cfi,
|
||||
detail.tocItem,
|
||||
detail.section,
|
||||
detail.location,
|
||||
detail.time,
|
||||
detail.range,
|
||||
);
|
||||
};
|
||||
|
||||
const getDocTransformHandler = ({ width, height }: { width: number; height: number }) => {
|
||||
@@ -90,6 +104,7 @@ const FoliateViewer: React.FC<{
|
||||
if (detail.doc) {
|
||||
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
viewSettings.vertical =
|
||||
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
|
||||
viewSettings.rtl =
|
||||
@@ -99,7 +114,7 @@ const FoliateViewer: React.FC<{
|
||||
false;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
|
||||
mountAdditionalFonts(detail.doc);
|
||||
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
|
||||
|
||||
if (!detail.doc.isEventListenersAdded) {
|
||||
// listened events in iframes are posted to the main window
|
||||
|
||||
@@ -41,8 +41,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
|
||||
useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
|
||||
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
|
||||
const [actionTab, setActionTab] = React.useState('');
|
||||
const sliderHeight = useResponsiveSize(28);
|
||||
@@ -53,6 +53,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const viewState = getViewState(bookKey);
|
||||
|
||||
const handleProgressChange = (value: number) => {
|
||||
view?.goToFraction(value / 100.0);
|
||||
@@ -108,11 +109,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
|
||||
const handleSpeakText = async () => {
|
||||
if (!view || !progress) return;
|
||||
const { range } = progress;
|
||||
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
|
||||
eventDispatcher.dispatch('tts-stop', { bookKey });
|
||||
} else {
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey, range });
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,6 +147,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
};
|
||||
|
||||
const isVisible = hoveredBookKey === bookKey;
|
||||
const ttsEnabled = viewState?.ttsEnabled;
|
||||
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
|
||||
const progressValid = !!progressInfo;
|
||||
const progressFraction =
|
||||
@@ -312,7 +313,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
}
|
||||
onClick={() => handleSetActionTab('font')}
|
||||
/>
|
||||
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
|
||||
<Button
|
||||
icon={<TTSIcon className={ttsEnabled ? 'text-blue-500' : ''} />}
|
||||
onClick={() => handleSetActionTab('tts')}
|
||||
/>
|
||||
</div>
|
||||
{/* Desktop footer bar */}
|
||||
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
|
||||
@@ -351,7 +355,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
|
||||
}
|
||||
/>
|
||||
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
|
||||
<Button
|
||||
icon={<FaHeadphones className={ttsEnabled ? 'text-blue-500' : ''} />}
|
||||
onClick={handleSpeakText}
|
||||
tooltip={_('Speak')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
|
||||
|
||||
@@ -2,12 +2,15 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { getFootnoteStyles, getStyles, getThemeCode, mountAdditionalFonts } from '@/utils/style';
|
||||
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
interface FootnotePopupProps {
|
||||
@@ -26,6 +29,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const [popupPosition, setPopupPosition] = useState<Position | null>();
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
@@ -55,7 +59,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
});
|
||||
view.addEventListener('load', (e: CustomEvent) => {
|
||||
const { doc } = e.detail;
|
||||
mountAdditionalFonts(doc);
|
||||
const bookData = getBookData(bookKey)!;
|
||||
mountAdditionalFonts(doc, isCJKLang(bookData.book?.primaryLanguage));
|
||||
});
|
||||
footnoteViewRef.current = view;
|
||||
footnoteRef.current?.replaceChildren(view);
|
||||
|
||||
@@ -14,6 +14,7 @@ import SidebarToggler from './SidebarToggler';
|
||||
import BookmarkToggler from './BookmarkToggler';
|
||||
import NotebookToggler from './NotebookToggler';
|
||||
import SettingsToggler from './SettingsToggler';
|
||||
import TranslationToggler from './TranslationToggler';
|
||||
import ViewMenu from './ViewMenu';
|
||||
|
||||
interface HeaderBarProps {
|
||||
@@ -119,6 +120,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
</div>
|
||||
|
||||
<div className='header-title z-15 bg-base-100 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
|
||||
|
||||
+24
-15
@@ -1,32 +1,37 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { PageInfo } from '@/types/book';
|
||||
import { PageInfo, TimeInfo } from '@/types/book';
|
||||
|
||||
interface PageInfoProps {
|
||||
bookKey: string;
|
||||
bookFormat: string;
|
||||
section?: PageInfo;
|
||||
pageinfo?: PageInfo;
|
||||
showDoubleBorder: boolean;
|
||||
isScrolled: boolean;
|
||||
isVertical: boolean;
|
||||
timeinfo?: TimeInfo;
|
||||
horizontalGap: number;
|
||||
verticalMargin: number;
|
||||
}
|
||||
|
||||
const PageInfoView: React.FC<PageInfoProps> = ({
|
||||
const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
bookKey,
|
||||
bookFormat,
|
||||
section,
|
||||
pageinfo,
|
||||
showDoubleBorder,
|
||||
isScrolled,
|
||||
isVertical,
|
||||
timeinfo,
|
||||
horizontalGap,
|
||||
verticalMargin,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
|
||||
const isScrolled = viewSettings.scrolled;
|
||||
const isVertical = viewSettings.vertical;
|
||||
const pageInfo = ['PDF', 'CBZ'].includes(bookFormat)
|
||||
? section
|
||||
? isVertical
|
||||
@@ -39,11 +44,15 @@ const PageInfoView: React.FC<PageInfoProps> = ({
|
||||
totalPage: pageinfo.total,
|
||||
})
|
||||
: '';
|
||||
const timeInfo = timeinfo
|
||||
? _('{{time}} min left in chapter', { time: Math.round(timeinfo.section) })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'pageinfo absolute bottom-0 flex items-center justify-end',
|
||||
'pageinfo absolute bottom-0 flex items-center justify-between',
|
||||
'text-neutral-content font-sans text-xs font-extralight',
|
||||
isVertical ? 'writing-vertical-rl' : 'h-12 w-full',
|
||||
isScrolled && !isVertical && 'bg-base-100',
|
||||
)}
|
||||
@@ -53,21 +62,21 @@ const PageInfoView: React.FC<PageInfoProps> = ({
|
||||
bottom: `${verticalMargin * 1.5}px`,
|
||||
left: showDoubleBorder ? `calc(${horizontalGap}% - 32px)` : 0,
|
||||
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
height: `calc(100% - ${verticalMargin * 3}px)`,
|
||||
}
|
||||
: {
|
||||
insetInlineEnd: `${horizontalGap}%`,
|
||||
paddingInlineStart: `${horizontalGap}%`,
|
||||
paddingInlineEnd: `${horizontalGap}%`,
|
||||
paddingBottom: appService?.hasSafeAreaInset
|
||||
? 'calc(env(safe-area-inset-bottom)*0.67)'
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
|
||||
{pageInfo}
|
||||
</h2>
|
||||
{viewSettings.showRemainingTime && <span className='text-start'>{timeInfo}</span>}
|
||||
{viewSettings.showPageNumber && <span className='ms-auto text-end'>{pageInfo}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageInfoView;
|
||||
export default ProgressInfoView;
|
||||
@@ -15,7 +15,8 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { mountAdditionalFonts } from '@/utils/style';
|
||||
import { interceptWindowOpen } from '@/utils/open';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { setSystemUIVisibility } from '@/utils/bridge';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
@@ -39,6 +40,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
|
||||
useEffect(() => {
|
||||
mountAdditionalFonts(document);
|
||||
interceptWindowOpen();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { RiTranslateAi } from 'react-icons/ri';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import { isSameLang } from '@/utils/lang';
|
||||
import Button from '@/components/Button';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
|
||||
const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getViewSettings, setViewSettings, setHoveredBookKey } = useReaderStore();
|
||||
|
||||
const bookData = getBookData(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
|
||||
|
||||
const primaryLanguage = bookData?.book?.primaryLanguage;
|
||||
const targetLanguage = viewSettings.translateTargetLang;
|
||||
|
||||
useEffect(() => {
|
||||
if (translationEnabled === viewSettings.translationEnabled) return;
|
||||
if (appService?.isMobile) {
|
||||
setHoveredBookKey('');
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
|
||||
viewSettings.translationEnabled = translationEnabled;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [translationEnabled]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={
|
||||
<RiTranslateAi className={translationEnabled ? 'text-blue-500' : 'text-base-content'} />
|
||||
}
|
||||
disabled={
|
||||
!bookData ||
|
||||
bookData.book?.format === 'PDF' ||
|
||||
isSameLang(primaryLanguage, targetLanguage) ||
|
||||
(!targetLanguage && isSameLang(primaryLanguage, getLocale()))
|
||||
}
|
||||
onClick={() => setTranslationEnabled(!translationEnabled)}
|
||||
tooltip={translationEnabled ? _('Disable Translation') : _('Enable Translation')}
|
||||
tooltipDirection='bottom'
|
||||
></Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranslationToggler;
|
||||
@@ -1,17 +1,22 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
|
||||
import { MdSync, MdSyncProblem } from 'react-icons/md';
|
||||
|
||||
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { ThemeMode } from '@/styles/themes';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
@@ -29,13 +34,20 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
onSetSettingsDialogOpen,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getConfig } = useBookDataStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const config = getConfig(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
|
||||
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
|
||||
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
|
||||
viewSettings!.invertImgColorInDark,
|
||||
);
|
||||
|
||||
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
||||
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
||||
@@ -48,8 +60,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const nextMode: ThemeMode =
|
||||
themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
||||
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
||||
setThemeMode(nextMode);
|
||||
};
|
||||
|
||||
@@ -58,6 +69,15 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleSync = () => {
|
||||
if (!user) {
|
||||
navigateToLogin(router);
|
||||
setIsDropdownOpen?.(false);
|
||||
} else {
|
||||
eventDispatcher.dispatch('sync-book-progress', { bookKey });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrolledMode === viewSettings!.scrolled) return;
|
||||
viewSettings!.scrolled = isScrolledMode;
|
||||
@@ -76,10 +96,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [zoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
|
||||
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark, true, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invertImgColorInDark]);
|
||||
|
||||
const lastSyncTime = Math.max(config?.lastSyncedAtConfig || 0, config?.lastSyncedAtNotes || 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='view-menu dropdown-content bgcolor-base-200 dropdown-right no-triangle border-base-200 z-20 mt-1 w-72 border shadow-2xl'
|
||||
className='view-menu dropdown-content bgcolor-base-200 dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl'
|
||||
>
|
||||
<div className={clsx('flex items-center justify-between rounded-md')}>
|
||||
<button
|
||||
@@ -123,6 +151,22 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
|
||||
<hr className='border-base-300 my-1' />
|
||||
|
||||
<MenuItem
|
||||
label={
|
||||
!user
|
||||
? _('Sign in to Sync')
|
||||
: lastSyncTime
|
||||
? _('Synced at {{time}}', {
|
||||
time: new Date(lastSyncTime).toLocaleString(),
|
||||
})
|
||||
: _('Never synced')
|
||||
}
|
||||
Icon={user ? MdSync : MdSyncProblem}
|
||||
onClick={handleSync}
|
||||
/>
|
||||
|
||||
<hr className='border-base-300 my-1' />
|
||||
|
||||
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
||||
<MenuItem
|
||||
label={
|
||||
@@ -135,6 +179,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
|
||||
onClick={cycleThemeMode}
|
||||
/>
|
||||
<MenuItem
|
||||
label={_('Invert Image In Dark Mode')}
|
||||
disabled={!isDarkMode}
|
||||
Icon={invertImgColorInDark ? MdCheck : undefined}
|
||||
onClick={() => setInvertImgColorInDark(!invertImgColorInDark)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
import WikipediaPopup from './WikipediaPopup';
|
||||
import DeepLPopup from './DeepLPopup';
|
||||
import TranslatorPopup from './TranslatorPopup';
|
||||
|
||||
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
@@ -91,8 +91,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
view?.deselect();
|
||||
};
|
||||
|
||||
const { handleScroll, handlePointerup, handleSelectionchange, handleShowPopup, handleUpToPopup } =
|
||||
useTextSelector(bookKey, setSelection, handleDismissPopup);
|
||||
const {
|
||||
handleScroll,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
handlePointerup,
|
||||
handleSelectionchange,
|
||||
handleShowPopup,
|
||||
handleUpToPopup,
|
||||
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
|
||||
|
||||
const onLoad = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
@@ -105,7 +112,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
};
|
||||
if (bookData.book?.format !== 'PDF') {
|
||||
view?.renderer?.addEventListener('scroll', handleScroll);
|
||||
detail.doc?.addEventListener('touchstart', handleTouchStart);
|
||||
detail.doc?.addEventListener('touchmove', handleTouchmove);
|
||||
detail.doc?.addEventListener('touchend', handleTouchEnd);
|
||||
detail.doc?.addEventListener('pointerup', () => handlePointerup(doc, index));
|
||||
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
|
||||
|
||||
@@ -499,7 +508,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
)}
|
||||
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
|
||||
<DeepLPopup
|
||||
<TranslatorPopup
|
||||
text={selection?.text as string}
|
||||
position={translatorPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
|
||||
+82
-49
@@ -6,6 +6,9 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTranslator } from '@/hooks/useTranslator';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import { UseTranslatorOptions } from '@/services/translators';
|
||||
import { localeToLang } from '@/utils/lang';
|
||||
import Select from '@/components/Select';
|
||||
|
||||
const notSupportedLangs = ['hi', 'vi'];
|
||||
|
||||
@@ -14,12 +17,7 @@ const generateTranslatorLangs = () => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [code, name] of Object.entries(langs)) {
|
||||
if (notSupportedLangs.includes(code)) continue;
|
||||
let newCode = code.toUpperCase();
|
||||
if (newCode === 'ZH-CN') {
|
||||
newCode = 'ZH-HANS';
|
||||
} else if (newCode === 'ZH-TW') {
|
||||
newCode = 'ZH-HANT';
|
||||
}
|
||||
const newCode = localeToLang(code).toUpperCase();
|
||||
result[newCode] = name;
|
||||
}
|
||||
return result;
|
||||
@@ -27,7 +25,7 @@ const generateTranslatorLangs = () => {
|
||||
|
||||
const TRANSLATOR_LANGS = generateTranslatorLangs();
|
||||
|
||||
interface DeepLPopupProps {
|
||||
interface TranslatorPopupProps {
|
||||
text: string;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
@@ -35,7 +33,12 @@ interface DeepLPopupProps {
|
||||
popupHeight: number;
|
||||
}
|
||||
|
||||
const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
interface TranslatorType {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
text,
|
||||
position,
|
||||
trianglePosition,
|
||||
@@ -45,8 +48,10 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
const _ = useTranslation();
|
||||
const { token } = useAuth();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const [providers, setProviders] = useState<TranslatorType[]>([]);
|
||||
const [sourceLang, setSourceLang] = useState('AUTO');
|
||||
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
|
||||
const [provider, setProvider] = useState(settings.globalReadSettings.translationProvider);
|
||||
const [translation, setTranslation] = useState<string | null>(null);
|
||||
const [detectedSourceLang, setDetectedSourceLang] = useState<
|
||||
keyof typeof TRANSLATOR_LANGS | null
|
||||
@@ -54,10 +59,11 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { translate } = useTranslator({
|
||||
const { translate, translators } = useTranslator({
|
||||
provider,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
});
|
||||
} as UseTranslatorOptions);
|
||||
|
||||
const handleSourceLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSourceLang(event.target.value);
|
||||
@@ -69,14 +75,31 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
setTargetLang(event.target.value);
|
||||
};
|
||||
|
||||
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedTranslator = translators.find((t) => t.name === event.target.value);
|
||||
if (selectedTranslator) {
|
||||
settings.globalReadSettings.translationProvider = selectedTranslator.name;
|
||||
setSettings(settings);
|
||||
setProvider(selectedTranslator.name);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const availableProviders = translators.map((t) => {
|
||||
return { name: t.name, label: t.label };
|
||||
});
|
||||
setProviders(availableProviders);
|
||||
}, [translators]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const fetchTranslation = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTranslation(null);
|
||||
|
||||
try {
|
||||
const result = await translate([text]);
|
||||
const input = text.replaceAll('\n', '').trim();
|
||||
const result = await translate([input]);
|
||||
const translatedText = result[0];
|
||||
const detectedSource = null;
|
||||
|
||||
@@ -102,7 +125,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
|
||||
fetchTranslation();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text, token, sourceLang, targetLang]);
|
||||
}, [text, token, sourceLang, targetLang, provider]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -118,65 +141,75 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
<div className='overflow-y-auto p-4 font-sans'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h1 className='text-sm font-normal'>{_('Original Text')}</h1>
|
||||
<select
|
||||
<Select
|
||||
value={sourceLang}
|
||||
onChange={handleSourceLangChange}
|
||||
className='select h-8 min-h-8 rounded-md border-none bg-gray-600 text-sm text-white/75 focus:outline-none focus:ring-0'
|
||||
>
|
||||
{[
|
||||
['AUTO', _('Auto Detect')],
|
||||
...Object.entries(TRANSLATOR_LANGS).sort((a, b) => a[1].localeCompare(b[1])),
|
||||
].map(([code, name]) => {
|
||||
return (
|
||||
<option key={code} value={code}>
|
||||
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
|
||||
? `${TRANSLATOR_LANGS[detectedSourceLang] || detectedSourceLang} ` +
|
||||
_('(detected)')
|
||||
: name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'AUTO', label: _('Auto Detect') },
|
||||
...Object.entries(TRANSLATOR_LANGS)
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => {
|
||||
const label =
|
||||
detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
|
||||
? `${TRANSLATOR_LANGS[detectedSourceLang] || detectedSourceLang} ` +
|
||||
_('(detected)')
|
||||
: name;
|
||||
return { value: code, label };
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-base text-white/90'>{text}</p>
|
||||
</div>
|
||||
|
||||
<div className='mx-4 flex-shrink-0 border-t border-gray-500/30'></div>
|
||||
|
||||
<div className='overflow-y-auto p-4 font-sans'>
|
||||
<div className='overflow-y-auto px-4 pb-8 pt-4 font-sans'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
|
||||
<select
|
||||
<Select
|
||||
value={targetLang}
|
||||
onChange={handleTargetLangChange}
|
||||
className='select h-8 min-h-8 rounded-md border-none bg-gray-600 text-sm text-white/75 focus:outline-none focus:ring-0'
|
||||
>
|
||||
{Object.entries(TRANSLATOR_LANGS)
|
||||
options={Object.entries(TRANSLATOR_LANGS)
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => (
|
||||
<option key={code} value={code}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
.map(([code, name]) => ({ value: code, label: name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
|
||||
) : error ? (
|
||||
<p className='text-base text-red-600'>{error}</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className='text-base text-white/90'>
|
||||
{translation || 'No translation available.'}
|
||||
</p>
|
||||
<div className='pt-4 text-sm opacity-60'>Translated by DeepL.</div>
|
||||
{error ? (
|
||||
<p className='text-base text-red-600'>{error}</p>
|
||||
) : (
|
||||
<p className='text-base text-white/90'>
|
||||
{translation || _('No translation available.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between bg-gray-600 px-4'>
|
||||
{provider && !loading && (
|
||||
<div className='text-xs opacity-60'>
|
||||
{error
|
||||
? ''
|
||||
: _('Translated by {{provider}}.', {
|
||||
provider: providers.find((p) => p.name === provider)?.label,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className='ml-auto'>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={handleProviderChange}
|
||||
options={providers.map(({ name: value, label }) => ({ value, label }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeepLPopup;
|
||||
export default TranslatorPopup;
|
||||
@@ -9,9 +9,11 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
const NotebookHeader: React.FC<{
|
||||
isPinned: boolean;
|
||||
isSearchBarVisible: boolean;
|
||||
handleClose: () => void;
|
||||
handleTogglePin: () => void;
|
||||
}> = ({ isPinned, handleClose, handleTogglePin }) => {
|
||||
handleToggleSearchBar: () => void;
|
||||
}> = ({ isPinned, isSearchBarVisible, handleClose, handleTogglePin, handleToggleSearchBar }) => {
|
||||
const _ = useTranslation();
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
return (
|
||||
@@ -38,7 +40,10 @@ const NotebookHeader: React.FC<{
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center justify-end gap-x-4'>
|
||||
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
|
||||
<button
|
||||
onClick={handleToggleSearchBar}
|
||||
className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0', isSearchBarVisible && 'bg-base-300')}
|
||||
>
|
||||
<FiSearch />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { TextSelection } from '@/utils/sel';
|
||||
import { md5Fingerprint } from '@/utils/md5';
|
||||
import { BookNote } from '@/types/book';
|
||||
@@ -24,6 +25,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
||||
} = useNotebookStore();
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [note, setNote] = React.useState('');
|
||||
const separatorWidth = useResponsiveSize(3);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
@@ -125,10 +127,15 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-start pt-2'>
|
||||
<div className='mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
|
||||
<div className='content font-size-sm line-clamp-3 py-2'>
|
||||
<span className='content font-size-xs inline text-gray-500'>{getAnnotationText()}</span>
|
||||
<div className='flex items-center pt-2'>
|
||||
<div
|
||||
className='me-2 mt-0.5 min-h-full self-stretch rounded-xl bg-gray-300'
|
||||
style={{
|
||||
minWidth: `${separatorWidth}px`,
|
||||
}}
|
||||
></div>
|
||||
<div className='content font-size-sm line-clamp-3'>
|
||||
<span className='content font-size-xs text-gray-500'>{getAnnotationText()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end p-2' dir='ltr'>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
@@ -18,6 +18,7 @@ import { getBookDirFromLanguage } from '@/utils/book';
|
||||
import BooknoteItem from '../sidebar/BooknoteItem';
|
||||
import NotebookHeader from './Header';
|
||||
import NoteEditor from './NoteEditor';
|
||||
import SearchBar from './SearchBar';
|
||||
|
||||
const MIN_NOTEBOOK_WIDTH = 0.15;
|
||||
const MAX_NOTEBOOK_WIDTH = 0.45;
|
||||
@@ -35,6 +36,10 @@ const Notebook: React.FC = ({}) => {
|
||||
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
|
||||
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
|
||||
|
||||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<BookNote[] | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const onNavigateEvent = async () => {
|
||||
const pinButton = document.querySelector('.sidebar-pin-btn');
|
||||
const isPinButtonHidden = !pinButton || window.getComputedStyle(pinButton).display === 'none';
|
||||
@@ -135,6 +140,39 @@ const Notebook: React.FC = ({}) => {
|
||||
|
||||
const { handleDragStart } = useDrag(onDragMove);
|
||||
|
||||
const config = getConfig(sideBarBookKey);
|
||||
const { booknotes: allNotes = [] } = config || {};
|
||||
const annotationNotes = allNotes
|
||||
.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
const excerptNotes = allNotes
|
||||
.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
const handleToggleSearchBar = () => {
|
||||
setIsSearchBarVisible((prev) => !prev);
|
||||
if (isSearchBarVisible) {
|
||||
setSearchResults(null);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAnnotationNotes = useMemo(
|
||||
() =>
|
||||
isSearchBarVisible && searchResults
|
||||
? searchResults.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
|
||||
: annotationNotes,
|
||||
[annotationNotes, searchResults, isSearchBarVisible],
|
||||
);
|
||||
|
||||
const filteredExcerptNotes = useMemo(
|
||||
() =>
|
||||
isSearchBarVisible && searchResults
|
||||
? searchResults.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
|
||||
: excerptNotes,
|
||||
[excerptNotes, searchResults, isSearchBarVisible],
|
||||
);
|
||||
|
||||
if (!sideBarBookKey) return null;
|
||||
|
||||
const bookData = getBookData(sideBarBookKey);
|
||||
@@ -145,14 +183,8 @@ const Notebook: React.FC = ({}) => {
|
||||
const { bookDoc } = bookData;
|
||||
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
|
||||
|
||||
const config = getConfig(sideBarBookKey);
|
||||
const { booknotes: allNotes = [] } = config || {};
|
||||
const annotationNotes = allNotes
|
||||
.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
const excerptNotes = allNotes
|
||||
.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
const hasSearchResults = filteredAnnotationNotes.length > 0 || filteredExcerptNotes.length > 0;
|
||||
const hasAnyNotes = annotationNotes.length > 0 || excerptNotes.length > 0;
|
||||
|
||||
return isNotebookVisible ? (
|
||||
<>
|
||||
@@ -161,7 +193,7 @@ const Notebook: React.FC = ({}) => {
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'notebook-container bg-base-200 right-0 z-20 min-w-60 select-none',
|
||||
'notebook-container bg-base-200 right-0 z-20 flex min-w-60 select-none flex-col',
|
||||
'font-sans text-base font-normal sm:text-sm',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
@@ -187,29 +219,60 @@ const Notebook: React.FC = ({}) => {
|
||||
className='drag-bar absolute left-0 top-0 h-full w-0.5 cursor-col-resize'
|
||||
onMouseDown={handleDragStart}
|
||||
/>
|
||||
<NotebookHeader
|
||||
isPinned={isNotebookPinned}
|
||||
handleClose={() => setNotebookVisible(false)}
|
||||
handleTogglePin={handleTogglePin}
|
||||
/>
|
||||
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
|
||||
<div className='flex-shrink-0'>
|
||||
<NotebookHeader
|
||||
isPinned={isNotebookPinned}
|
||||
isSearchBarVisible={isSearchBarVisible}
|
||||
handleClose={() => setNotebookVisible(false)}
|
||||
handleTogglePin={handleTogglePin}
|
||||
handleToggleSearchBar={handleToggleSearchBar}
|
||||
/>
|
||||
<div
|
||||
className={clsx('search-bar', {
|
||||
'search-bar-visible': isSearchBarVisible,
|
||||
})}
|
||||
>
|
||||
<SearchBar
|
||||
isVisible={isSearchBarVisible}
|
||||
bookKey={sideBarBookKey}
|
||||
searchTerm={searchTerm}
|
||||
onSearchResultChange={setSearchResults}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-grow overflow-y-auto px-3'>
|
||||
{isSearchBarVisible && searchResults && !hasSearchResults && hasAnyNotes && (
|
||||
<div className='flex h-32 items-center justify-center text-gray-500'>
|
||||
<p className='font-size-sm text-center'>{_('No notes match your search')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div dir='ltr'>
|
||||
{excerptNotes.length > 0 && (
|
||||
<p className='content font-size-base pt-1'>{_('Excerpts')}</p>
|
||||
{filteredExcerptNotes.length > 0 && (
|
||||
<p className='content font-size-base'>
|
||||
{_('Excerpts')}
|
||||
{isSearchBarVisible && searchResults && (
|
||||
<span className='font-size-xs ml-2 text-gray-500'>
|
||||
({filteredExcerptNotes.length})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ul className=''>
|
||||
{excerptNotes.map((item, index) => (
|
||||
{filteredExcerptNotes.map((item, index) => (
|
||||
<li key={`${index}-${item.id}`} className='my-2'>
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='collapse-arrow border-base-300 bg-base-100 collapse border'
|
||||
>
|
||||
<div
|
||||
className='collapse-title font-size-sm h-9 min-h-9 p-2 pe-8 font-medium'
|
||||
className={clsx(
|
||||
'collapse-title pe-8 text-sm font-medium',
|
||||
'h-[2.5rem] min-h-[2.5rem] p-[0.6rem]',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--top-override': '1.2rem',
|
||||
'--top-override': '1.25rem',
|
||||
'--end-override': '0.7rem',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
@@ -232,15 +295,22 @@ const Notebook: React.FC = ({}) => {
|
||||
))}
|
||||
</ul>
|
||||
<div dir='ltr'>
|
||||
{(notebookNewAnnotation || annotationNotes.length > 0) && (
|
||||
<p className='content font-size-base pt-1'>{_('Notes')}</p>
|
||||
{(notebookNewAnnotation || filteredAnnotationNotes.length > 0) && (
|
||||
<p className='content font-size-base'>
|
||||
{_('Notes')}
|
||||
{isSearchBarVisible && searchResults && filteredAnnotationNotes.length > 0 && (
|
||||
<span className='font-size-xs ml-2 text-gray-500'>
|
||||
({filteredAnnotationNotes.length})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(notebookNewAnnotation || notebookEditAnnotation) && (
|
||||
{(notebookNewAnnotation || notebookEditAnnotation) && !isSearchBarVisible && (
|
||||
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
|
||||
)}
|
||||
<ul>
|
||||
{annotationNotes.map((item, index) => (
|
||||
{filteredAnnotationNotes.map((item, index) => (
|
||||
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FaSearch, FaTimes } from 'react-icons/fa';
|
||||
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface SearchBarProps {
|
||||
isVisible: boolean;
|
||||
bookKey: string;
|
||||
searchTerm: string;
|
||||
onSearchResultChange: (results: BookNote[] | null) => void;
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<SearchBarProps> = ({
|
||||
isVisible,
|
||||
bookKey,
|
||||
searchTerm: term,
|
||||
onSearchResultChange,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { getConfig } = useBookDataStore();
|
||||
const [searchTerm, setSearchTerm] = useState(term);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize12 = useResponsiveSize(12);
|
||||
|
||||
useEffect(() => {
|
||||
handleSearchTermChange(searchTerm);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(term);
|
||||
handleSearchTermChange(term);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [term]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && inputRef.current) {
|
||||
inputRef.current.blur();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (searchTimeout.current) {
|
||||
clearTimeout(searchTimeout.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchTerm(value);
|
||||
|
||||
if (searchTimeout.current) {
|
||||
clearTimeout(searchTimeout.current);
|
||||
}
|
||||
|
||||
searchTimeout.current = setTimeout(() => {
|
||||
handleSearchTermChange(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm('');
|
||||
handleSearchTermChange('');
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const filterNotes = (notes: BookNote[], query: string): BookNote[] => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return notes.filter((note) => {
|
||||
const textMatch = note.text?.toLowerCase().includes(lowercaseQuery) || false;
|
||||
const noteMatch = note.note?.toLowerCase().includes(lowercaseQuery) || false;
|
||||
return textMatch || noteMatch;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchTermChange = (term: string) => {
|
||||
if (term.trim().length >= 1) {
|
||||
handleSearch(term);
|
||||
} else {
|
||||
resetSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (term: string) => {
|
||||
const config = getConfig(bookKey);
|
||||
if (!config) {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const { booknotes: allNotes = [] } = config;
|
||||
const validNotes = allNotes.filter((note) => !note.deletedAt);
|
||||
|
||||
const results = filterNotes(validNotes, term);
|
||||
onSearchResultChange(results);
|
||||
};
|
||||
|
||||
const resetSearch = () => {
|
||||
onSearchResultChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative px-3 py-2'>
|
||||
<div className='bg-base-100 flex h-8 items-center rounded-lg'>
|
||||
<div className='pl-3'>
|
||||
<FaSearch size={iconSize16} className='text-gray-500' />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={searchTerm}
|
||||
spellCheck={false}
|
||||
onChange={handleInputChange}
|
||||
placeholder={_('Search notes and excerpts...')}
|
||||
className='w-full bg-transparent p-2 font-sans text-sm font-light focus:outline-none'
|
||||
/>
|
||||
|
||||
{searchTerm && (
|
||||
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 rounded-none rounded-r-lg p-0'
|
||||
>
|
||||
<FaTimes size={iconSize12} className='text-gray-500' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -13,24 +13,38 @@ import {
|
||||
themes,
|
||||
} from '@/styles/themes';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import ThemeEditor from './ThemeEditor';
|
||||
|
||||
const ColorPanel: React.FC<{ bookKey: string }> = ({}) => {
|
||||
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
|
||||
useThemeStore();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
|
||||
viewSettings.invertImgColorInDark,
|
||||
);
|
||||
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize24 = useResponsiveSize(24);
|
||||
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
|
||||
const [customThems, setCustomThemes] = useState<Theme[]>([]);
|
||||
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
|
||||
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
const customThemes = settings.globalReadSettings.customThemes ?? [];
|
||||
setCustomThemes(
|
||||
@@ -112,6 +126,17 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({}) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Invert Image In Dark Mode')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={invertImgColorInDark}
|
||||
disabled={!isDarkMode}
|
||||
onChange={() => setInvertImgColorInDark(!invertImgColorInDark)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
@@ -12,7 +13,9 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
|
||||
@@ -21,6 +24,8 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [allowScript, setAllowScript] = useState(viewSettings.allowScript!);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrolledMode === viewSettings.scrolled) return;
|
||||
@@ -67,10 +72,27 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [swapClickArea]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
|
||||
if (animated) {
|
||||
getView(bookKey)?.renderer.setAttribute('animated', '');
|
||||
} else {
|
||||
getView(bookKey)?.renderer.removeAttribute('animated');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewSettings.allowScript === allowScript) return;
|
||||
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowScript]);
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Touch')}</h2>
|
||||
<h2 className='mb-2 font-medium'>{_('Scroll')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
@@ -92,7 +114,7 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
label={_('Scrolling Overlap (px)')}
|
||||
label={_('Overlap Pixels')}
|
||||
value={scrollingOverlap}
|
||||
onChange={setScrollingOverlap}
|
||||
disabled={!viewSettings.scrolled}
|
||||
@@ -100,17 +122,14 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
max={200}
|
||||
step={10}
|
||||
/>
|
||||
{appService?.isMobileApp && (
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Volume Keys for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={volumeKeysToFlip}
|
||||
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Click')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Clicks for Page Flip')}</span>
|
||||
<input
|
||||
@@ -130,6 +149,55 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setSwapClickArea(!swapClickArea)}
|
||||
/>
|
||||
</div>
|
||||
{appService?.isMobileApp && (
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Volume Keys for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={volumeKeysToFlip}
|
||||
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Paging Animation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={animated}
|
||||
onChange={() => setAnimated(!animated)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Security')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item !h-16'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className=''>{_('Allow JavaScript')}</span>
|
||||
<span className='text-xs'>{_('Enable only if you trust the file.')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={allowScript}
|
||||
disabled={bookData.book?.format !== 'EPUB'}
|
||||
onChange={() => setAllowScript(!allowScript)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -21,7 +22,10 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl'
|
||||
className={clsx(
|
||||
'dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl',
|
||||
'text-base sm:text-sm',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className='hover:bg-base-200 text-base-content flex w-full items-center justify-between rounded-md p-1'
|
||||
|
||||
@@ -8,17 +8,30 @@ interface DropDownProps {
|
||||
selected: { option: string; label: string };
|
||||
options: { option: string; label: string }[];
|
||||
onSelect: (option: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
listClassName?: string;
|
||||
}
|
||||
|
||||
const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
|
||||
const DropDown: React.FC<DropDownProps> = ({
|
||||
selected,
|
||||
options,
|
||||
onSelect,
|
||||
className,
|
||||
listClassName,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const defaultIconSize = useDefaultIconSize();
|
||||
|
||||
return (
|
||||
<div className='dropdown dropdown-bottom'>
|
||||
<div className={clsx('dropdown dropdown-bottom', className)}>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
|
||||
className={clsx(
|
||||
'btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case',
|
||||
disabled && 'btn-disabled',
|
||||
)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<span>{selected.label}</span>
|
||||
@@ -29,6 +42,7 @@ const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] shadow',
|
||||
'menu-vertical right-[-32px] mt-2 inline max-h-80 w-44 overflow-y-scroll sm:right-0',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{options.map(({ option, label }) => (
|
||||
|
||||
@@ -226,6 +226,16 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Font')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideFont}
|
||||
onChange={() => setOverrideFont(!overrideFont)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Font Size')}</h2>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
@@ -288,16 +298,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onSelect={setDefaultCJKFont}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Override Book Font')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideFont}
|
||||
onChange={() => setOverrideFont(!overrideFont)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import clsx from 'clsx';
|
||||
import i18n from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { getTranslators } from '@/services/translators';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import DropDown from './DropDown';
|
||||
|
||||
const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
|
||||
const [translationProvider, setTranslationProvider] = useState(viewSettings.translationProvider!);
|
||||
const [translateTargetLang, setTranslateTargetLang] = useState(viewSettings.translateTargetLang!);
|
||||
|
||||
const getCurrentUILangOption = () => {
|
||||
const uiLanguage = viewSettings.uiLanguage;
|
||||
return {
|
||||
option: uiLanguage,
|
||||
label:
|
||||
uiLanguage === ''
|
||||
? _('Auto')
|
||||
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
|
||||
};
|
||||
};
|
||||
|
||||
const getLangOptions = () => {
|
||||
const langs = TRANSLATED_LANGS as Record<string, string>;
|
||||
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
|
||||
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
options.unshift({ option: '', label: _('System Language') });
|
||||
return options;
|
||||
};
|
||||
|
||||
const handleSelectUILang = (option: string) => {
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
|
||||
i18n.changeLanguage(option ? option : navigator.language);
|
||||
};
|
||||
|
||||
const getTranslationProviderOptions = () => {
|
||||
const translators = getTranslators();
|
||||
const availableProviders = translators.map((t) => {
|
||||
return { option: t.name, label: t.label };
|
||||
});
|
||||
return availableProviders;
|
||||
};
|
||||
|
||||
const getCurrentTranslationProviderOption = () => {
|
||||
const option = translationProvider;
|
||||
const availableProviders = getTranslationProviderOptions();
|
||||
return availableProviders.find((p) => p.option === option) || availableProviders[0]!;
|
||||
};
|
||||
|
||||
const handleSelectTranslationProvider = (option: string) => {
|
||||
setTranslationProvider(option);
|
||||
saveViewSettings(envConfig, bookKey, 'translationProvider', option, false, false);
|
||||
viewSettings.translationProvider = option;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
};
|
||||
|
||||
const getCurrentTargetLangOption = () => {
|
||||
const option = translateTargetLang;
|
||||
const availableOptions = getLangOptions();
|
||||
return availableOptions.find((o) => o.option === option) || availableOptions[0]!;
|
||||
};
|
||||
|
||||
const handleSelectTargetLang = (option: string) => {
|
||||
setTranslateTargetLang(option);
|
||||
saveViewSettings(envConfig, bookKey, 'translateTargetLang', option, false, false);
|
||||
viewSettings.translateTargetLang = option;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (translationEnabled === viewSettings.translationEnabled) return;
|
||||
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
|
||||
viewSettings.translationEnabled = translationEnabled;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [translationEnabled]);
|
||||
|
||||
return (
|
||||
<div className={clsx('my-4 w-full space-y-6')}>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Interface Language')}</span>
|
||||
<DropDown
|
||||
options={getLangOptions()}
|
||||
selected={getCurrentUILangOption()}
|
||||
onSelect={handleSelectUILang}
|
||||
className='dropdown-bottom'
|
||||
listClassName='!max-h-60'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Translation')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Enable Translation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={translationEnabled}
|
||||
onChange={() => setTranslationEnabled(!translationEnabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Translation Service')}</span>
|
||||
<DropDown
|
||||
selected={getCurrentTranslationProviderOption()}
|
||||
options={getTranslationProviderOptions()}
|
||||
onSelect={handleSelectTranslationProvider}
|
||||
disabled={!translationEnabled}
|
||||
className='dropdown-top'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Translate To')}</span>
|
||||
<DropDown
|
||||
options={getLangOptions()}
|
||||
selected={getCurrentTargetLangOption()}
|
||||
onSelect={handleSelectTargetLang}
|
||||
disabled={!translationEnabled}
|
||||
className='dropdown-top'
|
||||
listClassName='!max-h-60'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LangPanel;
|
||||
@@ -45,6 +45,8 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [showHeader, setShowHeader] = useState(viewSettings.showHeader!);
|
||||
const [showFooter, setShowFooter] = useState(viewSettings.showFooter!);
|
||||
const [showBarsOnScroll, setShowBarsOnScroll] = useState(viewSettings.showBarsOnScroll!);
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(viewSettings.showRemainingTime!);
|
||||
const [showPageNumber, setShowPageNumber] = useState(viewSettings.showPageNumber!);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
|
||||
@@ -162,6 +164,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}, [writingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideLayout === viewSettings.overrideLayout) return;
|
||||
saveViewSettings(envConfig, bookKey, 'overrideLayout', overrideLayout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideLayout]);
|
||||
@@ -190,6 +193,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showBarsOnScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showRemainingTime', showRemainingTime, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showRemainingTime]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showPageNumber', showPageNumber, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showPageNumber]);
|
||||
|
||||
const applyMarginAndGap = () => {
|
||||
const isCompact = !showHeader && !showFooter;
|
||||
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
|
||||
@@ -246,46 +259,53 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Layout')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideLayout}
|
||||
onChange={() => setOverrideLayout(!overrideLayout)}
|
||||
/>
|
||||
</div>
|
||||
{mightBeRTLBook && (
|
||||
<div className='w-full'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Writing Mode')}</h2>
|
||||
<div className='flex gap-4'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('auto')}
|
||||
>
|
||||
<MdOutlineAutoMode />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Writing Mode')}</h2>
|
||||
<div className='flex gap-4'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('auto')}
|
||||
>
|
||||
<MdOutlineAutoMode />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-tb')}
|
||||
>
|
||||
<MdOutlineTextRotationNone />
|
||||
</button>
|
||||
</div>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-tb')}
|
||||
>
|
||||
<MdOutlineTextRotationNone />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('vertical-rl')}
|
||||
>
|
||||
<MdTextRotateVertical />
|
||||
</button>
|
||||
</div>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('vertical-rl')}
|
||||
>
|
||||
<MdTextRotateVertical />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('RTL Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-rl')}
|
||||
>
|
||||
<TbTextDirectionRtl />
|
||||
</button>
|
||||
</div>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('RTL Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-rl')}
|
||||
>
|
||||
<TbTextDirectionRtl />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -389,15 +409,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setHyphenation(!hyphenation)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Override Book Layout')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideLayout}
|
||||
onChange={() => setOverrideLayout(!overrideLayout)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,6 +487,26 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setShowFooter(!showFooter)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Remaining Time')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showRemainingTime}
|
||||
disabled={!showFooter}
|
||||
onChange={() => setShowRemainingTime(!showRemainingTime)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Page Number')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showPageNumber}
|
||||
disabled={!showFooter}
|
||||
onChange={() => setShowPageNumber(!showPageNumber)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Apply also in Scrolled Mode')}</span>
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import i18n from 'i18next';
|
||||
import { IoPhoneLandscapeOutline, IoPhonePortraitOutline } from 'react-icons/io5';
|
||||
import { MdOutlineScreenRotation } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -10,10 +9,8 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import cssbeautify from 'cssbeautify';
|
||||
import cssValidate from '@/utils/css';
|
||||
import DropDown from './DropDown';
|
||||
|
||||
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
@@ -22,12 +19,11 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
|
||||
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
|
||||
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
@@ -97,40 +93,6 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentUILangOption = () => {
|
||||
const uiLanguage = viewSettings.uiLanguage;
|
||||
return {
|
||||
option: uiLanguage,
|
||||
label:
|
||||
uiLanguage === ''
|
||||
? _('Auto')
|
||||
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
|
||||
};
|
||||
};
|
||||
|
||||
const getUILangOptions = () => {
|
||||
const langs = TRANSLATED_LANGS as Record<string, string>;
|
||||
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
|
||||
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
options.unshift({ option: '', label: _('Auto') });
|
||||
return options;
|
||||
};
|
||||
|
||||
const handleSelectUILang = (option: string) => {
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
|
||||
i18n.changeLanguage(option ? option : navigator.language);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
|
||||
if (animated) {
|
||||
getView(bookKey)?.renderer.setAttribute('animated', '');
|
||||
} else {
|
||||
getView(bookKey)?.renderer.removeAttribute('animated');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'screenOrientation', screenOrientation, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
@@ -146,39 +108,6 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
inputFocusInAndroid && 'h-[50%] overflow-y-auto pb-[200px]',
|
||||
)}
|
||||
>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Language')}</span>
|
||||
<DropDown
|
||||
selected={getCurrentUILangOption()}
|
||||
options={getUILangOptions()}
|
||||
onSelect={handleSelectUILang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Paging Animation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={animated}
|
||||
onChange={() => setAnimated(!animated)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appService?.isMobileApp && (
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Screen')}</h2>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { RiFontSize } from 'react-icons/ri';
|
||||
import { RiDashboardLine } from 'react-icons/ri';
|
||||
import { RiDashboardLine, RiTranslate } from 'react-icons/ri';
|
||||
import { VscSymbolColor } from 'react-icons/vsc';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { LiaHandPointerSolid } from 'react-icons/lia';
|
||||
@@ -19,9 +19,10 @@ import Dropdown from '@/components/Dropdown';
|
||||
import Dialog from '@/components/Dialog';
|
||||
import DialogMenu from './DialogMenu';
|
||||
import ControlPanel from './ControlPanel';
|
||||
import LangPanel from './LangPanel';
|
||||
import MiscPanel from './MiscPanel';
|
||||
|
||||
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Control' | 'Misc';
|
||||
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Control' | 'Language' | 'Custom';
|
||||
|
||||
type TabConfig = {
|
||||
tab: SettingsPanelType;
|
||||
@@ -34,10 +35,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
const { appService } = useEnv();
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showTabLabels, setShowTabLabels] = useState(false);
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>(
|
||||
(localStorage.getItem('lastConfigPanel') || 'Font') as SettingsPanelType,
|
||||
);
|
||||
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
|
||||
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const tabConfig = [
|
||||
@@ -62,12 +60,25 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
label: _('Behavior'),
|
||||
},
|
||||
{
|
||||
tab: 'Misc',
|
||||
tab: 'Language',
|
||||
icon: RiTranslate,
|
||||
label: _('Language'),
|
||||
},
|
||||
{
|
||||
tab: 'Custom',
|
||||
icon: IoAccessibilityOutline,
|
||||
label: _('Misc'),
|
||||
label: _('Custom'),
|
||||
},
|
||||
] as TabConfig[];
|
||||
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>(() => {
|
||||
const lastPanel = localStorage.getItem('lastConfigPanel');
|
||||
if (lastPanel && tabConfig.some((tab) => tab.tab === lastPanel)) {
|
||||
return lastPanel as SettingsPanelType;
|
||||
}
|
||||
return 'Font' as SettingsPanelType;
|
||||
});
|
||||
|
||||
const handleSetActivePanel = (tab: SettingsPanelType) => {
|
||||
setActivePanel(tab);
|
||||
localStorage.setItem('lastConfigPanel', tab);
|
||||
@@ -82,7 +93,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
if (!container) return;
|
||||
|
||||
const checkButtonWidths = () => {
|
||||
const threshold = (container.clientWidth - 64) * 0.25;
|
||||
const threshold = (container.clientWidth - 64) * 0.22;
|
||||
const hideLabel = Array.from(container.querySelectorAll('button')).some((button) => {
|
||||
const labelSpan = button.querySelector('span');
|
||||
const labelText = labelSpan?.textContent || '';
|
||||
@@ -100,7 +111,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
document.body.removeChild(clone);
|
||||
return fullWidth > threshold;
|
||||
});
|
||||
setShowTabLabels(!hideLabel);
|
||||
setShowAllTabLabels(!hideLabel);
|
||||
};
|
||||
|
||||
checkButtonWidths();
|
||||
@@ -120,15 +131,18 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
|
||||
snapHeight={appService?.isMobile ? 0.7 : undefined}
|
||||
header={
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
|
||||
snapHeight={appService?.isMobile ? 0.7 : undefined}
|
||||
header={
|
||||
<div className='flex w-full flex-col items-center'>
|
||||
<div className='tab-title flex pb-2 text-base font-semibold sm:hidden'>
|
||||
{tabConfig.find((tab) => tab.tab === activePanel)?.label || ''}
|
||||
</div>
|
||||
<div className='flex w-full flex-row items-center justify-between'>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
onClick={handleClose}
|
||||
@@ -140,23 +154,27 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
</button>
|
||||
<div
|
||||
ref={tabsRef}
|
||||
className={clsx(
|
||||
'dialog-tabs flex h-10 w-full items-center',
|
||||
showTabLabels ? 'gap-4 sm:gap-2' : 'gap-4',
|
||||
)}
|
||||
className={clsx('dialog-tabs ms-1 flex h-10 w-full items-center gap-1 sm:ms-0')}
|
||||
>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
data-tab={tab}
|
||||
className={clsx(
|
||||
'btn btn-ghost text-base-content btn-sm px-1',
|
||||
'btn btn-ghost text-base-content btn-sm gap-1 px-2',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => handleSetActivePanel(tab)}
|
||||
>
|
||||
<Icon className='mr-0' />
|
||||
<span className={clsx('label', !showTabLabels && 'hidden')}>{label}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
window.innerWidth < 640 && 'hidden',
|
||||
!(showAllTabLabels || activePanel === tab) && 'hidden',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -188,15 +206,16 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activePanel === 'Font' && <FontPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Layout' && <LayoutPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Color' && <ColorPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Control' && <ControlPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Misc' && <MiscPanel bookKey={bookKey} />}
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activePanel === 'Font' && <FontPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Layout' && <LayoutPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Color' && <ColorPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Control' && <ControlPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Language' && <LangPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Custom' && <MiscPanel bookKey={bookKey} />}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -11,6 +12,7 @@ import { isWebAppPlatform } from '@/services/environment';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface BookMenuProps {
|
||||
menuClassName?: string;
|
||||
@@ -19,9 +21,13 @@ interface BookMenuProps {
|
||||
|
||||
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getVisibleLibrary } = useLibraryStore();
|
||||
const { openParallelView } = useBooksManager();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const viewSettings = getViewSettings(sideBarBookKey!);
|
||||
|
||||
const [isSortedTOC, setIsSortedTOC] = React.useState(viewSettings?.sortedTOC || false);
|
||||
|
||||
const handleParallelView = (id: string) => {
|
||||
openParallelView(id);
|
||||
@@ -43,18 +49,25 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handleToggleSortTOC = () => {
|
||||
setIsSortedTOC((prev) => !prev);
|
||||
setIsDropdownOpen?.(false);
|
||||
if (sideBarBookKey) {
|
||||
const viewSettings = getViewSettings(sideBarBookKey)!;
|
||||
viewSettings.sortedTOC = !isSortedTOC;
|
||||
setViewSettings(sideBarBookKey, viewSettings);
|
||||
}
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
};
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'book-menu dropdown-content border-base-100 z-20 w-60 shadow-2xl',
|
||||
menuClassName,
|
||||
)}
|
||||
className={clsx('book-menu dropdown-content border-base-100 z-20 shadow-2xl', menuClassName)}
|
||||
>
|
||||
<MenuItem label={_('Parallel Read')} noIcon>
|
||||
<MenuItem label={_('Parallel Read')}>
|
||||
<ul className='max-h-60 overflow-y-auto'>
|
||||
{getVisibleLibrary()
|
||||
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
|
||||
@@ -81,11 +94,16 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
))}
|
||||
</ul>
|
||||
</MenuItem>
|
||||
<MenuItem label={_('Export Annotations')} noIcon onClick={handleExportAnnotations} />
|
||||
<MenuItem label={_('Reload Page')} noIcon shortcut='Shift+R' onClick={handleReloadPage} />
|
||||
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
|
||||
<MenuItem
|
||||
label={_('Sort TOC by Page')}
|
||||
Icon={isSortedTOC ? MdCheck : undefined}
|
||||
onClick={handleToggleSortTOC}
|
||||
/>
|
||||
<MenuItem label={_('Reload Page')} shortcut='Shift+R' onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} noIcon onClick={downloadReadest} />}
|
||||
<MenuItem label={_('About Readest')} noIcon onClick={showAboutReadest} />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -8,8 +9,9 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import useScrollToItem from '../../hooks/useScrollToItem';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import useScrollToItem from '../../hooks/useScrollToItem';
|
||||
|
||||
interface BooknoteItemProps {
|
||||
bookKey: string;
|
||||
@@ -23,6 +25,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getProgress, getView, getViewsById } = useReaderStore();
|
||||
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
|
||||
const separatorWidth = useResponsiveSize(3);
|
||||
|
||||
const { text, cfi, note } = item;
|
||||
const progress = getProgress(bookKey);
|
||||
@@ -82,15 +85,22 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
}
|
||||
>
|
||||
{item.note && (
|
||||
<span className='content font-size-sm font-normal' dir='auto'>
|
||||
{item.note}
|
||||
</span>
|
||||
<div
|
||||
className='content prose prose-sm font-size-sm'
|
||||
dir='auto'
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.note) }}
|
||||
></div>
|
||||
)}
|
||||
<div className='flex items-start'>
|
||||
{item.note && (
|
||||
<div className='my-1 me-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
|
||||
<div
|
||||
className='me-2 mt-2.5 min-h-full self-stretch rounded-xl bg-gray-300'
|
||||
style={{
|
||||
minWidth: `${separatorWidth}px`,
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'my-2')}>
|
||||
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'mt-2')}>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { findParentPath } from '@/utils/toc';
|
||||
import { getContentMd5 } from '@/utils/misc';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { BookProgress } from '@/types/book';
|
||||
import { useTextTranslation } from '../../hooks/useTextTranslation';
|
||||
|
||||
const createExpanderIcon = (isExpanded: boolean) => {
|
||||
return (
|
||||
@@ -129,6 +130,8 @@ const TOCView: React.FC<{
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
const viewRef = useRef<HTMLUListElement | null>(null);
|
||||
|
||||
useTextTranslation(bookKey, viewRef.current);
|
||||
|
||||
const expandParents = (toc: TOCItem[], href: string) => {
|
||||
const parentPath = findParentPath(toc, href).map((item) => item.href);
|
||||
setExpandedItems(parentPath.filter(Boolean) as string[]);
|
||||
|
||||
@@ -5,13 +5,14 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { TTSController, SILENCE_DATA } from '@/services/tts';
|
||||
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
|
||||
import { getPopupPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { parseSSMLLang } from '@/utils/ssml';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { invokeUseBackgroundAudio } from '@/utils/bridge';
|
||||
import { CFI } from '@/libs/document';
|
||||
import Popup from '@/components/Popup';
|
||||
import TTSPanel from './TTSPanel';
|
||||
import TTSIcon from './TTSIcon';
|
||||
@@ -24,7 +25,8 @@ const TTSControl = () => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { setViewSettings, setTTSEnabled } = useReaderStore();
|
||||
const [bookKey, setBookKey] = useState<string>('');
|
||||
const [ttsLang, setTtsLang] = useState<string>('en');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -45,8 +47,10 @@ const TTSControl = () => {
|
||||
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const [ttsController, setTtsController] = useState<TTSController | null>(null);
|
||||
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
|
||||
|
||||
// this enables WebAudio to play even when the mute toggle switch is ON
|
||||
const unblockAudio = () => {
|
||||
@@ -95,12 +99,58 @@ const TTSControl = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ttsController || !bookKey) return;
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
|
||||
const handleSpeakMark = (e: Event) => {
|
||||
const progress = getProgress(bookKey);
|
||||
const { sectionLabel } = progress || {};
|
||||
const mark = (e as CustomEvent<TTSMark>).detail;
|
||||
if (appService?.isMobileApp && 'mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: mark.text,
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleHighlightMark = (e: Event) => {
|
||||
const range = (e as CustomEvent<Range>).detail;
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (!range || !view || !progress || !viewSettings) return;
|
||||
|
||||
const { location } = progress;
|
||||
const { index } = view.resolveCFI(location);
|
||||
const cfi = view?.getCFI(index, range);
|
||||
if (cfi) {
|
||||
viewSettings.ttsLocation = cfi || '';
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
};
|
||||
|
||||
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
return () => {
|
||||
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ttsController, bookKey]);
|
||||
|
||||
const handleTTSSpeak = async (event: CustomEvent) => {
|
||||
const { bookKey, range } = event.detail;
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!view || !viewSettings || !bookData || !bookData.book) return;
|
||||
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
|
||||
if (bookData.book?.format === 'PDF') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for PDF'),
|
||||
@@ -109,6 +159,21 @@ const TTSControl = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let ttsFromRange = range || progress.range;
|
||||
if (viewSettings.ttsLocation) {
|
||||
const { location } = progress;
|
||||
const ttsCfi = viewSettings.ttsLocation;
|
||||
const start = CFI.collapse(location);
|
||||
const end = CFI.collapse(location, true);
|
||||
if (CFI.compare(start, ttsCfi) * CFI.compare(end, ttsCfi) <= 0) {
|
||||
const { index, anchor } = view.resolveCFI(ttsCfi);
|
||||
const { doc } = view.renderer.getContents().find((x) => (x.index = index)) || {};
|
||||
if (doc) {
|
||||
ttsFromRange = anchor(doc) || ttsFromRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const primaryLang = bookData.book.primaryLanguage;
|
||||
setBookKey(bookKey);
|
||||
|
||||
@@ -116,6 +181,8 @@ const TTSControl = () => {
|
||||
ttsControllerRef.current.stop();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
|
||||
setTTSEnabled(bookKey, true);
|
||||
setShowIndicator(true);
|
||||
|
||||
try {
|
||||
@@ -125,10 +192,11 @@ const TTSControl = () => {
|
||||
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
|
||||
unblockAudio();
|
||||
}
|
||||
setTtsClientsInitialized(false);
|
||||
const ttsController = new TTSController(view);
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS();
|
||||
const ssml = view.tts?.from(range);
|
||||
const ssml = view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
let lang = parseSSMLLang(ssml) || 'en';
|
||||
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
|
||||
@@ -140,10 +208,11 @@ const TTSControl = () => {
|
||||
|
||||
ttsController.setLang(lang);
|
||||
ttsController.setRate(viewSettings.ttsRate);
|
||||
ttsController.setVoice(viewSettings.ttsVoice);
|
||||
ttsController.speak(ssml);
|
||||
ttsControllerRef.current = ttsController;
|
||||
setTtsController(ttsController);
|
||||
}
|
||||
setTtsClientsInitialized(true);
|
||||
} catch (error) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported in this device'),
|
||||
@@ -153,9 +222,10 @@ const TTSControl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTTSStop = async () => {
|
||||
const handleTTSStop = async (event: CustomEvent) => {
|
||||
const { bookKey } = event.detail;
|
||||
if (ttsControllerRef.current) {
|
||||
handleStop();
|
||||
handleStop(bookKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,11 +268,12 @@ const TTSControl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
const handleStop = async (bookKey: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.stop();
|
||||
ttsControllerRef.current = null;
|
||||
setTtsController(null);
|
||||
getView(bookKey)?.deselect();
|
||||
setIsPlaying(false);
|
||||
setShowPanel(false);
|
||||
@@ -214,6 +285,7 @@ const TTSControl = () => {
|
||||
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
|
||||
releaseUnblockAudio();
|
||||
}
|
||||
setTTSEnabled(bookKey, false);
|
||||
};
|
||||
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
@@ -236,15 +308,15 @@ const TTSControl = () => {
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetVoice = useCallback(
|
||||
throttle(async (voice: string) => {
|
||||
throttle(async (voice: string, lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setVoice(voice);
|
||||
await ttsController.setVoice(voice, lang);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice);
|
||||
await ttsController.setVoice(voice, lang);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
@@ -267,7 +339,7 @@ const TTSControl = () => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSelectTimeout = (value: number) => {
|
||||
const handleSelectTimeout = (bookKey: string, value: number) => {
|
||||
setTimeoutOption(value);
|
||||
if (timeoutFunc) {
|
||||
clearTimeout(timeoutFunc);
|
||||
@@ -275,7 +347,7 @@ const TTSControl = () => {
|
||||
if (value > 0) {
|
||||
setTimeoutFunc(
|
||||
setTimeout(() => {
|
||||
handleStop();
|
||||
handleStop(bookKey);
|
||||
}, value * 1000),
|
||||
);
|
||||
setTimeoutTimestamp(Date.now() + value * 1000);
|
||||
@@ -311,6 +383,10 @@ const TTSControl = () => {
|
||||
|
||||
const togglePopup = () => {
|
||||
updatePanelPosition();
|
||||
if (!showPanel && ttsControllerRef.current) {
|
||||
const speakingLang = ttsControllerRef.current.getSpeakingLang() || ttsLang;
|
||||
setTtsLang(speakingLang);
|
||||
}
|
||||
setShowPanel((prev) => !prev);
|
||||
};
|
||||
|
||||
@@ -353,10 +429,10 @@ const TTSControl = () => {
|
||||
: 'bottom-[70px] sm:bottom-14',
|
||||
)}
|
||||
>
|
||||
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
|
||||
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
|
||||
</div>
|
||||
)}
|
||||
{showPanel && panelPosition && trianglePosition && (
|
||||
{showPanel && panelPosition && trianglePosition && ttsClientsInited && (
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
type TTSIconProps = {
|
||||
isPlaying: boolean;
|
||||
ttsInited: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, onClick }) => {
|
||||
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
|
||||
const bars = [1, 2, 3, 4];
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full cursor-pointer' onClick={onClick}>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative h-full w-full',
|
||||
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
|
||||
<div
|
||||
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
|
||||
style={{
|
||||
animation: isPlaying ? 'moveGradient 2s alternate infinite' : 'none',
|
||||
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,9 @@ type TTSPanelProps = {
|
||||
onForward: () => void;
|
||||
onSetRate: (rate: number) => void;
|
||||
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
|
||||
onSetVoice: (voice: string) => void;
|
||||
onSetVoice: (voice: string, lang: string) => void;
|
||||
onGetVoiceId: () => string;
|
||||
onSelectTimeout: (value: number) => void;
|
||||
onSelectTimeout: (bookKey: string, value: number) => void;
|
||||
};
|
||||
|
||||
const getTTSTimeoutOptions = (_: TranslationFunc) => {
|
||||
@@ -144,8 +144,8 @@ const TTSPanel = ({
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
const handleSelectVoice = (voice: string) => {
|
||||
onSetVoice(voice);
|
||||
const handleSelectVoice = (voice: string, lang: string) => {
|
||||
onSetVoice(voice, lang);
|
||||
setSelectedVoice(voice);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.ttsVoice = voice;
|
||||
@@ -155,7 +155,7 @@ const TTSPanel = ({
|
||||
const updateTimeout = (timeout: number) => {
|
||||
const now = Date.now();
|
||||
if (timeout > 0 && timeout < now) {
|
||||
onSelectTimeout(0);
|
||||
onSelectTimeout(bookKey, 0);
|
||||
setTimeoutCountdown('');
|
||||
} else if (timeout > 0) {
|
||||
setTimeoutCountdown(getCountdownTime(timeout));
|
||||
@@ -256,7 +256,10 @@ const TTSPanel = ({
|
||||
)}
|
||||
>
|
||||
{timeoutOptions.map((option, index) => (
|
||||
<li key={`${index}-${option.value}`} onClick={() => onSelectTimeout(option.value)}>
|
||||
<li
|
||||
key={`${index}-${option.value}`}
|
||||
onClick={() => onSelectTimeout(bookKey, option.value)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
|
||||
@@ -282,7 +285,7 @@ const TTSPanel = ({
|
||||
{voices.map((voice, index) => (
|
||||
<li
|
||||
key={`${index}-${voice.id}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id)}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { ScrollSource } from './usePagination';
|
||||
|
||||
export const useMouseEvent = (
|
||||
@@ -9,27 +9,23 @@ export const useMouseEvent = (
|
||||
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
|
||||
) => {
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
const throttledScroll = throttle(handleContinuousScroll, 500, {
|
||||
emitLast: false,
|
||||
});
|
||||
const throttledFlip = throttle(handlePageFlip, 1000, {
|
||||
emitLast: false,
|
||||
});
|
||||
const debounceScroll = debounce(handleContinuousScroll, 500);
|
||||
const debounceFlip = debounce(handlePageFlip, 100);
|
||||
const handleMouseEvent = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
if (msg.data.type === 'iframe-wheel') {
|
||||
throttledScroll('mouse', -msg.data.deltaY, 0);
|
||||
debounceScroll('mouse', -msg.data.deltaY, 0);
|
||||
}
|
||||
if (msg.data.type === 'iframe-wheel') {
|
||||
throttledFlip(msg);
|
||||
debounceFlip(msg);
|
||||
} else {
|
||||
handlePageFlip(msg);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'wheel') {
|
||||
const event = msg as React.WheelEvent<HTMLDivElement>;
|
||||
throttledScroll('mouse', -event.deltaY, 0);
|
||||
debounceScroll('mouse', -event.deltaY, 0);
|
||||
} else {
|
||||
handlePageFlip(msg);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { BookConfig } from '@/types/book';
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { serializeConfig } from '@/utils/serializer';
|
||||
import { CFI } from '@/libs/document';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
@@ -21,9 +22,9 @@ export const useProgressSync = (bookKey: string) => {
|
||||
const view = getView(bookKey);
|
||||
const config = getConfig(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
// flag to prevent accidental sync without first pulling the config
|
||||
const configSynced = useRef(false);
|
||||
const firstPulled = useRef(false);
|
||||
|
||||
const configPulled = useRef(false);
|
||||
const hasPulledConfigOnce = useRef(false);
|
||||
|
||||
const pushConfig = (bookKey: string, config: BookConfig | null) => {
|
||||
if (!config || !user) return;
|
||||
@@ -41,9 +42,10 @@ export const useProgressSync = (bookKey: string) => {
|
||||
syncConfigs([], bookHash, 'pull');
|
||||
};
|
||||
const syncConfig = () => {
|
||||
if (!configSynced.current) {
|
||||
if (!configPulled.current) {
|
||||
pullConfig(bookKey);
|
||||
} else {
|
||||
const config = getConfig(bookKey);
|
||||
if (config && config.progress && config.progress[0] > 0) {
|
||||
pushConfig(bookKey, config);
|
||||
}
|
||||
@@ -57,6 +59,7 @@ export const useProgressSync = (bookKey: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Push: ad-hoc push when the book is closed
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('sync-book-progress', handleSyncBookProgress);
|
||||
return () => {
|
||||
@@ -65,45 +68,33 @@ export const useProgressSync = (bookKey: string) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!progress || firstPulled.current) return;
|
||||
firstPulled.current = true;
|
||||
pullConfig(bookKey);
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedAutoSync = useCallback(
|
||||
debounce(() => {
|
||||
syncConfig();
|
||||
};
|
||||
}, SYNC_PROGRESS_INTERVAL_SEC * 1000),
|
||||
[],
|
||||
);
|
||||
|
||||
// Push: auto-push progress when progress changes with a debounce
|
||||
useEffect(() => {
|
||||
if (!progress?.location || !user) return;
|
||||
debouncedAutoSync();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
const lastProgressSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Pull: pull progress once when the book is opened
|
||||
useEffect(() => {
|
||||
if (!config?.location || !user) return;
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastProgressSyncTime.current;
|
||||
if (timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
|
||||
lastProgressSyncTime.current = now;
|
||||
syncConfig();
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastProgressSyncTime.current = Date.now();
|
||||
syncTimeoutRef.current = null;
|
||||
syncConfig();
|
||||
},
|
||||
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
if (!progress || hasPulledConfigOnce.current) return;
|
||||
hasPulledConfigOnce.current = true;
|
||||
pullConfig(bookKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
}, [progress]);
|
||||
|
||||
// sync progress once when the book is opened
|
||||
// Pull: proccess the pulled progress
|
||||
useEffect(() => {
|
||||
if (!configSynced.current && syncedConfigs) {
|
||||
configSynced.current = true;
|
||||
if (!configPulled.current && syncedConfigs) {
|
||||
configPulled.current = true;
|
||||
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
|
||||
if (syncedConfig) {
|
||||
const configCFI = config?.location;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
@@ -11,10 +12,10 @@ export const useTextSelector = (
|
||||
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>,
|
||||
handleDismissPopup: () => void,
|
||||
) => {
|
||||
const { appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const primaryLang = bookData.book?.primaryLanguage || 'en';
|
||||
const osPlatform = getOSPlatform();
|
||||
@@ -22,7 +23,9 @@ export const useTextSelector = (
|
||||
const isPopuped = useRef(false);
|
||||
const isUpToPopup = useRef(false);
|
||||
const isTextSelected = useRef(false);
|
||||
const isTouchStarted = useRef(false);
|
||||
const selectionPosition = useRef<number | null>(null);
|
||||
const [textSelected, setTextSelected] = useState(false);
|
||||
|
||||
const isValidSelection = (sel: Selection) => {
|
||||
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
|
||||
@@ -41,6 +44,7 @@ export const useTextSelector = (
|
||||
};
|
||||
const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => {
|
||||
isTextSelected.current = true;
|
||||
setTextSelected(true);
|
||||
const range = sel.getRangeAt(0);
|
||||
if (rebuildRange) {
|
||||
sel.removeAllRanges();
|
||||
@@ -51,6 +55,7 @@ export const useTextSelector = (
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
const makeSelectionOnIOS = async (sel: Selection, index: number) => {
|
||||
isTextSelected.current = true;
|
||||
setTextSelected(true);
|
||||
const range = sel.getRangeAt(0);
|
||||
setTimeout(() => {
|
||||
sel.removeAllRanges();
|
||||
@@ -70,6 +75,7 @@ export const useTextSelector = (
|
||||
if (!isUpToPopup.current) {
|
||||
handleDismissPopup();
|
||||
isTextSelected.current = false;
|
||||
setTextSelected(false);
|
||||
}
|
||||
if (isPopuped.current) {
|
||||
isUpToPopup.current = false;
|
||||
@@ -79,7 +85,8 @@ export const useTextSelector = (
|
||||
|
||||
// On Android no proper events are fired to notify selection done,
|
||||
// we make the popup show when the selection is changed
|
||||
if (osPlatform === 'android') {
|
||||
// note that selection may be initiated by a tts speak
|
||||
if (isTouchStarted.current && osPlatform === 'android') {
|
||||
makeSelection(sel, index, false);
|
||||
}
|
||||
isUpToPopup.current = true;
|
||||
@@ -96,11 +103,24 @@ export const useTextSelector = (
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleTouchStart = () => {
|
||||
isTouchStarted.current = true;
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
isTouchStarted.current = false;
|
||||
};
|
||||
const handleScroll = () => {
|
||||
// Prevent the container from scrolling when text is selected in paginated mode
|
||||
// This is a workaround for the issue #873
|
||||
// FIXME: this is a workaround for issue #873
|
||||
// TODO: support text selection across pages
|
||||
if (!viewSettings?.scrolled && view?.renderer?.containerPosition && selectionPosition.current) {
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (
|
||||
appService?.isAndroidApp &&
|
||||
!viewSettings?.scrolled &&
|
||||
view?.renderer?.containerPosition &&
|
||||
selectionPosition.current
|
||||
) {
|
||||
console.warn('Keep container position', selectionPosition.current);
|
||||
view.renderer.containerPosition = selectionPosition.current;
|
||||
}
|
||||
};
|
||||
@@ -128,7 +148,7 @@ export const useTextSelector = (
|
||||
selectionPosition.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isTextSelected.current]);
|
||||
}, [textSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSingleClick = (): boolean => {
|
||||
@@ -139,6 +159,7 @@ export const useTextSelector = (
|
||||
if (isTextSelected.current) {
|
||||
handleDismissPopup();
|
||||
isTextSelected.current = false;
|
||||
setTextSelected(false);
|
||||
view?.deselect();
|
||||
return true;
|
||||
}
|
||||
@@ -158,6 +179,8 @@ export const useTextSelector = (
|
||||
|
||||
return {
|
||||
handleScroll,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
handlePointerup,
|
||||
handleSelectionchange,
|
||||
handleShowPopup,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { UseTranslatorOptions } from '@/services/translators';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslator } from '@/hooks/useTranslator';
|
||||
import { walkTextNodes } from '@/utils/walk';
|
||||
import { localeToLang } from '@/utils/lang';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
|
||||
export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElement | null) {
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
const enabled = useRef(viewSettings?.translationEnabled);
|
||||
const [provider, setProvider] = useState(viewSettings?.translationProvider);
|
||||
const [targetLang, setTargetLang] = useState(viewSettings?.translateTargetLang);
|
||||
|
||||
const { translate } = useTranslator({
|
||||
provider,
|
||||
targetLang: localeToLang(targetLang || getLocale()),
|
||||
} as UseTranslatorOptions);
|
||||
|
||||
const translateRef = useRef(translate);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const translatedElements = useRef<HTMLElement[]>([]);
|
||||
const allTextNodes = useRef<HTMLElement[]>([]);
|
||||
|
||||
const toggleTranslationVisibility = (visible: boolean) => {
|
||||
translatedElements.current.forEach((element) => {
|
||||
const translationTargets = element.querySelectorAll('.translation-target');
|
||||
translationTargets.forEach((target) => {
|
||||
if (visible) {
|
||||
target.classList.remove('hidden');
|
||||
} else {
|
||||
target.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
translateRef.current = translate;
|
||||
}, [translate]);
|
||||
|
||||
const observeTextNodes = () => {
|
||||
if (!view || !enabled.current) return;
|
||||
const observer = createTranslationObserver();
|
||||
observerRef.current = observer;
|
||||
const nodes = walkTextNodes(view);
|
||||
console.log('Observing text nodes for translation:', nodes.length);
|
||||
allTextNodes.current = nodes;
|
||||
nodes.forEach((el) => observer.observe(el));
|
||||
};
|
||||
|
||||
const updateTranslation = () => {
|
||||
translatedElements.current.forEach((element) => {
|
||||
const translationTargets = element.querySelectorAll('.translation-target');
|
||||
translationTargets.forEach((target) => target.remove());
|
||||
});
|
||||
|
||||
translatedElements.current = [];
|
||||
if (viewSettings?.translationEnabled && view) {
|
||||
recreateTranslationObserver();
|
||||
}
|
||||
};
|
||||
|
||||
const createTranslationObserver = () => {
|
||||
return new IntersectionObserver(
|
||||
(entries) => {
|
||||
let beforeIntersectedElement: HTMLElement | null = null;
|
||||
let lastIntersectedElement: HTMLElement | null = null;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
if (!lastIntersectedElement) {
|
||||
beforeIntersectedElement = entry.target as HTMLElement;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const currentElement = entry.target as HTMLElement;
|
||||
translateElement(currentElement);
|
||||
lastIntersectedElement = currentElement;
|
||||
}
|
||||
if (beforeIntersectedElement) {
|
||||
translateElement(beforeIntersectedElement);
|
||||
}
|
||||
if (lastIntersectedElement) {
|
||||
preTranslateNextElements(lastIntersectedElement, 2);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '1280px',
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const preTranslateNextElements = (currentElement: HTMLElement, count: number) => {
|
||||
if (!allTextNodes.current || count <= 0) return;
|
||||
const currentIndex = allTextNodes.current.indexOf(currentElement);
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextElements = allTextNodes.current.slice(currentIndex + 1, currentIndex + 1 + count);
|
||||
nextElements.forEach((element, index) => {
|
||||
setTimeout(() => {
|
||||
translateElement(element);
|
||||
}, index * 500);
|
||||
});
|
||||
};
|
||||
|
||||
const recreateTranslationObserver = () => {
|
||||
const observer = createTranslationObserver();
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = observer;
|
||||
allTextNodes.current.forEach((el) => observer.observe(el));
|
||||
};
|
||||
|
||||
const translateElement = async (el: HTMLElement) => {
|
||||
if (!enabled.current) return;
|
||||
const text = el.textContent?.trim();
|
||||
if (!text) return;
|
||||
|
||||
if (el.querySelector('.translation-target')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const translated = await translateRef.current([text]);
|
||||
const translatedText = translated[0];
|
||||
if (!translatedText || text === translatedText) return;
|
||||
|
||||
const wrapper = document.createElement('font');
|
||||
wrapper.className = `translation-target ${!enabled.current ? 'hidden' : ''}`;
|
||||
wrapper.setAttribute('translation-element-mark', '1');
|
||||
wrapper.setAttribute('lang', targetLang || getLocale());
|
||||
wrapper.appendChild(document.createElement('br'));
|
||||
|
||||
const blockWrapper = document.createElement('font');
|
||||
blockWrapper.className = 'translation-target translation-block-wrapper';
|
||||
|
||||
const inner = document.createElement('font');
|
||||
inner.className = 'translation-target target-inner target-inner-theme-none';
|
||||
inner.textContent = translatedText;
|
||||
|
||||
blockWrapper.appendChild(inner);
|
||||
wrapper.appendChild(blockWrapper);
|
||||
|
||||
if (el.querySelector('.translation-target')) {
|
||||
return;
|
||||
}
|
||||
el.appendChild(wrapper);
|
||||
translatedElements.current.push(el);
|
||||
} catch (err) {
|
||||
console.warn('Translation failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewSettings) return;
|
||||
|
||||
const enabledChanged = enabled.current !== viewSettings.translationEnabled;
|
||||
const providerChanged = provider !== viewSettings.translationProvider;
|
||||
const targetLangChanged = targetLang !== viewSettings.translateTargetLang;
|
||||
|
||||
if (enabledChanged) {
|
||||
enabled.current = viewSettings.translationEnabled;
|
||||
}
|
||||
|
||||
if (providerChanged) {
|
||||
setProvider(viewSettings.translationProvider);
|
||||
}
|
||||
|
||||
if (targetLangChanged) {
|
||||
setTargetLang(viewSettings.translateTargetLang);
|
||||
}
|
||||
|
||||
if (enabledChanged) {
|
||||
toggleTranslationVisibility(viewSettings.translationEnabled);
|
||||
if (enabled.current) {
|
||||
observeTextNodes();
|
||||
}
|
||||
} else if (providerChanged || targetLangChanged) {
|
||||
updateTranslation();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey, viewSettings, provider, targetLang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || !enabled.current) return;
|
||||
|
||||
if ('renderer' in view) {
|
||||
view.addEventListener('load', observeTextNodes);
|
||||
} else {
|
||||
observeTextNodes();
|
||||
}
|
||||
return () => {
|
||||
if ('renderer' in view) {
|
||||
view.removeEventListener('load', observeTextNodes);
|
||||
}
|
||||
observerRef.current?.disconnect();
|
||||
translatedElements.current = [];
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const Alert: React.FC<{
|
||||
'min-w-[70vw] flex-col sm:min-w-[40vw] sm:flex-row',
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center space-x-2 self-start'>
|
||||
<div className='labels flex items-center space-x-2 self-start sm:space-x-4 sm:self-center'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
@@ -34,12 +34,12 @@ const Alert: React.FC<{
|
||||
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
></path>
|
||||
</svg>
|
||||
<div className=''>
|
||||
<h3 className='font-sm text-start text-base sm:text-center'>{title}</h3>
|
||||
<div className='text-xs'>{message}</div>
|
||||
<div className='flex flex-col gap-y-2'>
|
||||
<h3 className='text-start text-sm sm:text-center'>{title}</h3>
|
||||
<div className='text-start text-xs sm:text-center'>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2 self-end sm:max-w-[20vw]'>
|
||||
<div className='buttons flex flex-wrap items-center justify-end gap-2 self-end sm:max-w-[20vw] sm:self-center'>
|
||||
<button className='btn btn-sm btn-neutral' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
|
||||
@@ -153,13 +153,13 @@ const BookDetailModal = ({
|
||||
<div className='mb-4 grid grid-cols-2 gap-4 sm:grid-cols-3'>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Publisher:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatPublisher(bookMeta.publisher || '') || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Published:')}</span>
|
||||
<p className='text-neutral-content max-w-28 text-ellipsis text-sm'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatDate(bookMeta.published) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -179,27 +179,25 @@ const BookDetailModal = ({
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Subjects:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
<p className='text-neutral-content line-clamp-3 text-sm'>
|
||||
{formatSubject(bookMeta.subject) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Format:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
{book.format || _('Unknown')}
|
||||
</p>
|
||||
<p className='text-neutral-content text-sm'>{book.format || _('Unknown')}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('File Size:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatFileSize(fileSize) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-bold'>{_('Description:')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{bookMeta.description || _('No description available')}
|
||||
<p className='text-neutral-content text-sm prose prose-sm'
|
||||
dangerouslySetInnerHTML={{ __html: bookMeta.description || _('No description available') }}>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +213,7 @@ const BookDetailModal = ({
|
||||
>
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
message={_('Are you sure to delete the selected book?')}
|
||||
onCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
}}
|
||||
|
||||
@@ -35,7 +35,7 @@ const Button: React.FC<ButtonProps> = ({
|
||||
className={clsx(
|
||||
'btn btn-ghost h-8 min-h-8 w-8 p-0',
|
||||
appService?.isMobileApp && 'hover:bg-transparent',
|
||||
disabled && 'btn-disabled !bg-transparent',
|
||||
disabled && 'btn-disabled !bg-transparent opacity-50',
|
||||
className,
|
||||
)}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface MenuItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
buttonClass?: string;
|
||||
labelClass?: string;
|
||||
shortcut?: string;
|
||||
@@ -17,6 +18,7 @@ interface MenuItemProps {
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
label,
|
||||
description,
|
||||
buttonClass,
|
||||
labelClass,
|
||||
shortcut,
|
||||
@@ -30,40 +32,52 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
const menuButton = (
|
||||
<button
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-1',
|
||||
'hover:bg-base-300 text-base-content flex w-full flex-col items-center justify-center rounded-md p-1 py-[10px]',
|
||||
disabled && 'btn-disabled text-gray-400',
|
||||
buttonClass,
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex min-w-0 items-center'>
|
||||
{!noIcon && (
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
{typeof Icon === 'function' ? (
|
||||
<Icon className='text-base-content' size={iconSize} />
|
||||
) : (
|
||||
Icon
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex min-w-0 items-center'>
|
||||
{!noIcon && (
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
{typeof Icon === 'function' ? (
|
||||
<Icon className='text-base-content' size={iconSize} />
|
||||
) : (
|
||||
Icon
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm', labelClass)}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{shortcut && (
|
||||
<kbd
|
||||
className={clsx(
|
||||
'border-base-300/40 bg-base-300/75 text-neutral-content hidden rounded-md border shadow-sm sm:flex',
|
||||
'shrink-0 px-1.5 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex w-full'>
|
||||
{description && (
|
||||
<span
|
||||
className='mt-1 truncate text-start text-xs text-gray-500'
|
||||
style={{ minWidth: 0, paddingInlineStart: noIcon ? '0' : `${iconSize + 8}px` }}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm', labelClass)}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{shortcut && (
|
||||
<kbd
|
||||
className={clsx(
|
||||
'border-base-300/40 bg-base-300/75 text-neutral-content hidden rounded-md border shadow-sm sm:flex',
|
||||
'shrink-0 px-1.5 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ReactNode } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
interface ModalPortalProps {
|
||||
children: ReactNode;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
const ModalPortal: React.FC<ModalPortalProps> = ({ children, showOverlay = true }) => {
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 isolate z-50 flex items-center justify-center',
|
||||
showOverlay && 'bg-black bg-opacity-50',
|
||||
)}
|
||||
style={{ transform: 'translateZ(0)' }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalPortal;
|
||||
@@ -1,15 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconContext } from 'react-icons';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { SyncProvider } from '@/context/SyncContext';
|
||||
import { IconContext } from 'react-icons';
|
||||
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
const iconSize = useDefaultIconSize();
|
||||
return (
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
return mounted || isWebAppPlatform() ? (
|
||||
<CSPostHogProvider>
|
||||
<EnvProvider>
|
||||
<AuthProvider>
|
||||
@@ -19,7 +23,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
</AuthProvider>
|
||||
</EnvProvider>
|
||||
</CSPostHogProvider>
|
||||
);
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default Providers;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SelectProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
options: Option[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Select({ value, onChange, options, className }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
|
||||
'bg-gray-600 text-white/75 focus:outline-none focus:ring-0',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
textAlignLast: 'end',
|
||||
}}
|
||||
>
|
||||
{options.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import posthog from 'posthog-js';
|
||||
import { ReactNode } from 'react';
|
||||
import { PostHogProvider } from 'posthog-js/react';
|
||||
import { TELEMETRY_OPT_OUT_KEY } from '@/utils/telemetry';
|
||||
|
||||
const shouldDisablePostHog = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem(TELEMETRY_OPT_OUT_KEY) === 'true';
|
||||
};
|
||||
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
@@ -12,6 +18,9 @@ if (
|
||||
api_host: process.env['NEXT_PUBLIC_POSTHOG_HOST'],
|
||||
person_profiles: 'always',
|
||||
});
|
||||
if (shouldDisablePostHog()) {
|
||||
posthog.opt_out_capturing();
|
||||
}
|
||||
}
|
||||
export const CSPostHogProvider = ({ children }: { children: ReactNode }) => {
|
||||
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
// use desktop size as base size
|
||||
export const useResponsiveSize = (baseSize: number) => {
|
||||
const isPhone = useMediaQuery({ maxWidth: 480 });
|
||||
const isTablet = useMediaQuery({ minWidth: 481, maxWidth: 1024 });
|
||||
if (typeof window === 'undefined') {
|
||||
return baseSize;
|
||||
}
|
||||
const pixelRatio = window.devicePixelRatio || 2.4;
|
||||
const isMobile = ['android', 'ios'].includes(getOSPlatform());
|
||||
if (isPhone && isMobile) return baseSize * (pixelRatio / 3) * 1.25;
|
||||
if (isTablet && isMobile) return baseSize * (pixelRatio / 3) * 1.25;
|
||||
if (isPhone) return baseSize * 1.25;
|
||||
if (isTablet) return baseSize * 1.25;
|
||||
return baseSize;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
deeplProvider,
|
||||
getFromCache,
|
||||
storeInCache,
|
||||
UseTranslatorOptions,
|
||||
} from '@/services/translators';
|
||||
import { getTranslator, getTranslators } from '@/services/translators';
|
||||
import { getFromCache, storeInCache, polish, UseTranslatorOptions } from '@/services/translators';
|
||||
|
||||
export function useTranslator({
|
||||
provider = deeplProvider,
|
||||
provider = 'deepl',
|
||||
sourceLang = 'AUTO',
|
||||
targetLang = 'EN',
|
||||
enablePolishing = true,
|
||||
}: UseTranslatorOptions = {}) {
|
||||
const { token } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [translator, setTransltor] = useState(() => getTranslator(provider));
|
||||
const [translators] = useState(() => getTranslators());
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [provider.name, sourceLang, targetLang]);
|
||||
}, [provider, sourceLang, targetLang]);
|
||||
|
||||
useEffect(() => {
|
||||
setTransltor(getTranslator(provider));
|
||||
}, [provider]);
|
||||
|
||||
const translate = useCallback(
|
||||
async (
|
||||
@@ -44,7 +47,7 @@ export function useTranslator({
|
||||
text,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider.name,
|
||||
provider,
|
||||
);
|
||||
if (cachedTranslation) return;
|
||||
|
||||
@@ -56,19 +59,23 @@ export function useTranslator({
|
||||
if (textsNeedingTranslation.length === 0) {
|
||||
const results = await Promise.all(
|
||||
textsToTranslate.map((text) =>
|
||||
getFromCache(text, sourceLanguage, targetLanguage, provider.name).then(
|
||||
getFromCache(text, sourceLanguage, targetLanguage, provider).then(
|
||||
(cached) => cached || text,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return results;
|
||||
return enablePolishing ? polish(results, targetLanguage) : results;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const translatedTexts = await provider.translate(
|
||||
const translator = translators.find((t) => t.name === provider);
|
||||
if (!translator) {
|
||||
throw new Error(`No translator found for provider: ${provider}`);
|
||||
}
|
||||
const translatedTexts = await translator.translate(
|
||||
textsNeedingTranslation,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
@@ -83,7 +90,7 @@ export function useTranslator({
|
||||
translatedTexts[index] || '',
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider.name,
|
||||
provider,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -103,7 +110,7 @@ export function useTranslator({
|
||||
originalText,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider.name,
|
||||
provider,
|
||||
);
|
||||
|
||||
if (cachedTranslation) {
|
||||
@@ -114,18 +121,21 @@ export function useTranslator({
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
return results;
|
||||
return enablePolishing ? polish(results, targetLanguage) : results;
|
||||
} catch (err) {
|
||||
console.error('Translation error:', err);
|
||||
setLoading(false);
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
},
|
||||
[provider, sourceLang, targetLang, token],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[provider, sourceLang, targetLang, translator, token],
|
||||
);
|
||||
|
||||
return {
|
||||
translate,
|
||||
translator,
|
||||
translators,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export class DocumentLoader {
|
||||
);
|
||||
}
|
||||
|
||||
public async open(): Promise<{ book: BookDoc; format: BookFormat }> {
|
||||
public async open({ allowScript = false } = {}): Promise<{ book: BookDoc; format: BookFormat }> {
|
||||
let book = null;
|
||||
let format: BookFormat = 'EPUB';
|
||||
if (!this.file.size) {
|
||||
@@ -200,7 +200,7 @@ export class DocumentLoader {
|
||||
format = 'FBZ';
|
||||
} else {
|
||||
const { EPUB } = await import('foliate-js/epub.js');
|
||||
book = await new EPUB(loader).init();
|
||||
book = await new EPUB(loader).init({ allowScript });
|
||||
format = 'EPUB';
|
||||
}
|
||||
} else if (await this.isPDF()) {
|
||||
|
||||
@@ -174,11 +174,12 @@ export class EdgeSpeechTTS {
|
||||
});
|
||||
|
||||
const genSSML = (lang: string, text: string, voice: string, rate: number) => {
|
||||
const cleanedText = text.replace(/^<break\b[^>]*>/i, '');
|
||||
return `
|
||||
<speak version="1.0" xml:lang="${lang}">
|
||||
<voice name="${voice}">
|
||||
<prosody rate="${rate}">
|
||||
${text}
|
||||
${cleanedText}
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>
|
||||
@@ -255,6 +256,12 @@ export class EdgeSpeechTTS {
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!audioData.byteLength) {
|
||||
reject(new Error('No audio data received.'));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
ws.close();
|
||||
reject(new Error('WebSocket error occurred.'));
|
||||
|
||||
@@ -2,11 +2,18 @@ import crypto from 'crypto';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { getUserPlan } from '@/utils/access';
|
||||
import { getDailyTranslationPlanData, getUserPlan } from '@/utils/access';
|
||||
|
||||
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
|
||||
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
|
||||
|
||||
const ErrorCodes = {
|
||||
UNAUTHORIZED: 'Unauthorized',
|
||||
DEEPL_API_ERROR: 'DeepL API Error',
|
||||
DAILY_QUOTA_EXCEEDED: 'Daily Quota Exceeded',
|
||||
INTERNAL_SERVER_ERROR: 'Internal Server Error',
|
||||
};
|
||||
|
||||
interface KVNamespace {
|
||||
get(key: string): Promise<string | null>;
|
||||
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
|
||||
@@ -101,7 +108,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
}
|
||||
}
|
||||
|
||||
// if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
|
||||
|
||||
return await callDeepLAPI(
|
||||
user?.id,
|
||||
token,
|
||||
singleText,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
@@ -115,11 +126,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return res.status(200).json({ translations });
|
||||
} catch (error) {
|
||||
console.error('Error proxying DeepL request:', error);
|
||||
return res.status(500).json({ error: 'Internal Server Error' });
|
||||
if (error instanceof Error && error.message.includes(ErrorCodes.DAILY_QUOTA_EXCEEDED)) {
|
||||
return res.status(429).json({ error: ErrorCodes.DAILY_QUOTA_EXCEEDED });
|
||||
}
|
||||
return res.status(500).json({ error: ErrorCodes.INTERNAL_SERVER_ERROR });
|
||||
}
|
||||
};
|
||||
|
||||
async function callDeepLAPI(
|
||||
userId: string | undefined,
|
||||
token: string | undefined,
|
||||
text: string,
|
||||
sourceLang: string,
|
||||
targetLang: string,
|
||||
@@ -128,10 +144,24 @@ async function callDeepLAPI(
|
||||
translationsKV: KVNamespace | undefined,
|
||||
useCache: boolean,
|
||||
) {
|
||||
let dailyUsageKey = '';
|
||||
if (userId && token) {
|
||||
const { quota: dailyQuota } = getDailyTranslationPlanData(token);
|
||||
const currentDate = new Date().toISOString().split('T')[0]!;
|
||||
dailyUsageKey = `daily_usage:${currentDate}:${userId}`;
|
||||
const dailyUsage = (await translationsKV?.get(dailyUsageKey)) || '0';
|
||||
if (dailyQuota <= parseInt(dailyUsage) + text.length) {
|
||||
throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED);
|
||||
}
|
||||
}
|
||||
|
||||
const isV2Api = apiUrl.endsWith('/v2/translate');
|
||||
|
||||
// TODO: this should be processed in the client, but for now, we need to do it here
|
||||
// please remove this when most clients are updated
|
||||
const input = text.replaceAll('\n', '').trim();
|
||||
const requestBody = {
|
||||
text: isV2Api ? [text] : text,
|
||||
text: isV2Api ? [input] : input,
|
||||
source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang),
|
||||
target_lang: isV2Api ? targetLang : (LANG_V2_V1_MAP[targetLang] ?? targetLang),
|
||||
};
|
||||
@@ -163,6 +193,20 @@ async function callDeepLAPI(
|
||||
translatedText = data.data;
|
||||
}
|
||||
|
||||
let newDailyUsage = 0;
|
||||
if (dailyUsageKey && translationsKV) {
|
||||
try {
|
||||
const usage = translatedText.length + text.length;
|
||||
const dailyUsage = (await translationsKV.get(dailyUsageKey)) || '0';
|
||||
newDailyUsage = parseInt(dailyUsage) + usage;
|
||||
await translationsKV.put(dailyUsageKey, newDailyUsage.toString(), {
|
||||
expirationTtl: 86400 * 30,
|
||||
});
|
||||
} catch (cacheError) {
|
||||
console.error('Cache storage error:', cacheError);
|
||||
}
|
||||
}
|
||||
|
||||
if (useCache && translationsKV && translatedText) {
|
||||
try {
|
||||
const cacheKey = generateCacheKey(text, sourceLang, targetLang);
|
||||
@@ -174,6 +218,7 @@ async function callDeepLAPI(
|
||||
|
||||
return {
|
||||
text: translatedText,
|
||||
daily_usage: newDailyUsage,
|
||||
detected_source_language: detectedSourceLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ import {
|
||||
DEFAULT_CJK_VIEW_SETTINGS,
|
||||
DEFAULT_MOBILE_READSETTINGS,
|
||||
DEFAULT_SCREEN_CONFIG,
|
||||
DEFAULT_TRANSLATOR_CONFIG,
|
||||
} from './constants';
|
||||
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
|
||||
import { getOSPlatform, getTargetLang, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
|
||||
import { ClosableFile } from '@/utils/file';
|
||||
@@ -97,6 +98,7 @@ export abstract class BaseAppService implements AppService {
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...DEFAULT_SCREEN_CONFIG,
|
||||
...{ ...DEFAULT_TRANSLATOR_CONFIG, translateTargetLang: getTargetLang() },
|
||||
...settings.globalViewSettings,
|
||||
};
|
||||
} catch {
|
||||
@@ -117,6 +119,7 @@ export abstract class BaseAppService implements AppService {
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...DEFAULT_SCREEN_CONFIG,
|
||||
...{ ...DEFAULT_TRANSLATOR_CONFIG, translateTargetLang: getTargetLang() },
|
||||
},
|
||||
} as SystemSettings;
|
||||
|
||||
@@ -195,6 +198,7 @@ export abstract class BaseAppService implements AppService {
|
||||
if (!transient) {
|
||||
existingBook.deletedAt = null;
|
||||
}
|
||||
existingBook.createdAt = Date.now();
|
||||
existingBook.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
@@ -397,7 +401,7 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
} catch (error) {
|
||||
// don't throw error here since some books may not have cover images at all
|
||||
console.log('Failed to download cover file:', error);
|
||||
console.log(`Failed to download cover file for book: '${book.title}'`, error);
|
||||
}
|
||||
|
||||
if (needDownBook) {
|
||||
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
BookStyle,
|
||||
HighlightColor,
|
||||
ScreenConfig,
|
||||
TranslatorConfig,
|
||||
TTSConfig,
|
||||
ViewConfig,
|
||||
ViewSettings,
|
||||
} from '@/types/book';
|
||||
import { ReadSettings, SystemSettings } from '@/types/settings';
|
||||
import { UserStorageQuota } from '@/types/user';
|
||||
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/user';
|
||||
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
|
||||
@@ -41,6 +42,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
openLastBooks: false,
|
||||
lastOpenBooks: [],
|
||||
autoImportBooksOnOpen: false,
|
||||
telemetryEnabled: true,
|
||||
libraryViewMode: 'grid',
|
||||
librarySortBy: 'updated',
|
||||
librarySortAscending: false,
|
||||
@@ -56,6 +58,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
|
||||
notebookWidth: '25%',
|
||||
isNotebookPinned: false,
|
||||
autohideCursor: true,
|
||||
translationProvider: 'deepl',
|
||||
translateTargetLang: 'EN',
|
||||
|
||||
customThemes: [],
|
||||
@@ -100,12 +103,8 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
writingMode: 'auto',
|
||||
vertical: false,
|
||||
rtl: false,
|
||||
doubleBorder: false,
|
||||
borderColor: 'red',
|
||||
showHeader: true,
|
||||
showFooter: true,
|
||||
showBarsOnScroll: false,
|
||||
scrollingOverlap: 0,
|
||||
allowScript: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
@@ -117,7 +116,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
textIndent: 0,
|
||||
fullJustification: true,
|
||||
hyphenation: true,
|
||||
invert: false,
|
||||
invertImgColorInDark: false,
|
||||
theme: 'light',
|
||||
overrideFont: false,
|
||||
overrideLayout: false,
|
||||
@@ -138,11 +137,28 @@ export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
sideBarTab: 'toc',
|
||||
uiLanguage: '',
|
||||
sortedTOC: false,
|
||||
|
||||
doubleBorder: false,
|
||||
borderColor: 'red',
|
||||
|
||||
showHeader: true,
|
||||
showFooter: true,
|
||||
showBarsOnScroll: false,
|
||||
showRemainingTime: false,
|
||||
showPageNumber: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_TTS_CONFIG: TTSConfig = {
|
||||
ttsRate: 1.3,
|
||||
ttsVoice: '',
|
||||
ttsLocation: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
|
||||
translationEnabled: false,
|
||||
translationProvider: 'deepl',
|
||||
translateTargetLang: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
|
||||
@@ -177,7 +193,7 @@ export const CJK_SERIF_FONTS = [
|
||||
|
||||
export const CJK_SANS_SERIF_FONTS = ['Noto Sans SC', 'Noto Sans TC'];
|
||||
|
||||
export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica', 'Arial'];
|
||||
export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'];
|
||||
|
||||
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
|
||||
|
||||
@@ -503,7 +519,7 @@ export const READEST_UPDATER_FILE = `${GITHUB_LATEST_DOWNLOAD}/latest.json`;
|
||||
|
||||
export const READEST_CHANGELOG_FILE = `${GITHUB_LATEST_DOWNLOAD}/release-notes.json`;
|
||||
|
||||
export const SYNC_PROGRESS_INTERVAL_SEC = 60;
|
||||
export const SYNC_PROGRESS_INTERVAL_SEC = 3;
|
||||
export const SYNC_NOTES_INTERVAL_SEC = 10;
|
||||
export const SYNC_BOOKS_INTERVAL_SEC = 10;
|
||||
export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60;
|
||||
@@ -518,6 +534,12 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
pro: 10 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = {
|
||||
free: 100 * 1024,
|
||||
plus: 1 * 1024 * 1024,
|
||||
pro: 10 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
|
||||
export const DISABLE_DOUBLE_CLICK_ON_MOBILE = true;
|
||||
export const LONG_HOLD_THRESHOLD = 500;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './cache';
|
||||
export * from './polish';
|
||||
export * from './providers';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export type Polisher = (text: string) => string;
|
||||
|
||||
const languagePolishers: Record<string, Polisher> = {
|
||||
// Chinese - fix punctuation spacing
|
||||
zh: (text: string) =>
|
||||
text
|
||||
.replace(/--/g, '⸺')
|
||||
.replace(/\s+([。、!?])/g, '$1')
|
||||
.replace(/([。、!?])\s+/g, '$1'),
|
||||
|
||||
// Spanish - fix punctuation spacing
|
||||
es: (text: string) =>
|
||||
text.replace(/\?([A-ZÁÉÍÓÚÑÜ])/g, '? $1').replace(/\!([A-ZÁÉÍÓÚÑÜ])/g, '! $1'),
|
||||
|
||||
// French - fix punctuation spacing
|
||||
fr: (text: string) => text.replace(/\s+([!?:;])/g, ' $1').replace(/([!?:;])\s+/g, '$1 '),
|
||||
|
||||
// Japanese - fix punctuation spacing
|
||||
ja: (text: string) => text.replace(/\s+([。、!?])/g, '$1').replace(/([。、!?])\s+/g, '$1'),
|
||||
};
|
||||
|
||||
export const basicPolish: Polisher = (text: string) => {
|
||||
return text
|
||||
.replace(/\s+/g, ' ') // Multiple spaces to single space
|
||||
.replace(/\s+([.,!?;:])/g, '$1') // Remove space before punctuation
|
||||
.trim();
|
||||
};
|
||||
|
||||
export function getPolisher(targetLang: string): Polisher {
|
||||
const langCode = targetLang.split('-')[0]!.toLowerCase();
|
||||
const languagePolisher = languagePolishers[langCode];
|
||||
|
||||
if (languagePolisher) {
|
||||
return (text: string) => {
|
||||
const basicPolished = basicPolish(text);
|
||||
const polished = languagePolisher(basicPolished);
|
||||
return polished;
|
||||
};
|
||||
}
|
||||
|
||||
return basicPolish;
|
||||
}
|
||||
|
||||
export function polish(texts: string[], targetLang: string): string[] {
|
||||
const polisher = getPolisher(targetLang);
|
||||
return texts.map(polisher);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { TranslationProvider } from '../types';
|
||||
import { langToDefaultLocale } from '@/utils/lang';
|
||||
|
||||
interface TokenCache {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let tokenCache: TokenCache | null = null;
|
||||
|
||||
const getAuthToken = async (): Promise<string> => {
|
||||
const now = Date.now();
|
||||
|
||||
if (tokenCache && tokenCache.expiresAt > now) {
|
||||
return tokenCache.token;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
const tokenResponse = await fetch('https://edge.microsoft.com/translate/auth', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Failed to get auth token: ${tokenResponse.status}`);
|
||||
}
|
||||
|
||||
const token = await tokenResponse.text();
|
||||
const expiresAt = now + 8 * 60 * 1000;
|
||||
|
||||
tokenCache = {
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('Error getting Microsoft translation auth token:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const azureProvider: TranslationProvider = {
|
||||
name: 'azure',
|
||||
label: _('Azure Translator'),
|
||||
translate: async (text: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
|
||||
if (!text.length) return [];
|
||||
|
||||
const results: string[] = [];
|
||||
const msSourceLang = sourceLang ? langToDefaultLocale(sourceLang.toLowerCase()) : '';
|
||||
const msTargetLang = langToDefaultLocale(targetLang.toLowerCase());
|
||||
|
||||
const translationPromises = text.map(async (line, index) => {
|
||||
if (!line?.trim().length) {
|
||||
results[index] = line;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = 'https://api-edge.cognitive.microsofttranslator.com/translate';
|
||||
const params = new URLSearchParams({
|
||||
to: msTargetLang,
|
||||
'api-version': '3.0',
|
||||
});
|
||||
if (msSourceLang && msSourceLang !== 'auto') {
|
||||
params.append('from', msSourceLang);
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
const response = await fetch(`${url}?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify([{ Text: line }]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Translation failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data) && data.length > 0 && data[0].translations) {
|
||||
results[index] = data[0].translations[0].text || line;
|
||||
} else {
|
||||
results[index] = line;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(translationPromises);
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { TranslationProvider } from '../types';
|
||||
|
||||
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
|
||||
|
||||
export const deeplProvider: TranslationProvider = {
|
||||
name: 'deepl',
|
||||
label: _('DeepL'),
|
||||
translate: async (
|
||||
text: string[],
|
||||
sourceLang: string,
|
||||
@@ -12,46 +14,37 @@ export const deeplProvider: TranslationProvider = {
|
||||
token?: string | null,
|
||||
useCache: boolean = false,
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(DEEPL_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
source_lang: sourceLang,
|
||||
target_lang: targetLang,
|
||||
use_cache: useCache,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Translation failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const result = [...text];
|
||||
let translationIndex = 0;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i]!.trim().length > 0) {
|
||||
result[i] = data.translations?.[translationIndex]?.text || text[i];
|
||||
translationIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Translation error:', error);
|
||||
return text;
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
text: text,
|
||||
source_lang: sourceLang.toUpperCase(),
|
||||
target_lang: targetLang.toUpperCase(),
|
||||
use_cache: useCache,
|
||||
});
|
||||
|
||||
const response = await fetch(DEEPL_API_ENDPOINT, { method: 'POST', headers, body });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Translation failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || !data.translations) {
|
||||
throw new Error('Invalid response from translation service');
|
||||
}
|
||||
|
||||
return text.map((line, i) => {
|
||||
if (!line?.trim().length) {
|
||||
return line;
|
||||
}
|
||||
return data.translations?.[i]?.text || line;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { TranslationProvider } from '../types';
|
||||
|
||||
export const googleProvider: TranslationProvider = {
|
||||
name: 'google',
|
||||
label: _('Google Translate'),
|
||||
translate: async (text: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
|
||||
if (!text.length) return [];
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
const translationPromises = text.map(async (line, index) => {
|
||||
if (!line?.trim().length) {
|
||||
results[index] = line;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL('https://translate.googleapis.com/translate_a/single');
|
||||
url.searchParams.append('client', 'gtx');
|
||||
url.searchParams.append('dt', 't');
|
||||
url.searchParams.append('sl', sourceLang.toLowerCase() || 'auto');
|
||||
url.searchParams.append('tl', targetLang.toLowerCase());
|
||||
url.searchParams.append('q', line);
|
||||
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Translation failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data) && Array.isArray(data[0])) {
|
||||
const translatedText = data[0]
|
||||
.filter((segment) => Array.isArray(segment) && segment[0])
|
||||
.map((segment) => segment[0])
|
||||
.join('');
|
||||
|
||||
results[index] = translatedText || line;
|
||||
} else {
|
||||
results[index] = line;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(translationPromises);
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
@@ -1 +1,37 @@
|
||||
export * from './deepl';
|
||||
import { TranslationProvider } from '../types';
|
||||
import { deeplProvider } from './deepl';
|
||||
import { azureProvider } from './azure';
|
||||
import { googleProvider } from './google';
|
||||
|
||||
function createTranslator<T extends string>(
|
||||
name: T,
|
||||
implementation: TranslationProvider,
|
||||
): TranslationProvider & { name: T } {
|
||||
if (name !== implementation.name) {
|
||||
throw Error(
|
||||
`Translator name "${name}" does not match implementation name "${implementation.name}"`,
|
||||
);
|
||||
}
|
||||
return { ...implementation, name };
|
||||
}
|
||||
|
||||
const deeplTranslator = createTranslator('deepl', deeplProvider);
|
||||
const azureTranslator = createTranslator('azure', azureProvider);
|
||||
const googleTranslator = createTranslator('google', googleProvider);
|
||||
|
||||
const availableTranslators = [
|
||||
deeplTranslator,
|
||||
azureTranslator,
|
||||
googleTranslator,
|
||||
// Add more translators here
|
||||
];
|
||||
|
||||
export type TranslatorName = (typeof availableTranslators)[number]['name'];
|
||||
|
||||
export const getTranslator = (name: TranslatorName): TranslationProvider | undefined => {
|
||||
return availableTranslators.find((translator) => translator.name === name);
|
||||
};
|
||||
|
||||
export const getTranslators = (): TranslationProvider[] => {
|
||||
return availableTranslators;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { TranslatorName } from './providers';
|
||||
|
||||
export interface TranslationProvider {
|
||||
name: string;
|
||||
label: string;
|
||||
translate: (
|
||||
texts: string[],
|
||||
sourceLang: string,
|
||||
@@ -14,7 +17,8 @@ export interface TranslationCache {
|
||||
}
|
||||
|
||||
export interface UseTranslatorOptions {
|
||||
provider?: TranslationProvider;
|
||||
provider?: TranslatorName;
|
||||
sourceLang?: string;
|
||||
targetLang?: string;
|
||||
enablePolishing?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
|
||||
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
|
||||
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
|
||||
import { TTSGranularity } from '@/types/view';
|
||||
import { TTSController } from './TTSController';
|
||||
import { TTSUtils } from './TTSUtils';
|
||||
|
||||
export class EdgeTTSClient implements TTSClient {
|
||||
controller?: TTSController;
|
||||
#primaryLang = 'en';
|
||||
#speakingLang = '';
|
||||
#rate = 1.0;
|
||||
#pitch = 1.0;
|
||||
#voice: TTSVoice | null = null;
|
||||
#currentVoiceLang = '';
|
||||
#voices: TTSVoice[] = [];
|
||||
#edgeTTS: EdgeSpeechTTS;
|
||||
|
||||
@@ -20,7 +22,8 @@ export class EdgeTTSClient implements TTSClient {
|
||||
#startedAt = 0;
|
||||
available = true;
|
||||
|
||||
constructor() {
|
||||
constructor(controller?: TTSController) {
|
||||
this.controller = controller;
|
||||
this.#edgeTTS = new EdgeSpeechTTS();
|
||||
}
|
||||
|
||||
@@ -45,25 +48,26 @@ export class EdgeTTSClient implements TTSClient {
|
||||
return { lang, text, voice: voiceId, rate: this.#rate, pitch: this.#pitch } as EdgeTTSPayload;
|
||||
};
|
||||
|
||||
getVoiceIdFromLang = async (lang: string) => {
|
||||
let voiceId = 'en-US-AriaNeural';
|
||||
const preferredVoiceId = TTSUtils.getPreferredVoice('edge-tts', lang);
|
||||
const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId);
|
||||
this.#voice = preferredVoice ? preferredVoice : (await this.getVoices(lang))[0] || null;
|
||||
if (this.#voice) {
|
||||
voiceId = this.#voice.id;
|
||||
}
|
||||
return voiceId;
|
||||
};
|
||||
|
||||
async *speak(
|
||||
ssml: string,
|
||||
signal: AbortSignal,
|
||||
preload = false,
|
||||
): AsyncGenerator<TTSMessageEvent> {
|
||||
const { marks } = parseSSMLMarks(ssml);
|
||||
let lang = parseSSMLLang(ssml) || 'en';
|
||||
if (lang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
|
||||
lang = this.#primaryLang;
|
||||
}
|
||||
let voiceId = 'en-US-AriaNeural';
|
||||
if (!this.#voice || this.#currentVoiceLang !== lang) {
|
||||
const preferredVoiceId = TTSUtils.getPreferredVoice('edge-tts', lang);
|
||||
const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId);
|
||||
this.#voice = preferredVoice ? preferredVoice : (await this.getVoices(lang))[0] || null;
|
||||
this.#currentVoiceLang = lang;
|
||||
}
|
||||
if (this.#voice) {
|
||||
voiceId = this.#voice.id;
|
||||
let defaultLang = parseSSMLLang(ssml) || 'en';
|
||||
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
|
||||
defaultLang = this.#primaryLang;
|
||||
}
|
||||
|
||||
if (preload) {
|
||||
@@ -71,16 +75,24 @@ export class EdgeTTSClient implements TTSClient {
|
||||
const maxImmediate = 2;
|
||||
for (let i = 0; i < Math.min(maxImmediate, marks.length); i++) {
|
||||
const mark = marks[i]!;
|
||||
await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((err) => {
|
||||
console.warn('Error preloading mark', i, err);
|
||||
});
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
const voiceId = await this.getVoiceIdFromLang(voiceLang);
|
||||
await this.#edgeTTS
|
||||
.createAudio(this.getPayload(voiceLang, mark.text, voiceId))
|
||||
.catch((err) => {
|
||||
console.warn('Error preloading mark', i, err);
|
||||
});
|
||||
}
|
||||
if (marks.length > maxImmediate) {
|
||||
(async () => {
|
||||
for (let i = maxImmediate; i < marks.length; i++) {
|
||||
const mark = marks[i]!;
|
||||
try {
|
||||
await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId));
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
const voiceId = await this.getVoiceIdFromLang(voiceLang);
|
||||
await this.#edgeTTS.createAudio(this.getPayload(voiceLang, mark.text, voiceId));
|
||||
} catch (err) {
|
||||
console.warn('Error preloading mark (bg)', i, err);
|
||||
}
|
||||
@@ -107,13 +119,21 @@ export class EdgeTTSClient implements TTSClient {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const blob = await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId));
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
const voiceId = await this.getVoiceIdFromLang(voiceLang);
|
||||
this.#speakingLang = voiceLang;
|
||||
const blob = await this.#edgeTTS.createAudio(
|
||||
this.getPayload(voiceLang, mark.text, voiceId),
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.#audioElement = new Audio(url);
|
||||
const audio = this.#audioElement;
|
||||
audio.setAttribute('x-webkit-airplay', 'deny');
|
||||
audio.preload = 'auto';
|
||||
|
||||
this.controller?.dispatchSpeakMark(mark);
|
||||
|
||||
yield {
|
||||
code: 'boundary',
|
||||
message: `Start chunk: ${mark.name}`,
|
||||
@@ -240,9 +260,6 @@ export class EdgeTTSClient implements TTSClient {
|
||||
}
|
||||
|
||||
async getVoices(lang: string): Promise<TTSVoice[]> {
|
||||
if (this.#currentVoiceLang) {
|
||||
lang = this.#currentVoiceLang;
|
||||
}
|
||||
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
|
||||
const voices = await this.getAllVoices();
|
||||
return voices
|
||||
@@ -276,4 +293,8 @@ export class EdgeTTSClient implements TTSClient {
|
||||
getVoiceId(): string {
|
||||
return this.#voice?.id || '';
|
||||
}
|
||||
|
||||
getSpeakingLang(): string {
|
||||
return this.#speakingLang;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,5 @@ export interface TTSClient {
|
||||
getVoices(lang: string): Promise<TTSVoice[]>;
|
||||
getGranularities(): TTSGranularity[];
|
||||
getVoiceId(): string;
|
||||
getSpeakingLang(): string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { FoliateView, TTSGranularity } from '@/types/view';
|
||||
import { TTSClient, TTSMessageCode, TTSVoice } from './TTSClient';
|
||||
import { parseSSMLMarks } from '@/utils/ssml';
|
||||
import { WebSpeechClient } from './WebSpeechClient';
|
||||
import { EdgeTTSClient } from './EdgeTTSClient';
|
||||
import { TTSUtils } from './TTSUtils';
|
||||
import { TTSMark } from './types';
|
||||
|
||||
type TTSState =
|
||||
| 'stopped'
|
||||
@@ -30,8 +32,8 @@ export class TTSController extends EventTarget {
|
||||
|
||||
constructor(view: FoliateView) {
|
||||
super();
|
||||
this.ttsWebClient = new WebSpeechClient();
|
||||
this.ttsEdgeClient = new EdgeTTSClient();
|
||||
this.ttsWebClient = new WebSpeechClient(this);
|
||||
this.ttsEdgeClient = new EdgeTTSClient(this);
|
||||
this.ttsClient = this.ttsWebClient;
|
||||
this.view = view;
|
||||
}
|
||||
@@ -81,9 +83,12 @@ export class TTSController extends EventTarget {
|
||||
#preprocessSSML(ssml?: string) {
|
||||
if (!ssml) return;
|
||||
ssml = ssml
|
||||
.replace(/<emphasis[^>]*>([^<]+)<\/emphasis>/g, '$1')
|
||||
.replace(/[–—]/g, ',')
|
||||
.replace(/\.{3,}/g, '<break time="400ms"/>')
|
||||
.replace(/·/g, '<break time="200ms"/>');
|
||||
.replace('<break/>', ' ')
|
||||
.replace(/\.{3,}/g, ' ')
|
||||
.replace(/……/g, ' ')
|
||||
.replace(/·/g, ' ');
|
||||
|
||||
return ssml;
|
||||
}
|
||||
@@ -104,7 +109,7 @@ export class TTSController extends EventTarget {
|
||||
// FIXME: in case we are at the end of the book, need a better way to handle this
|
||||
if (this.#nossmlCnt < 10 && this.state === 'playing') {
|
||||
resolve();
|
||||
await this.view.next(1);
|
||||
await this.view.next();
|
||||
await this.forward();
|
||||
}
|
||||
return;
|
||||
@@ -112,6 +117,11 @@ export class TTSController extends EventTarget {
|
||||
this.#nossmlCnt = 0;
|
||||
}
|
||||
|
||||
const { plainText, marks } = parseSSMLMarks(ssml);
|
||||
if (!plainText || marks.length === 0) {
|
||||
resolve();
|
||||
return await this.forward();
|
||||
}
|
||||
const iter = await this.ttsClient.speak(ssml, signal);
|
||||
let lastCode: TTSMessageCode = 'boundary';
|
||||
for await (const { code, mark } of iter) {
|
||||
@@ -120,7 +130,8 @@ export class TTSController extends EventTarget {
|
||||
return;
|
||||
}
|
||||
if (mark && this.state === 'playing') {
|
||||
this.view.tts?.setMark(mark);
|
||||
const range = this.view.tts?.setMark(mark);
|
||||
this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: range }));
|
||||
}
|
||||
lastCode = code;
|
||||
}
|
||||
@@ -239,7 +250,7 @@ export class TTSController extends EventTarget {
|
||||
return [...ttsEdgeVoices, ...ttsWebVoices];
|
||||
}
|
||||
|
||||
async setVoice(voiceId: string) {
|
||||
async setVoice(voiceId: string, lang: string) {
|
||||
this.state = 'setvoice-paused';
|
||||
const useEdgeTTS = !!this.ttsEdgeVoices.find(
|
||||
(voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled,
|
||||
@@ -247,11 +258,11 @@ export class TTSController extends EventTarget {
|
||||
if (useEdgeTTS) {
|
||||
this.ttsClient = this.ttsEdgeClient;
|
||||
await this.ttsClient.setRate(this.ttsRate);
|
||||
TTSUtils.setPreferredVoice('edge-tts', this.ttsLang, voiceId);
|
||||
TTSUtils.setPreferredVoice('edge-tts', lang, voiceId);
|
||||
} else {
|
||||
this.ttsClient = this.ttsWebClient;
|
||||
await this.ttsClient.setRate(this.ttsRate);
|
||||
TTSUtils.setPreferredVoice('web-speech', this.ttsLang, voiceId);
|
||||
TTSUtils.setPreferredVoice('web-speech', lang, voiceId);
|
||||
}
|
||||
await this.ttsClient.setVoice(voiceId);
|
||||
}
|
||||
@@ -260,6 +271,14 @@ export class TTSController extends EventTarget {
|
||||
return this.ttsClient.getVoiceId();
|
||||
}
|
||||
|
||||
getSpeakingLang() {
|
||||
return this.ttsClient.getSpeakingLang();
|
||||
}
|
||||
|
||||
dispatchSpeakMark(mark: TTSMark) {
|
||||
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark }));
|
||||
}
|
||||
|
||||
error(e: unknown) {
|
||||
console.error(e);
|
||||
this.state = 'stopped';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getUserLocale } from '@/utils/misc';
|
||||
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
|
||||
import { AsyncQueue } from '@/utils/queue';
|
||||
import { findSSMLMark, parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
|
||||
import { isCJKStr } from '@/utils/lang';
|
||||
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
|
||||
import { TTSGranularity } from '@/types/view';
|
||||
import { TTSController } from './TTSController';
|
||||
import { TTSUtils } from './TTSUtils';
|
||||
import { TTSMark } from './types';
|
||||
|
||||
const BLACKLISTED_VOICES = [
|
||||
'Albert',
|
||||
@@ -46,105 +46,34 @@ interface TTSBoundaryEvent {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function* speakWithBoundary(
|
||||
ssml: string,
|
||||
getRate: () => number,
|
||||
getPitch: () => number,
|
||||
getVoice: () => SpeechSynthesisVoice | null,
|
||||
) {
|
||||
const lang = parseSSMLLang(ssml);
|
||||
const { plainText, marks } = parseSSMLMarks(ssml);
|
||||
// console.log('ssml', ssml, marks);
|
||||
// console.log('text', plainText);
|
||||
|
||||
const synth = window.speechSynthesis;
|
||||
const utterance = new SpeechSynthesisUtterance(plainText);
|
||||
|
||||
utterance.rate = getRate();
|
||||
utterance.pitch = getPitch();
|
||||
const voice = getVoice();
|
||||
if (voice) {
|
||||
utterance.voice = voice;
|
||||
}
|
||||
if (lang) {
|
||||
utterance.lang = lang;
|
||||
}
|
||||
|
||||
const queue = new AsyncQueue<TTSBoundaryEvent>();
|
||||
|
||||
utterance.onboundary = (event: SpeechSynthesisEvent) => {
|
||||
utterance.rate = getRate();
|
||||
utterance.pitch = getPitch();
|
||||
const voice = getVoice();
|
||||
if (voice) {
|
||||
utterance.voice = voice;
|
||||
}
|
||||
const mark = findSSMLMark(event.charIndex, marks);
|
||||
// console.log('boundary', event.charIndex, mark);
|
||||
queue.enqueue({
|
||||
type: 'boundary',
|
||||
speaking: true,
|
||||
name: event.name,
|
||||
mark: mark?.name ?? '',
|
||||
charIndex: event.charIndex,
|
||||
charLength: event.charLength,
|
||||
});
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
queue.enqueue({ type: 'end', speaking: false });
|
||||
queue.finish();
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
queue.enqueue({ type: 'error', speaking: false, error: event.error });
|
||||
queue.finish();
|
||||
};
|
||||
|
||||
synth.speak(utterance);
|
||||
|
||||
while (true) {
|
||||
const ev = await queue.dequeue();
|
||||
if (ev === null) {
|
||||
break;
|
||||
}
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
|
||||
async function* speakWithMarks(
|
||||
ssml: string,
|
||||
defaultLang: string,
|
||||
getRate: () => number,
|
||||
getPitch: () => number,
|
||||
getVoice: () => SpeechSynthesisVoice | null,
|
||||
getVoice: (lang: string) => Promise<SpeechSynthesisVoice | null>,
|
||||
setSpeakingLang: (lang: string) => void,
|
||||
dispatchSpeakMark: (mark: TTSMark) => void,
|
||||
) {
|
||||
const { plainText, marks } = parseSSMLMarks(ssml);
|
||||
const lang = parseSSMLLang(ssml);
|
||||
|
||||
const isCJK = (lang: string | null) => {
|
||||
const cjkLangs = ['zh', 'ja', 'kr'];
|
||||
if (lang && cjkLangs.some((cjk) => lang.startsWith(cjk))) return true;
|
||||
return isCJKStr(plainText);
|
||||
};
|
||||
|
||||
if (!isCJK(lang)) {
|
||||
yield* speakWithBoundary(ssml, getRate, getPitch, getVoice);
|
||||
return;
|
||||
}
|
||||
const { marks } = parseSSMLMarks(ssml);
|
||||
|
||||
const synth = window.speechSynthesis;
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance();
|
||||
for (const mark of marks) {
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
setSpeakingLang(voiceLang);
|
||||
dispatchSpeakMark(mark);
|
||||
utterance.text = mark.text;
|
||||
utterance.rate = getRate();
|
||||
utterance.pitch = getPitch();
|
||||
const voice = getVoice();
|
||||
const voice = await getVoice(voiceLang);
|
||||
if (voice) {
|
||||
utterance.voice = voice;
|
||||
}
|
||||
if (lang) {
|
||||
utterance.lang = lang;
|
||||
if (voiceLang) {
|
||||
utterance.lang = voiceLang;
|
||||
}
|
||||
|
||||
yield {
|
||||
@@ -174,15 +103,20 @@ async function* speakWithMarks(
|
||||
}
|
||||
|
||||
export class WebSpeechClient implements TTSClient {
|
||||
controller?: TTSController;
|
||||
#primaryLang = 'en';
|
||||
#speakingLang = '';
|
||||
#rate = 1.0;
|
||||
#pitch = 1.0;
|
||||
#voice: SpeechSynthesisVoice | null = null;
|
||||
#currentVoiceLang = '';
|
||||
#voices: SpeechSynthesisVoice[] = [];
|
||||
#synth = window.speechSynthesis;
|
||||
available = true;
|
||||
|
||||
constructor(controller?: TTSController) {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.#synth) {
|
||||
this.available = false;
|
||||
@@ -209,6 +143,18 @@ export class WebSpeechClient implements TTSClient {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
getVoiceFromLang = async (lang: string) => {
|
||||
const preferredVoiceId = TTSUtils.getPreferredVoice('web-speech', lang);
|
||||
const preferredVoice = this.#voices.find((v) => v.voiceURI === preferredVoiceId);
|
||||
if (preferredVoice) {
|
||||
this.#voice = preferredVoice;
|
||||
} else {
|
||||
const voiceId = (await this.getVoices(lang))[0]?.id ?? '';
|
||||
this.#voice = this.#voices.find((v) => v.voiceURI === voiceId) || null;
|
||||
}
|
||||
return this.#voice;
|
||||
};
|
||||
|
||||
async *speak(
|
||||
ssml: string,
|
||||
signal: AbortSignal,
|
||||
@@ -217,24 +163,19 @@ export class WebSpeechClient implements TTSClient {
|
||||
// no need to preload for web speech
|
||||
if (preload) return;
|
||||
|
||||
let lang = parseSSMLLang(ssml) || 'en';
|
||||
if (lang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
|
||||
lang = this.#primaryLang;
|
||||
}
|
||||
if (!this.#voice || this.#currentVoiceLang !== lang) {
|
||||
const preferredVoiceId = TTSUtils.getPreferredVoice('web-speech', lang);
|
||||
const preferredVoice = this.#voices.find((v) => v.voiceURI === preferredVoiceId);
|
||||
const voiceId = (await this.getVoices(lang))[0]?.id ?? '';
|
||||
this.#voice = preferredVoice
|
||||
? preferredVoice
|
||||
: this.#voices.find((v) => v.voiceURI === voiceId) || null;
|
||||
this.#currentVoiceLang = lang;
|
||||
let defaultLang = parseSSMLLang(ssml) || 'en';
|
||||
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
|
||||
defaultLang = this.#primaryLang;
|
||||
}
|
||||
|
||||
for await (const ev of speakWithMarks(
|
||||
ssml,
|
||||
defaultLang,
|
||||
() => this.#rate,
|
||||
() => this.#pitch,
|
||||
() => this.#voice,
|
||||
this.getVoiceFromLang,
|
||||
(lang) => (this.#speakingLang = lang),
|
||||
(mark) => this.controller?.dispatchSpeakMark(mark),
|
||||
)) {
|
||||
if (signal.aborted) {
|
||||
console.log('TTS aborted');
|
||||
@@ -301,9 +242,6 @@ export class WebSpeechClient implements TTSClient {
|
||||
}
|
||||
|
||||
async getVoices(lang: string) {
|
||||
if (this.#currentVoiceLang) {
|
||||
lang = this.#currentVoiceLang;
|
||||
}
|
||||
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
|
||||
const isValidVoice = (id: string) => {
|
||||
return !id.includes('com.apple') || id.includes('com.apple.voice.compact');
|
||||
@@ -351,4 +289,8 @@ export class WebSpeechClient implements TTSClient {
|
||||
getVoiceId(): string {
|
||||
return this.#voice?.voiceURI ?? '';
|
||||
}
|
||||
|
||||
getSpeakingLang(): string {
|
||||
return this.#speakingLang;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './TTSClient';
|
||||
export * from './WebSpeechClient';
|
||||
export * from './EdgeTTSClient';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type TTSMark = {
|
||||
offset: number;
|
||||
name: string;
|
||||
text: string;
|
||||
language?: string;
|
||||
};
|
||||
@@ -1,23 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
import { Book } from '@/types/book';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { EnvConfigType, isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
interface LibraryState {
|
||||
library: Book[]; // might contain deleted books
|
||||
checkOpenWithBooks: boolean;
|
||||
checkLastOpenBooks: boolean;
|
||||
currentBookshelf: (Book | BooksGroup)[];
|
||||
getVisibleLibrary: () => Book[];
|
||||
setCheckOpenWithBooks: (check: boolean) => void;
|
||||
setCheckLastOpenBooks: (check: boolean) => void;
|
||||
setLibrary: (books: Book[]) => void;
|
||||
updateBook: (envConfig: EnvConfigType, book: Book) => void;
|
||||
setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => void;
|
||||
}
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
library: [],
|
||||
currentBookshelf: [],
|
||||
checkOpenWithBooks: isTauriAppPlatform(),
|
||||
checkLastOpenBooks: isTauriAppPlatform(),
|
||||
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
|
||||
setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => {
|
||||
set({ currentBookshelf: bookshelf });
|
||||
},
|
||||
setCheckOpenWithBooks: (check) => set({ checkOpenWithBooks: check }),
|
||||
setCheckLastOpenBooks: (check) => set({ checkLastOpenBooks: check }),
|
||||
setLibrary: (books) => set({ library: books }),
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book';
|
||||
import {
|
||||
BookContent,
|
||||
BookConfig,
|
||||
PageInfo,
|
||||
BookProgress,
|
||||
ViewSettings,
|
||||
TimeInfo,
|
||||
} from '@/types/book';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { BookDoc, DocumentLoader, TOCItem } from '@/libs/document';
|
||||
import { DocumentLoader, TOCItem } from '@/libs/document';
|
||||
import { updateToc } from '@/utils/toc';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useBookDataStore } from './bookDataStore';
|
||||
@@ -19,6 +26,7 @@ interface ViewState {
|
||||
error: string | null;
|
||||
progress: BookProgress | null;
|
||||
ribbonVisible: boolean;
|
||||
ttsEnabled: boolean;
|
||||
/* View settings for the view:
|
||||
generally view settings have a hierarchy of global settings < book settings < view settings
|
||||
view settings for primary view are saved to book config which is persisted to config file
|
||||
@@ -33,12 +41,14 @@ interface ReaderStore {
|
||||
setBookKeys: (keys: string[]) => void;
|
||||
setHoveredBookKey: (key: string | null) => void;
|
||||
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
|
||||
setTTSEnabled: (key: string, enabled: boolean) => void;
|
||||
setProgress: (
|
||||
key: string,
|
||||
location: string,
|
||||
tocItem: TOCItem,
|
||||
section: PageInfo,
|
||||
pageinfo: PageInfo,
|
||||
timeinfo: TimeInfo,
|
||||
range: Range,
|
||||
) => void;
|
||||
getProgress: (key: string) => BookProgress | null;
|
||||
@@ -103,6 +113,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: null,
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
viewSettings: null,
|
||||
},
|
||||
},
|
||||
@@ -119,11 +130,10 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const content = (await appService.loadBookContent(book, settings)) as BookContent;
|
||||
const { file, config } = content;
|
||||
console.log('Loading book', key);
|
||||
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
|
||||
const bookDoc = loadedBookDoc as BookDoc;
|
||||
if (bookDoc.toc?.length && bookDoc.sections?.length) {
|
||||
updateToc(bookDoc, bookDoc.toc, bookDoc.sections);
|
||||
}
|
||||
const { book: bookDoc } = await new DocumentLoader(file).open({
|
||||
allowScript: config.viewSettings?.allowScript,
|
||||
});
|
||||
updateToc(bookDoc, config.viewSettings?.sortedTOC ?? false);
|
||||
// Set the book's language for formerly imported books, newly imported books have this field set
|
||||
book.primaryLanguage =
|
||||
book.primaryLanguage ?? getPrimaryLanguage(bookDoc.metadata.language);
|
||||
@@ -149,6 +159,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: null,
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
viewSettings: JSON.parse(JSON.stringify(configViewSettings)) as ViewSettings,
|
||||
},
|
||||
},
|
||||
@@ -167,6 +178,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
error: 'Failed to load book.',
|
||||
progress: null,
|
||||
ribbonVisible: false,
|
||||
ttsEnabled: false,
|
||||
viewSettings: null,
|
||||
},
|
||||
},
|
||||
@@ -211,6 +223,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
tocItem: TOCItem,
|
||||
section: PageInfo,
|
||||
pageinfo: PageInfo,
|
||||
timeinfo: TimeInfo,
|
||||
range: Range,
|
||||
) =>
|
||||
set((state) => {
|
||||
@@ -266,6 +279,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
sectionId: tocItem?.id,
|
||||
section,
|
||||
pageinfo,
|
||||
timeinfo,
|
||||
range,
|
||||
},
|
||||
},
|
||||
@@ -282,4 +296,15 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setTTSEnabled: (key: string, enabled: boolean) =>
|
||||
set((state) => ({
|
||||
viewStates: {
|
||||
...state.viewStates,
|
||||
[key]: {
|
||||
...state.viewStates[key]!,
|
||||
ttsEnabled: enabled,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -41,6 +41,12 @@ export interface PageInfo {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Remaining time of the book in minutes
|
||||
export interface TimeInfo {
|
||||
section: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface BookNote {
|
||||
bookHash?: string;
|
||||
id: string;
|
||||
@@ -82,12 +88,8 @@ export interface BookLayout {
|
||||
writingMode: WritingMode;
|
||||
vertical: boolean;
|
||||
rtl: boolean;
|
||||
doubleBorder: boolean;
|
||||
borderColor: string;
|
||||
showHeader: boolean;
|
||||
showFooter: boolean;
|
||||
showBarsOnScroll: boolean;
|
||||
scrollingOverlap: number;
|
||||
allowScript: boolean;
|
||||
}
|
||||
|
||||
export interface BookStyle {
|
||||
@@ -99,7 +101,7 @@ export interface BookStyle {
|
||||
textIndent: number;
|
||||
fullJustification: boolean;
|
||||
hyphenation: boolean;
|
||||
invert: boolean;
|
||||
invertImgColorInDark: boolean;
|
||||
theme: string;
|
||||
overrideFont: boolean;
|
||||
overrideLayout: boolean;
|
||||
@@ -120,11 +122,28 @@ export interface BookFont {
|
||||
export interface ViewConfig {
|
||||
sideBarTab: string;
|
||||
uiLanguage: string;
|
||||
sortedTOC: boolean;
|
||||
|
||||
doubleBorder: boolean;
|
||||
borderColor: string;
|
||||
|
||||
showHeader: boolean;
|
||||
showFooter: boolean;
|
||||
showRemainingTime: boolean;
|
||||
showPageNumber: boolean;
|
||||
showBarsOnScroll: boolean;
|
||||
}
|
||||
|
||||
export interface TTSConfig {
|
||||
ttsRate: number;
|
||||
ttsVoice: string;
|
||||
ttsLocation: string;
|
||||
}
|
||||
|
||||
export interface TranslatorConfig {
|
||||
translationEnabled: boolean;
|
||||
translationProvider: string;
|
||||
translateTargetLang: string;
|
||||
}
|
||||
|
||||
export interface ScreenConfig {
|
||||
@@ -137,6 +156,7 @@ export interface ViewSettings
|
||||
BookFont,
|
||||
ViewConfig,
|
||||
TTSConfig,
|
||||
TranslatorConfig,
|
||||
ScreenConfig {}
|
||||
|
||||
export interface BookProgress {
|
||||
@@ -146,6 +166,7 @@ export interface BookProgress {
|
||||
sectionLabel: string;
|
||||
section: PageInfo;
|
||||
pageinfo: PageInfo;
|
||||
timeinfo: TimeInfo;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ReadSettings {
|
||||
notebookWidth: string;
|
||||
isNotebookPinned: boolean;
|
||||
autohideCursor: boolean;
|
||||
translationProvider: string;
|
||||
translateTargetLang: string;
|
||||
|
||||
highlightStyle: HighlightStyle;
|
||||
@@ -30,6 +31,7 @@ export interface SystemSettings {
|
||||
openLastBooks: boolean;
|
||||
lastOpenBooks: string[];
|
||||
autoImportBooksOnOpen: boolean;
|
||||
telemetryEnabled: boolean;
|
||||
libraryViewMode: LibraryViewModeType;
|
||||
librarySortBy: LibrarySortByType;
|
||||
librarySortAscending: boolean;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export type UserStorageQuota = {
|
||||
export interface UserQuota {
|
||||
free: number;
|
||||
plus: number;
|
||||
pro: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type UserPlan = keyof UserStorageQuota;
|
||||
export type UserPlan = keyof UserQuota;
|
||||
export type UserStorageQuota = UserQuota;
|
||||
export type UserDailyTranslationQuota = UserQuota;
|
||||
|
||||
export type QuotaType = {
|
||||
name: string;
|
||||
|
||||
@@ -10,11 +10,12 @@ export interface FoliateView extends HTMLElement {
|
||||
init: (options: { lastLocation: string }) => void;
|
||||
goTo: (href: string) => void;
|
||||
goToFraction: (fraction: number) => void;
|
||||
prev: (distance: number) => void;
|
||||
next: (distance: number) => void;
|
||||
prev: (distance?: number) => void;
|
||||
next: (distance?: number) => void;
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
resolveCFI: (cfi: string) => { index: number; anchor: (doc: Document) => Range };
|
||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
||||
clearSearch: () => void;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user