Compare commits

..

36 Commits

Author SHA1 Message Date
Huang Xin f815844577 release: version 0.9.60 (#1452) 2025-06-22 11:24:47 +02:00
Huang Xin 7fe4d38c51 fix: allow inline scripts on Tauri platforms (#1451) 2025-06-22 11:22:39 +02:00
Huang Xin 43edcb4e9e android: dismiss the splash screen and change icon background color in task list (#1450)
* layout: various layout fix on iPad

* android: dismiss the splash screen and change icon background color in task list, closes #1424
2025-06-22 09:10:03 +02:00
Huang Xin e259b29afa fix: disable translation when the primary language is not defined, closes #1443 (#1448) 2025-06-22 07:10:22 +02:00
Huang Xin cd5f57a004 layout: ignore system top/bottom safe insets when there is no header or footer, closes #1442 (#1447) 2025-06-22 06:32:23 +02:00
Huang Xin 8c0cdb0e22 layout: tweaks on layout for iPad (#1446) 2025-06-22 05:55:40 +02:00
初五 c0df9f1cb6 doc: correct spelling 'Themeing' to 'Theming' in README (#1444) 2025-06-22 08:19:21 +08:00
Huang Xin cb1ddaeedb fix: smoother orientation change in iOS, closes #1434 (#1441) 2025-06-21 17:11:45 +02:00
Huang Xin 4447dd7b9a css: fix unintended font size adjust in scrolled mode on iOS, closes #1438 (#1440) 2025-06-21 13:25:46 +02:00
Huang Xin dcb7602837 fix: importing books on iOS at first launch is more reliable now (#1439) 2025-06-21 13:17:24 +02:00
Huang Xin 60e48742fe chore: allow download url for installers (#1437) 2025-06-21 11:13:59 +02:00
Huang Xin dd43569778 chore: download releases using cloudflare cdn (#1436) 2025-06-21 10:36:51 +02:00
Huang Xin 8433dd6950 chore: upload release artifacts to a R2 release bucket (#1435) 2025-06-21 16:24:49 +08:00
Huang Xin 379802f580 release: ready for Google Play release, closes #1001 (#1433) 2025-06-20 16:48:53 +02:00
Huang Xin 2dcb5d7c1e sync: add network timeout of sync to 5 sec (#1432)
* sync: add network timeout of sync to 5 sec

* css: minor tweaks on headings and list
2025-06-20 16:45:10 +02:00
Huang Xin 98870d9f3e release: version 0.9.59 (#1429) 2025-06-19 17:10:14 +02:00
Huang Xin 31aeb2289c settings: more reasonable constraints for top and bottom margins (#1428) 2025-06-19 17:01:20 +02:00
Huang Xin 0f66ecbba1 layout: remove unnecessary inset for header on landscape screen in iOS (#1425) 2025-06-19 12:01:42 +02:00
Huang Xin f0d3c9dd4e fix: transform vw/vh to pixels correctly, closes #1420 (#1421)
* i18n: update translations

* fix: transform vw/vh to pixels correctly, closes #1420
2025-06-19 10:53:58 +02:00
Huang Xin 92aae340d9 settings: unset deprecated margin settings (#1419) 2025-06-18 18:56:41 +02:00
Huang Xin 3cf40039ee release: version 0.9.58 (#1418) 2025-06-18 18:23:09 +02:00
Huang Xin 7ed4937d9d settings: add an option to always show status bar (#1417) 2025-06-18 18:15:55 +02:00
Huang Xin b3593c0365 feat: support split-screen mode on iPad, closes #971 (#1416) 2025-06-18 15:54:46 +02:00
Huang Xin 6235202066 layout: fix issue where sidebars couldn’t be dragged to resize on iPad (#1415) 2025-06-18 15:43:51 +02:00
Huang Xin b3d1085ddf fix: now it's possible to have multiple columns in portrait mode, closes #1369 (#1413) 2025-06-18 14:25:32 +02:00
Huang Xin b2a4ddae60 feat: show annotation create time and various other UI fixes (#1412) 2025-06-18 13:17:18 +02:00
Huang Xin 42f50af27b tts: each book view now has its own tts controller (#1411) 2025-06-18 11:37:02 +02:00
Huang Xin 07f74d8858 settings: individually adjust top, bottom, left, and right margins on the reader page (#1410) 2025-06-18 10:55:24 +02:00
Huang Xin 9477789db1 layout: apply responsive safe area insets to the reader page, closes #1067 and closes #1212 (#1408)
This also fixes layout glitches when showing status bar on devices without display cutouts
2025-06-17 03:36:23 +02:00
Huang Xin 8a7d0e1b0d fix: annotation tools now works when TTS is enabled, closes #1403 (#1406) 2025-06-16 08:53:55 +02:00
Huang Xin 261ce95ed1 feat: add keyboard shortcut t to toggle TTS (#1405) 2025-06-16 08:50:10 +02:00
Huang Xin c5a3b44cbf chore: add NEXT_PUBLIC_API_BASE_URL in example env file (#1404) 2025-06-16 06:02:50 +02:00
Huang Xin ed68d25b15 fix: restore full view settings when reopening book (#1400) 2025-06-15 16:07:51 +02:00
Huang Xin f7f8872a13 tts: translation also when TTS is played in the background, closes #1383 (#1399) 2025-06-15 09:07:22 +02:00
Huang Xin be9802a936 release: version 0.9.57 (#1395) 2025-06-14 15:42:08 +02:00
Huang Xin 84328dcfb2 hotfix: resolve compatibility issues with Android Text-to-Speech API (#1394) 2025-06-14 15:39:12 +02:00
102 changed files with 1641 additions and 752 deletions
+5
View File
@@ -177,6 +177,11 @@ jobs:
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
MANIFEST="src-tauri/gen/android/app/src/main/AndroidManifest.xml"
PERMISSION_LINE='<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>'
grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST" || \
sed -i "/android.permission.INTERNET/a \ $PERMISSION_LINE" "$MANIFEST"
pushd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
+60
View File
@@ -0,0 +1,60 @@
name: Upload Release Assets to R2
on:
workflow_call:
inputs:
tag:
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: 'Release tag name (e.g., v1.2.3)'
required: true
type: string
jobs:
upload-to-r2:
runs-on: ubuntu-latest
env:
RELEASE_R2_BUCKET: readest-releases
RELEASE_R2_ACCOUNT_ID: ${{ secrets.RELEASE_R2_ACCOUNT_ID }}
RELEASE_R2_ACCESS_KEY_ID: ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
RELEASE_R2_SECRET_ACCESS_KEY: ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
steps:
- name: Download release assets
run: |
gh release download "${{ inputs.tag }}" --repo readest/readest --dir ./release-assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install rclone
run: curl https://rclone.org/install.sh | sudo bash
- name: Configure rclone
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = $RELEASE_R2_ACCESS_KEY_ID
secret_access_key = $RELEASE_R2_SECRET_ACCESS_KEY
endpoint = https://${RELEASE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com
EOF
- name: Modify latest.json download URLs
run: |
GITHUB_BASE_URL="https://github.com/readest/readest/releases/download"
READEST_BASE_URL="https://download.readest.com/releases"
sed -i "s#${GITHUB_BASE_URL}#${READEST_BASE_URL}#g" ./release-assets/latest.json
- name: Upload to R2
run: |
mkdir releases
mv ./release-assets/latest.json releases
mv ./release-assets/release-notes.json releases
rclone copy ./release-assets r2:${RELEASE_R2_BUCKET}/releases/${{ inputs.tag }}/
rclone copy ./releases r2:${RELEASE_R2_BUCKET}/releases/
+1 -1
View File
@@ -87,7 +87,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
![Themeing Dark Mode](./data/screenshots/theming_dark_mode.png)
![Theming Dark Mode](./data/screenshots/theming_dark_mode.png)
---
+2
View File
@@ -6,6 +6,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824
NEXT_PUBLIC_API_BASE_URL=https://your-api-base-url.com
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.56",
"version": "0.9.60",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -30,6 +30,7 @@
"build-ios-appstore": "dotenv -e .env.ios-appstore.local -- tauri ios build --export-method app-store-connect",
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh",
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare deploy",
@@ -63,6 +64,7 @@
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cssbeautify": "^0.3.1",
"dayjs": "^1.11.13",
"foliate-js": "workspace:*",
"highlight.js": "^11.11.1",
"i18next": "^24.2.0",
@@ -114,8 +114,6 @@
"Your Library": "المكتبة الخاصة بك",
"TTS not supported for PDF": "القراءة الصوتية غير مدعومة لملفات PDF",
"Override Book Font": "تجاوز خط الكتاب",
"Vertical Margins (px)": "هوامش عمودية (بكسل)",
"Horizontal Margins (%)": "هوامش أفقية (%)",
"Apply to All Books": "تطبيق على جميع الكتب",
"Apply to This Book": "تطبيق على هذا الكتاب",
"Unable to fetch the translation. Try again later.": "تعذر جلب الترجمة. حاول مرة أخرى لاحقًا.",
@@ -266,7 +264,7 @@
"Clear Search": "مسح البحث",
"Header & Footer": "الترويسة والتذييل",
"Apply also in Scrolled Mode": "تطبيق أيضًا في وضع التمرير",
"A new version of Readest is Available!": "يتوفر إصدار جديد من Readest!",
"A new version of Readest is available!": "يتوفر إصدار جديد من Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "إصدار {{newVersion}} من ريديست متاح للتنزيل (الإصدار المثبت حاليًا {{currentVersion}}).",
"Download and install now?": "هل ترغب في تنزيله وتثبيته الآن؟",
"Downloading {{downloaded}} of {{contentLength}}": "جارٍ تنزيل {{downloaded}} من {{contentLength}}",
@@ -371,5 +369,12 @@
"Contact Support": "اتصل بالدعم الفني",
"Code Highlighting": "تمييز الكود",
"Enable Highlighting": "تمكين التمييز",
"Code Language": "لغة الكود"
"Code Language": "لغة الكود",
"Top Margin (px)": "الهامش العلوي (بكسل)",
"Bottom Margin (px)": "الهامش السفلي (بكسل)",
"Right Margin (px)": "الهامش الأيمن (بكسل)",
"Left Margin (px)": "الهامش الأيسر (بكسل)",
"Column Gap (%)": "تباعد الأعمدة (%)",
"Always Show Status Bar": "دائمًا إظهار شريط الحالة",
"Translation Not Available": "الترجمة غير متاحة"
}
@@ -114,8 +114,6 @@
"Your Library": "Ihre Bibliothek",
"TTS not supported for PDF": "TTS wird für PDF nicht unterstützt",
"Override Book Font": "Buch-Schriftart überschreiben",
"Vertical Margins (px)": "Vertikale Ränder (px)",
"Horizontal Margins (%)": "Horizontale Ränder (%)",
"Apply to All Books": "Auf alle Bücher anwenden",
"Apply to This Book": "Auf dieses Buch anwenden",
"Unable to fetch the translation. Try again later.": "Übersetzung konnte nicht abgerufen werden. Versuchen Sie es später erneut.",
@@ -266,7 +264,7 @@
"Clear Search": "Suche löschen",
"Header & Footer": "Kopf- & Fußzeile",
"Apply also in Scrolled Mode": "Auch im Scroll-Modus anwenden",
"A new version of Readest is Available!": "Eine neue Version von Readest ist verfügbar!",
"A new version of Readest is available!": "Eine neue Version von Readest ist verfügbar!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} ist verfügbar (installierte Version {{currentVersion}}).",
"Download and install now?": "Jetzt herunterladen und installieren?",
"Downloading {{downloaded}} of {{contentLength}}": "Herunterladen {{downloaded}} von {{contentLength}}",
@@ -359,5 +357,12 @@
"Contact Support": "Support kontaktieren",
"Code Highlighting": "Code-Hervorhebung",
"Enable Highlighting": "Code-Hervorhebung aktivieren",
"Code Language": "Programmiersprache"
"Code Language": "Programmiersprache",
"Top Margin (px)": "Oberer Rand (px)",
"Bottom Margin (px)": "Unterer Rand (px)",
"Right Margin (px)": "Rechter Rand (px)",
"Left Margin (px)": "Linker Rand (px)",
"Column Gap (%)": "Spaltenabstand (%)",
"Always Show Status Bar": "Statusleiste immer anzeigen",
"Translation Not Available": "Übersetzung nicht verfügbar"
}
@@ -114,8 +114,6 @@
"Your Library": "Η βιβλιοθήκη σας",
"TTS not supported for PDF": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται για αρχεία PDF",
"Override Book Font": "Παράκαμψη γραμματοσειράς βιβλίου",
"Vertical Margins (px)": "Κάθετα περιθώρια (px)",
"Horizontal Margins (%)": "Οριζόντια περιθώρια (%)",
"Apply to All Books": "Εφαρμογή σε όλα τα βιβλία",
"Apply to This Book": "Εφαρμογή σε αυτό το βιβλίο",
"Unable to fetch the translation. Try again later.": "Αδυναμία λήψης της μετάφρασης. Δοκιμάστε ξανά αργότερα.",
@@ -266,7 +264,7 @@
"Clear Search": "Καθαρισμός αναζήτησης",
"Header & Footer": "Κεφαλίδα & Υποσέλιδο",
"Apply also in Scrolled Mode": "Εφαρμογή και σε λειτουργία κύλισης",
"A new version of Readest is Available!": "Μια νέα έκδοση του Readest είναι διαθέσιμη!",
"A new version of Readest is available!": "Μια νέα έκδοση του Readest είναι διαθέσιμη!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Η έκδοση Readest {{newVersion}} είναι διαθέσιμη (έκδοση που είναι εγκατεστημένη {{currentVersion}}).",
"Download and install now?": "Θέλετε να την κατεβάσετε και να την εγκαταστήσετε τώρα;",
"Downloading {{downloaded}} of {{contentLength}}": "Κατεβάζω {{downloaded}} από {{contentLength}}",
@@ -359,5 +357,12 @@
"Contact Support": "Επικοινωνήστε με την υποστήριξη",
"Code Highlighting": "Επισήμανση κώδικα",
"Enable Highlighting": "Ενεργοποίηση επισήμανσης",
"Code Language": "Γλώσσα κώδικα"
"Code Language": "Γλώσσα κώδικα",
"Top Margin (px)": "Πάνω περιθώριο (px)",
"Bottom Margin (px)": "Κάτω περιθώριο (px)",
"Right Margin (px)": "Δεξί περιθώριο (px)",
"Left Margin (px)": "Αριστερό περιθώριο (px)",
"Column Gap (%)": "Απόσταση στηλών (%)",
"Always Show Status Bar": "Πάντα εμφάνιση γραμμής κατάστασης",
"Translation Not Available": "Η μετάφραση δεν είναι διαθέσιμη"
}
@@ -114,8 +114,6 @@
"Your Library": "Tu biblioteca",
"TTS not supported for PDF": "TTS no es compatible con PDF",
"Override Book Font": "Sobrescribir fuente del libro",
"Vertical Margins (px)": "Márgenes verticales (px)",
"Horizontal Margins (%)": "Márgenes horizontales (%)",
"Apply to All Books": "Aplicar a todos los libros",
"Apply to This Book": "Aplicar a este libro",
"Unable to fetch the translation. Try again later.": "No se puede obtener la traducción. Inténtalo de nuevo más tarde.",
@@ -266,7 +264,7 @@
"Clear Search": "Limpiar búsqueda",
"Header & Footer": "Encabezado y pie de página",
"Apply also in Scrolled Mode": "Aplicar también en modo desplazamiento",
"A new version of Readest is Available!": "¡Una nueva versión de Readest está disponible!",
"A new version of Readest is available!": "¡Una nueva versión de Readest está disponible!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} está disponible (versión instalada {{currentVersion}}).",
"Download and install now?": "Descargar e instalar ahora?",
"Downloading {{downloaded}} of {{contentLength}}": "Descargando {{downloaded}} de {{contentLength}}",
@@ -362,5 +360,12 @@
"Contact Support": "Contactar con soporte",
"Code Highlighting": "Resaltado de código",
"Enable Highlighting": "Activar resaltado",
"Code Language": "Lenguaje de código"
"Code Language": "Lenguaje de código",
"Top Margin (px)": "Margen superior (px)",
"Bottom Margin (px)": "Margen inferior (px)",
"Right Margin (px)": "Margen derecho (px)",
"Left Margin (px)": "Margen izquierdo (px)",
"Column Gap (%)": "Espacio entre columnas (%)",
"Always Show Status Bar": "Mostrar siempre la barra de estado",
"Translation Not Available": "Traducción no disponible"
}
@@ -114,8 +114,6 @@
"Your Library": "Votre bibliothèque",
"TTS not supported for PDF": "La synthèse vocale n'est pas prise en charge pour les fichiers PDF",
"Override Book Font": "Remplacer la police du livre",
"Vertical Margins (px)": "Marges verticales (px)",
"Horizontal Margins (%)": "Marges horizontales (%)",
"Apply to All Books": "Appliquer à tous les livres",
"Apply to This Book": "Appliquer à ce livre",
"Unable to fetch the translation. Try again later.": "Impossible de récupérer la traduction. Réessayez plus tard.",
@@ -266,7 +264,7 @@
"Clear Search": "Effacer la recherche",
"Header & Footer": "En-tête et pied de page",
"Apply also in Scrolled Mode": "Appliquer également en mode défilement",
"A new version of Readest is Available!": "Avis de mise à jour",
"A new version of Readest is available!": "Avis de mise à jour",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} est disponible (version installée {{currentVersion}}).",
"Download and install now?": "Télécharger et installer maintenant ?",
"Downloading {{downloaded}} of {{contentLength}}": "Téléchargement {{downloaded}} sur {{contentLength}}",
@@ -362,5 +360,12 @@
"Contact Support": "Contacter le support",
"Code Highlighting": "Coloration syntaxique",
"Enable Highlighting": "Activer la coloration syntaxique",
"Code Language": "Langage de programmation"
"Code Language": "Langage de programmation",
"Top Margin (px)": "Marge supérieure (px)",
"Bottom Margin (px)": "Marge inférieure (px)",
"Right Margin (px)": "Marge droite (px)",
"Left Margin (px)": "Marge gauche (px)",
"Column Gap (%)": "Espacement des colonnes (%)",
"Always Show Status Bar": "Afficher toujours la barre d'état",
"Translation Not Available": "Traduction non disponible"
}
@@ -114,8 +114,6 @@
"Your Library": "आपकी लाइब्रेरी",
"TTS not supported for PDF": "PDF के लिए TTS समर्थित नहीं है",
"Override Book Font": "पुस्तक फ़ॉन्ट ओवरराइड करें",
"Vertical Margins (px)": "ऊर्ध्वाधर मार्जिन (px)",
"Horizontal Margins (%)": "क्षैतिज मार्जिन (%)",
"Apply to All Books": "सभी पुस्तकों पर लागू करें",
"Apply to This Book": "इस पुस्तक पर लागू करें",
"Unable to fetch the translation. Try again later.": "अनुवाद प्राप्त करने में असमर्थ। बाद में पुनः प्रयास करें।",
@@ -266,7 +264,7 @@
"Clear Search": "खोज साफ करें",
"Header & Footer": "हेडर और फ़ुटर",
"Apply also in Scrolled Mode": "स्क्रॉल मोड में भी लागू करें",
"A new version of Readest is Available!": "Readest का एक नया संस्करण उपलब्ध है!",
"A new version of Readest is available!": "Readest का एक नया संस्करण उपलब्ध है!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} उपलब्ध है (स्थापित संस्करण {{currentVersion}})।",
"Download and install now?": "अब डाउनलोड और इंस्टॉल करें?",
"Downloading {{downloaded}} of {{contentLength}}": "डाउनलोड कर रहा है {{downloaded}} का {{contentLength}}",
@@ -359,5 +357,12 @@
"Contact Support": "सहायता से संपर्क करें",
"Code Highlighting": "कोड हाइलाइटिंग",
"Enable Highlighting": "हाइलाइटिंग सक्षम करें",
"Code Language": "कोड भाषा"
"Code Language": "कोड भाषा",
"Top Margin (px)": "ऊपरी हाशिया (px)",
"Bottom Margin (px)": "निचला हाशिया (px)",
"Right Margin (px)": "दायां हाशिया (px)",
"Left Margin (px)": "बायां हाशिया (px)",
"Column Gap (%)": "स्तंभों के बीच की दूरी (%)",
"Always Show Status Bar": "स्थिति पट्टी हमेशा दिखाएं",
"Translation Not Available": "अनुवाद उपलब्ध नहीं है"
}
@@ -114,8 +114,6 @@
"Your Library": "Perpustakaan Anda",
"TTS not supported for PDF": "TTS tidak didukung untuk PDF",
"Override Book Font": "Ganti Font Buku",
"Vertical Margins (px)": "Margin Vertikal (px)",
"Horizontal Margins (%)": "Margin Horizontal (%)",
"Apply to All Books": "Terapkan ke semua buku",
"Apply to This Book": "Terapkan ke buku ini",
"Unable to fetch the translation. Try again later.": "Tidak dapat mengambil terjemahan. Coba lagi nanti.",
@@ -266,7 +264,7 @@
"Clear Search": "Hapus Pencarian",
"Header & Footer": "Header & Footer",
"Apply also in Scrolled Mode": "Terapkan juga di Mode Gulir",
"A new version of Readest is Available!": "Versi baru Readest Tersedia!",
"A new version of Readest is available!": "Versi baru Readest Tersedia!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} tersedia (versi terinstal {{currentVersion}}).",
"Download and install now?": "Unduh dan instal sekarang?",
"Downloading {{downloaded}} of {{contentLength}}": "Mengunduh {{downloaded}} dari {{contentLength}}",
@@ -356,5 +354,12 @@
"Contact Support": "Hubungi Dukungan",
"Code Highlighting": "Penyorotan Kode",
"Enable Highlighting": "Aktifkan Penyorotan",
"Code Language": "Bahasa Kode"
"Code Language": "Bahasa Kode",
"Top Margin (px)": "Margin Atas (px)",
"Bottom Margin (px)": "Margin Bawah (px)",
"Right Margin (px)": "Margin Kanan (px)",
"Left Margin (px)": "Margin Kiri (px)",
"Column Gap (%)": "Jarak Antar Kolom (%)",
"Always Show Status Bar": "Selalu Tampilkan Bilah Status",
"Translation Not Available": "Terjemahan Tidak Tersedia"
}
@@ -114,8 +114,6 @@
"Your Library": "La tua biblioteca",
"TTS not supported for PDF": "TTS non supportato per PDF",
"Override Book Font": "Sovrascrivi font libro",
"Vertical Margins (px)": "Margini verticali (px)",
"Horizontal Margins (%)": "Margini orizzontali (%)",
"Apply to All Books": "Applica a tutti i libri",
"Apply to This Book": "Applica a questo libro",
"Unable to fetch the translation. Try again later.": "Impossibile recuperare la traduzione. Riprova più tardi.",
@@ -266,7 +264,7 @@
"Clear Search": "Cancella ricerca",
"Header & Footer": "Intestazione e piè di pagina",
"Apply also in Scrolled Mode": "Applica anche in modalità scorrimento",
"A new version of Readest is Available!": "È disponibile una nuova versione di Readest!",
"A new version of Readest is available!": "È disponibile una nuova versione di Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} è disponibile (versione installata {{currentVersion}}).",
"Download and install now?": "Scarica e installa ora?",
"Downloading {{downloaded}} of {{contentLength}}": "Scaricando {{downloaded}} di {{contentLength}}",
@@ -362,5 +360,12 @@
"Contact Support": "Contatta il supporto",
"Code Highlighting": "Evidenziazione codice",
"Enable Highlighting": "Abilita evidenziazione",
"Code Language": "Tipo di codice"
"Code Language": "Tipo di codice",
"Top Margin (px)": "Margine Superiore (px)",
"Bottom Margin (px)": "Margine Inferiore (px)",
"Right Margin (px)": "Margine Destro (px)",
"Left Margin (px)": "Margine Sinistro (px)",
"Column Gap (%)": "Spazio tra Colonne (%)",
"Always Show Status Bar": "Mostra sempre la barra di stato",
"Translation Not Available": "Traduzione non disponibile"
}
@@ -114,8 +114,6 @@
"Your Library": "ライブラリー",
"TTS not supported for PDF": "PDFではTTSがサポートされていません",
"Override Book Font": "書籍フォントを上書き",
"Vertical Margins (px)": "縦マージン(px",
"Horizontal Margins (%)": "横マージン(%",
"Apply to All Books": "すべての書籍に適用",
"Apply to This Book": "この書籍に適用",
"Unable to fetch the translation. Try again later.": "翻訳を取得できません。後で再試行してください。",
@@ -266,7 +264,7 @@
"Clear Search": "検索をクリア",
"Header & Footer": "ヘッダーとフッター",
"Apply also in Scrolled Mode": "スクロールモードでも適用",
"A new version of Readest is Available!": "新しいバージョンのReadestが利用可能です!",
"A new version of Readest is available!": "新しいバージョンのReadestが利用可能です!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}}が利用可能です(インストールされたバージョン{{currentVersion}})。",
"Download and install now?": "今すぐダウンロードしてインストールしますか?",
"Downloading {{downloaded}} of {{contentLength}}": "ダウンロード中 {{downloaded}} / {{contentLength}}",
@@ -356,5 +354,12 @@
"Contact Support": "サポートに連絡",
"Code Highlighting": "コードハイライト",
"Enable Highlighting": "ハイライトを有効にする",
"Code Language": "コード言語"
"Code Language": "コード言語",
"Top Margin (px)": "上マージン (px)",
"Bottom Margin (px)": "下マージン (px)",
"Right Margin (px)": "右マージン (px)",
"Left Margin (px)": "左マージン (px)",
"Column Gap (%)": "列間の間隔 (%)",
"Always Show Status Bar": "ステータスバーを常に表示",
"Translation Not Available": "翻訳は利用できません"
}
@@ -114,8 +114,6 @@
"Your Library": "내 서재",
"TTS not supported for PDF": "PDF에서 TTS가 지원되지 않음",
"Override Book Font": "책 글꼴 덮어쓰기",
"Vertical Margins (px)": "수직 여백 (px)",
"Horizontal Margins (%)": "수평 여백 (%)",
"Apply to All Books": "모든 책에 적용",
"Apply to This Book": "이 책에 적용",
"Unable to fetch the translation. Try again later.": "번역을 가져올 수 없습니다. 나중에 다시 시도하세요.",
@@ -266,7 +264,7 @@
"Clear Search": "검색 지우기",
"Header & Footer": "헤더 및 푸터",
"Apply also in Scrolled Mode": "스크롤 모드에서도 적용",
"A new version of Readest is Available!": "Readest의 새 버전이 있습니다!",
"A new version of Readest is available!": "Readest의 새 버전이 있습니다!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}}가 사용 가능 (설치된 버전 {{currentVersion}}).",
"Download and install now?": "지금 다운로드하고 설치하시겠습니까?",
"Downloading {{downloaded}} of {{contentLength}}": "다운로드 중 {{downloaded}} / {{contentLength}}",
@@ -356,5 +354,12 @@
"Contact Support": "지원팀에 문의",
"Code Highlighting": "코드 하이라이팅",
"Enable Highlighting": "하이라이팅 활성화",
"Code Language": "코드 언어"
"Code Language": "코드 언어",
"Top Margin (px)": "위쪽 여백 (px)",
"Bottom Margin (px)": "아래쪽 여백 (px)",
"Right Margin (px)": "오른쪽 여백 (px)",
"Left Margin (px)": "왼쪽 여백 (px)",
"Column Gap (%)": "열 간격 (%)",
"Always Show Status Bar": "상태 표시줄 항상 표시",
"Translation Not Available": "번역을 사용할 수 없음"
}
@@ -169,8 +169,6 @@
"Hyphenation": "Woordafbreking",
"Override Book Layout": "Boekopmaak overschrijven",
"Page": "Pagina",
"Vertical Margins (px)": "Verticale marges (px)",
"Horizontal Margins (%)": "Horizontale marges (%)",
"Maximum Number of Columns": "Maximum aantal kolommen",
"Maximum Column Height": "Maximale kolomhoogte",
"Maximum Column Width": "Maximale kolombreedte",
@@ -277,7 +275,7 @@
"Description:": "Beschrijving:",
"No description available": "Geen beschrijving beschikbaar",
"Drop to Import Books": "Sleep om boeken te importeren",
"A new version of Readest is Available!": "Een nieuwe versie van Readest is beschikbaar!",
"A new version of Readest is available!": "Een nieuwe versie van Readest is beschikbaar!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} is beschikbaar (geïnstalleerde versie {{currentVersion}}).",
"Download and install now?": "Nu downloaden en installeren?",
"Downloading {{downloaded}} of {{contentLength}}": "Downloaden {{downloaded}} van {{contentLength}}",
@@ -359,5 +357,12 @@
"Contact Support": "Neem contact op met ondersteuning",
"Code Highlighting": "Code markering",
"Enable Highlighting": "Markering inschakelen",
"Code Language": "Code taal"
"Code Language": "Code taal",
"Top Margin (px)": "Bovenmarge (px)",
"Bottom Margin (px)": "Ondermarge (px)",
"Right Margin (px)": "Rechter marge (px)",
"Left Margin (px)": "Linker marge (px)",
"Column Gap (%)": "Kolomafstand (%)",
"Always Show Status Bar": "Statusbalk altijd weergeven",
"Translation Not Available": "Vertaling niet beschikbaar"
}
@@ -114,8 +114,6 @@
"Your Library": "Twoja biblioteka",
"TTS not supported for PDF": "TTS nie jest obsługiwane dla plików PDF",
"Override Book Font": "Nadpisz czcionkę książki",
"Vertical Margins (px)": "Marginesy pionowe (px)",
"Horizontal Margins (%)": "Marginesy poziome (%)",
"Apply to All Books": "Zastosuj do wszystkich książek",
"Apply to This Book": "Zastosuj do tej książki",
"Unable to fetch the translation. Try again later.": "Nie udało się pobrać tłumaczenia. Spróbuj ponownie później.",
@@ -266,7 +264,7 @@
"Clear Search": "Wyczyść wyszukiwanie",
"Header & Footer": "Nagłówek i stopka",
"Apply also in Scrolled Mode": "Zastosuj również w trybie przewijania",
"A new version of Readest is Available!": "Nowa wersja Readest jest dostępna!",
"A new version of Readest is available!": "Nowa wersja Readest jest dostępna!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} jest dostępny (zainstalowana wersja {{currentVersion}}).",
"Download and install now?": "Pobierz i zainstaluj teraz?",
"Downloading {{downloaded}} of {{contentLength}}": "Pobieranie {{downloaded}} z {{contentLength}}",
@@ -365,5 +363,12 @@
"Contact Support": "Skontaktuj się z pomocą techniczną",
"Code Highlighting": "Podświetlanie kodu",
"Enable Highlighting": "Zezwól na podświetlanie",
"Code Language": "Język kodu"
"Code Language": "Język kodu",
"Top Margin (px)": "Górny margines (px)",
"Bottom Margin (px)": "Dolny margines (px)",
"Right Margin (px)": "Prawy margines (px)",
"Left Margin (px)": "Lewy margines (px)",
"Column Gap (%)": "Odstęp między kolumnami (%)",
"Always Show Status Bar": "Zawsze pokazuj pasek stanu",
"Translation Not Available": "Brak dostępnego tłumaczenia"
}
@@ -114,8 +114,6 @@
"Your Library": "Sua Biblioteca",
"TTS not supported for PDF": "TTS não suportado para PDF",
"Override Book Font": "Sobrescrever Fonte do Livro",
"Vertical Margins (px)": "Margens Verticais (px)",
"Horizontal Margins (%)": "Margens Horizontais (%)",
"Apply to All Books": "Aplicar a todos os livros",
"Apply to This Book": "Aplicar a este livro",
"Unable to fetch the translation. Try again later.": "Não foi possível buscar a tradução. Tente novamente mais tarde.",
@@ -266,7 +264,7 @@
"Clear Search": "Limpar Pesquisa",
"Header & Footer": "Cabeçalho e Rodapé",
"Apply also in Scrolled Mode": "Aplicar também no Modo Rolado",
"A new version of Readest is Available!": "Uma nova versão do Readest está disponível!",
"A new version of Readest is available!": "Uma nova versão do Readest está disponível!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} está disponível (versão instalada {{currentVersion}}).",
"Download and install now?": "Baixar e instalar agora?",
"Downloading {{downloaded}} of {{contentLength}}": "Baixando {{downloaded}} de {{contentLength}}",
@@ -362,5 +360,12 @@
"Contact Support": "Contato com Suporte",
"Code Highlighting": "Realce de Código",
"Enable Highlighting": "Ativar Realce",
"Code Language": "Idioma do Código"
"Code Language": "Idioma do Código",
"Top Margin (px)": "Margem superior (px)",
"Bottom Margin (px)": "Margem inferior (px)",
"Right Margin (px)": "Margem direita (px)",
"Left Margin (px)": "Margem esquerda (px)",
"Column Gap (%)": "Espaço entre colunas (%)",
"Always Show Status Bar": "Exibir sempre a barra de status",
"Translation Not Available": "Tradução não disponível"
}
@@ -114,8 +114,6 @@
"Your Library": "Ваша библиотека",
"TTS not supported for PDF": "TTS не поддерживается для PDF",
"Override Book Font": "Переопределить шрифт книги",
"Vertical Margins (px)": "Вертикальные поля (px)",
"Horizontal Margins (%)": "Горизонтальные поля (%)",
"Apply to All Books": "Применить ко всем книгам",
"Apply to This Book": "Применить к этой книге",
"Unable to fetch the translation. Try again later.": "Не удалось получить перевод. Попробуйте позже.",
@@ -266,7 +264,7 @@
"Clear Search": "Очистить поиск",
"Header & Footer": "Заголовок и нижний колонтитул",
"Apply also in Scrolled Mode": "Применить также в прокручиваемом режиме",
"A new version of Readest is Available!": "Доступна новая версия Readest!",
"A new version of Readest is available!": "Доступна новая версия Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} доступен (установленная версия {{currentVersion}}).",
"Download and install now?": "Скачать и установить сейчас?",
"Downloading {{downloaded}} of {{contentLength}}": "Скачано {{downloaded}} из {{contentLength}}",
@@ -365,5 +363,12 @@
"Contact Support": "Связаться с поддержкой",
"Code Highlighting": "Подсветка кода",
"Enable Highlighting": "Включить подсветку",
"Code Language": "Язык кода"
"Code Language": "Язык кода",
"Top Margin (px)": "Верхний отступ (пикс)",
"Bottom Margin (px)": "Нижний отступ (пикс)",
"Right Margin (px)": "Правый отступ (пикс)",
"Left Margin (px)": "Левый отступ (пикс)",
"Column Gap (%)": "Промежуток между колонками (%)",
"Always Show Status Bar": "Всегда показывать панель состояния",
"Translation Not Available": "Перевод недоступен"
}
@@ -114,8 +114,6 @@
"Your Library": "Kütüphaneniz",
"TTS not supported for PDF": "PDF için TTS desteklenmiyor",
"Override Book Font": "Kitap Yazı Tipini Geçersiz Kıl",
"Vertical Margins (px)": "Dikey Kenar Boşlukları (px)",
"Horizontal Margins (%)": "Yatay Kenar Boşlukları (%)",
"Apply to All Books": "Tüm kitaplara uygula",
"Apply to This Book": "Bu kitaba uygula",
"Unable to fetch the translation. Try again later.": "Çeviri alınamıyor. Daha sonra tekrar deneyin.",
@@ -266,7 +264,7 @@
"Clear Search": "Aramayı Temizle",
"Header & Footer": "Başlık ve Altbilgi",
"Apply also in Scrolled Mode": "Kaydırılmış Modda da Uygula",
"A new version of Readest is Available!": "Yeni bir Readest sürümü mevcut!",
"A new version of Readest is available!": "Yeni bir Readest sürümü mevcut!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} mevcut (yüklü sürüm {{currentVersion}}).",
"Download and install now?": "Şimdi indirip yükleyelim mi?",
"Downloading {{downloaded}} of {{contentLength}}": "İndiriliyor {{downloaded}}/{{contentLength}}",
@@ -359,5 +357,12 @@
"Contact Support": "Destekle İletişime Geç",
"Code Highlighting": "Code Vurgulama",
"Enable Highlighting": "Vurgulamayı Etkinleştir",
"Code Language": "Code Dili"
"Code Language": "Code Dili",
"Top Margin (px)": "Üst Kenar Boşluğu (px)",
"Bottom Margin (px)": "Alt Kenar Boşluğu (px)",
"Right Margin (px)": "Sağ Kenar Boşluğu (px)",
"Left Margin (px)": "Sol Kenar Boşluğu (px)",
"Column Gap (%)": "Sütun Aralığı (%)",
"Always Show Status Bar": "Durum Çubuğunu Her Zaman Göster",
"Translation Not Available": "Çeviri Mevcut Değil"
}
@@ -114,8 +114,6 @@
"Your Library": "Ваша бібліотека",
"TTS not supported for PDF": "TTS не підтримується для PDF",
"Override Book Font": "Перевизначити шрифт книги",
"Vertical Margins (px)": "Вертикальні відступи (px)",
"Horizontal Margins (%)": "Горизонтальні відступи (%)",
"Apply to All Books": "Застосувати до всіх книг",
"Apply to This Book": "Застосувати до цієї книги",
"Unable to fetch the translation. Try again later.": "Не вдалося отримати переклад. Спробуйте пізніше.",
@@ -266,7 +264,7 @@
"Clear Search": "Очистити пошук",
"Header & Footer": "Заголовок і нижній колонтитул",
"Apply also in Scrolled Mode": "Застосувати також у прокрученому режимі",
"A new version of Readest is Available!": "Доступна нова версія Readest!",
"A new version of Readest is available!": "Доступна нова версія Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} доступна (встановлена версія {{currentVersion}}).",
"Download and install now?": "Завантажити та встановити зараз?",
"Downloading {{downloaded}} of {{contentLength}}": "Завантаження {{downloaded}} з {{contentLength}}",
@@ -365,5 +363,12 @@
"Contact Support": "Зв'язатися з підтримкою",
"Code Highlighting": "Підсвічування коду",
"Enable Highlighting": "Увімкнути підсвічування",
"Code Language": "Мова коду"
"Code Language": "Мова коду",
"Top Margin (px)": "Верхній відступ (px)",
"Bottom Margin (px)": "Нижній відступ (px)",
"Right Margin (px)": "Правий відступ (px)",
"Left Margin (px)": "Лівий відступ (px)",
"Column Gap (%)": "Проміжок між стовпцями (%)",
"Always Show Status Bar": "Завжди показувати панель стану",
"Translation Not Available": "Переклад недоступний"
}
@@ -114,8 +114,6 @@
"Your Library": "Thư viện của bạn",
"TTS not supported for PDF": "TTS không được hỗ trợ cho PDF",
"Override Book Font": "Ghi đè phông chữ sách",
"Vertical Margins (px)": "Lề dọc (px)",
"Horizontal Margins (%)": "Lề ngang (%)",
"Apply to All Books": "Áp dụng cho tất cả sách",
"Apply to This Book": "Áp dụng cho sách này",
"Unable to fetch the translation. Try again later.": "Không thể tải dịch vụ. Vui lòng thử lại sau.",
@@ -266,7 +264,7 @@
"Clear Search": "Xóa tìm kiếm",
"Header & Footer": "Đầu trang & Chân trang",
"Apply also in Scrolled Mode": "Áp dụng cũng trong chế độ cuộn",
"A new version of Readest is Available!": "Đã có phiên bản mới của Readest!",
"A new version of Readest is available!": "Đã có phiên bản mới của Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} đã có sẵn (phiên bản đã cài đặt {{currentVersion}}).",
"Download and install now?": "Tải xuống và cài đặt ngay bây giờ?",
"Downloading {{downloaded}} of {{contentLength}}": "Đang tải {{downloaded}} trong {{contentLength}}",
@@ -356,5 +354,12 @@
"Contact Support": "Liên hệ hỗ trợ",
"Code Highlighting": "Đánh dấu mã",
"Enable Highlighting": "Cho phép đánh dấu",
"Code Language": "Ngôn ngữ mã"
"Code Language": "Ngôn ngữ mã",
"Top Margin (px)": "Lề trên (px)",
"Bottom Margin (px)": "Lề dưới (px)",
"Right Margin (px)": "Lề phải (px)",
"Left Margin (px)": "Lề trái (px)",
"Column Gap (%)": "Khoảng cách cột (%)",
"Always Show Status Bar": "Luôn hiển thị thanh trạng thái",
"Translation Not Available": "Không có bản dịch"
}
@@ -114,8 +114,6 @@
"Your Library": "书库",
"TTS not supported for PDF": "PDF 文档暂不支持 TTS",
"Override Book Font": "覆盖书籍字体",
"Vertical Margins (px)": "垂直边距(px",
"Horizontal Margins (%)": "水平边距(%",
"Apply to All Books": "应用于所有书籍",
"Apply to This Book": "应用于此书籍",
"Unable to fetch the translation. Try again later.": "无法获取翻译,请稍后重试。",
@@ -266,7 +264,7 @@
"Clear Search": "清除搜索",
"Header & Footer": "页眉页脚",
"Apply also in Scrolled Mode": "应用到滚动模式",
"A new version of Readest is Available!": "有新版本的 Readest 可更新!",
"A new version of Readest is available!": "有新版本的 Readest 可更新!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} 可更新(已安装版本 {{currentVersion}})。",
"Download and install now?": "立即下载并安装?",
"Downloading {{downloaded}} of {{contentLength}}": "正在下载 {{downloaded}} / {{contentLength}}",
@@ -356,5 +354,12 @@
"Contact Support": "联系客服",
"Code Highlighting": "代码高亮",
"Enable Highlighting": "启用高亮",
"Code Language": "代码语言"
"Code Language": "代码语言",
"Top Margin (px)": "上边距",
"Bottom Margin (px)": "下边距",
"Right Margin (px)": "右边距",
"Left Margin (px)": "左边距",
"Column Gap (%)": "列间距",
"Always Show Status Bar": "始终显示状态栏",
"Translation Not Available": "翻译不可用"
}
@@ -114,8 +114,6 @@
"Your Library": "書庫",
"TTS not supported for PDF": "PDF 文檔暫不支持 TTS",
"Override Book Font": "覆蓋書籍字體",
"Vertical Margins (px)": "垂直邊距(px",
"Horizontal Margins (%)": "水平邊距(%",
"Apply to All Books": "應用於所有書籍",
"Apply to This Book": "應用於此書籍",
"Unable to fetch the translation. Try again later.": "無法獲取翻譯,請稍後再試。",
@@ -266,7 +264,7 @@
"Clear Search": "清除搜索",
"Header & Footer": "頁眉頁腳",
"Apply also in Scrolled Mode": "滾動模式同樣適用",
"A new version of Readest is Available!": "有新版本的 Readest 可更新!",
"A new version of Readest is available!": "有新版本的 Readest 可更新!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} 可更新(已安裝版本 {{currentVersion}})。",
"Download and install now?": "立即下載並安裝?",
"Downloading {{downloaded}} of {{contentLength}}": "正在下載 {{downloaded}} / {{contentLength}}",
@@ -356,5 +354,12 @@
"Contact Support": "聯繫支援",
"Code Highlighting": "程式碼高亮",
"Enable Highlighting": "啟用高亮",
"Code Language": "程式語言"
"Code Language": "程式語言",
"Top Margin (px)": "上邊距",
"Bottom Margin (px)": "下邊距",
"Right Margin (px)": "右邊距",
"Left Margin (px)": "左邊距",
"Column Gap (%)": "欄間距",
"Always Show Status Bar": "始終顯示狀態欄",
"Translation Not Available": "翻譯不可用"
}
+41
View File
@@ -1,5 +1,46 @@
{
"releases": {
"0.9.60": {
"date": "2025-06-22",
"notes": [
"Layout has been improved for iPad devices",
"Screen orientation changes are now smoother on iOS",
"Importing books on iOS during the first launch is now more reliable",
"Font size no longer changes unexpectedly in scrolled mode on iOS",
"Top and bottom safe insets are now ignored when no header or footer is present",
"Interactive books now load correctly on the Desktop platform",
"Translation is now disabled when the primary language is undefined",
"Android: the task list background color has been updated with current theme color"
]
},
"0.9.59": {
"date": "2025-06-19",
"notes": [
"Layout: Image size now adjusts more accurately by converting screen units to pixels properly",
"Layout: Improved header layout on iOS landscape mode — extra spacing has been removed",
"Settings: Top and bottom margin settings are now more intuitive and better constrained"
]
},
"0.9.58": {
"date": "2025-06-18",
"notes": [
"TTS: Translation now works even when TTS is playing in the background",
"TTS: Press T on your keyboard to quickly toggle TTS on or off",
"TTS: Each open book now has its own independent TTS controller",
"Annotations: Annotation tools now function properly while TTS is active",
"Settings: Margins for top, bottom, left, and right can now be adjusted individually",
"Settings: New option to always show the status bar while reading",
"Layout: Reader page now adapts better to device safe areas for a more comfortable layout",
"UI: Added creation timestamps to annotations and made various interface improvements",
"iPad: Multiple columns are now supported in portrait mode",
"iPad: Fixed issue where sidebars couldn't be resized by dragging",
"iPad: Added support for split-screen mode"
]
},
"0.9.57": {
"date": "2025-06-14",
"notes": ["TTS: Fixed an issue to detect custom TTS engines on some Android devices"]
},
"0.9.56": {
"date": "2025-06-13",
"notes": [
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
set -e
VERSION=$(jq -r '.version' package.json)
if [[ -z "$VERSION" ]]; then
echo "❌ Failed to extract version from package.json"
exit 1
fi
echo "📦 Found version: $VERSION"
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
if [[ -z "$MAJOR" || -z "$MINOR" || -z "$PATCH" ]]; then
echo "❌ Invalid version format: $VERSION"
exit 1
fi
# Convert x.y.z => x * 10000 + y * 1000 + z
VERSION_CODE=$((10#$MAJOR * 10000 + 10#$MINOR * 1000 + 10#$PATCH))
echo "🔢 Computed versionCode: $VERSION_CODE"
PROPERTIES_FILE="./src-tauri/gen/android/app/tauri.properties"
if [[ ! -f "$PROPERTIES_FILE" ]]; then
echo "❌ File not found: $PROPERTIES_FILE"
exit 1
fi
tmpfile=$(mktemp)
sed "s/^tauri\.android\.versionName=.*/tauri.android.versionName=$VERSION/" "$PROPERTIES_FILE" | \
sed "s/^tauri\.android\.versionCode=.*/tauri.android.versionCode=$VERSION_CODE/" > "$tmpfile"
mv "$tmpfile" "$PROPERTIES_FILE"
echo "✅ Updated $PROPERTIES_FILE"
echo "🚀 Running: pnpm tauri android build"
pnpm tauri android build
+2 -2
View File
@@ -11,7 +11,7 @@
<key>UIHomeIndicatorAutoHidden</key>
<true/>
<key>UIRequiresFullScreen</key>
<true/>
<false/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -198,4 +198,4 @@
</dict>
</array>
</dict>
</plist>
</plist>
@@ -54,11 +54,14 @@
"identifier": "http:default",
"allow": [
{
"url": "https://*.deepl.com"
"url": "https://*.readest.com"
},
{
"url": "https://github.com/readest/*"
},
{
"url": "https://*.deepl.com"
},
{
"url": "https://*.cloudflarestorage.com"
},
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -1,10 +1,14 @@
package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import android.view.KeyEvent
import android.webkit.WebView
import android.util.Log
import android.graphics.Color
import android.app.ActivityManager
import android.content.res.Configuration
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -77,5 +81,39 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
updateTaskDescription()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateTaskDescription()
}
override fun onResume() {
super.onResume()
updateTaskDescription()
}
private fun updateTaskDescription() {
val backgroundColor = if (isDarkTheme()) {
Color.parseColor("#2D2D2D")
} else {
Color.parseColor("#FFFFFF")
}
setTaskDescription(
ActivityManager.TaskDescription(
"Readest",
null,
backgroundColor
)
)
}
private fun isDarkTheme(): Boolean {
return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
Configuration.UI_MODE_NIGHT_YES -> true
else -> false
}
}
}
@@ -2,6 +2,9 @@
<!-- Base application theme. -->
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowSplashScreenBackground">@android:color/transparent</item>
<item name="android:windowSplashScreenAnimatedIcon" tools:targetApi="31">@null</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">false</item>
@@ -154,9 +154,8 @@ class VolumeKeyHandler: NSObject {
class NativeBridgePlugin: Plugin {
private var webView: WKWebView?
private var authSession: ASWebAuthenticationSession?
private var isOrientationLocked = false
private var currentOrientationMask: UIInterfaceOrientationMask = .all
private var orientationObserver: NSObjectProtocol?
private var originalDelegate: UIApplicationDelegate?
@objc public override func load(webview: WKWebView) {
self.webView = webview
@@ -175,16 +174,19 @@ class NativeBridgePlugin: Plugin {
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
if let app = UIApplication.value(forKey: "sharedApplication") as? UIApplication {
self.originalDelegate = app.delegate
app.delegate = self
} else {
Logger.error("NativeBridgePlugin: Failed to get shared application")
}
}
@objc func appDidBecomeActive() {
if volumeKeyHandler != nil {
activateVolumeKeyInterception()
}
if orientationObserver != nil {
self.setupOrientationObserver()
}
}
@objc func appDidEnterBackground() {
@@ -360,18 +362,11 @@ class NativeBridgePlugin: Plugin {
let orientation = args.orientation ?? "auto"
switch orientation.lowercased() {
case "portrait":
self.isOrientationLocked = true
self.currentOrientationMask = .portrait
self.forceOrientation(.portrait)
self.setupOrientationObserver()
self.changeOrientation(.portrait)
case "landscape":
self.isOrientationLocked = true
self.currentOrientationMask = .landscape
self.forceOrientation(.landscapeRight)
self.setupOrientationObserver()
self.changeOrientation(.landscape)
case "auto":
self.isOrientationLocked = false
self.currentOrientationMask = .all
self.changeOrientation(.all)
default:
invoke.reject("Invalid orientation mode")
return
@@ -381,40 +376,49 @@ class NativeBridgePlugin: Plugin {
}
}
private func forceOrientation(_ orientation: UIInterfaceOrientation) {
private func changeOrientation(_ orientationMask: UIInterfaceOrientationMask) {
self.currentOrientationMask = orientationMask
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)")
if orientationMask == .all {
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .all)) { error in
logger.error("Orientation update error: \(error.localizedDescription)")
DispatchQueue.main.async {
UIViewController.attemptRotationToDeviceOrientation()
}
}
} else {
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask)) { error in
logger.error("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)
if orientationMask == .all {
UIViewController.attemptRotationToDeviceOrientation()
} else {
let specificOrientation: UIInterfaceOrientation
if orientationMask.contains(.portrait) {
specificOrientation = .portrait
} else if orientationMask.contains(.landscape) {
let currentOrientation = UIDevice.current.orientation
if currentOrientation == .landscapeLeft {
specificOrientation = .landscapeRight
} else if currentOrientation == .landscapeRight {
specificOrientation = .landscapeLeft
} else {
specificOrientation = .landscapeRight
}
} else {
specificOrientation = .portrait
}
UIDevice.current.setValue(specificOrientation.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
}
}
@@ -431,3 +435,66 @@ extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
return UIApplication.shared.windows.first ?? UIWindow()
}
}
extension NativeBridgePlugin: UIApplicationDelegate {
public func application(
_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?
) -> UIInterfaceOrientationMask {
return self.currentOrientationMask
}
/*
Proxy all application delegate methods to the original delegate:
sel!(application:didFinishLaunchingWithOptions:),
sel!(application:openURL:options:),
sel!(application:continue:restorationHandler:),
sel!(applicationDidBecomeActive:),
sel!(applicationWillResignActive:),
sel!(applicationWillEnterForeground:),
sel!(applicationDidEnterBackground:),
sel!(applicationWillTerminate:),
*/
public func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
self.originalDelegate?.application?(application, didFinishLaunchingWithOptions: launchOptions)
?? false
}
public func application(
_ application: UIApplication, open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
self.originalDelegate?.application?(application, open: url, options: options) ?? false
}
public func application(
_ application: UIApplication, continue continueUserActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
self.originalDelegate?.application?(
application, continue: continueUserActivity, restorationHandler: restorationHandler) ?? false
}
public func applicationDidBecomeActive(_ application: UIApplication) {
self.originalDelegate?.applicationDidBecomeActive?(application)
}
public func applicationWillResignActive(_ application: UIApplication) {
self.originalDelegate?.applicationWillResignActive?(application)
}
public func applicationWillEnterForeground(_ application: UIApplication) {
self.originalDelegate?.applicationWillEnterForeground?(application)
}
public func applicationDidEnterBackground(_ application: UIApplication) {
self.originalDelegate?.applicationDidEnterBackground?(application)
}
public func applicationWillTerminate(_ application: UIApplication) {
self.originalDelegate?.applicationWillTerminate?(application)
}
}
@@ -1,3 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
</manifest>
@@ -3,6 +3,7 @@ package com.readest.native_tts
import android.os.Bundle
import android.app.Activity
import android.content.Context
import android.provider.Settings
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.speech.tts.Voice
@@ -97,7 +98,11 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
private suspend fun initializeTTS(): Boolean = suspendCancellableCoroutine { continuation ->
try {
textToSpeech = TextToSpeech(activity) { status ->
val preferredEngine = Settings.Secure.getString(
activity.contentResolver,
Settings.Secure.TTS_DEFAULT_SYNTH
)
textToSpeech = TextToSpeech(activity, { status ->
when (status) {
TextToSpeech.SUCCESS -> {
setupTTSListener()
@@ -111,7 +116,7 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
continuation.resume(false) {}
}
}
}
}, preferredEngine)
} catch (e: Exception) {
Log.e(TAG, "Exception during TTS initialization", e)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+4 -1
View File
@@ -151,7 +151,10 @@
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK",
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
"endpoints": [
"https://download.readest.com/releases/latest.json",
"https://github.com/readest/readest/releases/latest/download/latest.json"
]
}
}
}
+2 -3
View File
@@ -18,14 +18,13 @@ import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { isTauriAppPlatform } from '@/services/environment';
import { getBaseUrl, isTauriAppPlatform } from '@/services/environment';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
import { READEST_WEB_BASE_URL } from '@/services/constants';
import WindowButtons from '@/components/WindowButtons';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -42,7 +41,7 @@ interface ProviderLoginProp {
label: string;
}
const WEB_AUTH_CALLBACK = `${READEST_WEB_BASE_URL}/auth/callback`;
const WEB_AUTH_CALLBACK = `${getBaseUrl()}/auth/callback`;
const DEEPLINK_CALLBACK = 'readest://auth-callback';
const USE_APPLE_SIGN_IN = process.env['NEXT_PUBLIC_USE_APPLE_SIGN_IN'] === 'true';
+5 -5
View File
@@ -19,7 +19,7 @@ export default function Error({ error, reset }: ErrorPageProps) {
useEffect(() => {
setBrowserInfo(parseWebViewVersion(appService));
posthog.captureException(error);
}, [error]);
}, [appService, error]);
const handleGoHome = () => {
window.location.href = '/library';
@@ -36,8 +36,8 @@ export default function Error({ error, reset }: ErrorPageProps) {
return (
<div className='hero bg-base-200 min-h-screen'>
<div className='hero-content text-center'>
<div className='max-w-2xl'>
<div className='mb-8'>
<div className='w-full max-w-2xl p-1'>
<div className='mb-8 mt-6'>
<div className='text-error animate-pulse text-8xl'></div>
</div>
@@ -55,8 +55,8 @@ export default function Error({ error, reset }: ErrorPageProps) {
<p className='break-words font-mono text-sm'>{error.message}</p>
{browserInfo && <p className='mt-2 font-mono text-sm'>Browser: {browserInfo}</p>}
{error.stack && (
<p className='mt-2 break-words font-mono text-sm'>
{error.stack.split('\n').slice(0, 5).join('\n')}
<p className='mt-2 whitespace-pre-wrap break-all font-mono text-sm'>
{error.stack.split('\n').slice(0, 3).join('\n')}
</p>
)}
{error.digest && <p className='mt-2 text-xs opacity-70'>Error ID: {error.digest}</p>}
@@ -102,6 +102,10 @@ const BookItem: React.FC<BookItemProps> = ({
</div>
<div
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
style={{
height: `${iconSize15}px`,
minHeight: `${iconSize15}px`,
}}
>
{book.progress && <ReadingProgress book={book} />}
<div className='flex items-center justify-center gap-x-2'>
@@ -13,6 +13,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { debounce } from '@/utils/debounce';
@@ -44,7 +45,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { statusBarHeight } = useThemeStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { currentBookshelf } = useLibraryStore();
const {
isTrafficLightVisible,
@@ -58,6 +59,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const headerRef = useRef<HTMLDivElement>(null);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
const insets = useSafeAreaInsets();
useShortcuts({
onToggleSelectMode,
@@ -102,6 +104,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
0,
);
if (!insets) return null;
return (
<div
ref={headerRef}
@@ -111,8 +115,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '',
? `max(${insets.top}px, ${systemUIVisible ? statusBarHeight : 0}px)`
: '0px',
}}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
@@ -38,6 +38,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
const [isAlwaysShowStatusBar, setIsAlwaysShowStatusBar] = useState(settings.alwaysShowStatusBar);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks);
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
@@ -92,6 +93,14 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const toggleAlwaysShowStatusBar = () => {
settings.alwaysShowStatusBar = !settings.alwaysShowStatusBar;
setSettings(settings);
saveSettings(envConfig, settings);
setIsAlwaysShowStatusBar(settings.alwaysShowStatusBar);
setIsDropdownOpen?.(false);
};
const toggleAutoUploadBooks = () => {
settings.autoUpload = !settings.autoUpload;
setSettings(settings);
@@ -214,6 +223,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleAlwaysOnTop}
/>
)}
{appService?.isMobileApp && (
<MenuItem
label={_('Always Show Status Bar')}
Icon={isAlwaysShowStatusBar ? MdCheck : undefined}
onClick={toggleAlwaysShowStatusBar}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
Icon={isScreenWakeLock ? MdCheck : undefined}
+16 -15
View File
@@ -28,7 +28,7 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useTheme } from '@/hooks/useTheme';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useThemeStore } from '@/store/themeStore';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { lockScreenOrientation } from '@/utils/bridge';
@@ -69,8 +69,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
} = useLibraryStore();
const _ = useTranslation();
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
const insets = useSafeAreaInsets();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { statusBarHeight } = useThemeStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -401,8 +401,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const selectFilesTauri = async () => {
const exts = appService?.isAndroidApp ? [] : SUPPORTED_FILE_EXTS;
const exts = appService?.isMobileApp ? [] : SUPPORTED_FILE_EXTS;
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
if (appService?.isIOSApp) {
return files.filter((file) => {
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
return SUPPORTED_FILE_EXTS.includes(fileExt);
});
}
// Cannot filter out files on Android since some content providers may not return the file name
return files;
};
@@ -525,11 +531,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
let files;
if (isTauriAppPlatform()) {
if (appService?.isIOSApp) {
files = (await selectFilesWeb()) as [File];
} else {
files = (await selectFilesTauri()) as [string];
}
files = (await selectFilesTauri()) as [string];
} else {
files = (await selectFilesWeb()) as [File];
}
@@ -559,7 +561,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setShowDetailsBook(book);
};
if (!appService) {
if (!appService || !insets) {
return null;
}
@@ -582,7 +584,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<div className='fixed top-0 z-40 w-full'>
<div className='top-0 z-40 w-full'>
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
@@ -603,14 +605,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
ref={containerRef}
className={clsx(
'scroll-container drop-zone flex-grow overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[52px]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '48px',
paddingTop: '0px',
paddingRight: `${insets.right}px`,
paddingBottom: `${insets.bottom}px`,
paddingLeft: `${insets.left}px`,
}}
>
<DropIndicator />
@@ -4,10 +4,12 @@ import React, { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
import FooterBar from './FooterBar';
@@ -18,7 +20,6 @@ import Annotator from './annotator/Annotator';
import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
import DoubleBorder from './DoubleBorder';
import TTSControl from './tts/TTSControl';
interface BooksGridProps {
bookKeys: string[];
@@ -28,10 +29,14 @@ interface BooksGridProps {
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings, hoveredBookKey } = useReaderStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { getGridInsets, setGridInsets, hoveredBookKey } = useReaderStore();
const { isSideBarVisible, sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
const screenInsets = useSafeAreaInsets();
const aspectRatio = window.innerWidth / window.innerHeight;
const gridTemplate = getGridTemplate(bookKeys.length, aspectRatio);
useEffect(() => {
if (!sideBarBookKey) return;
@@ -41,6 +46,23 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
useEffect(() => {
if (!screenInsets) return;
bookKeys.forEach((bookKey, index) => {
const { top, right, bottom, left } = getInsetEdges(index, bookKeys.length, aspectRatio);
const gridInsets = {
top: top ? screenInsets.top : 0,
right: right ? screenInsets.right : 0,
bottom: bottom ? screenInsets.bottom : 0,
left: left ? screenInsets.left : 0,
};
setGridInsets(bookKey, gridInsets);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKeys, screenInsets]);
if (!screenInsets) return null;
return (
<div
className={clsx('relative grid h-full flex-grow')}
@@ -54,13 +76,20 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const gridInsets = getGridInsets(bookKey);
const { book, bookDoc } = bookData || {};
if (!book || !config || !bookDoc || !viewSettings) return null;
if (!book || !config || !bookDoc || !viewSettings || !gridInsets) return null;
const { section, pageinfo, timeinfo, sectionLabel } = progress || {};
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
const horizontalGapPercent = viewSettings.gapPercent;
const verticalMarginPixels = viewSettings.marginPx;
const viewInsets = getViewInsets(viewSettings);
const contentInsets = {
top: gridInsets.top + viewInsets.top,
right: gridInsets.right + viewInsets.right,
bottom: gridInsets.bottom + viewInsets.bottom,
left: gridInsets.left + viewInsets.left,
};
const scrolled = viewSettings.scrolled;
const showBarsOnScroll = viewSettings.showBarsOnScroll;
const showHeader = viewSettings.showHeader && (scrolled ? showBarsOnScroll : true);
@@ -72,10 +101,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
key={bookKey}
className={clsx(
'relative h-full w-full overflow-hidden',
index === 0 &&
!viewSettings?.scrolled &&
appService?.hasSafeAreaInset &&
'pt-[env(safe-area-inset-top)]',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
)}
>
@@ -87,16 +112,22 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isHoveredAnim={bookKeys.length > 2}
onCloseBook={onCloseBook}
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
gridInsets={gridInsets}
/>
<FoliateViewer
bookKey={bookKey}
bookDoc={bookDoc}
config={config}
contentInsets={contentInsets}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
{viewSettings.vertical && viewSettings.scrolled && (
<>
{(showFooter || viewSettings.doubleBorder) && (
<div
className='bg-base-100 absolute left-0 top-0 h-full'
style={{
width: `calc(${horizontalGapPercent}%)`,
height: `calc(100% - ${verticalMarginPixels}px)`,
width: `calc(${contentInsets.left + (showFooter ? 32 : 0)}px)`,
height: `calc(100%)`,
}}
/>
)}
@@ -104,8 +135,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
className='bg-base-100 absolute right-0 top-0 h-full'
style={{
width: `calc(${horizontalGapPercent}%)`,
height: `calc(100% - ${verticalMarginPixels}px)`,
width: `calc(${contentInsets.right + (showHeader ? 32 : 0)}px)`,
height: `calc(100%)`,
}}
/>
)}
@@ -117,7 +148,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
showFooter={showFooter}
borderColor={viewSettings.borderColor}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
contentInsets={contentInsets}
/>
)}
{showHeader && (
@@ -127,7 +158,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
contentInsets={contentInsets}
gridInsets={gridInsets}
/>
)}
<HintInfo
@@ -136,7 +168,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
contentInsets={contentInsets}
gridInsets={gridInsets}
/>
{showFooter && (
<ProgressInfoView
@@ -146,7 +179,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
pageinfo={pageinfo}
timeinfo={timeinfo}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
contentInsets={contentInsets}
gridInsets={gridInsets}
/>
)}
<Annotator bookKey={bookKey} />
@@ -157,12 +191,12 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
section={section}
pageinfo={pageinfo}
isHoveredAnim={false}
gridInsets={gridInsets}
/>
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
</div>
);
})}
<TTSControl />
</div>
);
};
@@ -1,31 +1,32 @@
import { Insets } from '@/types/misc';
interface DoubleBorderProps {
borderColor: string;
horizontalGap: number;
verticalMargin: number;
showHeader: boolean;
showFooter: boolean;
contentInsets: Insets;
}
const paddingPx = 10;
const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderColor,
horizontalGap,
verticalMargin,
showHeader,
showFooter,
contentInsets,
}) => {
return (
<>
<div>
{/* outter frame */}
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `4px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px + ${paddingPx * 2}px)`,
top: `calc(${verticalMargin}px - ${paddingPx}px)`,
left: `calc(${horizontalGap}% - ${showFooter ? 32 : 0}px - ${paddingPx}px)`,
right: `calc(${horizontalGap}% - ${showHeader ? 32 : 0}px - ${paddingPx}px)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px + ${paddingPx * 2}px)`,
top: `calc(${contentInsets.top}px - ${paddingPx}px)`,
left: `calc(${contentInsets.left}px - ${paddingPx}px)`,
right: `calc(${contentInsets.right}px - ${paddingPx}px)`,
}}
></div>
{/* inner frame */}
@@ -33,10 +34,10 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
className={'borderframe pointer-events-none absolute'}
style={{
border: `1px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: showFooter ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
right: showHeader ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(${contentInsets.left + (showFooter ? 32 : 0)}px`,
right: `calc(${contentInsets.right + (showHeader ? 32 : 0)}px`,
}}
/>
{/* footer */}
@@ -48,9 +49,9 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderBottom: `1px solid ${borderColor}`,
borderLeft: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(${horizontalGap}% - 32px)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(${contentInsets.left}px)`,
}}
/>
)}
@@ -63,13 +64,13 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderBottom: `1px solid ${borderColor}`,
borderRight: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(100% - ${horizontalGap}%)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(100% - ${contentInsets.right}px - 32px)`,
}}
/>
)}
</>
</div>
);
};
@@ -2,6 +2,7 @@ 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 { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
@@ -29,27 +30,38 @@ import {
import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { isCJKLang } from '@/utils/lang';
import { isTauriAppPlatform } from '@/services/environment';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
declare global {
interface Window {
eval(script: string): void;
}
}
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);
contentInsets: Insets;
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey);
const viewRef = useRef<FoliateView | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isViewCreated = useRef(false);
const [toastMessage, setToastMessage] = useState('');
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
@@ -80,7 +92,7 @@ const FoliateViewer: React.FC<{
.then((data) => {
const viewSettings = getViewSettings(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(viewSettings, width, height, data);
return transformStylesheet(width, height, data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
@@ -117,6 +129,11 @@ const FoliateViewer: React.FC<{
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
// Inline scripts in tauri platforms are not executed by default
if (viewSettings.allowScript && isTauriAppPlatform()) {
evalInlineScripts(detail.doc);
}
// only call on load if we have highlighting turned on.
if (viewSettings.codeHighlighting) {
manageSyntaxHighlighting(detail.doc, viewSettings);
@@ -139,6 +156,22 @@ const FoliateViewer: React.FC<{
}
};
const evalInlineScripts = (doc: Document) => {
if (doc.defaultView && doc.defaultView.frameElement) {
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
const scripts = doc.querySelectorAll('script:not([src])');
scripts.forEach((script, index) => {
const scriptContent = script.textContent || script.innerHTML;
try {
console.warn('Evaluating inline scripts in iframe');
iframe.contentWindow?.eval(scriptContent);
} catch (error) {
console.error(`Error executing iframe script ${index + 1}:`, error);
}
});
}
};
const docRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
@@ -166,14 +199,6 @@ const FoliateViewer: React.FC<{
onRendererRelocate: docRelocateHandler,
});
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode]);
useEffect(() => {
if (isViewCreated.current) return;
isViewCreated.current = true;
@@ -186,10 +211,6 @@ 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 viewSettings = getViewSettings(bookKey)!;
const writingMode = viewSettings.writingMode;
if (writingMode) {
@@ -215,15 +236,13 @@ const FoliateViewer: React.FC<{
detail.allowScript = viewSettings.allowScript ?? false;
}
});
const viewWidth = appService?.isMobile ? screen.width : window.innerWidth;
const viewHeight = appService?.isMobile ? screen.height : window.innerHeight;
const width = viewWidth - insets.left - insets.right;
const height = viewHeight - insets.top - insets.bottom;
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
const isScrolled = viewSettings.scrolled!;
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);
@@ -237,12 +256,10 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
view.renderer.setAttribute('flow', isScrolled ? 'scrolled' : 'paginated');
view.renderer.setAttribute('margin', `${marginPx}px`);
view.renderer.setAttribute('gap', `${gapPercent}%`);
view.renderer.setAttribute('max-column-count', maxColumnCount);
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
applyMarginAndGap();
const lastLocation = config.location;
if (lastLocation) {
@@ -256,15 +273,63 @@ const FoliateViewer: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const applyMarginAndGap = () => {
const viewSettings = getViewSettings(bookKey)!;
const viewInsets = getViewInsets(viewSettings);
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
const showDoubleBorderHeader = showDoubleBorder && viewSettings.showHeader;
const showDoubleBorderFooter = showDoubleBorder && viewSettings.showFooter;
const showTopHeader = viewSettings.showHeader && !viewSettings.vertical;
const showBottomFooter = viewSettings.showFooter && !viewSettings.vertical;
const moreTopInset = showTopHeader ? Math.max(0, 44 - insets.top) : 0;
const moreBottomInset = showBottomFooter ? Math.max(0, 44 - insets.bottom) : 0;
const moreRightInset = showDoubleBorderHeader ? 32 : 0;
const moreLeftInset = showDoubleBorderFooter ? 32 : 0;
const topMargin = (showTopHeader ? insets.top : viewInsets.top) + moreTopInset;
const rightMargin = insets.right + moreRightInset;
const bottomMargin = (showBottomFooter ? insets.bottom : viewInsets.bottom) + moreBottomInset;
const leftMargin = insets.left + moreLeftInset;
viewRef.current?.renderer.setAttribute('margin-top', `${topMargin}px`);
viewRef.current?.renderer.setAttribute('margin-right', `${rightMargin}px`);
viewRef.current?.renderer.setAttribute('margin-bottom', `${bottomMargin}px`);
viewRef.current?.renderer.setAttribute('margin-left', `${leftMargin}px`);
viewRef.current?.renderer.setAttribute('gap', `${viewSettings.gapPercent}%`);
if (viewSettings.scrolled) {
viewRef.current?.renderer.setAttribute('flow', 'scrolled');
}
};
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode]);
useEffect(() => {
if (viewRef.current && viewRef.current.renderer && viewSettings) {
applyMarginAndGap();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
insets.top,
insets.right,
insets.bottom,
insets.left,
viewSettings?.doubleBorder,
viewSettings?.showHeader,
viewSettings?.showFooter,
]);
return (
<>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
</>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
);
};
@@ -21,8 +21,10 @@ import { eventDispatcher } from '@/utils/event';
import { viewPagination } from '../hooks/usePagination';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { PageInfo } from '@/types/book';
import { Insets } from '@/types/misc';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
import TTSControl from './tts/TTSControl';
interface FooterBarProps {
bookKey: string;
@@ -30,6 +32,7 @@ interface FooterBarProps {
section?: PageInfo;
pageinfo?: PageInfo;
isHoveredAnim: boolean;
gridInsets: Insets;
}
const FooterBar: React.FC<FooterBarProps> = ({
@@ -38,6 +41,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
section,
pageinfo,
isHoveredAnim,
gridInsets,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
@@ -64,9 +68,13 @@ const FooterBar: React.FC<FooterBarProps> = ({
};
const handleMarginChange = (value: number) => {
const viewSettings = getViewSettings(bookKey)!;
const marginPx = Math.round((value / 100) * 88);
const gapPercent = Math.round((value / 100) * 10);
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx, false, false);
viewSettings.marginTopPx = marginPx;
viewSettings.marginBottomPx = marginPx / 2;
viewSettings.marginLeftPx = marginPx / 2;
viewSettings.marginRightPx = marginPx / 2;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
@@ -155,6 +163,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
? (progressInfo!.current + 1) / progressInfo!.total || 0
: 0;
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
return (
<>
<div
@@ -194,9 +204,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom) + 64px)'
: '64px',
bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px',
}}
>
<div className='flex w-full items-center justify-between gap-x-6'>
@@ -250,9 +258,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom) + 64px)'
: '64px',
bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px',
}}
>
<Slider
@@ -269,7 +275,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
initialValue={getMarginProgressValue(
viewSettings?.marginPx ?? 44,
viewSettings?.marginTopPx ?? 44,
viewSettings?.gapPercent ?? 5,
)}
bubbleElement={<TbBoxMargin size={marginIconSize} />}
@@ -292,8 +298,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
<div
className={clsx(
'bg-base-200 z-50 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
style={{
paddingBottom: isMobile ? `${gridInsets.bottom + 16}px` : '0px',
}}
>
<Button
icon={<TOCIcon size={tocIconSize} className='' />}
@@ -318,8 +326,13 @@ const FooterBar: React.FC<FooterBarProps> = ({
onClick={() => handleSetActionTab('tts')}
/>
</div>
{/* Desktop footer bar */}
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
{/* Desktop / Pad footer bar */}
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
style={{
bottom: isMobile ? `${gridInsets.bottom / 2}px` : '0px',
}}
>
<Button
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
@@ -372,6 +385,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
/>
</div>
</div>
<TTSControl bookKey={bookKey} />
</>
);
};
@@ -66,7 +66,10 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
footnoteRef.current?.replaceChildren(view);
const { renderer } = view;
renderer.setAttribute('flow', 'scrolled');
renderer.setAttribute('margin', '0px');
renderer.setAttribute('margin-top', '0px');
renderer.setAttribute('margin-right', '0px');
renderer.setAttribute('margin-bottom', '0px');
renderer.setAttribute('margin-left', '0px');
renderer.setAttribute('gap', '0%');
const viewSettings = getViewSettings(bookKey)!;
const themeCode = getThemeCode();
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
@@ -22,6 +23,7 @@ interface HeaderBarProps {
bookTitle: string;
isTopLeft: boolean;
isHoveredAnim: boolean;
gridInsets: Insets;
onCloseBook: (bookKey: string) => void;
onSetSettingsDialogOpen: (open: boolean) => void;
}
@@ -31,6 +33,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
bookTitle,
isTopLeft,
isHoveredAnim,
gridInsets,
onCloseBook,
onSetSettingsDialogOpen,
}) => {
@@ -77,10 +80,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
return (
<div
className={clsx(
'bg-base-100 absolute top-0 w-full',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
className={clsx('bg-base-100 absolute top-0 w-full')}
style={{
paddingTop: appService?.hasSafeAreaInset ? `${gridInsets.top}px` : '0px',
}}
>
<div
className={clsx('absolute top-0 z-10 hidden h-11 w-full sm:flex')}
@@ -89,11 +92,11 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
/>
<div
className={clsx(
'bg-base-100 absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
'bg-base-100 absolute left-0 right-0 top-0 z-10',
isVisible ? 'visible' : 'hidden',
)}
style={{
height: systemUIVisible ? `max(env(safe-area-inset-top), ${statusBarHeight}px)` : '',
height: systemUIVisible ? `${Math.max(gridInsets.top, statusBarHeight)}px` : '0px',
}}
/>
<div
@@ -110,8 +113,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
)}
style={{
marginTop: systemUIVisible
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: 'env(safe-area-inset-top)',
? `${Math.max(gridInsets.top, statusBarHeight)}px`
: `${gridInsets.top}px`,
}}
onMouseLeave={() => !appService?.isMobile && setHoveredBookKey('')}
>
@@ -1,5 +1,8 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { eventDispatcher } from '@/utils/event';
interface SectionInfoProps {
@@ -8,7 +11,8 @@ interface SectionInfoProps {
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
contentInsets: Insets;
gridInsets: Insets;
}
const HintInfo: React.FC<SectionInfoProps> = ({
@@ -17,8 +21,16 @@ const HintInfo: React.FC<SectionInfoProps> = ({
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
contentInsets,
gridInsets,
}) => {
const { appService } = useEnv();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const topInset = Math.max(
gridInsets.top,
appService?.isAndroidApp && systemUIVisible ? statusBarHeight / 2 : 0,
);
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
const hintTimeout = useRef(2000);
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -50,14 +62,16 @@ const HintInfo: React.FC<SectionInfoProps> = ({
<>
<div
className={clsx(
'absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
'absolute left-0 right-0 top-0 z-10',
hintMessage ? 'bg-base-100' : 'bg-transparent',
)}
style={{
height: `${topInset}px`,
}}
/>
<div
className={clsx(
'hintinfo absolute flex items-center justify-end overflow-hidden ps-2',
'mt-[env(safe-area-inset-top)]',
hintMessage ? 'bg-base-100' : 'bg-transparent',
isVertical ? 'writing-vertical-rl' : 'top-0 h-[44px]',
isScrolled
@@ -71,11 +85,16 @@ const HintInfo: React.FC<SectionInfoProps> = ({
style={
isVertical
? {
bottom: `${verticalMargin * 1.5}px`,
left: `calc(100% - ${horizontalGap}%)`,
bottom: `${contentInsets.bottom * 1.5}px`,
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
}
: { insetInlineEnd: `${horizontalGap}%` }
: {
top: `${topInset}px`,
insetInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
}
}
>
<h2 className={clsx('text-neutral-content text-center font-sans text-xs font-light')}>
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import React from 'react';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -12,7 +13,8 @@ interface PageInfoProps {
pageinfo?: PageInfo;
timeinfo?: TimeInfo;
horizontalGap: number;
verticalMargin: number;
contentInsets: Insets;
gridInsets: Insets;
}
const ProgressInfoView: React.FC<PageInfoProps> = ({
@@ -22,7 +24,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
pageinfo,
timeinfo,
horizontalGap,
verticalMargin,
contentInsets,
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -51,25 +54,25 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
return (
<div
className={clsx(
'pageinfo absolute bottom-0 flex items-center justify-between',
'progressinfo absolute bottom-0 flex items-center justify-between',
'text-neutral-content font-sans text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'h-12 w-full',
isVertical ? 'writing-vertical-rl' : 'h-[52px] w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
style={
isVertical
? {
bottom: `${verticalMargin * 1.5}px`,
left: showDoubleBorder ? `calc(${horizontalGap}% - 32px)` : 0,
bottom: `${contentInsets.bottom * 1.5}px`,
left: showDoubleBorder
? `calc(${contentInsets.left}px)`
: `calc(${Math.max(0, contentInsets.left - 32)}px)`,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 3}px)`,
height: `calc(100% - ${((contentInsets.top + contentInsets.bottom) / 2) * 3}px)`,
}
: {
paddingInlineStart: `${horizontalGap}%`,
paddingInlineEnd: `${horizontalGap}%`,
paddingBottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom)*0.67)'
: 0,
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.67}px` : 0,
}
}
>
@@ -17,10 +17,13 @@ import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { eventDispatcher } from '@/utils/event';
import { interceptWindowOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/utils/font';
import { setSystemUIVisibility } from '@/utils/bridge';
import { isTauriAppPlatform } from '@/services/environment';
import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
import { initDayjs } from '@/utils/time';
import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
@@ -30,33 +33,23 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { settings, setSettings } = useSettingsStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore();
const { isDarkMode, systemUIAlwaysHidden, showSystemUI, dismissSystemUI } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [libraryLoaded, setLibraryLoaded] = useState(false);
const isInitiating = useRef(false);
useTheme({ systemUIVisible: false, appThemeColor: 'base-100' });
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
mountAdditionalFonts(document);
interceptWindowOpen();
if (isTauriAppPlatform()) {
setTimeout(getSysFontsList, 3000);
}
initDayjs(getLocale());
}, []);
useEffect(() => {
const handleVisibilityChange = () => {
if (appService?.isMobileApp && !document.hidden) {
dismissSystemUI();
setSystemUIVisibility({ visible: false, darkMode: isDarkMode });
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isDarkMode]);
const handleKeyDown = (event: CustomEvent) => {
if (event.detail.keyName === 'Back') {
setSideBarVisible(false);
@@ -102,9 +95,10 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useEffect(() => {
if (!appService?.isMobileApp) return;
const systemUIVisible = !!hoveredBookKey;
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
if (systemUIVisible) {
const systemUIVisible = !!hoveredBookKey || settings.alwaysShowStatusBar;
const visible = systemUIVisible && !systemUIAlwaysHidden;
setSystemUIVisibility({ visible, darkMode: isDarkMode });
if (visible) {
showSystemUI();
} else {
dismissSystemUI();
@@ -36,7 +36,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { sideBarBookKey, setSideBarBookKey } = useSidebarStore();
const { saveSettings } = useSettingsStore();
const { getConfig, getBookData, saveConfig } = useBookDataStore();
const { getView, setBookKeys } = useReaderStore();
const { getView, setBookKeys, getViewSettings } = useReaderStore();
const { initViewState, getViewState, clearViewState } = useReaderStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const isInitiating = useRef(false);
@@ -150,7 +150,8 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (!bookKeys || bookKeys.length === 0) return null;
const bookData = getBookData(bookKeys[0]!);
if (!bookData || !bookData.book || !bookData.bookDoc) {
const viewSettings = getViewSettings(bookKeys[0]!);
if (!bookData || !bookData.book || !bookData.bookDoc || !viewSettings) {
setTimeout(() => setLoading(true), 300);
return (
loading && (
@@ -1,5 +1,8 @@
import clsx from 'clsx';
import React from 'react';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
interface SectionInfoProps {
section?: string;
@@ -7,7 +10,8 @@ interface SectionInfoProps {
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
contentInsets: Insets;
gridInsets: Insets;
}
const SectionInfo: React.FC<SectionInfoProps> = ({
@@ -16,32 +20,48 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
contentInsets,
gridInsets,
}) => {
const { appService } = useEnv();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const topInset = Math.max(
gridInsets.top,
appService?.isAndroidApp && systemUIVisible ? statusBarHeight / 2 : 0,
);
return (
<>
<div
className={clsx(
'absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
'absolute left-0 right-0 top-0 z-10',
isScrolled && !isVertical && 'bg-base-100',
)}
style={{
height: `${topInset}px`,
}}
/>
<div
className={clsx(
'sectioninfo absolute flex items-center overflow-hidden',
'mt-[env(safe-area-inset-top)]',
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
isScrolled && !isVertical && 'bg-base-100',
)}
style={
isVertical
? {
top: `${verticalMargin * 1.5}px`,
left: `calc(100% - ${horizontalGap}%)`,
top: `${contentInsets.top * 1.5}px`,
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 2}px)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
}
: {
top: `${topInset}px`,
insetInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
width: `calc(100% - ${contentInsets.left + contentInsets.right}px)`,
}
: { insetInlineStart: `${horizontalGap}%`, width: `calc(100% - ${horizontalGap * 2}%)` }
}
>
<h2
@@ -6,9 +6,8 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { isSameLang } from '@/utils/lang';
import { isTranslationAvailable } from '@/services/translators/utils';
import Button from '@/components/Button';
import { getLocale } from '@/utils/misc';
const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
const _ = useTranslation();
@@ -19,9 +18,9 @@ const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
const primaryLanguage = bookData?.book?.primaryLanguage;
const targetLanguage = viewSettings.translateTargetLang;
const [translationAvailable, setTranslationAvailable] = useState(
isTranslationAvailable(bookData?.book, viewSettings.translateTargetLang),
);
useEffect(() => {
if (translationEnabled === viewSettings.translationEnabled) return;
@@ -34,19 +33,27 @@ const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translationEnabled]);
useEffect(() => {
setTranslationEnabled(viewSettings.translationEnabled);
setTranslationAvailable(
isTranslationAvailable(bookData?.book, viewSettings.translateTargetLang),
);
}, [bookData, viewSettings.translationEnabled, viewSettings.translateTargetLang]);
return (
<Button
icon={
<RiTranslateAi className={translationEnabled ? 'text-blue-500' : 'text-base-content'} />
}
disabled={
!bookData ||
bookData.book?.format === 'PDF' ||
isSameLang(primaryLanguage, targetLanguage) ||
(!targetLanguage && isSameLang(primaryLanguage, getLocale()))
}
disabled={!translationAvailable}
onClick={() => setTranslationEnabled(!translationEnabled)}
tooltip={translationEnabled ? _('Disable Translation') : _('Enable Translation')}
tooltip={
translationAvailable
? translationEnabled
? _('Disable Translation')
: _('Enable Translation')
: _('Translation Not Available')
}
tooltipDirection='bottom'
></Button>
);
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { FiSearch } from 'react-icons/fi';
import { FiCopy } from 'react-icons/fi';
import { PiHighlighterFill } from 'react-icons/pi';
@@ -26,6 +26,7 @@ import { useTextSelector } from '../../hooks/useTextSelector';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/utils/toc';
import { throttle } from '@/utils/throttle';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
@@ -78,13 +79,17 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 0;
const handleDismissPopup = () => {
setSelection(null);
setShowAnnotPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleDismissPopup = useCallback(
throttle(() => {
setSelection(null);
setShowAnnotPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
}, 500),
[],
);
const handleDismissPopupAndSelection = () => {
handleDismissPopup();
@@ -69,16 +69,18 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
className={clsx(
'w-4 p-0 text-center leading-none',
style === 'highlight' &&
(selectedStyle === 'highlight' ? `bg-${selectedColor}-400` : `bg-gray-300`),
(selectedStyle === 'highlight'
? `bg-${selectedColor}-300 pt-[2px]`
: `bg-gray-300 pt-[2px]`),
(style === 'underline' || style === 'squiggly') &&
'text-gray-300 underline decoration-2',
style === 'underline' &&
(selectedStyle === 'underline'
? `decoration-${selectedColor}-400`
? `decoration-${selectedColor}-300`
: `decoration-gray-300`),
style === 'squiggly' &&
(selectedStyle === 'squiggly'
? `decoration-wavy decoration-${selectedColor}-400`
? `decoration-wavy decoration-${selectedColor}-300`
: `decoration-gray-300 decoration-wavy`),
)}
>
@@ -100,10 +102,10 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
key={color}
onClick={() => handleSelectColor(color)}
style={{ width: size16, height: size16 }}
className={clsx(`rounded-full p-0`, selectedColor !== color && `bg-${color}-400`)}
className={clsx(`rounded-full p-0`, selectedColor !== color && `bg-${color}-300`)}
>
{selectedColor === color && (
<FaCheckCircle size={size16} className={clsx(`fill-${color}-400`)} />
<FaCheckCircle size={size16} className={clsx(`fill-${color}-300`)} />
)}
</button>
))}
@@ -7,20 +7,14 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATED_LANGS } from '@/services/constants';
import { UseTranslatorOptions, getTranslators } from '@/services/translators';
import { localeToLang } from '@/utils/lang';
import Select from '@/components/Select';
const notSupportedLangs = ['hi', 'vi'];
const notSupportedLangs = [''];
const generateTranslatorLangs = () => {
const langs = { ...TRANSLATED_LANGS };
const result: Record<string, string> = {};
for (const [code, name] of Object.entries(langs)) {
if (notSupportedLangs.includes(code)) continue;
const newCode = localeToLang(code).toUpperCase();
result[newCode] = name;
}
return result;
return Object.fromEntries(
Object.entries(TRANSLATED_LANGS).filter(([code]) => !notSupportedLangs.includes(code)),
);
};
const TRANSLATOR_LANGS = generateTranslatorLangs();
@@ -205,7 +199,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
</div>
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between bg-gray-600 px-4'>
{provider && !loading && (
<div className='text-xs opacity-60'>
<div className='line-clamp-1 text-xs opacity-60'>
{error
? ''
: _('Translated by {{provider}}.', {
@@ -16,10 +16,11 @@ const NotebookHeader: React.FC<{
}> = ({ isPinned, isSearchBarVisible, handleClose, handleTogglePin, handleToggleSearchBar }) => {
const _ = useTranslation();
const iconSize14 = useResponsiveSize(14);
const iconSize18 = useResponsiveSize(18);
return (
<div className='notebook-header relative flex h-11 items-center px-3' dir='ltr'>
<div className='absolute inset-0 z-[-1] flex items-center justify-center space-x-2'>
<LuNotebookPen />
<LuNotebookPen size={iconSize18} />
<div className='notebook-title hidden text-sm font-medium sm:flex'>{_('Notebook')}</div>
</div>
<div className='flex w-full items-center gap-x-4'>
@@ -44,7 +45,7 @@ const NotebookHeader: React.FC<{
onClick={handleToggleSearchBar}
className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0', isSearchBarVisible && 'bg-base-300')}
>
<FiSearch />
<FiSearch size={iconSize18} />
</button>
</div>
</div>
@@ -216,8 +216,9 @@ const Notebook: React.FC = ({}) => {
}
`}</style>
<div
className='drag-bar absolute left-0 top-0 h-full w-0.5 cursor-col-resize'
className='drag-bar absolute left-0 top-0 -m-3 h-full w-0.5 cursor-col-resize p-3'
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
/>
<div className='flex-shrink-0'>
<NotebookHeader
@@ -8,6 +8,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { getTranslators } from '@/services/translators';
import { TRANSLATED_LANGS } from '@/services/constants';
import { initDayjs } from '@/utils/time';
import Select from '@/components/Select';
const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
@@ -43,7 +44,9 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleSelectUILang = (event: React.ChangeEvent<HTMLSelectElement>) => {
const option = event.target.value;
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
i18n.changeLanguage(option ? option : navigator.language);
const locale = option ? option : navigator.language;
i18n.changeLanguage(locale);
initDayjs(locale);
};
const getTranslationProviderOptions = () => {
@@ -18,11 +18,12 @@ import NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { getView, getViewSettings, getGridInsets, setViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const gridInsets = getGridInsets(bookKey)!;
const [paragraphMargin, setParagraphMargin] = useState(viewSettings.paragraphMargin!);
const [lineHeight, setLineHeight] = useState(viewSettings.lineHeight!);
@@ -31,10 +32,23 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [textIndent, setTextIndent] = useState(viewSettings.textIndent!);
const [fullJustification, setFullJustification] = useState(viewSettings.fullJustification!);
const [hyphenation, setHyphenation] = useState(viewSettings.hyphenation!);
const [marginPx, setMarginPx] = useState(viewSettings.marginPx!);
const [marginTopPx, setMarginTopPx] = useState(
viewSettings.marginPx || viewSettings.marginTopPx!,
);
const [marginBottomPx, setMarginBottomPx] = useState(viewSettings.marginBottomPx!);
const [marginLeftPx, setMarginLeftPx] = useState(viewSettings.marginLeftPx!);
const [marginRightPx, setMarginRightPx] = useState(viewSettings.marginRightPx!);
const [compactMarginTopPx, setCompactMarginTopPx] = useState(
viewSettings.compactMarginPx || viewSettings.compactMarginTopPx!,
);
const [compactMarginBottomPx, setCompactMarginBottomPx] = useState(
viewSettings.compactMarginBottomPx!,
);
const [gapPercent, setGapPercent] = useState(viewSettings.gapPercent!);
const [compactMarginPx, setCompactMarginPx] = useState(viewSettings.compactMarginPx!);
const [compactGapPercent, setCompactGapPercent] = useState(viewSettings.compactGapPercent!);
const [compactMarginLeftPx, setCompactMarginLeftPx] = useState(viewSettings.compactMarginLeftPx!);
const [compactMarginRightPx, setCompactMarginRightPx] = useState(
viewSettings.compactMarginRightPx!,
);
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
@@ -84,18 +98,90 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [hyphenation]);
useEffect(() => {
if (marginPx === viewSettings.marginPx) return;
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
if (marginTopPx === viewSettings.marginTopPx) return;
if (viewSettings.marginPx !== undefined) {
saveViewSettings(envConfig, bookKey, 'marginPx', undefined, false, false);
}
saveViewSettings(envConfig, bookKey, 'marginTopPx', marginTopPx, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marginPx]);
}, [marginTopPx]);
useEffect(() => {
if (compactMarginPx === viewSettings.compactMarginPx) return;
saveViewSettings(envConfig, bookKey, 'compactMarginPx', compactMarginPx, false, false);
view?.renderer.setAttribute('margin', `${compactMarginPx}px`);
if (marginBottomPx === viewSettings.marginBottomPx) return;
if (viewSettings.marginPx !== undefined) {
saveViewSettings(envConfig, bookKey, 'marginPx', undefined, false, false);
}
saveViewSettings(envConfig, bookKey, 'marginBottomPx', marginBottomPx, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactMarginPx]);
}, [marginBottomPx]);
useEffect(() => {
if (marginRightPx === viewSettings.marginRightPx) return;
if (viewSettings.marginPx !== undefined) {
saveViewSettings(envConfig, bookKey, 'marginPx', undefined, false, false);
}
saveViewSettings(envConfig, bookKey, 'marginRightPx', marginRightPx, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marginRightPx]);
useEffect(() => {
if (marginLeftPx === viewSettings.marginLeftPx) return;
if (viewSettings.marginPx !== undefined) {
saveViewSettings(envConfig, bookKey, 'marginPx', undefined, false, false);
}
saveViewSettings(envConfig, bookKey, 'marginLeftPx', marginLeftPx, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marginLeftPx]);
useEffect(() => {
if (compactMarginTopPx === viewSettings.compactMarginTopPx) return;
if (viewSettings.compactMarginPx !== undefined) {
saveViewSettings(envConfig, bookKey, 'compactMarginPx', undefined, false, false);
}
saveViewSettings(envConfig, bookKey, 'compactMarginTopPx', compactMarginTopPx, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactMarginTopPx]);
useEffect(() => {
if (compactMarginBottomPx === viewSettings.compactMarginBottomPx) return;
if (viewSettings.compactMarginPx !== undefined) {
saveViewSettings(envConfig, bookKey, 'compactMarginPx', undefined, false, false);
}
saveViewSettings(
envConfig,
bookKey,
'compactMarginBottomPx',
compactMarginBottomPx,
false,
false,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactMarginBottomPx]);
useEffect(() => {
if (compactMarginRightPx === viewSettings.compactMarginRightPx) return;
if (viewSettings.compactMarginPx !== undefined) {
saveViewSettings(envConfig, bookKey, 'compactMarginPx', undefined, false, false);
}
saveViewSettings(
envConfig,
bookKey,
'compactMarginRightPx',
compactMarginRightPx,
false,
false,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactMarginRightPx]);
useEffect(() => {
if (compactMarginLeftPx === viewSettings.compactMarginLeftPx) return;
if (viewSettings.compactMarginPx !== undefined) {
saveViewSettings(envConfig, bookKey, 'compactMarginPx', undefined, false, false);
}
saveViewSettings(envConfig, bookKey, 'compactMarginLeftPx', compactMarginLeftPx, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactMarginLeftPx]);
useEffect(() => {
if (gapPercent === viewSettings.gapPercent) return;
@@ -107,16 +193,6 @@ 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);
@@ -171,14 +247,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
if (doubleBorder === viewSettings.doubleBorder) return;
if (doubleBorder && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'doubleBorder', doubleBorder, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doubleBorder]);
@@ -203,59 +271,35 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPageNumber]);
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) {
viewSettings.marginPx = Math.max(viewSettings.marginPx, 44);
setMarginPx(viewSettings.marginPx);
setViewSettings(bookKey, viewSettings);
} else if (showHeader && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
const minMarginTop = Math.max(0, Math.round((44 - gridInsets.top) / 4) * 4);
viewSettings.marginTopPx = Math.max(viewSettings.marginTopPx, minMarginTop);
setMarginTopPx(viewSettings.marginTopPx);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader, false, false);
applyMarginAndGap();
// Margin and gap settings will be applied in FoliateViewer
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showHeader]);
useEffect(() => {
if (showFooter === viewSettings.showFooter) return;
if (showFooter && !viewSettings.vertical) {
viewSettings.marginPx = Math.max(viewSettings.marginPx, 44);
setMarginPx(viewSettings.marginPx);
setViewSettings(bookKey, viewSettings);
} else if (showFooter && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
const minMarginBottom = Math.max(0, Math.round((44 - gridInsets.bottom) / 4) * 4);
viewSettings.marginBottomPx = Math.max(viewSettings.marginBottomPx, minMarginBottom);
setMarginBottomPx(viewSettings.marginBottomPx);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter, false, false);
applyMarginAndGap();
// Margin and gap settings will be applied in FoliateViewer
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showFooter]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const mightBeRTLBook = MIGHT_BE_RTL_LANGS.includes(langCode) || isCJKEnv();
const isVertical = viewSettings.vertical || writingMode.includes('vertical');
return (
<div className='my-4 w-full space-y-6'>
@@ -418,22 +462,50 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
label={_('Vertical Margins (px)')}
value={showFooter || showHeader ? marginPx : compactMarginPx}
onChange={showFooter || showHeader ? setMarginPx : setCompactMarginPx}
min={!viewSettings.vertical && (showFooter || showHeader) ? 44 : 0}
label={_('Top Margin (px)')}
value={showHeader && !isVertical ? marginTopPx : compactMarginTopPx}
onChange={showHeader && !isVertical ? setMarginTopPx : setCompactMarginTopPx}
min={
showHeader && !isVertical
? Math.max(0, Math.round((44 - gridInsets.top) / 4) * 4)
: 0
}
max={88}
step={4}
/>
<NumberInput
label={_('Horizontal Margins (%)')}
value={showFooter || showHeader ? gapPercent : compactGapPercent}
onChange={showFooter || showHeader ? setGapPercent : setCompactGapPercent}
label={_('Bottom Margin (px)')}
value={showFooter && !isVertical ? marginBottomPx : compactMarginBottomPx}
onChange={showFooter && !isVertical ? setMarginBottomPx : setCompactMarginBottomPx}
min={
viewSettings.vertical && (showFooter || showHeader)
? Math.ceil(4800 / window.innerWidth)
showFooter && !isVertical
? Math.max(0, Math.round((44 - gridInsets.bottom) / 4) * 4)
: 0
}
max={88}
step={4}
/>
<NumberInput
label={_('Left Margin (px)')}
value={showFooter && isVertical ? marginLeftPx : compactMarginLeftPx}
onChange={showFooter && isVertical ? setMarginLeftPx : setCompactMarginLeftPx}
min={0}
max={88}
step={4}
/>
<NumberInput
label={_('Right Margin (px)')}
value={showHeader && isVertical ? marginRightPx : compactMarginRightPx}
onChange={showHeader && isVertical ? setMarginRightPx : setCompactMarginRightPx}
min={0}
max={88}
step={4}
/>
<NumberInput
label={_('Column Gap (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
max={30}
/>
<NumberInput
@@ -1,4 +1,5 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import React from 'react';
import { marked } from 'marked';
@@ -103,7 +104,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'mt-2')}>
<span
className={clsx(
'inline',
'inline leading-normal',
item.note && 'content font-size-xs text-gray-500',
(item.style === 'underline' || item.style === 'squiggly') &&
'underline decoration-2',
@@ -130,47 +131,54 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
}
onClick={(e) => e.stopPropagation()}
>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
{item.note && (
<button
<div className='flex cursor-default items-center justify-between'>
<div className='flex items-center'>
<span className='text-sm text-gray-500 sm:text-xs'>
{dayjs(item.createdAt).fromNow()}
</span>
</div>
<div className='flex items-center justify-end space-x-3 p-2' dir='ltr'>
{item.note && (
<div
className={clsx(
'content settings-content cursor-pointer',
'flex items-end p-0 hover:bg-transparent',
)}
onClick={editNote.bind(null, item)}
>
<div
className={clsx(
'align-bottom text-blue-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-blue-600',
)}
>
{_('Edit')}
</div>
</div>
)}
<div
className={clsx(
'btn btn-ghost content settings-content hover:bg-transparent',
'flex h-4 min-h-4 items-end p-0',
'content settings-content cursor-pointer',
'flex items-end p-0 hover:bg-transparent',
)}
onClick={editNote.bind(null, item)}
onClick={deleteNote.bind(null, item)}
>
<div
className={clsx(
'align-bottom text-blue-500',
'align-bottom text-red-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-blue-600',
'hover:text-red-600',
)}
>
{_('Edit')}
{_('Delete')}
</div>
</button>
)}
<button
className={clsx(
'btn btn-ghost content settings-content hover:bg-transparent',
'flex h-4 min-h-4 items-end p-0',
)}
onClick={deleteNote.bind(null, item)}
>
<div
className={clsx(
'align-bottom text-red-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-red-600',
)}
>
{_('Delete')}
</div>
</button>
</div>
</div>
</div>
</li>
@@ -177,6 +177,7 @@ const SideBar: React.FC<{
className={clsx(
'sidebar-container bg-base-200 z-20 flex min-w-60 select-none flex-col',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
'transition-[padding-top] duration-300',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
@@ -248,8 +249,9 @@ const SideBar: React.FC<{
<SidebarContent bookDoc={bookDoc} sideBarBookKey={sideBarBookKey!} />
)}
<div
className='drag-bar absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
className='drag-bar absolute right-0 top-0 -m-3 h-full w-0.5 cursor-col-resize p-3'
onMouseDown={handleHorizontalDragStart}
onTouchStart={handleHorizontalDragStart}
></div>
</div>
{!isSideBarPinned && (
@@ -20,13 +20,16 @@ const POPUP_WIDTH = 282;
const POPUP_HEIGHT = 160;
const POPUP_PADDING = 10;
const TTSControl = () => {
interface TTSControlProps {
bookKey: string;
}
const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
@@ -152,7 +155,9 @@ const TTSControl = () => {
}, [ttsController, bookKey]);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey, range } = event.detail;
const { bookKey: ttsBookKey, range } = event.detail;
if (bookKey !== ttsBookKey) return;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
@@ -182,7 +187,6 @@ const TTSControl = () => {
}
const primaryLang = bookData.book.primaryLanguage;
setBookKey(bookKey);
if (ttsControllerRef.current) {
ttsControllerRef.current.stop();
@@ -230,8 +234,8 @@ const TTSControl = () => {
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey } = event.detail;
if (ttsControllerRef.current) {
const { bookKey: ttsBookKey } = event.detail;
if (ttsControllerRef.current && bookKey === ttsBookKey) {
handleStop(bookKey);
}
};
@@ -274,7 +278,7 @@ const TTSControl = () => {
const handleStop = async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.stop();
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
@@ -17,7 +17,7 @@ interface UseBookShortcutsProps {
}
const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) => {
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { getView, getViewState, getViewSettings, setViewSettings } = useReaderStore();
const { toggleSideBar, setSideBarBookKey } = useSidebarStore();
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const { toggleNotebook } = useNotebookStore();
@@ -136,6 +136,13 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const toggleTTS = () => {
if (!sideBarBookKey) return;
const bookKey = sideBarBookKey;
const viewState = getViewState(bookKey);
eventDispatcher.dispatch(viewState?.ttsEnabled ? 'tts-stop' : 'tts-speak', { bookKey });
};
useShortcuts(
{
onSwitchSideBar: switchSideBar,
@@ -144,8 +151,9 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onToggleScrollMode: toggleScrollMode,
onOpenFontLayoutSettings: () => setFontLayoutSettingsDialogOpen(true),
onToggleSearchBar: showSearchBar,
onReloadPage: reloadPage,
onToggleFullscreen: toggleFullscreen,
onToggleTTS: toggleTTS,
onReloadPage: reloadPage,
onQuitApp: quitApp,
onGoLeft: goLeft,
onGoRight: goRight,
@@ -14,7 +14,7 @@ export const useTextSelector = (
) => {
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewState, getViewSettings } = useReaderStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const primaryLang = bookData.book?.primaryLanguage || 'en';
@@ -94,9 +94,6 @@ export const useTextSelector = (
const handlePointerup = (doc: Document, index: number) => {
// Available on iOS and Desktop, fired at touchend or mouseup
// Note that on Android, pointerup event is fired after an additional touch event
const viewState = getViewState(bookKey);
if (viewState?.ttsEnabled) return;
const sel = doc.getSelection() as Selection;
if (isValidSelection(sel)) {
if (osPlatform === 'ios') {
@@ -116,6 +113,7 @@ export const useTextSelector = (
// Prevent the container from scrolling when text is selected in paginated mode
// FIXME: this is a workaround for issue #873
// TODO: support text selection across pages
handleDismissPopup();
const viewSettings = getViewSettings(bookKey);
if (
appService?.isAndroidApp &&
@@ -1,15 +1,17 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FoliateView } from '@/types/view';
import { UseTranslatorOptions } from '@/services/translators';
import { useReaderStore } from '@/store/readerStore';
import { useTranslator } from '@/hooks/useTranslator';
import { walkTextNodes } from '@/utils/walk';
import { localeToLang } from '@/utils/lang';
import { debounce } from '@/utils/debounce';
import { getLocale } from '@/utils/misc';
export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElement | null) {
const { getViewSettings } = useReaderStore();
const { getViewSettings, getViewState, getProgress } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
const progress = getProgress(bookKey);
const enabled = useRef(viewSettings?.translationEnabled);
const [provider, setProvider] = useState(viewSettings?.translationProvider);
@@ -17,7 +19,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
const { translate } = useTranslator({
provider,
targetLang: localeToLang(targetLang || getLocale()),
targetLang: targetLang || getLocale(),
} as UseTranslatorOptions);
const translateRef = useRef(translate);
@@ -118,7 +120,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
const translateElement = async (el: HTMLElement) => {
if (!enabled.current) return;
const text = el.textContent?.trim();
const text = el.textContent?.replaceAll('\n', '').trim();
if (!text) return;
if (el.querySelector('.translation-target')) {
@@ -134,7 +136,6 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
wrapper.className = `translation-target ${!enabled.current ? 'hidden' : ''}`;
wrapper.setAttribute('translation-element-mark', '1');
wrapper.setAttribute('lang', targetLang || getLocale());
wrapper.appendChild(document.createElement('br'));
const blockWrapper = document.createElement('font');
blockWrapper.className = 'translation-target translation-block-wrapper';
@@ -156,6 +157,61 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
}
};
const findNodeIndicesInRange = (range: Range, nodes: HTMLElement[]) => {
const startContainer = range.startContainer;
const endContainer = range.endContainer;
let startIndex = -1;
let endIndex = -1;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]!;
if (node === startContainer || node.contains(startContainer)) {
if (startIndex === -1) startIndex = i;
}
if (node === endContainer || node.contains(endContainer)) {
endIndex = i;
}
}
if (startIndex !== -1 && endIndex === -1) {
endIndex = startIndex;
}
return { startIndex, endIndex };
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const translateInRange = useCallback(
debounce((range: Range) => {
const nodes = allTextNodes.current;
if (nodes.length === 0) {
console.warn('No text nodes available for translation.');
return;
}
const { startIndex, endIndex } = findNodeIndicesInRange(range, nodes);
if (startIndex === -1) {
console.log('Range not found in text nodes');
return;
}
const beforeStart = Math.max(0, startIndex - 2);
const afterEnd = Math.min(nodes.length - 1, endIndex + 2);
for (let i = beforeStart; i <= afterEnd; i++) {
const node = nodes[i];
if (node) {
translateElement(node);
}
}
}, 500),
[],
);
useEffect(() => {
if (viewState?.ttsEnabled && progress && document.hidden) {
const { range } = progress;
translateInRange(range);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewState?.ttsEnabled, progress]);
useEffect(() => {
if (!viewSettings) return;
+4 -6
View File
@@ -47,7 +47,7 @@ const Dialog: React.FC<DialogProps> = ({
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const dialogRef = useRef<HTMLDialogElement>(null);
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640;
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
const handleKeyDown = (event: KeyboardEvent | CustomEvent) => {
if (event instanceof CustomEvent) {
@@ -63,7 +63,7 @@ const Dialog: React.FC<DialogProps> = ({
useEffect(() => {
if (!isOpen) return;
setIsFullHeightInMobile(!snapHeight);
setIsFullHeightInMobile(!snapHeight && isMobile);
window.addEventListener('keydown', handleKeyDown);
if (appService?.isAndroidApp) {
acquireBackKeyInterception();
@@ -177,9 +177,7 @@ const Dialog: React.FC<DialogProps> = ({
appService?.hasSafeAreaInset && isFullHeightInMobile
? `max(env(safe-area-inset-top), ${systemUIVisible ? statusBarHeight : 0}px)`
: '0px',
...(appService?.isMobile
? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 }
: {}),
...(isMobile ? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 } : {}),
}}
>
<div
@@ -192,7 +190,7 @@ const Dialog: React.FC<DialogProps> = ({
>
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
</div>
<div className='dialog-header bg-base-100 sticky top-1 z-10 flex items-center justify-between px-4'>
<div className='dialog-header bg-base-100 sticky top-1 z-10 flex items-center justify-between px-2 sm:px-4'>
{header ? (
header
) : (
@@ -274,7 +274,7 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
<div className='text-base-content flex-grow text-sm'>
<h2 className='mb-4 text-center font-bold sm:text-start'>
{_('A new version of Readest is Available!')}
{_('A new version of Readest is available!')}
</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
+6 -27
View File
@@ -1,36 +1,11 @@
export interface ShortcutConfig {
onSwitchSideBar: string[];
onToggleSideBar: string[];
onToggleNotebook: string[];
onToggleSearchBar: string[];
onToggleScrollMode: string[];
onToggleSelectMode: string[];
onOpenFontLayoutSettings: string[];
onReloadPage: string[];
onToggleFullscreen: string[];
onQuitApp: string[];
onGoLeft: string[];
onGoRight: string[];
onGoNext: string[];
onGoPrev: string[];
onGoHalfPageDown: string[];
onGoHalfPageUp: string[];
onGoBack: string[];
onGoForward: string[];
onZoomIn: string[];
onZoomOut: string[];
onResetZoom: string[];
onSaveNote: string[];
onCloseNote: string[];
}
const DEFAULT_SHORTCUTS: ShortcutConfig = {
const DEFAULT_SHORTCUTS = {
onSwitchSideBar: ['ctrl+Tab', 'opt+Tab', 'alt+Tab'],
onToggleSideBar: ['s'],
onToggleNotebook: ['n'],
onToggleSearchBar: ['ctrl+f', 'cmd+f'],
onToggleScrollMode: ['shift+j'],
onToggleSelectMode: ['shift+s'],
onToggleTTS: ['t'],
onOpenFontLayoutSettings: ['shift+f'],
onReloadPage: ['shift+r'],
onToggleFullscreen: ['F11'],
@@ -50,6 +25,10 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onCloseNote: ['Escape'],
};
export type ShortcutConfig = {
[K in keyof typeof DEFAULT_SHORTCUTS]: string[];
};
// Load shortcuts from localStorage or fallback to defaults
export const loadShortcuts = (): ShortcutConfig => {
if (typeof localStorage === 'undefined') return DEFAULT_SHORTCUTS;
@@ -0,0 +1,35 @@
import { useState, useEffect, useCallback } from 'react';
export const useSafeAreaInsets = () => {
const [updated, setUpdated] = useState(false);
const [insets, setInsets] = useState({
top: 0,
right: 0,
bottom: 0,
left: 0,
});
const updateSafeAreaInsets = useCallback(() => {
const rootStyles = getComputedStyle(document.documentElement);
const hasCustomProperties = rootStyles.getPropertyValue('--safe-area-inset-top');
if (hasCustomProperties) {
setInsets({
top: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-top')) || 0,
right: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-right')) || 0,
bottom: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-bottom')) || 0,
left: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-left')) || 0,
});
}
setUpdated(true);
}, []);
useEffect(() => {
updateSafeAreaInsets();
window.addEventListener('resize', updateSafeAreaInsets);
return () => {
window.removeEventListener('resize', updateSafeAreaInsets);
};
}, [updateSafeAreaInsets]);
return updated ? insets : null;
};
+53 -10
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme, Palette } from '@/styles/themes';
import { getStatusBarHeight, setSystemUIVisibility } from '@/utils/bridge';
import { getOSPlatform } from '@/utils/misc';
type UseThemeProps = {
systemUIVisible?: boolean;
@@ -23,18 +24,12 @@ export const useTheme = ({
dismissSystemUI,
updateAppTheme,
setStatusBarHeight,
systemUIAlwaysHidden,
setSystemUIAlwaysHidden,
} = useThemeStore();
useEffect(() => {
updateAppTheme(appThemeColor);
if (appService?.isMobileApp) {
if (systemUIVisible) {
showSystemUI();
} else {
dismissSystemUI();
}
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
}
if (appService?.isAndroidApp) {
getStatusBarHeight().then((res) => {
if (res.height && res.height > 0) {
@@ -43,7 +38,55 @@ export const useTheme = ({
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isDarkMode]);
}, []);
const handleSystemUIVisibility = useCallback(() => {
if (!appService?.isMobileApp) return;
const visible = systemUIVisible && !systemUIAlwaysHidden;
if (visible) {
showSystemUI();
} else {
dismissSystemUI();
}
setSystemUIVisibility({ visible, darkMode: isDarkMode });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isDarkMode, systemUIVisible]);
useEffect(() => {
if (appService?.isMobileApp) {
handleSystemUIVisibility();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleSystemUIVisibility]);
useEffect(() => {
if (!appService?.isMobileApp) return;
handleSystemUIVisibility();
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
handleSystemUIVisibility();
}
};
const handleOrientationChange = () => {
if (appService?.isIOSApp && getOSPlatform() === 'ios') {
// FIXME: This is a workaround for iPhone apps where the system UI is not visible in landscape mode
// when the app is in fullscreen mode until we find a better solution to override the prefersStatusBarHidden
// in the ViewController. Note that screen.orientation.type is not abailable in iOS before 16.4.
const systemUIAlwaysHidden = screen.orientation?.type.includes('landscape');
setSystemUIAlwaysHidden(systemUIAlwaysHidden);
handleSystemUIVisibility();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
screen.orientation?.addEventListener('change', handleOrientationChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
screen.orientation?.removeEventListener('change', handleOrientationChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleSystemUIVisibility]);
useEffect(() => {
const customThemes = settings.globalReadSettings?.customThemes ?? [];
+20 -11
View File
@@ -1,6 +1,7 @@
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
import { getAPIBaseUrl } from '@/services/environment';
import { getAccessToken } from '@/utils/access';
import { fetchWithTimeout } from '@/utils/fetch';
const SYNC_API_ENDPOINT = getAPIBaseUrl() + '/sync';
@@ -33,11 +34,15 @@ export class SyncClient {
if (!token) throw new Error('Not authenticated');
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
const res = await fetchWithTimeout(
url,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
});
5000,
);
if (!res.ok) {
const error = await res.json();
@@ -55,14 +60,18 @@ export class SyncClient {
const token = await getAccessToken();
if (!token) throw new Error('Not authenticated');
const res = await fetch(SYNC_API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
const res = await fetchWithTimeout(
SYNC_API_ENDPOINT,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
},
body: JSON.stringify(payload),
});
5000,
);
if (!res.ok) {
const error = await res.json();
+13 -6
View File
@@ -37,6 +37,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
keepLogin: false,
autoUpload: true,
alwaysOnTop: false,
alwaysShowStatusBar: false,
autoCheckUpdates: true,
screenWakeLock: false,
openLastBooks: false,
@@ -87,10 +88,15 @@ export const DEFAULT_BOOK_FONT: BookFont = {
};
export const DEFAULT_BOOK_LAYOUT: BookLayout = {
marginPx: 44,
marginTopPx: 44,
marginBottomPx: 44,
marginLeftPx: 16,
marginRightPx: 16,
compactMarginTopPx: 16,
compactMarginBottomPx: 16,
compactMarginLeftPx: 16,
compactMarginRightPx: 16,
gapPercent: 5,
compactMarginPx: 0,
compactGapPercent: 5,
scrolled: false,
disableClick: false,
swapClickArea: false,
@@ -130,6 +136,7 @@ export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
fullJustification: false,
animated: true,
defaultFont: 'Sans-serif',
marginBottomPx: 16,
};
export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
@@ -516,11 +523,11 @@ export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web'
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
export const GITHUB_LATEST_DOWNLOAD = 'https://github.com/readest/readest/releases/latest/download';
const LATEST_DOWNLOAD_BASE_URL = 'https://download.readest.com/releases';
export const READEST_UPDATER_FILE = `${GITHUB_LATEST_DOWNLOAD}/latest.json`;
export const READEST_UPDATER_FILE = `${LATEST_DOWNLOAD_BASE_URL}/latest.json`;
export const READEST_CHANGELOG_FILE = `${GITHUB_LATEST_DOWNLOAD}/release-notes.json`;
export const READEST_CHANGELOG_FILE = `${LATEST_DOWNLOAD_BASE_URL}/release-notes.json`;
export const SYNC_PROGRESS_INTERVAL_SEC = 3;
export const SYNC_NOTES_INTERVAL_SEC = 5;
+2 -3
View File
@@ -11,14 +11,13 @@ export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM']
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
export const isPWA = () => window.matchMedia('(display-mode: standalone)').matches;
export const getBaseUrl = () => process.env['NEXT_PUBLIC_API_BASE_URL'] ?? READEST_WEB_BASE_URL;
// Dev API only in development mode and web platform
// with command `pnpm dev-web`
// for production build or tauri app use the production Web API
export const getAPIBaseUrl = () =>
process.env['NODE_ENV'] === 'development' && isWebAppPlatform()
? '/api'
: `${process.env['NEXT_PUBLIC_API_BASE_URL'] ?? READEST_WEB_BASE_URL}/api`;
process.env['NODE_ENV'] === 'development' && isWebAppPlatform() ? '/api' : `${getBaseUrl()}/api`;
export interface EnvConfigType {
getAppService: () => Promise<AppService>;
@@ -2,7 +2,7 @@ import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { TranslationProvider } from '../types';
import { langToDefaultLocale } from '@/utils/lang';
import { normalizeToFullLang } from '@/utils/lang';
interface TokenCache {
token: string;
@@ -53,8 +53,8 @@ export const azureProvider: TranslationProvider = {
if (!text.length) return [];
const results: string[] = [];
const msSourceLang = sourceLang ? langToDefaultLocale(sourceLang.toLowerCase()) : '';
const msTargetLang = langToDefaultLocale(targetLang.toLowerCase());
const msSourceLang = sourceLang ? normalizeToFullLang(sourceLang) : '';
const msTargetLang = normalizeToFullLang(targetLang);
const translationPromises = text.map(async (line, index) => {
if (!line?.trim().length) {
@@ -67,7 +67,7 @@ export const azureProvider: TranslationProvider = {
to: msTargetLang,
'api-version': '3.0',
});
if (msSourceLang && msSourceLang !== 'auto') {
if (msSourceLang && msSourceLang.toLowerCase() !== 'auto') {
params.append('from', msSourceLang);
}
@@ -3,6 +3,7 @@ import { stubTranslation as _ } from '@/utils/misc';
import { ErrorCodes, TranslationProvider } from '../types';
import { UserPlan } from '@/types/user';
import { getUserPlan } from '@/utils/access';
import { normalizeToShortLang } from '@/utils/lang';
import { DEFAULT_DAILY_TRANSLATION_QUOTA } from '@/services/constants';
import { saveDailyUsage } from '../utils';
@@ -38,8 +39,8 @@ export const deeplProvider: TranslationProvider = {
const body = JSON.stringify({
text: text,
source_lang: sourceLang.toUpperCase(),
target_lang: targetLang.toUpperCase(),
source_lang: normalizeToShortLang(sourceLang).toUpperCase(),
target_lang: normalizeToShortLang(targetLang).toUpperCase(),
use_cache: useCache,
});
@@ -1,6 +1,7 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { normalizeToShortLang } from '@/utils/lang';
import { TranslationProvider } from '../types';
export const googleProvider: TranslationProvider = {
@@ -20,8 +21,8 @@ export const googleProvider: TranslationProvider = {
const url = new URL('https://translate.googleapis.com/translate_a/single');
url.searchParams.append('client', 'gtx');
url.searchParams.append('dt', 't');
url.searchParams.append('sl', sourceLang.toLowerCase() || 'auto');
url.searchParams.append('tl', targetLang.toLowerCase());
url.searchParams.append('sl', normalizeToShortLang(sourceLang).toLowerCase() || 'auto');
url.searchParams.append('tl', normalizeToShortLang(targetLang).toLowerCase());
url.searchParams.append('q', line);
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
@@ -1,3 +1,7 @@
import { Book } from '@/types/book';
import { isSameLang } from '@/utils/lang';
import { getLocale } from '@/utils/misc';
const DAILY_USAGE_KEY = 'translationDailyUsage';
export const saveDailyUsage = (usage: number, date?: string) => {
@@ -21,3 +25,24 @@ export const getDailyUsage = (date?: string): number | null => {
}
return null;
};
export const isTranslationAvailable = (book?: Book | null, targetLanguage?: string | null) => {
if (!book || book.format === 'PDF') {
return false;
}
const primaryLanguage = book.primaryLanguage || '';
if (!primaryLanguage || primaryLanguage.toLowerCase() === 'und') {
return false;
}
if (targetLanguage && isSameLang(primaryLanguage, targetLanguage)) {
return false;
}
if (!targetLanguage && isSameLang(primaryLanguage, getLocale())) {
return false;
}
return true;
};
@@ -264,7 +264,7 @@ export class EdgeTTSClient implements TTSClient {
const voicesGroup: TTSVoicesGroup = {
id: 'edge-tts',
name: 'Edge TTS',
voices: filteredVoices,
voices: filteredVoices.sort(TTSUtils.sortVoicesFunc),
disabled: !this.initialized || filteredVoices.length === 0,
};
@@ -14,12 +14,25 @@ type TTSEventPayload = {
const TTSEngines = {
default: 'System TTS',
msctts: 'Msc TTS',
mstrans: 'MSTrans TTS',
microsoft: 'Microsoft TTS',
bytedance: 'ByteDance TTS',
peiyinya: 'PeiYinYa TTS',
huoshan: 'HuoShan TTS',
sougou: 'Sougou TTS',
xiaomi: 'XiaoMi TTS',
bdetts: 'BDeTTS',
bdotts: 'BDoTTS',
vcstts: 'VcsTTS',
isstts: 'IssTTS',
xfpeiyin: 'XFPeiYin',
azure: 'Azure TTS',
edgetts: 'Edge TTS',
google: 'Google TTS',
gemini: 'Gemini TTS',
weread: 'WeRead TTS',
aispeech: 'Aispeech',
} as Record<string, string>;
export class NativeTTSClient implements TTSClient {
@@ -189,7 +202,7 @@ export class NativeTTSClient implements TTSClient {
const defaultVoice = preferredVoice
? preferredVoice
: (await this.getVoices(lang))[0]?.voices[0] || null;
return defaultVoice?.id || 'NOT_SET';
return defaultVoice?.id || '';
}
async pause() {
@@ -244,7 +257,6 @@ export class NativeTTSClient implements TTSClient {
const filteredVoices = voices.filter(
(v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)),
);
const voiceGroups = new Map<string, TTSVoice[]>();
filteredVoices.forEach((voice) => {
const { name, lang } = voice;
@@ -271,7 +283,7 @@ export class NativeTTSClient implements TTSClient {
({
id: groupId,
name: TTSEngines[groupId] || groupId,
voices: voices,
voices: voices.sort(TTSUtils.sortVoicesFunc),
disabled: !this.initialized || voices.length === 0,
}) as TTSVoicesGroup,
)
@@ -1,7 +1,8 @@
import { FoliateView } from '@/types/view';
import { AppService } from '@/types/system';
import { parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity, TTSMark, TTSVoice } from './types';
import { Overlayer } from 'foliate-js/overlayer.js';
import { TTSGranularity, TTSHighlightOptions, TTSMark, TTSVoice } from './types';
import { WebSpeechClient } from './WebSpeechClient';
import { NativeTTSClient } from './NativeTTSClient';
import { EdgeTTSClient } from './EdgeTTSClient';
@@ -18,6 +19,8 @@ type TTSState =
| 'setrate-paused'
| 'setvoice-paused';
const HIGHLIGHT_KEY = 'tts-highlight';
export class TTSController extends EventTarget {
appService: AppService | null = null;
view: FoliateView;
@@ -51,16 +54,17 @@ export class TTSController extends EventTarget {
async init() {
const availableClients = [];
if (await this.ttsEdgeClient.init()) {
availableClients.push(this.ttsEdgeClient);
}
if (this.ttsNativeClient && (await this.ttsNativeClient.init())) {
availableClients.push(this.ttsNativeClient);
this.ttsNativeVoices = await this.ttsNativeClient.getAllVoices();
}
if (await this.ttsEdgeClient.init()) {
availableClients.push(this.ttsEdgeClient);
}
if (await this.ttsWebClient.init()) {
availableClients.push(this.ttsWebClient);
}
this.ttsClient = availableClients[0] || this.ttsWebClient;
const preferredClientName = TTSUtils.getPreferredClient();
if (preferredClientName) {
const preferredClient = availableClients.find(
@@ -70,20 +74,39 @@ export class TTSController extends EventTarget {
this.ttsClient = preferredClient;
}
}
if (!this.ttsClient) {
this.ttsClient = availableClients[0] || this.ttsWebClient;
}
this.ttsWebVoices = await this.ttsWebClient.getAllVoices();
this.ttsEdgeVoices = await this.ttsEdgeClient.getAllVoices();
}
#getHighlighter(options: TTSHighlightOptions) {
return (range: Range) => {
const { overlayer } = this.view.renderer.getContents()[0] as { overlayer: Overlayer };
const { style, color } = options;
overlayer?.remove(HIGHLIGHT_KEY);
overlayer?.add(HIGHLIGHT_KEY, range, Overlayer[style], { color });
const rect = range.getBoundingClientRect();
const { start, size, viewSize, sideProp } = this.view.renderer;
const position = rect[sideProp === 'height' ? 'y' : 'x'] + 88;
const offset = this.view.book.dir === 'rtl' ? viewSize - position : position;
if (!this.view.renderer.scrolled || offset < start || offset > start + size) {
this.view.renderer.scrollToAnchor(range);
}
};
}
#clearHighlighter() {
const { overlayer } = (this.view.renderer.getContents()?.[0] || {}) as { overlayer: Overlayer };
overlayer?.remove(HIGHLIGHT_KEY);
}
async initViewTTS() {
let granularity: TTSGranularity = this.view.language.isCJK ? 'sentence' : 'word';
const supportedGranularities = this.ttsClient.getGranularities();
if (!supportedGranularities.includes(granularity)) {
granularity = supportedGranularities[0]!;
}
await this.view.initTTS(granularity);
const highlightOptions: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
await this.view.initTTS(granularity, this.#getHighlighter(highlightOptions));
}
async preloadSSML(ssml: string | undefined) {
@@ -115,6 +138,7 @@ export class TTSController extends EventTarget {
.replace('<break/>', ' ')
.replace(/\.{3,}/g, ' ')
.replace(/……/g, ' ')
.replace(/\*/g, ' ')
.replace(/·/g, ' ');
return ssml;
@@ -288,26 +312,6 @@ export class TTSController extends EventTarget {
const ttsNativeVoices = (await this.ttsNativeClient?.getVoices(lang)) ?? [];
const voicesGroups = [...ttsNativeVoices, ...ttsEdgeVoices, ...ttsWebVoices];
voicesGroups.forEach((group) => {
group.voices = group.voices.sort((a, b) => {
const aRegion = a.lang.split('-')[1] || '';
const bRegion = b.lang.split('-')[1] || '';
if (aRegion === bRegion) {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
}
if (aRegion === 'CN') return -1;
if (bRegion === 'CN') return 1;
if (aRegion === 'TW') return -1;
if (bRegion === 'TW') return 1;
if (aRegion === 'HK') return -1;
if (bRegion === 'HK') return 1;
if (aRegion === 'US') return -1;
if (bRegion === 'US') return 1;
if (aRegion === 'GB') return -1;
if (bRegion === 'GB') return 1;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
});
return voicesGroups;
}
@@ -356,6 +360,7 @@ export class TTSController extends EventTarget {
async shutdown() {
await this.stop();
this.#clearHighlighter();
if (this.ttsWebClient.initialized) {
await this.ttsWebClient.shutdown();
}
@@ -1,3 +1,5 @@
import { TTSVoice } from './types';
export class TTSUtils {
private static readonly LOCAL_STORAGE_KEY = 'ttsPreferredVoices';
private static readonly PREFERRED_CLIENT_KEY = 'preferredClient';
@@ -37,4 +39,23 @@ export class TTSUtils {
const storedPreferences = localStorage.getItem(this.LOCAL_STORAGE_KEY);
return storedPreferences ? JSON.parse(storedPreferences) : {};
}
static sortVoicesFunc(a: TTSVoice, b: TTSVoice): number {
const aRegion = a.lang.split('-')[1] || '';
const bRegion = b.lang.split('-')[1] || '';
if (aRegion === bRegion) {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
}
if (aRegion === 'CN') return -1;
if (bRegion === 'CN') return 1;
if (aRegion === 'TW') return -1;
if (bRegion === 'TW') return 1;
if (aRegion === 'HK') return -1;
if (bRegion === 'HK') return 1;
if (aRegion === 'US') return -1;
if (bRegion === 'US') return 1;
if (aRegion === 'GB') return -1;
if (bRegion === 'GB') return 1;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
}
}
@@ -270,7 +270,7 @@ export class WebSpeechClient implements TTSClient {
const voicesGroup: TTSVoicesGroup = {
id: 'web-speech-api',
name: 'Web TTS',
voices,
voices: voices.sort(TTSUtils.sortVoicesFunc),
disabled: !this.initialized || voices.length === 0,
};
return [voicesGroup];
@@ -1,5 +1,10 @@
export type TTSGranularity = 'sentence' | 'word';
export type TTSHighlightOptions = {
style: 'highlight' | 'underline' | 'squiggly' | 'outline';
color: string;
};
export type TTSVoice = {
id: string;
name: string;
+5 -1
View File
@@ -42,7 +42,11 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
setConfig: (key: string, partialConfig: Partial<BookConfig>) => {
set((state: BookDataState) => {
const id = key.split('-')[0]!;
const config = (state.booksData[id]?.config || {}) as BookConfig;
const config = (state.booksData[id]?.config || null) as BookConfig;
if (!config) {
console.warn('No config found for book', id);
return state;
}
Object.assign(config, partialConfig);
return {
booksData: {
+22 -2
View File
@@ -8,6 +8,7 @@ import {
ViewSettings,
TimeInfo,
} from '@/types/book';
import { Insets } from '@/types/misc';
import { EnvConfigType } from '@/services/environment';
import { FoliateView } from '@/types/view';
import { DocumentLoader, TOCItem } from '@/libs/document';
@@ -27,6 +28,7 @@ interface ViewState {
progress: BookProgress | null;
ribbonVisible: boolean;
ttsEnabled: boolean;
gridInsets: Insets | null;
/* View settings for the view:
generally view settings have a hierarchy of global settings < book settings < view settings
view settings for primary view are saved to book config which is persisted to config file
@@ -67,6 +69,8 @@ interface ReaderStore {
) => Promise<void>;
clearViewState: (key: string) => void;
getViewState: (key: string) => ViewState | null;
getGridInsets: (key: string) => Insets | null;
setGridInsets: (key: string, insets: Insets | null) => void;
}
export const useReaderStore = create<ReaderStore>((set, get) => ({
@@ -114,14 +118,15 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
progress: null,
ribbonVisible: false,
ttsEnabled: false,
gridInsets: null,
viewSettings: null,
},
},
}));
try {
const { settings } = useSettingsStore.getState();
if (!bookData) {
const appService = await envConfig.getAppService();
const { settings } = useSettingsStore.getState();
const { library } = useLibraryStore.getState();
const book = library.find((b) => b.hash === id);
if (!book) {
@@ -145,6 +150,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const booksData = useBookDataStore.getState().booksData;
const config = booksData[id]?.config as BookConfig;
const configViewSettings = config.viewSettings!;
const globalViewSettings = settings.globalViewSettings;
set((state) => ({
viewStates: {
...state.viewStates,
@@ -158,7 +164,8 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
progress: null,
ribbonVisible: false,
ttsEnabled: false,
viewSettings: JSON.parse(JSON.stringify(configViewSettings)) as ViewSettings,
gridInsets: null,
viewSettings: { ...globalViewSettings, ...configViewSettings },
},
},
}));
@@ -177,6 +184,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
progress: null,
ribbonVisible: false,
ttsEnabled: false,
gridInsets: null,
viewSettings: null,
},
},
@@ -305,4 +313,16 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
},
})),
getGridInsets: (key: string) => get().viewStates[key]?.gridInsets || null,
setGridInsets: (key: string, insets: Insets | null) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
gridInsets: insets,
},
},
})),
}));
+4
View File
@@ -12,6 +12,8 @@ interface ThemeState {
isDarkMode: boolean;
systemUIVisible: boolean;
statusBarHeight: number;
systemUIAlwaysHidden: boolean;
setSystemUIAlwaysHidden: (hidden: boolean) => void;
setStatusBarHeight: (height: number) => void;
showSystemUI: () => void;
dismissSystemUI: () => void;
@@ -69,9 +71,11 @@ export const useThemeStore = create<ThemeState>((set, get) => {
themeCode,
systemUIVisible: false,
statusBarHeight: 24,
systemUIAlwaysHidden: false,
showSystemUI: () => set({ systemUIVisible: true }),
dismissSystemUI: () => set({ systemUIVisible: false }),
setStatusBarHeight: (height: number) => set({ statusBarHeight: height }),
setSystemUIAlwaysHidden: (hidden: boolean) => set({ systemUIAlwaysHidden: hidden }),
getIsDarkMode: () => get().isDarkMode,
setThemeMode: (mode) => {
if (typeof window !== 'undefined' && localStorage) {
+5
View File
@@ -6,6 +6,11 @@
border-radius: 10px;
scrollbar-gutter: auto !important;
overscroll-behavior: none;
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-right: env(safe-area-inset-right);
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-left: env(safe-area-inset-left);
}
/* Fixes the scrollbar gutter issue for DaisyUI */
+10 -3
View File
@@ -73,10 +73,17 @@ export interface BooknoteGroup {
export type WritingMode = 'auto' | 'horizontal-tb' | 'horizontal-rl' | 'vertical-rl';
export interface BookLayout {
marginPx: number;
marginTopPx: number;
marginBottomPx: number;
marginLeftPx: number;
marginRightPx: number;
marginPx?: number; // deprecated
compactMarginTopPx: number;
compactMarginBottomPx: number;
compactMarginLeftPx: number;
compactMarginRightPx: number;
compactMarginPx?: number; // deprecated
gapPercent: number;
compactMarginPx: number;
compactGapPercent: number;
scrolled: boolean;
disableClick: boolean;
swapClickArea: boolean;
+6
View File
@@ -0,0 +1,6 @@
export type Insets = {
top: number;
bottom: number;
left: number;
right: number;
};
+1
View File
@@ -28,6 +28,7 @@ export interface SystemSettings {
alwaysOnTop: boolean;
autoCheckUpdates: boolean;
screenWakeLock: boolean;
alwaysShowStatusBar: boolean;
openLastBooks: boolean;
lastOpenBooks: string[];
autoImportBooksOnOpen: boolean;
+4 -2
View File
@@ -20,7 +20,7 @@ export interface FoliateView extends HTMLElement {
clearSearch: () => void;
select: (target: string | number | { fraction: number }) => void;
deselect: () => void;
initTTS: (granularity?: TTSGranularity) => Promise<void>;
initTTS: (granularity?: TTSGranularity, highlight?: (range: Range) => void) => Promise<void>;
book: BookDoc;
tts: TTS | null;
language: {
@@ -41,6 +41,7 @@ export interface FoliateView extends HTMLElement {
start: number;
end: number;
containerPosition: number;
sideProp: 'width' | 'height';
setAttribute: (name: string, value: string | number) => void;
removeAttribute: (name: string) => void;
next: () => Promise<void>;
@@ -49,7 +50,8 @@ export interface FoliateView extends HTMLElement {
prevSection?: () => Promise<void>;
goTo?: (params: { index: number; anchor: number }) => void;
setStyles?: (css: string) => void;
getContents: () => { doc: Document; index?: number }[];
getContents: () => { doc: Document; index?: number; overlayer?: unknown }[];
scrollToAnchor: (anchor: number | Range) => void;
addEventListener: (
type: string,
listener: EventListener,
+10
View File
@@ -1,5 +1,15 @@
import { getAccessToken } from './access';
export const fetchWithTimeout = (url: string, options: RequestInit, timeout = 8000) => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
return fetch(url, {
...options,
signal: controller.signal,
}).finally(() => clearTimeout(id));
};
export const fetchWithAuth = async (url: string, options: RequestInit) => {
const token = await getAccessToken();
if (!token) {
+16 -2
View File
@@ -1,4 +1,4 @@
const getGridTemplate = (count: number, aspectRatio: number) => {
export const getGridTemplate = (count: number, aspectRatio: number) => {
if (count <= 1) {
return { columns: '1fr', rows: '1fr' };
} else if (count === 2) {
@@ -12,4 +12,18 @@ const getGridTemplate = (count: number, aspectRatio: number) => {
}
};
export default getGridTemplate;
export const getInsetEdges = (index: number, count: number, aspectRatio: number) => {
const gridTemplate = getGridTemplate(count, aspectRatio);
const cols = gridTemplate.columns.split(' ').length;
const rows = gridTemplate.rows.split(' ').length;
const row = Math.floor(index / cols);
const col = index % cols;
return {
top: row === 0,
right: col === cols - 1,
bottom: row === rows - 1,
left: col === 0,
};
};
+23
View File
@@ -0,0 +1,23 @@
import { Insets } from '@/types/misc';
import { ViewSettings } from '@/types/book';
export const getViewInsets = (viewSettings: ViewSettings) => {
const showHeader = viewSettings.showHeader!;
const showFooter = viewSettings.showFooter!;
const isVertical = viewSettings.vertical || viewSettings.writingMode.includes('vertical');
const fullMarginTopPx = viewSettings.marginPx || viewSettings.marginTopPx;
const compactMarginTopPx = viewSettings.compactMarginPx || viewSettings.compactMarginTopPx;
const fullMarginBottomPx = viewSettings.marginBottomPx;
const compactMarginBottomPx = viewSettings.compactMarginBottomPx;
const fullMarginLeftPx = viewSettings.marginLeftPx;
const fullMarginRightPx = viewSettings.marginRightPx;
const compactMarginLeftPx = viewSettings.compactMarginLeftPx;
const compactMarginRightPx = viewSettings.compactMarginRightPx;
return {
top: showHeader && !isVertical ? fullMarginTopPx : compactMarginTopPx,
right: showHeader && isVertical ? fullMarginRightPx : compactMarginRightPx,
bottom: showFooter && !isVertical ? fullMarginBottomPx : compactMarginBottomPx,
left: showFooter && isVertical ? fullMarginLeftPx : compactMarginLeftPx,
} as Insets;
};
+16 -9
View File
@@ -8,7 +8,7 @@ export const isCJKLang = (lang: string | null | undefined): boolean => {
return ['zh', 'ja', 'ko'].includes(normalizedLang);
};
export const langToDefaultLocale = (langCode: string): string => {
export const normalizeToFullLang = (langCode: string): string => {
const mapping: Record<string, string> = {
en: 'en-US',
fr: 'fr-FR',
@@ -26,24 +26,31 @@ export const langToDefaultLocale = (langCode: string): string => {
ru: 'ru-RU',
uk: 'uk-UA',
zh: 'zh-Hans',
'zh-cn': 'zh-Hans',
'zh-tw': 'zh-Hant',
'zh-mo': 'zh-Hant',
'zh-hans': 'zh-Hans',
'zh-hant': 'zh-Hant',
};
return mapping[langCode] || langCode;
return mapping[langCode.toLowerCase()] || langCode;
};
export const localeToLang = (locale: string): string => {
export const normalizeToShortLang = (langCode: string): string => {
const lang = langCode.toLowerCase();
const mapping: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant',
'zh-HK': 'zh-Hant',
'zh-cn': 'zh-Hans',
'zh-tw': 'zh-Hant',
'zh-hk': 'zh-Hant',
'zh-mo': 'zh-Hant',
'zh-hans': 'zh-Hans',
'zh-hant': 'zh-Hant',
};
if (locale.startsWith('zh')) {
return mapping[locale] || 'zh-Hans';
if (lang.startsWith('zh')) {
return mapping[lang] || 'zh-Hans';
}
return mapping[locale] || locale.split('-')[0]!.toLowerCase();
return lang.split('-')[0]!;
};
export const normalizedLangCode = (lang: string | null | undefined): string => {
+4 -3
View File
@@ -31,7 +31,8 @@ export interface TextSelection {
annotated?: boolean;
}
const frameRect = (frame: Frame, rect: Rect, sx = 1, sy = 1) => {
const frameRect = (frame: Frame, rect?: Rect, sx = 1, sy = 1) => {
if (!rect) return { left: 0, right: 0, top: 0, bottom: 0 };
const left = sx * rect.left + frame.left;
const right = sx * rect.right + frame.left;
const top = sy * rect.top + frame.top;
@@ -84,8 +85,8 @@ export const getPosition = (
const frame = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
const rects = Array.from(target.getClientRects());
const first = frameRect(frame, rects[0] as Rect, sx, sy);
const last = frameRect(frame, rects.at(-1) as Rect, sx, sy);
const first = frameRect(frame, rects[0], sx, sy);
const last = frameRect(frame, rects.at(-1), sx, sy);
if (isVertical) {
const leftSpace = first.left - rect.left;
+14 -11
View File
@@ -58,6 +58,8 @@ const getFontStyles = (
font-family: var(${defaultFont.toLowerCase() === 'serif' ? '--serif' : '--sans-serif'}) ${overrideFont ? '!important' : ''};
font-size: ${fontSize}px !important;
font-weight: ${fontWeight};
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
font[size="1"] {
font-size: ${minFontSize}px;
@@ -209,7 +211,7 @@ const getLayoutStyles = (
width: auto;
background-color: transparent !important;
}
p, li, blockquote, dd, div:not(:has(*:not(b, a, em, i, strong, u, span))) {
p, blockquote, dd, div:not(:has(*:not(b, a, em, i, strong, u, span))) {
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
@@ -240,6 +242,14 @@ const getLayoutStyles = (
${!vertical && overrideLayout ? `margin-top: ${paragraphMargin}em !important;` : ''}
${!vertical && overrideLayout ? `margin-bottom: ${paragraphMargin}em !important;` : ''}
}
h1, h2, h3, h4, h5, h6 {
text-align: initial;
}
:lang(zh), :lang(ja), :lang(ko) {
widows: 1;
orphans: 1;
}
pre {
white-space: pre-wrap !important;
@@ -415,16 +425,9 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
return `${layoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${userStylesheet}`;
};
export const transformStylesheet = (
viewSettings: ViewSettings,
width: number,
height: number,
css: string,
) => {
export const transformStylesheet = (vw: number, vh: 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;
const ruleRegex = /([^{]+)({[^}]+})/g;
css = css.replace(ruleRegex, (match, selector, block) => {
const hasTextAlignCenter = /text-align\s*:\s*center\s*[;$]/.test(block);
@@ -462,8 +465,8 @@ export const transformStylesheet = (
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(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * vw) / 100 + 'px')
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * vh) / 100 + 'px')
.replace(/[\s;]color\s*:\s*black/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*#000000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*#000/gi, 'color: var(--theme-fg-color)')
+33
View File
@@ -0,0 +1,33 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/en';
import 'dayjs/locale/zh';
import 'dayjs/locale/de';
import 'dayjs/locale/ja';
import 'dayjs/locale/ko';
import 'dayjs/locale/ru';
import 'dayjs/locale/fr';
import 'dayjs/locale/el';
import 'dayjs/locale/es';
import 'dayjs/locale/it';
import 'dayjs/locale/pt';
import 'dayjs/locale/pt-br';
import 'dayjs/locale/ar';
import 'dayjs/locale/id';
import 'dayjs/locale/hi';
import 'dayjs/locale/th';
import 'dayjs/locale/tr';
import 'dayjs/locale/vi';
import 'dayjs/locale/uk';
import 'dayjs/locale/pl';
import 'dayjs/locale/fi';
import 'dayjs/locale/nl';
import 'dayjs/locale/ro';
import 'dayjs/locale/zh-tw';
import 'dayjs/locale/zh-cn';
export const initDayjs = (locale: string) => {
dayjs.locale(locale);
dayjs.extend(relativeTime);
};
+1 -1
View File
@@ -69,7 +69,7 @@ export const webDownload = async (downloadUrl: string, onProgress?: ProgressHand
}
}
return new Blob(chunks);
return new Blob(chunks as BlobPart[]);
};
export const tauriUpload = async (

Some files were not shown because too many files have changed in this diff Show More