Compare commits

...

25 Commits

Author SHA1 Message Date
Huang Xin f9adf789d7 release: version 0.9.38 (#986) 2025-04-28 18:24:29 +02:00
Huang Xin efb8f70d01 fix: inherit book fonts for popup footnotes (#985) 2025-04-28 18:16:56 +02:00
Huang Xin e235cb98d2 fix: intercept the Back key to dismiss popup dialogs on Android, closes #636 (#983) 2025-04-28 16:57:15 +02:00
Huang Xin 4275508ccd feat: add volume keys for page turning, closes #471 (#982) 2025-04-28 12:36:24 +02:00
Huang Xin a424ae8b15 feat: retrieve system fonts on iOS and Android and show font weight variants, closes #949 and closes #557 (#976) 2025-04-26 17:37:04 +02:00
Huang Xin 9303ec8c5f feat: add primary color input in theme editor, closes #963 (#970) 2025-04-26 09:58:20 +02:00
Huang Xin 98faed320a feat: add more English voices for all en locales, closes #966 (#969) 2025-04-26 09:00:09 +02:00
Huang Xin 5bbdc70420 fix: preserve selection anchor when spanning paginated content, closes #873 (#968) 2025-04-26 07:45:51 +02:00
Huang Xin de21182240 fix: don't panic on plugin log initialization error, closes #953 (#961) 2025-04-25 14:42:57 +02:00
Huang Xin 4ae35992a1 compat: auto hide navigation bar also for Android version below 11 (#960) 2025-04-25 10:56:36 +02:00
Huang Xin b91d7b7a61 release: version 0.9.37 (#957) 2025-04-24 21:14:57 +02:00
Huang Xin 7c0d9bf375 fix: popup footnotes for anchors without epub namespace, closes #948 (#956) 2025-04-24 21:03:05 +02:00
Huang Xin ea8ab91ed3 feat: add list view for the bookshelf, closes #542 (#955) 2025-04-24 19:53:09 +02:00
Huang Xin efd415ece2 fix: dismiss status bar when resumed from background on Android (#952) 2025-04-24 11:12:10 +02:00
Huang Xin ec86d68b80 fix: check xdg-mime before registering deeplink in Linux, closes #475 (#951) 2025-04-24 16:19:14 +08:00
Huang Xin 172ab382bc fix: query status bar height on Android, closes #943 (#947) 2025-04-23 14:01:37 +02:00
Huang Xin ad551a04a1 feat: add description from metadata in book details, closes #913 (#946) 2025-04-22 19:01:43 +02:00
Huang Xin 4dc943d23e fix: tts now fallbacks to metadata language of the book, closes #916 (#945) 2025-04-22 18:12:14 +02:00
Huang Xin ee93885c04 fix: sync custom themes from global settings to localstorage, closes #917 (#944) 2025-04-22 14:52:46 +02:00
Huang Xin 14c032aaff feat: add global keyboard shortcut to toggle fullscreen with F11, closes #933 (#942) 2025-04-22 14:13:52 +02:00
Huang Xin 2f619ca745 fix: open book even the library is empty, closes #937 (#941) 2025-04-22 13:56:07 +02:00
Huang Xin 8979827cf5 fix: compat language code for api versions (#940) 2025-04-22 12:38:07 +02:00
Huang Xin ae16eeffa7 fix: prevent scrollToAnchor on focus outside content container, closes #906 (#928) 2025-04-21 14:57:01 +02:00
Huang Xin 8e96ef3e5d fix: hide navigation bar on reader page on Android 11, closes #921 (#927) 2025-04-21 14:49:16 +02:00
Huang Xin 38e24da968 fix: support v1 and v2 of translate API (#919) 2025-04-20 12:30:25 +08:00
95 changed files with 1857 additions and 451 deletions
Generated
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.36",
"version": "0.9.38",
"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 المخصص هنا...",
@@ -259,7 +258,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.": "هذا تنسيق الكتاب غير مدعوم.",
@@ -292,5 +290,14 @@
"Sort by...": "ترتيب حسب...",
"Added:": "تاريخ الإضافة:",
"Format:": "التنسيق:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "الوصف:",
"No description available": "لا يوجد وصف متاح",
"List": "قائمة",
"Grid": "شبكة",
"(from 'As You Like It', Act II)": "(من 'كما تحب'، الفصل الثاني)",
"Link Color": "لون الرابط",
"Volume Keys for Page Flip": "مفاتيح الصوت لتقليب الصفحات",
"Clicks for Page Flip": "نقرات لتقليب الصفحات",
"Swap Clicks Area": "تبديل منطقة النقرات"
}
@@ -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...",
@@ -259,7 +258,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.",
@@ -292,5 +290,14 @@
"Sort by...": "Sortieren nach...",
"Added:": "Hinzugefügt:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Beschreibung:",
"No description available": "Keine Beschreibung verfügbar",
"List": "Liste",
"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"
}
@@ -27,7 +27,6 @@
"Delete": "Διαγραφή",
"Delete Highlight": "Διαγραφή επισήμανσης",
"Dictionary": "Λεξικό",
"Disable Click-to-Flip": "Απενεργοποίηση κλικ για γύρισμα σελίδας",
"Download Readest": "Λήψη Readest",
"Edit": "Επεξεργασία",
"Enter your custom CSS here...": "Εισάγετε το προσαρμοσμένο CSS εδώ...",
@@ -259,7 +258,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.": "Αυτή η μορφή βιβλίου δεν υποστηρίζεται.",
@@ -292,5 +290,14 @@
"Sort by...": "Ταξινόμηση κατά...",
"Added:": "Προστέθηκε:",
"Format:": "Μορφή:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Περιγραφή:",
"No description available": "Δεν είναι διαθέσιμη καμία περιγραφή",
"List": "Λίστα",
"Grid": "Πλέγμα",
"(from 'As You Like It', Act II)": "('Όπως σας αρέσει', Πράξη II)",
"Link Color": "Χρώμα συνδέσμου",
"Volume Keys for Page Flip": "Πλήκτρα έντασης για ανατροπή σελίδας",
"Clicks for Page Flip": "Κλικ για ανατροπή σελίδας",
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ"
}
@@ -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í...",
@@ -259,7 +258,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.",
@@ -292,5 +290,14 @@
"Sort by...": "Ordenar por...",
"Added:": "Agregado:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descripción:",
"No description available": "No hay descripción disponible",
"List": "Lista",
"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"
}
@@ -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...",
@@ -259,7 +258,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.",
@@ -292,5 +290,14 @@
"Sort by...": "Trier par...",
"Added:": "Ajouté :",
"Format:": "Format :",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Description :",
"No description available": "Aucune description disponible",
"List": "Liste",
"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"
}
@@ -27,7 +27,6 @@
"Delete": "हटाएं",
"Delete Highlight": "हाइलाइट हटाएं",
"Dictionary": "शब्दकोश",
"Disable Click-to-Flip": "क्लिक-टू-फ्लिप अक्षम करें",
"Download Readest": "Readest डाउनलोड करें",
"Edit": "संपादित करें",
"Enter your custom CSS here...": "अपना कस्टम CSS यहां दर्ज करें...",
@@ -259,7 +258,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.": "यह पुस्तक प्रारूप समर्थित नहीं है।",
@@ -292,5 +290,14 @@
"Sort by...": "द्वारा क्रमबद्ध करें...",
"Added:": "जोड़ा गया:",
"Format:": "फॉर्मेट:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "विवरण:",
"No description available": "कोई विवरण उपलब्ध नहीं है",
"List": "सूची",
"Grid": "ग्रिड",
"(from 'As You Like It', Act II)": "('जैसा आप इसे पसंद करते हैं', अधिनियम II)",
"Link Color": "लिंक रंग",
"Volume Keys for Page Flip": "पृष्ठ फ़्लिप के लिए वॉल्यूम कुंजियाँ",
"Clicks for Page Flip": "पृष्ठ फ़्लिप के लिए क्लिक करें",
"Swap Clicks Area": "क्लिक क्षेत्र स्वैप करें"
}
@@ -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...",
@@ -259,7 +258,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.",
@@ -292,5 +290,14 @@
"Sort by...": "Urutkan berdasarkan...",
"Added:": "Ditambahkan:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Deskripsi:",
"No description available": "Tidak ada deskripsi yang tersedia",
"List": "Daftar",
"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"
}
@@ -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...",
@@ -259,7 +258,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.",
@@ -292,5 +290,14 @@
"Sort by...": "Ordina per...",
"Added:": "Aggiunto:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descrizione:",
"No description available": "Nessuna descrizione disponibile",
"List": "Elenco",
"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"
}
@@ -27,7 +27,6 @@
"Delete": "削除",
"Delete Highlight": "ハイライトを削除",
"Dictionary": "辞書",
"Disable Click-to-Flip": "クリックめくりを無効化",
"Download Readest": "Readestをダウンロード",
"Edit": "編集",
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
@@ -259,7 +258,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.": "この書籍形式はサポートされていません。",
@@ -292,5 +290,14 @@
"Sort by...": "並べ替え...",
"Added:": "追加日:",
"Format:": "形式:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "説明:",
"No description available": "説明はありません",
"List": "リスト",
"Grid": "グリッド",
"(from 'As You Like It', Act II)": "(「お気に召すまま」第2幕)",
"Link Color": "リンクカラー",
"Volume Keys for Page Flip": "音量キーでページめくり",
"Clicks for Page Flip": "タップでページめくり",
"Swap Clicks Area": "タップ領域を入れ替え"
}
@@ -27,7 +27,6 @@
"Delete": "삭제",
"Delete Highlight": "하이라이트 삭제",
"Dictionary": "사전",
"Disable Click-to-Flip": "클릭 넘기기 비활성화",
"Download Readest": "Readest 다운로드",
"Edit": "편집",
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
@@ -259,7 +258,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.": "이 책 형식은 지원되지 않습니다.",
@@ -292,5 +290,14 @@
"Sort by...": "정렬 기준...",
"Added:": "추가됨:",
"Format:": "형식:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "설명:",
"No description available": "설명 없음",
"List": "목록",
"Grid": "격자",
"(from 'As You Like It', Act II)": "(당신이 좋아하는 대로, 2막)",
"Link Color": "링크 색상",
"Volume Keys for Page Flip": "페이지 넘김을 위한 볼륨 키",
"Clicks for Page Flip": "페이지 넘김을 위한 클릭",
"Swap Clicks Area": "클릭 영역 교환"
}
@@ -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...",
@@ -259,7 +258,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.",
@@ -292,5 +290,14 @@
"Sort by...": "Sortuj według...",
"Added:": "Data dodania:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Opis:",
"No description available": "Brak opisu",
"List": "Lista",
"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"
}
@@ -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...",
@@ -259,7 +258,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.",
@@ -292,5 +290,14 @@
"Sort by...": "Ordenar por...",
"Added:": "Adicionado:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descrição:",
"No description available": "Nenhuma descrição disponível",
"List": "Lista",
"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"
}
@@ -27,7 +27,6 @@
"Delete": "Удалить",
"Delete Highlight": "Удалить выделение",
"Dictionary": "Словарь",
"Disable Click-to-Flip": "Отключить переворот по клику",
"Download Readest": "Скачать Readest",
"Edit": "Редактировать",
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
@@ -259,7 +258,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.": "Этот формат книги не поддерживается.",
@@ -292,5 +290,14 @@
"Sort by...": "Сортировать по...",
"Added:": "Добавлено:",
"Format:": "Формат:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Описание:",
"No description available": "Нет доступного описания",
"List": "Список",
"Grid": "Сетка",
"(from 'As You Like It', Act II)": "(из 'Как вам это понравится', Акт II)",
"Link Color": "Цвет ссылки",
"Volume Keys for Page Flip": "Клавиши громкости для перелистывания страниц",
"Clicks for Page Flip": "Клики для перелистывания страниц",
"Swap Clicks Area": "Переключить область кликов"
}
@@ -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...",
@@ -259,7 +258,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.",
@@ -292,5 +290,14 @@
"Sort by...": "Şuna göre sırala...",
"Added:": "Eklenme:",
"Format:": "Biçim:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Tanım:",
"No description available": "Tanım mevcut değil",
"List": "Liste",
"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"
}
@@ -27,7 +27,6 @@
"Delete": "Видалити",
"Delete Highlight": "Видалити виділення",
"Dictionary": "Словник",
"Disable Click-to-Flip": "Вимкнути гортання кліком",
"Download Readest": "Завантажити Readest",
"Edit": "Редагувати",
"Enter your custom CSS here...": "Введіть ваш користувацький CSS тут...",
@@ -259,7 +258,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.": "Цей формат книги не підтримується.",
@@ -292,5 +290,14 @@
"Sort by...": "Сортувати за...",
"Added:": "Додано:",
"Format:": "Формат:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Опис:",
"No description available": "Опис недоступний",
"List": "Список",
"Grid": "Сітка",
"(from 'As You Like It', Act II)": "(з 'Як вам це подобається', Акт II)",
"Link Color": "Колір посилання",
"Volume Keys for Page Flip": "Клавіші гучності для перевороту сторінки",
"Clicks for Page Flip": "Клацання для перевороту сторінки",
"Swap Clicks Area": "Змінити область клацання"
}
@@ -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...",
@@ -259,7 +258,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ợ.",
@@ -292,5 +290,14 @@
"Sort by...": "Sắp xếp theo...",
"Added:": "Đã thêm:",
"Format:": "Định dạng:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Miêu tả:",
"No description available": "Không có mô tả nào",
"List": "Danh sách",
"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"
}
@@ -27,7 +27,6 @@
"Delete": "删除",
"Delete Highlight": "删除划线",
"Dictionary": "词典",
"Disable Click-to-Flip": "禁用点击翻页",
"Download Readest": "下载 Readest",
"Edit": "编辑",
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
@@ -259,7 +258,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.": "不支持此书籍格式。",
@@ -292,5 +290,14 @@
"Sort by...": "排序方式...",
"Added:": "添加日期",
"Format:": "格式",
"GuanKiapTsingKhai-T": "原俠正楷-T"
"GuanKiapTsingKhai-T": "原俠正楷-T",
"Description:": "简介",
"No description available": "暂无简介",
"List": "列表",
"Grid": "网格",
"(from 'As You Like It', Act II)": "出自《皆大欢喜》,第二幕",
"Link Color": "链接颜色",
"Volume Keys for Page Flip": "音量键翻页",
"Clicks for Page Flip": "点击翻页",
"Swap Clicks Area": "交换点击区域"
}
@@ -27,7 +27,6 @@
"Delete": "刪除",
"Delete Highlight": "刪除劃線",
"Dictionary": "詞典",
"Disable Click-to-Flip": "禁用點擊翻頁",
"Download Readest": "下載 Readest",
"Edit": "編輯",
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
@@ -259,7 +258,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.": "不支援此書籍格式。",
@@ -292,5 +290,14 @@
"Sort by...": "排序方式...",
"Added:": "添加日期",
"Format:": "格式",
"GuanKiapTsingKhai-T": "原俠正楷-T"
"GuanKiapTsingKhai-T": "原俠正楷-T",
"Description:": "簡介",
"No description available": "暫無簡介",
"List": "列表",
"Grid": "網格",
"(from 'As You Like It', Act II)": "(出自《皆大歡喜》,第二幕)",
"Link Color": "連結顏色",
"Volume Keys for Page Flip": "音量鍵翻頁",
"Clicks for Page Flip": "點擊翻頁",
"Swap Clicks Area": "交換點擊區域"
}
+24
View File
@@ -1,5 +1,29 @@
{
"releases": {
"0.9.38": {
"date": "2025-04-29",
"notes": [
"Page Turning with Volume Keys: You can now turn pages using your devices 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": [
"Full-Screen Toggle: You can now press F11 to quickly toggle full-screen mode",
"List View: A new list layout is available for your bookshelf",
"Book Details: Book descriptions from metadata are now shown in the details view",
"Enhanced Android behavior: better navigation bar handling and status bar dismissal when resuming",
"TTS now defaults to the books metadata language when needed",
"Synced custom themes reliably across sessions"
]
},
"0.9.36": {
"date": "2025-04-20",
"notes": [
-1
View File
@@ -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"
@@ -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"
@@ -6,12 +6,18 @@ 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.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 +25,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 +50,17 @@ class SetSystemUIVisibilityRequestArgs {
var darkMode: Boolean? = false
}
@InvokeArg
class InterceptKeysRequestArgs {
var volumeKeys: Boolean? = null
var backKey: Boolean? = null
}
interface KeyDownInterceptor {
fun interceptVolumeKeys(enabled: Boolean)
fun interceptBackKey(enabled: Boolean)
}
@TauriPlugin
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
@@ -177,8 +195,12 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
window.setDecorFitsSystemWindows(false)
val controller = window.insetsController
if (controller != null) {
controller.systemBarsBehavior =
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
val compatController = WindowCompat.getInsetsController(window, decorView)
compatController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
if (isDarkMode) {
controller.setSystemBarsAppearance(
@@ -197,26 +219,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)
@@ -224,4 +244,81 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
@Command
fun get_status_bar_height(invoke: Invoke) {
val ret = JSObject()
try {
val resourceId = activity.resources.getIdentifier("status_bar_height", "dimen", "android")
val height = if (resourceId > 0) {
activity.resources.getDimensionPixelSize(resourceId)
} else {
0
}
ret.put("height", height)
} catch (e: Exception) {
ret.put("height", -1)
ret.put("error", e.message)
}
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()
}
ret.put("fonts", JSONArray(fontList))
} 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()
}
}
@@ -5,6 +5,9 @@ const COMMANDS: &[&str] = &[
"use_background_audio",
"install_package",
"set_system_ui_visibility",
"get_status_bar_height",
"get_sys_fonts_list",
"intercept_keys",
];
fn main() {
@@ -1,11 +1,28 @@
import AuthenticationServices
import AVFoundation
import AuthenticationServices
import CoreText
import MediaPlayer
import ObjectiveC
import SwiftRs
import Tauri
import UIKit
import WebKit
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 +36,140 @@ class SetSystemUIVisibilityRequestArgs: Decodable {
let darkMode: Bool
}
class InterceptKeysRequestArgs: Decodable {
let backKey: Bool?
let volumeKeys: Bool?
}
class VolumeKeyHandler: NSObject {
private var audioSession: AVAudioSession?
private var originalVolume: Float = 0.0
private var volumeView: MPVolumeView?
private var isIntercepting = false
private var webView: WKWebView?
private var volumeSlider: UISlider?
func startInterception(webView: WKWebView) {
if isIntercepting {
return
}
self.webView = webView
isIntercepting = true
audioSession = AVAudioSession.sharedInstance()
do {
try audioSession?.setActive(true)
} catch {
print("Failed to activate audio session: \(error)")
}
originalVolume = audioSession?.outputVolume ?? 0.1
DispatchQueue.main.async { [weak self] in
self?.setupHiddenVolumeView()
}
audioSession?.addObserver(self, forKeyPath: "outputVolume", options: [.new], context: nil)
}
func stopInterception() {
if !isIntercepting {
return
}
isIntercepting = false
audioSession?.removeObserver(self, forKeyPath: "outputVolume")
DispatchQueue.main.async { [weak self] in
self?.setSystemVolume(self?.originalVolume ?? 0.1)
self?.volumeView?.removeFromSuperview()
self?.volumeView = nil
self?.volumeSlider = nil
}
do {
try audioSession?.setActive(false)
} catch {
print("Failed to deactivate audio session: \(error)")
}
}
private func setSystemVolume(_ 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!)
}
setSystemVolume(originalVolume)
}
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 > originalVolume {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"window.onNativeKeyDown('VolumeUp');", completionHandler: nil)
self?.setSystemVolume(self?.originalVolume ?? 0.1)
}
} else if currentVolume < originalVolume {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"window.onNativeKeyDown('VolumeDown');", completionHandler: nil)
self?.setSystemVolume(self?.originalVolume ?? 0.1)
}
}
}
}
}
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
private var webView: WKWebView?
@objc public override func load(webview: WKWebView) {
self.webView = webview
print("NativeBridgePlugin loaded")
}
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 {
@@ -95,6 +244,57 @@ class NativeBridgePlugin: Plugin {
}
invoke.resolve(["success": true])
}
@objc public func get_sys_fonts_list(_ invoke: Invoke) throws {
var fontList: [String] = []
for family in UIFont.familyNames.sorted() {
if let localized = getLocalizedDisplayName(familyName: family) {
fontList.append(localized)
} else {
let fontNames = UIFont.fontNames(forFamilyName: family)
if fontNames.isEmpty {
fontList.append(family)
} else {
fontList.append(contentsOf: fontNames)
}
}
}
invoke.resolve(["fonts": fontList])
}
private func interceptVolumeKeys(_ intercept: Bool) {
interceptingVolumeKeys = intercept
if intercept {
if volumeKeyHandler == nil {
volumeKeyHandler = VolumeKeyHandler()
}
if let webView = self.webView {
volumeKeyHandler?.startInterception(webView: webView)
}
} else {
volumeKeyHandler?.stopInterception()
}
}
@objc public func intercept_keys(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(InterceptKeysRequestArgs.self)
if let volumeKeys = args.volumeKeys {
DispatchQueue.main.async { [weak self] in
self?.interceptVolumeKeys(volumeKeys)
}
}
invoke.resolve()
} catch {
invoke.reject(error.localizedDescription)
}
}
}
@_cdecl("init_plugin_native_bridge")
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-status-bar-height"
description = "Enables the get_status_bar_height command without any pre-configured scope."
commands.allow = ["get_status_bar_height"]
[[permission]]
identifier = "deny-get-status-bar-height"
description = "Denies the get_status_bar_height command without any pre-configured scope."
commands.deny = ["get_status_bar_height"]
@@ -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"]
@@ -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"]
@@ -10,6 +10,9 @@ Default permissions for the plugin
- `allow-use-background-audio`
- `allow-install-package`
- `allow-set-system-ui-visibility`
- `allow-get-status-bar-height`
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
## Permission Table
@@ -101,6 +104,58 @@ Denies the copy_uri_to_path command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-status-bar-height`
</td>
<td>
Enables the get_status_bar_height command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-status-bar-height`
</td>
<td>
Denies the get_status_bar_height command without any pre-configured scope.
</td>
</tr>
<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>
@@ -127,6 +182,32 @@ 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-set-system-ui-visibility`
</td>
@@ -7,4 +7,7 @@ permissions = [
"allow-use-background-audio",
"allow-install-package",
"allow-set-system-ui-visibility",
"allow-get-status-bar-height",
"allow-get-sys-fonts-list",
"allow-intercept-keys",
]
@@ -330,6 +330,30 @@
"const": "deny-copy-uri-to-path",
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
"type": "string",
"const": "allow-get-status-bar-height",
"markdownDescription": "Enables the get_status_bar_height command without any pre-configured scope."
},
{
"description": "Denies the get_status_bar_height command without any pre-configured scope.",
"type": "string",
"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",
@@ -342,6 +366,18 @@
"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 set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
@@ -367,10 +403,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`",
"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`",
"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`"
"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`"
}
]
}
@@ -51,3 +51,25 @@ pub(crate) async fn set_system_ui_visibility<R: Runtime>(
) -> Result<SetSystemUIVisibilityResponse> {
app.native_bridge().set_system_ui_visibility(payload)
}
#[command]
pub(crate) async fn get_status_bar_height<R: Runtime>(
app: AppHandle<R>,
) -> 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)
}
@@ -43,4 +43,21 @@ impl<R: Runtime> NativeBridge<R> {
) -> crate::Result<SetSystemUIVisibilityResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
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 = Vec::new();
for font in font_collection.all() {
fonts.push(font.font_name.clone());
}
Ok(GetSysFontsListResponse { fonts, error: None })
}
pub fn intercept_keys(&self, _payload: InterceptKeysRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -43,6 +43,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::use_background_audio,
commands::install_package,
commands::set_system_ui_visibility,
commands::get_status_bar_height,
commands::get_sys_fonts_list,
commands::intercept_keys,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -77,3 +77,27 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
self.0
.run_mobile_plugin("get_status_bar_height", ())
.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)
}
}
@@ -58,3 +58,24 @@ pub struct SetSystemUIVisibilityResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetStatusBarHeightResponse {
pub height: u32,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSysFontsListResponse {
pub fonts: Vec<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>,
}
+23 -16
View File
@@ -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())
@@ -220,17 +207,37 @@ pub fn run() {
});
}
#[cfg(any(windows, target_os = "linux"))]
#[cfg(target_os = "windows")]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
app.handle().plugin(
#[cfg(target_os = "linux")]
{
fn has_xdg_mime() -> bool {
std::process::Command::new("which")
.arg("xdg-mime")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
if has_xdg_mime() {
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
} else {
println!("xdg-mime not found; skipping deep link setup on Linux.");
}
}
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());
@@ -1,14 +1,16 @@
import clsx from 'clsx';
import Image from 'next/image';
import { Book } from '@/types/book';
import { LibraryViewModeType } from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
interface BookCoverProps {
book: Book;
isPreview?: boolean;
mode?: LibraryViewModeType;
}
const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
const BookCover: React.FC<BookCoverProps> = ({ mode = 'grid', book, isPreview }) => {
return (
<div className='relative flex h-full w-full'>
<Image
@@ -31,8 +33,8 @@ const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : 'line-clamp-3',
isPreview ? 'text-[0.5em]' : 'text-lg',
isPreview ? 'line-clamp-2' : mode === 'grid' ? 'line-clamp-3' : 'line-clamp-2',
isPreview ? 'text-[0.5em]' : mode === 'grid' ? 'text-lg' : 'text-sm',
)}
>
{formatTitle(book.title)}
@@ -43,7 +45,7 @@ const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : 'text-base',
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
{formatAuthors(book.author)}
@@ -6,10 +6,13 @@ import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/li
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryViewModeType } from '@/types/settings';
import { formatAuthors } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
interface BookItemProps {
mode: LibraryViewModeType;
book: Book;
isSelectMode: boolean;
selectedBooks: string[];
@@ -20,6 +23,7 @@ interface BookItemProps {
}
const BookItem: React.FC<BookItemProps> = ({
mode,
book,
isSelectMode,
selectedBooks,
@@ -39,12 +43,19 @@ const BookItem: React.FC<BookItemProps> = ({
return (
<div
className={clsx(
'book-item flex h-full flex-col',
'book-item flex',
mode === 'grid' && 'h-full flex-col',
mode === 'list' && 'h-28 flex-row gap-4 overflow-hidden',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md'>
<BookCover book={book} />
<div
className={clsx(
'bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md',
mode === 'list' && 'min-w-20',
)}
>
<BookCover mode={mode} book={book} />
{selectedBooks.includes(book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
@@ -58,11 +69,28 @@ const BookItem: React.FC<BookItemProps> = ({
</div>
)}
</div>
<div className={clsx('flex w-full flex-col p-0 pt-2')}>
<div className='min-w-0 flex-1'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
<div
className={clsx(
'flex w-full flex-col p-0',
mode === 'grid' && 'pt-2',
mode === 'list' && 'py-2',
)}
>
<div className={clsx('min-w-0 flex-1', mode === 'list' && 'flex flex-col gap-2')}>
<h4
className={clsx(
'overflow-hidden text-ellipsis font-semibold',
mode === 'grid' && 'block whitespace-nowrap text-[0.6em] text-xs',
mode === 'list' && 'line-clamp-2 text-base',
)}
>
{book.title}
</h4>
{mode === 'list' && (
<p className='text-neutral-content line-clamp-1 text-sm'>
{formatAuthors(book.author, book.primaryLanguage) || ''}
</p>
)}
</div>
<div
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
@@ -6,6 +6,7 @@ import { MdDelete, MdOpenInNew, MdOutlineCancel } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
import { LibraryViewModeType } from '@/types/settings';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
@@ -16,7 +17,7 @@ import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import BookshelfItem, { generateGridItems, generateListItems } from './BookshelfItem';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
@@ -55,12 +56,16 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [queryTerm, setQueryTerm] = useState<string | null>(null);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || 'updated');
const [sortOrder, setSortOrder] = useState(searchParams?.get('order') || 'desc');
const [viewMode, setViewMode] = useState(searchParams?.get('view') || settings.libraryViewMode);
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || settings.librarySortBy);
const [sortOrder, setSortOrder] = useState(
searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc'),
);
const isImportingBook = useRef(false);
const { setLibrary } = useLibraryStore();
const allBookshelfItems = generateBookshelfItems(libraryBooks);
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
useEffect(() => {
if (isSelectMode) {
@@ -93,6 +98,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
const view = searchParams?.get('view') || settings.libraryViewMode;
const sort = searchParams?.get('sort') || settings.librarySortBy;
const order = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
const params = new URLSearchParams(searchParams?.toString());
@@ -115,9 +121,16 @@ const Bookshelf: React.FC<BookshelfProps> = ({
} else {
params.delete('order');
}
if (sort === 'updated' && order === 'desc') {
if (view) {
params.set('view', view);
setViewMode(view);
} else {
params.delete('view');
}
if (sort === 'updated' && order === 'desc' && view === 'grid') {
params.delete('sort');
params.delete('order');
params.delete('view');
}
if (group) {
const booksGroup = allBookshelfItems.find(
@@ -229,13 +242,16 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<div className='bookshelf'>
<div
className={clsx(
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
'transform-wrapper',
viewMode === 'grid' && 'grid flex-1 grid-cols-3 gap-x-4 px-4 sm:gap-x-0 sm:px-2',
viewMode === 'grid' && 'sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
viewMode === 'list' && 'flex flex-col',
)}
>
{filteredBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
mode={viewMode as LibraryViewModeType}
item={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
@@ -251,7 +267,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
/>
))}
{!navBooksGroup && allBookshelfItems.length > 0 && (
{viewMode === 'grid' && !navBooksGroup && allBookshelfItems.length > 0 && (
<div
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
@@ -10,6 +10,7 @@ import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getLocalBookFilename } from '@/utils/book';
import { LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
@@ -18,7 +19,7 @@ import GroupItem from './GroupItem';
export type BookshelfItem = Book | BooksGroup;
export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => {
export const generateGridItems = (books: Book[]): (Book | BooksGroup)[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
book.groupId = book.groupId || BOOK_UNGROUPED_ID;
@@ -47,6 +48,10 @@ export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] =>
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateListItems = (books: Book[]): (Book | BooksGroup)[] => {
return books.filter((book) => !book.deletedAt).sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateGroupsList = (items: Book[]): BookGroupType[] => {
return items
.sort((a, b) => b.updatedAt - a.updatedAt)
@@ -66,6 +71,7 @@ export const generateGroupsList = (items: Book[]): BookGroupType[] => {
};
interface BookshelfItemProps {
mode: LibraryViewModeType;
item: BookshelfItem;
isSelectMode: boolean;
selectedBooks: string[];
@@ -80,6 +86,7 @@ interface BookshelfItemProps {
}
const BookshelfItem: React.FC<BookshelfItemProps> = ({
mode,
item,
isSelectMode,
selectedBooks,
@@ -248,30 +255,35 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
return (
<div
className={clsx(
'sm:hover:bg-base-300/50 group flex h-full flex-col px-0 py-4 sm:px-4',
pressing ? 'scale-95' : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div
className={clsx(
'group',
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
pressing ? (mode === 'grid' ? 'scale-95' : 'scale-98') : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
mode={mode}
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
)}
</div>
</div>
</div>
);
@@ -9,6 +9,7 @@ import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
@@ -36,6 +37,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { statusBarHeight } = useThemeStore();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
@@ -96,7 +98,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
style={{
marginTop: appService?.hasSafeAreaInset ? 'max(env(safe-area-inset-top), 24px)' : '',
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '',
}}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
@@ -12,6 +12,7 @@ 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';
@@ -37,6 +38,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
);
const iconSize = useResponsiveSize(16);
const showAboutReadest = () => {
setAboutDialogVisible(true);
@@ -132,7 +134,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 w-72 shadow-2xl'
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
{user ? (
<MenuItem
@@ -142,45 +144,45 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
: _('Logged in')
}
labelClass='!max-w-40'
icon={
Icon={
avatarUrl ? (
<Image
src={avatarUrl}
alt={_('User avatar')}
className='h-5 w-5 rounded-full'
className='rounded-full'
referrerPolicy='no-referrer'
width={20}
height={20}
width={iconSize}
height={iconSize}
/>
) : (
<PiUserCircleCheck />
PiUserCircleCheck
)
}
>
<ul>
<Quota quotas={quotas} className='h-10 pl-4 pr-2' />
<Quota quotas={quotas} className='h-10 pl-3 pr-2' />
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
</ul>
</MenuItem>
) : (
<MenuItem label={_('Sign In')} icon={<PiUserCircle />} onClick={handleUserLogin}></MenuItem>
<MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem>
)}
<MenuItem
label={_('Auto Upload Books to Cloud')}
icon={isAutoUpload ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoUpload ? MdCheck : undefined}
onClick={toggleAutoUploadBooks}
/>
{isTauriAppPlatform() && !appService?.isMobile && (
<MenuItem
label={_('Auto Import on File Open')}
icon={isAutoImportBooksOnOpen ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoImportBooksOnOpen ? MdCheck : undefined}
onClick={toggleAutoImportBooksOnOpen}
/>
)}
{appService?.hasUpdater && (
<MenuItem
label={_('Check Updates on Start')}
icon={isAutoCheckUpdates ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoCheckUpdates ? MdCheck : undefined}
onClick={toggleAutoCheckUpdates}
/>
)}
@@ -189,13 +191,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
{appService?.hasWindow && (
<MenuItem
label={_('Always on Top')}
icon={isAlwaysOnTop ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAlwaysOnTop ? MdCheck : undefined}
onClick={toggleAlwaysOnTop}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
Icon={isScreenWakeLock ? MdCheck : undefined}
onClick={toggleScreenWakeLock}
/>
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
@@ -4,7 +4,7 @@ import { MdCheck } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { LibrarySortByType } from '@/types/settings';
import { LibrarySortByType, LibraryViewModeType } from '@/types/settings';
import { navigateToLibrary } from '@/utils/nav';
import MenuItem from '@/components/MenuItem';
@@ -19,9 +19,15 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const viewMode = settings.libraryViewMode;
const sortBy = settings.librarySortBy;
const isAscending = settings.librarySortAscending;
const viewOptions = [
{ label: _('List'), value: 'list' },
{ label: _('Grid'), value: 'grid' },
];
const sortByOptions = [
{ label: _('Title'), value: 'title' },
{ label: _('Author'), value: 'author' },
@@ -35,6 +41,17 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
{ label: _('Descending'), value: false },
];
const handleSetViewMode = (value: LibraryViewModeType) => {
settings.libraryViewMode = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('view', value);
navigateToLibrary(router, `${params.toString()}`);
};
const handleSetSortBy = (value: LibrarySortByType) => {
settings.librarySortBy = value;
setSettings(settings);
@@ -62,6 +79,16 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
{viewOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
Icon={viewMode === option.value ? MdCheck : undefined}
onClick={() => handleSetViewMode(option.value as LibraryViewModeType)}
/>
))}
<hr className='border-base-200 my-1' />
<MenuItem
label={_('Sort by...')}
buttonClass='h-8'
@@ -73,7 +100,7 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
key={option.value}
label={option.label}
buttonClass='h-8'
icon={sortBy === option.value ? <MdCheck className='text-base-content' /> : undefined}
Icon={sortBy === option.value ? MdCheck : undefined}
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
/>
))}
@@ -83,9 +110,7 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
key={option.value.toString()}
label={option.label}
buttonClass='h-8'
icon={
isAscending === option.value ? <MdCheck className='text-base-content' /> : undefined
}
Icon={isAscending === option.value ? MdCheck : undefined}
onClick={() => handleSetSortAscending(option.value)}
/>
))}
+14 -3
View File
@@ -28,9 +28,14 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useTheme } from '@/hooks/useTheme';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useThemeStore } from '@/store/themeStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { tauriHandleSetAlwaysOnTop, tauriQuitApp } from '@/utils/window';
import {
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
tauriQuitApp,
} from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
@@ -61,6 +66,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const _ = useTranslation();
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
const { settings, setSettings, saveSettings } = useSettingsStore();
const { statusBarHeight } = useThemeStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -85,6 +91,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useScreenWakeLock(settings.screenWakeLock);
useShortcuts({
onToggleFullscreen: async () => {
if (isTauriAppPlatform()) {
await tauriHandleToggleFullScreen();
}
},
onQuitApp: async () => {
if (isTauriAppPlatform()) {
await tauriQuitApp();
@@ -526,14 +537,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<div
ref={containerRef}
className={clsx(
'scroll-container drop-zone flex-grow overflow-y-auto px-4 sm:px-2',
'scroll-container drop-zone flex-grow overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[52px]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? 'max(env(safe-area-inset-top), 24px)'
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '48px',
}}
>
@@ -6,6 +6,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePageFlip } from '../hooks/usePageFlip';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
@@ -138,8 +139,9 @@ const FoliateViewer: React.FC<{
}
};
const { handlePageFlip } = usePageFlip(bookKey, viewRef, containerRef);
useTouchEvent(bookKey, viewRef);
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
useClickEvent(bookKey, handlePageFlip);
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
@@ -223,7 +225,7 @@ const FoliateViewer: React.FC<{
<>
<div
className='foliate-viewer h-[100%] w-[100%]'
onClick={(event) => handleTurnPage(event)}
onClick={(event) => handlePageFlip(event)}
ref={containerRef}
/>
</>
@@ -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!.next ?? 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;
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
@@ -42,7 +43,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { bookKeys, hoveredBookKey, systemUIVisible, setHoveredBookKey } = useReaderStore();
const { bookKeys, hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { isSideBarVisible } = useSidebarStore();
const iconSize16 = useResponsiveSize(16);
@@ -90,7 +92,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
isVisible ? 'visible' : 'hidden',
)}
style={{
height: systemUIVisible ? 'max(env(safe-area-inset-top), 24px)' : '',
height: systemUIVisible ? `max(env(safe-area-inset-top), ${statusBarHeight}px)` : '',
}}
/>
<div
@@ -107,7 +109,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
)}
style={{
marginTop: systemUIVisible
? 'max(env(safe-area-inset-top), 24px)'
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: 'env(safe-area-inset-top)',
}}
onMouseLeave={() => !appService?.isMobile && setHoveredBookKey('')}
@@ -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 {
@@ -2,7 +2,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useEffect, Suspense, useRef } from 'react';
import { useEffect, Suspense, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
@@ -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,16 +23,61 @@ import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig, appService } = useEnv();
const { setLibrary } = useLibraryStore();
const { hoveredBookKey } = useReaderStore();
const { settings, setSettings } = useSettingsStore();
const { isDarkMode } = useThemeStore();
const { hoveredBookKey, showSystemUI, dismissSystemUI } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const { getVisibleLibrary, setLibrary } = useLibraryStore();
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);
useTheme({ systemUIVisible: false, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
const handleVisibilityChange = () => {
if (appService?.isMobileApp && !document.hidden) {
dismissSystemUI();
setSystemUIVisibility({ visible: false, darkMode: isDarkMode });
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
// 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;
@@ -38,6 +86,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const settings = await appService.loadSettings();
setSettings(settings);
setLibrary(await appService.loadLibraryBooks());
setLibraryLoaded(true);
};
initLibrary();
@@ -57,7 +106,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}, [hoveredBookKey]);
return (
getVisibleLibrary().length > 0 &&
libraryLoaded &&
settings.globalReadSettings && (
<div
className={clsx(
@@ -117,7 +117,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<MenuItem
label={_('Scrolled Mode')}
shortcut='Shift+J'
icon={isScrolledMode ? <MdCheck /> : undefined}
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
/>
@@ -132,7 +132,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
? _('Light Mode')
: _('Auto Mode')
}
icon={themeMode === 'dark' ? <BiMoon /> : themeMode === 'light' ? <BiSun /> : <TbSunMoon />}
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
</div>
@@ -53,6 +53,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
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);
@@ -120,6 +122,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, 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 +135,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 +191,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 +282,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) {
@@ -3,7 +3,7 @@ import React from 'react';
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface DropdownProps {
family?: string;
@@ -24,33 +24,40 @@ const FontDropdown: React.FC<DropdownProps> = ({
}) => {
const _ = useTranslation();
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 +67,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,15 +76,17 @@ 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 ?? '') }}>
{option.label || option.option}
@@ -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,
@@ -19,12 +20,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;
@@ -51,7 +61,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 +76,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 +123,28 @@ 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) {
console.error('Failed to get system fonts list:', res.error);
return;
}
const fonts = res.fonts.filter((font) => font && !isSymbolicFontName(font));
setSysFonts([...new Set(fonts)].sort((a, b) => a.localeCompare(b)));
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -5,6 +5,7 @@ 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 { saveViewSettings } from '../../utils/viewSettingsHelper';
import { TRANSLATED_LANGS } from '@/services/constants';
@@ -16,12 +17,14 @@ 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);
@@ -140,6 +143,18 @@ 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
@@ -198,17 +213,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>
@@ -62,13 +62,13 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
.map((book) => (
<MenuItem
key={book.hash}
icon={
Icon={
<Image
src={book.coverImageUrl!}
alt={book.title}
width={56}
height={80}
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
className='aspect-auto max-h-8 max-w-4 rounded-sm shadow-md'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
@@ -99,7 +99,7 @@ const TTSControl = () => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !viewSettings || !bookData) return;
if (!view || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
@@ -108,6 +108,7 @@ const TTSControl = () => {
return;
}
const primaryLang = bookData.book.primaryLanguage;
setBookKey(bookKey);
if (ttsControllerRef.current) {
@@ -128,9 +129,13 @@ const TTSControl = () => {
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
if (ssml) {
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
let lang = parseSSMLLang(ssml) || 'en';
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
if (lang === 'en' && primaryLang && primaryLang !== 'en') {
lang = primaryLang.split('-')[0]!;
}
setIsPlaying(true);
setTtsLang(lang);
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
@@ -6,7 +6,7 @@ import useBooksManager from './useBooksManager';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { getStyles } from '@/utils/style';
import { tauriQuitApp } from '@/utils/window';
import { tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
@@ -84,6 +84,12 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
window.location.reload();
};
const toggleFullscreen = async () => {
if (isTauriAppPlatform()) {
await tauriHandleToggleFullScreen();
}
};
const quitApp = async () => {
// on web platform use browser's default shortcut to close the tab
if (isTauriAppPlatform()) {
@@ -136,6 +142,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onOpenFontLayoutSettings: () => setFontLayoutSettingsDialogOpen(true),
onToggleSearchBar: showSearchBar,
onReloadPage: reloadPage,
onToggleFullscreen: toggleFullscreen,
onQuitApp: quitApp,
onGoLeft: goLeft,
onGoRight: goRight,
@@ -1,118 +1,16 @@
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';
export const useClickEvent = (
bookKey: string,
viewRef: React.MutableRefObject<FoliateView | null>,
containerRef: React.RefObject<HTMLDivElement>,
) => {
const { appService } = useEnv();
const { getViewSettings } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const handleTurnPage = async (
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();
}
}
}
} 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();
}
}
};
export const useClickEvent = (bookKey: string, handlePageFlip: (msg: MessageEvent) => void) => {
const { hoveredBookKey } = useReaderStore();
useEffect(() => {
window.addEventListener('message', handleTurnPage);
window.addEventListener('message', handlePageFlip);
return () => {
window.removeEventListener('message', handleTurnPage);
window.removeEventListener('message', handlePageFlip);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef]);
return {
handleTurnPage,
};
}, [bookKey, hoveredBookKey, handlePageFlip]);
};
interface IframeTouch {
@@ -213,5 +111,5 @@ export const useTouchEvent = (
window.removeEventListener('message', handleTouch);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef]);
}, [hoveredBookKey, viewRef.current]);
};
@@ -0,0 +1,139 @@
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 const usePageFlip = (
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 {
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();
}
}
};
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,
};
};
@@ -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'>
@@ -52,7 +69,7 @@ export const AboutWindow = () => {
</div>
<div className='flex select-text flex-col items-center'>
<h2 className='text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-sm'>
<p className='text-neutral-content text-center text-sm'>
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
</p>
</div>
+5 -4
View File
@@ -17,9 +17,10 @@ const Alert: React.FC<{
'alert flex items-center justify-between',
'bg-base-300 rounded-lg border-none p-4 shadow-2xl',
'w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] lg:max-w-[40vw] xl:max-w-[40vw]',
'min-w-[70vw] flex-col sm:min-w-[40vw] sm:flex-row',
)}
>
<div className='flex items-center space-x-2'>
<div className='flex items-center space-x-2 self-start'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
@@ -34,12 +35,12 @@ const Alert: React.FC<{
></path>
</svg>
<div className=''>
<h3 className='font-sm text-base'>{title}</h3>
<h3 className='font-sm text-start text-base sm:text-center'>{title}</h3>
<div className='text-xs'>{message}</div>
</div>
</div>
<div className='flex max-w-[20vw] flex-wrap items-center justify-center gap-2'>
<button className='btn btn-sm' onClick={onCancel}>
<div className='flex flex-wrap items-center gap-2 self-end sm:max-w-[20vw]'>
<button className='btn btn-sm btn-neutral' onClick={onCancel}>
{_('Cancel')}
</button>
<button className='btn btn-sm btn-warning' onClick={onConfirm}>
@@ -107,12 +107,12 @@ const BookDetailModal = ({
isOpen={isOpen}
onClose={handleClose}
bgClassName='sm:bg-black/50'
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto'
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
contentClassName='!px-6 !py-2'
>
<div className='flex w-full select-text items-center justify-center'>
<div className='relative w-full rounded-lg'>
<div className='mb-10 me-4 flex h-32 items-start'>
<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!}
@@ -142,7 +142,7 @@ const BookDetailModal = ({
{formatTitle(book.title) || _('Untitled')}
</p>
<p className='text-neutral-content line-clamp-1'>
{formatAuthors(book.author, bookMeta.language) || _('Unknown')}
{formatAuthors(book.author, book.primaryLanguage) || _('Unknown')}
</p>
</div>
<div className='flex flex-wrap items-center gap-x-4'>
@@ -212,6 +212,12 @@ const BookDetailModal = ({
</p>
</div>
</div>
<div>
<span className='font-bold'>{_('Description:')}</span>
<p className='text-neutral-content text-sm'>
{bookMeta.description || _('No description available')}
</p>
</div>
</div>
</div>
</div>
+21 -3
View File
@@ -3,9 +3,11 @@ 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 { 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 +40,40 @@ const Dialog: React.FC<DialogProps> = ({
onClose,
}) => {
const { appService } = useEnv();
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;
+15 -6
View File
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React from 'react';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { IconType } from 'react-icons';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface MenuItemProps {
label: string;
@@ -9,7 +10,7 @@ interface MenuItemProps {
shortcut?: string;
disabled?: boolean;
noIcon?: boolean;
icon?: React.ReactNode;
Icon?: React.ReactNode | IconType;
children?: React.ReactNode;
onClick?: () => void;
}
@@ -21,15 +22,15 @@ const MenuItem: React.FC<MenuItemProps> = ({
shortcut,
disabled,
noIcon = false,
icon,
Icon,
children,
onClick,
}) => {
const iconSize = useDefaultIconSize();
const iconSize = useResponsiveSize(16);
const menuButton = (
<button
className={clsx(
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-2',
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-1',
disabled && 'btn-disabled text-gray-400',
buttonClass,
)}
@@ -37,7 +38,15 @@ const MenuItem: React.FC<MenuItemProps> = ({
disabled={disabled}
>
<div className='flex min-w-0 items-center'>
{!noIcon && <span style={{ minWidth: `${iconSize}px` }}>{icon}</span>}
{!noIcon && (
<span style={{ minWidth: `${iconSize}px` }}>
{typeof Icon === 'function' ? (
<Icon className='text-base-content' size={iconSize} />
) : (
Icon
)}
</span>
)}
<span
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm', labelClass)}
style={{ minWidth: 0 }}
+3 -1
View File
@@ -40,7 +40,9 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
></div>
)}
<div className={clsx('relative flex items-center justify-between p-2', className)}>
<div
className={clsx('relative flex items-center justify-between gap-4 p-2', className)}
>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
<span className='truncate'>{quota.name}</span>
</div>
@@ -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} />
@@ -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>;
@@ -7,6 +7,7 @@ export interface ShortcutConfig {
onToggleSelectMode: string[];
onOpenFontLayoutSettings: string[];
onReloadPage: string[];
onToggleFullscreen: string[];
onQuitApp: string[];
onGoLeft: string[];
onGoRight: string[];
@@ -32,6 +33,7 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onToggleSelectMode: ['shift+s'],
onOpenFontLayoutSettings: ['shift+f'],
onReloadPage: ['shift+r'],
onToggleFullscreen: ['F11'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h'],
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
+7 -6
View File
@@ -63,8 +63,8 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
handler &&
shortcutList?.some((shortcut) =>
isShortcutMatch(shortcut, key, ctrlKey, altKey, metaKey, shiftKey),
)
) {
)
) {
handler();
return true;
}
@@ -76,11 +76,12 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
// Check if the focus is on an input, textarea, or contenteditable element
const activeElement = document.activeElement as HTMLElement;
const isInteractiveElement =
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable);
activeElement.isContentEditable;
const isNoteEditor = (activeElement.tagName === 'TEXTAREA' && activeElement.classList.contains('note-editor'))
const isNoteEditor =
activeElement.tagName === 'TEXTAREA' && activeElement.classList.contains('note-editor');
if (isInteractiveElement && !isNoteEditor) {
return; // Skip handling if the user is typing in an input, textarea, or contenteditable
@@ -89,7 +90,7 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
if (event instanceof KeyboardEvent) {
const { key, ctrlKey, altKey, metaKey, shiftKey } = event;
if (isNoteEditor && !((key === "Enter" && ctrlKey) || (key == "Escape"))) {
if (isNoteEditor && !((key === 'Enter' && ctrlKey) || key == 'Escape')) {
return;
}
+11 -3
View File
@@ -3,7 +3,7 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme, Palette } from '@/styles/themes';
import { setSystemUIVisibility } from '@/utils/bridge';
import { getStatusBarHeight, setSystemUIVisibility } from '@/utils/bridge';
type UseThemeProps = {
systemUIVisible?: boolean;
@@ -16,21 +16,29 @@ export const useTheme = ({
}: UseThemeProps = {}) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { themeColor, isDarkMode, updateAppTheme } = useThemeStore();
const { themeColor, isDarkMode, updateAppTheme, setStatusBarHeight } = useThemeStore();
useEffect(() => {
updateAppTheme(appThemeColor);
if (appService?.isMobile) {
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
}
if (appService?.isAndroidApp) {
getStatusBarHeight().then((res) => {
if (res.height && res.height > 0) {
setStatusBarHeight(res.height / window.devicePixelRatio);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDarkMode]);
}, [appService, isDarkMode]);
useEffect(() => {
const customThemes = settings.globalReadSettings?.customThemes ?? [];
customThemes.forEach((customTheme) => {
applyCustomTheme(customTheme);
});
localStorage.setItem('customThemes', JSON.stringify(customThemes));
}, [settings]);
useEffect(() => {
@@ -20,6 +20,11 @@ const getUserAndToken = async (authHeader: string | undefined) => {
return { user, token };
};
const LANG_V2_V1_MAP: Record<string, string> = {
'ZH-HANS': 'ZH',
'ZH-HANT': 'ZH-TW',
};
const getDeepLAPIKey = (keys: string | undefined) => {
const keyArray = keys?.split(',') ?? [];
return keyArray.length ? keyArray[Math.floor(Math.random() * keyArray.length)] : '';
@@ -52,12 +57,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const {
text,
source_lang: sourceLang = 'auto',
target_lang: targetLang = 'en',
source_lang: sourceLang = 'AUTO',
target_lang: targetLang = 'EN',
}: { text: string[]; source_lang: string; target_lang: string } = req.body;
try {
if (user && token) {
console.log('deeplApiUrl', deeplApiUrl);
const isV2Api = deeplApiUrl.endsWith('/v2/translate');
const requestBody = {
text: isV2Api ? text : (text[0] ?? ''),
source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang),
target_lang: isV2Api ? targetLang : (LANG_V2_V1_MAP[targetLang] ?? targetLang),
};
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
@@ -65,10 +75,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
'x-fingerprint': process.env['DEEPL_X_FINGERPRINT'] || '',
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
body: JSON.stringify(requestBody),
});
res.status(response.status);
res.json(await response.json());
const data = await response.json();
const result = {
...data,
translations: data.translations || [
{
text: data.data,
},
],
};
res.status(response.status).json(result);
} else {
const result = await deeplQuery({
text: text[0] ?? '',
+9 -2
View File
@@ -15,6 +15,7 @@ import {
formatTitle,
formatAuthors,
getFilename,
getPrimaryLanguage,
} from '@/utils/book';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader } from '@/libs/document';
@@ -31,6 +32,7 @@ import {
DEFAULT_MOBILE_VIEW_SETTINGS,
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
DEFAULT_MOBILE_READSETTINGS,
} from './constants';
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
@@ -49,6 +51,7 @@ export abstract class BaseAppService implements AppService {
isAppDataSandbox = false;
isAndroidApp = false;
isIOSApp = false;
isMobileApp = false;
hasTrafficLight = false;
hasWindow = false;
hasWindowBar = false;
@@ -56,7 +59,6 @@ export abstract class BaseAppService implements AppService {
hasRoundedWindow = false;
hasSafeAreaInset = false;
hasHaptics = false;
hasSysFontsList = false;
hasUpdater = false;
abstract fs: FileSystem;
@@ -99,7 +101,10 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_SYSTEM_SETTINGS,
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.getInitBooksDir(),
globalReadSettings: DEFAULT_READSETTINGS,
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
},
globalViewSettings: {
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
@@ -194,6 +199,7 @@ export abstract class BaseAppService implements AppService {
format,
title: formatTitle(loadedBook.metadata.title),
author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language),
primaryLanguage: getPrimaryLanguage(loadedBook.metadata.language),
createdAt: existingBook ? existingBook.createdAt : Date.now(),
uploadedAt: existingBook ? existingBook.uploadedAt : null,
deletedAt: transient ? Date.now() : null,
@@ -204,6 +210,7 @@ export abstract class BaseAppService implements AppService {
if (existingBook) {
existingBook.title = book.title;
existingBook.author = book.author;
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
}
if (!(await this.fs.exists(getDir(book), 'Books'))) {
+13 -1
View File
@@ -38,6 +38,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
autoCheckUpdates: true,
screenWakeLock: true,
autoImportBooksOnOpen: false,
libraryViewMode: 'grid',
librarySortBy: 'updated',
librarySortAscending: false,
@@ -63,6 +64,11 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
},
};
export const DEFAULT_MOBILE_READSETTINGS: Partial<ReadSettings> = {
sideBarWidth: '25%',
isSideBarPinned: false,
};
export const DEFAULT_BOOK_FONT: BookFont = {
serifFont: 'Bitter',
sansSerifFont: 'Roboto',
@@ -80,6 +86,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
scrolled: false,
disableClick: false,
swapClickArea: false,
volumeKeysToFlip: false,
continuousScroll: false,
maxColumnCount: 2,
maxInlineSize: getDefaultMaxInlineSize(),
@@ -428,6 +435,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',
@@ -455,7 +466,6 @@ export const CJK_FONTS_PATTENS = new RegExp(
'Yu\\s?Gothic',
'Yu\\s?Mincho',
'Mincho',
'Gothic',
'Nanum',
'Malgun',
'Gulim',
@@ -583,3 +593,5 @@ export const TRANSLATED_LANGS = {
'zh-CN': '简体中文',
'zh-TW': '正體中文',
};
export const SUPPORTED_LANGS: Record<string, string> = { ...TRANSLATED_LANGS, zh: '中文' };
@@ -213,6 +213,7 @@ export class NativeAppService extends BaseAppService {
override isAndroidApp = OS_TYPE === 'android';
override isIOSApp = OS_TYPE === 'ios';
override isMacOSApp = OS_TYPE === 'macos';
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 +221,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 } {
@@ -6,6 +6,7 @@ import { TTSGranularity } from '@/types/view';
import { TTSUtils } from './TTSUtils';
export class EdgeTTSClient implements TTSClient {
#primaryLang = 'en';
#rate = 1.0;
#pitch = 1.0;
#voice: TTSVoice | null = null;
@@ -50,7 +51,10 @@ export class EdgeTTSClient implements TTSClient {
preload = false,
): AsyncGenerator<TTSMessageEvent> {
const { marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml) || 'en';
let lang = parseSSMLLang(ssml) || 'en';
if (lang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
lang = this.#primaryLang;
}
let voiceId = 'en-US-AriaNeural';
if (!this.#voice || this.#currentVoiceLang !== lang) {
const preferredVoiceId = TTSUtils.getPreferredVoice('edge-tts', lang);
@@ -207,6 +211,10 @@ export class EdgeTTSClient implements TTSClient {
}
}
setPrimaryLang(lang: string) {
this.#primaryLang = lang;
}
async setRate(rate: number) {
// The Edge TTS API uses rate in [0.5 .. 2.0].
this.#rate = rate;
@@ -237,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[] {
@@ -21,6 +21,7 @@ export interface TTSClient {
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
setPrimaryLang(lang: string): void;
setRate(rate: number): Promise<void>;
setPitch(pitch: number): Promise<void>;
setVoice(voice: string): Promise<void>;
@@ -219,6 +219,12 @@ export class TTSController extends EventTarget {
async setLang(lang: string) {
this.ttsLang = lang;
this.setPrimaryLang(lang);
}
async setPrimaryLang(lang: string) {
this.ttsEdgeClient.setPrimaryLang(lang);
this.ttsWebClient.setPrimaryLang(lang);
}
async setRate(rate: number) {
@@ -173,6 +173,7 @@ async function* speakWithMarks(
}
export class WebSpeechClient implements TTSClient {
#primaryLang = 'en';
#rate = 1.0;
#pitch = 1.0;
#voice: SpeechSynthesisVoice | null = null;
@@ -215,7 +216,10 @@ export class WebSpeechClient implements TTSClient {
// no need to preload for web speech
if (preload) return;
const lang = parseSSMLLang(ssml) || 'en';
let lang = parseSSMLLang(ssml) || 'en';
if (lang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
lang = this.#primaryLang;
}
if (!this.#voice || this.#currentVoiceLang !== lang) {
const preferredVoiceId = TTSUtils.getPreferredVoice('web-speech', lang);
const preferredVoice = this.#voices.find((v) => v.voiceURI === preferredVoiceId);
@@ -262,6 +266,10 @@ export class WebSpeechClient implements TTSClient {
this.#synth.cancel();
}
setPrimaryLang(lang: string) {
this.#primaryLang = lang;
}
async setRate(rate: number) {
// The Web Speech API uses utterance.rate in [0.1 .. 10],
this.#rate = rate;
@@ -303,7 +311,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>();
+77
View File
@@ -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 });
}
},
}));
+4 -8
View File
@@ -8,6 +8,7 @@ import { updateTocCFI, updateTocID } from '@/utils/toc';
import { useSettingsStore } from './settingsStore';
import { useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
import { getPrimaryLanguage } from '@/utils/book';
interface ViewState {
/* Unique key for each book view */
@@ -29,13 +30,9 @@ interface ReaderStore {
viewStates: { [key: string]: ViewState };
bookKeys: string[];
hoveredBookKey: string | null;
systemUIVisible: boolean;
setBookKeys: (keys: string[]) => void;
setHoveredBookKey: (key: string | null) => void;
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
showSystemUI: () => void;
dismissSystemUI: () => void;
setProgress: (
key: string,
location: string,
@@ -66,12 +63,8 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
viewStates: {},
bookKeys: [],
hoveredBookKey: null,
systemUIVisible: false,
setBookKeys: (keys: string[]) => set({ bookKeys: keys }),
setHoveredBookKey: (key: string | null) => set({ hoveredBookKey: key }),
showSystemUI: () => set({ systemUIVisible: true }),
dismissSystemUI: () => set({ systemUIVisible: false }),
getView: (key: string | null) => (key && get().viewStates[key]?.view) || null,
setView: (key: string, view) =>
set((state) => ({
@@ -136,6 +129,9 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
}, {});
updateTocCFI(bookDoc, bookDoc.toc, sections);
}
// Set the book's language for formerly imported books, newly imported books have this field set
book.primaryLanguage =
book.primaryLanguage ?? getPrimaryLanguage(bookDoc.metadata.language);
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
+10
View File
@@ -10,6 +10,11 @@ interface ThemeState {
systemIsDarkMode: boolean;
themeCode: ThemeCode;
isDarkMode: boolean;
systemUIVisible: boolean;
statusBarHeight: number;
setStatusBarHeight: (height: number) => void;
showSystemUI: () => void;
dismissSystemUI: () => void;
getIsDarkMode: () => boolean;
setThemeMode: (mode: ThemeMode) => void;
setThemeColor: (color: string) => void;
@@ -62,6 +67,11 @@ export const useThemeStore = create<ThemeState>((set, get) => {
systemIsDarkMode,
isDarkMode,
themeCode,
systemUIVisible: false,
statusBarHeight: 24,
showSystemUI: () => set({ systemUIVisible: true }),
dismissSystemUI: () => set({ systemUIVisible: false }),
setStatusBarHeight: (height: number) => set({ statusBarHeight: height }),
getIsDarkMode: () => get().isDarkMode,
setThemeMode: (mode) => {
if (typeof window !== 'undefined' && localStorage) {
+2
View File
@@ -27,6 +27,7 @@ export interface Book {
lastUpdated?: number; // deprecated in favor of updatedAt
progress?: [number, number]; // Add progress field: [current, total], 1-based page number
primaryLanguage?: string;
}
export interface BookGroupType {
@@ -70,6 +71,7 @@ export interface BookLayout {
scrolled: boolean;
disableClick: boolean;
swapClickArea: boolean;
volumeKeysToFlip: boolean;
continuousScroll: boolean;
maxColumnCount: number;
maxInlineSize: number;
+2
View File
@@ -2,6 +2,7 @@ import { CustomTheme } from '@/styles/themes';
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
export type ThemeType = 'light' | 'dark' | 'auto';
export type LibraryViewModeType = 'grid' | 'list';
export type LibrarySortByType = 'title' | 'author' | 'updated' | 'created' | 'size' | 'format';
export interface ReadSettings {
@@ -27,6 +28,7 @@ export interface SystemSettings {
autoCheckUpdates: boolean;
screenWakeLock: boolean;
autoImportBooksOnOpen: boolean;
libraryViewMode: LibraryViewModeType;
librarySortBy: LibrarySortByType;
librarySortAscending: boolean;
+1 -1
View File
@@ -33,10 +33,10 @@ 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;
+1
View File
@@ -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>;
+11 -4
View File
@@ -3,6 +3,7 @@ import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
import { getUserLang, isContentURI, isValidURL, makeSafeFilename } from './misc';
import { getStorageType } from './object';
import { getDirFromLanguage } from './rtl';
import { SUPPORTED_LANGS } from '@/services/constants';
export const getDir = (book: Book) => {
return `${book.hash}`;
@@ -107,11 +108,17 @@ export const formatPublisher = (publisher: string | LanguageMap) => {
return typeof publisher === 'string' ? publisher : formatLanguageMap(publisher);
};
export const formatLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang.join(', ') : lang;
const langCodeToLangName = (langCode: string) => {
return SUPPORTED_LANGS[langCode] || langCode.toUpperCase();
};
export const primaryLanguage = (lang: string | string[] | undefined) => {
export const formatLanguage = (lang: string | string[] | undefined): string => {
return Array.isArray(lang)
? lang.map(langCodeToLangName).join(', ')
: langCodeToLangName(lang || '');
};
export const getPrimaryLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang[0] : lang;
};
@@ -159,6 +166,6 @@ export const getBookDirFromWritingMode = (writingMode: WritingMode) => {
};
export const getBookDirFromLanguage = (language: string | string[] | undefined) => {
const lang = primaryLanguage(language) || '';
const lang = getPrimaryLanguage(language) || '';
return getDirFromLanguage(lang);
};
+41
View File
@@ -33,6 +33,21 @@ export interface SetSystemUIVisibilityResponse {
error?: string;
}
export interface GetStatusBarHeightResponse {
height: number;
error?: string;
}
export interface GetSystemFontsListResponse {
fonts: string[];
error?: string;
}
export interface InterceptKeysRequest {
volumeKeys?: boolean;
backKey?: boolean;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
@@ -67,3 +82,29 @@ export async function setSystemUIVisibility(
);
return result;
}
export async function getStatusBarHeight(): Promise<GetStatusBarHeightResponse> {
const result = await invoke<GetStatusBarHeightResponse>(
'plugin:native-bridge|get_status_bar_height',
);
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,
});
}
+11 -5
View File
@@ -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;
-30
View File
@@ -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 [];
}
};