forked from akai/readest
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f815844577 | |||
| 7fe4d38c51 | |||
| 43edcb4e9e | |||
| e259b29afa | |||
| cd5f57a004 | |||
| 8c0cdb0e22 | |||
| c0df9f1cb6 | |||
| cb1ddaeedb | |||
| 4447dd7b9a | |||
| dcb7602837 | |||
| 60e48742fe | |||
| dd43569778 | |||
| 8433dd6950 | |||
| 379802f580 | |||
| 2dcb5d7c1e | |||
| 98870d9f3e | |||
| 31aeb2289c | |||
| 0f66ecbba1 | |||
| f0d3c9dd4e | |||
| 92aae340d9 | |||
| 3cf40039ee | |||
| 7ed4937d9d | |||
| b3593c0365 | |||
| 6235202066 | |||
| b3d1085ddf | |||
| b2a4ddae60 | |||
| 42f50af27b | |||
| 07f74d8858 | |||
| 9477789db1 | |||
| 8a7d0e1b0d | |||
| 261ce95ed1 | |||
| c5a3b44cbf | |||
| ed68d25b15 | |||
| f7f8872a13 | |||
| be9802a936 | |||
| 84328dcfb2 |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -87,7 +87,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "翻譯不可用"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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"/>
|
||||
|
||||
|
||||
+38
@@ -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>
|
||||
|
||||
+107
-40
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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>
|
||||
|
||||
+7
-2
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}}).', {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type Insets = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
@@ -28,6 +28,7 @@ export interface SystemSettings {
|
||||
alwaysOnTop: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
screenWakeLock: boolean;
|
||||
alwaysShowStatusBar: boolean;
|
||||
openLastBooks: boolean;
|
||||
lastOpenBooks: string[];
|
||||
autoImportBooksOnOpen: boolean;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user