forked from akai/readest
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f47d6d393 | |||
| 38101cb76d | |||
| 8f0982ae33 | |||
| 1c015f5973 | |||
| 4c1af671bb | |||
| b4cda00a91 | |||
| c326ad402d | |||
| e03636e0a1 | |||
| 9669f1272d | |||
| 4094fd86ff | |||
| 27df89e261 | |||
| ddcee6b3df | |||
| 5e04f6ae03 | |||
| 6299ea09b7 | |||
| 4f6f45fe8a | |||
| 6b290f09f5 | |||
| c9e08f2c01 | |||
| 8167f27156 | |||
| 77c7176526 | |||
| a8d657414d | |||
| ed368e1205 | |||
| 7de1bdf824 | |||
| 141c502c76 | |||
| b1ea729a54 | |||
| d3eff19ab9 | |||
| 59f0cede39 | |||
| 946d17882b | |||
| f9adf789d7 | |||
| efb8f70d01 | |||
| e235cb98d2 | |||
| 4275508ccd | |||
| a424ae8b15 | |||
| 9303ec8c5f | |||
| 98faed320a | |||
| 5bbdc70420 | |||
| de21182240 | |||
| 4ae35992a1 |
Generated
+1
-1
@@ -14,7 +14,6 @@ version = "0.2.2"
|
||||
dependencies = [
|
||||
"block",
|
||||
"cocoa",
|
||||
"font-enumeration",
|
||||
"futures-util",
|
||||
"log",
|
||||
"objc",
|
||||
@@ -5438,6 +5437,7 @@ dependencies = [
|
||||
name = "tauri-plugin-native-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"font-enumeration",
|
||||
"schemars",
|
||||
"serde",
|
||||
"tauri",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.37",
|
||||
"version": "0.9.39",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "حذف",
|
||||
"Delete Highlight": "حذف التمييز",
|
||||
"Dictionary": "القاموس",
|
||||
"Disable Click-to-Flip": "تعطيل التقليب بالنقر",
|
||||
"Download Readest": "تحميل ريديست",
|
||||
"Edit": "تحرير",
|
||||
"Enter your custom CSS here...": "أدخل الـ CSS المخصص هنا...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "غير معروف",
|
||||
"Untitled": "بدون عنوان",
|
||||
"Updated:": "تم التحديث:",
|
||||
"User avatar": "صورة المستخدم",
|
||||
"Version {{version}}": "الإصدار {{version}}",
|
||||
"Vertical Direction": "الاتجاه العمودي",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "مرحبًا بكم في مكتبتكم. يمكنكم استيراد كتبكم هنا وقراءتها في أي وقت.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "تبديل منطقة التقليب بالنقر",
|
||||
"No chapters detected.": "لم يتم التعرف على فصول.",
|
||||
"Failed to parse the EPUB file.": "فشل في تحليل ملف EPUB.",
|
||||
"This book format is not supported.": "هذا تنسيق الكتاب غير مدعوم.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "الوصف:",
|
||||
"No description available": "لا يوجد وصف متاح",
|
||||
"List": "قائمة",
|
||||
"Grid": "شبكة"
|
||||
"Grid": "شبكة",
|
||||
"(from 'As You Like It', Act II)": "(من 'كما تحب'، الفصل الثاني)",
|
||||
"Link Color": "لون الرابط",
|
||||
"Volume Keys for Page Flip": "مفاتيح الصوت لتقليب الصفحات",
|
||||
"Clicks for Page Flip": "نقرات لتقليب الصفحات",
|
||||
"Swap Clicks Area": "تبديل منطقة النقرات",
|
||||
"Screen": "الشاشة",
|
||||
"Orientation": "الاتجاه",
|
||||
"Portrait": "العمودي",
|
||||
"Landscape": "الأفقي",
|
||||
"Open Last Book on Start": "فتح آخر كتاب عند البدء"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Löschen",
|
||||
"Delete Highlight": "Hervorhebung löschen",
|
||||
"Dictionary": "Wörterbuch",
|
||||
"Disable Click-to-Flip": "Klick zum Blättern deaktivieren",
|
||||
"Download Readest": "Readest herunterladen",
|
||||
"Edit": "Bearbeiten",
|
||||
"Enter your custom CSS here...": "Geben Sie hier Ihr benutzerdefiniertes CSS ein...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Unbekannt",
|
||||
"Untitled": "Ohne Titel",
|
||||
"Updated:": "Aktualisiert:",
|
||||
"User avatar": "Benutzer-Avatar",
|
||||
"Version {{version}}": "Version {{version}}",
|
||||
"Vertical Direction": "Vertikale Richtung",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Automatischer Import beim Öffnen einer Datei",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Klick-zu-Blättern-Bereich tauschen",
|
||||
"No chapters detected.": "Keine Kapitel erkannt.",
|
||||
"Failed to parse the EPUB file.": "Fehler beim Parsen der EPUB-Datei.",
|
||||
"This book format is not supported.": "Dieses Buchformat wird nicht unterstützt.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Beschreibung:",
|
||||
"No description available": "Keine Beschreibung verfügbar",
|
||||
"List": "Liste",
|
||||
"Grid": "Raster"
|
||||
"Grid": "Raster",
|
||||
"(from 'As You Like It', Act II)": "(aus 'Wie es euch gefällt', Akt II)",
|
||||
"Link Color": "Linkfarbe",
|
||||
"Volume Keys for Page Flip": "Volumen-Tasten für Seitenwechsel",
|
||||
"Clicks for Page Flip": "Tippen zum Blättern",
|
||||
"Swap Clicks Area": "Tippbereiche tauschen",
|
||||
"Screen": "Bildschirm",
|
||||
"Orientation": "Orientierung",
|
||||
"Portrait": "Hochformat",
|
||||
"Landscape": "Querformat",
|
||||
"Open Last Book on Start": "Letztes Buch beim Start öffnen"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Διαγραφή",
|
||||
"Delete Highlight": "Διαγραφή επισήμανσης",
|
||||
"Dictionary": "Λεξικό",
|
||||
"Disable Click-to-Flip": "Απενεργοποίηση κλικ για γύρισμα σελίδας",
|
||||
"Download Readest": "Λήψη Readest",
|
||||
"Edit": "Επεξεργασία",
|
||||
"Enter your custom CSS here...": "Εισάγετε το προσαρμοσμένο CSS εδώ...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Άγνωστο",
|
||||
"Untitled": "Χωρίς τίτλο",
|
||||
"Updated:": "Ενημερώθηκε:",
|
||||
"User avatar": "Εικόνα χρήστη",
|
||||
"Version {{version}}": "Έκδοση {{version}}",
|
||||
"Vertical Direction": "Κάθετη κατεύθυνση",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Καλώς ήρθατε στη βιβλιοθήκη σας. Μπορείτε να εισάγετε τα βιβλία σας εδώ και να τα διαβάσετε οποιαδήποτε στιγμή.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Αυτόματη εισαγωγή κατά το άνοιγμα αρχείου",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας",
|
||||
"No chapters detected.": "Δεν εντοπίστηκαν κεφάλαια.",
|
||||
"Failed to parse the EPUB file.": "Αποτυχία ανάλυσης του αρχείου EPUB.",
|
||||
"This book format is not supported.": "Αυτή η μορφή βιβλίου δεν υποστηρίζεται.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Περιγραφή:",
|
||||
"No description available": "Δεν είναι διαθέσιμη καμία περιγραφή",
|
||||
"List": "Λίστα",
|
||||
"Grid": "Πλέγμα"
|
||||
"Grid": "Πλέγμα",
|
||||
"(from 'As You Like It', Act II)": "('Όπως σας αρέσει', Πράξη II)",
|
||||
"Link Color": "Χρώμα συνδέσμου",
|
||||
"Volume Keys for Page Flip": "Πλήκτρα έντασης για ανατροπή σελίδας",
|
||||
"Clicks for Page Flip": "Κλικ για ανατροπή σελίδας",
|
||||
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ",
|
||||
"Screen": "Οθόνη",
|
||||
"Orientation": "Προσανατολισμός",
|
||||
"Portrait": "Πορτραίτο",
|
||||
"Landscape": "Τοπίο",
|
||||
"Open Last Book on Start": "Άνοιγμα τελευταίου βιβλίου κατά την εκκίνηση"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Eliminar",
|
||||
"Delete Highlight": "Eliminar resaltado",
|
||||
"Dictionary": "Diccionario",
|
||||
"Disable Click-to-Flip": "Desactivar clic para voltear",
|
||||
"Download Readest": "Descargar Readest",
|
||||
"Edit": "Editar",
|
||||
"Enter your custom CSS here...": "Introduce tu CSS personalizado aquí...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Desconocido",
|
||||
"Untitled": "Sin título",
|
||||
"Updated:": "Actualizado:",
|
||||
"User avatar": "Avatar del usuario",
|
||||
"Version {{version}}": "Versión {{version}}",
|
||||
"Vertical Direction": "Dirección vertical",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Importación automática al abrir archivo",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Intercambiar área de clic para voltear",
|
||||
"No chapters detected.": "No se detectaron capítulos.",
|
||||
"Failed to parse the EPUB file.": "Error al analizar el archivo EPUB.",
|
||||
"This book format is not supported.": "Este formato de libro no es compatible.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Descripción:",
|
||||
"No description available": "No hay descripción disponible",
|
||||
"List": "Lista",
|
||||
"Grid": "Cuadrícula"
|
||||
"Grid": "Cuadrícula",
|
||||
"(from 'As You Like It', Act II)": "(de 'Como gustéis', Acto II)",
|
||||
"Link Color": "Color del enlace",
|
||||
"Volume Keys for Page Flip": "Botones de volumen para pasar página",
|
||||
"Clicks for Page Flip": "Toques para pasar página",
|
||||
"Swap Clicks Area": "Invertir zonas de toque",
|
||||
"Screen": "Pantalla",
|
||||
"Orientation": "Orientación",
|
||||
"Portrait": "Vertical",
|
||||
"Landscape": "Horizontal",
|
||||
"Open Last Book on Start": "Abrir último libro al iniciar"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Supprimer",
|
||||
"Delete Highlight": "Supprimer le surlignage",
|
||||
"Dictionary": "Dictionnaire",
|
||||
"Disable Click-to-Flip": "Désactiver le clic pour tourner",
|
||||
"Download Readest": "Télécharger Readest",
|
||||
"Edit": "Modifier",
|
||||
"Enter your custom CSS here...": "Saisissez votre CSS personnalisé ici...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Inconnu",
|
||||
"Untitled": "Sans titre",
|
||||
"Updated:": "Mis à jour :",
|
||||
"User avatar": "Avatar de l'utilisateur",
|
||||
"Version {{version}}": "Version {{version}}",
|
||||
"Vertical Direction": "Direction verticale",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Importation automatique à l'ouverture du fichier",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Échanger la zone de clic pour tourner",
|
||||
"No chapters detected.": "Aucun chapitre détecté.",
|
||||
"Failed to parse the EPUB file.": "Échec de l'analyse du fichier EPUB.",
|
||||
"This book format is not supported.": "Ce format de livre n'est pas pris en charge.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Description :",
|
||||
"No description available": "Aucune description disponible",
|
||||
"List": "Liste",
|
||||
"Grid": "Grille"
|
||||
"Grid": "Grille",
|
||||
"(from 'As You Like It', Act II)": "(de 'Comme il vous plaira', Acte II)",
|
||||
"Link Color": "Couleur du lien",
|
||||
"Volume Keys for Page Flip": "Boutons volume pour tourner la page",
|
||||
"Clicks for Page Flip": "Taps pour tourner la page",
|
||||
"Swap Clicks Area": "Inverser zones de tap",
|
||||
"Screen": "Écran",
|
||||
"Orientation": "Orientation",
|
||||
"Portrait": "Portrait",
|
||||
"Landscape": "Paysage",
|
||||
"Open Last Book on Start": "Ouvrir le dernier livre au démarrage"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "हटाएं",
|
||||
"Delete Highlight": "हाइलाइट हटाएं",
|
||||
"Dictionary": "शब्दकोश",
|
||||
"Disable Click-to-Flip": "क्लिक-टू-फ्लिप अक्षम करें",
|
||||
"Download Readest": "Readest डाउनलोड करें",
|
||||
"Edit": "संपादित करें",
|
||||
"Enter your custom CSS here...": "अपना कस्टम CSS यहां दर्ज करें...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "अज्ञात",
|
||||
"Untitled": "शीर्षकहीन",
|
||||
"Updated:": "अपडेट किया गया:",
|
||||
"User avatar": "उपयोगकर्ता अवतार",
|
||||
"Version {{version}}": "संस्करण {{version}}",
|
||||
"Vertical Direction": "ऊर्ध्वाधर दिशा",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "आपकी लाइब्रेरी में आपका स्वागत है। आप यहां अपनी पुस्तकें आयात कर सकते हैं और कभी भी पढ़ सकते हैं।",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "फ़ाइल खोलने पर स्वचालित आयात",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें",
|
||||
"No chapters detected.": "कोई अध्याय नहीं मिला।",
|
||||
"Failed to parse the EPUB file.": "EPUB फ़ाइल को पार्स करने में विफल।",
|
||||
"This book format is not supported.": "यह पुस्तक प्रारूप समर्थित नहीं है।",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "विवरण:",
|
||||
"No description available": "कोई विवरण उपलब्ध नहीं है",
|
||||
"List": "सूची",
|
||||
"Grid": "ग्रिड"
|
||||
"Grid": "ग्रिड",
|
||||
"(from 'As You Like It', Act II)": "('जैसा आप इसे पसंद करते हैं', अधिनियम II)",
|
||||
"Link Color": "लिंक रंग",
|
||||
"Volume Keys for Page Flip": "पृष्ठ फ़्लिप के लिए वॉल्यूम कुंजियाँ",
|
||||
"Clicks for Page Flip": "पृष्ठ फ़्लिप के लिए क्लिक करें",
|
||||
"Swap Clicks Area": "क्लिक क्षेत्र स्वैप करें",
|
||||
"Screen": "स्क्रीन",
|
||||
"Orientation": "अवयव",
|
||||
"Portrait": "पोर्ट्रेट",
|
||||
"Landscape": "लैंडस्केप",
|
||||
"Open Last Book on Start": "शुरू में अंतिम पुस्तक खोलें"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Hapus",
|
||||
"Delete Highlight": "Hapus Sorotan",
|
||||
"Dictionary": "Kamus",
|
||||
"Disable Click-to-Flip": "Nonaktifkan Klik untuk Membalik",
|
||||
"Download Readest": "Unduh Readest",
|
||||
"Edit": "Edit",
|
||||
"Enter your custom CSS here...": "Masukkan CSS kustom Anda di sini...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Tidak Diketahui",
|
||||
"Untitled": "Tanpa Judul",
|
||||
"Updated:": "Diperbarui:",
|
||||
"User avatar": "Avatar Pengguna",
|
||||
"Version {{version}}": "Versi {{version}}",
|
||||
"Vertical Direction": "Arah Vertikal",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Impor Otomatis saat File Dibuka",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Tukar Area Klik untuk Membalik",
|
||||
"No chapters detected.": "Tidak ada bab yang terdeteksi.",
|
||||
"Failed to parse the EPUB file.": "Gagal mengurai file EPUB.",
|
||||
"This book format is not supported.": "Format buku ini tidak didukung.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Deskripsi:",
|
||||
"No description available": "Tidak ada deskripsi yang tersedia",
|
||||
"List": "Daftar",
|
||||
"Grid": "Jala"
|
||||
"Grid": "Jala",
|
||||
"(from 'As You Like It', Act II)": "(dari 'Seperti yang Anda Inginkan', Bab II)",
|
||||
"Link Color": "Warna Tautan",
|
||||
"Volume Keys for Page Flip": "Volume Keys untuk Pembalikan Halaman",
|
||||
"Clicks for Page Flip": "Klik untuk Pembalikan Halaman",
|
||||
"Swap Clicks Area": "Area Klik Tukar",
|
||||
"Screen": "Screen",
|
||||
"Orientation": "Orientasi",
|
||||
"Portrait": "Potret",
|
||||
"Landscape": "Lanskap",
|
||||
"Open Last Book on Start": "Buka Buku Terakhir saat Memulai"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Elimina",
|
||||
"Delete Highlight": "Elimina evidenziazione",
|
||||
"Dictionary": "Dizionario",
|
||||
"Disable Click-to-Flip": "Disattiva click per voltare pagina",
|
||||
"Download Readest": "Scarica Readest",
|
||||
"Edit": "Modifica",
|
||||
"Enter your custom CSS here...": "Inserisci qui il tuo CSS personalizzato...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Sconosciuto",
|
||||
"Untitled": "Senza titolo",
|
||||
"Updated:": "Aggiornato:",
|
||||
"User avatar": "Avatar utente",
|
||||
"Version {{version}}": "Versione {{version}}",
|
||||
"Vertical Direction": "Direzione verticale",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Importazione automatica all'apertura del file",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Scambia area click per voltare pagina",
|
||||
"No chapters detected.": "Nessun capitolo rilevato.",
|
||||
"Failed to parse the EPUB file.": "Impossibile analizzare il file EPUB.",
|
||||
"This book format is not supported.": "Questo formato di libro non è supportato.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Descrizione:",
|
||||
"No description available": "Nessuna descrizione disponibile",
|
||||
"List": "Elenco",
|
||||
"Grid": "Griglia"
|
||||
"Grid": "Griglia",
|
||||
"(from 'As You Like It', Act II)": "(da 'Come vi piace', Atto II)",
|
||||
"Link Color": "Colore link",
|
||||
"Volume Keys for Page Flip": "Tasti volume per girare pagina",
|
||||
"Clicks for Page Flip": "Clic per girare pagina",
|
||||
"Swap Clicks Area": "Inverti area dei clic",
|
||||
"Screen": "Schermo",
|
||||
"Orientation": "Orientamento",
|
||||
"Portrait": "Ritratto",
|
||||
"Landscape": "Paesaggio",
|
||||
"Open Last Book on Start": "Apri ultimo libro all'avvio"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "削除",
|
||||
"Delete Highlight": "ハイライトを削除",
|
||||
"Dictionary": "辞書",
|
||||
"Disable Click-to-Flip": "クリックめくりを無効化",
|
||||
"Download Readest": "Readestをダウンロード",
|
||||
"Edit": "編集",
|
||||
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "不明",
|
||||
"Untitled": "無題",
|
||||
"Updated:": "更新日:",
|
||||
"User avatar": "ユーザーアバター",
|
||||
"Version {{version}}": "バージョン {{version}}",
|
||||
"Vertical Direction": "縦書",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "ファイルを開くと自動インポート",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "クリックめくりエリアを入れ替え",
|
||||
"No chapters detected.": "章が検出されません。",
|
||||
"Failed to parse the EPUB file.": "EPUBファイルの解析に失敗しました。",
|
||||
"This book format is not supported.": "この書籍形式はサポートされていません。",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "説明:",
|
||||
"No description available": "説明はありません",
|
||||
"List": "リスト",
|
||||
"Grid": "グリッド"
|
||||
"Grid": "グリッド",
|
||||
"(from 'As You Like It', Act II)": "(「お気に召すまま」第2幕)",
|
||||
"Link Color": "リンクカラー",
|
||||
"Volume Keys for Page Flip": "音量キーでページめくり",
|
||||
"Clicks for Page Flip": "タップでページめくり",
|
||||
"Swap Clicks Area": "タップ領域を入れ替え",
|
||||
"Screen": "画面",
|
||||
"Orientation": "向き",
|
||||
"Portrait": "縦",
|
||||
"Landscape": "横",
|
||||
"Open Last Book on Start": "開始時に最後の書籍を開く"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "삭제",
|
||||
"Delete Highlight": "하이라이트 삭제",
|
||||
"Dictionary": "사전",
|
||||
"Disable Click-to-Flip": "클릭 넘기기 비활성화",
|
||||
"Download Readest": "Readest 다운로드",
|
||||
"Edit": "편집",
|
||||
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "알 수 없음",
|
||||
"Untitled": "제목 없음",
|
||||
"Updated:": "업데이트일:",
|
||||
"User avatar": "사용자 아바타",
|
||||
"Version {{version}}": "버전 {{version}}",
|
||||
"Vertical Direction": "수직 방향",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "파일 열 때 자동 가져오기",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "클릭-뒤집기 영역 교환",
|
||||
"No chapters detected.": "장 발견되지 않음.",
|
||||
"Failed to parse the EPUB file.": "EPUB 파일 구문 분석 실패.",
|
||||
"This book format is not supported.": "이 책 형식은 지원되지 않습니다.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "설명:",
|
||||
"No description available": "설명 없음",
|
||||
"List": "목록",
|
||||
"Grid": "격자"
|
||||
"Grid": "격자",
|
||||
"(from 'As You Like It', Act II)": "(당신이 좋아하는 대로, 2막)",
|
||||
"Link Color": "링크 색상",
|
||||
"Volume Keys for Page Flip": "페이지 넘김을 위한 볼륨 키",
|
||||
"Clicks for Page Flip": "페이지 넘김을 위한 클릭",
|
||||
"Swap Clicks Area": "클릭 영역 교환",
|
||||
"Screen": "화면",
|
||||
"Orientation": "방향",
|
||||
"Portrait": "세로",
|
||||
"Landscape": "가로",
|
||||
"Open Last Book on Start": "시작 시 마지막 책 열기"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Usuń",
|
||||
"Delete Highlight": "Usuń zaznaczenie",
|
||||
"Dictionary": "Słownik",
|
||||
"Disable Click-to-Flip": "Wyłącz przewracanie kliknięciem",
|
||||
"Download Readest": "Pobierz Readest",
|
||||
"Edit": "Edytuj",
|
||||
"Enter your custom CSS here...": "Wprowadź własny CSS tutaj...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Nieznane",
|
||||
"Untitled": "Bez tytułu",
|
||||
"Updated:": "Zaktualizowano:",
|
||||
"User avatar": "Awatar użytkownika",
|
||||
"Version {{version}}": "Wersja {{version}}",
|
||||
"Vertical Direction": "Kierunek pionowy",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Witaj w swojej bibliotece. Możesz tutaj zaimportować swoje książki i czytać je w dowolnym momencie.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Automatyczne importowanie przy otwieraniu pliku",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Zamień obszar kliknięcia na przewracanie",
|
||||
"No chapters detected.": "Nie wykryto rozdziałów.",
|
||||
"Failed to parse the EPUB file.": "Nie udało się przeanalizować pliku EPUB.",
|
||||
"This book format is not supported.": "Ten format książki nie jest obsługiwany.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Opis:",
|
||||
"No description available": "Brak opisu",
|
||||
"List": "Lista",
|
||||
"Grid": "Siatka"
|
||||
"Grid": "Siatka",
|
||||
"(from 'As You Like It', Act II)": "(z „Jak wam się podoba”, Akt II)",
|
||||
"Link Color": "Kolor linku",
|
||||
"Volume Keys for Page Flip": "Klucze głośności do przewracania stron",
|
||||
"Clicks for Page Flip": "Kliknięcia do przewracania stron",
|
||||
"Swap Clicks Area": "Zamień obszar kliknięcia",
|
||||
"Screen": "Ekran",
|
||||
"Orientation": "Orientacja",
|
||||
"Portrait": "Portret",
|
||||
"Landscape": "Krajobraz",
|
||||
"Open Last Book on Start": "Otwórz ostatnią książkę przy starcie"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Excluir",
|
||||
"Delete Highlight": "Excluir Destaque",
|
||||
"Dictionary": "Dicionário",
|
||||
"Disable Click-to-Flip": "Desativar Clique para Virar",
|
||||
"Download Readest": "Baixar Readest",
|
||||
"Edit": "Editar",
|
||||
"Enter your custom CSS here...": "Insira seu CSS personalizado aqui...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Desconhecido",
|
||||
"Untitled": "Sem Título",
|
||||
"Updated:": "Atualizado:",
|
||||
"User avatar": "Avatar do usuário",
|
||||
"Version {{version}}": "Versão {{version}}",
|
||||
"Vertical Direction": "Direção Vertical",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Importação Automática ao Abrir Arquivo",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Trocar Área de Clique para Virar",
|
||||
"No chapters detected.": "Nenhum capítulo detectado.",
|
||||
"Failed to parse the EPUB file.": "Falha ao analisar o arquivo EPUB.",
|
||||
"This book format is not supported.": "Este formato de livro não é suportado.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Descrição:",
|
||||
"No description available": "Nenhuma descrição disponível",
|
||||
"List": "Lista",
|
||||
"Grid": "Grade"
|
||||
"Grid": "Grade",
|
||||
"(from 'As You Like It', Act II)": "(de 'Como Gostais', Ato II)",
|
||||
"Link Color": "Cor do Link",
|
||||
"Volume Keys for Page Flip": "Teclas de Volume para Virar Página",
|
||||
"Clicks for Page Flip": "Cliques para Virar Página",
|
||||
"Swap Clicks Area": "Trocar Área de Cliques",
|
||||
"Screen": "Tela",
|
||||
"Orientation": "Orientação",
|
||||
"Portrait": "Retrato",
|
||||
"Landscape": "Paisagem",
|
||||
"Open Last Book on Start": "Abrir o Último Livro ao Iniciar"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Удалить",
|
||||
"Delete Highlight": "Удалить выделение",
|
||||
"Dictionary": "Словарь",
|
||||
"Disable Click-to-Flip": "Отключить переворот по клику",
|
||||
"Download Readest": "Скачать Readest",
|
||||
"Edit": "Редактировать",
|
||||
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Неизвестно",
|
||||
"Untitled": "Без названия",
|
||||
"Updated:": "Обновлено:",
|
||||
"User avatar": "Аватар пользователя",
|
||||
"Version {{version}}": "Версия {{version}}",
|
||||
"Vertical Direction": "Вертикальное направление",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Автоматический импорт при открытии файла",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Переключить область клика для переворота",
|
||||
"No chapters detected.": "Не обнаружено глав.",
|
||||
"Failed to parse the EPUB file.": "Не удалось разобрать файл EPUB.",
|
||||
"This book format is not supported.": "Этот формат книги не поддерживается.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Описание:",
|
||||
"No description available": "Нет доступного описания",
|
||||
"List": "Список",
|
||||
"Grid": "Сетка"
|
||||
"Grid": "Сетка",
|
||||
"(from 'As You Like It', Act II)": "(из 'Как вам это понравится', Акт II)",
|
||||
"Link Color": "Цвет ссылки",
|
||||
"Volume Keys for Page Flip": "Клавиши громкости для перелистывания страниц",
|
||||
"Clicks for Page Flip": "Клики для перелистывания страниц",
|
||||
"Swap Clicks Area": "Переключить область кликов",
|
||||
"Screen": "Экран",
|
||||
"Orientation": "Ориентация",
|
||||
"Portrait": "Портрет",
|
||||
"Landscape": "Ландшафт",
|
||||
"Open Last Book on Start": "Открыть последнюю книгу при запуске"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Sil",
|
||||
"Delete Highlight": "Vurguyu Sil",
|
||||
"Dictionary": "Sözlük",
|
||||
"Disable Click-to-Flip": "Tıkla-Çevir'i Devre Dışı Bırak",
|
||||
"Download Readest": "Readest'i İndir",
|
||||
"Edit": "Düzenle",
|
||||
"Enter your custom CSS here...": "Özel CSS'nizi buraya girin...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Bilinmiyor",
|
||||
"Untitled": "Başlıksız",
|
||||
"Updated:": "Güncellendi:",
|
||||
"User avatar": "Kullanıcı avatarı",
|
||||
"Version {{version}}": "Sürüm {{version}}",
|
||||
"Vertical Direction": "Dikey Yön",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Dosya Açıldığında Otomatik İçe Aktar",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Tıkla-Çevir Alanını Değiştir",
|
||||
"No chapters detected.": "Bölüm bulunamadı.",
|
||||
"Failed to parse the EPUB file.": "EPUB dosyası ayrıştırılamadı.",
|
||||
"This book format is not supported.": "Bu kitap formatı desteklenmiyor.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Tanım:",
|
||||
"No description available": "Tanım mevcut değil",
|
||||
"List": "Liste",
|
||||
"Grid": "Izgara"
|
||||
"Grid": "Izgara",
|
||||
"(from 'As You Like It', Act II)": "(As You Like It, 2. Perde)",
|
||||
"Link Color": "Bağlantı Rengi",
|
||||
"Volume Keys for Page Flip": "Sayfa Çevirme için Ses Seviyesi Tuşları",
|
||||
"Clicks for Page Flip": "Sayfa Çevirme için Tıklamalar",
|
||||
"Swap Clicks Area": "Tıklama Alanını Değiştir",
|
||||
"Screen": "Ekran",
|
||||
"Orientation": "Yönlendirme",
|
||||
"Portrait": "Portre",
|
||||
"Landscape": "Yatay",
|
||||
"Open Last Book on Start": "Açılışta Son Kitabı Aç"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Видалити",
|
||||
"Delete Highlight": "Видалити виділення",
|
||||
"Dictionary": "Словник",
|
||||
"Disable Click-to-Flip": "Вимкнути гортання кліком",
|
||||
"Download Readest": "Завантажити Readest",
|
||||
"Edit": "Редагувати",
|
||||
"Enter your custom CSS here...": "Введіть ваш користувацький CSS тут...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Невідомо",
|
||||
"Untitled": "Без назви",
|
||||
"Updated:": "Оновлено:",
|
||||
"User avatar": "Аватар користувача",
|
||||
"Version {{version}}": "Версія {{version}}",
|
||||
"Vertical Direction": "Вертикальний напрямок",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Ласкаво просимо до вашої бібліотеки. Ви можете імпортувати сюди свої книги та читати їх будь-коли.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Поміняти місцями область кліку для перевороту",
|
||||
"No chapters detected.": "Не виявлено жодного розділу.",
|
||||
"Failed to parse the EPUB file.": "Не вдалося розібрати файл EPUB.",
|
||||
"This book format is not supported.": "Цей формат книги не підтримується.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Опис:",
|
||||
"No description available": "Опис недоступний",
|
||||
"List": "Список",
|
||||
"Grid": "Сітка"
|
||||
"Grid": "Сітка",
|
||||
"(from 'As You Like It', Act II)": "(з 'Як вам це подобається', Акт II)",
|
||||
"Link Color": "Колір посилання",
|
||||
"Volume Keys for Page Flip": "Клавіші гучності для перевороту сторінки",
|
||||
"Clicks for Page Flip": "Клацання для перевороту сторінки",
|
||||
"Swap Clicks Area": "Змінити область клацання",
|
||||
"Screen": "Екран",
|
||||
"Orientation": "Орієнтація",
|
||||
"Portrait": "Портрет",
|
||||
"Landscape": "Ландшафт",
|
||||
"Open Last Book on Start": "Відкрити останню книгу при запуску"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "Xóa",
|
||||
"Delete Highlight": "Xóa văn bản được đánh dấu",
|
||||
"Dictionary": "Từ điển",
|
||||
"Disable Click-to-Flip": "Tắt tính năng nhấp để lật trang",
|
||||
"Download Readest": "Tải Readest",
|
||||
"Edit": "Chỉnh sửa",
|
||||
"Enter your custom CSS here...": "Nhập CSS tùy chỉnh của bạn vào đây...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "Không xác định",
|
||||
"Untitled": "Không có tiêu đề",
|
||||
"Updated:": "Cập nhật:",
|
||||
"User avatar": "Ảnh đại diện",
|
||||
"Version {{version}}": "Phiên bản {{version}}",
|
||||
"Vertical Direction": "Hướng dọc",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "Tự động nhập khi mở tệp",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Hoán đổi khu vực nhấp để lật trang",
|
||||
"No chapters detected.": "Không phát hiện thấy chương nào.",
|
||||
"Failed to parse the EPUB file.": "Không thể phân tích tệp EPUB.",
|
||||
"This book format is not supported.": "Định dạng sách này không được hỗ trợ.",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "Miêu tả:",
|
||||
"No description available": "Không có mô tả nào",
|
||||
"List": "Danh sách",
|
||||
"Grid": "Lưới"
|
||||
"Grid": "Lưới",
|
||||
"(from 'As You Like It', Act II)": "(từ 'Như bạn thích', Hành động II)",
|
||||
"Link Color": "Màu liên kết",
|
||||
"Volume Keys for Page Flip": "Phím âm lượng để lật trang",
|
||||
"Clicks for Page Flip": "Nhấp để lật trang",
|
||||
"Swap Clicks Area": "Đổi khu vực nhấp",
|
||||
"Screen": "Màn hình",
|
||||
"Orientation": "Định hướng",
|
||||
"Portrait": "Chân dung",
|
||||
"Landscape": "Phong cảnh",
|
||||
"Open Last Book on Start": "Mở sách cuối cùng khi khởi động"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "删除",
|
||||
"Delete Highlight": "删除划线",
|
||||
"Dictionary": "词典",
|
||||
"Disable Click-to-Flip": "禁用点击翻页",
|
||||
"Download Readest": "下载 Readest",
|
||||
"Edit": "编辑",
|
||||
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "未知",
|
||||
"Untitled": "无标题",
|
||||
"Updated:": "更新日期",
|
||||
"User avatar": "用户头像",
|
||||
"Version {{version}}": "版本 {{version}}",
|
||||
"Vertical Direction": "竖排",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "打开文件时自动导入",
|
||||
"LXGW WenKai GB Screen": "霞鹜文楷",
|
||||
"LXGW WenKai TC": "霞鶩文楷",
|
||||
"Swap Click-to-Flip Area": "交换点击翻页区域",
|
||||
"No chapters detected.": "未检测到章节。",
|
||||
"Failed to parse the EPUB file.": "无法解析 EPUB 文件。",
|
||||
"This book format is not supported.": "不支持此书籍格式。",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "简介",
|
||||
"No description available": "暂无简介",
|
||||
"List": "列表",
|
||||
"Grid": "网格"
|
||||
"Grid": "网格",
|
||||
"(from 'As You Like It', Act II)": "出自《皆大欢喜》,第二幕",
|
||||
"Link Color": "链接颜色",
|
||||
"Volume Keys for Page Flip": "音量键翻页",
|
||||
"Clicks for Page Flip": "点击翻页",
|
||||
"Swap Clicks Area": "交换点击区域",
|
||||
"Screen": "屏幕",
|
||||
"Orientation": "屏幕方向",
|
||||
"Portrait": "竖屏",
|
||||
"Landscape": "横屏",
|
||||
"Open Last Book on Start": "启动时打开上次阅读的书籍"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"Delete": "刪除",
|
||||
"Delete Highlight": "刪除劃線",
|
||||
"Dictionary": "詞典",
|
||||
"Disable Click-to-Flip": "禁用點擊翻頁",
|
||||
"Download Readest": "下載 Readest",
|
||||
"Edit": "編輯",
|
||||
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
|
||||
@@ -112,7 +111,6 @@
|
||||
"Unknown": "未知",
|
||||
"Untitled": "無標題",
|
||||
"Updated:": "更新日期",
|
||||
"User avatar": "用戶頭像",
|
||||
"Version {{version}}": "版本 {{version}}",
|
||||
"Vertical Direction": "豎排",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
|
||||
@@ -259,7 +257,6 @@
|
||||
"Auto Import on File Open": "打開文件時自動導入",
|
||||
"LXGW WenKai GB Screen": "霞鶩文楷 SC",
|
||||
"LXGW WenKai TC": "霞鶩文楷 TC",
|
||||
"Swap Click-to-Flip Area": "交換點擊翻頁區域",
|
||||
"No chapters detected.": "未檢測到章節。",
|
||||
"Failed to parse the EPUB file.": "無法解析 EPUB 文件。",
|
||||
"This book format is not supported.": "不支援此書籍格式。",
|
||||
@@ -296,5 +293,15 @@
|
||||
"Description:": "簡介",
|
||||
"No description available": "暫無簡介",
|
||||
"List": "列表",
|
||||
"Grid": "網格"
|
||||
"Grid": "網格",
|
||||
"(from 'As You Like It', Act II)": "(出自《皆大歡喜》,第二幕)",
|
||||
"Link Color": "連結顏色",
|
||||
"Volume Keys for Page Flip": "音量鍵翻頁",
|
||||
"Clicks for Page Flip": "點擊翻頁",
|
||||
"Swap Clicks Area": "交換點擊區域",
|
||||
"Screen": "螢幕",
|
||||
"Orientation": "螢幕方向",
|
||||
"Portrait": "豎屏",
|
||||
"Landscape": "橫屏",
|
||||
"Open Last Book on Start": "啟動時打開上次閱讀的書籍"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.39": {
|
||||
"date": "2025-05-07",
|
||||
"notes": [
|
||||
"Enhanced Navigation: Added location information to the table of contents",
|
||||
"Enhanced Navigation: Larger touch targets for table of contents expansion icons",
|
||||
"Screen Orientation Lock: You can now lock your device's screen orientation when reading",
|
||||
"Quick Access to Recent Books: Option to automatically open your last read book when starting the app",
|
||||
"Better Language Support: Improved handling of CJK characters in search",
|
||||
"Enhanced Text-to-Speech: More accurate language detection for TTS",
|
||||
"Enhanced Text-to-Speech: Text-to-Speech now maintains your position in scroll mode",
|
||||
"Enhanced Annotations: Better handling of annotations with ruby elements in Japanese books",
|
||||
"Smoother Scrolling: We've made continuous scrolling more reliable for a seamless reading experience",
|
||||
"Compact View: Reading view is more compact when header and footer are hidden",
|
||||
"TXT Parsing: More robust handling of TXT book format"
|
||||
]
|
||||
},
|
||||
"0.9.38": {
|
||||
"date": "2025-04-29",
|
||||
"notes": [
|
||||
"Page Turning with Volume Keys: You can now turn pages using your device’s volume buttons",
|
||||
"Expanded English Voices: More English voices are available across all en locales",
|
||||
"Theme Editor: Added a primary color picker for easier customization",
|
||||
"System Fonts: Retrieve system fonts on iOS and Android, with improved font weight and style detection",
|
||||
"Popup Footnotes: Popup footnotes now inherit the book's font for consistent appearance",
|
||||
"Better Text Selection: Preserves text selection anchors when spanning paginated content",
|
||||
"Android Compatibility: Navigation bar auto-hides correctly on Android 10 and below",
|
||||
"Popup Dialogs: Back key now properly dismisses popup dialogs on Android"
|
||||
]
|
||||
},
|
||||
"0.9.37": {
|
||||
"date": "2025-04-25",
|
||||
"notes": [
|
||||
|
||||
@@ -63,4 +63,3 @@ tauri-plugin-cli = "2"
|
||||
tauri-plugin-single-instance = "2.2.3"
|
||||
tauri-plugin-updater = "2.7.0"
|
||||
tauri-plugin-window-state = "2.2.2"
|
||||
font-enumeration = "0.9.0"
|
||||
|
||||
+71
-1
@@ -2,8 +2,78 @@ package com.bilingify.readest
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import android.view.KeyEvent
|
||||
import android.webkit.WebView
|
||||
import android.util.Log
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import com.readest.native_bridge.KeyDownInterceptor
|
||||
|
||||
class MainActivity : TauriActivity(), KeyDownInterceptor {
|
||||
private lateinit var wv: WebView
|
||||
private var interceptVolumeKeysEnabled = false
|
||||
private var interceptBackKeyEnabled = false
|
||||
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
wv = webView
|
||||
}
|
||||
|
||||
private val keyEventMap = mapOf(
|
||||
KeyEvent.KEYCODE_BACK to "Back",
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN to "VolumeDown",
|
||||
KeyEvent.KEYCODE_VOLUME_UP to "VolumeUp"
|
||||
)
|
||||
|
||||
override fun interceptVolumeKeys(enabled: Boolean) {
|
||||
Log.d("MainActivity", "Intercept volume keys: $enabled")
|
||||
interceptVolumeKeysEnabled = enabled
|
||||
}
|
||||
|
||||
override fun interceptBackKey(enabled: Boolean) {
|
||||
Log.d("MainActivity", "Intercept back key: $enabled")
|
||||
interceptBackKeyEnabled = enabled
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
val keyName = keyEventMap[keyCode]
|
||||
if (keyName != null) {
|
||||
wv.evaluateJavascript(
|
||||
"""
|
||||
try {
|
||||
window.onNativeKeyDown("$keyName", $keyCode)
|
||||
} catch (err) {
|
||||
false
|
||||
}
|
||||
""".trimIndent()
|
||||
) { result ->
|
||||
run {
|
||||
if (result.equals("true", ignoreCase = true)) {
|
||||
Log.d("Key Event", "Key event $keyName intercepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
if (interceptVolumeKeysEnabled) {
|
||||
true
|
||||
} else {
|
||||
super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
if (interceptBackKeyEnabled) {
|
||||
true
|
||||
} else {
|
||||
super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
else -> super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -17,3 +17,6 @@ schemars = "0.8"
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2.0.4", features = ["build"] }
|
||||
schemars = "0.8"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
|
||||
font-enumeration = "0.9.0"
|
||||
|
||||
+121
-18
@@ -6,12 +6,19 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.view.WindowInsetsController
|
||||
import android.graphics.Color
|
||||
import android.webkit.WebView
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.fonts.SystemFonts
|
||||
import android.graphics.fonts.Font
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
@@ -19,6 +26,7 @@ import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import org.json.JSONArray
|
||||
import java.io.*
|
||||
|
||||
@InvokeArg
|
||||
@@ -43,6 +51,22 @@ class SetSystemUIVisibilityRequestArgs {
|
||||
var darkMode: Boolean? = false
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
class InterceptKeysRequestArgs {
|
||||
var volumeKeys: Boolean? = null
|
||||
var backKey: Boolean? = null
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
class LockScreenOrientationRequestArgs {
|
||||
var orientation: String? = null
|
||||
}
|
||||
|
||||
interface KeyDownInterceptor {
|
||||
fun interceptVolumeKeys(enabled: Boolean)
|
||||
fun interceptBackKey(enabled: Boolean)
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
private val implementation = NativeBridge()
|
||||
@@ -180,7 +204,8 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
} else {
|
||||
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
|
||||
val compatController = WindowCompat.getInsetsController(window, decorView)
|
||||
compatController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
|
||||
if (isDarkMode) {
|
||||
@@ -200,26 +225,24 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
controller.hide(WindowInsets.Type.systemBars())
|
||||
}
|
||||
}
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
} 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
|
||||
val compatController = WindowCompat.getInsetsController(window, decorView)
|
||||
compatController?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
if (!isDarkMode) {
|
||||
it.isAppearanceLightStatusBars = true
|
||||
} else {
|
||||
it.isAppearanceLightStatusBars = false
|
||||
}
|
||||
if (visible) {
|
||||
it.show(WindowInsetsCompat.Type.statusBars())
|
||||
} else {
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
}
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
}
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
ret.put("success", true)
|
||||
} catch (e: Exception) {
|
||||
ret.put("success", false)
|
||||
@@ -245,4 +268,84 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun get_sys_fonts_list(invoke: Invoke) {
|
||||
val ret = JSObject()
|
||||
try {
|
||||
val fontList = mutableListOf<String>()
|
||||
val fontFileList = mutableListOf<String>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val systemFonts = SystemFonts.getAvailableFonts()
|
||||
for (font in systemFonts) {
|
||||
val file = font.getFile()?: continue
|
||||
if (file.isFile && (file.name.endsWith(".ttf", true) || file.name.endsWith(".otf", true))) {
|
||||
fontFileList.add(file.name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val fontDirs = listOf("/system/fonts", "/system/font", "/data/fonts")
|
||||
for (dirPath in fontDirs) {
|
||||
val dir = File(dirPath)
|
||||
if (dir.exists() && dir.isDirectory) {
|
||||
dir.listFiles()?.forEach { file ->
|
||||
if (file.isFile && (file.name.endsWith(".ttf", true) || file.name.endsWith(".otf", true))) {
|
||||
fontFileList.add(file.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (fileFileName in fontFileList) {
|
||||
var fontName = fileFileName
|
||||
.replace(Regex("\\.(ttf|otf)$", RegexOption.IGNORE_CASE), "")
|
||||
.trim()
|
||||
fontList.add(fontName)
|
||||
}
|
||||
var fontDict = JSObject()
|
||||
for (fontName in fontList) {
|
||||
fontDict.put(fontName, fontName)
|
||||
}
|
||||
ret.put("fonts", fontDict)
|
||||
} catch (e: Exception) {
|
||||
ret.put("error", e.message)
|
||||
}
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun intercept_keys(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(InterceptKeysRequestArgs::class.java)
|
||||
if (activity is KeyDownInterceptor) {
|
||||
when (args.backKey) {
|
||||
true -> (activity as KeyDownInterceptor).interceptBackKey(true)
|
||||
false -> (activity as KeyDownInterceptor).interceptBackKey(false)
|
||||
else -> {}
|
||||
}
|
||||
when (args.volumeKeys) {
|
||||
true -> (activity as KeyDownInterceptor).interceptVolumeKeys(true)
|
||||
false -> (activity as KeyDownInterceptor).interceptVolumeKeys(false)
|
||||
else -> {}
|
||||
}
|
||||
} else {
|
||||
Log.e("NativeBridgePlugin", "Activity does not implement KeyDownInterceptor")
|
||||
}
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@Command
|
||||
fun lock_screen_orientation(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(LockScreenOrientationRequestArgs::class.java)
|
||||
val orientation = args.orientation ?: "auto"
|
||||
when (orientation) {
|
||||
"portrait" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
"landscape" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
"auto" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
|
||||
else -> {
|
||||
invoke.reject("Invalid orientation mode")
|
||||
return
|
||||
}
|
||||
}
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ const COMMANDS: &[&str] = &[
|
||||
"install_package",
|
||||
"set_system_ui_visibility",
|
||||
"get_status_bar_height",
|
||||
"get_sys_fonts_list",
|
||||
"intercept_keys",
|
||||
"lock_screen_orientation",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
@@ -4,29 +4,29 @@
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "tauri-plugin-native-bridge",
|
||||
platforms: [
|
||||
.macOS(.v10_13),
|
||||
.iOS(.v13),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "tauri-plugin-native-bridge",
|
||||
type: .static,
|
||||
targets: ["tauri-plugin-native-bridge"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Tauri", path: "../.tauri/tauri-api")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "tauri-plugin-native-bridge",
|
||||
dependencies: [
|
||||
.byName(name: "Tauri")
|
||||
],
|
||||
path: "Sources")
|
||||
]
|
||||
name: "tauri-plugin-native-bridge",
|
||||
platforms: [
|
||||
.macOS(.v10_13),
|
||||
.iOS(.v14),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "tauri-plugin-native-bridge",
|
||||
type: .static,
|
||||
targets: ["tauri-plugin-native-bridge"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Tauri", path: "../.tauri/tauri-api")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "tauri-plugin-native-bridge",
|
||||
dependencies: [
|
||||
.byName(name: "Tauri")
|
||||
],
|
||||
path: "Sources")
|
||||
]
|
||||
)
|
||||
|
||||
+331
-8
@@ -1,10 +1,30 @@
|
||||
import AuthenticationServices
|
||||
import AVFoundation
|
||||
import AuthenticationServices
|
||||
import CoreText
|
||||
import MediaPlayer
|
||||
import ObjectiveC
|
||||
import SwiftRs
|
||||
import Tauri
|
||||
import UIKit
|
||||
import WebKit
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NativeBridge")
|
||||
|
||||
func getLocalizedDisplayName(familyName: String) -> String? {
|
||||
let fontDescriptor = CTFontDescriptorCreateWithAttributes(
|
||||
[
|
||||
kCTFontFamilyNameAttribute: familyName
|
||||
] as CFDictionary)
|
||||
|
||||
let font = CTFontCreateWithFontDescriptor(fontDescriptor, 0.0, nil)
|
||||
|
||||
var actualLanguage: Unmanaged<CFString>?
|
||||
if let localizedName = CTFontCopyLocalizedName(font, kCTFontFamilyNameKey, &actualLanguage) {
|
||||
return localizedName as String
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
class SafariAuthRequestArgs: Decodable {
|
||||
let authUrl: String
|
||||
@@ -19,8 +39,202 @@ class SetSystemUIVisibilityRequestArgs: Decodable {
|
||||
let darkMode: Bool
|
||||
}
|
||||
|
||||
class InterceptKeysRequestArgs: Decodable {
|
||||
let backKey: Bool?
|
||||
let volumeKeys: Bool?
|
||||
}
|
||||
|
||||
class LockScreenOrientationRequestArgs: Decodable {
|
||||
let orientation: String?
|
||||
}
|
||||
|
||||
class VolumeKeyHandler: NSObject {
|
||||
private var audioSession: AVAudioSession?
|
||||
private var originalVolume: Float = 0.0
|
||||
private var referenceVolume: Float = 0.5
|
||||
private var previousVolume: Float = 0.5
|
||||
private var volumeView: MPVolumeView?
|
||||
private(set) var isIntercepting = false
|
||||
private var webView: WKWebView?
|
||||
private var volumeSlider: UISlider?
|
||||
|
||||
func startInterception(webView: WKWebView) {
|
||||
if isIntercepting {
|
||||
stopInterception()
|
||||
}
|
||||
|
||||
logger.log("Starting volume key interception")
|
||||
self.webView = webView
|
||||
isIntercepting = true
|
||||
|
||||
audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession?.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
try audioSession?.setActive(true)
|
||||
} catch {
|
||||
logger.error("Failed to activate audio session: \(error)")
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.originalVolume = self.audioSession?.outputVolume ?? 0.1
|
||||
if self.originalVolume > 0.9 {
|
||||
self.referenceVolume = 0.9
|
||||
} else if self.originalVolume < 0.1 {
|
||||
self.referenceVolume = 0.1
|
||||
} else {
|
||||
self.referenceVolume = self.originalVolume
|
||||
}
|
||||
logger.log("Reference volume set to \(self.referenceVolume)")
|
||||
self.previousVolume = self.referenceVolume
|
||||
self.setSessionVolume(self.referenceVolume)
|
||||
self.setupHiddenVolumeView()
|
||||
self.audioSession?.addObserver(
|
||||
self, forKeyPath: "outputVolume", options: [.new], context: nil)
|
||||
}
|
||||
|
||||
audioSession?.addObserver(self, forKeyPath: "outputVolume", options: [.new], context: nil)
|
||||
}
|
||||
|
||||
func stopInterception() {
|
||||
if !isIntercepting {
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("Stopping volume key interception")
|
||||
isIntercepting = false
|
||||
audioSession?.removeObserver(self, forKeyPath: "outputVolume")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.setSessionVolume(self?.originalVolume ?? 0.1)
|
||||
self?.volumeView?.removeFromSuperview()
|
||||
self?.volumeView = nil
|
||||
self?.volumeSlider = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setSessionVolume(_ volume: Float) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.volumeSlider?.value = volume
|
||||
}
|
||||
}
|
||||
|
||||
private func setupHiddenVolumeView() {
|
||||
assert(Thread.isMainThread, "setupHiddenVolumeView must be called on main thread")
|
||||
let frame = CGRect(x: -1000, y: -1000, width: 1, height: 1)
|
||||
volumeView = MPVolumeView(frame: frame)
|
||||
volumeSlider = volumeView?.subviews.first(where: { $0 is UISlider }) as? UISlider
|
||||
if let window = UIApplication.shared.windows.first {
|
||||
window.addSubview(volumeView!)
|
||||
}
|
||||
}
|
||||
|
||||
override func observeValue(
|
||||
forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?,
|
||||
context: UnsafeMutableRawPointer?
|
||||
) {
|
||||
if keyPath == "outputVolume", let audioSession = self.audioSession, isIntercepting {
|
||||
let currentVolume = audioSession.outputVolume
|
||||
if currentVolume > self.previousVolume {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.webView?.evaluateJavaScript(
|
||||
"window.onNativeKeyDown('VolumeUp');", completionHandler: nil)
|
||||
}
|
||||
} else if currentVolume < self.previousVolume {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.webView?.evaluateJavaScript(
|
||||
"window.onNativeKeyDown('VolumeDown');", completionHandler: nil)
|
||||
}
|
||||
}
|
||||
self.previousVolume = currentVolume
|
||||
self.setSessionVolume(self.referenceVolume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NativeBridgePlugin: Plugin {
|
||||
private var webView: WKWebView?
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
private var isOrientationLocked = false
|
||||
private var currentOrientationMask: UIInterfaceOrientationMask = .all
|
||||
private var orientationObserver: NSObjectProtocol?
|
||||
|
||||
@objc public override func load(webview: WKWebView) {
|
||||
self.webView = webview
|
||||
logger.log("NativeBridgePlugin loaded")
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(appDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(appDidEnterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc func appDidBecomeActive() {
|
||||
if volumeKeyHandler != nil {
|
||||
activateVolumeKeyInterception()
|
||||
}
|
||||
|
||||
if orientationObserver != nil {
|
||||
self.setupOrientationObserver()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func appDidEnterBackground() {
|
||||
if let handler = volumeKeyHandler, handler.isIntercepting {
|
||||
handler.stopInterception()
|
||||
}
|
||||
}
|
||||
|
||||
func activateVolumeKeyInterception() {
|
||||
if volumeKeyHandler == nil {
|
||||
volumeKeyHandler = VolumeKeyHandler()
|
||||
}
|
||||
|
||||
if let webView = self.webView {
|
||||
volumeKeyHandler?.stopInterception()
|
||||
volumeKeyHandler?.startInterception(webView: webView)
|
||||
} else {
|
||||
logger.warning("Cannot activate volume key interception: webView is nil")
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private struct AssociatedKeys {
|
||||
static var volumeKeyHandler = "volumeKeyHandler"
|
||||
static var interceptingVolumeKeys = "interceptingVolumeKeys"
|
||||
}
|
||||
|
||||
private var volumeKeyHandler: VolumeKeyHandler? {
|
||||
get {
|
||||
return objc_getAssociatedObject(self, &AssociatedKeys.volumeKeyHandler) as? VolumeKeyHandler
|
||||
}
|
||||
set {
|
||||
objc_setAssociatedObject(
|
||||
self, &AssociatedKeys.volumeKeyHandler, newValue, .OBJC_ASSOCIATION_RETAIN)
|
||||
}
|
||||
}
|
||||
|
||||
private var interceptingVolumeKeys: Bool {
|
||||
get {
|
||||
return objc_getAssociatedObject(self, &AssociatedKeys.interceptingVolumeKeys) as? Bool
|
||||
?? false
|
||||
}
|
||||
set {
|
||||
objc_setAssociatedObject(
|
||||
self, &AssociatedKeys.interceptingVolumeKeys, newValue, .OBJC_ASSOCIATION_RETAIN)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func use_background_audio(_ invoke: Invoke) {
|
||||
do {
|
||||
@@ -30,15 +244,15 @@ class NativeBridgePlugin: Plugin {
|
||||
if enabled {
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
try session.setActive(true)
|
||||
print("AVAudioSession activated")
|
||||
logger.log("AVAudioSession activated")
|
||||
} else {
|
||||
try session.setActive(false)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
print("AVAudioSession deactivated")
|
||||
logger.log("AVAudioSession deactivated")
|
||||
}
|
||||
invoke.resolve()
|
||||
} catch {
|
||||
print("Failed to set up audio session:", error)
|
||||
logger.error("Failed to set up audio session: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,13 +265,12 @@ class NativeBridgePlugin: Plugin {
|
||||
guard let strongSelf = self else { return }
|
||||
|
||||
if let error = error {
|
||||
Logger.error("Auth session error: \(error.localizedDescription)")
|
||||
logger.error("Auth session error: \(error.localizedDescription)")
|
||||
invoke.reject(error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
if let callbackURL = callbackURL {
|
||||
Logger.info("Auth session callback URL: \(callbackURL.absoluteString)")
|
||||
strongSelf.authSession?.cancel()
|
||||
strongSelf.authSession = nil
|
||||
invoke.resolve(["redirectUrl": callbackURL.absoluteString])
|
||||
@@ -69,7 +282,7 @@ class NativeBridgePlugin: Plugin {
|
||||
}
|
||||
|
||||
let started = authSession?.start() ?? false
|
||||
Logger.info("Auth session start result: \(started)")
|
||||
logger.log("Auth session start result: \(started)")
|
||||
}
|
||||
|
||||
@objc public func set_system_ui_visibility(_ invoke: Invoke) throws {
|
||||
@@ -90,11 +303,121 @@ class NativeBridgePlugin: Plugin {
|
||||
keyWindow.overrideUserInterfaceStyle = darkMode ? .dark : .light
|
||||
keyWindow.layoutIfNeeded()
|
||||
} else {
|
||||
print("No key window found")
|
||||
logger.error("No key window found")
|
||||
}
|
||||
}
|
||||
invoke.resolve(["success": true])
|
||||
}
|
||||
|
||||
@objc public func get_sys_fonts_list(_ invoke: Invoke) throws {
|
||||
var fontDict: [String: String] = [:]
|
||||
|
||||
for family in UIFont.familyNames.sorted() {
|
||||
if let localized = getLocalizedDisplayName(familyName: family) {
|
||||
fontDict[family] = localized
|
||||
} else {
|
||||
let fontNames = UIFont.fontNames(forFamilyName: family)
|
||||
if fontNames.isEmpty {
|
||||
fontDict[family] = family
|
||||
} else {
|
||||
for fontName in fontNames {
|
||||
fontDict[fontName] = family
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invoke.resolve(["fonts": fontDict])
|
||||
}
|
||||
|
||||
@objc public func intercept_keys(_ invoke: Invoke) {
|
||||
do {
|
||||
let args = try invoke.parseArgs(InterceptKeysRequestArgs.self)
|
||||
|
||||
if let volumeKeys = args.volumeKeys {
|
||||
if volumeKeys {
|
||||
self.activateVolumeKeyInterception()
|
||||
} else {
|
||||
self.volumeKeyHandler?.stopInterception()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.volumeKeyHandler = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
invoke.resolve()
|
||||
} catch {
|
||||
invoke.reject(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func lock_screen_orientation(_ invoke: Invoke) throws {
|
||||
guard let args = try? invoke.parseArgs(LockScreenOrientationRequestArgs.self) else {
|
||||
return invoke.reject("Invalid arguments")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
let orientation = args.orientation ?? "auto"
|
||||
switch orientation.lowercased() {
|
||||
case "portrait":
|
||||
self.isOrientationLocked = true
|
||||
self.currentOrientationMask = .portrait
|
||||
self.forceOrientation(.portrait)
|
||||
self.setupOrientationObserver()
|
||||
case "landscape":
|
||||
self.isOrientationLocked = true
|
||||
self.currentOrientationMask = .landscape
|
||||
self.forceOrientation(.landscapeRight)
|
||||
self.setupOrientationObserver()
|
||||
case "auto":
|
||||
self.isOrientationLocked = false
|
||||
self.currentOrientationMask = .all
|
||||
default:
|
||||
invoke.reject("Invalid orientation mode")
|
||||
return
|
||||
}
|
||||
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
private func forceOrientation(_ orientation: UIInterfaceOrientation) {
|
||||
if #available(iOS 16.0, *) {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
let orientationMask: UIInterfaceOrientationMask =
|
||||
orientation.isPortrait ? .portrait : .landscape
|
||||
|
||||
for window in windowScene.windows {
|
||||
if let rootVC = window.rootViewController {
|
||||
rootVC.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
|
||||
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask)) { error in
|
||||
print("Orientation update error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UIDevice.current.setValue(orientation.rawValue, forKey: "orientation")
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupOrientationObserver() {
|
||||
orientationObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIDevice.orientationDidChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self, self.isOrientationLocked else { return }
|
||||
|
||||
if self.currentOrientationMask == .portrait {
|
||||
self.forceOrientation(.portrait)
|
||||
} else if self.currentOrientationMask == .landscape {
|
||||
self.forceOrientation(.landscapeRight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_native_bridge")
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-get-sys-fonts-list"
|
||||
description = "Enables the get_sys_fonts_list command without any pre-configured scope."
|
||||
commands.allow = ["get_sys_fonts_list"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-get-sys-fonts-list"
|
||||
description = "Denies the get_sys_fonts_list command without any pre-configured scope."
|
||||
commands.deny = ["get_sys_fonts_list"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-intercept-keys"
|
||||
description = "Enables the intercept_keys command without any pre-configured scope."
|
||||
commands.allow = ["intercept_keys"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-intercept-keys"
|
||||
description = "Denies the intercept_keys command without any pre-configured scope."
|
||||
commands.deny = ["intercept_keys"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-lock-screen-orientation"
|
||||
description = "Enables the lock_screen_orientation command without any pre-configured scope."
|
||||
commands.allow = ["lock_screen_orientation"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-lock-screen-orientation"
|
||||
description = "Denies the lock_screen_orientation command without any pre-configured scope."
|
||||
commands.deny = ["lock_screen_orientation"]
|
||||
+81
@@ -11,6 +11,9 @@ Default permissions for the plugin
|
||||
- `allow-install-package`
|
||||
- `allow-set-system-ui-visibility`
|
||||
- `allow-get-status-bar-height`
|
||||
- `allow-get-sys-fonts-list`
|
||||
- `allow-intercept-keys`
|
||||
- `allow-lock-screen-orientation`
|
||||
|
||||
## Permission Table
|
||||
|
||||
@@ -128,6 +131,32 @@ Denies the get_status_bar_height command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-get-sys-fonts-list`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the get_sys_fonts_list command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-get-sys-fonts-list`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the get_sys_fonts_list command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-install-package`
|
||||
|
||||
</td>
|
||||
@@ -154,6 +183,58 @@ Denies the install_package command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-intercept-keys`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the intercept_keys command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-intercept-keys`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the intercept_keys command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-lock-screen-orientation`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the lock_screen_orientation command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-lock-screen-orientation`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the lock_screen_orientation command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-set-system-ui-visibility`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -8,4 +8,7 @@ permissions = [
|
||||
"allow-install-package",
|
||||
"allow-set-system-ui-visibility",
|
||||
"allow-get-status-bar-height",
|
||||
"allow-get-sys-fonts-list",
|
||||
"allow-intercept-keys",
|
||||
"allow-lock-screen-orientation",
|
||||
]
|
||||
|
||||
+38
-2
@@ -342,6 +342,18 @@
|
||||
"const": "deny-get-status-bar-height",
|
||||
"markdownDescription": "Denies the get_status_bar_height command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_sys_fonts_list command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-get-sys-fonts-list",
|
||||
"markdownDescription": "Enables the get_sys_fonts_list command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_sys_fonts_list command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-get-sys-fonts-list",
|
||||
"markdownDescription": "Denies the get_sys_fonts_list command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install_package command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -354,6 +366,30 @@
|
||||
"const": "deny-install-package",
|
||||
"markdownDescription": "Denies the install_package command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the intercept_keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-intercept-keys",
|
||||
"markdownDescription": "Enables the intercept_keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the intercept_keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-intercept-keys",
|
||||
"markdownDescription": "Denies the intercept_keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the lock_screen_orientation command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-lock-screen-orientation",
|
||||
"markdownDescription": "Enables the lock_screen_orientation command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the lock_screen_orientation command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-lock-screen-orientation",
|
||||
"markdownDescription": "Denies the lock_screen_orientation command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -379,10 +415,10 @@
|
||||
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`",
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`"
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -58,3 +58,26 @@ pub(crate) async fn get_status_bar_height<R: Runtime>(
|
||||
) -> Result<GetStatusBarHeightResponse> {
|
||||
app.native_bridge().get_status_bar_height()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn get_sys_fonts_list<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
) -> Result<GetSysFontsListResponse> {
|
||||
app.native_bridge().get_sys_fonts_list()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn intercept_keys<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: InterceptKeysRequest,
|
||||
) -> Result<()> {
|
||||
app.native_bridge().intercept_keys(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn lock_screen_orientation<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: LockScreenOrientationRequest,
|
||||
) -> Result<()> {
|
||||
app.native_bridge().lock_screen_orientation(payload)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{plugin::PluginApi, AppHandle, Runtime};
|
||||
|
||||
use crate::models::*;
|
||||
@@ -47,4 +48,29 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
|
||||
let font_collection = font_enumeration::Collection::new().unwrap();
|
||||
let mut fonts = HashMap::new();
|
||||
for font in font_collection.all() {
|
||||
if cfg!(target_os = "windows") {
|
||||
// FIXME: temporarily disable font name with style for windows
|
||||
fonts.insert(font.family_name.clone(), font.family_name.clone());
|
||||
} else {
|
||||
fonts.insert(font.font_name.clone(), font.family_name.clone());
|
||||
}
|
||||
}
|
||||
Ok(GetSysFontsListResponse { fonts, error: None })
|
||||
}
|
||||
|
||||
pub fn intercept_keys(&self, _payload: InterceptKeysRequest) -> crate::Result<()> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn lock_screen_orientation(
|
||||
&self,
|
||||
_payload: LockScreenOrientationRequest,
|
||||
) -> crate::Result<()> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
commands::install_package,
|
||||
commands::set_system_ui_visibility,
|
||||
commands::get_status_bar_height,
|
||||
commands::get_sys_fonts_list,
|
||||
commands::intercept_keys,
|
||||
commands::lock_screen_orientation,
|
||||
])
|
||||
.setup(|app, api| {
|
||||
#[cfg(mobile)]
|
||||
|
||||
@@ -85,3 +85,30 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_sys_fonts_list", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn intercept_keys(&self, payload: InterceptKeysRequest) -> crate::Result<()> {
|
||||
self.0
|
||||
.run_mobile_plugin("intercept_keys", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn lock_screen_orientation(
|
||||
&self,
|
||||
payload: LockScreenOrientationRequest,
|
||||
) -> crate::Result<()> {
|
||||
self.0
|
||||
.run_mobile_plugin("lock_screen_orientation", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -65,3 +66,23 @@ pub struct GetStatusBarHeightResponse {
|
||||
pub height: u32,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetSysFontsListResponse {
|
||||
pub fonts: HashMap<String, String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InterceptKeysRequest {
|
||||
pub volume_keys: Option<bool>,
|
||||
pub back_key: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LockScreenOrientationRequest {
|
||||
pub orientation: String,
|
||||
}
|
||||
|
||||
@@ -105,17 +105,6 @@ async fn start_server(window: Window) -> Result<u16, String> {
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[command]
|
||||
async fn list_fonts() -> Result<Vec<String>, String> {
|
||||
let font_collection = font_enumeration::Collection::new().unwrap();
|
||||
let mut fonts = Vec::new();
|
||||
for font in font_collection.all() {
|
||||
fonts.push(font.family_name.clone());
|
||||
}
|
||||
Ok(fonts)
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Payload {
|
||||
@@ -138,8 +127,6 @@ pub fn run() {
|
||||
macos::apple_auth::start_apple_sign_in,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::traffic_light::set_traffic_lights,
|
||||
#[cfg(desktop)]
|
||||
list_fonts
|
||||
])
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
@@ -244,11 +231,13 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
app.handle().plugin(
|
||||
if let Err(e) = app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
) {
|
||||
eprintln!("Failed to initialize tauri_plugin_log: {}", e);
|
||||
};
|
||||
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
"minSdkVersion": 26
|
||||
},
|
||||
"iOS": {
|
||||
"developmentTeam": "J5W48D69VR"
|
||||
"developmentTeam": "J5W48D69VR",
|
||||
"minimumSystemVersion": "14.0"
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { LibraryViewModeType } from '@/types/settings';
|
||||
import { formatAuthors } from '@/utils/book';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
import BookCover from './BookCover';
|
||||
import BookCover from '@/components/BookCover';
|
||||
|
||||
interface BookItemProps {
|
||||
mode: LibraryViewModeType;
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BooksGroup } from '@/types/book';
|
||||
import BookCover from './BookCover';
|
||||
import BookCover from '@/components/BookCover';
|
||||
|
||||
interface GroupItemProps {
|
||||
group: BooksGroup;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
@@ -12,10 +11,12 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
|
||||
@@ -34,9 +35,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
|
||||
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
|
||||
const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks);
|
||||
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
|
||||
settings.autoImportBooksOnOpen,
|
||||
);
|
||||
const iconSize = useResponsiveSize(16);
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
@@ -108,6 +111,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsScreenWakeLock(settings.screenWakeLock);
|
||||
};
|
||||
|
||||
const toggleOpenLastBooks = () => {
|
||||
settings.openLastBooks = !settings.openLastBooks;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsOpenLastBooks(settings.openLastBooks);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
const storagPlan = getStoragePlanData(token);
|
||||
@@ -144,14 +154,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
labelClass='!max-w-40'
|
||||
Icon={
|
||||
avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={_('User avatar')}
|
||||
className='h-5 w-5 rounded-full'
|
||||
referrerPolicy='no-referrer'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<UserAvatar url={avatarUrl} size={iconSize} DefaultIcon={PiUserCircleCheck} />
|
||||
) : (
|
||||
PiUserCircleCheck
|
||||
)
|
||||
@@ -177,6 +180,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={toggleAutoImportBooksOnOpen}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
label={_('Open Last Book on Start')}
|
||||
Icon={isOpenLastBooks ? MdCheck : undefined}
|
||||
onClick={toggleOpenLastBooks}
|
||||
/>
|
||||
{appService?.hasUpdater && (
|
||||
<MenuItem
|
||||
label={_('Check Updates on Start')}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useBooksSync } from './hooks/useBooksSync';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import {
|
||||
tauriHandleSetAlwaysOnTop,
|
||||
tauriHandleToggleFullScreen,
|
||||
@@ -61,7 +62,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
updateBook,
|
||||
setLibrary,
|
||||
checkOpenWithBooks,
|
||||
checkLastOpenBooks,
|
||||
setCheckOpenWithBooks,
|
||||
setCheckLastOpenBooks,
|
||||
} = useLibraryStore();
|
||||
const _ = useTranslation();
|
||||
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
|
||||
@@ -116,6 +119,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appService?.isMobileApp) {
|
||||
lockScreenOrientation({ orientation: 'portrait' });
|
||||
}
|
||||
}, [appService]);
|
||||
|
||||
const handleDropedFiles = async (files: File[] | string[]) => {
|
||||
if (files.length === 0) return;
|
||||
const supportedFiles = files.filter((file) => {
|
||||
@@ -231,6 +240,27 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenLastBooks = async (
|
||||
appService: AppService,
|
||||
lastBookIds: string[],
|
||||
libraryBooks: Book[],
|
||||
) => {
|
||||
if (lastBookIds.length === 0) return;
|
||||
const bookIds: string[] = [];
|
||||
for (const bookId of lastBookIds) {
|
||||
const book = libraryBooks.find((b) => b.hash === bookId);
|
||||
if (book && (await appService.isBookAvailable(book))) {
|
||||
bookIds.push(book.hash);
|
||||
}
|
||||
console.log('Opening last books:', bookIds);
|
||||
if (bookIds.length > 0) {
|
||||
setTimeout(() => {
|
||||
navigateToReader(router, bookIds);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
@@ -256,13 +286,19 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setSettings(settings);
|
||||
|
||||
const libraryBooks = await appService.loadLibraryBooks();
|
||||
if (checkOpenWithBooks && isTauriAppPlatform()) {
|
||||
if (checkOpenWithBooks) {
|
||||
await handleOpenWithBooks(appService, libraryBooks);
|
||||
} else {
|
||||
setCheckOpenWithBooks(false);
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
|
||||
if (checkLastOpenBooks && settings.openLastBooks) {
|
||||
await handleOpenLastBooks(appService, settings.lastOpenBooks, libraryBooks);
|
||||
} else {
|
||||
setCheckLastOpenBooks(false);
|
||||
}
|
||||
|
||||
setLibraryLoaded(true);
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setLoading(false);
|
||||
@@ -283,6 +319,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
initLibrary();
|
||||
return () => {
|
||||
setCheckOpenWithBooks(false);
|
||||
setCheckLastOpenBooks(false);
|
||||
isInitiating.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -501,7 +538,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
return null;
|
||||
}
|
||||
|
||||
if (checkOpenWithBooks) {
|
||||
if (checkOpenWithBooks || checkLastOpenBooks) {
|
||||
return (
|
||||
loading && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
|
||||
@@ -2,10 +2,12 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookDoc, getDirection } from '@/libs/document';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
|
||||
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
|
||||
@@ -25,12 +27,14 @@ import {
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
bookDoc: BookDoc;
|
||||
config: BookConfig;
|
||||
}> = ({ bookKey, bookDoc, config }) => {
|
||||
const { appService } = useEnv();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const isViewCreated = useRef(false);
|
||||
@@ -55,27 +59,30 @@ const FoliateViewer: React.FC<{
|
||||
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
|
||||
};
|
||||
|
||||
const docTransformHandler = (event: Event) => {
|
||||
const { detail } = event as CustomEvent;
|
||||
detail.data = Promise.resolve(detail.data)
|
||||
.then((data) => {
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (detail.type === 'text/css') return transformStylesheet(data);
|
||||
if (viewSettings && detail.type === 'application/xhtml+xml') {
|
||||
const ctx = {
|
||||
bookKey,
|
||||
viewSettings,
|
||||
content: data,
|
||||
transformers: ['punctuation'],
|
||||
};
|
||||
return Promise.resolve(transformContent(ctx));
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
|
||||
return '';
|
||||
});
|
||||
const getDocTransformHandler = ({ width, height }: { width: number; height: number }) => {
|
||||
return (event: Event) => {
|
||||
const { detail } = event as CustomEvent;
|
||||
detail.data = Promise.resolve(detail.data)
|
||||
.then((data) => {
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (viewSettings && detail.type === 'text/css')
|
||||
return transformStylesheet(viewSettings, width, height, data);
|
||||
if (viewSettings && detail.type === 'application/xhtml+xml') {
|
||||
const ctx = {
|
||||
bookKey,
|
||||
viewSettings,
|
||||
content: data,
|
||||
transformers: ['punctuation'],
|
||||
};
|
||||
return Promise.resolve(transformContent(ctx));
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
|
||||
return '';
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const docLoadHandler = (event: Event) => {
|
||||
@@ -96,6 +103,9 @@ const FoliateViewer: React.FC<{
|
||||
mountAdditionalFonts(detail.doc);
|
||||
|
||||
if (!detail.doc.isEventListenersAdded) {
|
||||
// listened events in iframes are posted to the main window
|
||||
// and then used by useMouseEvent and useTouchEvent
|
||||
// and more gesture events can be detected in the iframeEventHandlers
|
||||
detail.doc.isEventListenersAdded = true;
|
||||
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
|
||||
@@ -113,18 +123,6 @@ const FoliateViewer: React.FC<{
|
||||
const detail = (event as CustomEvent).detail;
|
||||
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
|
||||
|
||||
if (detail.reason === 'scroll') {
|
||||
const renderer = viewRef.current?.renderer;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (renderer && viewSettings.continuousScroll) {
|
||||
if (renderer.start <= 0) {
|
||||
viewRef.current?.prev(1);
|
||||
// sometimes viewSize has subpixel value that the end never reaches
|
||||
} else if (renderer.end + 1 >= renderer.viewSize) {
|
||||
viewRef.current?.next(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const parallelViews = getParallels(bookKey);
|
||||
if (parallelViews && parallelViews.size > 0) {
|
||||
parallelViews.forEach((key) => {
|
||||
@@ -138,8 +136,9 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
useTouchEvent(bookKey, viewRef);
|
||||
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
|
||||
const { handlePageFlip, handleContinuousScroll } = usePagination(bookKey, viewRef, containerRef);
|
||||
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip, handleContinuousScroll);
|
||||
const touchHandlers = useTouchEvent(bookKey, handleContinuousScroll);
|
||||
|
||||
useFoliateEvents(viewRef.current, {
|
||||
onLoad: docLoadHandler,
|
||||
@@ -167,6 +166,10 @@ const FoliateViewer: React.FC<{
|
||||
document.body.append(view);
|
||||
containerRef.current?.appendChild(view);
|
||||
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
const width = containerRect?.width || window.innerWidth;
|
||||
const height = containerRect?.height || window.innerHeight;
|
||||
|
||||
const writingMode = viewSettings.writingMode;
|
||||
if (writingMode) {
|
||||
const settingsDir = getBookDirFromWritingMode(writingMode);
|
||||
@@ -185,16 +188,23 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
const { book } = view;
|
||||
|
||||
book.transformTarget?.addEventListener('data', docTransformHandler);
|
||||
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
|
||||
const isScrolled = viewSettings.scrolled!;
|
||||
const marginPx = viewSettings.marginPx!;
|
||||
const gapPercent = viewSettings.gapPercent!;
|
||||
const showHeader = viewSettings.showHeader!;
|
||||
const showFooter = viewSettings.showFooter!;
|
||||
const isCompact = !showHeader && !showFooter;
|
||||
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
|
||||
const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent;
|
||||
const animated = viewSettings.animated!;
|
||||
const maxColumnCount = viewSettings.maxColumnCount!;
|
||||
const maxInlineSize = getMaxInlineSize(viewSettings);
|
||||
const maxBlockSize = viewSettings.maxBlockSize!;
|
||||
const screenOrientation = viewSettings.screenOrientation!;
|
||||
if (appService?.isMobileApp) {
|
||||
await lockScreenOrientation({ orientation: screenOrientation });
|
||||
}
|
||||
if (animated) {
|
||||
view.renderer.setAttribute('animated', '');
|
||||
} else {
|
||||
@@ -222,9 +232,10 @@ const FoliateViewer: React.FC<{
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
onClick={(event) => handleTurnPage(event)}
|
||||
ref={containerRef}
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
{...mouseHandlers}
|
||||
{...touchHandlers}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -103,7 +103,6 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
};
|
||||
|
||||
const handleSetActionTab = (tab: string) => {
|
||||
console.log('handleSetActionTab', tab);
|
||||
setActionTab(actionTab === tab ? '' : tab);
|
||||
if (tab === 'tts') {
|
||||
setHoveredBookKey('');
|
||||
@@ -130,9 +129,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
const isVisible = hoveredBookKey === bookKey;
|
||||
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
|
||||
const progressValid = !!progressInfo;
|
||||
const progressFraction = progressValid
|
||||
? ((progressInfo!.next ?? progressInfo!.current) + 1) / progressInfo!.total
|
||||
: 0;
|
||||
const progressFraction =
|
||||
progressValid && progressInfo?.total > 0
|
||||
? (progressInfo!.current + 1) / progressInfo!.total || 0
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
|
||||
import { getFootnoteStyles, getStyles, getThemeCode, mountAdditionalFonts } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FoliateView } from '@/types/view';
|
||||
@@ -53,6 +53,10 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
getView(bookKey)?.goTo(popupLinkDetail.href);
|
||||
});
|
||||
});
|
||||
view.addEventListener('load', (e: CustomEvent) => {
|
||||
const { doc } = e.detail;
|
||||
mountAdditionalFonts(doc);
|
||||
});
|
||||
footnoteViewRef.current = view;
|
||||
footnoteRef.current?.replaceChildren(view);
|
||||
const { renderer } = view;
|
||||
@@ -131,6 +135,11 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
setGridRect(rect);
|
||||
setTrianglePosition(triangPos);
|
||||
|
||||
const { a: anchor } = detail as { a: HTMLAnchorElement };
|
||||
const footnoteClasses = ['duokan-footnote', 'footnote-link', 'footnote'];
|
||||
if (footnoteClasses.some((cls) => anchor.classList.contains(cls))) {
|
||||
detail['follow'] = true;
|
||||
}
|
||||
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
|
||||
console.warn(err);
|
||||
const detail = (event as CustomEvent).detail;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { LuNotebookPen } from 'react-icons/lu';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -13,11 +15,16 @@ interface NotebookTogglerProps {
|
||||
|
||||
const NotebookToggler: React.FC<NotebookTogglerProps> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { setHoveredBookKey } = useReaderStore();
|
||||
const { sideBarBookKey, setSideBarBookKey } = useSidebarStore();
|
||||
const { isNotebookVisible, toggleNotebook } = useNotebookStore();
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (appService?.isMobile) {
|
||||
setHoveredBookKey('');
|
||||
}
|
||||
if (sideBarBookKey === bookKey) {
|
||||
toggleNotebook();
|
||||
} else {
|
||||
|
||||
@@ -35,7 +35,7 @@ const PageInfoView: React.FC<PageInfoProps> = ({
|
||||
: ''
|
||||
: pageinfo
|
||||
? _(isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}', {
|
||||
currentPage: (pageinfo.next ?? pageinfo.current) + 1,
|
||||
currentPage: pageinfo.current + 1,
|
||||
totalPage: pageinfo.total,
|
||||
})
|
||||
: '';
|
||||
|
||||
@@ -10,8 +10,11 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { setSystemUIVisibility } from '@/utils/bridge';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
@@ -20,11 +23,13 @@ import ReaderContent from './ReaderContent';
|
||||
|
||||
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore();
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { setLibrary } = useLibraryStore();
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
|
||||
const { isNotebookVisible, setNotebookVisible } = useNotebookStore();
|
||||
const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore();
|
||||
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
@@ -33,7 +38,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (appService?.isMobile && !document.hidden) {
|
||||
if (appService?.isMobileApp && !document.hidden) {
|
||||
dismissSystemUI();
|
||||
setSystemUIVisibility({ visible: false, darkMode: isDarkMode });
|
||||
}
|
||||
@@ -45,6 +50,34 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService, isDarkMode]);
|
||||
|
||||
const handleKeyDown = (event: CustomEvent) => {
|
||||
if (event.detail.keyName === 'Back') {
|
||||
setSideBarVisible(false);
|
||||
setNotebookVisible(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.isAndroidApp) return;
|
||||
if (isSideBarVisible || isNotebookVisible) {
|
||||
acquireBackKeyInterception();
|
||||
eventDispatcher.onSync('native-key-down', handleKeyDown);
|
||||
}
|
||||
if (!isSideBarVisible && !isNotebookVisible) {
|
||||
releaseBackKeyInterception();
|
||||
eventDispatcher.offSync('native-key-down', handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
if (appService?.isAndroidApp) {
|
||||
releaseBackKeyInterception();
|
||||
eventDispatcher.offSync('native-key-down', handleKeyDown);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSideBarVisible, isNotebookVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
@@ -77,6 +77,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookKeys && bookKeys.length > 0) {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
settings.lastOpenBooks = bookKeys.map((key) => key.split('-')[0]!);
|
||||
saveSettings(envConfig, settings);
|
||||
}
|
||||
|
||||
if (isTauriAppPlatform()) tauriHandleOnCloseWindow(handleCloseBooks);
|
||||
window.addEventListener('beforeunload', handleCloseBooks);
|
||||
eventDispatcher.on('quit-app', handleCloseBooks);
|
||||
|
||||
@@ -22,7 +22,13 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
import {
|
||||
getPopupPosition,
|
||||
getPosition,
|
||||
getTextFromRange,
|
||||
Position,
|
||||
TextSelection,
|
||||
} from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
@@ -48,11 +54,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const primaryLang = bookData.book?.primaryLanguage || 'en';
|
||||
|
||||
const isShowingPopup = useRef(false);
|
||||
const isTextSelected = useRef(false);
|
||||
const isUpToShowPopup = useRef(false);
|
||||
const isTouchstarted = useRef(false);
|
||||
const selectionStartRef = useRef<number | null>(null);
|
||||
const selectionAnchorRef = useRef<{ node: Node; offset: number } | null>(null);
|
||||
const [selection, setSelection] = useState<TextSelection | null>();
|
||||
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
|
||||
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
|
||||
@@ -96,6 +105,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
transformers: ['punctuation'],
|
||||
reversePunctuationTransform: true,
|
||||
};
|
||||
const getAnnotationText = (range: Range) => {
|
||||
return getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []);
|
||||
};
|
||||
const makeSelection = async (sel: Selection, rebuildRange = false) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
@@ -103,7 +115,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
transformCtx['content'] = sel.toString();
|
||||
transformCtx['content'] = getAnnotationText(range);
|
||||
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
|
||||
};
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
@@ -115,11 +127,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setTimeout(async () => {
|
||||
if (!isTextSelected.current) return;
|
||||
sel.addRange(range);
|
||||
transformCtx['content'] = range.toString();
|
||||
transformCtx['content'] = getAnnotationText(range);
|
||||
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
|
||||
}, 40);
|
||||
}, 0);
|
||||
};
|
||||
const handleSelectstart = () => {
|
||||
selectionAnchorRef.current = null;
|
||||
};
|
||||
const handleSelectionchange = () => {
|
||||
// Available on iOS, Android and Desktop, fired when the selection is changed
|
||||
// Ideally the popup only shows when the selection is done,
|
||||
@@ -130,6 +145,27 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const sel = doc.getSelection();
|
||||
if (isValidSelection(sel)) {
|
||||
if (osPlatform === 'android' && isTouchstarted.current) {
|
||||
if (!selectionAnchorRef.current) {
|
||||
const range = sel.getRangeAt(0);
|
||||
selectionAnchorRef.current = {
|
||||
node: range.startContainer,
|
||||
offset: range.startOffset,
|
||||
};
|
||||
}
|
||||
if (selectionAnchorRef.current) {
|
||||
const currentRange = sel.getRangeAt(0);
|
||||
if (
|
||||
currentRange.startContainer !== selectionAnchorRef.current.node ||
|
||||
currentRange.startOffset !== selectionAnchorRef.current.offset
|
||||
) {
|
||||
const newRange = doc.createRange();
|
||||
newRange.setStart(selectionAnchorRef.current.node, selectionAnchorRef.current.offset);
|
||||
newRange.setEnd(currentRange.endContainer, currentRange.endOffset);
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
}
|
||||
makeSelection(sel, false);
|
||||
}
|
||||
} else if (!isUpToShowPopup.current) {
|
||||
@@ -165,11 +201,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// Available on iOS, on Android fired after an additional touch event
|
||||
isTouchstarted.current = false;
|
||||
};
|
||||
const handleScroll = () => {
|
||||
// Prevent the container from scrolling when text is selected in paginated mode
|
||||
// This is a workaround for the issue #873
|
||||
// TODO: support text selection across pages
|
||||
if (
|
||||
!viewSettings?.scrolled &&
|
||||
isTextSelected.current &&
|
||||
view?.renderer?.containerPosition &&
|
||||
selectionStartRef.current
|
||||
) {
|
||||
view.renderer.containerPosition = selectionStartRef.current;
|
||||
}
|
||||
};
|
||||
if (bookData.book?.format !== 'PDF') {
|
||||
view?.renderer?.addEventListener('scroll', handleScroll);
|
||||
detail.doc?.addEventListener('pointerup', handlePointerup);
|
||||
detail.doc?.addEventListener('touchstart', handleTouchstart);
|
||||
detail.doc?.addEventListener('touchmove', handleTouchmove);
|
||||
detail.doc?.addEventListener('touchend', handleTouchend);
|
||||
detail.doc?.addEventListener('selectstart', handleSelectstart);
|
||||
detail.doc?.addEventListener('selectionchange', handleSelectionchange);
|
||||
|
||||
// Disable the default context menu on mobile devices,
|
||||
@@ -241,6 +292,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
isTextSelected.current = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isTextSelected.current && !selectionStartRef.current) {
|
||||
selectionStartRef.current = view?.renderer?.start || null;
|
||||
} else if (!isTextSelected.current) {
|
||||
selectionStartRef.current = null;
|
||||
selectionAnchorRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isTextSelected.current]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSingleClick = (): boolean => {
|
||||
if (isUpToShowPopup.current) {
|
||||
|
||||
@@ -2,8 +2,9 @@ import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface DropdownProps {
|
||||
family?: string;
|
||||
@@ -23,34 +24,42 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
onGetFontFamily,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const defaultIconSize = useDefaultIconSize();
|
||||
const allOptions = [...options, ...(moreOptions ?? [])];
|
||||
const selectedOption = allOptions.find((option) => option.option === selected) ?? allOptions[0]!;
|
||||
return (
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
|
||||
className='btn btn-sm flex items-center px-[10px] font-normal normal-case sm:px-[20px]'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<span style={{ fontFamily: onGetFontFamily(selectedOption.option, family ?? '') }}>
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
<FiChevronUp size={iconSize16} />
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span
|
||||
className='text-ellipsis'
|
||||
style={{
|
||||
fontFamily: onGetFontFamily(selectedOption.option, family ?? ''),
|
||||
}}
|
||||
>
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
<FiChevronUp size={iconSize16} />
|
||||
</div>
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-[-32px] z-[1] mt-4 w-44 shadow sm:right-0',
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] mt-4 shadow',
|
||||
'!sm:px-2 right-[-32px] w-[46vw] !px-1 sm:right-0 sm:w-44',
|
||||
moreOptions?.length ? '' : 'inline max-h-80 overflow-y-scroll',
|
||||
)}
|
||||
>
|
||||
{options.map(({ option, label }) => (
|
||||
<li key={option} onClick={() => onSelect(option)}>
|
||||
<div className='flex items-center px-0'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
{selected === option && <MdCheck className='text-base-content' />}
|
||||
<div className='flex w-full items-center overflow-hidden px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize16}px` }}>
|
||||
{selected === option && <MdCheck className='text-base-content' size={iconSize16} />}
|
||||
</span>
|
||||
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>
|
||||
{label || option}
|
||||
@@ -60,8 +69,8 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
))}
|
||||
{moreOptions && moreOptions.length > 0 && (
|
||||
<li className='dropdown dropdown-left dropdown-top'>
|
||||
<div className='flex items-center px-0'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
<div className='flex items-center px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize16}px` }}>
|
||||
<FiChevronLeft size={iconSize16} />
|
||||
</span>
|
||||
<span>{_('System Fonts')}</span>
|
||||
@@ -69,17 +78,25 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] overflow-y-scroll shadow',
|
||||
'!mr-5 mb-[-46px] inline max-h-80 w-[200px] overflow-y-scroll',
|
||||
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] shadow',
|
||||
'!sm:px-2 !mr-4 mb-[-46px] inline max-h-80 w-[46vw] overflow-y-scroll !px-1 sm:w-[200px]',
|
||||
)}
|
||||
>
|
||||
{moreOptions.map((option, index) => (
|
||||
<li key={`${index}-${option.option}`} onClick={() => onSelect(option.option)}>
|
||||
<div className='flex items-center px-2'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
{selected === option.option && <MdCheck className='text-base-content' />}
|
||||
<div className='flex w-full items-center overflow-hidden px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize16}px` }}>
|
||||
{selected === option.option && (
|
||||
<MdCheck className='text-base-content' size={iconSize16} />
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontFamily: onGetFontFamily(option.option, family ?? '') }}>
|
||||
<span
|
||||
style={
|
||||
!appService?.isLinuxApp
|
||||
? { fontFamily: onGetFontFamily(option.option, family ?? '') }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{option.label || option.option}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
ANDROID_FONTS,
|
||||
CJK_EXCLUDE_PATTENS,
|
||||
CJK_FONTS_PATTENS,
|
||||
CJK_NAMES_PATTENS,
|
||||
CJK_SANS_SERIF_FONTS,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
LINUX_FONTS,
|
||||
MACOS_FONTS,
|
||||
MONOSPACE_FONTS,
|
||||
NON_FREE_FONTS,
|
||||
SANS_SERIF_FONTS,
|
||||
SERIF_FONTS,
|
||||
WINDOWS_FONTS,
|
||||
@@ -19,12 +21,21 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
|
||||
import { getSysFontsList } from '@/utils/font';
|
||||
import { getSysFontsList } from '@/utils/bridge';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import NumberInput from './NumberInput';
|
||||
import FontDropdown from './FontDropDown';
|
||||
|
||||
const genCJKFontsList = (sysFonts: string[]) => {
|
||||
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
|
||||
.filter((font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font))
|
||||
.filter((font) => !CJK_EXCLUDE_PATTENS.test(font))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const isSymbolicFontName = (font: string) =>
|
||||
/emoji|icons|symbol|dingbats|ornaments|webdings|wingdings|miuiex/i.test(font);
|
||||
interface FontFaceProps {
|
||||
className?: string;
|
||||
family: string;
|
||||
@@ -39,6 +50,10 @@ const handleFontFaceFont = (option: string, family: string) => {
|
||||
return `'${option}', ${family}`;
|
||||
};
|
||||
|
||||
const filterNonFreeFonts = (font: string) => {
|
||||
return !['android', 'linux'].includes(getOSPlatform()) || !NON_FREE_FONTS.includes(font);
|
||||
};
|
||||
|
||||
const FontFace = ({
|
||||
className,
|
||||
family,
|
||||
@@ -51,7 +66,7 @@ const FontFace = ({
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<div className={clsx('config-item', className)}>
|
||||
<span className=''>{label}</span>
|
||||
<span className='min-w-10'>{label}</span>
|
||||
<FontDropdown
|
||||
family={family}
|
||||
options={options.map((option) => ({ option, label: _(option) }))}
|
||||
@@ -66,7 +81,7 @@ const FontFace = ({
|
||||
|
||||
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { envConfig } = useEnv();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const view = getView(bookKey)!;
|
||||
@@ -113,19 +128,42 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
|
||||
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
|
||||
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
|
||||
const [CJKFonts] = useState<string[]>(() => {
|
||||
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
|
||||
.filter((font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const [CJKFonts, setCJKFonts] = useState<string[]>(() => {
|
||||
return genCJKFontsList(sysFonts);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauriAppPlatform() && appService?.hasSysFontsList) {
|
||||
getSysFontsList().then((fonts) => {
|
||||
setSysFonts(fonts);
|
||||
setCJKFonts((prev) => {
|
||||
const newFonts = genCJKFontsList(sysFonts);
|
||||
return prev.length !== newFonts.length ? newFonts : prev;
|
||||
});
|
||||
}, [sysFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauriAppPlatform()) {
|
||||
getSysFontsList().then((res) => {
|
||||
if (res.error || Object.keys(res.fonts).length === 0) {
|
||||
console.error('Failed to get system fonts list:', res.error);
|
||||
return;
|
||||
}
|
||||
const processedFonts: string[] = [];
|
||||
Object.entries(res.fonts).forEach(([fontName, fontFamily]) => {
|
||||
if (!fontName || isSymbolicFontName(fontName)) return;
|
||||
|
||||
const fontsInFamily = Object.entries(res.fonts).filter(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
([_, family]) => family === fontFamily,
|
||||
);
|
||||
|
||||
if (fontsInFamily.length === 1) {
|
||||
processedFonts.push(fontFamily);
|
||||
} else {
|
||||
processedFonts.push(fontName);
|
||||
}
|
||||
});
|
||||
setSysFonts([...new Set(processedFonts)].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -272,7 +310,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
className='config-item-top'
|
||||
family='serif'
|
||||
label={_('Serif Font')}
|
||||
options={[...SERIF_FONTS, ...CJK_SERIF_FONTS]}
|
||||
options={[...SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SERIF_FONTS]}
|
||||
moreOptions={sysFonts}
|
||||
selected={serifFont}
|
||||
onSelect={setSerifFont}
|
||||
@@ -280,7 +318,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<FontFace
|
||||
family='sans-serif'
|
||||
label={_('Sans-Serif Font')}
|
||||
options={[...SANS_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]}
|
||||
options={[...SANS_SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SANS_SERIF_FONTS]}
|
||||
moreOptions={sysFonts}
|
||||
selected={sansSerifFont}
|
||||
onSelect={setSansSerifFont}
|
||||
|
||||
@@ -33,6 +33,8 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [hyphenation, setHyphenation] = useState(viewSettings.hyphenation!);
|
||||
const [marginPx, setMarginPx] = useState(viewSettings.marginPx!);
|
||||
const [gapPercent, setGapPercent] = useState(viewSettings.gapPercent!);
|
||||
const [compactMarginPx, setCompactMarginPx] = useState(viewSettings.compactMarginPx!);
|
||||
const [compactGapPercent, setCompactGapPercent] = useState(viewSettings.compactGapPercent!);
|
||||
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
|
||||
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
|
||||
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
|
||||
@@ -87,6 +89,13 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [marginPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compactMarginPx === viewSettings.compactMarginPx) return;
|
||||
saveViewSettings(envConfig, bookKey, 'compactMarginPx', compactMarginPx, false, false);
|
||||
view?.renderer.setAttribute('margin', `${compactMarginPx}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [compactMarginPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gapPercent === viewSettings.gapPercent) return;
|
||||
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
|
||||
@@ -97,6 +106,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gapPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compactGapPercent === viewSettings.compactGapPercent) return;
|
||||
saveViewSettings(envConfig, bookKey, 'compactGapPercent', compactGapPercent, false, false);
|
||||
view?.renderer.setAttribute('gap', `${compactGapPercent}%`);
|
||||
if (viewSettings.scrolled) {
|
||||
view?.renderer.setAttribute('flow', 'scrolled');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [compactGapPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (maxColumnCount === viewSettings.maxColumnCount) return;
|
||||
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount, false, false);
|
||||
@@ -179,6 +198,22 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [borderColor]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showBarsOnScroll', showBarsOnScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showBarsOnScroll]);
|
||||
|
||||
const applyMarginAndGap = () => {
|
||||
const isCompact = !showHeader && !showFooter;
|
||||
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
|
||||
const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent;
|
||||
view?.renderer.setAttribute('margin', `${marginPx}px`);
|
||||
view?.renderer.setAttribute('gap', `${gapPercent}%`);
|
||||
if (viewSettings.scrolled) {
|
||||
view?.renderer.setAttribute('flow', 'scrolled');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showHeader === viewSettings.showHeader) return;
|
||||
if (showHeader && !viewSettings.vertical) {
|
||||
@@ -194,14 +229,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader, false, false);
|
||||
|
||||
applyMarginAndGap();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showBarsOnScroll', showBarsOnScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showBarsOnScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showFooter === viewSettings.showFooter) return;
|
||||
if (showFooter && !viewSettings.vertical) {
|
||||
@@ -217,6 +249,8 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter, false, false);
|
||||
|
||||
applyMarginAndGap();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showFooter]);
|
||||
|
||||
@@ -399,16 +433,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<NumberInput
|
||||
label={_('Vertical Margins (px)')}
|
||||
value={marginPx}
|
||||
onChange={setMarginPx}
|
||||
value={showFooter || showHeader ? marginPx : compactMarginPx}
|
||||
onChange={showFooter || showHeader ? setMarginPx : setCompactMarginPx}
|
||||
min={!viewSettings.vertical && (showFooter || showHeader) ? 44 : 0}
|
||||
max={88}
|
||||
step={4}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Horizontal Margins (%)')}
|
||||
value={gapPercent}
|
||||
onChange={setGapPercent}
|
||||
value={showFooter || showHeader ? gapPercent : compactGapPercent}
|
||||
onChange={showFooter || showHeader ? setGapPercent : setCompactGapPercent}
|
||||
min={
|
||||
viewSettings.vertical && (showFooter || showHeader)
|
||||
? Math.ceil(4800 / window.innerWidth)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
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';
|
||||
@@ -16,15 +20,18 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
|
||||
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
|
||||
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 [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
|
||||
@@ -140,11 +147,31 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [swapClickArea]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
if (volumeKeysToFlip) {
|
||||
acquireVolumeKeyInterception();
|
||||
} else {
|
||||
releaseVolumeKeyInterception();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [volumeKeysToFlip]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isContinuousScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'screenOrientation', screenOrientation, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
lockScreenOrientation({ orientation: screenOrientation });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenOrientation]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -185,6 +212,47 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appService?.isMobileApp && (
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Screen')}</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=''>{_('Orientation')}</span>
|
||||
<div className='flex gap-4'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setScreenOrientation('auto')}
|
||||
>
|
||||
<MdOutlineScreenRotation />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Portrait')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'portrait' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setScreenOrientation('portrait')}
|
||||
>
|
||||
<IoPhonePortraitOutline />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Landscape')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'landscape' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setScreenOrientation('landscape')}
|
||||
>
|
||||
<IoPhoneLandscapeOutline />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
@@ -198,17 +266,28 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
|
||||
/>
|
||||
</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 className='config-item'>
|
||||
<span className=''>{_('Disable Click-to-Flip')}</span>
|
||||
<span className=''>{_('Clicks for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isDisableClick}
|
||||
checked={!isDisableClick}
|
||||
onChange={() => setIsDisableClick(!isDisableClick)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Swap Click-to-Flip Area')}</span>
|
||||
<span className=''>{_('Swap Clicks Area')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
|
||||
@@ -25,20 +25,27 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
|
||||
const [lightBackgroundColor, setLightBackgroundColor] = useState(
|
||||
customTheme?.colors.light.bg || template.light.bg,
|
||||
);
|
||||
const [lightPrimaryColor, setLightPrimaryColor] = useState(
|
||||
customTheme?.colors.light.primary || template.light.primary,
|
||||
);
|
||||
const [darkTextColor, setDarkTextColor] = useState(
|
||||
customTheme?.colors.dark.fg || template.dark.fg,
|
||||
);
|
||||
const [darkBackgroundColor, setDarkBackgroundColor] = useState(
|
||||
customTheme?.colors.dark.bg || template.dark.bg,
|
||||
);
|
||||
const [darkPrimaryColor, setDarkPrimaryColor] = useState(
|
||||
customTheme?.colors.dark.primary || template.dark.primary,
|
||||
);
|
||||
|
||||
const [themeName, setThemeName] = useState(customTheme?.label || _('Custom'));
|
||||
|
||||
const ThemePreview: React.FC<{
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
primaryColor: string;
|
||||
label: string;
|
||||
}> = ({ textColor, backgroundColor, label }) => (
|
||||
}> = ({ textColor, backgroundColor, primaryColor, label }) => (
|
||||
<div className='mb-2 mt-4'>
|
||||
<label className='mb-1 block text-sm font-medium'>{label}</label>
|
||||
<div
|
||||
@@ -52,6 +59,14 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
|
||||
{_(
|
||||
"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",
|
||||
)}
|
||||
<p
|
||||
className='mt-4 cursor-pointer italic'
|
||||
style={{
|
||||
color: primaryColor,
|
||||
}}
|
||||
>
|
||||
{_("(from 'As You Like It', Act II)")}
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,12 +80,12 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
|
||||
light: {
|
||||
fg: lightTextColor,
|
||||
bg: lightBackgroundColor,
|
||||
primary: '#3b82f6',
|
||||
primary: lightPrimaryColor,
|
||||
},
|
||||
dark: {
|
||||
fg: darkTextColor,
|
||||
bg: darkBackgroundColor,
|
||||
primary: '#60a5fa',
|
||||
primary: darkPrimaryColor,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -89,17 +104,16 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
|
||||
{_('Save')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-sm px-2',
|
||||
!settings.globalReadSettings.customThemes.find(
|
||||
(theme) => theme.name === md5Fingerprint(themeName),
|
||||
) && 'btn-disabled',
|
||||
)}
|
||||
onClick={() => onDelete(getCustomTheme())}
|
||||
>
|
||||
{_('Delete')}
|
||||
</button>
|
||||
{settings.globalReadSettings.customThemes.find(
|
||||
(theme) => theme.name === md5Fingerprint(themeName),
|
||||
) && (
|
||||
<button
|
||||
className={clsx('btn btn-ghost btn-sm px-2')}
|
||||
onClick={() => onDelete(getCustomTheme())}
|
||||
>
|
||||
{_('Delete')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className='btn btn-ghost btn-sm px-2' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
@@ -129,9 +143,16 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
|
||||
onChange={setLightBackgroundColor}
|
||||
/>
|
||||
|
||||
<ColorInput
|
||||
label={_('Link Color')}
|
||||
value={lightPrimaryColor}
|
||||
onChange={setLightPrimaryColor}
|
||||
/>
|
||||
|
||||
<ThemePreview
|
||||
textColor={lightTextColor}
|
||||
backgroundColor={lightBackgroundColor}
|
||||
primaryColor={lightPrimaryColor}
|
||||
label={_('Preview')}
|
||||
/>
|
||||
</div>
|
||||
@@ -147,9 +168,16 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
|
||||
onChange={setDarkBackgroundColor}
|
||||
/>
|
||||
|
||||
<ColorInput
|
||||
label={_('Link Color')}
|
||||
value={darkPrimaryColor}
|
||||
onChange={setDarkPrimaryColor}
|
||||
/>
|
||||
|
||||
<ThemePreview
|
||||
textColor={darkTextColor}
|
||||
backgroundColor={darkBackgroundColor}
|
||||
primaryColor={darkPrimaryColor}
|
||||
label={_('Preview')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { isCJKStr } from '@/utils/lang';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SearchOptions from './SearchOptions';
|
||||
|
||||
@@ -31,6 +32,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getConfig, saveConfig } = useBookDataStore();
|
||||
const { getView, getProgress } = useReaderStore();
|
||||
const [searchTerm, setSearchTerm] = useState(term);
|
||||
@@ -38,7 +40,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
|
||||
const view = getView(bookKey)!;
|
||||
const config = getConfig(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const primaryLang = bookData.book?.primaryLanguage || 'en';
|
||||
const searchConfig = config.searchConfig! as BookSearchConfig;
|
||||
|
||||
const queuedSearchTerm = useRef('');
|
||||
@@ -104,8 +108,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
};
|
||||
|
||||
const exceedMinSearchTermLength = (searchTerm: string) => {
|
||||
const isCJK = /[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/.test(searchTerm);
|
||||
const minLength = isCJK ? MINIMUM_SEARCH_TERM_LENGTH_CJK : MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
|
||||
const minLength = isCJKStr(searchTerm)
|
||||
? MINIMUM_SEARCH_TERM_LENGTH_CJK
|
||||
: MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
|
||||
|
||||
return searchTerm.length >= minLength;
|
||||
};
|
||||
@@ -118,12 +123,30 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const createAcceptNode = ({ withRT = true } = {}) => {
|
||||
return (node: Node): number => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const name = (node as Element).tagName.toLowerCase();
|
||||
if (name === 'script' || name === 'style' || (!withRT && name === 'rt')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
};
|
||||
};
|
||||
|
||||
const handleSearch = async (term: string) => {
|
||||
console.log('searching for:', term);
|
||||
isSearchPending.current = true;
|
||||
const { section } = progress;
|
||||
const index = searchConfig.scope === 'section' ? section.current : undefined;
|
||||
const generator = await view.search({ ...searchConfig, query: term, index });
|
||||
const generator = await view.search({
|
||||
...searchConfig,
|
||||
index,
|
||||
query: term,
|
||||
acceptNode: createAcceptNode({ withRT: !primaryLang.startsWith('ja') }),
|
||||
});
|
||||
const results: BookSearchResult[] = [];
|
||||
for await (const result of generator) {
|
||||
if (typeof result === 'string') {
|
||||
|
||||
@@ -32,7 +32,7 @@ const SearchResultItem: React.FC<SearchResultItemProps> = ({
|
||||
>
|
||||
<div className='line-clamp-3'>
|
||||
<span className=''>{excerpt.pre}</span>
|
||||
<span className='font-semibold'>{excerpt.match}</span>
|
||||
<span className='font-bold text-red-500'>{excerpt.match}</span>
|
||||
<span className=''>{excerpt.post}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -58,7 +58,7 @@ const TOCItemView: React.FC<{
|
||||
}, [expandedItems, item.href]);
|
||||
|
||||
return (
|
||||
<li className='w-full' style={{ paddingTop: '1px' }}>
|
||||
<li className='border-base-300 w-full border-b sm:border-none sm:pt-[1px]'>
|
||||
<span
|
||||
role='treeitem'
|
||||
tabIndex={-1}
|
||||
@@ -67,17 +67,26 @@ const TOCItemView: React.FC<{
|
||||
aria-expanded={isExpanded ? 'true' : 'false'}
|
||||
aria-selected={isActive ? 'true' : 'false'}
|
||||
data-href={item.href ? getContentMd5(item.href) : undefined}
|
||||
className={`flex w-full cursor-pointer items-center rounded-md py-2 ${
|
||||
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'sm:hover:bg-base-300/85'
|
||||
className={`flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2 ${
|
||||
isActive
|
||||
? 'sm:bg-base-300/85 sm:hover:bg-base-300 sm:text-base-content text-blue-500'
|
||||
: 'sm:hover:bg-base-300/85'
|
||||
}`}
|
||||
>
|
||||
{item.subitems && (
|
||||
<span onClick={handleToggleExpand} className='inline-block cursor-pointer'>
|
||||
<span
|
||||
onClick={handleToggleExpand}
|
||||
className='inline-block cursor-pointer'
|
||||
style={{
|
||||
padding: '12px',
|
||||
margin: '-12px',
|
||||
}}
|
||||
>
|
||||
{createExpanderIcon(isExpanded)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className='ml-2 truncate text-ellipsis'
|
||||
className='ms-2 truncate text-ellipsis'
|
||||
style={{
|
||||
maxWidth: 'calc(100% - 24px)',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -86,6 +95,11 @@ const TOCItemView: React.FC<{
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.location && (
|
||||
<span className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
|
||||
{item.location.current + 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.subitems && isExpanded && (
|
||||
<ol role='group'>
|
||||
@@ -161,7 +175,7 @@ const TOCView: React.FC<{
|
||||
|
||||
return (
|
||||
<div className='rounded pt-2'>
|
||||
<ul role='tree' ref={viewRef} className='px-2'>
|
||||
<ul role='tree' ref={viewRef} className='pe-4 ps-2 sm:pe-2'>
|
||||
{toc &&
|
||||
toc.map((item, index) => (
|
||||
<TOCItemView
|
||||
|
||||
@@ -153,7 +153,9 @@ const TTSControl = () => {
|
||||
};
|
||||
|
||||
const handleTTSStop = async () => {
|
||||
handleStop();
|
||||
if (ttsControllerRef.current) {
|
||||
handleStop();
|
||||
}
|
||||
};
|
||||
|
||||
const handleQueryIsSpeaking = () => {
|
||||
|
||||
@@ -232,7 +232,10 @@ const TTSPanel = ({
|
||||
<MdFastForward size={iconSize32} />
|
||||
</button>
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button className='flex flex-col items-center justify-center rounded-full p-1'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='flex flex-col items-center justify-center rounded-full p-1'
|
||||
>
|
||||
<MdAlarm size={iconSize32} />
|
||||
{timeoutCountdown && (
|
||||
<span
|
||||
|
||||
@@ -1,117 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { tauriGetWindowLogicalPosition } from '@/utils/window';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { ScrollSource } from './usePagination';
|
||||
|
||||
export const useClickEvent = (
|
||||
export const useMouseEvent = (
|
||||
bookKey: string,
|
||||
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
handlePageFlip: (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => void,
|
||||
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
|
||||
) => {
|
||||
const { appService } = useEnv();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const handleTurnPage = async (
|
||||
msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => {
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
const throttledScroll = throttle(handleContinuousScroll, 500, {
|
||||
emitLast: false,
|
||||
});
|
||||
const handleMouseEvent = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const { screenX } = msg.data;
|
||||
const viewRect = viewElement.getBoundingClientRect();
|
||||
let windowStartX;
|
||||
// Currently for tauri APP the window.screenX is always 0
|
||||
if (isTauriAppPlatform()) {
|
||||
if (appService?.isMobile) {
|
||||
windowStartX = 0;
|
||||
} else {
|
||||
const windowPosition = (await tauriGetWindowLogicalPosition()) as {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
windowStartX = windowPosition.x;
|
||||
}
|
||||
} else {
|
||||
windowStartX = window.screenX;
|
||||
}
|
||||
const viewStartX = windowStartX + viewRect.left;
|
||||
const viewCenterX = viewStartX + viewRect.width / 2;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click');
|
||||
if (!consumed) {
|
||||
const centerStartX = viewStartX + viewRect.width * 0.375;
|
||||
const centerEndX = viewStartX + viewRect.width * 0.625;
|
||||
if (
|
||||
viewSettings.disableClick! ||
|
||||
(screenX >= centerStartX && screenX <= centerEndX)
|
||||
) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? null : bookKey);
|
||||
} else {
|
||||
if (hoveredBookKey) {
|
||||
setHoveredBookKey(null);
|
||||
return;
|
||||
}
|
||||
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
|
||||
if (viewSettings.swapClickArea) {
|
||||
viewRef.current?.goLeft();
|
||||
} else {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
} else if (!viewSettings.disableClick! && screenX < viewCenterX) {
|
||||
if (viewSettings.swapClickArea) {
|
||||
viewRef.current?.goRight();
|
||||
} else {
|
||||
viewRef.current?.goLeft();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||
// The wheel event is handled by the iframe itself in scrolled mode.
|
||||
const { deltaY } = msg.data;
|
||||
if (deltaY > 0) {
|
||||
viewRef.current?.next(1);
|
||||
} else if (deltaY < 0) {
|
||||
viewRef.current?.prev(1);
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-mouseup') {
|
||||
if (msg.data.button === 3) {
|
||||
viewRef.current?.history.back();
|
||||
} else if (msg.data.button === 4) {
|
||||
viewRef.current?.history.forward();
|
||||
}
|
||||
if (msg.data.type === 'iframe-wheel') {
|
||||
throttledScroll('mouse', -msg.data.deltaY, 0);
|
||||
}
|
||||
handlePageFlip(msg);
|
||||
}
|
||||
} else if (msg.type === 'wheel') {
|
||||
const event = msg as React.WheelEvent<HTMLDivElement>;
|
||||
throttledScroll('mouse', -event.deltaY, 0);
|
||||
} else {
|
||||
const { clientX } = msg;
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
handlePageFlip(msg);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleTurnPage);
|
||||
window.addEventListener('message', handleMouseEvent);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleTurnPage);
|
||||
window.removeEventListener('message', handleMouseEvent);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
}, [bookKey, hoveredBookKey]);
|
||||
|
||||
return {
|
||||
handleTurnPage,
|
||||
onClick: handlePageFlip,
|
||||
onWheel: handleMouseEvent,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -128,28 +55,28 @@ interface IframeTouchEvent {
|
||||
|
||||
export const useTouchEvent = (
|
||||
bookKey: string,
|
||||
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
|
||||
) => {
|
||||
const { hoveredBookKey, setHoveredBookKey, getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
let touchStart: IframeTouch | null = null;
|
||||
let touchEnd: IframeTouch | null = null;
|
||||
|
||||
const onTouchStart = (e: IframeTouchEvent) => {
|
||||
const onTouchStart = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
|
||||
touchEnd = null;
|
||||
const touch = e.targetTouches[0];
|
||||
if (!touch) return;
|
||||
touchStart = touch;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: IframeTouchEvent) => {
|
||||
const onTouchMove = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!touchStart) return;
|
||||
const touch = e.targetTouches[0];
|
||||
if (touch) {
|
||||
touchEnd = touch;
|
||||
}
|
||||
if (hoveredBookKey && touchEnd) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const deltaY = touchEnd.screenY - touchStart.screenY;
|
||||
const deltaX = touchEnd.screenX - touchStart.screenX;
|
||||
if (!viewSettings!.scrolled && !viewSettings!.vertical) {
|
||||
@@ -162,7 +89,7 @@ export const useTouchEvent = (
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: IframeTouchEvent) => {
|
||||
const onTouchEnd = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!touchStart) return;
|
||||
|
||||
const touch = e.targetTouches[0];
|
||||
@@ -172,6 +99,7 @@ export const useTouchEvent = (
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
if (touchEnd) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const deltaY = touchEnd.screenY - touchStart.screenY;
|
||||
const deltaX = touchEnd.screenX - touchStart.screenX;
|
||||
// also check for deltaX to prevent swipe page turn from triggering the toggle
|
||||
@@ -189,6 +117,7 @@ export const useTouchEvent = (
|
||||
setHoveredBookKey(null);
|
||||
}
|
||||
}
|
||||
handleContinuousScroll('touch', deltaY, 30);
|
||||
}
|
||||
|
||||
touchStart = null;
|
||||
@@ -213,5 +142,11 @@ export const useTouchEvent = (
|
||||
window.removeEventListener('message', handleTouch);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
}, [hoveredBookKey]);
|
||||
|
||||
return {
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { tauriGetWindowLogicalPosition } from '@/utils/window';
|
||||
|
||||
export type ScrollSource = 'touch' | 'mouse';
|
||||
|
||||
export const usePagination = (
|
||||
bookKey: string,
|
||||
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const { appService } = useEnv();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
|
||||
|
||||
const handlePageFlip = async (
|
||||
msg: MessageEvent | CustomEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const { screenX } = msg.data;
|
||||
const viewRect = viewElement.getBoundingClientRect();
|
||||
let windowStartX;
|
||||
// Currently for tauri APP the window.screenX is always 0
|
||||
if (isTauriAppPlatform()) {
|
||||
if (appService?.isMobile) {
|
||||
windowStartX = 0;
|
||||
} else {
|
||||
const windowPosition = (await tauriGetWindowLogicalPosition()) as {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
windowStartX = windowPosition.x;
|
||||
}
|
||||
} else {
|
||||
windowStartX = window.screenX;
|
||||
}
|
||||
const viewStartX = windowStartX + viewRect.left;
|
||||
const viewCenterX = viewStartX + viewRect.width / 2;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click');
|
||||
if (!consumed) {
|
||||
const centerStartX = viewStartX + viewRect.width * 0.375;
|
||||
const centerEndX = viewStartX + viewRect.width * 0.625;
|
||||
if (
|
||||
viewSettings.disableClick! ||
|
||||
(screenX >= centerStartX && screenX <= centerEndX)
|
||||
) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? null : bookKey);
|
||||
} else {
|
||||
if (hoveredBookKey) {
|
||||
setHoveredBookKey(null);
|
||||
return;
|
||||
}
|
||||
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
|
||||
if (viewSettings.swapClickArea) {
|
||||
viewRef.current?.goLeft();
|
||||
} else {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
} else if (!viewSettings.disableClick! && screenX < viewCenterX) {
|
||||
if (viewSettings.swapClickArea) {
|
||||
viewRef.current?.goRight();
|
||||
} else {
|
||||
viewRef.current?.goLeft();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||
// The wheel event is handled by the iframe itself in scrolled mode.
|
||||
const { deltaY } = msg.data;
|
||||
if (deltaY > 0) {
|
||||
viewRef.current?.next(1);
|
||||
} else if (deltaY < 0) {
|
||||
viewRef.current?.prev(1);
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-mouseup') {
|
||||
if (msg.data.button === 3) {
|
||||
viewRef.current?.history.back();
|
||||
} else if (msg.data.button === 4) {
|
||||
viewRef.current?.history.forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg instanceof CustomEvent) {
|
||||
const { keyName } = msg.detail;
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (viewSettings?.volumeKeysToFlip) {
|
||||
setHoveredBookKey('');
|
||||
if (keyName === 'VolumeUp') {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (keyName === 'VolumeDown') {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (msg.type === 'click') {
|
||||
const { clientX } = msg;
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinuousScroll = (mode: ScrollSource, scrollDelta: number, threshold: number) => {
|
||||
const renderer = viewRef.current?.renderer;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (renderer && viewSettings.scrolled && viewSettings.continuousScroll) {
|
||||
const doScroll = () => {
|
||||
// may have overscroll where the start is greater than 0
|
||||
if (renderer.start <= scrollDelta && scrollDelta > threshold) {
|
||||
setTimeout(() => {
|
||||
viewRef.current?.prev(renderer.start + 1);
|
||||
}, 100);
|
||||
// sometimes viewSize has subpixel value that the end never reaches
|
||||
} else if (
|
||||
Math.ceil(renderer.end) - scrollDelta >= renderer.viewSize &&
|
||||
scrollDelta < -threshold
|
||||
) {
|
||||
setTimeout(() => {
|
||||
viewRef.current?.next(renderer.viewSize - Math.floor(renderer.end) + 1);
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
if (mode === 'mouse') {
|
||||
// we can always get mouse wheel events
|
||||
doScroll();
|
||||
} else if (mode === 'touch') {
|
||||
// when the document height is less than the viewport height, we can't get the relocate event
|
||||
if (renderer.size >= renderer.viewSize) {
|
||||
doScroll();
|
||||
} else {
|
||||
// scroll after the relocate event
|
||||
renderer.addEventListener('relocate', () => doScroll(), { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.isMobileApp) return;
|
||||
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (viewSettings?.volumeKeysToFlip) {
|
||||
acquireVolumeKeyInterception();
|
||||
} else {
|
||||
releaseVolumeKeyInterception();
|
||||
}
|
||||
|
||||
eventDispatcher.on('native-key-down', handlePageFlip);
|
||||
return () => {
|
||||
releaseVolumeKeyInterception();
|
||||
eventDispatcher.off('native-key-down', handlePageFlip);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handlePageFlip,
|
||||
handleContinuousScroll,
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IoArrowBack } from 'react-icons/io5';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
@@ -20,6 +19,7 @@ import { eventDispatcher } from '@/utils/event';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Quota from '@/components/Quota';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
const ProfilePage = () => {
|
||||
const _ = useTranslation();
|
||||
@@ -191,14 +191,12 @@ const ProfilePage = () => {
|
||||
<div className='mb-8 flex flex-col items-center gap-x-6 gap-y-4 md:flex-row md:items-start'>
|
||||
<div className='flex-shrink-0'>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={_('User avatar')}
|
||||
className='border-base-100 h-16 w-16 rounded-full border-4 md:h-24 md:w-24'
|
||||
referrerPolicy='no-referrer'
|
||||
width={128}
|
||||
height={128}
|
||||
priority
|
||||
<UserAvatar
|
||||
url={avatarUrl}
|
||||
size={128}
|
||||
DefaultIcon={PiUserCircle}
|
||||
className='h-16 w-16 md:h-24 md:w-24'
|
||||
borderClassName='border-base-100 border-4'
|
||||
/>
|
||||
) : (
|
||||
<PiUserCircle className='h-16 w-16 md:h-24 md:w-24' />
|
||||
|
||||
@@ -10,10 +10,11 @@ import Link from './Link';
|
||||
|
||||
export const setAboutDialogVisible = (visible: boolean) => {
|
||||
const dialog = document.getElementById('about_window');
|
||||
if (visible) {
|
||||
(dialog as HTMLDialogElement)?.showModal();
|
||||
} else {
|
||||
(dialog as HTMLDialogElement)?.close();
|
||||
if (dialog) {
|
||||
const event = new CustomEvent('setDialogVisibility', {
|
||||
detail: { visible },
|
||||
});
|
||||
dialog.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,16 +23,32 @@ export const AboutWindow = () => {
|
||||
const { appService } = useEnv();
|
||||
const [isUpdated, setIsUpdated] = useState(false);
|
||||
const [browserInfo, setBrowserInfo] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBrowserInfo(parseWebViewVersion(appService));
|
||||
|
||||
const handleCustomEvent = (event: CustomEvent) => {
|
||||
setIsOpen(event.detail.visible);
|
||||
};
|
||||
|
||||
const el = document.getElementById('about_window');
|
||||
if (el) {
|
||||
el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (el) {
|
||||
el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
const update = await checkForAppUpdates(_, false);
|
||||
if (update) {
|
||||
setAboutDialogVisible(false);
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
setIsUpdated(true);
|
||||
}
|
||||
@@ -40,9 +57,9 @@ export const AboutWindow = () => {
|
||||
return (
|
||||
<Dialog
|
||||
id='about_window'
|
||||
isOpen={false}
|
||||
isOpen={isOpen}
|
||||
title={_('About Readest')}
|
||||
onClose={() => setAboutDialogVisible(false)}
|
||||
onClose={() => setIsOpen(false)}
|
||||
boxClassName='sm:!w-96 sm:h-auto'
|
||||
>
|
||||
<div className='about-content flex h-full flex-col items-center justify-center'>
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ interface BookCoverProps {
|
||||
|
||||
const BookCover: React.FC<BookCoverProps> = ({ mode = 'grid', book, isPreview }) => {
|
||||
return (
|
||||
<div className='relative flex h-full w-full'>
|
||||
<div className={clsx('relative flex h-full w-full')}>
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
@@ -1,6 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { MdDelete, MdCloudDownload, MdCloudUpload } from 'react-icons/md';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
@@ -17,8 +16,9 @@ import {
|
||||
formatTitle,
|
||||
} from '@/utils/book';
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from './Spinner';
|
||||
import Dialog from './Dialog';
|
||||
import Spinner from './Spinner';
|
||||
import BookCover from './BookCover';
|
||||
|
||||
interface BookDetailModalProps {
|
||||
book: Book;
|
||||
@@ -113,29 +113,9 @@ const BookDetailModal = ({
|
||||
<div className='flex w-full select-text items-center justify-center'>
|
||||
<div className='relative w-full rounded-lg'>
|
||||
<div className='mb-6 me-4 flex h-32 items-start'>
|
||||
<div className='book-cover relative mr-10 aspect-[28/41] h-32 items-end shadow-lg'>
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={formatTitle(book.title)}
|
||||
fill={true}
|
||||
className='w-10 object-cover'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove(
|
||||
'invisible',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'invisible absolute inset-0 flex items-center justify-center p-1',
|
||||
'text-neutral-content rounded-none text-center font-serif text-base font-medium',
|
||||
)}
|
||||
>
|
||||
{formatTitle(book.title)}
|
||||
</div>
|
||||
<div className='me-10 aspect-[28/41] h-32 shadow-lg'>
|
||||
<BookCover mode='list' book={book} />
|
||||
</div>
|
||||
|
||||
<div className='title-author flex h-32 flex-col justify-between'>
|
||||
<div>
|
||||
<p className='text-base-content mb-2 line-clamp-2 break-all text-lg font-bold'>
|
||||
|
||||
@@ -3,9 +3,12 @@ import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useDrag } from '@/hooks/useDrag';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
const SNAP_THRESHOLD = 0.2;
|
||||
@@ -38,24 +41,41 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
|
||||
const [isFullHeightInMobile, setIsFullHeightInMobile] = React.useState(!snapHeight);
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
const handleKeyDown = (event: KeyboardEvent | CustomEvent) => {
|
||||
if (event instanceof CustomEvent) {
|
||||
if (event.detail.keyName === 'Back') {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
if (appService?.isAndroidApp) {
|
||||
acquireBackKeyInterception();
|
||||
eventDispatcher.onSync('native-key-down', handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (appService?.isAndroidApp) {
|
||||
releaseBackKeyInterception();
|
||||
eventDispatcher.offSync('native-key-down', handleKeyDown);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDragMove = (data: { clientY: number; deltaY: number }) => {
|
||||
if (!isMobile) return;
|
||||
@@ -87,41 +107,39 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
data.velocity > VELOCITY_THRESHOLD ||
|
||||
(data.velocity >= 0 && data.clientY >= window.innerHeight * snapLower)
|
||||
) {
|
||||
// dialog is dismissed
|
||||
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
|
||||
modal.style.height = '100%';
|
||||
modal.style.transition = `transform ${transitionDuration}s ease-out`;
|
||||
modal.style.transform = 'translateY(100%)';
|
||||
overlay.style.transition = `opacity ${transitionDuration}s ease-out`;
|
||||
overlay.style.opacity = '0';
|
||||
onClose();
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
modal.style.transform = 'translateY(0%)';
|
||||
}, 300);
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
} else if (
|
||||
snapHeight &&
|
||||
data.clientY > window.innerHeight * snapUpper &&
|
||||
data.clientY < window.innerHeight * snapLower
|
||||
) {
|
||||
// dialog is snapped
|
||||
overlay.style.transition = `opacity 0.3s ease-out`;
|
||||
overlay.style.opacity = `${1 - snapHeight}`;
|
||||
modal.style.height = `${snapHeight * 100}%`;
|
||||
modal.style.bottom = '0';
|
||||
modal.style.transition = `transform 0.3s ease-out`;
|
||||
modal.style.transform = `translateY(${(1 - snapHeight) * window.innerHeight}px)`;
|
||||
setTimeout(() => {
|
||||
modal.style.height = `${snapHeight * 100}%`;
|
||||
}, 100);
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
modal.style.transform = '';
|
||||
} else {
|
||||
// dialog is opened without snap
|
||||
setIsFullHeightInMobile(true);
|
||||
modal.style.height = '100%';
|
||||
modal.style.transition = `transform 0.3s ease-out`;
|
||||
modal.style.transform = `translateY(0%)`;
|
||||
overlay.style.opacity = '0';
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
}
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,7 +161,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
|
||||
'modal-box settings-content absolute z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
|
||||
'h-full max-h-full w-full max-w-full',
|
||||
window.innerWidth < window.innerHeight
|
||||
? 'sm:h-[50%] sm:w-3/4'
|
||||
@@ -157,14 +175,23 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
snapHeight
|
||||
? {
|
||||
height: `${snapHeight * 100}%`,
|
||||
transform: `translateY(${(1 - snapHeight) * window.innerHeight}px)`,
|
||||
bottom: 0,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{window.innerWidth < 640 && (
|
||||
<div
|
||||
className='drag-handle flex h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center'
|
||||
className={clsx(
|
||||
'drag-handle flex h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center',
|
||||
'transition-padding-top duration-300 ease-out',
|
||||
)}
|
||||
style={{
|
||||
paddingTop:
|
||||
appService?.isAndroidApp && systemUIVisible && isFullHeightInMobile
|
||||
? statusBarHeight
|
||||
: 0,
|
||||
}}
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
>
|
||||
|
||||
@@ -10,10 +10,10 @@ import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { tauriDownload } from '@/utils/transfer';
|
||||
import { installPackage } from '@/utils/bridge';
|
||||
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
|
||||
import packageJson from '../../package.json';
|
||||
import Dialog from '@/components/Dialog';
|
||||
import { installPackage } from '@/utils/bridge';
|
||||
|
||||
interface ReleaseNotes {
|
||||
releases: Record<
|
||||
@@ -333,36 +333,47 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
|
||||
};
|
||||
|
||||
export const setUpdaterWindowVisible = (visible: boolean, newVersion?: string) => {
|
||||
if (newVersion) {
|
||||
localStorage.setItem('newVersion', newVersion);
|
||||
window.dispatchEvent(new CustomEvent('new-version-detected'));
|
||||
}
|
||||
const dialog = document.getElementById('updater_window');
|
||||
if (visible) {
|
||||
(dialog as HTMLDialogElement)?.showModal();
|
||||
} else {
|
||||
(dialog as HTMLDialogElement)?.close();
|
||||
if (dialog) {
|
||||
const event = new CustomEvent('setDialogVisibility', {
|
||||
detail: { visible, newVersion },
|
||||
});
|
||||
dialog.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
export const UpdaterWindow = () => {
|
||||
const _ = useTranslation();
|
||||
const [newVersion, setNewVersion] = useState(localStorage.getItem('newVersion') || '');
|
||||
const [newVersion, setNewVersion] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setNewVersion(localStorage.getItem('newVersion') || '');
|
||||
const handleCustomEvent = (event: CustomEvent) => {
|
||||
const { visible, newVersion } = event.detail;
|
||||
setIsOpen(visible);
|
||||
if (newVersion) {
|
||||
setNewVersion(newVersion);
|
||||
}
|
||||
};
|
||||
|
||||
const el = document.getElementById('updater_window');
|
||||
if (el) {
|
||||
el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (el) {
|
||||
el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener);
|
||||
}
|
||||
};
|
||||
window.addEventListener('new-version-detected', handler);
|
||||
return () => window.removeEventListener('new-version-detected', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id='updater_window'
|
||||
isOpen={false}
|
||||
isOpen={isOpen}
|
||||
title={_('Software Update')}
|
||||
onClose={() => setUpdaterWindowVisible(false)}
|
||||
onClose={() => setIsOpen(false)}
|
||||
boxClassName='sm:!w-[80%] sm:h-auto'
|
||||
>
|
||||
<UpdaterContent version={newVersion ?? undefined} />
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import { IconType } from 'react-icons';
|
||||
|
||||
interface UserAvatarProps {
|
||||
url: string;
|
||||
size: number;
|
||||
DefaultIcon: IconType;
|
||||
className?: string;
|
||||
borderClassName?: string;
|
||||
}
|
||||
|
||||
const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
url,
|
||||
size,
|
||||
className,
|
||||
borderClassName,
|
||||
DefaultIcon,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className='relative flex h-full w-full items-center justify-center rounded-full'
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{url ? (
|
||||
<div>
|
||||
<Image
|
||||
src={url}
|
||||
alt='User Avatar'
|
||||
className={clsx('rounded-full', className, borderClassName)}
|
||||
referrerPolicy='no-referrer'
|
||||
width={size}
|
||||
height={size}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
|
||||
}}
|
||||
/>
|
||||
<div className='invisible absolute inset-0 flex items-center justify-center'>
|
||||
<DefaultIcon className={clsx('text-neutral-content', className)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DefaultIcon className='text-neutral-content' size={size} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAvatar;
|
||||
@@ -18,6 +18,14 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
React.useEffect(() => {
|
||||
envConfig.getAppService().then((service) => setAppService(service));
|
||||
window.addEventListener('error', (e) => {
|
||||
if (e.message === 'ResizeObserver loop limit exceeded') {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [envConfig]);
|
||||
|
||||
return <EnvContext.Provider value={{ envConfig, appService }}>{children}</EnvContext.Provider>;
|
||||
|
||||
@@ -20,7 +20,6 @@ export const useDrag = (
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
|
||||
if ('touches' in e) {
|
||||
|
||||
@@ -16,11 +16,23 @@ export const useTheme = ({
|
||||
}: UseThemeProps = {}) => {
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { themeColor, isDarkMode, updateAppTheme, setStatusBarHeight } = useThemeStore();
|
||||
const {
|
||||
themeColor,
|
||||
isDarkMode,
|
||||
showSystemUI,
|
||||
dismissSystemUI,
|
||||
updateAppTheme,
|
||||
setStatusBarHeight,
|
||||
} = useThemeStore();
|
||||
|
||||
useEffect(() => {
|
||||
updateAppTheme(appThemeColor);
|
||||
if (appService?.isMobile) {
|
||||
if (systemUIVisible) {
|
||||
showSystemUI();
|
||||
} else {
|
||||
dismissSystemUI();
|
||||
}
|
||||
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
|
||||
}
|
||||
if (appService?.isAndroidApp) {
|
||||
|
||||
@@ -36,11 +36,18 @@ export const CFI = epubcfi;
|
||||
|
||||
export type DocumentFile = File;
|
||||
|
||||
export type Location = {
|
||||
current: number;
|
||||
next: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export interface TOCItem {
|
||||
id: number;
|
||||
label: string;
|
||||
href: string;
|
||||
cfi?: string;
|
||||
location?: Location;
|
||||
subitems?: TOCItem[];
|
||||
}
|
||||
|
||||
@@ -48,6 +55,8 @@ export interface SectionItem {
|
||||
id: string;
|
||||
cfi: string;
|
||||
size: number;
|
||||
linear: string;
|
||||
location?: Location;
|
||||
}
|
||||
|
||||
export interface BookDoc {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
DEFAULT_SYSTEM_SETTINGS,
|
||||
DEFAULT_CJK_VIEW_SETTINGS,
|
||||
DEFAULT_MOBILE_READSETTINGS,
|
||||
DEFAULT_SCREEN_CONFIG,
|
||||
} from './constants';
|
||||
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
@@ -48,9 +49,11 @@ export abstract class BaseAppService implements AppService {
|
||||
localBooksDir = '';
|
||||
isMobile = false;
|
||||
isMacOSApp = false;
|
||||
isLinuxApp = false;
|
||||
isAppDataSandbox = false;
|
||||
isAndroidApp = false;
|
||||
isIOSApp = false;
|
||||
isMobileApp = false;
|
||||
hasTrafficLight = false;
|
||||
hasWindow = false;
|
||||
hasWindowBar = false;
|
||||
@@ -58,7 +61,6 @@ export abstract class BaseAppService implements AppService {
|
||||
hasRoundedWindow = false;
|
||||
hasSafeAreaInset = false;
|
||||
hasHaptics = false;
|
||||
hasSysFontsList = false;
|
||||
hasUpdater = false;
|
||||
|
||||
abstract fs: FileSystem;
|
||||
@@ -94,6 +96,7 @@ export abstract class BaseAppService implements AppService {
|
||||
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...DEFAULT_SCREEN_CONFIG,
|
||||
...settings.globalViewSettings,
|
||||
};
|
||||
} catch {
|
||||
@@ -113,6 +116,7 @@ export abstract class BaseAppService implements AppService {
|
||||
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...DEFAULT_SCREEN_CONFIG,
|
||||
},
|
||||
} as SystemSettings;
|
||||
|
||||
@@ -410,6 +414,20 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
}
|
||||
|
||||
async isBookAvailable(book: Book): Promise<boolean> {
|
||||
const fp = getLocalBookFilename(book);
|
||||
if (await this.fs.exists(fp, 'Books')) {
|
||||
return true;
|
||||
}
|
||||
if (book.filePath) {
|
||||
return await this.fs.exists(book.filePath, 'None');
|
||||
}
|
||||
if (book.url) {
|
||||
return isValidURL(book.url);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
|
||||
let file: File;
|
||||
const fp = getLocalBookFilename(book);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BookSearchConfig,
|
||||
BookStyle,
|
||||
HighlightColor,
|
||||
ScreenConfig,
|
||||
TTSConfig,
|
||||
ViewConfig,
|
||||
ViewSettings,
|
||||
@@ -37,6 +38,8 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
alwaysOnTop: false,
|
||||
autoCheckUpdates: true,
|
||||
screenWakeLock: true,
|
||||
openLastBooks: false,
|
||||
lastOpenBooks: [],
|
||||
autoImportBooksOnOpen: false,
|
||||
libraryViewMode: 'grid',
|
||||
librarySortBy: 'updated',
|
||||
@@ -83,9 +86,12 @@ export const DEFAULT_BOOK_FONT: BookFont = {
|
||||
export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
marginPx: 44,
|
||||
gapPercent: 5,
|
||||
compactMarginPx: 0,
|
||||
compactGapPercent: 0,
|
||||
scrolled: false,
|
||||
disableClick: false,
|
||||
swapClickArea: false,
|
||||
volumeKeysToFlip: false,
|
||||
continuousScroll: false,
|
||||
maxColumnCount: 2,
|
||||
maxInlineSize: getDefaultMaxInlineSize(),
|
||||
@@ -138,6 +144,10 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = {
|
||||
ttsVoice: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
|
||||
screenOrientation: 'auto',
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
|
||||
scope: 'book',
|
||||
matchCase: false,
|
||||
@@ -156,6 +166,8 @@ export const SERIF_FONTS = [
|
||||
'Times New Roman',
|
||||
];
|
||||
|
||||
export const NON_FREE_FONTS = ['Georgia', 'Times New Roman'];
|
||||
|
||||
export const CJK_SERIF_FONTS = [
|
||||
_('LXGW WenKai GB Screen'),
|
||||
_('LXGW WenKai TC'),
|
||||
@@ -434,6 +446,10 @@ export const ANDROID_FONTS = [
|
||||
];
|
||||
|
||||
export const CJK_NAMES_PATTENS = /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/;
|
||||
export const CJK_EXCLUDE_PATTENS = new RegExp(
|
||||
['AlBayan', 'STIX', 'Kailasa', 'ITCTT', 'Luminari', 'Myanmar'].join('|'),
|
||||
'i',
|
||||
);
|
||||
export const CJK_FONTS_PATTENS = new RegExp(
|
||||
[
|
||||
'CJK',
|
||||
@@ -461,7 +477,6 @@ export const CJK_FONTS_PATTENS = new RegExp(
|
||||
'Yu\\s?Gothic',
|
||||
'Yu\\s?Mincho',
|
||||
'Mincho',
|
||||
'Gothic',
|
||||
'Nanum',
|
||||
'Malgun',
|
||||
'Gulim',
|
||||
|
||||
@@ -213,6 +213,8 @@ export class NativeAppService extends BaseAppService {
|
||||
override isAndroidApp = OS_TYPE === 'android';
|
||||
override isIOSApp = OS_TYPE === 'ios';
|
||||
override isMacOSApp = OS_TYPE === 'macos';
|
||||
override isLinuxApp = OS_TYPE === 'linux';
|
||||
override isMobileApp = ['android', 'ios'].includes(OS_TYPE);
|
||||
override hasTrafficLight = OS_TYPE === 'macos';
|
||||
override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
|
||||
override hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
|
||||
@@ -220,7 +222,6 @@ export class NativeAppService extends BaseAppService {
|
||||
override hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android') && !!window.IS_ROUNDED;
|
||||
override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
|
||||
override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
|
||||
override hasSysFontsList = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
|
||||
override hasUpdater = OS_TYPE !== 'ios' && !process.env['NEXT_PUBLIC_DISABLE_UPDATER'];
|
||||
|
||||
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
|
||||
|
||||
@@ -245,7 +245,28 @@ export class EdgeTTSClient implements TTSClient {
|
||||
}
|
||||
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
|
||||
const voices = await this.getAllVoices();
|
||||
return voices.filter((v) => v.lang.startsWith(locale));
|
||||
return voices
|
||||
.filter(
|
||||
(v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aRegion = a.lang.split('-')[1] || '';
|
||||
const bRegion = b.lang.split('-')[1] || '';
|
||||
if (aRegion === bRegion) {
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
||||
}
|
||||
if (aRegion === 'CN') return -1;
|
||||
if (bRegion === 'CN') return 1;
|
||||
if (aRegion === 'TW') return -1;
|
||||
if (bRegion === 'TW') return 1;
|
||||
if (aRegion === 'HK') return -1;
|
||||
if (bRegion === 'HK') return 1;
|
||||
if (aRegion === 'US') return -1;
|
||||
if (bRegion === 'US') return 1;
|
||||
if (aRegion === 'GB') return -1;
|
||||
if (bRegion === 'GB') return 1;
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
getGranularities(): TTSGranularity[] {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { TTSGranularity } from '@/types/view';
|
||||
import { TTSUtils } from './TTSUtils';
|
||||
|
||||
@@ -123,7 +124,7 @@ async function* speakWithMarks(
|
||||
const isCJK = (lang: string | null) => {
|
||||
const cjkLangs = ['zh', 'ja', 'kr'];
|
||||
if (lang && cjkLangs.some((cjk) => lang.startsWith(cjk))) return true;
|
||||
return /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(plainText);
|
||||
return isCJKStr(plainText);
|
||||
};
|
||||
|
||||
if (!isCJK(lang)) {
|
||||
@@ -311,7 +312,11 @@ export class WebSpeechClient implements TTSClient {
|
||||
return BLACKLISTED_VOICES.some((name) => voice.name.includes(name)) === false;
|
||||
};
|
||||
const filteredVoices = this.#voices
|
||||
.filter((voice) => voice.lang.startsWith(locale))
|
||||
.filter(
|
||||
(voice) =>
|
||||
voice.lang.startsWith(locale) ||
|
||||
(lang === 'en' && ['en-US', 'en-GB'].includes(voice.lang)),
|
||||
)
|
||||
.filter((voice) => isValidVoice(voice.voiceURI || ''))
|
||||
.filter(isNotBlacklisted);
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { create } from 'zustand';
|
||||
import { interceptKeys } from '@/utils/bridge';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
onNativeKeyDown?: (keyName: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const handleNativeKeyDown = (keyName: string) => {
|
||||
if (keyName === 'VolumeUp' || keyName === 'VolumeDown') {
|
||||
return eventDispatcher.dispatch('native-key-down', { keyName });
|
||||
}
|
||||
if (keyName === 'Back') {
|
||||
return eventDispatcher.dispatchSync('native-key-down', { keyName });
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
type DeviceControlState = {
|
||||
volumeKeysIntercepted: boolean;
|
||||
backKeyIntercepted: boolean;
|
||||
volumeKeysInterceptionCount: number;
|
||||
backKeyInterceptionCount: number;
|
||||
acquireVolumeKeyInterception: () => void;
|
||||
releaseVolumeKeyInterception: () => void;
|
||||
acquireBackKeyInterception: () => void;
|
||||
releaseBackKeyInterception: () => void;
|
||||
};
|
||||
|
||||
export const useDeviceControlStore = create<DeviceControlState>((set, get) => ({
|
||||
volumeKeysIntercepted: false,
|
||||
backKeyIntercepted: false,
|
||||
volumeKeysInterceptionCount: 0,
|
||||
backKeyInterceptionCount: 0,
|
||||
|
||||
acquireVolumeKeyInterception: () => {
|
||||
const { volumeKeysInterceptionCount } = get();
|
||||
if (volumeKeysInterceptionCount == 0) {
|
||||
window.onNativeKeyDown = handleNativeKeyDown;
|
||||
interceptKeys({ volumeKeys: true });
|
||||
set({ volumeKeysIntercepted: true });
|
||||
}
|
||||
set({ volumeKeysInterceptionCount: volumeKeysInterceptionCount + 1 });
|
||||
},
|
||||
|
||||
releaseVolumeKeyInterception: () => {
|
||||
const { volumeKeysInterceptionCount } = get();
|
||||
if (volumeKeysInterceptionCount <= 1) {
|
||||
interceptKeys({ volumeKeys: false });
|
||||
set({ volumeKeysIntercepted: false, volumeKeysInterceptionCount: 0 });
|
||||
} else {
|
||||
set({ volumeKeysInterceptionCount: volumeKeysInterceptionCount - 1 });
|
||||
}
|
||||
},
|
||||
|
||||
acquireBackKeyInterception: () => {
|
||||
const { backKeyInterceptionCount } = get();
|
||||
if (backKeyInterceptionCount == 0) {
|
||||
window.onNativeKeyDown = handleNativeKeyDown;
|
||||
interceptKeys({ backKey: true });
|
||||
set({ backKeyIntercepted: true });
|
||||
}
|
||||
set({ backKeyInterceptionCount: backKeyInterceptionCount + 1 });
|
||||
},
|
||||
|
||||
releaseBackKeyInterception: () => {
|
||||
const { backKeyInterceptionCount } = get();
|
||||
if (backKeyInterceptionCount <= 1) {
|
||||
interceptKeys({ backKey: false });
|
||||
set({ backKeyIntercepted: false, backKeyInterceptionCount: 0 });
|
||||
} else {
|
||||
set({ backKeyInterceptionCount: backKeyInterceptionCount - 1 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,21 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import { Book } from '@/types/book';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { EnvConfigType, isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
interface LibraryState {
|
||||
library: Book[]; // might contain deleted books
|
||||
checkOpenWithBooks: boolean;
|
||||
checkLastOpenBooks: boolean;
|
||||
getVisibleLibrary: () => Book[];
|
||||
setCheckOpenWithBooks: (check: boolean) => void;
|
||||
setCheckLastOpenBooks: (check: boolean) => void;
|
||||
setLibrary: (books: Book[]) => void;
|
||||
updateBook: (envConfig: EnvConfigType, book: Book) => void;
|
||||
}
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
library: [],
|
||||
checkOpenWithBooks: true,
|
||||
checkOpenWithBooks: isTauriAppPlatform(),
|
||||
checkLastOpenBooks: isTauriAppPlatform(),
|
||||
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
|
||||
setCheckOpenWithBooks: (check) => set({ checkOpenWithBooks: check }),
|
||||
setCheckLastOpenBooks: (check) => set({ checkLastOpenBooks: check }),
|
||||
setLibrary: (books) => set({ library: books }),
|
||||
updateBook: async (envConfig: EnvConfigType, book: Book) => {
|
||||
const appService = await envConfig.getAppService();
|
||||
|
||||
@@ -3,8 +3,8 @@ import { create } from 'zustand';
|
||||
import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { BookDoc, DocumentLoader, SectionItem, TOCItem } from '@/libs/document';
|
||||
import { updateTocCFI, updateTocID } from '@/utils/toc';
|
||||
import { BookDoc, DocumentLoader, TOCItem } from '@/libs/document';
|
||||
import { updateToc } from '@/utils/toc';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useBookDataStore } from './bookDataStore';
|
||||
import { useLibraryStore } from './libraryStore';
|
||||
@@ -122,12 +122,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
|
||||
const bookDoc = loadedBookDoc as BookDoc;
|
||||
if (bookDoc.toc?.length && bookDoc.sections?.length) {
|
||||
updateTocID(bookDoc.toc);
|
||||
const sections = bookDoc.sections.reduce((map: Record<string, SectionItem>, section) => {
|
||||
map[section.id] = section;
|
||||
return map;
|
||||
}, {});
|
||||
updateTocCFI(bookDoc, bookDoc.toc, sections);
|
||||
updateToc(bookDoc, bookDoc.toc, bookDoc.sections);
|
||||
}
|
||||
// Set the book's language for formerly imported books, newly imported books have this field set
|
||||
book.primaryLanguage =
|
||||
|
||||
@@ -68,9 +68,12 @@ export type WritingMode = 'auto' | 'horizontal-tb' | 'horizontal-rl' | 'vertical
|
||||
export interface BookLayout {
|
||||
marginPx: number;
|
||||
gapPercent: number;
|
||||
compactMarginPx: number;
|
||||
compactGapPercent: number;
|
||||
scrolled: boolean;
|
||||
disableClick: boolean;
|
||||
swapClickArea: boolean;
|
||||
volumeKeysToFlip: boolean;
|
||||
continuousScroll: boolean;
|
||||
maxColumnCount: number;
|
||||
maxInlineSize: number;
|
||||
@@ -123,7 +126,17 @@ export interface TTSConfig {
|
||||
ttsVoice: string;
|
||||
}
|
||||
|
||||
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig, TTSConfig {}
|
||||
export interface ScreenConfig {
|
||||
screenOrientation: 'auto' | 'portrait' | 'landscape';
|
||||
}
|
||||
|
||||
export interface ViewSettings
|
||||
extends BookLayout,
|
||||
BookStyle,
|
||||
BookFont,
|
||||
ViewConfig,
|
||||
TTSConfig,
|
||||
ScreenConfig {}
|
||||
|
||||
export interface BookProgress {
|
||||
location: string;
|
||||
@@ -142,6 +155,7 @@ export interface BookSearchConfig {
|
||||
matchDiacritics: boolean;
|
||||
index?: number;
|
||||
query?: string;
|
||||
acceptNode?: (node: Node) => number;
|
||||
}
|
||||
|
||||
export interface SearchExcerpt {
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface SystemSettings {
|
||||
alwaysOnTop: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
screenWakeLock: boolean;
|
||||
openLastBooks: boolean;
|
||||
lastOpenBooks: string[];
|
||||
autoImportBooksOnOpen: boolean;
|
||||
libraryViewMode: LibraryViewModeType;
|
||||
librarySortBy: LibrarySortByType;
|
||||
|
||||
@@ -33,13 +33,14 @@ export interface AppService {
|
||||
hasRoundedWindow: boolean;
|
||||
hasSafeAreaInset: boolean;
|
||||
hasHaptics: boolean;
|
||||
hasSysFontsList: boolean;
|
||||
hasUpdater: boolean;
|
||||
isMobile: boolean;
|
||||
isAppDataSandbox: boolean;
|
||||
isMobileApp: boolean;
|
||||
isAndroidApp: boolean;
|
||||
isIOSApp: boolean;
|
||||
isMacOSApp: boolean;
|
||||
isLinuxApp: boolean;
|
||||
|
||||
selectDirectory(): Promise<string>;
|
||||
selectFiles(name: string, extensions: string[]): Promise<string[]>;
|
||||
@@ -56,6 +57,7 @@ export interface AppService {
|
||||
deleteBook(book: Book, includingUploaded?: boolean): Promise<void>;
|
||||
uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void>;
|
||||
downloadBook(book: Book, onlyCover?: boolean, onProgress?: ProgressHandler): Promise<void>;
|
||||
isBookAvailable(book: Book): Promise<boolean>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookDoc['metadata']>;
|
||||
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface FoliateView extends HTMLElement {
|
||||
viewSize: number; // whole document view height
|
||||
start: number;
|
||||
end: number;
|
||||
containerPosition: number;
|
||||
setAttribute: (name: string, value: string | number) => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
next: () => Promise<void>;
|
||||
@@ -47,7 +48,11 @@ export interface FoliateView extends HTMLElement {
|
||||
goTo?: (params: { index: number; anchor: number }) => void;
|
||||
setStyles?: (css: string) => void;
|
||||
getContents: () => { doc: Document; index?: number }[];
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
addEventListener: (
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
option?: AddEventListenerOptions,
|
||||
) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,20 @@ export interface GetStatusBarHeightResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetSystemFontsListResponse {
|
||||
fonts: Record<string, string>; // { fontName: fontFamily }
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InterceptKeysRequest {
|
||||
volumeKeys?: boolean;
|
||||
backKey?: boolean;
|
||||
}
|
||||
|
||||
export interface LockScreenRequest {
|
||||
orientation: 'portrait' | 'landscape' | 'auto';
|
||||
}
|
||||
|
||||
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
|
||||
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
|
||||
payload: request,
|
||||
@@ -79,3 +93,28 @@ export async function getStatusBarHeight(): Promise<GetStatusBarHeightResponse>
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
let cachedSysFontsResult: GetSystemFontsListResponse | null = null;
|
||||
|
||||
export async function getSysFontsList(): Promise<GetSystemFontsListResponse> {
|
||||
if (cachedSysFontsResult) {
|
||||
return cachedSysFontsResult;
|
||||
}
|
||||
const result = await invoke<GetSystemFontsListResponse>(
|
||||
'plugin:native-bridge|get_sys_fonts_list',
|
||||
);
|
||||
cachedSysFontsResult = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function interceptKeys(request: InterceptKeysRequest): Promise<void> {
|
||||
await invoke('plugin:native-bridge|intercept_keys', {
|
||||
payload: request,
|
||||
});
|
||||
}
|
||||
|
||||
export async function lockScreenOrientation(request: LockScreenRequest): Promise<void> {
|
||||
await invoke('plugin:native-bridge|lock_screen_orientation', {
|
||||
payload: request,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class EventDispatcher {
|
||||
private syncListeners: Map<string, Set<(event: CustomEvent) => boolean>>;
|
||||
private syncListeners: Map<string, Array<(event: CustomEvent) => boolean>>;
|
||||
private asyncListeners: Map<string, Set<(event: CustomEvent) => Promise<void> | void>>;
|
||||
|
||||
constructor() {
|
||||
@@ -30,20 +30,26 @@ class EventDispatcher {
|
||||
|
||||
onSync(event: string, callback: (event: CustomEvent) => boolean): void {
|
||||
if (!this.syncListeners.has(event)) {
|
||||
this.syncListeners.set(event, new Set());
|
||||
this.syncListeners.set(event, []);
|
||||
}
|
||||
this.syncListeners.get(event)!.add(callback);
|
||||
this.syncListeners.get(event)!.push(callback);
|
||||
}
|
||||
|
||||
offSync(event: string, callback: (event: CustomEvent) => boolean): void {
|
||||
this.syncListeners.get(event)?.delete(callback);
|
||||
const listeners = this.syncListeners.get(event);
|
||||
if (listeners) {
|
||||
this.syncListeners.set(
|
||||
event,
|
||||
listeners.filter((listener) => listener !== callback),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dispatchSync(event: string, detail?: unknown): boolean {
|
||||
const listeners = this.syncListeners.get(event);
|
||||
if (listeners) {
|
||||
const customEvent = new CustomEvent(event, { detail });
|
||||
for (const listener of listeners) {
|
||||
for (const listener of [...listeners].reverse()) {
|
||||
const consumed = listener(customEvent);
|
||||
if (consumed) {
|
||||
return true;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getOSPlatform } from './misc';
|
||||
|
||||
let cachedSysFonts: string[] | null = null;
|
||||
|
||||
export const FONT_ENUM_SUPPORTED_OS_PLATFORMS = ['macos', 'windows', 'linux'];
|
||||
|
||||
const isSymbolicFontName = (font: string) =>
|
||||
/emoji|icons|symbol|dingbats|ornaments|webdings|wingdings/i.test(font);
|
||||
|
||||
export const getSysFontsList = async (): Promise<string[]> => {
|
||||
if (cachedSysFonts) {
|
||||
return cachedSysFonts;
|
||||
}
|
||||
|
||||
try {
|
||||
const osPlatform = getOSPlatform();
|
||||
if (FONT_ENUM_SUPPORTED_OS_PLATFORMS.includes(osPlatform)) {
|
||||
const fonts = await invoke<string[]>('list_fonts');
|
||||
cachedSysFonts = [...new Set(fonts.filter((font) => !isSymbolicFontName(font)))].sort();
|
||||
return cachedSysFonts;
|
||||
} else {
|
||||
console.warn(`Unsupported platform: ${osPlatform}`);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching font list:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const isCJKStr = (str: string) => {
|
||||
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(str ?? '');
|
||||
};
|
||||
@@ -159,3 +159,26 @@ export const getPopupPosition = (
|
||||
|
||||
return { point: popupPoint, dir: position.dir } as Position;
|
||||
};
|
||||
|
||||
export const getTextFromRange = (range: Range, rejects: string[] = []): string => {
|
||||
const clonedRange = range.cloneRange();
|
||||
const fragment = clonedRange.cloneContents();
|
||||
const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const parent = node.parentElement;
|
||||
if (rejects.includes(parent?.tagName.toLowerCase() || '')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
let text = '';
|
||||
let node: Text | null;
|
||||
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
text += node.nodeValue ?? '';
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
@@ -57,14 +57,24 @@ export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
|
||||
};
|
||||
|
||||
export const parseSSMLLang = (ssml: string): string | null => {
|
||||
let lang = null;
|
||||
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
|
||||
if (/[\p{Script=Han}]/u.test(ssml)) {
|
||||
return 'zh';
|
||||
} else if (match && match[1]) {
|
||||
if (match && match[1]) {
|
||||
const parts = match[1].split('-');
|
||||
return parts.length > 1
|
||||
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
|
||||
: parts[0]!.toLowerCase();
|
||||
lang =
|
||||
parts.length > 1
|
||||
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
|
||||
: parts[0]!.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -87,13 +87,13 @@ const getFontStyles = (
|
||||
body * {
|
||||
${overrideFont ? 'font-family: revert !important;' : ''}
|
||||
}
|
||||
a:any-link {
|
||||
a:any-link * {
|
||||
${overrideFont ? `color: ${primary};` : ''}
|
||||
}
|
||||
/* https://github.com/whatwg/html/issues/5426 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a:link {
|
||||
${overrideFont ? `color: lightblue;` : ''}
|
||||
a:link * {
|
||||
${overrideFont ? `color: ${primary};` : `color: lightblue;`}
|
||||
}
|
||||
}
|
||||
/* override inline hardcoded text color */
|
||||
@@ -111,6 +111,7 @@ const getAdditionalFontLinks = () => `
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous">
|
||||
<link rel='stylesheet' href='https://fontsapi.zeoseven.com/431/main/result.css' crossorigin="anonymous"/>
|
||||
<link rel='stylesheet' href="https://fonts.googleapis.com/css2?family=Vollkorn&display=swap" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=LXGW+WenKai+TC&display=swap" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC&family=Noto+Sans+TC&display=swap" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP&display=swap" crossorigin="anonymous">
|
||||
@@ -212,7 +213,7 @@ const getLayoutStyles = (
|
||||
background-color: var(--theme-bg-color, transparent);
|
||||
background: var(--background-set, none);
|
||||
}
|
||||
body *:not(a):not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(img):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
|
||||
body.pbg, body *:not(a):not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(img):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
|
||||
${bg === '#ffffff' ? '' : `background-color: ${bg} !important;`}
|
||||
}
|
||||
body {
|
||||
@@ -222,11 +223,11 @@ const getLayoutStyles = (
|
||||
svg, img {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
p, li, blockquote, dd {
|
||||
p:not(.poem):not(.poetry):not(.lh):not(:has(> :is(img, video, font, b, h1, h2, h3, h4, h5, table))), li, blockquote, dd {
|
||||
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
|
||||
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em;
|
||||
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em ${overrideLayout ? '!important' : ''};
|
||||
text-align: ${overrideLayout ? 'var(--default-text-align)' : 'inherit'};
|
||||
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
|
||||
hyphens: ${hyphenate ? 'auto' : 'manual'};
|
||||
@@ -242,7 +243,7 @@ const getLayoutStyles = (
|
||||
${!vertical ? `margin-top: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
|
||||
${!vertical ? `margin-bottom: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
|
||||
}
|
||||
li, p:has(> :is(img, video, font, h1, h2, h3, h4, h5, table)) {
|
||||
li, p:has(> :is(img, video, font, b, h1, h2, h3, h4, h5, table)) {
|
||||
text-indent: 0 !important;
|
||||
}
|
||||
/* prevent the above from overriding the align attribute */
|
||||
@@ -273,12 +274,16 @@ const getLayoutStyles = (
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Now begins really dirty hacks to fix some badly designed epubs */
|
||||
.duokan-footnote-content,
|
||||
.duokan-footnote-item {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Now begins really dirty hacks to fix some badly designed epubs */
|
||||
.duokan-image-single {
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
.calibre {
|
||||
color: unset;
|
||||
}
|
||||
@@ -419,10 +424,18 @@ export const mountAdditionalFonts = (document: Document) => {
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
export const transformStylesheet = (css: string) => {
|
||||
export const transformStylesheet = (
|
||||
viewSettings: ViewSettings,
|
||||
width: number,
|
||||
height: number,
|
||||
css: string,
|
||||
) => {
|
||||
const isMobile = ['ios', 'android'].includes(getOSPlatform());
|
||||
const fontScale = isMobile ? 1.25 : 1;
|
||||
const w = width * (1 - viewSettings.gapPercent / 100);
|
||||
const h = height - viewSettings.marginPx * 2;
|
||||
// replace absolute font sizes with rem units
|
||||
// replace vw and vh as they cause problems with layout
|
||||
// replace hardcoded colors
|
||||
return css
|
||||
.replace(/font-size\s*:\s*xx-small/gi, 'font-size: 0.6rem')
|
||||
@@ -441,6 +454,8 @@ export const transformStylesheet = (css: string) => {
|
||||
const rem = parseFloat(pt) / fontScale / 12;
|
||||
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')
|
||||
.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)');
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export interface ThrottleOptions {
|
||||
emitLast?: boolean;
|
||||
}
|
||||
|
||||
export const throttle = <T extends (...args: Parameters<T>) => void | Promise<void>>(
|
||||
func: T,
|
||||
delay: number,
|
||||
options: ThrottleOptions = { emitLast: true },
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let lastCall = 0;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -27,7 +32,7 @@ export const throttle = <T extends (...args: Parameters<T>) => void | Promise<vo
|
||||
if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
if (lastArgs) {
|
||||
if (lastArgs && options.emitLast) {
|
||||
func(...(lastArgs as Parameters<T>));
|
||||
lastArgs = null;
|
||||
}
|
||||
|
||||
@@ -37,31 +37,50 @@ export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateTocID = (items: TOCItem[], index = 0): number => {
|
||||
items.forEach((item) => {
|
||||
item.id ??= index++;
|
||||
if (item.subitems) {
|
||||
index = updateTocID(item.subitems, index);
|
||||
}
|
||||
export const updateToc = (bookDoc: BookDoc, items: TOCItem[], sections: SectionItem[]): void => {
|
||||
const sizes = sections.map((s) => (s.linear != 'no' && s.size > 0 ? s.size : 0));
|
||||
let cumulativeSize = 0;
|
||||
const cumulativeSizes = sizes.reduce((acc: number[], size) => {
|
||||
acc.push(cumulativeSize);
|
||||
cumulativeSize += size;
|
||||
return acc;
|
||||
}, []);
|
||||
const totalSize = cumulativeSizes[cumulativeSizes.length - 1] || 0;
|
||||
const sizePerLoc = 1500;
|
||||
sections.forEach((section, index) => {
|
||||
section.location = {
|
||||
current: Math.floor(cumulativeSizes[index]! / sizePerLoc),
|
||||
next: Math.floor((cumulativeSizes[index]! + sizes[index]!) / sizePerLoc),
|
||||
total: Math.floor(totalSize / sizePerLoc),
|
||||
};
|
||||
});
|
||||
return index;
|
||||
|
||||
const sectionsMap = sections.reduce((map: Record<string, SectionItem>, section) => {
|
||||
map[section.id] = section;
|
||||
return map;
|
||||
}, {});
|
||||
updateTocData(bookDoc, items, sectionsMap);
|
||||
};
|
||||
|
||||
export const updateTocCFI = (
|
||||
const updateTocData = (
|
||||
bookDoc: BookDoc,
|
||||
items: TOCItem[],
|
||||
sections: { [id: string]: SectionItem },
|
||||
): void => {
|
||||
index = 0,
|
||||
): number => {
|
||||
items.forEach((item) => {
|
||||
item.id ??= index++;
|
||||
if (item.href) {
|
||||
const id = bookDoc.splitTOCHref(item.href)[0]!;
|
||||
const section = sections[id];
|
||||
if (section) {
|
||||
item.cfi = section.cfi;
|
||||
item.location = section.location;
|
||||
}
|
||||
}
|
||||
if (item.subitems) {
|
||||
updateTocCFI(bookDoc, item.subitems, sections);
|
||||
index = updateTocData(bookDoc, item.subitems, sections, index);
|
||||
}
|
||||
});
|
||||
return index;
|
||||
};
|
||||
|
||||
@@ -35,6 +35,16 @@ const zipWriteOptions = {
|
||||
lastModDate: new Date(0),
|
||||
};
|
||||
|
||||
const escapeXml = (str: string) => {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
export class TxtToEpubConverter {
|
||||
public async convert(options: Txt2EpubOptions): Promise<ConversionResult> {
|
||||
const { file: txtFile, author: providedAuthor, language: providedLanguage } = options;
|
||||
@@ -88,14 +98,24 @@ export class TxtToEpubConverter {
|
||||
const segmentRegex = new RegExp(`(?:\\r?\\n){${linesBetweenSegments},}|-{4,}\r?\n`);
|
||||
let chapterRegex: RegExp;
|
||||
if (language === 'zh') {
|
||||
chapterRegex =
|
||||
/(?:^|\n|\s|《[^》]+》)(第?[一二三四五六七八九十百千万0-9]+[章卷节回讲篇](?:[::、 \(\)0-9]+[^\n-]*)?(?!\S)|(?:^|\n|\s|《[^》]+》)[一二三四五六七八九十百千万]+(?:[::、 ][^\n-]+)(?!\S)|(?:楔子|前言|引言|序言|序章|总论|概论)(?:[:: ][^\n-]*)?(?!\S))/g;
|
||||
chapterRegex = new RegExp(
|
||||
String.raw`(?:^|\n|\s)` +
|
||||
'(' +
|
||||
[
|
||||
String.raw`第[零〇一二三四五六七八九十0-9][零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封])(?:[::、 \(\)0-9]*[^\n-]{0,24})(?!\S)`,
|
||||
String.raw`(?:^|\n|\s|《[^》]+》)[一二三四五六七八九十][零〇一二三四五六七八九十百千万]*(?:[:: ][^\n-]{0,24})(?!\S)`,
|
||||
String.raw`(?:楔子|前言|引言|序言|序章|总论|概论)(?:[:: ][^\n-]{0,24})?(?!\S)`,
|
||||
].join('|') +
|
||||
')',
|
||||
'gu',
|
||||
);
|
||||
} else {
|
||||
chapterRegex =
|
||||
/(?:^|\n|\s)(Chapter [0-9]+(?:[: ][^\n]*)?(?!\S)|Part [0-9]+(?:[: ][^\n]*)?(?!\S)|Prologue(?:[: ][^\n]*)?(?!\S)|Introduction(?:[: ][^\n]*)?(?!\S))/g;
|
||||
}
|
||||
|
||||
const formatSegment = (segment: string): string => {
|
||||
segment = escapeXml(segment);
|
||||
return segment
|
||||
.replace(/-{4,}|_{4,}/g, '\n')
|
||||
.split(/\n+/)
|
||||
@@ -104,6 +124,24 @@ export class TxtToEpubConverter {
|
||||
.join('</p><p>');
|
||||
};
|
||||
|
||||
const joinAroundUndefined = (arr: (string | undefined)[]) =>
|
||||
arr.reduce<string[]>((acc, curr, i, src) => {
|
||||
if (
|
||||
curr === undefined &&
|
||||
i > 0 &&
|
||||
i < src.length - 1 &&
|
||||
src[i - 1] !== undefined &&
|
||||
src[i + 1] !== undefined
|
||||
) {
|
||||
acc[acc.length - 1] += src[i + 1]!;
|
||||
return acc;
|
||||
}
|
||||
if (curr !== undefined && (i === 0 || src[i - 1] !== undefined)) {
|
||||
acc.push(curr);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const chapters: Chapter[] = [];
|
||||
const segments = txtContent.split(segmentRegex);
|
||||
for (const segment of segments) {
|
||||
@@ -111,7 +149,7 @@ export class TxtToEpubConverter {
|
||||
if (!trimmedSegment) continue;
|
||||
|
||||
const segmentChapters = [];
|
||||
const matches = trimmedSegment.split(chapterRegex);
|
||||
const matches = joinAroundUndefined(trimmedSegment.split(chapterRegex));
|
||||
for (let j = 1; j < matches.length; j += 2) {
|
||||
const title = matches[j]?.trim() || '';
|
||||
const content = matches[j + 1]?.trim() || '';
|
||||
@@ -126,7 +164,7 @@ export class TxtToEpubConverter {
|
||||
const headTitle = isVolume ? `<h1>${title}</h1>` : `<h2>${title}</h2>`;
|
||||
const formattedSegment = formatSegment(content);
|
||||
segmentChapters.push({
|
||||
title,
|
||||
title: escapeXml(title),
|
||||
content: `${headTitle}<p>${formattedSegment}</p>`,
|
||||
});
|
||||
}
|
||||
@@ -139,7 +177,7 @@ export class TxtToEpubConverter {
|
||||
initialContent.slice(0, 16);
|
||||
const formattedSegment = formatSegment(initialContent);
|
||||
segmentChapters.unshift({
|
||||
title: segmentTitle,
|
||||
title: escapeXml(segmentTitle),
|
||||
content: `<h3></h3><p>${formattedSegment}</p>`,
|
||||
});
|
||||
}
|
||||
@@ -194,10 +232,10 @@ export class TxtToEpubConverter {
|
||||
<meta name="dtb:maxPageNumber" content="0" />
|
||||
</head>
|
||||
<docTitle>
|
||||
<text>${bookTitle}</text>
|
||||
<text>${escapeXml(bookTitle)}</text>
|
||||
</docTitle>
|
||||
<docAuthor>
|
||||
<text>${author}</text>
|
||||
<text>${escapeXml(author)}</text>
|
||||
</docAuthor>
|
||||
<navMap>
|
||||
${navPoints}
|
||||
@@ -258,9 +296,9 @@ export class TxtToEpubConverter {
|
||||
const contentOpf = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="book-id" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>${bookTitle}</dc:title>
|
||||
<dc:title>${escapeXml(bookTitle)}</dc:title>
|
||||
<dc:language>${language}</dc:language>
|
||||
<dc:creator>${author}</dc:creator>
|
||||
<dc:creator>${escapeXml(author)}</dc:creator>
|
||||
<dc:identifier id="book-id">${identifier}</dc:identifier>
|
||||
</metadata>
|
||||
<manifest>
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 1f545c5ef5...6f69a365b8
Reference in New Issue
Block a user