Compare commits

...

26 Commits

Author SHA1 Message Date
Huang Xin cc85e5c304 release: version 0.9.49 (#1247) 2025-05-27 14:47:09 +02:00
Huang Xin b0cc0b6f1a layout: better organized panels in settings dialog (#1246) 2025-05-27 14:37:32 +02:00
Huang Xin 6b033f7509 translator: post-process translated text for punctuation spacing (#1245) 2025-05-27 14:32:25 +02:00
Huang Xin 5c0670031a fix: use updated library when dropfile to import on web browser (#1244) 2025-05-26 16:33:18 +02:00
Huang Xin 35735cd931 fix: skip speech when the text is empty at the end of some chapters, closes #1231 (#1243) 2025-05-26 16:03:43 +02:00
Huang Xin 699a01c78f feat: support full book translation to bilingual books, closes #398 (#1240) 2025-05-26 12:23:33 +02:00
Huang Xin 033e161455 layout: overlay the footer of the translator popup and less sensitive page flip with mouse wheel (#1238)
* layout: overlay the footer of the translator popup

* fix: less sensitive page flip with mouse wheel, addresses #1165
2025-05-25 09:32:00 +02:00
Huang Xin e6428656c2 fix: select filtered books when activating select all (#1237) 2025-05-25 08:02:34 +02:00
Huang Xin 68c3c45da3 feat: add an option to opt-out telemetry (#1236) 2025-05-25 07:48:57 +02:00
Huang Xin 36e87d4eb6 layout: display full publisher field in book details, closes #1224 (#1234) 2025-05-24 07:14:22 +02:00
Huang Xin 7f5059a8ea tts: always infer language code from script in bilingual TTS (#1233) 2025-05-24 07:01:41 +02:00
Huang Xin 8dc04f4095 feat: add bilingual TTS support for bilingual books, closes #1226 (#1230) 2025-05-23 18:18:24 +02:00
Huang Xin 6a48f3feb4 fix: use external browsers to open links in epub file, closes #1211 (#1228) 2025-05-23 08:15:57 +02:00
Huang Xin facf5fba75 fix: reorder icons in the ico file for Windows, closes #1175 (#1227) 2025-05-22 16:47:21 +02:00
Huang Xin 9700942e24 fix: transient navigation bar should work now for Android 8-15, closes #1199 (#1225) 2025-05-22 16:12:59 +02:00
Huang Xin e238b49fbf feat: add an option to invert image color in dark mode (#1223) 2025-05-22 10:38:09 +02:00
Huang Xin 32dfadf182 doc: update screenshots (#1222) 2025-05-22 09:34:16 +02:00
Huang Xin 66744108e2 css: unset link color in footnotes in dark mode for safari (#1221) 2025-05-22 09:09:02 +02:00
Huang Xin 401f28847c fix: add location only when toc item is at the same level as the section (#1220) 2025-05-22 08:08:52 +02:00
Huang Xin 9674b45cf0 layout: align text end for select labels on safari (#1219) 2025-05-22 07:03:10 +02:00
Huang Xin a4bc88d305 feat: add azure and google translator providers, closes #1135 and closes #1215 (#1218) 2025-05-21 17:25:00 +02:00
Huang Xin b6b8b17760 fix: normalize input text by removing newlines to prevent translation glitches (#1210) 2025-05-21 05:26:20 +02:00
Huang Xin 14810d6737 feat: add select all button in select mode, closes #1202 (#1209) 2025-05-20 16:58:04 +02:00
Huang Xin ed6c92a65a css: don't override body background, closes #1206 (#1208)
Revisit #1151.
2025-05-20 15:11:36 +02:00
Huang Xin 20d7a1185e layout: grouping modal now should stay on top of the header bar (#1207) 2025-05-20 14:12:17 +02:00
Huang Xin 32bcfda9ad fix: reimporting books will update the import time, being consistent with update time, closes #1200 (#1203) 2025-05-20 04:40:41 +02:00
89 changed files with 2181 additions and 742 deletions
+2 -2
View File
@@ -78,10 +78,10 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![Annotations](./data/screenshots/annotations.png)
![DeepL](./data/screenshots/deepl.png)
![TTS](./data/screenshots/tts_speak_aloud.png)
![DeepL](./data/screenshots/deepl.png)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.43",
"version": "0.9.49",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -68,7 +68,7 @@
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"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",
@@ -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,37 @@
"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": "لغة النظام"
}
@@ -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,33 @@
"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"
}
@@ -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,33 @@
"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": "Γλώσσα συστήματος"
}
@@ -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,34 @@
"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"
}
@@ -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,34 @@
"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"
}
@@ -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,33 @@
"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": "सिस्टम भाषा"
}
@@ -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,32 @@
"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"
}
@@ -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,34 @@
"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"
}
@@ -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,32 @@
"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": "システム言語"
}
@@ -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,32 @@
"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": "시스템 언어"
}
@@ -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,31 @@
"Reveal in Folder": "Tonen in map",
"Dont 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"
}
@@ -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,35 @@
"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"
}
@@ -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,34 @@
"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"
}
@@ -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,35 @@
"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": "Системный язык"
}
@@ -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,33 @@
"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": "Readesti 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"
}
@@ -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,35 @@
"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": "Системна мова"
}
@@ -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,32 @@
"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"
}
@@ -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,32 @@
"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": "系统语言"
}
@@ -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,32 @@
"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": "系統語言"
}
+11
View File
@@ -1,5 +1,16 @@
{
"releases": {
"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

@@ -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
+1 -1
View File
@@ -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,
@@ -67,14 +72,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
useEffect(() => {
if (isSelectMode) {
setShowSelectModeActions(true);
} else {
setSelectedBooks([]);
setShowSelectModeActions(false);
}
}, [isSelectMode]);
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
useEffect(() => {
if (isImportingBook.current) return;
@@ -169,14 +167,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 +198,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');
@@ -245,6 +250,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 +376,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
</div>
{showGroupingModal && (
<div>
<ModalPortal>
<GroupingModal
libraryBooks={libraryBooks}
selectedBooks={selectedBooks}
@@ -367,7 +389,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleSetSelectMode(false);
}}
/>
</div>
</ModalPortal>
)}
{showDeleteAlert && (
<div
@@ -378,7 +400,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',
@@ -24,14 +24,20 @@ 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();
@@ -182,14 +188,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>
) : (
@@ -16,6 +16,7 @@ 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';
@@ -40,6 +41,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
);
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
const iconSize = useResponsiveSize(16);
const showAboutReadest = () => {
@@ -119,6 +121,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);
@@ -215,6 +229,12 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
<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>
);
};
+24 -11
View File
@@ -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}
@@ -28,6 +28,7 @@ import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -52,6 +53,7 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -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 {
@@ -128,6 +129,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
</div>
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4'>
<TranslationToggler bookKey={bookKey} />
<SettingsToggler />
<NotebookToggler bookKey={bookKey} />
<Dropdown
@@ -15,6 +15,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { eventDispatcher } from '@/utils/event';
import { interceptGlobalOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/utils/style';
import { setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
@@ -39,6 +40,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useEffect(() => {
mountAdditionalFonts(document);
interceptGlobalOpen();
}, []);
useEffect(() => {
@@ -0,0 +1,46 @@
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';
const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getBookData } = useBookDataStore();
const { getViewSettings, setViewSettings, setHoveredBookKey } = useReaderStore();
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
useEffect(() => {
if (translationEnabled === viewSettings.translationEnabled) return;
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 || isSameLang(bookData.book?.primaryLanguage, viewSettings.translateTargetLang!)
}
onClick={() => setTranslationEnabled(!translationEnabled)}
tooltip={translationEnabled ? _('Disable Translation') : _('Enable Translation')}
tooltipDirection='bottom'
></Button>
);
};
export default TranslationToggler;
@@ -33,9 +33,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const { getView, getViewSettings, setViewSettings } = useReaderStore();
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));
@@ -76,6 +79,12 @@ 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]);
return (
<div
tabIndex={0}
@@ -135,6 +144,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();
@@ -499,7 +499,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
)}
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
<DeepLPopup
<TranslatorPopup
text={selection?.text as string}
position={translatorPopupPosition}
trianglePosition={trianglePosition}
@@ -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;
@@ -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'>
@@ -21,6 +21,7 @@ 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!);
useEffect(() => {
if (isScrolledMode === viewSettings.scrolled) return;
@@ -67,10 +68,20 @@ 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]);
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 +103,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 +111,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 +138,34 @@ 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>
@@ -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;
@@ -162,6 +162,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]);
@@ -246,46 +247,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 +397,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>
@@ -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,83 +131,82 @@ 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 items-center justify-between'>
<button
tabIndex={-1}
onClick={handleClose}
className={
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
}
>
{isRtl ? <MdArrowForwardIos /> : <MdArrowBackIosNew />}
</button>
<div
ref={tabsRef}
className={clsx(
'dialog-tabs ms-2 flex h-10 w-full items-center sm:ms-0',
showAllTabLabels ? 'gap-1' : 'gap-2',
)}
>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
data-tab={tab}
className={clsx(
'btn btn-ghost text-base-content btn-sm',
showAllTabLabels ? 'gap-0 px-1.5' : 'gap-1 px-2',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => handleSetActivePanel(tab)}
>
<Icon className='mr-0' />
<span
className={clsx('label', !(showAllTabLabels || activePanel === tab) && 'hidden')}
>
{label}
</span>
</button>
))}
</div>
<div className='flex h-full items-center justify-end gap-x-2'>
<Dropdown
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 flex items-center justify-center'
toggleButton={<PiDotsThreeVerticalBold />}
>
<DialogMenu />
</Dropdown>
<button
tabIndex={-1}
onClick={handleClose}
className={
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 p-0 sm:flex'
}
>
{isRtl ? <MdArrowForwardIos /> : <MdArrowBackIosNew />}
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path
fill='currentColor'
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
/>
</svg>
</button>
<div
ref={tabsRef}
className={clsx(
'dialog-tabs flex h-10 w-full items-center',
showTabLabels ? 'gap-4 sm:gap-2' : 'gap-4',
)}
>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
data-tab={tab}
className={clsx(
'btn btn-ghost text-base-content btn-sm px-1',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => handleSetActivePanel(tab)}
>
<Icon className='mr-0' />
<span className={clsx('label', !showTabLabels && 'hidden')}>{label}</span>
</button>
))}
</div>
<div className='flex h-full items-center justify-end gap-x-2'>
<Dropdown
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 flex items-center justify-center'
toggleButton={<PiDotsThreeVerticalBold />}
>
<DialogMenu />
</Dropdown>
<button
onClick={handleClose}
className={
'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 p-0 sm:flex'
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
width='1em'
height='1em'
viewBox='0 0 24 24'
>
<path
fill='currentColor'
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
/>
</svg>
</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>
);
};
@@ -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);
}
@@ -0,0 +1,201 @@
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 | 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 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);
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) {
recreateTranslationObserver();
}
} else if (providerChanged || targetLangChanged) {
updateTranslation();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey, viewSettings, provider, targetLang]);
useEffect(() => {
if (!view) return;
const onLoad = () => {
const observer = createTranslationObserver();
observerRef.current = observer;
const nodes = walkTextNodes(view);
allTextNodes.current = nodes;
nodes.forEach((el) => observer.observe(el));
};
view.addEventListener('load', onLoad);
return () => {
view.removeEventListener('load', onLoad);
observerRef.current?.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
useEffect(() => {
return () => {
observerRef.current?.disconnect();
translatedElements.current = [];
};
}, [view]);
}
+5 -5
View File
@@ -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,19 +179,17 @@ 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>
@@ -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);
}}
+1 -1
View File
@@ -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}
+38 -24
View File
@@ -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;
@@ -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>
);
}
+10 -1
View File
@@ -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>;
+25 -15
View File
@@ -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,7 +59,7 @@ 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,
),
),
@@ -68,7 +71,11 @@ export function useTranslator({
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,
};
}
+8 -1
View File
@@ -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.'));
@@ -130,8 +130,11 @@ async function callDeepLAPI(
) {
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),
};
+5 -1
View File
@@ -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();
}
+10 -1
View File
@@ -5,6 +5,7 @@ import {
BookStyle,
HighlightColor,
ScreenConfig,
TranslatorConfig,
TTSConfig,
ViewConfig,
ViewSettings,
@@ -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: [],
@@ -117,7 +120,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
textIndent: 0,
fullJustification: true,
hyphenation: true,
invert: false,
invertImgColorInDark: false,
theme: 'light',
overrideFont: false,
overrideLayout: false,
@@ -145,6 +148,12 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsVoice: '',
};
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
translationEnabled: false,
translationProvider: 'deepl',
translateTargetLang: '',
};
export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
screenOrientation: 'auto',
};
@@ -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;
}
@@ -10,7 +10,6 @@ export class EdgeTTSClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: TTSVoice | null = null;
#currentVoiceLang = '';
#voices: TTSVoice[] = [];
#edgeTTS: EdgeSpeechTTS;
@@ -45,25 +44,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 +71,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,7 +115,12 @@ 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);
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;
@@ -240,9 +253,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
@@ -1,5 +1,6 @@
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';
@@ -81,9 +82,11 @@ 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, ' ');
return ssml;
}
@@ -104,7 +107,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 +115,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) {
@@ -1,8 +1,6 @@
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 { TTSUtils } from './TTSUtils';
@@ -46,105 +44,30 @@ 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>,
) {
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;
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 {
@@ -178,7 +101,6 @@ export class WebSpeechClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: SpeechSynthesisVoice | null = null;
#currentVoiceLang = '';
#voices: SpeechSynthesisVoice[] = [];
#synth = window.speechSynthesis;
available = true;
@@ -209,6 +131,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 +151,17 @@ 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,
)) {
if (signal.aborted) {
console.log('TTS aborted');
@@ -301,9 +228,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');
+8 -1
View File
@@ -99,7 +99,7 @@ export interface BookStyle {
textIndent: number;
fullJustification: boolean;
hyphenation: boolean;
invert: boolean;
invertImgColorInDark: boolean;
theme: string;
overrideFont: boolean;
overrideLayout: boolean;
@@ -127,6 +127,12 @@ export interface TTSConfig {
ttsVoice: string;
}
export interface TranslatorConfig {
translationEnabled: boolean;
translationProvider: string;
translateTargetLang: string;
}
export interface ScreenConfig {
screenOrientation: 'auto' | 'portrait' | 'landscape';
}
@@ -137,6 +143,7 @@ export interface ViewSettings
BookFont,
ViewConfig,
TTSConfig,
TranslatorConfig,
ScreenConfig {}
export interface BookProgress {
+2
View File
@@ -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;
+2 -2
View File
@@ -10,8 +10,8 @@ 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;
+38
View File
@@ -0,0 +1,38 @@
interface DebounceOptions {
emitLast?: boolean;
}
/**
* Debounces a function by waiting `delay` ms after the last call before executing it.
* If `emitLast` is false, it cancels the call instead of delaying it.
*/
export const debounce = <T extends (...args: Parameters<T>) => void | Promise<void>>(
func: T,
delay: number,
options: DebounceOptions = { emitLast: true },
): ((...args: Parameters<T>) => void) => {
let timeout: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
return (...args: Parameters<T>): void => {
if (timeout) {
clearTimeout(timeout);
}
if (options.emitLast) {
lastArgs = args;
timeout = setTimeout(() => {
if (lastArgs) {
func(...(lastArgs as Parameters<T>));
lastArgs = null;
}
timeout = null;
}, delay);
} else {
timeout = setTimeout(() => {
func(...args);
timeout = null;
}, delay);
}
};
};
+50
View File
@@ -1,3 +1,53 @@
export const isCJKStr = (str: string) => {
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(str ?? '');
};
export const langToDefaultLocale = (langCode: string): string => {
const mapping: Record<string, string> = {
en: 'en-US',
fr: 'fr-FR',
de: 'de-DE',
es: 'es-ES',
it: 'it-IT',
ja: 'ja-JP',
ko: 'ko-KR',
pt: 'pt-PT',
ar: 'ar-SA',
nl: 'nl-NL',
pl: 'pl-PL',
tr: 'tr-TR',
id: 'id-ID',
ru: 'ru-RU',
uk: 'uk-UA',
zh: 'zh-Hans',
'zh-hans': 'zh-Hans',
'zh-hant': 'zh-Hant',
};
return mapping[langCode] || langCode;
};
export const localeToLang = (locale: string): string => {
const mapping: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant',
'zh-HK': 'zh-Hant',
};
if (locale.startsWith('zh')) {
return mapping[locale] || 'zh-Hans';
}
return mapping[locale] || locale.split('-')[0]!.toLowerCase();
};
export const normalizedLangCode = (lang: string | null | undefined): string => {
if (!lang) return '';
return lang.split('-')[0]!.toLowerCase();
};
export const isSameLang = (lang1?: string | null, lang2?: string | null): boolean => {
if (!lang1 || !lang2) return false;
const normalizedLang1 = normalizedLangCode(lang1);
const normalizedLang2 = normalizedLangCode(lang2);
return normalizedLang1 === normalizedLang2;
};
+8
View File
@@ -40,6 +40,14 @@ export const getUserLang = () => {
return locale.split('-')[0] || 'en';
};
export const getTargetLang = () => {
const locale = getLocale();
if (locale.startsWith('zh')) {
return locale === 'zh-Hant' || locale === 'zh-HK' || locale === 'zh-TW' ? 'zh-Hant' : 'zh-Hans';
}
return locale.split('-')[0] || 'en';
};
export const isCJKEnv = () => {
const browserLanguage = navigator.language || '';
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
+18
View File
@@ -0,0 +1,18 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { isTauriAppPlatform } from '@/services/environment';
export const interceptGlobalOpen = () => {
const windowOpen = window.open;
globalThis.open = function (
url?: string | URL,
target?: string,
features?: string,
): Window | null {
if (isTauriAppPlatform()) {
openUrl(url?.toString() || '');
return null;
} else {
return windowOpen(url, target, features);
}
};
};
+66 -33
View File
@@ -2,35 +2,65 @@ type TTSMark = {
offset: number;
name: string;
text: string;
language?: string;
};
export const parseSSMLMarks = (ssml: string) => {
ssml = ssml.replace(/<speak[^>]*>/i, '');
ssml = ssml.replace(/<\/speak>/i, '');
const cleanTextContent = (text: string) =>
text.replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ').trimStart();
export const parseSSMLMarks = (ssml: string) => {
const speakMatch = ssml.match(/<speak[^>]*xml:lang="([^"]+)"[^>]*>/i);
const defaultLang = speakMatch?.[1] ?? 'en';
ssml = ssml.replace(/<speak[^>]*>/i, '').replace(/<\/speak>/i, '');
const markRegex = /<mark\s+name="([^"]+)"\s*\/>/g;
let plainText = '';
const marks: TTSMark[] = [];
let match;
while ((match = markRegex.exec(ssml)) !== null) {
const markTagEndIndex = markRegex.lastIndex;
const nextMarkIndex = ssml.indexOf('<mark', markTagEndIndex);
const nextChunk = ssml.slice(
markTagEndIndex,
nextMarkIndex !== -1 ? nextMarkIndex : ssml.length,
);
const cleanedChunk = nextChunk
.replace(/<[^>]+>/g, '')
.replace(/\r\n/g, ' ')
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.trimStart();
plainText += cleanedChunk;
let activeMark: string | null = null;
let currentLang = defaultLang;
const langStack: string[] = [];
const offset = plainText.length - cleanedChunk.length;
const markName = match[1]!;
marks.push({ offset, name: markName, text: cleanedChunk });
const tagRegex = /<(\/?)(\w+)([^>]*)>|([^<]+)/g;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(ssml)) !== null) {
if (match[4]) {
const rawText = match[4];
const text = cleanTextContent(rawText);
if (text && activeMark) {
const offset = plainText.length;
plainText += text;
marks.push({
offset,
name: activeMark,
text,
language: inferLangFromScript(text, currentLang) || currentLang,
});
} else {
plainText += cleanTextContent(rawText);
}
} else {
const isEnd = match[1] === '/';
const tagName = match[2];
const attr = match[3];
if (tagName === 'mark' && !isEnd) {
const nameMatch = attr?.match(/name="([^"]+)"/);
if (nameMatch) {
activeMark = nameMatch[1]!;
}
} else if (tagName === 'lang') {
if (!isEnd) {
langStack.push(currentLang);
const langMatch = attr?.match(/xml:lang="([^"]+)"/);
if (langMatch) {
currentLang = langMatch[1]!;
}
} else {
currentLang = langStack.pop() ?? defaultLang;
}
}
}
}
return { plainText, marks };
@@ -56,6 +86,19 @@ export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
return result;
};
const inferLangFromScript = (text: string, lang: string | null): string | null => {
if (!lang || lang.startsWith('en')) {
if (/[\p{Script=Hangul}]/u.test(text)) {
return 'ko';
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
return 'ja';
} else if (/[\p{Script=Han}]/u.test(text)) {
return 'zh';
}
}
return lang;
};
export const parseSSMLLang = (ssml: string): string | null => {
let lang = null;
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
@@ -66,15 +109,5 @@ export const parseSSMLLang = (ssml: string): string | null => {
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
: parts[0]!.toLowerCase();
}
if (!lang || lang.startsWith('en')) {
if (/[\p{Script=Hangul}]/u.test(ssml)) {
lang = 'ko';
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(ssml)) {
lang = 'ja';
} else if (/[\p{Script=Han}]/u.test(ssml)) {
lang = 'zh';
}
}
return lang;
return inferLangFromScript(ssml, lang);
};
+29 -20
View File
@@ -29,7 +29,7 @@ const getFontStyles = (
overrideFont: boolean,
themeCode: ThemeCode,
) => {
const { fg, primary } = themeCode;
const { fg, primary, isDarkMode } = themeCode;
const lastSerifFonts = ['Georgia', 'Times New Roman'];
const serifFonts = [
serif,
@@ -87,15 +87,9 @@ const getFontStyles = (
${overrideFont ? 'font-family: revert !important;' : ''}
}
a:any-link, a:any-link * {
${overrideFont ? `color: ${primary};` : ''}
${overrideFont ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''}
text-decoration: none;
}
/* https://github.com/whatwg/html/issues/5426 */
@media (prefers-color-scheme: dark) {
a:any-link, a:any-link * {
${overrideFont ? `color: ${primary};` : `color: lightblue;`}
}
}
/* override inline hardcoded text color */
*[style*="color: rgb(0,0,0)"], *[style*="color: rgb(0, 0, 0)"],
*[style*="color: #000"], *[style*="color: #000000"], *[style*="color: black"],
@@ -188,6 +182,7 @@ const getLayoutStyles = (
zoomLevel: number,
writingMode: string,
vertical: boolean,
invertImgColorInDark: boolean,
themeCode: ThemeCode,
) => {
const { bg, fg, primary, isDarkMode } = themeCode;
@@ -226,7 +221,7 @@ const getLayoutStyles = (
text-align: var(--default-text-align);
max-height: unset;
}
html, body {
html {
background-color: var(--theme-bg-color, transparent);
background: var(--background-set, none);
}
@@ -253,6 +248,10 @@ const getLayoutStyles = (
hanging-punctuation: allow-end last;
widows: 2;
}
p:has(> img:only-child) {
text-indent: unset !important;
text-align: unset !important;
}
p, div {
${vertical ? `margin-left: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${vertical ? `margin-right: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
@@ -293,11 +292,13 @@ const getLayoutStyles = (
color: unset;
}
img {
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
}
/* inline images without dimension */
p img, span img, sup img {
height: 1em;
mix-blend-mode: ${isDarkMode ? 'screen' : 'multiply'};
${isDarkMode ? 'filter: invert(100%);' : ''}
}
p:has(img), span:has(img) {
background-color: ${bg};
@@ -318,7 +319,7 @@ const getLayoutStyles = (
#pg-header * {
color: inherit !important;
}
.x-ebookmaker-cover {
.x-ebookmaker, .x-ebookmaker-cover, .x-ebookmaker-coverpage {
background-color: unset !important;
}
@@ -356,6 +357,16 @@ export const getFootnoteStyles = () => `
}
`;
const getTranslationStyles = () => `
.translation-block-wrapper {
display: block !important;
margin: 0.5em 0 !important;
}
.translation-target.hidden {
display: none !important;
}
`;
export interface ThemeCode {
bg: string;
fg: string;
@@ -417,6 +428,7 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
viewSettings.zoomLevel! / 100.0,
viewSettings.writingMode!,
viewSettings.vertical!,
viewSettings.invertImgColorInDark!,
themeCode,
);
// scale the font size on-the-fly so that we can sync the same font size on different devices
@@ -434,8 +446,9 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
viewSettings.overrideFont!,
themeCode,
);
const translationStyles = getTranslationStyles();
const userStylesheet = viewSettings.userStylesheet!;
return `${layoutStyles}\n${fontStyles}\n${userStylesheet}`;
return `${layoutStyles}\n${fontStyles}\n${translationStyles}\n${userStylesheet}`;
};
export const mountAdditionalFonts = (document: Document) => {
@@ -507,13 +520,9 @@ export const transformStylesheet = (
return `font-size: ${rem}rem`;
})
.replace(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * w) / 100 + 'px')
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * h) / 100 + 'px');
if (viewSettings.overrideFont) {
css = css
.replace(/[\s;]color\s*:\s*#000000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*#000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)');
}
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * h) / 100 + 'px')
.replace(/[\s;]color\s*:\s*#000000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*#000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)');
return css;
};
+22
View File
@@ -0,0 +1,22 @@
import posthog from 'posthog-js';
export const TELEMETRY_OPT_OUT_KEY = 'readest-telemetry-opt-out';
export const hasOptedOutTelemetry = () => {
return localStorage.getItem(TELEMETRY_OPT_OUT_KEY) === 'true';
};
export const captureEvent = (event: string, properties?: Record<string, unknown>) => {
if (!hasOptedOutTelemetry()) {
posthog.capture(event, properties);
}
};
export const optInTelemetry = () => {
localStorage.setItem(TELEMETRY_OPT_OUT_KEY, 'false');
posthog.opt_in_capturing();
};
export const optOutTelemetry = () => {
localStorage.setItem(TELEMETRY_OPT_OUT_KEY, 'true');
posthog.opt_out_capturing();
};
+1 -1
View File
@@ -1,4 +1,4 @@
export interface ThrottleOptions {
interface ThrottleOptions {
emitLast?: boolean;
}
+5 -1
View File
@@ -75,7 +75,11 @@ const updateTocData = (
const section = sections[id];
if (section) {
item.cfi = section.cfi;
item.location = section.location;
// Add location only when toc item is at the same level as the section
// otherwise the location will not be accurate
if (id === item.href) {
item.location = section.location;
}
}
}
if (item.subitems) {
+40
View File
@@ -0,0 +1,40 @@
export const walkTextNodes = (root: HTMLElement): HTMLElement[] => {
const elements: HTMLElement[] = [];
const walk = (node: HTMLElement | Document | ShadowRoot, depth = 0) => {
if (depth > 15) return;
if (node instanceof HTMLElement && node.shadowRoot) {
walk(node.shadowRoot, depth + 1);
}
const children = 'children' in node ? (Array.from(node.children) as HTMLElement[]) : [];
for (const child of children) {
if (child.tagName === 'STYLE' || child.tagName === 'LINK') {
continue;
}
if (child.shadowRoot) {
walk(child.shadowRoot, depth + 1);
}
if (child.tagName === 'IFRAME') {
const iframe = child as HTMLIFrameElement;
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDoc && iframeDoc.body) {
walk(iframeDoc.body, depth + 1);
}
}
const hasDirectText =
child.childNodes &&
Array.from(child.childNodes).some(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
);
if (child.children.length === 0 && child.textContent?.trim()) {
elements.push(child);
} else if (hasDirectText) {
elements.push(child);
} else if (child.children.length > 0) {
walk(child, depth + 1);
}
}
};
walk(root);
return elements;
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+13 -5
View File
@@ -135,8 +135,8 @@ importers:
specifier: 15.2.4
version: 15.2.4(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
posthog-js:
specifier: ^1.205.0
version: 1.205.0
specifier: ^1.246.0
version: 1.246.0
react:
specifier: 19.0.0
version: 19.0.0
@@ -4877,8 +4877,16 @@ packages:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.205.0:
resolution: {integrity: sha512-zP4SQ9Dg9JwqkEteoAOviAAAMdT/nJ4vk1jqfE6fVudziEa3szkQWd7czk5ehlEdrKFUE85MonCKW4L/uwtybA==}
posthog-js@1.246.0:
resolution: {integrity: sha512-5lN/UMqDfxsLeSnT3LsY4P+eD1H+P9qxgN/iUk473LmhCM7IV8TAfdjJj23nnXk/euGNQtvyINYoD2DG+d4eEw==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
preact@10.25.0:
resolution: {integrity: sha512-6bYnzlLxXV3OSpUxLdaxBmE7PMOu0aR3pG6lryK/0jmvcDFPlcXGQAt5DpK3RITWiDrfYZRI0druyaK/S9kYLg==}
@@ -11851,7 +11859,7 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-js@1.205.0:
posthog-js@1.246.0:
dependencies:
core-js: 3.39.0
fflate: 0.4.8