Compare commits

...

38 Commits

Author SHA1 Message Date
Huang Xin e91a39e8b6 release: version 0.9.40 (#1092) 2025-05-07 20:03:58 +02:00
Huang Xin 2006baac4b revert: unbreak style overriding, closes #1058 and closes #1075 (#1091) 2025-05-07 19:56:14 +02:00
Huang Xin 0d4f61f08a compat: fix insane img dimension might cause layout miscalculation, closes #1073 (#1090) 2025-05-07 18:33:13 +02:00
Huang Xin 0b5f0ede3b fix: close action bar when the footbar is dismissed, closes #1064 (#1086) 2025-05-07 14:44:17 +02:00
Huang Xin 99a146525f fix: transient navigation bar in Android 9, closes #1059 (#1085) 2025-05-07 14:11:46 +02:00
Huang Xin 6080f9e0f3 fix: revert to unlock screen orientation for library page, closes #1063 (#1084) 2025-05-07 13:50:40 +02:00
Huang Xin 6b3d41a4b8 fix: styling links and also the content of links, closes #1057 (#1083) 2025-05-07 12:46:28 +02:00
Huang Xin e403c009c2 fix: avoid glitches when expanding toc sub items, closes #1033 (#1081) 2025-05-07 12:22:42 +02:00
Huang Xin 14bf2bcf8f fix: segment separator now should be more than 8 dash, closes #1056 (#1077) 2025-05-07 09:58:30 +02:00
Huang Xin 7f66af71c8 fix: dialog drag handler layout, closes #1036 (#1076) 2025-05-07 09:27:20 +02:00
Huang Xin cbab201138 doc: format issue templates (#1071) 2025-05-07 06:04:20 +02:00
Huang Xin 0f47d6d393 release: version 0.9.39 (#1055) 2025-05-06 18:58:43 +02:00
Huang Xin 38101cb76d fix: temporarily disable font name with style for windows (#1054) 2025-05-06 18:29:27 +02:00
Huang Xin 8f0982ae33 fix: replace bg color only for non-default theme color, closes #1049 (#1053) 2025-05-06 18:16:00 +02:00
Huang Xin 1c015f5973 feat: add option to open last book on start, closes #328 (#1052) 2025-05-06 17:00:57 +02:00
Huang Xin 4c1af671bb fix: use separate compact margin and gap when header and footer are dismissed, closes #734 (#1047) 2025-05-06 12:38:04 +02:00
Huang Xin b4cda00a91 fix: don't scroll when selection is in current page in TTS, closes #863 (#1046) 2025-05-06 11:09:50 +02:00
Huang Xin c326ad402d fix: calculate vw/vh size from iframe viewport, closes #1027 (#1045) 2025-05-06 09:32:01 +02:00
Huang Xin e03636e0a1 fix: expanding clickable region of TOC expander icons, closes #1033 (#1044) 2025-05-06 06:45:51 +02:00
Huang Xin 9669f1272d fix: more robust txt parser, closes #1042 (#1043)
* refactor: reuse BookCover component in BookDetail dialog

* fix: more robust txt parser, closes #1042
2025-05-06 06:28:25 +02:00
Huang Xin 4094fd86ff fix: padding drag handler on Android, closes #1036 (#1039)
* refactor: load default icon when avatar image fails to load

* fix: padding drag handler on Android, closes #1036
2025-05-05 17:42:57 +02:00
Huang Xin 27df89e261 fix: override text-indent when overriding book layout, closes #1026 (#1038) 2025-05-05 11:58:49 +02:00
Huang Xin ddcee6b3df fix: TTS timeout options should popup now in iOS (#1037) 2025-05-05 11:43:10 +02:00
Huang Xin 5e04f6ae03 feat: support locking screen orientation, closes #860 (#1034) 2025-05-05 10:55:50 +02:00
Huang Xin 6299ea09b7 fix: escape special characters when converting from txt to epub, closes #1018 (#1024) 2025-05-04 13:46:08 +02:00
Huang Xin 4f6f45fe8a fix: don't preview font style for system fonts on Linux, closes #1015 (#1023) 2025-05-04 12:23:01 +02:00
Huang Xin 6b290f09f5 refactor: unify hooks for pagination and continuous scroll (#1021) 2025-05-04 10:58:44 +02:00
Huang Xin c9e08f2c01 fix: more robust continuous scroll, closes #993 (#1017) 2025-05-03 12:07:32 +02:00
Huang Xin 8167f27156 feat: add location info for each entry in table of contents (#1016) 2025-05-02 15:08:20 +02:00
Huang Xin 77c7176526 fix: volume retained when app is in background for iOS (#1014) 2025-05-02 11:15:42 +02:00
Huang Xin a8d657414d fix: more robust txt parser (#1005) 2025-04-30 18:01:47 +02:00
Huang Xin ed368e1205 fix: skip rt elements in annotation and search for Japanese books, closes #904 (#1004) 2025-04-30 11:36:52 +02:00
Huang Xin 7de1bdf824 fix: more robust method to detect language for TTS (#1003) 2025-04-30 09:36:51 +02:00
Huang Xin 141c502c76 fix: handle full CJK Unified Ideographs in search terms, closes #989 (#1002) 2025-04-30 08:52:19 +02:00
Huang Xin b1ea729a54 fix: also overriding anchor color when overriding font in dark mode, closes #995 (#1000) 2025-04-30 06:19:32 +02:00
Huang Xin d3eff19ab9 fix: filter out some non-free fonts on Android and Linux, closes #994 (#999) 2025-04-29 20:02:54 +02:00
Huang Xin 59f0cede39 fix: import system fonts list on Android (#998)
Note that the system fonts might not be available in Android webview
2025-04-29 19:14:03 +02:00
Huang Xin 946d17882b fix: reactivating volume keys interception when resumed from background on iOS (#997) 2025-04-29 18:39:36 +02:00
84 changed files with 1274 additions and 372 deletions
+8 -4
View File
@@ -7,13 +7,17 @@ assignees: ''
---
**Does your feature request involve difficulty for you to complete a task? Please describe.**
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
**Describe the solution you'd like**
A clear and concise description of what you'd like to happen.
> A clear and concise description of what you'd like to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
> A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any additional context or screenshots about the feature request here.
> Add any additional context or screenshots about the feature request here.
+10 -19
View File
@@ -11,22 +11,16 @@ assignees: ''
A clear and concise description of what the current behavior is.
Please also add **screenshots** of the existing application.
**Example:**
```
In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
[screenshot]
```
> **Example:**
> In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
> [screenshot]
## Expected behavior
A clear and concise description of what the expected behavior is.
**Example:**
```
The blank square should be transparent (invisible)
```
> **Example:**
> The blank square should be transparent (invisible)
## Technical inputs
@@ -34,11 +28,8 @@ Operating System:
Readest Version:
**Example:**
```
Operating System: macOS 14.3.1
Readest Version: 0.9.0
We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
Probably fixable with CSS
```
> **Example:**
> Operating System: Android 14 (WebView 135.0)
> Readest Version: 0.9.0
> We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
> Probably fixable with CSS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.38",
"version": "0.9.40",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -111,7 +111,6 @@
"Unknown": "غير معروف",
"Untitled": "بدون عنوان",
"Updated:": "تم التحديث:",
"User avatar": "صورة المستخدم",
"Version {{version}}": "الإصدار {{version}}",
"Vertical Direction": "الاتجاه العمودي",
"Welcome to your library. You can import your books here and read them anytime.": "مرحبًا بكم في مكتبتكم. يمكنكم استيراد كتبكم هنا وقراءتها في أي وقت.",
@@ -299,5 +298,10 @@
"Link Color": "لون الرابط",
"Volume Keys for Page Flip": "مفاتيح الصوت لتقليب الصفحات",
"Clicks for Page Flip": "نقرات لتقليب الصفحات",
"Swap Clicks Area": "تبديل منطقة النقرات"
"Swap Clicks Area": "تبديل منطقة النقرات",
"Screen": "الشاشة",
"Orientation": "الاتجاه",
"Portrait": "العمودي",
"Landscape": "الأفقي",
"Open Last Book on Start": "فتح آخر كتاب عند البدء"
}
@@ -111,7 +111,6 @@
"Unknown": "Unbekannt",
"Untitled": "Ohne Titel",
"Updated:": "Aktualisiert:",
"User avatar": "Benutzer-Avatar",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Vertikale Richtung",
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Tippbereiche tauschen",
"Screen": "Bildschirm",
"Orientation": "Orientierung",
"Portrait": "Hochformat",
"Landscape": "Querformat",
"Open Last Book on Start": "Letztes Buch beim Start öffnen"
}
@@ -111,7 +111,6 @@
"Unknown": "Άγνωστο",
"Untitled": "Χωρίς τίτλο",
"Updated:": "Ενημερώθηκε:",
"User avatar": "Εικόνα χρήστη",
"Version {{version}}": "Έκδοση {{version}}",
"Vertical Direction": "Κάθετη κατεύθυνση",
"Welcome to your library. You can import your books here and read them anytime.": "Καλώς ήρθατε στη βιβλιοθήκη σας. Μπορείτε να εισάγετε τα βιβλία σας εδώ και να τα διαβάσετε οποιαδήποτε στιγμή.",
@@ -299,5 +298,10 @@
"Link Color": "Χρώμα συνδέσμου",
"Volume Keys for Page Flip": "Πλήκτρα έντασης για ανατροπή σελίδας",
"Clicks for Page Flip": "Κλικ για ανατροπή σελίδας",
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ"
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ",
"Screen": "Οθόνη",
"Orientation": "Προσανατολισμός",
"Portrait": "Πορτραίτο",
"Landscape": "Τοπίο",
"Open Last Book on Start": "Άνοιγμα τελευταίου βιβλίου κατά την εκκίνηση"
}
@@ -111,7 +111,6 @@
"Unknown": "Desconocido",
"Untitled": "Sin título",
"Updated:": "Actualizado:",
"User avatar": "Avatar del usuario",
"Version {{version}}": "Versión {{version}}",
"Vertical Direction": "Dirección vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Invertir zonas de toque",
"Screen": "Pantalla",
"Orientation": "Orientación",
"Portrait": "Vertical",
"Landscape": "Horizontal",
"Open Last Book on Start": "Abrir último libro al iniciar"
}
@@ -111,7 +111,6 @@
"Unknown": "Inconnu",
"Untitled": "Sans titre",
"Updated:": "Mis à jour :",
"User avatar": "Avatar de l'utilisateur",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Direction verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Inverser zones de tap",
"Screen": "Écran",
"Orientation": "Orientation",
"Portrait": "Portrait",
"Landscape": "Paysage",
"Open Last Book on Start": "Ouvrir le dernier livre au démarrage"
}
@@ -111,7 +111,6 @@
"Unknown": "अज्ञात",
"Untitled": "शीर्षकहीन",
"Updated:": "अपडेट किया गया:",
"User avatar": "उपयोगकर्ता अवतार",
"Version {{version}}": "संस्करण {{version}}",
"Vertical Direction": "ऊर्ध्वाधर दिशा",
"Welcome to your library. You can import your books here and read them anytime.": "आपकी लाइब्रेरी में आपका स्वागत है। आप यहां अपनी पुस्तकें आयात कर सकते हैं और कभी भी पढ़ सकते हैं।",
@@ -299,5 +298,10 @@
"Link Color": "लिंक रंग",
"Volume Keys for Page Flip": "पृष्ठ फ़्लिप के लिए वॉल्यूम कुंजियाँ",
"Clicks for Page Flip": "पृष्ठ फ़्लिप के लिए क्लिक करें",
"Swap Clicks Area": "क्लिक क्षेत्र स्वैप करें"
"Swap Clicks Area": "क्लिक क्षेत्र स्वैप करें",
"Screen": "स्क्रीन",
"Orientation": "अवयव",
"Portrait": "पोर्ट्रेट",
"Landscape": "लैंडस्केप",
"Open Last Book on Start": "शुरू में अंतिम पुस्तक खोलें"
}
@@ -111,7 +111,6 @@
"Unknown": "Tidak Diketahui",
"Untitled": "Tanpa Judul",
"Updated:": "Diperbarui:",
"User avatar": "Avatar Pengguna",
"Version {{version}}": "Versi {{version}}",
"Vertical Direction": "Arah Vertikal",
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Area Klik Tukar",
"Screen": "Screen",
"Orientation": "Orientasi",
"Portrait": "Potret",
"Landscape": "Lanskap",
"Open Last Book on Start": "Buka Buku Terakhir saat Memulai"
}
@@ -111,7 +111,6 @@
"Unknown": "Sconosciuto",
"Untitled": "Senza titolo",
"Updated:": "Aggiornato:",
"User avatar": "Avatar utente",
"Version {{version}}": "Versione {{version}}",
"Vertical Direction": "Direzione verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Inverti area dei clic",
"Screen": "Schermo",
"Orientation": "Orientamento",
"Portrait": "Ritratto",
"Landscape": "Paesaggio",
"Open Last Book on Start": "Apri ultimo libro all'avvio"
}
@@ -111,7 +111,6 @@
"Unknown": "不明",
"Untitled": "無題",
"Updated:": "更新日:",
"User avatar": "ユーザーアバター",
"Version {{version}}": "バージョン {{version}}",
"Vertical Direction": "縦書",
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
@@ -299,5 +298,10 @@
"Link Color": "リンクカラー",
"Volume Keys for Page Flip": "音量キーでページめくり",
"Clicks for Page Flip": "タップでページめくり",
"Swap Clicks Area": "タップ領域を入れ替え"
"Swap Clicks Area": "タップ領域を入れ替え",
"Screen": "画面",
"Orientation": "向き",
"Portrait": "縦",
"Landscape": "横",
"Open Last Book on Start": "開始時に最後の書籍を開く"
}
@@ -111,7 +111,6 @@
"Unknown": "알 수 없음",
"Untitled": "제목 없음",
"Updated:": "업데이트일:",
"User avatar": "사용자 아바타",
"Version {{version}}": "버전 {{version}}",
"Vertical Direction": "수직 방향",
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
@@ -299,5 +298,10 @@
"Link Color": "링크 색상",
"Volume Keys for Page Flip": "페이지 넘김을 위한 볼륨 키",
"Clicks for Page Flip": "페이지 넘김을 위한 클릭",
"Swap Clicks Area": "클릭 영역 교환"
"Swap Clicks Area": "클릭 영역 교환",
"Screen": "화면",
"Orientation": "방향",
"Portrait": "세로",
"Landscape": "가로",
"Open Last Book on Start": "시작 시 마지막 책 열기"
}
@@ -111,7 +111,6 @@
"Unknown": "Nieznane",
"Untitled": "Bez tytułu",
"Updated:": "Zaktualizowano:",
"User avatar": "Awatar użytkownika",
"Version {{version}}": "Wersja {{version}}",
"Vertical Direction": "Kierunek pionowy",
"Welcome to your library. You can import your books here and read them anytime.": "Witaj w swojej bibliotece. Możesz tutaj zaimportować swoje książki i czytać je w dowolnym momencie.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Zamień obszar kliknięcia",
"Screen": "Ekran",
"Orientation": "Orientacja",
"Portrait": "Portret",
"Landscape": "Krajobraz",
"Open Last Book on Start": "Otwórz ostatnią książkę przy starcie"
}
@@ -111,7 +111,6 @@
"Unknown": "Desconhecido",
"Untitled": "Sem Título",
"Updated:": "Atualizado:",
"User avatar": "Avatar do usuário",
"Version {{version}}": "Versão {{version}}",
"Vertical Direction": "Direção Vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Trocar Área de Cliques",
"Screen": "Tela",
"Orientation": "Orientação",
"Portrait": "Retrato",
"Landscape": "Paisagem",
"Open Last Book on Start": "Abrir o Último Livro ao Iniciar"
}
@@ -111,7 +111,6 @@
"Unknown": "Неизвестно",
"Untitled": "Без названия",
"Updated:": "Обновлено:",
"User avatar": "Аватар пользователя",
"Version {{version}}": "Версия {{version}}",
"Vertical Direction": "Вертикальное направление",
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
@@ -299,5 +298,10 @@
"Link Color": "Цвет ссылки",
"Volume Keys for Page Flip": "Клавиши громкости для перелистывания страниц",
"Clicks for Page Flip": "Клики для перелистывания страниц",
"Swap Clicks Area": "Переключить область кликов"
"Swap Clicks Area": "Переключить область кликов",
"Screen": "Экран",
"Orientation": "Ориентация",
"Portrait": "Портрет",
"Landscape": "Ландшафт",
"Open Last Book on Start": "Открыть последнюю книгу при запуске"
}
@@ -111,7 +111,6 @@
"Unknown": "Bilinmiyor",
"Untitled": "Başlıksız",
"Updated:": "Güncellendi:",
"User avatar": "Kullanıcı avatarı",
"Version {{version}}": "Sürüm {{version}}",
"Vertical Direction": "Dikey Yön",
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Tıklama Alanını Değiştir",
"Screen": "Ekran",
"Orientation": "Yönlendirme",
"Portrait": "Portre",
"Landscape": "Yatay",
"Open Last Book on Start": "Açılışta Son Kitabı Aç"
}
@@ -111,7 +111,6 @@
"Unknown": "Невідомо",
"Untitled": "Без назви",
"Updated:": "Оновлено:",
"User avatar": "Аватар користувача",
"Version {{version}}": "Версія {{version}}",
"Vertical Direction": "Вертикальний напрямок",
"Welcome to your library. You can import your books here and read them anytime.": "Ласкаво просимо до вашої бібліотеки. Ви можете імпортувати сюди свої книги та читати їх будь-коли.",
@@ -299,5 +298,10 @@
"Link Color": "Колір посилання",
"Volume Keys for Page Flip": "Клавіші гучності для перевороту сторінки",
"Clicks for Page Flip": "Клацання для перевороту сторінки",
"Swap Clicks Area": "Змінити область клацання"
"Swap Clicks Area": "Змінити область клацання",
"Screen": "Екран",
"Orientation": "Орієнтація",
"Portrait": "Портрет",
"Landscape": "Ландшафт",
"Open Last Book on Start": "Відкрити останню книгу при запуску"
}
@@ -111,7 +111,6 @@
"Unknown": "Không xác định",
"Untitled": "Không có tiêu đề",
"Updated:": "Cập nhật:",
"User avatar": "Ảnh đại diện",
"Version {{version}}": "Phiên bản {{version}}",
"Vertical Direction": "Hướng dọc",
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
@@ -299,5 +298,10 @@
"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"
"Swap Clicks Area": "Đổi khu vực nhấp",
"Screen": "Màn hình",
"Orientation": "Định hướng",
"Portrait": "Chân dung",
"Landscape": "Phong cảnh",
"Open Last Book on Start": "Mở sách cuối cùng khi khởi động"
}
@@ -111,7 +111,6 @@
"Unknown": "未知",
"Untitled": "无标题",
"Updated:": "更新日期",
"User avatar": "用户头像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "竖排",
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
@@ -299,5 +298,10 @@
"Link Color": "链接颜色",
"Volume Keys for Page Flip": "音量键翻页",
"Clicks for Page Flip": "点击翻页",
"Swap Clicks Area": "交换点击区域"
"Swap Clicks Area": "交换点击区域",
"Screen": "屏幕",
"Orientation": "屏幕方向",
"Portrait": "竖屏",
"Landscape": "横屏",
"Open Last Book on Start": "启动时打开上次阅读的书籍"
}
@@ -111,7 +111,6 @@
"Unknown": "未知",
"Untitled": "無標題",
"Updated:": "更新日期",
"User avatar": "用戶頭像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "豎排",
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
@@ -299,5 +298,10 @@
"Link Color": "連結顏色",
"Volume Keys for Page Flip": "音量鍵翻頁",
"Clicks for Page Flip": "點擊翻頁",
"Swap Clicks Area": "交換點擊區域"
"Swap Clicks Area": "交換點擊區域",
"Screen": "螢幕",
"Orientation": "螢幕方向",
"Portrait": "豎屏",
"Landscape": "橫屏",
"Open Last Book on Start": "啟動時打開上次閱讀的書籍"
}
+26
View File
@@ -1,5 +1,31 @@
{
"releases": {
"0.9.40": {
"date": "2025-05-08",
"notes": [
"Prevented oversized images from causing layout issues",
"Resolved issue with transient navigation bar on Android 9",
"Action bar now closes properly when the footbar is dismissed",
"Smoother experience when expanding Table of Contents sections",
"Reverted unintended style overrides"
]
},
"0.9.39": {
"date": "2025-05-07",
"notes": [
"Enhanced Navigation: Added location information to the table of contents",
"Enhanced Navigation: Larger touch targets for table of contents expansion icons",
"Screen Orientation Lock: You can now lock your device's screen orientation when reading",
"Quick Access to Recent Books: Option to automatically open your last read book when starting the app",
"Better Language Support: Improved handling of CJK characters in search",
"Enhanced Text-to-Speech: More accurate language detection for TTS",
"Enhanced Text-to-Speech: Text-to-Speech now maintains your position in scroll mode",
"Enhanced Annotations: Better handling of annotations with ruby elements in Japanese books",
"Smoother Scrolling: We've made continuous scrolling more reliable for a seamless reading experience",
"Compact View: Reading view is more compact when header and footer are hidden",
"TXT Parsing: More robust handling of TXT book format"
]
},
"0.9.38": {
"date": "2025-04-29",
"notes": [
@@ -12,6 +12,7 @@ import android.view.WindowManager
import android.view.WindowInsetsController
import android.graphics.Color
import android.webkit.WebView
import android.content.pm.ActivityInfo
import android.graphics.fonts.SystemFonts
import android.graphics.fonts.Font
import androidx.core.view.WindowCompat
@@ -56,6 +57,11 @@ class InterceptKeysRequestArgs {
var backKey: Boolean? = null
}
@InvokeArg
class LockScreenOrientationRequestArgs {
var orientation: String? = null
}
interface KeyDownInterceptor {
fun interceptVolumeKeys(enabled: Boolean)
fun interceptBackKey(enabled: Boolean)
@@ -219,7 +225,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
controller.hide(WindowInsets.Type.systemBars())
}
}
} else {
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val compatController = WindowCompat.getInsetsController(window, decorView)
compatController?.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
@@ -234,6 +240,21 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
it.hide(WindowInsetsCompat.Type.systemBars())
}
}
} else {
@Suppress("DEPRECATION")
decorView.systemUiVisibility = when {
visible && !isDarkMode -> View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
visible -> View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
else -> View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN
}
}
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
@@ -294,8 +315,13 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
var fontName = fileFileName
.replace(Regex("\\.(ttf|otf)$", RegexOption.IGNORE_CASE), "")
.trim()
fontList.add(fontName)
}
ret.put("fonts", JSONArray(fontList))
var fontDict = JSObject()
for (fontName in fontList) {
fontDict.put(fontName, fontName)
}
ret.put("fonts", fontDict)
} catch (e: Exception) {
ret.put("error", e.message)
}
@@ -321,4 +347,20 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve()
}
@Command
fun lock_screen_orientation(invoke: Invoke) {
val args = invoke.parseArgs(LockScreenOrientationRequestArgs::class.java)
val orientation = args.orientation ?: "auto"
when (orientation) {
"portrait" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
"landscape" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
"auto" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
else -> {
invoke.reject("Invalid orientation mode")
return
}
}
invoke.resolve()
}
}
@@ -8,6 +8,7 @@ const COMMANDS: &[&str] = &[
"get_status_bar_height",
"get_sys_fonts_list",
"intercept_keys",
"lock_screen_orientation",
];
fn main() {
@@ -4,29 +4,29 @@
import PackageDescription
let package = Package(
name: "tauri-plugin-native-bridge",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-bridge",
type: .static,
targets: ["tauri-plugin-native-bridge"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-bridge",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
name: "tauri-plugin-native-bridge",
platforms: [
.macOS(.v10_13),
.iOS(.v14),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-bridge",
type: .static,
targets: ["tauri-plugin-native-bridge"])
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-bridge",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
)
@@ -7,6 +7,9 @@ import SwiftRs
import Tauri
import UIKit
import WebKit
import os
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NativeBridge")
func getLocalizedDisplayName(familyName: String) -> String? {
let fontDescriptor = CTFontDescriptorCreateWithAttributes(
@@ -41,33 +44,53 @@ class InterceptKeysRequestArgs: Decodable {
let volumeKeys: Bool?
}
class LockScreenOrientationRequestArgs: Decodable {
let orientation: String?
}
class VolumeKeyHandler: NSObject {
private var audioSession: AVAudioSession?
private var originalVolume: Float = 0.0
private var referenceVolume: Float = 0.5
private var previousVolume: Float = 0.5
private var volumeView: MPVolumeView?
private var isIntercepting = false
private(set) var isIntercepting = false
private var webView: WKWebView?
private var volumeSlider: UISlider?
func startInterception(webView: WKWebView) {
if isIntercepting {
return
stopInterception()
}
logger.log("Starting volume key interception")
self.webView = webView
isIntercepting = true
audioSession = AVAudioSession.sharedInstance()
do {
try audioSession?.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try audioSession?.setActive(true)
} catch {
print("Failed to activate audio session: \(error)")
logger.error("Failed to activate audio session: \(error)")
}
originalVolume = audioSession?.outputVolume ?? 0.1
DispatchQueue.main.async { [weak self] in
self?.setupHiddenVolumeView()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
self.originalVolume = self.audioSession?.outputVolume ?? 0.1
if self.originalVolume > 0.9 {
self.referenceVolume = 0.9
} else if self.originalVolume < 0.1 {
self.referenceVolume = 0.1
} else {
self.referenceVolume = self.originalVolume
}
logger.log("Reference volume set to \(self.referenceVolume)")
self.previousVolume = self.referenceVolume
self.setSessionVolume(self.referenceVolume)
self.setupHiddenVolumeView()
self.audioSession?.addObserver(
self, forKeyPath: "outputVolume", options: [.new], context: nil)
}
audioSession?.addObserver(self, forKeyPath: "outputVolume", options: [.new], context: nil)
@@ -78,23 +101,18 @@ class VolumeKeyHandler: NSObject {
return
}
logger.log("Stopping volume key interception")
isIntercepting = false
audioSession?.removeObserver(self, forKeyPath: "outputVolume")
DispatchQueue.main.async { [weak self] in
self?.setSystemVolume(self?.originalVolume ?? 0.1)
self?.setSessionVolume(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) {
private func setSessionVolume(_ volume: Float) {
DispatchQueue.main.async { [weak self] in
self?.volumeSlider?.value = volume
}
@@ -102,14 +120,12 @@ class VolumeKeyHandler: NSObject {
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(
@@ -118,31 +134,80 @@ class VolumeKeyHandler: NSObject {
) {
if keyPath == "outputVolume", let audioSession = self.audioSession, isIntercepting {
let currentVolume = audioSession.outputVolume
if currentVolume > originalVolume {
if currentVolume > self.previousVolume {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"window.onNativeKeyDown('VolumeUp');", completionHandler: nil)
self?.setSystemVolume(self?.originalVolume ?? 0.1)
}
} else if currentVolume < originalVolume {
} else if currentVolume < self.previousVolume {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"window.onNativeKeyDown('VolumeDown');", completionHandler: nil)
self?.setSystemVolume(self?.originalVolume ?? 0.1)
}
}
self.previousVolume = currentVolume
self.setSessionVolume(self.referenceVolume)
}
}
}
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
private var webView: WKWebView?
private var authSession: ASWebAuthenticationSession?
private var isOrientationLocked = false
private var currentOrientationMask: UIInterfaceOrientationMask = .all
private var orientationObserver: NSObjectProtocol?
@objc public override func load(webview: WKWebView) {
self.webView = webview
print("NativeBridgePlugin loaded")
logger.log("NativeBridgePlugin loaded")
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
@objc func appDidBecomeActive() {
if volumeKeyHandler != nil {
activateVolumeKeyInterception()
}
if orientationObserver != nil {
self.setupOrientationObserver()
}
}
@objc func appDidEnterBackground() {
if let handler = volumeKeyHandler, handler.isIntercepting {
handler.stopInterception()
}
}
func activateVolumeKeyInterception() {
if volumeKeyHandler == nil {
volumeKeyHandler = VolumeKeyHandler()
}
if let webView = self.webView {
volumeKeyHandler?.stopInterception()
volumeKeyHandler?.startInterception(webView: webView)
} else {
logger.warning("Cannot activate volume key interception: webView is nil")
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private struct AssociatedKeys {
@@ -179,15 +244,15 @@ class NativeBridgePlugin: Plugin {
if enabled {
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try session.setActive(true)
print("AVAudioSession activated")
logger.log("AVAudioSession activated")
} else {
try session.setActive(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("AVAudioSession deactivated")
logger.log("AVAudioSession deactivated")
}
invoke.resolve()
} catch {
print("Failed to set up audio session:", error)
logger.error("Failed to set up audio session: \(error)")
}
}
@@ -200,13 +265,12 @@ class NativeBridgePlugin: Plugin {
guard let strongSelf = self else { return }
if let error = error {
Logger.error("Auth session error: \(error.localizedDescription)")
logger.error("Auth session error: \(error.localizedDescription)")
invoke.reject(error.localizedDescription)
return
}
if let callbackURL = callbackURL {
Logger.info("Auth session callback URL: \(callbackURL.absoluteString)")
strongSelf.authSession?.cancel()
strongSelf.authSession = nil
invoke.resolve(["redirectUrl": callbackURL.absoluteString])
@@ -218,7 +282,7 @@ class NativeBridgePlugin: Plugin {
}
let started = authSession?.start() ?? false
Logger.info("Auth session start result: \(started)")
logger.log("Auth session start result: \(started)")
}
@objc public func set_system_ui_visibility(_ invoke: Invoke) throws {
@@ -239,45 +303,31 @@ class NativeBridgePlugin: Plugin {
keyWindow.overrideUserInterfaceStyle = darkMode ? .dark : .light
keyWindow.layoutIfNeeded()
} else {
print("No key window found")
logger.error("No key window found")
}
}
invoke.resolve(["success": true])
}
@objc public func get_sys_fonts_list(_ invoke: Invoke) throws {
var fontList: [String] = []
var fontDict: [String: String] = [:]
for family in UIFont.familyNames.sorted() {
if let localized = getLocalizedDisplayName(familyName: family) {
fontList.append(localized)
fontDict[family] = localized
} else {
let fontNames = UIFont.fontNames(forFamilyName: family)
if fontNames.isEmpty {
fontList.append(family)
fontDict[family] = family
} else {
fontList.append(contentsOf: fontNames)
for fontName in fontNames {
fontDict[fontName] = family
}
}
}
}
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()
}
invoke.resolve(["fonts": fontDict])
}
@objc public func intercept_keys(_ invoke: Invoke) {
@@ -285,16 +335,89 @@ class NativeBridgePlugin: Plugin {
let args = try invoke.parseArgs(InterceptKeysRequestArgs.self)
if let volumeKeys = args.volumeKeys {
DispatchQueue.main.async { [weak self] in
self?.interceptVolumeKeys(volumeKeys)
if volumeKeys {
self.activateVolumeKeyInterception()
} else {
self.volumeKeyHandler?.stopInterception()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.volumeKeyHandler = nil
}
}
}
invoke.resolve()
} catch {
invoke.reject(error.localizedDescription)
}
}
@objc public func lock_screen_orientation(_ invoke: Invoke) throws {
guard let args = try? invoke.parseArgs(LockScreenOrientationRequestArgs.self) else {
return invoke.reject("Invalid arguments")
}
DispatchQueue.main.async {
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
let orientation = args.orientation ?? "auto"
switch orientation.lowercased() {
case "portrait":
self.isOrientationLocked = true
self.currentOrientationMask = .portrait
self.forceOrientation(.portrait)
self.setupOrientationObserver()
case "landscape":
self.isOrientationLocked = true
self.currentOrientationMask = .landscape
self.forceOrientation(.landscapeRight)
self.setupOrientationObserver()
case "auto":
self.isOrientationLocked = false
self.currentOrientationMask = .all
default:
invoke.reject("Invalid orientation mode")
return
}
invoke.resolve()
}
}
private func forceOrientation(_ orientation: UIInterfaceOrientation) {
if #available(iOS 16.0, *) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
let orientationMask: UIInterfaceOrientationMask =
orientation.isPortrait ? .portrait : .landscape
for window in windowScene.windows {
if let rootVC = window.rootViewController {
rootVC.setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask)) { error in
print("Orientation update error: \(error.localizedDescription)")
}
}
} else {
UIDevice.current.setValue(orientation.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
}
private func setupOrientationObserver() {
orientationObserver = NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self = self, self.isOrientationLocked else { return }
if self.currentOrientationMask == .portrait {
self.forceOrientation(.portrait)
} else if self.currentOrientationMask == .landscape {
self.forceOrientation(.landscapeRight)
}
}
}
}
@_cdecl("init_plugin_native_bridge")
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-lock-screen-orientation"
description = "Enables the lock_screen_orientation command without any pre-configured scope."
commands.allow = ["lock_screen_orientation"]
[[permission]]
identifier = "deny-lock-screen-orientation"
description = "Denies the lock_screen_orientation command without any pre-configured scope."
commands.deny = ["lock_screen_orientation"]
@@ -13,6 +13,7 @@ Default permissions for the plugin
- `allow-get-status-bar-height`
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
- `allow-lock-screen-orientation`
## Permission Table
@@ -208,6 +209,32 @@ Denies the intercept_keys command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-lock-screen-orientation`
</td>
<td>
Enables the lock_screen_orientation command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-lock-screen-orientation`
</td>
<td>
Denies the lock_screen_orientation command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-set-system-ui-visibility`
</td>
@@ -10,4 +10,5 @@ permissions = [
"allow-get-status-bar-height",
"allow-get-sys-fonts-list",
"allow-intercept-keys",
"allow-lock-screen-orientation",
]
@@ -378,6 +378,18 @@
"const": "deny-intercept-keys",
"markdownDescription": "Denies the intercept_keys command without any pre-configured scope."
},
{
"description": "Enables the lock_screen_orientation command without any pre-configured scope.",
"type": "string",
"const": "allow-lock-screen-orientation",
"markdownDescription": "Enables the lock_screen_orientation command without any pre-configured scope."
},
{
"description": "Denies the lock_screen_orientation command without any pre-configured scope.",
"type": "string",
"const": "deny-lock-screen-orientation",
"markdownDescription": "Denies the lock_screen_orientation command without any pre-configured scope."
},
{
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
@@ -403,10 +415,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`"
}
]
}
@@ -73,3 +73,11 @@ pub(crate) async fn intercept_keys<R: Runtime>(
) -> Result<()> {
app.native_bridge().intercept_keys(payload)
}
#[command]
pub(crate) async fn lock_screen_orientation<R: Runtime>(
app: AppHandle<R>,
payload: LockScreenOrientationRequest,
) -> Result<()> {
app.native_bridge().lock_screen_orientation(payload)
}
@@ -1,4 +1,5 @@
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
@@ -50,9 +51,14 @@ impl<R: Runtime> NativeBridge<R> {
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
let font_collection = font_enumeration::Collection::new().unwrap();
let mut fonts = Vec::new();
let mut fonts = HashMap::new();
for font in font_collection.all() {
fonts.push(font.font_name.clone());
if cfg!(target_os = "windows") {
// FIXME: temporarily disable font name with style for windows
fonts.insert(font.family_name.clone(), font.family_name.clone());
} else {
fonts.insert(font.font_name.clone(), font.family_name.clone());
}
}
Ok(GetSysFontsListResponse { fonts, error: None })
}
@@ -60,4 +66,11 @@ impl<R: Runtime> NativeBridge<R> {
pub fn intercept_keys(&self, _payload: InterceptKeysRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn lock_screen_orientation(
&self,
_payload: LockScreenOrientationRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -46,6 +46,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::get_status_bar_height,
commands::get_sys_fonts_list,
commands::intercept_keys,
commands::lock_screen_orientation,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -101,3 +101,14 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn lock_screen_orientation(
&self,
payload: LockScreenOrientationRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("lock_screen_orientation", payload)
.map_err(Into::into)
}
}
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -69,7 +70,7 @@ pub struct GetStatusBarHeightResponse {
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSysFontsListResponse {
pub fonts: Vec<String>,
pub fonts: HashMap<String, String>,
pub error: Option<String>,
}
@@ -79,3 +80,9 @@ pub struct InterceptKeysRequest {
pub volume_keys: Option<bool>,
pub back_key: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LockScreenOrientationRequest {
pub orientation: String,
}
+2 -1
View File
@@ -65,7 +65,8 @@
"minSdkVersion": 26
},
"iOS": {
"developmentTeam": "J5W48D69VR"
"developmentTeam": "J5W48D69VR",
"minimumSystemVersion": "14.0"
},
"fileAssociations": [
{
@@ -9,7 +9,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryViewModeType } from '@/types/settings';
import { formatAuthors } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
import BookCover from '@/components/BookCover';
interface BookItemProps {
mode: LibraryViewModeType;
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { BooksGroup } from '@/types/book';
import BookCover from './BookCover';
import BookCover from '@/components/BookCover';
interface GroupItemProps {
group: BooksGroup;
@@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { PiUserCircle } from 'react-icons/pi';
import { PiUserCircleCheck } from 'react-icons/pi';
@@ -17,6 +16,7 @@ import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { QuotaType } from '@/types/user';
import UserAvatar from '@/components/UserAvatar';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -35,6 +35,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks);
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
);
@@ -110,6 +111,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsScreenWakeLock(settings.screenWakeLock);
};
const toggleOpenLastBooks = () => {
settings.openLastBooks = !settings.openLastBooks;
setSettings(settings);
saveSettings(envConfig, settings);
setIsOpenLastBooks(settings.openLastBooks);
};
useEffect(() => {
if (!user || !token) return;
const storagPlan = getStoragePlanData(token);
@@ -146,14 +154,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
labelClass='!max-w-40'
Icon={
avatarUrl ? (
<Image
src={avatarUrl}
alt={_('User avatar')}
className='rounded-full'
referrerPolicy='no-referrer'
width={iconSize}
height={iconSize}
/>
<UserAvatar url={avatarUrl} size={iconSize} DefaultIcon={PiUserCircleCheck} />
) : (
PiUserCircleCheck
)
@@ -179,6 +180,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleAutoImportBooksOnOpen}
/>
)}
<MenuItem
label={_('Open Last Book on Start')}
Icon={isOpenLastBooks ? MdCheck : undefined}
onClick={toggleOpenLastBooks}
/>
{appService?.hasUpdater && (
<MenuItem
label={_('Check Updates on Start')}
+39 -2
View File
@@ -31,6 +31,7 @@ import { useBooksSync } from './hooks/useBooksSync';
import { useThemeStore } from '@/store/themeStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { lockScreenOrientation } from '@/utils/bridge';
import {
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
@@ -61,7 +62,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
updateBook,
setLibrary,
checkOpenWithBooks,
checkLastOpenBooks,
setCheckOpenWithBooks,
setCheckLastOpenBooks,
} = useLibraryStore();
const _ = useTranslation();
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
@@ -116,6 +119,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
useEffect(() => {
if (appService?.isMobileApp) {
lockScreenOrientation({ orientation: 'auto' });
}
}, [appService]);
const handleDropedFiles = async (files: File[] | string[]) => {
if (files.length === 0) return;
const supportedFiles = files.filter((file) => {
@@ -231,6 +240,27 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
[],
);
const handleOpenLastBooks = async (
appService: AppService,
lastBookIds: string[],
libraryBooks: Book[],
) => {
if (lastBookIds.length === 0) return;
const bookIds: string[] = [];
for (const bookId of lastBookIds) {
const book = libraryBooks.find((b) => b.hash === bookId);
if (book && (await appService.isBookAvailable(book))) {
bookIds.push(book.hash);
}
console.log('Opening last books:', bookIds);
if (bookIds.length > 0) {
setTimeout(() => {
navigateToReader(router, bookIds);
}, 0);
}
}
};
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
@@ -256,13 +286,19 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setSettings(settings);
const libraryBooks = await appService.loadLibraryBooks();
if (checkOpenWithBooks && isTauriAppPlatform()) {
if (checkOpenWithBooks) {
await handleOpenWithBooks(appService, libraryBooks);
} else {
setCheckOpenWithBooks(false);
setLibrary(libraryBooks);
}
if (checkLastOpenBooks && settings.openLastBooks) {
await handleOpenLastBooks(appService, settings.lastOpenBooks, libraryBooks);
} else {
setCheckLastOpenBooks(false);
}
setLibraryLoaded(true);
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
@@ -283,6 +319,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
initLibrary();
return () => {
setCheckOpenWithBooks(false);
setCheckLastOpenBooks(false);
isInitiating.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -501,7 +538,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return null;
}
if (checkOpenWithBooks) {
if (checkOpenWithBooks || checkLastOpenBooks) {
return (
loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
@@ -2,11 +2,12 @@ import React, { useEffect, useRef, useState } from 'react';
import { BookDoc, getDirection } from '@/libs/document';
import { BookConfig } from '@/types/book';
import { FoliateView, wrappedFoliateView } from '@/types/view';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePageFlip } from '../hooks/usePageFlip';
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
@@ -26,12 +27,14 @@ import {
import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
config: BookConfig;
}> = ({ bookKey, bookDoc, config }) => {
const { appService } = useEnv();
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const isViewCreated = useRef(false);
@@ -56,27 +59,30 @@ const FoliateViewer: React.FC<{
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const docTransformHandler = (event: Event) => {
const { detail } = event as CustomEvent;
detail.data = Promise.resolve(detail.data)
.then((data) => {
const viewSettings = getViewSettings(bookKey);
if (detail.type === 'text/css') return transformStylesheet(data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
viewSettings,
content: data,
transformers: ['punctuation'],
};
return Promise.resolve(transformContent(ctx));
}
return data;
})
.catch((e) => {
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
return '';
});
const getDocTransformHandler = ({ width, height }: { width: number; height: number }) => {
return (event: Event) => {
const { detail } = event as CustomEvent;
detail.data = Promise.resolve(detail.data)
.then((data) => {
const viewSettings = getViewSettings(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(viewSettings, width, height, data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
viewSettings,
content: data,
transformers: ['punctuation'],
};
return Promise.resolve(transformContent(ctx));
}
return data;
})
.catch((e) => {
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
return '';
});
};
};
const docLoadHandler = (event: Event) => {
@@ -97,6 +103,9 @@ const FoliateViewer: React.FC<{
mountAdditionalFonts(detail.doc);
if (!detail.doc.isEventListenersAdded) {
// listened events in iframes are posted to the main window
// and then used by useMouseEvent and useTouchEvent
// and more gesture events can be detected in the iframeEventHandlers
detail.doc.isEventListenersAdded = true;
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
@@ -114,18 +123,6 @@ const FoliateViewer: React.FC<{
const detail = (event as CustomEvent).detail;
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
if (detail.reason === 'scroll') {
const renderer = viewRef.current?.renderer;
const viewSettings = getViewSettings(bookKey)!;
if (renderer && viewSettings.continuousScroll) {
if (renderer.start <= 0) {
viewRef.current?.prev(1);
// sometimes viewSize has subpixel value that the end never reaches
} else if (renderer.end + 1 >= renderer.viewSize) {
viewRef.current?.next(1);
}
}
}
const parallelViews = getParallels(bookKey);
if (parallelViews && parallelViews.size > 0) {
parallelViews.forEach((key) => {
@@ -139,9 +136,9 @@ const FoliateViewer: React.FC<{
}
};
const { handlePageFlip } = usePageFlip(bookKey, viewRef, containerRef);
useTouchEvent(bookKey, viewRef);
useClickEvent(bookKey, handlePageFlip);
const { handlePageFlip, handleContinuousScroll } = usePagination(bookKey, viewRef, containerRef);
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip, handleContinuousScroll);
const touchHandlers = useTouchEvent(bookKey, handleContinuousScroll);
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
@@ -169,6 +166,10 @@ const FoliateViewer: React.FC<{
document.body.append(view);
containerRef.current?.appendChild(view);
const containerRect = containerRef.current?.getBoundingClientRect();
const width = containerRect?.width || window.innerWidth;
const height = containerRect?.height || window.innerHeight;
const writingMode = viewSettings.writingMode;
if (writingMode) {
const settingsDir = getBookDirFromWritingMode(writingMode);
@@ -187,16 +188,23 @@ const FoliateViewer: React.FC<{
const { book } = view;
book.transformTarget?.addEventListener('data', docTransformHandler);
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
const isScrolled = viewSettings.scrolled!;
const marginPx = viewSettings.marginPx!;
const gapPercent = viewSettings.gapPercent!;
const showHeader = viewSettings.showHeader!;
const showFooter = viewSettings.showFooter!;
const isCompact = !showHeader && !showFooter;
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent;
const animated = viewSettings.animated!;
const maxColumnCount = viewSettings.maxColumnCount!;
const maxInlineSize = getMaxInlineSize(viewSettings);
const maxBlockSize = viewSettings.maxBlockSize!;
const screenOrientation = viewSettings.screenOrientation!;
if (appService?.isMobileApp) {
await lockScreenOrientation({ orientation: screenOrientation });
}
if (animated) {
view.renderer.setAttribute('animated', '');
} else {
@@ -224,9 +232,10 @@ const FoliateViewer: React.FC<{
return (
<>
<div
className='foliate-viewer h-[100%] w-[100%]'
onClick={(event) => handlePageFlip(event)}
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
</>
);
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import clsx from 'clsx';
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
@@ -122,6 +122,12 @@ const FooterBar: React.FC<FooterBarProps> = ({
}
};
useEffect(() => {
if (hoveredBookKey !== bookKey) {
setActionTab('');
}
}, [hoveredBookKey, bookKey]);
const getMarginProgressValue = (marginPx: number, gapPercent: number) => {
return (marginPx / 88 + gapPercent / 10) * 50;
};
@@ -131,7 +137,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
const progressValid = !!progressInfo;
const progressFraction =
progressValid && progressInfo?.total > 0
? ((progressInfo!.next ?? progressInfo!.current) + 1) / progressInfo!.total || 0
? (progressInfo!.current + 1) / progressInfo!.total || 0
: 0;
return (
@@ -35,7 +35,7 @@ const PageInfoView: React.FC<PageInfoProps> = ({
: ''
: pageinfo
? _(isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}', {
currentPage: (pageinfo.next ?? pageinfo.current) + 1,
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
})
: '';
@@ -77,6 +77,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}, []);
useEffect(() => {
if (bookKeys && bookKeys.length > 0) {
const settings = useSettingsStore.getState().settings;
settings.lastOpenBooks = bookKeys.map((key) => key.split('-')[0]!);
saveSettings(envConfig, settings);
}
if (isTauriAppPlatform()) tauriHandleOnCloseWindow(handleCloseBooks);
window.addEventListener('beforeunload', handleCloseBooks);
eventDispatcher.on('quit-app', handleCloseBooks);
@@ -22,7 +22,13 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import {
getPopupPosition,
getPosition,
getTextFromRange,
Position,
TextSelection,
} from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/utils/toc';
import { transformContent } from '@/services/transformService';
@@ -48,6 +54,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const bookData = getBookData(bookKey)!;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const primaryLang = bookData.book?.primaryLanguage || 'en';
const isShowingPopup = useRef(false);
const isTextSelected = useRef(false);
@@ -98,6 +105,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
transformers: ['punctuation'],
reversePunctuationTransform: true,
};
const getAnnotationText = (range: Range) => {
return getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []);
};
const makeSelection = async (sel: Selection, rebuildRange = false) => {
isTextSelected.current = true;
const range = sel.getRangeAt(0);
@@ -105,7 +115,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
sel.removeAllRanges();
sel.addRange(range);
}
transformCtx['content'] = sel.toString();
transformCtx['content'] = getAnnotationText(range);
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
};
// FIXME: extremely hacky way to dismiss system selection tools on iOS
@@ -117,7 +127,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setTimeout(async () => {
if (!isTextSelected.current) return;
sel.addRange(range);
transformCtx['content'] = range.toString();
transformCtx['content'] = getAnnotationText(range);
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
}, 40);
}, 0);
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React from 'react';
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -23,6 +24,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
onGetFontFamily,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const iconSize16 = useResponsiveSize(16);
const allOptions = [...options, ...(moreOptions ?? [])];
const selectedOption = allOptions.find((option) => option.option === selected) ?? allOptions[0]!;
@@ -88,7 +90,13 @@ const FontDropdown: React.FC<DropdownProps> = ({
<MdCheck className='text-base-content' size={iconSize16} />
)}
</span>
<span style={{ fontFamily: onGetFontFamily(option.option, family ?? '') }}>
<span
style={
!appService?.isLinuxApp
? { fontFamily: onGetFontFamily(option.option, family ?? '') }
: {}
}
>
{option.label || option.option}
</span>
</div>
@@ -12,6 +12,7 @@ import {
LINUX_FONTS,
MACOS_FONTS,
MONOSPACE_FONTS,
NON_FREE_FONTS,
SANS_SERIF_FONTS,
SERIF_FONTS,
WINDOWS_FONTS,
@@ -49,6 +50,10 @@ const handleFontFaceFont = (option: string, family: string) => {
return `'${option}', ${family}`;
};
const filterNonFreeFonts = (font: string) => {
return !['android', 'linux'].includes(getOSPlatform()) || !NON_FREE_FONTS.includes(font);
};
const FontFace = ({
className,
family,
@@ -137,12 +142,26 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
if (isTauriAppPlatform()) {
getSysFontsList().then((res) => {
if (res.error) {
if (res.error || Object.keys(res.fonts).length === 0) {
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)));
const processedFonts: string[] = [];
Object.entries(res.fonts).forEach(([fontName, fontFamily]) => {
if (!fontName || isSymbolicFontName(fontName)) return;
const fontsInFamily = Object.entries(res.fonts).filter(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([_, family]) => family === fontFamily,
);
if (fontsInFamily.length === 1) {
processedFonts.push(fontFamily);
} else {
processedFonts.push(fontName);
}
});
setSysFonts([...new Set(processedFonts)].sort((a, b) => a.localeCompare(b)));
});
}
}, []);
@@ -291,7 +310,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
className='config-item-top'
family='serif'
label={_('Serif Font')}
options={[...SERIF_FONTS, ...CJK_SERIF_FONTS]}
options={[...SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SERIF_FONTS]}
moreOptions={sysFonts}
selected={serifFont}
onSelect={setSerifFont}
@@ -299,7 +318,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<FontFace
family='sans-serif'
label={_('Sans-Serif Font')}
options={[...SANS_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]}
options={[...SANS_SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SANS_SERIF_FONTS]}
moreOptions={sysFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
@@ -33,6 +33,8 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [hyphenation, setHyphenation] = useState(viewSettings.hyphenation!);
const [marginPx, setMarginPx] = useState(viewSettings.marginPx!);
const [gapPercent, setGapPercent] = useState(viewSettings.gapPercent!);
const [compactMarginPx, setCompactMarginPx] = useState(viewSettings.compactMarginPx!);
const [compactGapPercent, setCompactGapPercent] = useState(viewSettings.compactGapPercent!);
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
@@ -87,6 +89,13 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marginPx]);
useEffect(() => {
if (compactMarginPx === viewSettings.compactMarginPx) return;
saveViewSettings(envConfig, bookKey, 'compactMarginPx', compactMarginPx, false, false);
view?.renderer.setAttribute('margin', `${compactMarginPx}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactMarginPx]);
useEffect(() => {
if (gapPercent === viewSettings.gapPercent) return;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
@@ -97,6 +106,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gapPercent]);
useEffect(() => {
if (compactGapPercent === viewSettings.compactGapPercent) return;
saveViewSettings(envConfig, bookKey, 'compactGapPercent', compactGapPercent, false, false);
view?.renderer.setAttribute('gap', `${compactGapPercent}%`);
if (viewSettings.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactGapPercent]);
useEffect(() => {
if (maxColumnCount === viewSettings.maxColumnCount) return;
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount, false, false);
@@ -179,6 +198,22 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [borderColor]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showBarsOnScroll', showBarsOnScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showBarsOnScroll]);
const applyMarginAndGap = () => {
const isCompact = !showHeader && !showFooter;
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent;
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
};
useEffect(() => {
if (showHeader === viewSettings.showHeader) return;
if (showHeader && !viewSettings.vertical) {
@@ -194,14 +229,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader, false, false);
applyMarginAndGap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showHeader]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showBarsOnScroll', showBarsOnScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showBarsOnScroll]);
useEffect(() => {
if (showFooter === viewSettings.showFooter) return;
if (showFooter && !viewSettings.vertical) {
@@ -217,6 +249,8 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter, false, false);
applyMarginAndGap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showFooter]);
@@ -399,16 +433,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='divide-base-200 divide-y'>
<NumberInput
label={_('Vertical Margins (px)')}
value={marginPx}
onChange={setMarginPx}
value={showFooter || showHeader ? marginPx : compactMarginPx}
onChange={showFooter || showHeader ? setMarginPx : setCompactMarginPx}
min={!viewSettings.vertical && (showFooter || showHeader) ? 44 : 0}
max={88}
step={4}
/>
<NumberInput
label={_('Horizontal Margins (%)')}
value={gapPercent}
onChange={setGapPercent}
value={showFooter || showHeader ? gapPercent : compactGapPercent}
onChange={showFooter || showHeader ? setGapPercent : setCompactGapPercent}
min={
viewSettings.vertical && (showFooter || showHeader)
? Math.ceil(4800 / window.innerWidth)
@@ -1,12 +1,15 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import i18n from 'i18next';
import { IoPhoneLandscapeOutline, IoPhonePortraitOutline } from 'react-icons/io5';
import { MdOutlineScreenRotation } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useDeviceControlStore } from '@/store/deviceStore';
import { getStyles } from '@/utils/style';
import { lockScreenOrientation } from '@/utils/bridge';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { TRANSLATED_LANGS } from '@/services/constants';
import cssbeautify from 'cssbeautify';
@@ -28,6 +31,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
const [error, setError] = useState<string | null>(null);
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
@@ -160,6 +164,14 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isContinuousScroll]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'screenOrientation', screenOrientation, false, false);
if (appService?.isMobileApp) {
lockScreenOrientation({ orientation: screenOrientation });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenOrientation]);
return (
<div
className={clsx(
@@ -200,6 +212,47 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
</div>
{appService?.isMobileApp && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Screen')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Orientation')}</span>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('auto')}
>
<MdOutlineScreenRotation />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Portrait')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'portrait' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('portrait')}
>
<IoPhonePortraitOutline />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Landscape')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'landscape' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('landscape')}
>
<IoPhoneLandscapeOutline />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { isCJKStr } from '@/utils/lang';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
@@ -31,6 +32,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { getConfig, saveConfig } = useBookDataStore();
const { getView, getProgress } = useReaderStore();
const [searchTerm, setSearchTerm] = useState(term);
@@ -38,7 +40,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
const view = getView(bookKey)!;
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
const progress = getProgress(bookKey)!;
const primaryLang = bookData.book?.primaryLanguage || 'en';
const searchConfig = config.searchConfig! as BookSearchConfig;
const queuedSearchTerm = useRef('');
@@ -104,8 +108,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
};
const exceedMinSearchTermLength = (searchTerm: string) => {
const isCJK = /[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/.test(searchTerm);
const minLength = isCJK ? MINIMUM_SEARCH_TERM_LENGTH_CJK : MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
const minLength = isCJKStr(searchTerm)
? MINIMUM_SEARCH_TERM_LENGTH_CJK
: MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
return searchTerm.length >= minLength;
};
@@ -118,12 +123,30 @@ const SearchBar: React.FC<SearchBarProps> = ({
}
};
const createAcceptNode = ({ withRT = true } = {}) => {
return (node: Node): number => {
if (node.nodeType === Node.ELEMENT_NODE) {
const name = (node as Element).tagName.toLowerCase();
if (name === 'script' || name === 'style' || (!withRT && name === 'rt')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_SKIP;
}
return NodeFilter.FILTER_ACCEPT;
};
};
const handleSearch = async (term: string) => {
console.log('searching for:', term);
isSearchPending.current = true;
const { section } = progress;
const index = searchConfig.scope === 'section' ? section.current : undefined;
const generator = await view.search({ ...searchConfig, query: term, index });
const generator = await view.search({
...searchConfig,
index,
query: term,
acceptNode: createAcceptNode({ withRT: !primaryLang.startsWith('ja') }),
});
const results: BookSearchResult[] = [];
for await (const result of generator) {
if (typeof result === 'string') {
@@ -32,7 +32,7 @@ const SearchResultItem: React.FC<SearchResultItemProps> = ({
>
<div className='line-clamp-3'>
<span className=''>{excerpt.pre}</span>
<span className='font-semibold'>{excerpt.match}</span>
<span className='font-bold text-red-500'>{excerpt.match}</span>
<span className=''>{excerpt.post}</span>
</div>
</li>
@@ -58,7 +58,7 @@ const TOCItemView: React.FC<{
}, [expandedItems, item.href]);
return (
<li className='w-full' style={{ paddingTop: '1px' }}>
<li className='border-base-300 w-full border-b sm:border-none sm:pt-[1px]'>
<span
role='treeitem'
tabIndex={-1}
@@ -67,17 +67,26 @@ const TOCItemView: React.FC<{
aria-expanded={isExpanded ? 'true' : 'false'}
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href ? getContentMd5(item.href) : undefined}
className={`flex w-full cursor-pointer items-center rounded-md py-2 ${
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'sm:hover:bg-base-300/85'
className={`flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2 ${
isActive
? 'sm:bg-base-300/85 sm:hover:bg-base-300 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/85'
}`}
>
{item.subitems && (
<span onClick={handleToggleExpand} className='inline-block cursor-pointer'>
<span
onClick={handleToggleExpand}
className='inline-block cursor-pointer'
style={{
padding: '12px',
margin: '-12px',
}}
>
{createExpanderIcon(isExpanded)}
</span>
)}
<span
className='ml-2 truncate text-ellipsis'
className='ms-2 truncate text-ellipsis'
style={{
maxWidth: 'calc(100% - 24px)',
whiteSpace: 'nowrap',
@@ -86,6 +95,11 @@ const TOCItemView: React.FC<{
>
{item.label}
</span>
{item.location && (
<span className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
{item.location.current + 1}
</span>
)}
</span>
{item.subitems && isExpanded && (
<ol role='group'>
@@ -109,7 +123,7 @@ const TOCView: React.FC<{
toc: TOCItem[];
}> = ({ bookKey, toc }) => {
const { getProgress } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { sideBarBookKey, isSideBarVisible } = useSidebarStore();
const progress = getProgress(bookKey);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
@@ -132,9 +146,6 @@ const TOCView: React.FC<{
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
if (currentHref) {
expandParents(toc, currentHref);
}
};
useEffect(() => {
@@ -151,17 +162,23 @@ const TOCView: React.FC<{
}
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewRef]);
}, [viewRef.current]);
useEffect(() => {
if (!progress || eventDispatcher.dispatchSync('tts-is-speaking')) return;
if (sideBarBookKey !== bookKey) return;
if (!isSideBarVisible) return;
const { sectionHref: currentHref } = progress;
if (currentHref) {
expandParents(toc, currentHref);
}
scrollToProgress(progress);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toc, progress, sideBarBookKey]);
}, [toc, progress, sideBarBookKey, isSideBarVisible]);
return (
<div className='rounded pt-2'>
<ul role='tree' ref={viewRef} className='px-2'>
<ul role='tree' ref={viewRef} className='pe-4 ps-2 sm:pe-2'>
{toc &&
toc.map((item, index) => (
<TOCItemView
@@ -153,7 +153,9 @@ const TTSControl = () => {
};
const handleTTSStop = async () => {
handleStop();
if (ttsControllerRef.current) {
handleStop();
}
};
const handleQueryIsSpeaking = () => {
@@ -232,7 +232,10 @@ const TTSPanel = ({
<MdFastForward size={iconSize32} />
</button>
<div className='dropdown dropdown-top'>
<button className='flex flex-col items-center justify-center rounded-full p-1'>
<button
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1'
>
<MdAlarm size={iconSize32} />
{timeoutCountdown && (
<span
@@ -1,16 +1,45 @@
import { useEffect } from 'react';
import { FoliateView } from '@/types/view';
import { useReaderStore } from '@/store/readerStore';
import { throttle } from '@/utils/throttle';
import { ScrollSource } from './usePagination';
export const useClickEvent = (bookKey: string, handlePageFlip: (msg: MessageEvent) => void) => {
export const useMouseEvent = (
bookKey: string,
handlePageFlip: (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => void,
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
) => {
const { hoveredBookKey } = useReaderStore();
const throttledScroll = throttle(handleContinuousScroll, 500, {
emitLast: false,
});
const handleMouseEvent = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (msg instanceof MessageEvent) {
if (msg.data && msg.data.bookKey === bookKey) {
if (msg.data.type === 'iframe-wheel') {
throttledScroll('mouse', -msg.data.deltaY, 0);
}
handlePageFlip(msg);
}
} else if (msg.type === 'wheel') {
const event = msg as React.WheelEvent<HTMLDivElement>;
throttledScroll('mouse', -event.deltaY, 0);
} else {
handlePageFlip(msg);
}
};
useEffect(() => {
window.addEventListener('message', handlePageFlip);
window.addEventListener('message', handleMouseEvent);
return () => {
window.removeEventListener('message', handlePageFlip);
window.removeEventListener('message', handleMouseEvent);
};
}, [bookKey, hoveredBookKey, handlePageFlip]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey, hoveredBookKey]);
return {
onClick: handlePageFlip,
onWheel: handleMouseEvent,
};
};
interface IframeTouch {
@@ -26,28 +55,28 @@ interface IframeTouchEvent {
export const useTouchEvent = (
bookKey: string,
viewRef: React.MutableRefObject<FoliateView | null>,
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
) => {
const { hoveredBookKey, setHoveredBookKey, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
let touchStart: IframeTouch | null = null;
let touchEnd: IframeTouch | null = null;
const onTouchStart = (e: IframeTouchEvent) => {
const onTouchStart = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
touchEnd = null;
const touch = e.targetTouches[0];
if (!touch) return;
touchStart = touch;
};
const onTouchMove = (e: IframeTouchEvent) => {
const onTouchMove = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
if (!touchStart) return;
const touch = e.targetTouches[0];
if (touch) {
touchEnd = touch;
}
if (hoveredBookKey && touchEnd) {
const viewSettings = getViewSettings(bookKey)!;
const deltaY = touchEnd.screenY - touchStart.screenY;
const deltaX = touchEnd.screenX - touchStart.screenX;
if (!viewSettings!.scrolled && !viewSettings!.vertical) {
@@ -60,7 +89,7 @@ export const useTouchEvent = (
}
};
const onTouchEnd = (e: IframeTouchEvent) => {
const onTouchEnd = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
if (!touchStart) return;
const touch = e.targetTouches[0];
@@ -70,6 +99,7 @@ export const useTouchEvent = (
const windowWidth = window.innerWidth;
if (touchEnd) {
const viewSettings = getViewSettings(bookKey)!;
const deltaY = touchEnd.screenY - touchStart.screenY;
const deltaX = touchEnd.screenX - touchStart.screenX;
// also check for deltaX to prevent swipe page turn from triggering the toggle
@@ -87,6 +117,7 @@ export const useTouchEvent = (
setHoveredBookKey(null);
}
}
handleContinuousScroll('touch', deltaY, 30);
}
touchStart = null;
@@ -111,5 +142,11 @@ export const useTouchEvent = (
window.removeEventListener('message', handleTouch);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef.current]);
}, [hoveredBookKey]);
return {
onTouchStart,
onTouchMove,
onTouchEnd,
};
};
@@ -7,7 +7,9 @@ import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { tauriGetWindowLogicalPosition } from '@/utils/window';
export const usePageFlip = (
export type ScrollSource = 'touch' | 'mouse';
export const usePagination = (
bookKey: string,
viewRef: React.MutableRefObject<FoliateView | null>,
containerRef: React.RefObject<HTMLDivElement>,
@@ -16,6 +18,7 @@ export const usePageFlip = (
const { getViewSettings } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const handlePageFlip = async (
msg: MessageEvent | CustomEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
@@ -103,14 +106,51 @@ export const usePageFlip = (
}
}
} 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();
if (msg.type === 'click') {
const { clientX } = msg;
const width = window.innerWidth;
const leftThreshold = width * 0.5;
const rightThreshold = width * 0.5;
if (clientX < leftThreshold) {
viewRef.current?.goLeft();
} else if (clientX > rightThreshold) {
viewRef.current?.goRight();
}
}
}
};
const handleContinuousScroll = (mode: ScrollSource, scrollDelta: number, threshold: number) => {
const renderer = viewRef.current?.renderer;
const viewSettings = getViewSettings(bookKey)!;
if (renderer && viewSettings.scrolled && viewSettings.continuousScroll) {
const doScroll = () => {
// may have overscroll where the start is greater than 0
if (renderer.start <= scrollDelta && scrollDelta > threshold) {
setTimeout(() => {
viewRef.current?.prev(renderer.start + 1);
}, 100);
// sometimes viewSize has subpixel value that the end never reaches
} else if (
Math.ceil(renderer.end) - scrollDelta >= renderer.viewSize &&
scrollDelta < -threshold
) {
setTimeout(() => {
viewRef.current?.next(renderer.viewSize - Math.floor(renderer.end) + 1);
}, 100);
}
};
if (mode === 'mouse') {
// we can always get mouse wheel events
doScroll();
} else if (mode === 'touch') {
// when the document height is less than the viewport height, we can't get the relocate event
if (renderer.size >= renderer.viewSize) {
doScroll();
} else {
// scroll after the relocate event
renderer.addEventListener('relocate', () => doScroll(), { once: true });
}
}
}
};
@@ -135,5 +175,6 @@ export const usePageFlip = (
return {
handlePageFlip,
handleContinuousScroll,
};
};
+7 -9
View File
@@ -2,7 +2,6 @@
import clsx from 'clsx';
import React, { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { IoArrowBack } from 'react-icons/io5';
import { PiUserCircle } from 'react-icons/pi';
@@ -20,6 +19,7 @@ import { eventDispatcher } from '@/utils/event';
import { Toast } from '@/components/Toast';
import WindowButtons from '@/components/WindowButtons';
import Quota from '@/components/Quota';
import UserAvatar from '@/components/UserAvatar';
const ProfilePage = () => {
const _ = useTranslation();
@@ -191,14 +191,12 @@ const ProfilePage = () => {
<div className='mb-8 flex flex-col items-center gap-x-6 gap-y-4 md:flex-row md:items-start'>
<div className='flex-shrink-0'>
{avatarUrl ? (
<Image
src={avatarUrl}
alt={_('User avatar')}
className='border-base-100 h-16 w-16 rounded-full border-4 md:h-24 md:w-24'
referrerPolicy='no-referrer'
width={128}
height={128}
priority
<UserAvatar
url={avatarUrl}
size={128}
DefaultIcon={PiUserCircle}
className='h-16 w-16 md:h-24 md:w-24'
borderClassName='border-base-100 border-4'
/>
) : (
<PiUserCircle className='h-16 w-16 md:h-24 md:w-24' />
@@ -12,7 +12,7 @@ interface BookCoverProps {
const BookCover: React.FC<BookCoverProps> = ({ mode = 'grid', book, isPreview }) => {
return (
<div className='relative flex h-full w-full'>
<div className={clsx('relative flex h-full w-full')}>
<Image
src={book.coverImageUrl!}
alt={book.title}
@@ -1,6 +1,5 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { MdDelete, MdCloudDownload, MdCloudUpload } from 'react-icons/md';
import { Book } from '@/types/book';
@@ -17,8 +16,9 @@ import {
formatTitle,
} from '@/utils/book';
import Alert from '@/components/Alert';
import Spinner from './Spinner';
import Dialog from './Dialog';
import Spinner from './Spinner';
import BookCover from './BookCover';
interface BookDetailModalProps {
book: Book;
@@ -113,29 +113,9 @@ const BookDetailModal = ({
<div className='flex w-full select-text items-center justify-center'>
<div className='relative w-full rounded-lg'>
<div className='mb-6 me-4 flex h-32 items-start'>
<div className='book-cover relative mr-10 aspect-[28/41] h-32 items-end shadow-lg'>
<Image
src={book.coverImageUrl!}
alt={formatTitle(book.title)}
fill={true}
className='w-10 object-cover'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove(
'invisible',
);
}}
/>
<div
className={clsx(
'invisible absolute inset-0 flex items-center justify-center p-1',
'text-neutral-content rounded-none text-center font-serif text-base font-medium',
)}
>
{formatTitle(book.title)}
</div>
<div className='me-10 aspect-[28/41] h-32 shadow-lg'>
<BookCover mode='list' book={book} />
</div>
<div className='title-author flex h-32 flex-col justify-between'>
<div>
<p className='text-base-content mb-2 line-clamp-2 break-all text-lg font-bold'>
+4 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import clsx from 'clsx';
import React from 'react';
import { useEnv } from '@/context/EnvContext';
interface ButtonProps {
icon: React.ReactNode;
@@ -18,6 +19,7 @@ const Button: React.FC<ButtonProps> = ({
tooltipDirection = 'top',
className,
}) => {
const { appService } = useEnv();
return (
<div
className={clsx(
@@ -32,6 +34,7 @@ const Button: React.FC<ButtonProps> = ({
<button
className={clsx(
'btn btn-ghost h-8 min-h-8 w-8 p-0',
appService?.isMobileApp && 'hover:bg-transparent',
disabled && 'btn-disabled !bg-transparent',
className,
)}
+28 -18
View File
@@ -3,6 +3,7 @@ import React, { ReactNode, useEffect, useState } from 'react';
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { useThemeStore } from '@/store/themeStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
@@ -40,8 +41,9 @@ const Dialog: React.FC<DialogProps> = ({
onClose,
}) => {
const { appService } = useEnv();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [isFullHeightInMobile, setIsFullHeightInMobile] = React.useState(!snapHeight);
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(!snapHeight);
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640;
@@ -60,6 +62,7 @@ const Dialog: React.FC<DialogProps> = ({
useEffect(() => {
if (!isOpen) return;
setIsFullHeightInMobile(!snapHeight);
window.addEventListener('keydown', handleKeyDown);
if (appService?.isAndroidApp) {
acquireBackKeyInterception();
@@ -105,41 +108,39 @@ const Dialog: React.FC<DialogProps> = ({
data.velocity > VELOCITY_THRESHOLD ||
(data.velocity >= 0 && data.clientY >= window.innerHeight * snapLower)
) {
// dialog is dismissed
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
modal.style.height = '100%';
modal.style.transition = `transform ${transitionDuration}s ease-out`;
modal.style.transform = 'translateY(100%)';
overlay.style.transition = `opacity ${transitionDuration}s ease-out`;
overlay.style.opacity = '0';
onClose();
setTimeout(() => {
onClose();
modal.style.transform = 'translateY(0%)';
}, 300);
if (appService?.hasHaptics) {
impactFeedback('medium');
}
} else if (
snapHeight &&
data.clientY > window.innerHeight * snapUpper &&
data.clientY < window.innerHeight * snapLower
) {
// dialog is snapped
overlay.style.transition = `opacity 0.3s ease-out`;
overlay.style.opacity = `${1 - snapHeight}`;
modal.style.height = `${snapHeight * 100}%`;
modal.style.bottom = '0';
modal.style.transition = `transform 0.3s ease-out`;
modal.style.transform = `translateY(${(1 - snapHeight) * window.innerHeight}px)`;
setTimeout(() => {
modal.style.height = `${snapHeight * 100}%`;
}, 100);
if (appService?.hasHaptics) {
impactFeedback('medium');
}
modal.style.transform = '';
} else {
// dialog is opened without snap
setIsFullHeightInMobile(true);
modal.style.height = '100%';
modal.style.transition = `transform 0.3s ease-out`;
modal.style.transform = `translateY(0%)`;
overlay.style.opacity = '0';
if (appService?.hasHaptics) {
impactFeedback('medium');
}
}
if (appService?.hasHaptics) {
impactFeedback('medium');
}
};
@@ -161,7 +162,7 @@ const Dialog: React.FC<DialogProps> = ({
/>
<div
className={clsx(
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
'modal-box settings-content absolute z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
'h-full max-h-full w-full max-w-full',
window.innerWidth < window.innerHeight
? 'sm:h-[50%] sm:w-3/4'
@@ -175,14 +176,23 @@ const Dialog: React.FC<DialogProps> = ({
snapHeight
? {
height: `${snapHeight * 100}%`,
transform: `translateY(${(1 - snapHeight) * window.innerHeight}px)`,
bottom: 0,
}
: {}
}
>
{window.innerWidth < 640 && (
<div
className='drag-handle flex h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center'
className={clsx(
'drag-handle flex h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center',
'transition-padding-top duration-300 ease-out',
)}
style={{
paddingTop:
appService?.isAndroidApp && systemUIVisible && isFullHeightInMobile
? statusBarHeight
: 0,
}}
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
>
@@ -0,0 +1,50 @@
import clsx from 'clsx';
import Image from 'next/image';
import { IconType } from 'react-icons';
interface UserAvatarProps {
url: string;
size: number;
DefaultIcon: IconType;
className?: string;
borderClassName?: string;
}
const UserAvatar: React.FC<UserAvatarProps> = ({
url,
size,
className,
borderClassName,
DefaultIcon,
}) => {
return (
<div
className='relative flex h-full w-full items-center justify-center rounded-full'
style={{ width: size, height: size }}
>
{url ? (
<div>
<Image
src={url}
alt='User Avatar'
className={clsx('rounded-full', className, borderClassName)}
referrerPolicy='no-referrer'
width={size}
height={size}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div className='invisible absolute inset-0 flex items-center justify-center'>
<DefaultIcon className={clsx('text-neutral-content', className)} />
</div>
</div>
) : (
<DefaultIcon className='text-neutral-content' size={size} />
)}
</div>
);
};
export default UserAvatar;
-1
View File
@@ -20,7 +20,6 @@ export const useDrag = (
const handleDragStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
isDragging.current = true;
if ('touches' in e) {
+13 -1
View File
@@ -16,11 +16,23 @@ export const useTheme = ({
}: UseThemeProps = {}) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { themeColor, isDarkMode, updateAppTheme, setStatusBarHeight } = useThemeStore();
const {
themeColor,
isDarkMode,
showSystemUI,
dismissSystemUI,
updateAppTheme,
setStatusBarHeight,
} = useThemeStore();
useEffect(() => {
updateAppTheme(appThemeColor);
if (appService?.isMobile) {
if (systemUIVisible) {
showSystemUI();
} else {
dismissSystemUI();
}
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
}
if (appService?.isAndroidApp) {
+9
View File
@@ -36,11 +36,18 @@ export const CFI = epubcfi;
export type DocumentFile = File;
export type Location = {
current: number;
next: number;
total: number;
};
export interface TOCItem {
id: number;
label: string;
href: string;
cfi?: string;
location?: Location;
subitems?: TOCItem[];
}
@@ -48,6 +55,8 @@ export interface SectionItem {
id: string;
cfi: string;
size: number;
linear: string;
location?: Location;
}
export interface BookDoc {
@@ -33,6 +33,7 @@ import {
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
DEFAULT_MOBILE_READSETTINGS,
DEFAULT_SCREEN_CONFIG,
} from './constants';
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
@@ -48,6 +49,7 @@ export abstract class BaseAppService implements AppService {
localBooksDir = '';
isMobile = false;
isMacOSApp = false;
isLinuxApp = false;
isAppDataSandbox = false;
isAndroidApp = false;
isIOSApp = false;
@@ -94,6 +96,7 @@ export abstract class BaseAppService implements AppService {
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_SCREEN_CONFIG,
...settings.globalViewSettings,
};
} catch {
@@ -113,6 +116,7 @@ export abstract class BaseAppService implements AppService {
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_SCREEN_CONFIG,
},
} as SystemSettings;
@@ -410,6 +414,20 @@ export abstract class BaseAppService implements AppService {
}
}
async isBookAvailable(book: Book): Promise<boolean> {
const fp = getLocalBookFilename(book);
if (await this.fs.exists(fp, 'Books')) {
return true;
}
if (book.filePath) {
return await this.fs.exists(book.filePath, 'None');
}
if (book.url) {
return isValidURL(book.url);
}
return false;
}
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
let file: File;
const fp = getLocalBookFilename(book);
@@ -4,6 +4,7 @@ import {
BookSearchConfig,
BookStyle,
HighlightColor,
ScreenConfig,
TTSConfig,
ViewConfig,
ViewSettings,
@@ -37,6 +38,8 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
alwaysOnTop: false,
autoCheckUpdates: true,
screenWakeLock: true,
openLastBooks: false,
lastOpenBooks: [],
autoImportBooksOnOpen: false,
libraryViewMode: 'grid',
librarySortBy: 'updated',
@@ -83,6 +86,8 @@ export const DEFAULT_BOOK_FONT: BookFont = {
export const DEFAULT_BOOK_LAYOUT: BookLayout = {
marginPx: 44,
gapPercent: 5,
compactMarginPx: 0,
compactGapPercent: 0,
scrolled: false,
disableClick: false,
swapClickArea: false,
@@ -139,6 +144,10 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsVoice: '',
};
export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
screenOrientation: 'auto',
};
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
scope: 'book',
matchCase: false,
@@ -157,6 +166,8 @@ export const SERIF_FONTS = [
'Times New Roman',
];
export const NON_FREE_FONTS = ['Georgia', 'Times New Roman'];
export const CJK_SERIF_FONTS = [
_('LXGW WenKai GB Screen'),
_('LXGW WenKai TC'),
@@ -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 isLinuxApp = OS_TYPE === 'linux';
override isMobileApp = ['android', 'ios'].includes(OS_TYPE);
override hasTrafficLight = OS_TYPE === 'macos';
override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
@@ -2,6 +2,7 @@ import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { AsyncQueue } from '@/utils/queue';
import { findSSMLMark, parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { isCJKStr } from '@/utils/lang';
import { TTSGranularity } from '@/types/view';
import { TTSUtils } from './TTSUtils';
@@ -123,7 +124,7 @@ async function* speakWithMarks(
const isCJK = (lang: string | null) => {
const cjkLangs = ['zh', 'ja', 'kr'];
if (lang && cjkLangs.some((cjk) => lang.startsWith(cjk))) return true;
return /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(plainText);
return isCJKStr(plainText);
};
if (!isCJK(lang)) {
+6 -2
View File
@@ -1,21 +1,25 @@
import { create } from 'zustand';
import { Book } from '@/types/book';
import { EnvConfigType } from '@/services/environment';
import { EnvConfigType, isTauriAppPlatform } from '@/services/environment';
interface LibraryState {
library: Book[]; // might contain deleted books
checkOpenWithBooks: boolean;
checkLastOpenBooks: boolean;
getVisibleLibrary: () => Book[];
setCheckOpenWithBooks: (check: boolean) => void;
setCheckLastOpenBooks: (check: boolean) => void;
setLibrary: (books: Book[]) => void;
updateBook: (envConfig: EnvConfigType, book: Book) => void;
}
export const useLibraryStore = create<LibraryState>((set, get) => ({
library: [],
checkOpenWithBooks: true,
checkOpenWithBooks: isTauriAppPlatform(),
checkLastOpenBooks: isTauriAppPlatform(),
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
setCheckOpenWithBooks: (check) => set({ checkOpenWithBooks: check }),
setCheckLastOpenBooks: (check) => set({ checkLastOpenBooks: check }),
setLibrary: (books) => set({ library: books }),
updateBook: async (envConfig: EnvConfigType, book: Book) => {
const appService = await envConfig.getAppService();
+3 -8
View File
@@ -3,8 +3,8 @@ import { create } from 'zustand';
import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book';
import { EnvConfigType } from '@/services/environment';
import { FoliateView } from '@/types/view';
import { BookDoc, DocumentLoader, SectionItem, TOCItem } from '@/libs/document';
import { updateTocCFI, updateTocID } from '@/utils/toc';
import { BookDoc, DocumentLoader, TOCItem } from '@/libs/document';
import { updateToc } from '@/utils/toc';
import { useSettingsStore } from './settingsStore';
import { useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
@@ -122,12 +122,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
const bookDoc = loadedBookDoc as BookDoc;
if (bookDoc.toc?.length && bookDoc.sections?.length) {
updateTocID(bookDoc.toc);
const sections = bookDoc.sections.reduce((map: Record<string, SectionItem>, section) => {
map[section.id] = section;
return map;
}, {});
updateTocCFI(bookDoc, bookDoc.toc, sections);
updateToc(bookDoc, bookDoc.toc, bookDoc.sections);
}
// Set the book's language for formerly imported books, newly imported books have this field set
book.primaryLanguage =
+14 -1
View File
@@ -68,6 +68,8 @@ export type WritingMode = 'auto' | 'horizontal-tb' | 'horizontal-rl' | 'vertical
export interface BookLayout {
marginPx: number;
gapPercent: number;
compactMarginPx: number;
compactGapPercent: number;
scrolled: boolean;
disableClick: boolean;
swapClickArea: boolean;
@@ -124,7 +126,17 @@ export interface TTSConfig {
ttsVoice: string;
}
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig, TTSConfig {}
export interface ScreenConfig {
screenOrientation: 'auto' | 'portrait' | 'landscape';
}
export interface ViewSettings
extends BookLayout,
BookStyle,
BookFont,
ViewConfig,
TTSConfig,
ScreenConfig {}
export interface BookProgress {
location: string;
@@ -143,6 +155,7 @@ export interface BookSearchConfig {
matchDiacritics: boolean;
index?: number;
query?: string;
acceptNode?: (node: Node) => number;
}
export interface SearchExcerpt {
+2
View File
@@ -27,6 +27,8 @@ export interface SystemSettings {
alwaysOnTop: boolean;
autoCheckUpdates: boolean;
screenWakeLock: boolean;
openLastBooks: boolean;
lastOpenBooks: string[];
autoImportBooksOnOpen: boolean;
libraryViewMode: LibraryViewModeType;
librarySortBy: LibrarySortByType;
+2
View File
@@ -40,6 +40,7 @@ export interface AppService {
isAndroidApp: boolean;
isIOSApp: boolean;
isMacOSApp: boolean;
isLinuxApp: boolean;
selectDirectory(): Promise<string>;
selectFiles(name: string, extensions: string[]): Promise<string[]>;
@@ -56,6 +57,7 @@ export interface AppService {
deleteBook(book: Book, includingUploaded?: boolean): Promise<void>;
uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void>;
downloadBook(book: Book, onlyCover?: boolean, onProgress?: ProgressHandler): Promise<void>;
isBookAvailable(book: Book): Promise<boolean>;
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookDoc['metadata']>;
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
+5 -1
View File
@@ -48,7 +48,11 @@ export interface FoliateView extends HTMLElement {
goTo?: (params: { index: number; anchor: number }) => void;
setStyles?: (css: string) => void;
getContents: () => { doc: Document; index?: number }[];
addEventListener: (type: string, listener: EventListener) => void;
addEventListener: (
type: string,
listener: EventListener,
option?: AddEventListenerOptions,
) => void;
removeEventListener: (type: string, listener: EventListener) => void;
};
}
+11 -1
View File
@@ -39,7 +39,7 @@ export interface GetStatusBarHeightResponse {
}
export interface GetSystemFontsListResponse {
fonts: string[];
fonts: Record<string, string>; // { fontName: fontFamily }
error?: string;
}
@@ -48,6 +48,10 @@ export interface InterceptKeysRequest {
backKey?: boolean;
}
export interface LockScreenRequest {
orientation: 'portrait' | 'landscape' | 'auto';
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
@@ -108,3 +112,9 @@ export async function interceptKeys(request: InterceptKeysRequest): Promise<void
payload: request,
});
}
export async function lockScreenOrientation(request: LockScreenRequest): Promise<void> {
await invoke('plugin:native-bridge|lock_screen_orientation', {
payload: request,
});
}
+3
View File
@@ -0,0 +1,3 @@
export const isCJKStr = (str: string) => {
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(str ?? '');
};
+23
View File
@@ -159,3 +159,26 @@ export const getPopupPosition = (
return { point: popupPoint, dir: position.dir } as Position;
};
export const getTextFromRange = (range: Range, rejects: string[] = []): string => {
const clonedRange = range.cloneRange();
const fragment = clonedRange.cloneContents();
const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = node.parentElement;
if (rejects.includes(parent?.tagName.toLowerCase() || '')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
let text = '';
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
text += node.nodeValue ?? '';
}
return text;
};
+17 -7
View File
@@ -57,14 +57,24 @@ export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
};
export const parseSSMLLang = (ssml: string): string | null => {
let lang = null;
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
if (/[\p{Script=Han}]/u.test(ssml)) {
return 'zh';
} else if (match && match[1]) {
if (match && match[1]) {
const parts = match[1].split('-');
return parts.length > 1
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
: parts[0]!.toLowerCase();
lang =
parts.length > 1
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
: parts[0]!.toLowerCase();
}
return null;
if (!lang || lang.startsWith('en')) {
if (/[\p{Script=Hangul}]/u.test(ssml)) {
lang = 'ko';
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(ssml)) {
lang = 'ja';
} else if (/[\p{Script=Han}]/u.test(ssml)) {
lang = 'zh';
}
}
return lang;
};
+27 -9
View File
@@ -87,13 +87,13 @@ const getFontStyles = (
body * {
${overrideFont ? 'font-family: revert !important;' : ''}
}
a:any-link {
a:any-link, a:any-link * {
${overrideFont ? `color: ${primary};` : ''}
}
/* https://github.com/whatwg/html/issues/5426 */
@media (prefers-color-scheme: dark) {
a:link {
${overrideFont ? `color: lightblue;` : ''}
a:any-link, a:any-link * {
${overrideFont ? `color: ${primary};` : `color: lightblue;`}
}
}
/* override inline hardcoded text color */
@@ -111,6 +111,7 @@ const getAdditionalFontLinks = () => `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous">
<link rel='stylesheet' href='https://fontsapi.zeoseven.com/431/main/result.css' crossorigin="anonymous"/>
<link rel='stylesheet' href="https://fonts.googleapis.com/css2?family=Vollkorn&display=swap" crossorigin="anonymous">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=LXGW+WenKai+TC&display=swap" crossorigin="anonymous">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC&family=Noto+Sans+TC&display=swap" crossorigin="anonymous">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP&display=swap" crossorigin="anonymous">
@@ -209,10 +210,12 @@ const getLayoutStyles = (
${writingMode === 'auto' ? '' : `writing-mode: ${writingMode} !important;`}
text-align: var(--default-text-align);
max-height: unset;
}
html {
background-color: var(--theme-bg-color, transparent);
background: var(--background-set, none);
}
body *:not(a):not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(img):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
body.pbg, body *:not(a):not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(img):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
${bg === '#ffffff' ? '' : `background-color: ${bg} !important;`}
}
body {
@@ -222,11 +225,11 @@ const getLayoutStyles = (
svg, img {
background-color: transparent !important;
}
p, li, blockquote, dd {
p, li, blockquote, dd {
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em;
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em ${overrideLayout ? '!important' : ''};
text-align: ${overrideLayout ? 'var(--default-text-align)' : 'inherit'};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
hyphens: ${hyphenate ? 'auto' : 'manual'};
@@ -242,7 +245,7 @@ const getLayoutStyles = (
${!vertical ? `margin-top: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-bottom: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
}
li, p:has(> :is(img, video, font, h1, h2, h3, h4, h5, table)) {
li, p:has(> :is(img, video, font, b, h1, h2, h3, h4, h5, table)) {
text-indent: 0 !important;
}
/* prevent the above from overriding the align attribute */
@@ -261,6 +264,11 @@ const getLayoutStyles = (
display: none;
}
img {
height: auto;
width: auto;
}
img.pi {
${vertical ? 'transform: rotate(90deg);' : ''}
${vertical ? 'transform-origin: center;' : ''}
@@ -273,12 +281,12 @@ const getLayoutStyles = (
display: none;
}
/* Now begins really dirty hacks to fix some badly designed epubs */
.duokan-footnote-content,
.duokan-footnote-item {
display: none;
}
/* Now begins really dirty hacks to fix some badly designed epubs */
.calibre {
color: unset;
}
@@ -419,10 +427,18 @@ export const mountAdditionalFonts = (document: Document) => {
document.head.appendChild(style);
};
export const transformStylesheet = (css: string) => {
export const transformStylesheet = (
viewSettings: ViewSettings,
width: number,
height: number,
css: string,
) => {
const isMobile = ['ios', 'android'].includes(getOSPlatform());
const fontScale = isMobile ? 1.25 : 1;
const w = width * (1 - viewSettings.gapPercent / 100);
const h = height - viewSettings.marginPx * 2;
// replace absolute font sizes with rem units
// replace vw and vh as they cause problems with layout
// replace hardcoded colors
return css
.replace(/font-size\s*:\s*xx-small/gi, 'font-size: 0.6rem')
@@ -441,6 +457,8 @@ export const transformStylesheet = (css: string) => {
const rem = parseFloat(pt) / fontScale / 12;
return `font-size: ${rem}rem`;
})
.replace(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * w) / 100 + 'px')
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * h) / 100 + 'px')
.replace(/[\s;]color\s*:\s*#000000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*#000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)');
+6 -1
View File
@@ -1,6 +1,11 @@
export interface ThrottleOptions {
emitLast?: boolean;
}
export const throttle = <T extends (...args: Parameters<T>) => void | Promise<void>>(
func: T,
delay: number,
options: ThrottleOptions = { emitLast: true },
): ((...args: Parameters<T>) => void) => {
let lastCall = 0;
let timeout: ReturnType<typeof setTimeout> | null = null;
@@ -27,7 +32,7 @@ export const throttle = <T extends (...args: Parameters<T>) => void | Promise<vo
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
if (lastArgs) {
if (lastArgs && options.emitLast) {
func(...(lastArgs as Parameters<T>));
lastArgs = null;
}
+29 -10
View File
@@ -37,31 +37,50 @@ export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
return result;
};
export const updateTocID = (items: TOCItem[], index = 0): number => {
items.forEach((item) => {
item.id ??= index++;
if (item.subitems) {
index = updateTocID(item.subitems, index);
}
export const updateToc = (bookDoc: BookDoc, items: TOCItem[], sections: SectionItem[]): void => {
const sizes = sections.map((s) => (s.linear != 'no' && s.size > 0 ? s.size : 0));
let cumulativeSize = 0;
const cumulativeSizes = sizes.reduce((acc: number[], size) => {
acc.push(cumulativeSize);
cumulativeSize += size;
return acc;
}, []);
const totalSize = cumulativeSizes[cumulativeSizes.length - 1] || 0;
const sizePerLoc = 1500;
sections.forEach((section, index) => {
section.location = {
current: Math.floor(cumulativeSizes[index]! / sizePerLoc),
next: Math.floor((cumulativeSizes[index]! + sizes[index]!) / sizePerLoc),
total: Math.floor(totalSize / sizePerLoc),
};
});
return index;
const sectionsMap = sections.reduce((map: Record<string, SectionItem>, section) => {
map[section.id] = section;
return map;
}, {});
updateTocData(bookDoc, items, sectionsMap);
};
export const updateTocCFI = (
const updateTocData = (
bookDoc: BookDoc,
items: TOCItem[],
sections: { [id: string]: SectionItem },
): void => {
index = 0,
): number => {
items.forEach((item) => {
item.id ??= index++;
if (item.href) {
const id = bookDoc.splitTOCHref(item.href)[0]!;
const section = sections[id];
if (section) {
item.cfi = section.cfi;
item.location = section.location;
}
}
if (item.subitems) {
updateTocCFI(bookDoc, item.subitems, sections);
index = updateTocData(bookDoc, item.subitems, sections, index);
}
});
return index;
};
+50 -12
View File
@@ -35,6 +35,16 @@ const zipWriteOptions = {
lastModDate: new Date(0),
};
const escapeXml = (str: string) => {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
export class TxtToEpubConverter {
public async convert(options: Txt2EpubOptions): Promise<ConversionResult> {
const { file: txtFile, author: providedAuthor, language: providedLanguage } = options;
@@ -85,25 +95,53 @@ export class TxtToEpubConverter {
): Chapter[] {
const { language } = metadata;
const { linesBetweenSegments } = option;
const segmentRegex = new RegExp(`(?:\\r?\\n){${linesBetweenSegments},}|-{4,}\r?\n`);
const segmentRegex = new RegExp(`(?:\\r?\\n){${linesBetweenSegments},}|-{8,}\r?\n`);
let chapterRegex: RegExp;
if (language === 'zh') {
chapterRegex =
/(?:^|\n|\s|《[^》]+》)(第?[一二三四五六七八九十百千万0-9]+[章卷节回讲篇](?:[:、  \(\)0-9]+[^\n-]*)?(?!\S)|(?:^|\n|\s|《[^》]+》)[一二三四五六七八九十百千万]+(?:[:、  ][^\n-]+)(?!\S)|(?:楔子|前言|引言|序言|序章|总论|概论)(?:[:  ][^\n-]*)?(?!\S))/g;
chapterRegex = new RegExp(
String.raw`(?:^|\n|\s)` +
'(' +
[
String.raw`第[零〇一二三四五六七八九十0-9][零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封])(?:[:、  \(\)0-9]*[^\n-]{0,24})(?!\S)`,
String.raw`(?:^|\n|\s|《[^》]+》)[一二三四五六七八九十][零〇一二三四五六七八九十百千万]*(?:[::  ][^\n-]{0,24})(?!\S)`,
String.raw`(?:楔子|前言|引言|序言|序章|总论|概论)(?:[:  ][^\n-]{0,24})?(?!\S)`,
].join('|') +
')',
'gu',
);
} else {
chapterRegex =
/(?:^|\n|\s)(Chapter [0-9]+(?:[: ][^\n]*)?(?!\S)|Part [0-9]+(?:[: ][^\n]*)?(?!\S)|Prologue(?:[: ][^\n]*)?(?!\S)|Introduction(?:[: ][^\n]*)?(?!\S))/g;
}
const formatSegment = (segment: string): string => {
segment = escapeXml(segment);
return segment
.replace(/-{4,}|_{4,}/g, '\n')
.replace(/-{8,}|_{8,}/g, '\n')
.split(/\n+/)
.map((line) => line.trim())
.filter((line) => line)
.join('</p><p>');
};
const joinAroundUndefined = (arr: (string | undefined)[]) =>
arr.reduce<string[]>((acc, curr, i, src) => {
if (
curr === undefined &&
i > 0 &&
i < src.length - 1 &&
src[i - 1] !== undefined &&
src[i + 1] !== undefined
) {
acc[acc.length - 1] += src[i + 1]!;
return acc;
}
if (curr !== undefined && (i === 0 || src[i - 1] !== undefined)) {
acc.push(curr);
}
return acc;
}, []);
const chapters: Chapter[] = [];
const segments = txtContent.split(segmentRegex);
for (const segment of segments) {
@@ -111,14 +149,14 @@ export class TxtToEpubConverter {
if (!trimmedSegment) continue;
const segmentChapters = [];
const matches = trimmedSegment.split(chapterRegex);
const matches = joinAroundUndefined(trimmedSegment.split(chapterRegex));
for (let j = 1; j < matches.length; j += 2) {
const title = matches[j]?.trim() || '';
const content = matches[j + 1]?.trim() || '';
let isVolume = false;
if (language === 'zh') {
isVolume = /第[一二三四五六七八九十百千万0-9]+卷/.test(title);
isVolume = /第[零〇一二三四五六七八九十百千万0-9]+卷/.test(title);
} else {
isVolume = /\b(Part|Volume|Book)\b/i.test(title);
}
@@ -126,7 +164,7 @@ export class TxtToEpubConverter {
const headTitle = isVolume ? `<h1>${title}</h1>` : `<h2>${title}</h2>`;
const formattedSegment = formatSegment(content);
segmentChapters.push({
title,
title: escapeXml(title),
content: `${headTitle}<p>${formattedSegment}</p>`,
});
}
@@ -139,7 +177,7 @@ export class TxtToEpubConverter {
initialContent.slice(0, 16);
const formattedSegment = formatSegment(initialContent);
segmentChapters.unshift({
title: segmentTitle,
title: escapeXml(segmentTitle),
content: `<h3></h3><p>${formattedSegment}</p>`,
});
}
@@ -194,10 +232,10 @@ export class TxtToEpubConverter {
<meta name="dtb:maxPageNumber" content="0" />
</head>
<docTitle>
<text>${bookTitle}</text>
<text>${escapeXml(bookTitle)}</text>
</docTitle>
<docAuthor>
<text>${author}</text>
<text>${escapeXml(author)}</text>
</docAuthor>
<navMap>
${navPoints}
@@ -258,9 +296,9 @@ export class TxtToEpubConverter {
const contentOpf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="book-id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>${bookTitle}</dc:title>
<dc:title>${escapeXml(bookTitle)}</dc:title>
<dc:language>${language}</dc:language>
<dc:creator>${author}</dc:creator>
<dc:creator>${escapeXml(author)}</dc:creator>
<dc:identifier id="book-id">${identifier}</dc:identifier>
</metadata>
<manifest>