Compare commits

..

10 Commits

67 changed files with 1292 additions and 330 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.37",
"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.": "هذا تنسيق الكتاب غير مدعوم.",
@@ -296,5 +294,10 @@
"Description:": "الوصف:",
"No description available": "لا يوجد وصف متاح",
"List": "قائمة",
"Grid": "شبكة"
"Grid": "شبكة",
"(from 'As You Like It', Act II)": "(من 'كما تحب'، الفصل الثاني)",
"Link Color": "لون الرابط",
"Volume Keys for Page Flip": "مفاتيح الصوت لتقليب الصفحات",
"Clicks for Page Flip": "نقرات لتقليب الصفحات",
"Swap Clicks Area": "تبديل منطقة النقرات"
}
@@ -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.",
@@ -296,5 +294,10 @@
"Description:": "Beschreibung:",
"No description available": "Keine Beschreibung verfügbar",
"List": "Liste",
"Grid": "Raster"
"Grid": "Raster",
"(from 'As You Like It', Act II)": "(aus 'Wie es euch gefällt', Akt II)",
"Link Color": "Linkfarbe",
"Volume Keys for Page Flip": "Volumen-Tasten für Seitenwechsel",
"Clicks for Page Flip": "Tippen zum Blättern",
"Swap Clicks Area": "Tippbereiche tauschen"
}
@@ -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.": "Αυτή η μορφή βιβλίου δεν υποστηρίζεται.",
@@ -296,5 +294,10 @@
"Description:": "Περιγραφή:",
"No description available": "Δεν είναι διαθέσιμη καμία περιγραφή",
"List": "Λίστα",
"Grid": "Πλέγμα"
"Grid": "Πλέγμα",
"(from 'As You Like It', Act II)": "('Όπως σας αρέσει', Πράξη II)",
"Link Color": "Χρώμα συνδέσμου",
"Volume Keys for Page Flip": "Πλήκτρα έντασης για ανατροπή σελίδας",
"Clicks for Page Flip": "Κλικ για ανατροπή σελίδας",
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ"
}
@@ -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.",
@@ -296,5 +294,10 @@
"Description:": "Descripción:",
"No description available": "No hay descripción disponible",
"List": "Lista",
"Grid": "Cuadrícula"
"Grid": "Cuadrícula",
"(from 'As You Like It', Act II)": "(de 'Como gustéis', Acto II)",
"Link Color": "Color del enlace",
"Volume Keys for Page Flip": "Botones de volumen para pasar página",
"Clicks for Page Flip": "Toques para pasar página",
"Swap Clicks Area": "Invertir zonas de toque"
}
@@ -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.",
@@ -296,5 +294,10 @@
"Description:": "Description :",
"No description available": "Aucune description disponible",
"List": "Liste",
"Grid": "Grille"
"Grid": "Grille",
"(from 'As You Like It', Act II)": "(de 'Comme il vous plaira', Acte II)",
"Link Color": "Couleur du lien",
"Volume Keys for Page Flip": "Boutons volume pour tourner la page",
"Clicks for Page Flip": "Taps pour tourner la page",
"Swap Clicks Area": "Inverser zones de tap"
}
@@ -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.": "यह पुस्तक प्रारूप समर्थित नहीं है।",
@@ -296,5 +294,10 @@
"Description:": "विवरण:",
"No description available": "कोई विवरण उपलब्ध नहीं है",
"List": "सूची",
"Grid": "ग्रिड"
"Grid": "ग्रिड",
"(from 'As You Like It', Act II)": "('जैसा आप इसे पसंद करते हैं', अधिनियम II)",
"Link Color": "लिंक रंग",
"Volume Keys for Page Flip": "पृष्ठ फ़्लिप के लिए वॉल्यूम कुंजियाँ",
"Clicks for Page Flip": "पृष्ठ फ़्लिप के लिए क्लिक करें",
"Swap Clicks Area": "क्लिक क्षेत्र स्वैप करें"
}
@@ -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.",
@@ -296,5 +294,10 @@
"Description:": "Deskripsi:",
"No description available": "Tidak ada deskripsi yang tersedia",
"List": "Daftar",
"Grid": "Jala"
"Grid": "Jala",
"(from 'As You Like It', Act II)": "(dari 'Seperti yang Anda Inginkan', Bab II)",
"Link Color": "Warna Tautan",
"Volume Keys for Page Flip": "Volume Keys untuk Pembalikan Halaman",
"Clicks for Page Flip": "Klik untuk Pembalikan Halaman",
"Swap Clicks Area": "Area Klik Tukar"
}
@@ -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.",
@@ -296,5 +294,10 @@
"Description:": "Descrizione:",
"No description available": "Nessuna descrizione disponibile",
"List": "Elenco",
"Grid": "Griglia"
"Grid": "Griglia",
"(from 'As You Like It', Act II)": "(da 'Come vi piace', Atto II)",
"Link Color": "Colore link",
"Volume Keys for Page Flip": "Tasti volume per girare pagina",
"Clicks for Page Flip": "Clic per girare pagina",
"Swap Clicks Area": "Inverti area dei clic"
}
@@ -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.": "この書籍形式はサポートされていません。",
@@ -296,5 +294,10 @@
"Description:": "説明:",
"No description available": "説明はありません",
"List": "リスト",
"Grid": "グリッド"
"Grid": "グリッド",
"(from 'As You Like It', Act II)": "(「お気に召すまま」第2幕)",
"Link Color": "リンクカラー",
"Volume Keys for Page Flip": "音量キーでページめくり",
"Clicks for Page Flip": "タップでページめくり",
"Swap Clicks Area": "タップ領域を入れ替え"
}
@@ -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.": "이 책 형식은 지원되지 않습니다.",
@@ -296,5 +294,10 @@
"Description:": "설명:",
"No description available": "설명 없음",
"List": "목록",
"Grid": "격자"
"Grid": "격자",
"(from 'As You Like It', Act II)": "(당신이 좋아하는 대로, 2막)",
"Link Color": "링크 색상",
"Volume Keys for Page Flip": "페이지 넘김을 위한 볼륨 키",
"Clicks for Page Flip": "페이지 넘김을 위한 클릭",
"Swap Clicks Area": "클릭 영역 교환"
}
@@ -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.",
@@ -296,5 +294,10 @@
"Description:": "Opis:",
"No description available": "Brak opisu",
"List": "Lista",
"Grid": "Siatka"
"Grid": "Siatka",
"(from 'As You Like It', Act II)": "(z „Jak wam się podoba”, Akt II)",
"Link Color": "Kolor linku",
"Volume Keys for Page Flip": "Klucze głośności do przewracania stron",
"Clicks for Page Flip": "Kliknięcia do przewracania stron",
"Swap Clicks Area": "Zamień obszar kliknięcia"
}
@@ -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.",
@@ -296,5 +294,10 @@
"Description:": "Descrição:",
"No description available": "Nenhuma descrição disponível",
"List": "Lista",
"Grid": "Grade"
"Grid": "Grade",
"(from 'As You Like It', Act II)": "(de 'Como Gostais', Ato II)",
"Link Color": "Cor do Link",
"Volume Keys for Page Flip": "Teclas de Volume para Virar Página",
"Clicks for Page Flip": "Cliques para Virar Página",
"Swap Clicks Area": "Trocar Área de Cliques"
}
@@ -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.": "Этот формат книги не поддерживается.",
@@ -296,5 +294,10 @@
"Description:": "Описание:",
"No description available": "Нет доступного описания",
"List": "Список",
"Grid": "Сетка"
"Grid": "Сетка",
"(from 'As You Like It', Act II)": "(из 'Как вам это понравится', Акт II)",
"Link Color": "Цвет ссылки",
"Volume Keys for Page Flip": "Клавиши громкости для перелистывания страниц",
"Clicks for Page Flip": "Клики для перелистывания страниц",
"Swap Clicks Area": "Переключить область кликов"
}
@@ -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.",
@@ -296,5 +294,10 @@
"Description:": "Tanım:",
"No description available": "Tanım mevcut değil",
"List": "Liste",
"Grid": "Izgara"
"Grid": "Izgara",
"(from 'As You Like It', Act II)": "(As You Like It, 2. Perde)",
"Link Color": "Bağlantı Rengi",
"Volume Keys for Page Flip": "Sayfa Çevirme için Ses Seviyesi Tuşları",
"Clicks for Page Flip": "Sayfa Çevirme için Tıklamalar",
"Swap Clicks Area": "Tıklama Alanını Değiştir"
}
@@ -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.": "Цей формат книги не підтримується.",
@@ -296,5 +294,10 @@
"Description:": "Опис:",
"No description available": "Опис недоступний",
"List": "Список",
"Grid": "Сітка"
"Grid": "Сітка",
"(from 'As You Like It', Act II)": "(з 'Як вам це подобається', Акт II)",
"Link Color": "Колір посилання",
"Volume Keys for Page Flip": "Клавіші гучності для перевороту сторінки",
"Clicks for Page Flip": "Клацання для перевороту сторінки",
"Swap Clicks Area": "Змінити область клацання"
}
@@ -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ợ.",
@@ -296,5 +294,10 @@
"Description:": "Miêu tả:",
"No description available": "Không có mô tả nào",
"List": "Danh sách",
"Grid": "Lưới"
"Grid": "Lưới",
"(from 'As You Like It', Act II)": "(từ 'Như bạn thích', Hành động II)",
"Link Color": "Màu liên kết",
"Volume Keys for Page Flip": "Phím âm lượng để lật trang",
"Clicks for Page Flip": "Nhấp để lật trang",
"Swap Clicks Area": "Đổi khu vực nhấp"
}
@@ -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.": "不支持此书籍格式。",
@@ -296,5 +294,10 @@
"Description:": "简介",
"No description available": "暂无简介",
"List": "列表",
"Grid": "网格"
"Grid": "网格",
"(from 'As You Like It', Act II)": "出自《皆大欢喜》,第二幕",
"Link Color": "链接颜色",
"Volume Keys for Page Flip": "音量键翻页",
"Clicks for Page Flip": "点击翻页",
"Swap Clicks Area": "交换点击区域"
}
@@ -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.": "不支援此書籍格式。",
@@ -296,5 +294,10 @@
"Description:": "簡介",
"No description available": "暫無簡介",
"List": "列表",
"Grid": "網格"
"Grid": "網格",
"(from 'As You Like It', Act II)": "(出自《皆大歡喜》,第二幕)",
"Link Color": "連結顏色",
"Volume Keys for Page Flip": "音量鍵翻頁",
"Clicks for Page Flip": "點擊翻頁",
"Swap Clicks Area": "交換點擊區域"
}
+13
View File
@@ -1,5 +1,18 @@
{
"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": [
-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()
@@ -180,7 +198,8 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
val compatController = WindowCompat.getInsetsController(window, decorView)
compatController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
if (isDarkMode) {
@@ -200,26 +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)
@@ -245,4 +262,63 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
@Command
fun get_sys_fonts_list(invoke: Invoke) {
val ret = JSObject()
try {
val fontList = mutableListOf<String>()
val fontFileList = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val systemFonts = SystemFonts.getAvailableFonts()
for (font in systemFonts) {
val file = font.getFile()?: continue
if (file.isFile && (file.name.endsWith(".ttf", true) || file.name.endsWith(".otf", true))) {
fontFileList.add(file.name)
}
}
} else {
val fontDirs = listOf("/system/fonts", "/system/font", "/data/fonts")
for (dirPath in fontDirs) {
val dir = File(dirPath)
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { file ->
if (file.isFile && (file.name.endsWith(".ttf", true) || file.name.endsWith(".otf", true))) {
fontFileList.add(file.name)
}
}
}
}
}
for (fileFileName in fontFileList) {
var fontName = fileFileName
.replace(Regex("\\.(ttf|otf)$", RegexOption.IGNORE_CASE), "")
.trim()
}
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()
}
}
@@ -6,6 +6,8 @@ const COMMANDS: &[&str] = &[
"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-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"]
@@ -11,6 +11,8 @@ Default permissions for the plugin
- `allow-install-package`
- `allow-set-system-ui-visibility`
- `allow-get-status-bar-height`
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
## Permission Table
@@ -128,6 +130,32 @@ Denies the get_status_bar_height command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-sys-fonts-list`
</td>
<td>
Enables the get_sys_fonts_list command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-sys-fonts-list`
</td>
<td>
Denies the get_sys_fonts_list command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-install-package`
</td>
@@ -154,6 +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>
@@ -8,4 +8,6 @@ permissions = [
"allow-install-package",
"allow-set-system-ui-visibility",
"allow-get-status-bar-height",
"allow-get-sys-fonts-list",
"allow-intercept-keys",
]
@@ -342,6 +342,18 @@
"const": "deny-get-status-bar-height",
"markdownDescription": "Denies the get_status_bar_height command without any pre-configured scope."
},
{
"description": "Enables the get_sys_fonts_list command without any pre-configured scope.",
"type": "string",
"const": "allow-get-sys-fonts-list",
"markdownDescription": "Enables the get_sys_fonts_list command without any pre-configured scope."
},
{
"description": "Denies the get_sys_fonts_list command without any pre-configured scope.",
"type": "string",
"const": "deny-get-sys-fonts-list",
"markdownDescription": "Denies the get_sys_fonts_list command without any pre-configured scope."
},
{
"description": "Enables the install_package command without any pre-configured scope.",
"type": "string",
@@ -354,6 +366,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",
@@ -379,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`\n- `allow-get-status-bar-height`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`"
}
]
}
@@ -58,3 +58,18 @@ pub(crate) async fn get_status_bar_height<R: Runtime>(
) -> Result<GetStatusBarHeightResponse> {
app.native_bridge().get_status_bar_height()
}
#[command]
pub(crate) async fn get_sys_fonts_list<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetSysFontsListResponse> {
app.native_bridge().get_sys_fonts_list()
}
#[command]
pub(crate) async fn intercept_keys<R: Runtime>(
app: AppHandle<R>,
payload: InterceptKeysRequest,
) -> Result<()> {
app.native_bridge().intercept_keys(payload)
}
@@ -47,4 +47,17 @@ impl<R: Runtime> NativeBridge<R> {
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
let font_collection = font_enumeration::Collection::new().unwrap();
let mut fonts = 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)
}
}
@@ -44,6 +44,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::install_package,
commands::set_system_ui_visibility,
commands::get_status_bar_height,
commands::get_sys_fonts_list,
commands::intercept_keys,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -85,3 +85,19 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
self.0
.run_mobile_plugin("get_sys_fonts_list", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn intercept_keys(&self, payload: InterceptKeysRequest) -> crate::Result<()> {
self.0
.run_mobile_plugin("intercept_keys", payload)
.map_err(Into::into)
}
}
@@ -65,3 +65,17 @@ 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>,
}
+4 -15
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())
@@ -244,11 +231,13 @@ pub fn run() {
}
}
app.handle().plugin(
if let Err(e) = app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
) {
eprintln!("Failed to initialize tauri_plugin_log: {}", e);
};
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
@@ -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);
@@ -147,10 +149,10 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
<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
@@ -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;
@@ -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 {
@@ -10,8 +10,11 @@ import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { eventDispatcher } from '@/utils/event';
import { setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
@@ -20,11 +23,13 @@ import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore();
const { hoveredBookKey } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const { setLibrary } = useLibraryStore();
const { hoveredBookKey } = useReaderStore();
const { settings, setSettings } = useSettingsStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [libraryLoaded, setLibraryLoaded] = useState(false);
const isInitiating = useRef(false);
@@ -33,7 +38,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useEffect(() => {
const handleVisibilityChange = () => {
if (appService?.isMobile && !document.hidden) {
if (appService?.isMobileApp && !document.hidden) {
dismissSystemUI();
setSystemUIVisibility({ visible: false, darkMode: isDarkMode });
}
@@ -45,6 +50,34 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isDarkMode]);
const handleKeyDown = (event: CustomEvent) => {
if (event.detail.keyName === 'Back') {
setSideBarVisible(false);
setNotebookVisible(false);
return true;
}
return false;
};
useEffect(() => {
if (!appService?.isAndroidApp) return;
if (isSideBarVisible || isNotebookVisible) {
acquireBackKeyInterception();
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
if (!isSideBarVisible && !isNotebookVisible) {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
return () => {
if (appService?.isAndroidApp) {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible, isNotebookVisible]);
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
@@ -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>
@@ -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'>
+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;
@@ -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>;
+1 -1
View File
@@ -51,6 +51,7 @@ export abstract class BaseAppService implements AppService {
isAppDataSandbox = false;
isAndroidApp = false;
isIOSApp = false;
isMobileApp = false;
hasTrafficLight = false;
hasWindow = false;
hasWindowBar = false;
@@ -58,7 +59,6 @@ export abstract class BaseAppService implements AppService {
hasRoundedWindow = false;
hasSafeAreaInset = false;
hasHaptics = false;
hasSysFontsList = false;
hasUpdater = false;
abstract fs: FileSystem;
+5 -1
View File
@@ -86,6 +86,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
scrolled: false,
disableClick: false,
swapClickArea: false,
volumeKeysToFlip: false,
continuousScroll: false,
maxColumnCount: 2,
maxInlineSize: getDefaultMaxInlineSize(),
@@ -434,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',
@@ -461,7 +466,6 @@ export const CJK_FONTS_PATTENS = new RegExp(
'Yu\\s?Gothic',
'Yu\\s?Mincho',
'Mincho',
'Gothic',
'Nanum',
'Malgun',
'Gulim',
@@ -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 } {
@@ -245,7 +245,28 @@ export class EdgeTTSClient implements TTSClient {
}
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
const voices = await this.getAllVoices();
return voices.filter((v) => v.lang.startsWith(locale));
return voices
.filter(
(v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)),
)
.sort((a, b) => {
const aRegion = a.lang.split('-')[1] || '';
const bRegion = b.lang.split('-')[1] || '';
if (aRegion === bRegion) {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
}
if (aRegion === 'CN') return -1;
if (bRegion === 'CN') return 1;
if (aRegion === 'TW') return -1;
if (bRegion === 'TW') return 1;
if (aRegion === 'HK') return -1;
if (bRegion === 'HK') return 1;
if (aRegion === 'US') return -1;
if (bRegion === 'US') return 1;
if (aRegion === 'GB') return -1;
if (bRegion === 'GB') return 1;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
}
getGranularities(): TTSGranularity[] {
@@ -311,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 });
}
},
}));
+1
View File
@@ -71,6 +71,7 @@ export interface BookLayout {
scrolled: boolean;
disableClick: boolean;
swapClickArea: boolean;
volumeKeysToFlip: boolean;
continuousScroll: boolean;
maxColumnCount: number;
maxInlineSize: number;
+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>;
+29
View File
@@ -38,6 +38,16 @@ export interface GetStatusBarHeightResponse {
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,
@@ -79,3 +89,22 @@ export async function getStatusBarHeight(): Promise<GetStatusBarHeightResponse>
);
return result;
}
let cachedSysFontsResult: GetSystemFontsListResponse | null = null;
export async function getSysFontsList(): Promise<GetSystemFontsListResponse> {
if (cachedSysFontsResult) {
return cachedSysFontsResult;
}
const result = await invoke<GetSystemFontsListResponse>(
'plugin:native-bridge|get_sys_fonts_list',
);
cachedSysFontsResult = result;
return result;
}
export async function interceptKeys(request: InterceptKeysRequest): Promise<void> {
await invoke('plugin:native-bridge|intercept_keys', {
payload: request,
});
}
+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 [];
}
};