forked from akai/readest
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a66786444f | |||
| 01ad18ca57 | |||
| a72c8f2391 | |||
| f90177713a | |||
| 041eb1b921 | |||
| 105302d241 | |||
| bd9c7b459c | |||
| 1c122752bb | |||
| 203ef8b986 | |||
| ceda806f77 | |||
| baac9dfa25 | |||
| 10aace0026 | |||
| 3e38896078 | |||
| 0fa53c5f15 | |||
| 2b6320b880 | |||
| 7ba938ea25 | |||
| 6d4ad34e32 | |||
| 3239ff0a80 | |||
| 8b7e3c6f79 | |||
| ace4b78420 | |||
| 39ac89fead | |||
| 490fe7e70c | |||
| 06a7a8ea82 | |||
| b7f4a3503f | |||
| 835ed443a5 | |||
| 3f3c6983ba | |||
| f22ce1c225 | |||
| 9990de112f | |||
| ede37757db | |||
| 0c65d44bc9 | |||
| 07c9813ec1 | |||
| 03f0ee1f7d | |||
| ebc23c4ce3 | |||
| d098487cd1 | |||
| 14f2db730a | |||
| a79f301165 | |||
| 05d086352f | |||
| ef864a32c2 | |||
| 0d93a13407 | |||
| ab0208b7ab | |||
| c52f3c0430 | |||
| accf6fb5c7 | |||
| 18c2ce1274 | |||
| 9a104b918c | |||
| 948303c43a | |||
| 82f303fcfe | |||
| 359fa8df35 | |||
| 72e6c058f5 | |||
| 7648aa47b2 | |||
| 48074f0f53 | |||
| 66bd8d74f3 | |||
| 37f3c8b829 | |||
| 8775f90bc1 |
@@ -56,27 +56,35 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- os: ubuntu-latest
|
||||
release: android
|
||||
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
|
||||
- os: ubuntu-22.04
|
||||
release: linux
|
||||
arch: x86_64
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-22.04-arm
|
||||
release: linux
|
||||
arch: aarch64
|
||||
rust_target: aarch64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
release: macos
|
||||
arch: aarch64
|
||||
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
|
||||
args: '--target universal-apple-darwin'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: x86_64
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: aarch64
|
||||
rust_target: aarch64-pc-windows-msvc
|
||||
args: '--target aarch64-pc-windows-msvc --bundles nsis'
|
||||
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -94,6 +102,21 @@ jobs:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: setup Java (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: setup Android SDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: install NDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
run: sdkmanager "ndk;27.0.11902837"
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -109,6 +132,12 @@ jobs:
|
||||
with:
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
@@ -116,17 +145,43 @@ jobs:
|
||||
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
|
||||
cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: copy .env.local to apps/readest-app
|
||||
run: cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.config.os, 'ubuntu')
|
||||
- name: build and upload Android apks
|
||||
if: matrix.config.release == 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.11902837
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
cd apps/readest-app/
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout .
|
||||
|
||||
pushd src-tauri/gen/android
|
||||
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
|
||||
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
|
||||
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
|
||||
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
|
||||
|
||||
popd
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
|
||||
universial_apk=Readest_${version}_universal.apk
|
||||
arm64_apk=Readest_${version}_arm64.apk
|
||||
pnpm tauri android build
|
||||
cp ${apk_path}/app-universal-release.apk $universial_apk
|
||||
pnpm tauri android build -t aarch64
|
||||
cp ${apk_path}/app-universal-release.apk $arm64_apk
|
||||
|
||||
echo "Uploading $universial_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
|
||||
echo "Uploading $arm64_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
if: matrix.config.release != 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -137,6 +192,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
with:
|
||||
projectPath: apps/readest-app
|
||||
releaseId: ${{ needs.get-release.outputs.release_id }}
|
||||
|
||||
Generated
+395
-403
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
|
||||
@@ -9,9 +9,18 @@ SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
|
||||
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
|
||||
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
|
||||
|
||||
# r2, s3
|
||||
NEXT_PUBLIC_OBJECT_STORAGE_TYPE=r2
|
||||
|
||||
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
|
||||
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
|
||||
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
|
||||
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
|
||||
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
|
||||
R2_REGION=YOUR_R2_REGION
|
||||
|
||||
S3_ENDPOINT=PLACE_HOLDER
|
||||
S3_ACCESS_KEY_ID=PLACE_HOLDER
|
||||
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
|
||||
S3_BUCKET_NAME=PLACE_HOLDER
|
||||
S3_REGION=PLACE_HOLDER
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.22",
|
||||
"version": "0.9.27",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -56,6 +56,7 @@
|
||||
"@tauri-apps/plugin-shell": "~2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.5.1",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cssbeautify": "^0.3.1",
|
||||
@@ -65,7 +66,7 @@
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "15.2.2",
|
||||
"next": "15.2.4",
|
||||
"posthog-js": "^1.205.0",
|
||||
"react": "19.0.0",
|
||||
"react-color": "^2.19.3",
|
||||
@@ -77,6 +78,7 @@
|
||||
"zustand": "5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opennextjs/cloudflare": "^0.5.12",
|
||||
"@tauri-apps/cli": "2.3.1",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
@@ -99,6 +101,7 @@
|
||||
"postcss-nested": "^7.0.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.2",
|
||||
"wrangler": "^4.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "التباين",
|
||||
"Sunset": "غروب الشمس",
|
||||
"Double Border": "حدود مزدوجة",
|
||||
"Border Color": "لون الحدود"
|
||||
"Border Color": "لون الحدود",
|
||||
"Border Frame": "إطار الحدود",
|
||||
"Show Header": "عرض الرأس",
|
||||
"Show Footer": "عرض التذييل",
|
||||
"Small": "صغير",
|
||||
"Large": "كبير",
|
||||
"Auto": "تلقائي",
|
||||
"Language": "اللغة",
|
||||
"No annotations to export": "لا توجد تعليقات للتصدير",
|
||||
"Author": "المؤلف",
|
||||
"Exported from Readest": "تم التصدير من ريديست",
|
||||
"Highlights & Annotations": "التمييزات والتعليقات",
|
||||
"Note": "ملاحظة",
|
||||
"Copied to clipboard": "تم النسخ إلى الحافظة",
|
||||
"Export Annotations": "تصدير التعليقات",
|
||||
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "تبديل منطقة التقليب بالنقر"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Kontrast",
|
||||
"Sunset": "Sonnenuntergang",
|
||||
"Double Border": "Doppelter Rand",
|
||||
"Border Color": "Randfarbe"
|
||||
"Border Color": "Randfarbe",
|
||||
"Border Frame": "Rahmenrand",
|
||||
"Show Header": "Kopfzeile anzeigen",
|
||||
"Show Footer": "Fußzeile anzeigen",
|
||||
"Small": "Klein",
|
||||
"Large": "Groß",
|
||||
"Auto": "Automatisch",
|
||||
"Language": "Sprache",
|
||||
"No annotations to export": "Keine Anmerkungen zum Exportieren",
|
||||
"Author": "Autor",
|
||||
"Exported from Readest": "Exportiert von Readest",
|
||||
"Highlights & Annotations": "Hervorhebungen & Anmerkungen",
|
||||
"Note": "Notiz",
|
||||
"Copied to clipboard": "In die Zwischenablage kopiert",
|
||||
"Export Annotations": "Anmerkungen exportieren",
|
||||
"Auto Import on File Open": "Automatischer Import beim Öffnen einer Datei",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Klick-zu-Blättern-Bereich tauschen"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Αντίθεση",
|
||||
"Sunset": "Ηλιοβασίλεμα",
|
||||
"Double Border": "Διπλό περιθώριο",
|
||||
"Border Color": "Χρώμα περιγράμματος"
|
||||
"Border Color": "Χρώμα περιγράμματος",
|
||||
"Border Frame": "Πλαίσιο περιγράμματος",
|
||||
"Show Header": "Εμφάνιση κεφαλίδας",
|
||||
"Show Footer": "Εμφάνιση υποσέλιδου",
|
||||
"Small": "Μικρό",
|
||||
"Large": "Μεγάλο",
|
||||
"Auto": "Αυτόματο",
|
||||
"Language": "Γλώσσα",
|
||||
"No annotations to export": "Δεν υπάρχουν σχολιασμοί για εξαγωγή",
|
||||
"Author": "Συγγραφέας",
|
||||
"Exported from Readest": "Εξήχθη από το Readest",
|
||||
"Highlights & Annotations": "Επισημάνσεις & Σχολιασμοί",
|
||||
"Note": "Σημείωση",
|
||||
"Copied to clipboard": "Αντιγράφηκε στο πρόχειρο",
|
||||
"Export Annotations": "Εξαγωγή σχολιασμών",
|
||||
"Auto Import on File Open": "Αυτόματη εισαγωγή κατά το άνοιγμα αρχείου",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας"
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{}
|
||||
{
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Contraste",
|
||||
"Sunset": "Atardecer",
|
||||
"Double Border": "Doble borde",
|
||||
"Border Color": "Color del borde"
|
||||
"Border Color": "Color del borde",
|
||||
"Border Frame": "Marco de borde",
|
||||
"Show Header": "Mostrar encabezado",
|
||||
"Show Footer": "Mostrar pie de página",
|
||||
"Small": "Pequeño",
|
||||
"Large": "Grande",
|
||||
"Auto": "Automático",
|
||||
"Language": "Idioma",
|
||||
"No annotations to export": "No hay anotaciones para exportar",
|
||||
"Author": "Autor",
|
||||
"Exported from Readest": "Exportado desde Readest",
|
||||
"Highlights & Annotations": "Resaltados y anotaciones",
|
||||
"Note": "Nota",
|
||||
"Copied to clipboard": "Copiado al portapapeles",
|
||||
"Export Annotations": "Exportar anotaciones",
|
||||
"Auto Import on File Open": "Importación automática al abrir archivo",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Intercambiar área de clic para voltear"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Contraste",
|
||||
"Sunset": "Coucher de soleil",
|
||||
"Double Border": "Double bordure",
|
||||
"Border Color": "Couleur de la bordure"
|
||||
"Border Color": "Couleur de la bordure",
|
||||
"Border Frame": "Cadre de bordure",
|
||||
"Show Header": "Afficher l'en-tête",
|
||||
"Show Footer": "Afficher le pied de page",
|
||||
"Small": "Petit",
|
||||
"Large": "Grand",
|
||||
"Auto": "Automatique",
|
||||
"Language": "Langue",
|
||||
"No annotations to export": "Aucune annotation à exporter",
|
||||
"Author": "Auteur",
|
||||
"Exported from Readest": "Exporté depuis Readest",
|
||||
"Highlights & Annotations": "Surlignages et annotations",
|
||||
"Note": "Note",
|
||||
"Copied to clipboard": "Copié dans le presse-papiers",
|
||||
"Export Annotations": "Exporter les annotations",
|
||||
"Auto Import on File Open": "Importation automatique à l'ouverture du fichier",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Échanger la zone de clic pour tourner"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "विरोधाभास",
|
||||
"Sunset": "सनसेट",
|
||||
"Double Border": "डबल बॉर्डर",
|
||||
"Border Color": "बॉर्डर रंग"
|
||||
"Border Color": "बॉर्डर रंग",
|
||||
"Border Frame": "बॉर्डर फ्रेम",
|
||||
"Show Header": "हेडर दिखाएं",
|
||||
"Show Footer": "फ़ुटर दिखाएं",
|
||||
"Small": "छोटा",
|
||||
"Large": "बड़ा",
|
||||
"Auto": "स्वचालित",
|
||||
"Language": "भाषा",
|
||||
"No annotations to export": "निर्यात करने के लिए कोई टिप्पणियाँ नहीं हैं",
|
||||
"Author": "लेखक",
|
||||
"Exported from Readest": "Readest से निर्यात किया गया",
|
||||
"Highlights & Annotations": "हाइलाइट और टिप्पणियाँ",
|
||||
"Note": "टिप्पणी",
|
||||
"Copied to clipboard": "क्लिपबोर्ड पर कॉपी किया गया",
|
||||
"Export Annotations": "टिप्पणियाँ निर्यात करें",
|
||||
"Auto Import on File Open": "फ़ाइल खोलने पर स्वचालित आयात",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Kontras",
|
||||
"Sunset": "Senja",
|
||||
"Double Border": "Garis Ganda",
|
||||
"Border Color": "Warna Garis"
|
||||
"Border Color": "Warna Garis",
|
||||
"Border Frame": "Bingkai Garis",
|
||||
"Show Header": "Tampilkan Header",
|
||||
"Show Footer": "Tampilkan Footer",
|
||||
"Small": "Kecil",
|
||||
"Large": "Besar",
|
||||
"Auto": "Otomatis",
|
||||
"Language": "Bahasa",
|
||||
"No annotations to export": "Tidak ada anotasi yang diekspor",
|
||||
"Author": "Penulis",
|
||||
"Exported from Readest": "Diekspor dari Readest",
|
||||
"Highlights & Annotations": "Sorotan & Anotasi",
|
||||
"Note": "Catatan",
|
||||
"Copied to clipboard": "Disalin ke papan klip",
|
||||
"Export Annotations": "Ekspor Anotasi",
|
||||
"Auto Import on File Open": "Impor Otomatis saat File Dibuka",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Tukar Area Klik untuk Membalik"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Contrasto",
|
||||
"Sunset": "Tramonto",
|
||||
"Double Border": "Doppio bordo",
|
||||
"Border Color": "Colore bordo"
|
||||
"Border Color": "Colore bordo",
|
||||
"Border Frame": "Cornice bordo",
|
||||
"Show Header": "Mostra intestazione",
|
||||
"Show Footer": "Mostra piè di pagina",
|
||||
"Small": "Piccolo",
|
||||
"Large": "Grande",
|
||||
"Auto": "Automatico",
|
||||
"Language": "Lingua",
|
||||
"No annotations to export": "Nessuna annotazione da esportare",
|
||||
"Author": "Autore",
|
||||
"Exported from Readest": "Esportato da Readest",
|
||||
"Highlights & Annotations": "Evidenziazioni e annotazioni",
|
||||
"Note": "Nota",
|
||||
"Copied to clipboard": "Copiato negli appunti",
|
||||
"Export Annotations": "Esporta annotazioni",
|
||||
"Auto Import on File Open": "Importazione automatica all'apertura del file",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Scambia area click per voltare pagina"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "コントラスト",
|
||||
"Sunset": "サンセット",
|
||||
"Double Border": "二重枠",
|
||||
"Border Color": "ボーダーカラー"
|
||||
"Border Color": "ボーダーカラー",
|
||||
"Border Frame": "ボーダーフレーム",
|
||||
"Show Header": "ヘッダーを表示",
|
||||
"Show Footer": "フッターを表示",
|
||||
"Small": "小",
|
||||
"Large": "大",
|
||||
"Auto": "自動",
|
||||
"Language": "言語",
|
||||
"No annotations to export": "エクスポートする注釈はありません",
|
||||
"Author": "著者",
|
||||
"Exported from Readest": "Readestからエクスポート",
|
||||
"Highlights & Annotations": "ハイライトと注釈",
|
||||
"Note": "メモ",
|
||||
"Copied to clipboard": "クリップボードにコピー",
|
||||
"Export Annotations": "注釈をエクスポート",
|
||||
"Auto Import on File Open": "ファイルを開くと自動インポート",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "クリックめくりエリアを入れ替え"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "대비",
|
||||
"Sunset": "일몰",
|
||||
"Double Border": "이중 테두리",
|
||||
"Border Color": "테두리 색상"
|
||||
"Border Color": "테두리 색상",
|
||||
"Border Frame": "테두리 프레임",
|
||||
"Show Header": "헤더 표시",
|
||||
"Show Footer": "푸터 표시",
|
||||
"Small": "작음",
|
||||
"Large": "큼",
|
||||
"Auto": "자동",
|
||||
"Language": "언어",
|
||||
"No annotations to export": "내보낼 주석이 없습니다",
|
||||
"Author": "저자",
|
||||
"Exported from Readest": "Readest에서 내보냄",
|
||||
"Highlights & Annotations": "하이라이트 및 주석",
|
||||
"Note": "노트",
|
||||
"Copied to clipboard": "클립보드에 복사",
|
||||
"Export Annotations": "주석 내보내기",
|
||||
"Auto Import on File Open": "파일 열 때 자동 가져오기",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "클릭-뒤집기 영역 교환"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Kontrast",
|
||||
"Sunset": "Zachód słońca",
|
||||
"Double Border": "Podwójna ramka",
|
||||
"Border Color": "Kolor ramki"
|
||||
"Border Color": "Kolor ramki",
|
||||
"Border Frame": "Ramka",
|
||||
"Show Header": "Pokaż nagłówek",
|
||||
"Show Footer": "Pokaż stopkę",
|
||||
"Small": "Mały",
|
||||
"Large": "Duży",
|
||||
"Auto": "Automatyczny",
|
||||
"Language": "Język",
|
||||
"No annotations to export": "Brak adnotacji do eksportu",
|
||||
"Author": "Autor",
|
||||
"Exported from Readest": "Eksportowane z Readest",
|
||||
"Highlights & Annotations": "Zaznaczenia i adnotacje",
|
||||
"Note": "Notatka",
|
||||
"Copied to clipboard": "Skopiowano do schowka",
|
||||
"Export Annotations": "Eksportuj adnotacje",
|
||||
"Auto Import on File Open": "Automatyczne importowanie przy otwieraniu pliku",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Zamień obszar kliknięcia na przewracanie"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Contraste",
|
||||
"Sunset": "Pôr do Sol",
|
||||
"Double Border": "Borda Dupla",
|
||||
"Border Color": "Cor da Borda"
|
||||
"Border Color": "Cor da Borda",
|
||||
"Border Frame": "Moldura da Borda",
|
||||
"Show Header": "Mostrar Cabeçalho",
|
||||
"Show Footer": "Mostrar Rodapé",
|
||||
"Small": "Pequeno",
|
||||
"Large": "Grande",
|
||||
"Auto": "Automático",
|
||||
"Language": "Idioma",
|
||||
"No annotations to export": "Nenhuma anotação para exportar",
|
||||
"Author": "Autor",
|
||||
"Exported from Readest": "Exportado do Readest",
|
||||
"Highlights & Annotations": "Destaques e Anotações",
|
||||
"Note": "Nota",
|
||||
"Copied to clipboard": "Copiado para a área de transferência",
|
||||
"Export Annotations": "Exportar Anotações",
|
||||
"Auto Import on File Open": "Importação Automática ao Abrir Arquivo",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Trocar Área de Clique para Virar"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Контраст",
|
||||
"Sunset": "Закат",
|
||||
"Double Border": "Двойная граница",
|
||||
"Border Color": "Цвет границы"
|
||||
"Border Color": "Цвет границы",
|
||||
"Border Frame": "Граница рамки",
|
||||
"Show Header": "Показать заголовок",
|
||||
"Show Footer": "Показать нижний колонтитул",
|
||||
"Small": "Маленький",
|
||||
"Large": "Большой",
|
||||
"Auto": "Авто",
|
||||
"Language": "Язык",
|
||||
"No annotations to export": "Нет аннотаций для экспорта",
|
||||
"Author": "Автор",
|
||||
"Exported from Readest": "Экспортировано из Readest",
|
||||
"Highlights & Annotations": "Выделения и аннотации",
|
||||
"Note": "Заметка",
|
||||
"Copied to clipboard": "Скопировано в буфер обмена",
|
||||
"Export Annotations": "Экспортировать аннотации",
|
||||
"Auto Import on File Open": "Автоматический импорт при открытии файла",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Переключить область клика для переворота"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Kontrast",
|
||||
"Sunset": "Gün Batımı",
|
||||
"Double Border": "Çift Sınır",
|
||||
"Border Color": "Sınır Rengi"
|
||||
"Border Color": "Sınır Rengi",
|
||||
"Border Frame": "Sınır Çerçevesi",
|
||||
"Show Header": "Başlık Göster",
|
||||
"Show Footer": "Altbilgi Göster",
|
||||
"Small": "Küçük",
|
||||
"Large": "Büyük",
|
||||
"Auto": "Otomatik",
|
||||
"Language": "Dil",
|
||||
"No annotations to export": "Dışa aktarılacak not yok",
|
||||
"Author": "Yazar",
|
||||
"Exported from Readest": "Readest'ten dışa aktarıldı",
|
||||
"Highlights & Annotations": "Vurgular ve Notlar",
|
||||
"Note": "Not",
|
||||
"Copied to clipboard": "Panoya kopyalandı",
|
||||
"Export Annotations": "Notları Dışa Aktar",
|
||||
"Auto Import on File Open": "Dosya Açıldığında Otomatik İçe Aktar",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Tıkla-Çevir Alanını Değiştir"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Контраст",
|
||||
"Sunset": "Захід сонця",
|
||||
"Double Border": "Подвійна рамка",
|
||||
"Border Color": "Колір рамки"
|
||||
"Border Color": "Колір рамки",
|
||||
"Border Frame": "Рамка рамки",
|
||||
"Show Header": "Показати заголовок",
|
||||
"Show Footer": "Показати нижню частину",
|
||||
"Small": "Малий",
|
||||
"Large": "Великий",
|
||||
"Auto": "Автоматичний",
|
||||
"Language": "Мова",
|
||||
"No annotations to export": "Немає анотацій для експорту",
|
||||
"Author": "Автор",
|
||||
"Exported from Readest": "Експортовано з Readest",
|
||||
"Highlights & Annotations": "Виділення та анотації",
|
||||
"Note": "Примітка",
|
||||
"Copied to clipboard": "Скопійовано в буфер обміну",
|
||||
"Export Annotations": "Експортувати анотації",
|
||||
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Поміняти місцями область кліку для перевороту"
|
||||
}
|
||||
|
||||
@@ -244,5 +244,22 @@
|
||||
"Contrast": "Tương phản",
|
||||
"Sunset": "Hoàng hôn",
|
||||
"Double Border": "Đôi viền",
|
||||
"Border Color": "Màu viền"
|
||||
"Border Color": "Màu viền",
|
||||
"Border Frame": "Khung viền",
|
||||
"Show Header": "Hiển thị tiêu đề",
|
||||
"Show Footer": "Hiển thị chân trang",
|
||||
"Small": "Nhỏ",
|
||||
"Large": "Lớn",
|
||||
"Auto": "Tự động",
|
||||
"Language": "Ngôn ngữ",
|
||||
"No annotations to export": "Không có chú thích để xuất",
|
||||
"Author": "Tác giả",
|
||||
"Exported from Readest": "Đã xuất từ Readest",
|
||||
"Highlights & Annotations": "Điểm nổi bật & Chú thích",
|
||||
"Note": "Ghi chú",
|
||||
"Copied to clipboard": "Đã sao chép vào bảng ghi",
|
||||
"Export Annotations": "Xuất chú thích",
|
||||
"Auto Import on File Open": "Tự động nhập khi mở tệp",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"Swap Click-to-Flip Area": "Hoán đổi khu vực nhấp để lật trang"
|
||||
}
|
||||
|
||||
@@ -243,6 +243,23 @@
|
||||
"Preview": "预览",
|
||||
"Contrast": "对比",
|
||||
"Sunset": "日落",
|
||||
"Double Border": "页面版框",
|
||||
"Border Color": "版框颜色"
|
||||
"Double Border": "显示版框",
|
||||
"Border Color": "版框颜色",
|
||||
"Border Frame": "页面版框",
|
||||
"Show Header": "显示页眉",
|
||||
"Show Footer": "显示页脚",
|
||||
"Small": "小",
|
||||
"Large": "大",
|
||||
"Auto": "自动",
|
||||
"Language": "语言",
|
||||
"No annotations to export": "没有要导出的笔记",
|
||||
"Author": "作者",
|
||||
"Exported from Readest": "从 Readest 导出",
|
||||
"Highlights & Annotations": "划线和笔记",
|
||||
"Note": "笔记",
|
||||
"Copied to clipboard": "已复制到剪贴板",
|
||||
"Export Annotations": "导出笔记",
|
||||
"Auto Import on File Open": "打开文件时自动导入",
|
||||
"LXGW WenKai GB Screen": "霞鹜文楷",
|
||||
"Swap Click-to-Flip Area": "交换点击翻页区域"
|
||||
}
|
||||
|
||||
@@ -243,6 +243,23 @@
|
||||
"Preview": "預覽",
|
||||
"Contrast": "對比",
|
||||
"Sunset": "日落",
|
||||
"Double Border": "頁面邊框",
|
||||
"Border Color": "邊框顏色"
|
||||
"Double Border": "顯示邊框",
|
||||
"Border Color": "邊框顏色",
|
||||
"Border Frame": "頁面邊框",
|
||||
"Show Header": "顯示頁眉",
|
||||
"Show Footer": "顯示頁腳",
|
||||
"Small": "小",
|
||||
"Large": "大",
|
||||
"Auto": "自動",
|
||||
"Language": "語言",
|
||||
"No annotations to export": "沒有要匯出的筆記",
|
||||
"Author": "作者",
|
||||
"Exported from Readest": "從 Readest 匯出",
|
||||
"Highlights & Annotations": "高亮和筆記",
|
||||
"Note": "筆記",
|
||||
"Copied to clipboard": "已複製到剪貼板",
|
||||
"Export Annotations": "匯出筆記",
|
||||
"Auto Import on File Open": "打開文件時自動導入",
|
||||
"LXGW WenKai GB Screen": "霞鶩文楷",
|
||||
"Swap Click-to-Flip Area": "交換點擊翻頁區域"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.27": {
|
||||
"date": "2025-03-29",
|
||||
"notes": [
|
||||
"Added option to swap click-to-flip area and reset password",
|
||||
"Added global shortcuts for full-text search",
|
||||
"Added support for both R2 and S3 storage in cloud sync",
|
||||
"Improved TTS with fewer unintended pauses, faster startup, and support for preferred voices",
|
||||
"Various fixes for rendering styles and UI layout"
|
||||
]
|
||||
},
|
||||
"0.9.26": {
|
||||
"date": "2025-03-27",
|
||||
"notes": [
|
||||
"Support open book from file manager without importing",
|
||||
"Add LXGW WenKai font in Serif fonts list",
|
||||
"Various fixes on layout and styles on Windows and Android"
|
||||
]
|
||||
},
|
||||
"0.9.25": {
|
||||
"date": "2025-03-24",
|
||||
"notes": [
|
||||
"Support exporting annotations in markdown format",
|
||||
"Support importing TXT files",
|
||||
"Support setting reader language in the UI",
|
||||
"Add bottom action bar on mobile platforms",
|
||||
"Various fixes on layout settings and styles"
|
||||
]
|
||||
},
|
||||
"0.9.23": {
|
||||
"date": "2025-03-19",
|
||||
"notes": [
|
||||
"Add options to show/hide header and footer",
|
||||
"Add responsive window size for popup footnotes",
|
||||
"Fix theme color not applied on Safari browsers",
|
||||
"Fix progress not updated to 100% when finished reading"
|
||||
]
|
||||
},
|
||||
"0.9.22": {
|
||||
"date": "2025-03-16",
|
||||
"notes": [
|
||||
|
||||
@@ -46,7 +46,7 @@ tauri-plugin-opener = "2.2.2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-sign-in-with-apple = "1.0.2"
|
||||
tauri-plugin-haptics = "2.2.3"
|
||||
tauri-plugin-safari-auth = { path = "./plugins/tauri-plugin-safari-auth" }
|
||||
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
|
||||
[patch.crates-io]
|
||||
tauri = { path = "../../../packages/tauri/crates/tauri" }
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"haptics:allow-impact-feedback",
|
||||
"haptics:allow-notification-feedback",
|
||||
"haptics:allow-selection-feedback",
|
||||
"safari-auth:default",
|
||||
"native-bridge:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:largeHeap="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.readest"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="web.readest.com" />
|
||||
|
||||
</intent-filter>
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "tauri-plugin-native-bridge"
|
||||
version = "0.1.0"
|
||||
authors = [ "You" ]
|
||||
description = "a bridge between tauri app and native OS functionality"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
|
||||
links = "tauri-plugin-native-bridge"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.3.1" }
|
||||
serde = "1.0"
|
||||
thiserror = "2"
|
||||
schemars = "0.8"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2.0.4", features = ["build"] }
|
||||
schemars = "0.8"
|
||||
@@ -0,0 +1 @@
|
||||
# Tauri Plugin native-bridge
|
||||
+1
-1
@@ -4,7 +4,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.plugin.safari_auth"
|
||||
namespace = "com.readest.native_bridge"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.plugin.safari_auth
|
||||
package com.readest.native_bridge
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.plugin.safari_auth", appContext.packageName)
|
||||
assertEquals("com.readest.native_bridge", appContext.packageName)
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.readest.native_bridge
|
||||
|
||||
import android.util.Log
|
||||
|
||||
class NativeBridge {
|
||||
fun auth_with_safari(value: String): String {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.readest.native_bridge
|
||||
|
||||
import android.app.Activity
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
|
||||
@InvokeArg
|
||||
class SafariAuthRequestArgs {
|
||||
var authUrl: String? = null
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
private val implementation = NativeBridge()
|
||||
|
||||
@Command
|
||||
fun auth_with_safari(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(SafariAuthRequestArgs::class.java)
|
||||
|
||||
val ret = JSObject()
|
||||
ret.put("redirectUrl", implementation.auth_with_safari(args.authUrl ?: ""))
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.plugin.safari_auth
|
||||
package com.readest.native_bridge
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const COMMANDS: &[&str] = &["auth_with_safari"];
|
||||
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(COMMANDS)
|
||||
.android_path("android")
|
||||
.ios_path("ios")
|
||||
.build();
|
||||
}
|
||||
+4
-4
@@ -4,7 +4,7 @@
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "tauri-plugin-safari-auth",
|
||||
name: "tauri-plugin-native-bridge",
|
||||
platforms: [
|
||||
.macOS(.v10_13),
|
||||
.iOS(.v13),
|
||||
@@ -12,9 +12,9 @@ let package = Package(
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "tauri-plugin-safari-auth",
|
||||
name: "tauri-plugin-native-bridge",
|
||||
type: .static,
|
||||
targets: ["tauri-plugin-safari-auth"]),
|
||||
targets: ["tauri-plugin-native-bridge"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Tauri", path: "../.tauri/tauri-api")
|
||||
@@ -23,7 +23,7 @@ let package = Package(
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "tauri-plugin-safari-auth",
|
||||
name: "tauri-plugin-native-bridge",
|
||||
dependencies: [
|
||||
.byName(name: "Tauri")
|
||||
],
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Tauri Plugin safari-auth
|
||||
# Tauri Plugin native-bridge
|
||||
|
||||
A description of this package.
|
||||
+7
-11
@@ -4,19 +4,15 @@ import Tauri
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
class RequestArgs: Decodable {
|
||||
class SafariAuthRequestArgs: Decodable {
|
||||
let authUrl: String
|
||||
}
|
||||
|
||||
struct Response: Encodable {
|
||||
let redirectUrl: String
|
||||
}
|
||||
|
||||
class SafariAuthPlugin: Plugin {
|
||||
class NativeBridgePlugin: Plugin {
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
|
||||
@objc public func auth_with_safari(_ invoke: Invoke) throws {
|
||||
let args = try invoke.parseArgs(RequestArgs.self)
|
||||
let args = try invoke.parseArgs(SafariAuthRequestArgs.self)
|
||||
let authUrl = URL(string: args.authUrl)!
|
||||
|
||||
authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: "readest") {
|
||||
@@ -46,14 +42,14 @@ class SafariAuthPlugin: Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_safari_auth")
|
||||
@_cdecl("init_plugin_native_bridge")
|
||||
func initPlugin() -> Plugin {
|
||||
return SafariAuthPlugin()
|
||||
return NativeBridgePlugin()
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
extension SafariAuthPlugin: ASWebAuthenticationPresentationContextProviding {
|
||||
extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
return UIApplication.shared.windows.first ?? UIWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import NativeBridgePlugin
|
||||
|
||||
final class NativeBridgePluginTests: XCTestCase {
|
||||
func testNativeBridge() throws {
|
||||
let plugin = NativeBridgePlugin()
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -16,7 +16,7 @@ Default permissions for the plugin
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`safari-auth:allow-auth-with-safari`
|
||||
`native-bridge:allow-auth-with-safari`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
@@ -29,7 +29,7 @@ Enables the auth_with_safari command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`safari-auth:deny-auth-with-safari`
|
||||
`native-bridge:deny-auth-with-safari`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
+2
-2
@@ -49,7 +49,7 @@
|
||||
"minimum": 1.0
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
|
||||
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -111,7 +111,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri internal convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
|
||||
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
use tauri::{command, AppHandle, Runtime};
|
||||
|
||||
use crate::models::*;
|
||||
use crate::NativeBridgeExt;
|
||||
use crate::Result;
|
||||
use crate::SafariAuthExt;
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn auth_with_safari<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: SafariAuthRequest,
|
||||
) -> Result<SafariAuthResponse> {
|
||||
app.safari_auth().auth_with_safari(payload)
|
||||
app.native_bridge().auth_with_safari(payload)
|
||||
}
|
||||
+5
-5
@@ -6,14 +6,14 @@ use crate::models::*;
|
||||
pub fn init<R: Runtime, C: DeserializeOwned>(
|
||||
app: &AppHandle<R>,
|
||||
_api: PluginApi<R, C>,
|
||||
) -> crate::Result<SafariAuth<R>> {
|
||||
Ok(SafariAuth(app.clone()))
|
||||
) -> crate::Result<NativeBridge<R>> {
|
||||
Ok(NativeBridge(app.clone()))
|
||||
}
|
||||
|
||||
/// Access to the safari-auth APIs.
|
||||
pub struct SafariAuth<R: Runtime>(AppHandle<R>);
|
||||
/// Access to the native-bridge APIs.
|
||||
pub struct NativeBridge<R: Runtime>(AppHandle<R>);
|
||||
|
||||
impl<R: Runtime> SafariAuth<R> {
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn auth_with_safari(
|
||||
&self,
|
||||
_payload: SafariAuthRequest,
|
||||
+2
@@ -6,6 +6,8 @@ pub type Result<T> = std::result::Result<T, Error>;
|
||||
pub enum Error {
|
||||
#[error("Unsupported platform for this plugin")]
|
||||
UnsupportedPlatformError,
|
||||
#[error("Native bridge error: {0}")]
|
||||
NativeBridgeError(String),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[cfg(mobile)]
|
||||
+13
-12
@@ -13,35 +13,36 @@ mod mobile;
|
||||
mod commands;
|
||||
mod error;
|
||||
mod models;
|
||||
mod platform;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
||||
#[cfg(desktop)]
|
||||
use desktop::SafariAuth;
|
||||
use desktop::NativeBridge;
|
||||
#[cfg(mobile)]
|
||||
use mobile::SafariAuth;
|
||||
use mobile::NativeBridge;
|
||||
|
||||
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the safari-auth APIs.
|
||||
pub trait SafariAuthExt<R: Runtime> {
|
||||
fn safari_auth(&self) -> &SafariAuth<R>;
|
||||
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the native-bridge APIs.
|
||||
pub trait NativeBridgeExt<R: Runtime> {
|
||||
fn native_bridge(&self) -> &NativeBridge<R>;
|
||||
}
|
||||
|
||||
impl<R: Runtime, T: Manager<R>> crate::SafariAuthExt<R> for T {
|
||||
fn safari_auth(&self) -> &SafariAuth<R> {
|
||||
self.state::<SafariAuth<R>>().inner()
|
||||
impl<R: Runtime, T: Manager<R>> crate::NativeBridgeExt<R> for T {
|
||||
fn native_bridge(&self) -> &NativeBridge<R> {
|
||||
self.state::<NativeBridge<R>>().inner()
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the plugin.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("safari-auth")
|
||||
Builder::new("native-bridge")
|
||||
.invoke_handler(tauri::generate_handler![commands::auth_with_safari])
|
||||
.setup(|app, api| {
|
||||
#[cfg(mobile)]
|
||||
let safari_auth = mobile::init(app, api)?;
|
||||
let native_bridge = mobile::init(app, api)?;
|
||||
#[cfg(desktop)]
|
||||
let safari_auth = desktop::init(app, api)?;
|
||||
app.manage(safari_auth);
|
||||
let native_bridge = desktop::init(app, api)?;
|
||||
app.manage(native_bridge);
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
+8
-9
@@ -7,25 +7,24 @@ use tauri::{
|
||||
use crate::models::*;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
tauri::ios_plugin_binding!(init_plugin_safari_auth);
|
||||
tauri::ios_plugin_binding!(init_plugin_native_bridge);
|
||||
|
||||
// initializes the Kotlin or Swift plugin classes
|
||||
pub fn init<R: Runtime, C: DeserializeOwned>(
|
||||
_app: &AppHandle<R>,
|
||||
api: PluginApi<R, C>,
|
||||
) -> crate::Result<SafariAuth<R>> {
|
||||
) -> crate::Result<NativeBridge<R>> {
|
||||
#[cfg(target_os = "android")]
|
||||
let handle = api.register_android_plugin("com.bilingify.safari_auth", "ExamplePlugin")?;
|
||||
let handle = api.register_android_plugin("com.readest.native_bridge", "NativeBridgePlugin")?;
|
||||
#[cfg(target_os = "ios")]
|
||||
let handle = api.register_ios_plugin(init_plugin_safari_auth)?;
|
||||
|
||||
Ok(SafariAuth(handle))
|
||||
let handle = api.register_ios_plugin(init_plugin_native_bridge)?;
|
||||
Ok(NativeBridge(handle))
|
||||
}
|
||||
|
||||
/// Access to the safari-auth APIs.
|
||||
pub struct SafariAuth<R: Runtime>(PluginHandle<R>);
|
||||
/// Access to the native-bridge APIs.
|
||||
pub struct NativeBridge<R: Runtime>(PluginHandle<R>);
|
||||
|
||||
impl<R: Runtime> SafariAuth<R> {
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn auth_with_safari(
|
||||
&self,
|
||||
payload: SafariAuthRequest,
|
||||
@@ -0,0 +1,2 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "tauri-plugin-safari-auth"
|
||||
version = "0.1.0"
|
||||
authors = [ "chrox" ]
|
||||
description = "OAuth with ASWebAuthenticationSession on iOS"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
|
||||
links = "tauri-plugin-safari-auth"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.2.4" }
|
||||
serde = "1.0"
|
||||
thiserror = "2"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2.0.3", features = ["build"] }
|
||||
@@ -1 +0,0 @@
|
||||
# Tauri Plugin safari-auth
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import android.util.Log
|
||||
|
||||
class Example {
|
||||
fun pong(value: String): String {
|
||||
Log.i("Pong", value)
|
||||
return value
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import android.app.Activity
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
|
||||
@InvokeArg
|
||||
class PingArgs {
|
||||
var value: String? = null
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
|
||||
private val implementation = Example()
|
||||
|
||||
@Command
|
||||
fun ping(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(PingArgs::class.java)
|
||||
|
||||
val ret = JSObject()
|
||||
ret.put("value", implementation.pong(args.value ?: "default value :("))
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
const COMMANDS: &[&str] = &["auth_with_safari"];
|
||||
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(COMMANDS)
|
||||
.android_path("android")
|
||||
.ios_path("ios")
|
||||
.build();
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import XCTest
|
||||
@testable import SafariAuthPlugin
|
||||
|
||||
final class SafariAuthPluginTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
let plugin = SafariAuthPlugin()
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,31 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn get_files_from_argv(argv: Vec<String>) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
|
||||
// or arguments (`--`) if your app supports them.
|
||||
// files may also be passed as `file://path/to/file`
|
||||
for (_, maybe_file) in argv.iter().enumerate().skip(1) {
|
||||
// skip flags like -f or --flag
|
||||
if maybe_file.starts_with("-") {
|
||||
continue;
|
||||
}
|
||||
// handle `file://` path urls and skip other urls
|
||||
if let Ok(url) = Url::parse(maybe_file) {
|
||||
if let Ok(path) = url.to_file_path() {
|
||||
files.push(path);
|
||||
} else {
|
||||
files.push(PathBuf::from(maybe_file))
|
||||
}
|
||||
} else {
|
||||
files.push(PathBuf::from(maybe_file))
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
let files = files
|
||||
@@ -63,6 +88,15 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn set_rounded_window(app: &AppHandle, rounded: bool) {
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
let script = format!("window.IS_ROUNDED = {};", rounded);
|
||||
if let Err(e) = window.eval(&script) {
|
||||
eprintln!("Failed to set IS_ROUNDED variable: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn start_server(window: Window) -> Result<u16, String> {
|
||||
start(move |url| {
|
||||
@@ -110,6 +144,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_native_bridge::init())
|
||||
.plugin(tauri_plugin_fs::init());
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -118,6 +153,10 @@ pub fn run() {
|
||||
.get_webview_window("main")
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
let files = get_files_from_argv(argv.clone());
|
||||
if !files.is_empty() {
|
||||
allow_file_in_scopes(app, files.clone());
|
||||
}
|
||||
app.emit("single-instance", Payload { args: argv, cwd })
|
||||
.unwrap();
|
||||
}));
|
||||
@@ -136,9 +175,6 @@ pub fn run() {
|
||||
#[cfg(target_os = "ios")]
|
||||
let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init());
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
let builder = builder.plugin(tauri_plugin_safari_auth::init());
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let builder = builder.plugin(tauri_plugin_haptics::init());
|
||||
|
||||
@@ -146,26 +182,7 @@ pub fn run() {
|
||||
.setup(|#[allow(unused_variables)] app| {
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let mut files = Vec::new();
|
||||
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
|
||||
// or arguments (`--`) if your app supports them.
|
||||
// files may also be passed as `file://path/to/file`
|
||||
for maybe_file in std::env::args().skip(1) {
|
||||
// skip flags like -f or --flag
|
||||
if maybe_file.starts_with("-") {
|
||||
continue;
|
||||
}
|
||||
// handle `file://` path urls and skip other urls
|
||||
if let Ok(url) = Url::parse(&maybe_file) {
|
||||
if let Ok(path) = url.to_file_path() {
|
||||
files.push(path);
|
||||
} else {
|
||||
files.push(PathBuf::from(maybe_file))
|
||||
}
|
||||
} else {
|
||||
files.push(PathBuf::from(maybe_file))
|
||||
}
|
||||
}
|
||||
let files = get_files_from_argv(std::env::args().collect());
|
||||
if !files.is_empty() {
|
||||
let app_handle = app.handle().clone();
|
||||
allow_file_in_scopes(&app_handle, files.clone());
|
||||
@@ -185,6 +202,12 @@ pub fn run() {
|
||||
app_handle.get_webview_window("main").unwrap()
|
||||
.eval("window.__READEST_CLI_ACCESS = true; window.__READEST_UPDATER_ACCESS = true;")
|
||||
.expect("Failed to set cli access config");
|
||||
|
||||
set_rounded_window(&app_handle, true);
|
||||
#[cfg(target_os = "windows")]
|
||||
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
|
||||
set_rounded_window(&app_handle, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,8 +248,7 @@ pub fn run() {
|
||||
.title("Readest");
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
let version = tauri_plugin_os::version();
|
||||
if version <= tauri_plugin_os::Version::from_string("10.0.19045") {
|
||||
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
|
||||
win_builder = win_builder.shadow(false);
|
||||
} else {
|
||||
win_builder = win_builder.shadow(true);
|
||||
|
||||
@@ -16,9 +16,27 @@ export default function AuthCallback() {
|
||||
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const type = params.get('type');
|
||||
const next = params.get('next') ?? '/';
|
||||
const error = params.get('error');
|
||||
const errorDescription = params.get('error_description');
|
||||
const errorCode = params.get('error_code');
|
||||
|
||||
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
|
||||
handleAuthCallback({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
type,
|
||||
next,
|
||||
error,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
login,
|
||||
navigate: router.push,
|
||||
});
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<span className='loading loading-infinity loading-xl w-20'></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
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 { onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||
@@ -23,6 +24,8 @@ import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { handleAuthCallback } from '@/helpers/auth';
|
||||
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
|
||||
import { authWithSafari } from './utils/safariAuth';
|
||||
import { READEST_WEB_BASE_URL } from '@/services/constants';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
|
||||
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
|
||||
|
||||
@@ -38,7 +41,7 @@ interface ProviderLoginProp {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const WEB_AUTH_CALLBACK = 'https://web.readest.com/auth/callback';
|
||||
const WEB_AUTH_CALLBACK = `${READEST_WEB_BASE_URL}/auth/callback`;
|
||||
const DEEPLINK_CALLBACK = 'readest://auth/callback';
|
||||
|
||||
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
|
||||
@@ -47,11 +50,11 @@ const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Ic
|
||||
onClick={() => handleSignIn(provider)}
|
||||
className={clsx(
|
||||
'mb-2 flex w-64 items-center justify-center rounded border p-2.5',
|
||||
'bg-base-100 border-gray-300 shadow-sm transition hover:bg-gray-50',
|
||||
'bg-base-100 border-base-300 hover:bg-base-200 shadow-sm transition',
|
||||
)}
|
||||
>
|
||||
<Icon />
|
||||
<span className='text-neutral-content/70 px-2 text-sm'>{label}</span>
|
||||
<span className='text-base-content/75 px-2 text-sm'>{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -62,11 +65,14 @@ export default function AuthPage() {
|
||||
const { login } = useAuth();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
const isOAuthServerRunning = useRef(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getTauriRedirectTo = (isOAuth: boolean) => {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
|
||||
if (appService?.isIOSApp) {
|
||||
@@ -79,6 +85,12 @@ export default function AuthPage() {
|
||||
return `http://localhost:${port}`; // only for development env on Desktop
|
||||
};
|
||||
|
||||
const getWebRedirectTo = () => {
|
||||
return process.env.NODE_ENV === 'production'
|
||||
? WEB_AUTH_CALLBACK
|
||||
: `${window.location.origin}/auth/callback`;
|
||||
};
|
||||
|
||||
const tauriSignInApple = async () => {
|
||||
if (!supabase) {
|
||||
throw new Error('No backend connected');
|
||||
@@ -136,9 +148,10 @@ export default function AuthPage() {
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const type = params.get('type');
|
||||
const next = params.get('next') ?? '/';
|
||||
if (accessToken) {
|
||||
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
|
||||
handleAuthCallback({ accessToken, refreshToken, type, next, login, navigate: router.push });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -235,13 +248,6 @@ export default function AuthPage() {
|
||||
link_text: _('Forgot your password?'),
|
||||
confirmation_text: _('Check your email for the password reset link'),
|
||||
},
|
||||
update_password: {
|
||||
password_label: _('New Password'),
|
||||
password_input_placeholder: _('Your new password'),
|
||||
button_label: _('Update password'),
|
||||
loading_button_label: _('Updating password ...'),
|
||||
confirmation_text: _('Your password has been updated'),
|
||||
},
|
||||
verify_otp: {
|
||||
email_input_label: _('Email address'),
|
||||
email_input_placeholder: _('Your email address'),
|
||||
@@ -298,21 +304,31 @@ export default function AuthPage() {
|
||||
return isTauriAppPlatform() ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-6 flex',
|
||||
'bg-base-100 border-base-200 flex h-dvh w-full select-none flex-col items-center border',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
appService?.hasTrafficLight && 'pt-11',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'btn btn-ghost fixed left-4 h-8 min-h-8 w-8 p-0',
|
||||
appService?.hasSafeAreaInset && 'top-[calc(env(safe-area-inset-top)+16px)]',
|
||||
appService?.hasTrafficLight && 'top-11',
|
||||
'fixed flex w-full items-center justify-between py-2 pe-6 ps-4',
|
||||
appService?.hasTrafficLight && 'pt-11',
|
||||
)}
|
||||
>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
<button onClick={handleGoBack} className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0')}>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
|
||||
{appService?.hasWindowBar && (
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={!isTrafficLightVisible}
|
||||
showMaximize={!isTrafficLightVisible}
|
||||
showClose={!isTrafficLightVisible}
|
||||
onClose={handleGoBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
|
||||
<ProviderLogin
|
||||
provider='google'
|
||||
@@ -332,7 +348,7 @@ export default function AuthPage() {
|
||||
Icon={FaGithub}
|
||||
label={_('Sign in with GitHub')}
|
||||
/>
|
||||
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
|
||||
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
@@ -358,7 +374,7 @@ export default function AuthPage() {
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={['google', 'apple', 'github']}
|
||||
redirectTo='/auth/callback'
|
||||
redirectTo={getWebRedirectTo()}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { ThemeSupa } from '@supabase/auth-ui-shared';
|
||||
import { Auth } from '@supabase/auth-ui-react';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
|
||||
const getAuthLocalization = () => {
|
||||
return {
|
||||
variables: {
|
||||
update_password: {
|
||||
password_label: _('New Password'),
|
||||
password_input_placeholder: _('Your new password'),
|
||||
button_label: _('Update password'),
|
||||
loading_button_label: _('Updating password ...'),
|
||||
confirmation_text: _('Your password has been updated'),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { data: subscription } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (session?.access_token && session.user && event === 'USER_UPDATED') {
|
||||
login(session.access_token, session.user);
|
||||
const redirectTo = new URLSearchParams(window.location.search).get('redirect');
|
||||
router.push(redirectTo ?? '/library');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.subscription.unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
view='update_password'
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={false}
|
||||
providers={[]}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface SafariAuthResponse {
|
||||
}
|
||||
|
||||
export async function authWithSafari(request: SafariAuthRequest): Promise<SafariAuthResponse> {
|
||||
const result = await invoke<SafariAuthResponse>('plugin:safari-auth|auth_with_safari', {
|
||||
const result = await invoke<SafariAuthResponse>('plugin:native-bridge|auth_with_safari', {
|
||||
payload: request,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getFilename } from '@/utils/book';
|
||||
import { getLocalBookFilename } from '@/utils/book';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
import { Book, BookGroupType, BooksGroup } from '@/types/book';
|
||||
@@ -152,7 +152,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
|
||||
const folder = `${settings.localBooksDir}/${getLocalBookFilename(book)}`;
|
||||
revealItemInDir(folder);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'titlebar h-13 z-10 flex w-full items-center py-2 pr-4 sm:pr-6',
|
||||
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
|
||||
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
|
||||
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,8 @@ const getProgressPercentage = (book: Book) => {
|
||||
if (book.progress && book.progress[1] === 1) {
|
||||
return 100;
|
||||
}
|
||||
return Math.round((book.progress[0] / book.progress[1]) * 100);
|
||||
const percentage = Math.round((book.progress[0] / book.progress[1]) * 100);
|
||||
return Math.max(0, Math.min(100, percentage));
|
||||
};
|
||||
|
||||
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { hasUpdater, isWebAppPlatform } from '@/services/environment';
|
||||
import { hasUpdater, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -33,6 +33,9 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
|
||||
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
|
||||
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
|
||||
settings.autoImportBooksOnOpen,
|
||||
);
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
@@ -74,6 +77,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoImportBooksOnOpen = () => {
|
||||
settings.autoImportBooksOnOpen = !settings.autoImportBooksOnOpen;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsAutoImportBooksOnOpen(settings.autoImportBooksOnOpen);
|
||||
};
|
||||
|
||||
const toggleAutoCheckUpdates = () => {
|
||||
settings.autoCheckUpdates = !settings.autoCheckUpdates;
|
||||
setSettings(settings);
|
||||
@@ -150,6 +160,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
icon={isAutoUpload ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleAutoUploadBooks}
|
||||
/>
|
||||
{isTauriAppPlatform() && !appService?.isMobile && (
|
||||
<MenuItem
|
||||
label={_('Auto Import on File Open')}
|
||||
icon={isAutoImportBooksOnOpen ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleAutoImportBooksOnOpen}
|
||||
/>
|
||||
)}
|
||||
{hasUpdater() && (
|
||||
<MenuItem
|
||||
label={_('Check Updates on Start')}
|
||||
@@ -163,9 +180,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={toggleScreenWakeLock}
|
||||
/>
|
||||
<hr className='border-base-200 my-1' />
|
||||
{appService?.hasRoundedWindow && (
|
||||
<MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />
|
||||
)}
|
||||
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
||||
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
|
||||
@@ -85,9 +85,8 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
}
|
||||
const mergedBook =
|
||||
matchingBook.updatedAt > oldBook.updatedAt
|
||||
? { ...oldBook, ...matchingBook, updatedAt: oldBook.updatedAt }
|
||||
: { ...matchingBook, ...oldBook, updatedAt: oldBook.updatedAt };
|
||||
mergedBook.progress = matchingBook.progress ?? oldBook.progress;
|
||||
? { ...oldBook, ...matchingBook }
|
||||
: { ...matchingBook, ...oldBook };
|
||||
return mergedBook;
|
||||
}
|
||||
return oldBook;
|
||||
|
||||
@@ -191,11 +191,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
const processOpenWithFiles = React.useCallback(
|
||||
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
|
||||
const settings = await appService.loadSettings();
|
||||
const bookIds: string[] = [];
|
||||
for (const file of openWithFiles) {
|
||||
console.log('Open with book:', file);
|
||||
try {
|
||||
const book = await appService.importBook(file, libraryBooks);
|
||||
const temp = !settings.autoImportBooksOnOpen;
|
||||
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
|
||||
if (book) {
|
||||
bookIds.push(book.hash);
|
||||
}
|
||||
@@ -364,6 +366,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
navigateToLogin(router);
|
||||
return false;
|
||||
} else if (err.message.includes('Insufficient storage quota')) {
|
||||
@@ -508,8 +512,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone mt-12 flex-grow overflow-auto px-4 sm:px-2',
|
||||
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
|
||||
'scroll-container drop-zone mt-[48px] flex-grow overflow-y-auto px-4 sm:px-2',
|
||||
appService?.hasSafeAreaInset && 'mt-[calc(52px+env(safe-area-inset-top))]',
|
||||
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
|
||||
@@ -81,37 +81,62 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
/>
|
||||
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
|
||||
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
|
||||
{viewSettings.scrolled ? null : (
|
||||
{viewSettings.vertical && viewSettings.scrolled && (
|
||||
<>
|
||||
{viewSettings.vertical && viewSettings.doubleBorder && (
|
||||
<DoubleBorder
|
||||
borderColor={viewSettings.borderColor}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
)}
|
||||
<SectionInfo
|
||||
section={sectionLabel}
|
||||
isVertical={viewSettings.vertical}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
<div
|
||||
className='bg-base-100 absolute left-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${horizontalGapPercent}%)`,
|
||||
height: `calc(100% - ${verticalMarginPixels}px)`,
|
||||
}}
|
||||
/>
|
||||
<HintInfo
|
||||
bookKey={bookKey}
|
||||
isVertical={viewSettings.vertical}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
<PageInfoView
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
isVertical={viewSettings.vertical}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
<div
|
||||
className='bg-base-100 absolute right-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${horizontalGapPercent}%)`,
|
||||
height: `calc(100% - ${verticalMarginPixels}px)`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{viewSettings.vertical && viewSettings.doubleBorder && (
|
||||
<DoubleBorder
|
||||
showHeader={viewSettings.showHeader}
|
||||
showFooter={viewSettings.showFooter}
|
||||
borderColor={viewSettings.borderColor}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
)}
|
||||
{viewSettings.showHeader && (
|
||||
<SectionInfo
|
||||
section={sectionLabel}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
isVertical={viewSettings.vertical}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
)}
|
||||
<HintInfo
|
||||
bookKey={bookKey}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isVertical={viewSettings.vertical}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
{viewSettings.showFooter && (
|
||||
<PageInfoView
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
isVertical={viewSettings.vertical}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
)}
|
||||
<Annotator bookKey={bookKey} />
|
||||
<FooterBar
|
||||
bookKey={bookKey}
|
||||
|
||||
@@ -2,6 +2,8 @@ interface DoubleBorderProps {
|
||||
borderColor: string;
|
||||
horizontalGap: number;
|
||||
verticalMargin: number;
|
||||
showHeader: boolean;
|
||||
showFooter: boolean;
|
||||
}
|
||||
|
||||
const paddingPx = 10;
|
||||
@@ -10,51 +12,63 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
|
||||
borderColor,
|
||||
horizontalGap,
|
||||
verticalMargin,
|
||||
showHeader,
|
||||
showFooter,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 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 * 2}% - 32px - ${paddingPx}px)`,
|
||||
right: `calc(${horizontalGap * 2}% - 32px - ${paddingPx}px)`,
|
||||
left: `calc(${horizontalGap}% - ${showFooter ? 32 : 0}px - ${paddingPx}px)`,
|
||||
right: `calc(${horizontalGap}% - ${showHeader ? 32 : 0}px - ${paddingPx}px)`,
|
||||
}}
|
||||
></div>
|
||||
{/* inner frame */}
|
||||
<div
|
||||
className={'borderframe pointer-events-none absolute'}
|
||||
style={{
|
||||
border: `1px solid ${borderColor}`,
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
top: `${verticalMargin}px`,
|
||||
left: `${horizontalGap * 2}%`,
|
||||
right: `${horizontalGap * 2}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={'borderframe pointer-events-none absolute'}
|
||||
style={{
|
||||
border: `1px solid ${borderColor}`,
|
||||
width: '32px',
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
top: `${verticalMargin}px`,
|
||||
left: `calc(${horizontalGap * 2}% - 32px)`,
|
||||
borderRight: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={'borderframe pointer-events-none absolute'}
|
||||
style={{
|
||||
border: `1px solid ${borderColor}`,
|
||||
width: '32px',
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
top: `${verticalMargin}px`,
|
||||
left: `calc(100% - ${horizontalGap * 2}%)`,
|
||||
borderLeft: 'none',
|
||||
left: showFooter ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
|
||||
right: showHeader ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
|
||||
}}
|
||||
/>
|
||||
{/* footer */}
|
||||
{showFooter && (
|
||||
<div
|
||||
className={'borderframe pointer-events-none absolute'}
|
||||
style={{
|
||||
borderTop: `1px solid ${borderColor}`,
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
borderLeft: `1px solid ${borderColor}`,
|
||||
width: '32px',
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
top: `${verticalMargin}px`,
|
||||
left: `calc(${horizontalGap}% - 32px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* header */}
|
||||
{showHeader && (
|
||||
<div
|
||||
className={'borderframe pointer-events-none absolute'}
|
||||
style={{
|
||||
borderTop: `1px solid ${borderColor}`,
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
borderRight: `1px solid ${borderColor}`,
|
||||
width: '32px',
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
top: `${verticalMargin}px`,
|
||||
left: `calc(100% - ${horizontalGap}%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ const FoliateViewer: React.FC<{
|
||||
viewSettings.vertical =
|
||||
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
|
||||
viewSettings.rtl = writingDir?.rtl || viewSettings.writingMode.includes('rl') || false;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
|
||||
mountAdditionalFonts(detail.doc);
|
||||
|
||||
|
||||
@@ -3,14 +3,24 @@ import clsx from 'clsx';
|
||||
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
|
||||
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
|
||||
import { FaHeadphones } from 'react-icons/fa6';
|
||||
import { IoIosList as TOCIcon } from 'react-icons/io';
|
||||
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
|
||||
import { RxSlider as SliderIcon } from 'react-icons/rx';
|
||||
import { RiFontFamily as FontIcon } from 'react-icons/ri';
|
||||
import { MdOutlineHeadphones as TTSIcon } from 'react-icons/md';
|
||||
import { TbBoxMargin } from 'react-icons/tb';
|
||||
import { RxLineHeight } from 'react-icons/rx';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import { PageInfo } from '@/types/book';
|
||||
import Button from '@/components/Button';
|
||||
import Slider from '@/components/Slider';
|
||||
|
||||
interface FooterBarProps {
|
||||
bookKey: string;
|
||||
@@ -28,17 +38,42 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
isHoveredAnim,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
|
||||
useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
|
||||
const [actionTab, setActionTab] = React.useState('');
|
||||
const sliderHeight = useResponsiveSize(28);
|
||||
const tocIconSize = useResponsiveSize(23);
|
||||
const fontIconSize = useResponsiveSize(18);
|
||||
const marginIconSize = useResponsiveSize(20);
|
||||
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
const handleProgressChange = (event: React.ChangeEvent) => {
|
||||
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
|
||||
view?.goToFraction(newProgress / 100.0);
|
||||
const handleProgressChange = (value: number) => {
|
||||
view?.goToFraction(value / 100.0);
|
||||
};
|
||||
|
||||
const handleFontSizeChange = (value: number) => {
|
||||
saveViewSettings(envConfig, bookKey, 'defaultFontSize', value);
|
||||
};
|
||||
|
||||
const handleMarginChange = (value: number) => {
|
||||
const marginPx = Math.round((value / 100) * 88);
|
||||
const gapPercent = Math.round((value / 100) * 10);
|
||||
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx, false, false);
|
||||
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
|
||||
view?.renderer.setAttribute('margin', `${marginPx}px`);
|
||||
view?.renderer.setAttribute('gap', `${gapPercent}%`);
|
||||
if (viewSettings?.scrolled) {
|
||||
view?.renderer.setAttribute('flow', 'scrolled');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLineHeightChange = (value: number) => {
|
||||
saveViewSettings(envConfig, bookKey, 'lineHeight', value / 10);
|
||||
};
|
||||
|
||||
const handleGoPrev = () => {
|
||||
@@ -67,68 +102,217 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetActionTab = (tab: string) => {
|
||||
console.log('handleSetActionTab', tab);
|
||||
setActionTab(actionTab === tab ? '' : tab);
|
||||
if (tab === 'tts') {
|
||||
setHoveredBookKey('');
|
||||
handleSpeakText();
|
||||
} else if (tab === 'toc') {
|
||||
setHoveredBookKey('');
|
||||
if (viewSettings) {
|
||||
viewSettings.sideBarTab = 'toc';
|
||||
}
|
||||
setSideBarVisible(true);
|
||||
} else if (tab === 'note') {
|
||||
setHoveredBookKey('');
|
||||
setSideBarVisible(true);
|
||||
if (viewSettings) {
|
||||
viewSettings.sideBarTab = 'annotations';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getMarginProgressValue = (marginPx: number, gapPercent: number) => {
|
||||
return (marginPx / 88 + gapPercent / 10) * 50;
|
||||
};
|
||||
|
||||
const isVisible = hoveredBookKey === bookKey;
|
||||
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
|
||||
const progressValid = !!progressInfo;
|
||||
const progressFraction = progressValid ? (progressInfo!.current + 1) / progressInfo!.total : 0;
|
||||
const progressFraction = progressValid
|
||||
? ((progressInfo!.next ?? progressInfo!.current) + 1) / progressInfo!.total
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute bottom-0 left-0 z-10 h-12 w-full',
|
||||
viewSettings?.vertical && viewSettings?.scrolled ? 'bottom-3 !h-6' : '',
|
||||
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
|
||||
// show scroll bar when vertical and scrolled in desktop
|
||||
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
|
||||
)}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center gap-x-4 px-4',
|
||||
'shadow-xs bg-base-100 transition-opacity duration-300',
|
||||
appService?.hasSafeAreaInset && 'pb-[env(safe-area-inset-bottom)]',
|
||||
'footer-bar shadow-xs absolute bottom-0 z-50 flex w-full flex-col',
|
||||
'sm:h-[52px] sm:justify-center',
|
||||
'sm:bg-base-100 border-base-300/50 border sm:border-none',
|
||||
'transition-[opacity,transform] duration-300',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
viewSettings?.vertical && viewSettings?.scrolled ? 'mb-3 !h-6' : '',
|
||||
isVisible ? `pointer-events-auto opacity-100` : `pointer-events-none opacity-0`,
|
||||
// show scroll bar when vertical and scrolled in desktop
|
||||
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
|
||||
isVisible
|
||||
? `pointer-events-auto translate-y-0 opacity-100`
|
||||
: `pointer-events-none translate-y-full opacity-0 sm:translate-y-0`,
|
||||
)}
|
||||
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
|
||||
onMouseLeave={() => setHoveredBookKey('')}
|
||||
onMouseLeave={() => window.innerWidth >= 640 && setHoveredBookKey('')}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<div className='hidden sm:flex'>
|
||||
{/* Mobile footer bar */}
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-200 absolute bottom-16 flex w-full items-center gap-x-2 px-4 transition-all sm:hidden',
|
||||
actionTab === 'progress'
|
||||
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
|
||||
: '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',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
|
||||
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
|
||||
onClick={handleGoBack}
|
||||
tooltip={_('Go Back')}
|
||||
disabled={!view?.history.canGoBack}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
|
||||
onClick={handleGoForward}
|
||||
tooltip={_('Go Forward')}
|
||||
disabled={!view?.history.canGoForward}
|
||||
/>
|
||||
<Slider
|
||||
heightPx={sliderHeight}
|
||||
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
|
||||
initialValue={progressValid ? progressFraction * 100 : 0}
|
||||
onChange={(e) => handleProgressChange(e)}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
|
||||
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
|
||||
onClick={handleGoBack}
|
||||
tooltip={_('Go Back')}
|
||||
disabled={!view?.history.canGoBack}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
|
||||
onClick={handleGoForward}
|
||||
tooltip={_('Go Forward')}
|
||||
disabled={!view?.history.canGoForward}
|
||||
/>
|
||||
<span className='mx-2 text-center text-sm'>
|
||||
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
|
||||
</span>
|
||||
<input
|
||||
type='range'
|
||||
className='text-base-content mx-2 w-full'
|
||||
min={0}
|
||||
max={100}
|
||||
value={progressValid ? progressFraction * 100 : 0}
|
||||
onChange={(e) => handleProgressChange(e)}
|
||||
/>
|
||||
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
|
||||
<div className='hidden sm:flex'>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
actionTab === 'font'
|
||||
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
|
||||
: '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',
|
||||
}}
|
||||
>
|
||||
<Slider
|
||||
initialValue={viewSettings?.defaultFontSize ?? 16}
|
||||
bubbleLabel={`${viewSettings?.defaultFontSize ?? 16}`}
|
||||
minLabel='A'
|
||||
maxLabel='A'
|
||||
minClassName='text-xs'
|
||||
maxClassName='text-base'
|
||||
onChange={handleFontSizeChange}
|
||||
min={8}
|
||||
max={30}
|
||||
/>
|
||||
<div className='flex w-full items-center justify-between gap-x-6'>
|
||||
<Slider
|
||||
initialValue={getMarginProgressValue(
|
||||
viewSettings?.marginPx ?? 44,
|
||||
viewSettings?.gapPercent ?? 5,
|
||||
)}
|
||||
bubbleElement={<TbBoxMargin size={marginIconSize} />}
|
||||
minLabel={_('Small')}
|
||||
maxLabel={_('Large')}
|
||||
step={10}
|
||||
onChange={handleMarginChange}
|
||||
/>
|
||||
<Slider
|
||||
initialValue={(viewSettings?.lineHeight ?? 1.6) * 10}
|
||||
bubbleElement={<RxLineHeight size={marginIconSize} />}
|
||||
minLabel={_('Small')}
|
||||
maxLabel={_('Large')}
|
||||
min={8}
|
||||
max={24}
|
||||
onChange={handleLineHeightChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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)]',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
icon={<TOCIcon size={tocIconSize} className='' />}
|
||||
onClick={() => handleSetActionTab('toc')}
|
||||
/>
|
||||
<Button icon={<NoteIcon className='' />} onClick={() => handleSetActionTab('note')} />
|
||||
<Button
|
||||
icon={<SliderIcon className={clsx(actionTab === 'progress' && 'text-blue-500')} />}
|
||||
onClick={() => handleSetActionTab('progress')}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
<FontIcon
|
||||
size={fontIconSize}
|
||||
className={clsx(actionTab === 'font' && 'text-blue-500')}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleSetActionTab('font')}
|
||||
/>
|
||||
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
|
||||
</div>
|
||||
{/* Desktop footer bar */}
|
||||
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
|
||||
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
|
||||
onClick={handleGoBack}
|
||||
tooltip={_('Go Back')}
|
||||
disabled={!view?.history.canGoBack}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
|
||||
onClick={handleGoForward}
|
||||
tooltip={_('Go Forward')}
|
||||
disabled={!view?.history.canGoForward}
|
||||
/>
|
||||
<span className='mx-2 text-center text-sm'>
|
||||
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
|
||||
</span>
|
||||
<input
|
||||
type='range'
|
||||
className='text-base-content mx-2 w-full'
|
||||
min={0}
|
||||
max={100}
|
||||
value={progressValid ? progressFraction * 100 : 0}
|
||||
onChange={(e) =>
|
||||
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
|
||||
}
|
||||
/>
|
||||
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { getFootnoteStyles, getStyles } from '@/utils/style';
|
||||
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FoliateView } from '@/types/view';
|
||||
@@ -26,12 +25,21 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position | null>();
|
||||
const [popupPosition, setPopupPosition] = useState<Position | null>();
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { themeCode } = useThemeStore();
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const footnoteHandler = new FootnoteHandler();
|
||||
|
||||
const [gridRect, setGridRect] = useState<DOMRect | null>(null);
|
||||
const [responsiveWidth, setResponsiveWidth] = useState(popupWidth);
|
||||
const [responsiveHeight, setResponsiveHeight] = useState(popupHeight);
|
||||
|
||||
const getResponsivePopupSize = (size: number, isVertical: boolean) => {
|
||||
const maxSize = isVertical ? window.innerWidth / 2 : window.innerHeight / 2;
|
||||
return Math.min(size, maxSize - popupPadding - 12);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeRender = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
@@ -50,8 +58,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const { renderer } = view;
|
||||
renderer.setAttribute('flow', 'scrolled');
|
||||
renderer.setAttribute('margin', '0px');
|
||||
renderer.setAttribute('gap', '5%');
|
||||
renderer.setAttribute('gap', '0%');
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const themeCode = getThemeCode();
|
||||
const popupTheme = { ...themeCode };
|
||||
const popupContainer = document.getElementById('popup-container');
|
||||
if (popupContainer) {
|
||||
@@ -65,8 +74,18 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
|
||||
const handleRender = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
console.log('render footnote', detail);
|
||||
setShowPopup(true);
|
||||
// console.log('render footnote', detail);
|
||||
const { view } = detail;
|
||||
view.addEventListener('relocate', () => {
|
||||
const { renderer } = view as FoliateView;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (viewSettings.vertical) {
|
||||
setResponsiveWidth(getResponsivePopupSize(renderer.viewSize, true));
|
||||
} else {
|
||||
setResponsiveHeight(getResponsivePopupSize(renderer.viewSize, false));
|
||||
}
|
||||
setShowPopup(true);
|
||||
});
|
||||
};
|
||||
|
||||
footnoteHandler.addEventListener('before-render', handleBeforeRender);
|
||||
@@ -76,25 +95,41 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
footnoteHandler.removeEventListener('render', handleRender);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, themeCode]);
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewSettings.vertical) {
|
||||
setResponsiveWidth(popupHeight);
|
||||
setResponsiveHeight(Math.max(popupWidth, window.innerHeight / 4));
|
||||
} else {
|
||||
setResponsiveWidth(Math.max(popupWidth, window.innerWidth / 4));
|
||||
setResponsiveHeight(popupHeight);
|
||||
}
|
||||
}, [viewSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trianglePosition && gridRect) {
|
||||
const popupPos = getPopupPosition(
|
||||
trianglePosition,
|
||||
gridRect,
|
||||
responsiveWidth,
|
||||
responsiveHeight,
|
||||
popupPadding,
|
||||
);
|
||||
setPopupPosition(popupPos);
|
||||
}
|
||||
}, [trianglePosition, gridRect, responsiveWidth, responsiveHeight]);
|
||||
|
||||
const docLinkHandler = async (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc link click', detail);
|
||||
// console.log('doc link click', detail);
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(detail.a, rect, popupPadding, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
viewSettings.vertical ? popupHeight : popupWidth,
|
||||
viewSettings.vertical ? popupWidth : popupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
setGridRect(rect);
|
||||
setTrianglePosition(triangPos);
|
||||
setPopupPosition(popupPos);
|
||||
|
||||
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
|
||||
console.warn(err);
|
||||
@@ -111,11 +146,13 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
closePopup();
|
||||
setGridRect(null);
|
||||
setPopupPosition(null);
|
||||
setTrianglePosition(null);
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
// Handle custom footnote popup event from iframe event
|
||||
const handleFootnotePopupEvent = (event: CustomEvent) => {
|
||||
const { element, footnote } = event.detail;
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
@@ -123,21 +160,30 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(element, rect, popupPadding, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
viewSettings.vertical ? popupHeight : popupWidth,
|
||||
viewSettings.vertical ? popupWidth : popupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
if (footnoteRef.current) {
|
||||
const elem = document.createElement('p');
|
||||
elem.textContent = footnote;
|
||||
elem.setAttribute('style', `padding: 16px; hanging-punctuation: allow-end last;`);
|
||||
elem.setAttribute('style', `padding: 1em; hanging-punctuation: allow-end last;`);
|
||||
elem.style.visibility = 'hidden';
|
||||
if (viewSettings.vertical) {
|
||||
elem.style.height = `${responsiveHeight}px`;
|
||||
} else {
|
||||
elem.style.width = `${responsiveWidth}px`;
|
||||
}
|
||||
document.body.appendChild(elem);
|
||||
const popupSize = elem.getBoundingClientRect();
|
||||
if (viewSettings.vertical) {
|
||||
setResponsiveWidth(getResponsivePopupSize(popupSize.width, true));
|
||||
} else {
|
||||
setResponsiveHeight(getResponsivePopupSize(popupSize.height, false));
|
||||
}
|
||||
document.body.removeChild(elem);
|
||||
|
||||
elem.style.visibility = 'visible';
|
||||
footnoteRef.current.replaceChildren(elem);
|
||||
setShowPopup(true);
|
||||
setGridRect(rect);
|
||||
setTrianglePosition(triangPos);
|
||||
setPopupPosition(popupPos);
|
||||
setShowPopup(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,8 +192,10 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleDismissPopup);
|
||||
eventDispatcher.on('footnote-popup', handleFootnotePopupEvent);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleDismissPopup);
|
||||
eventDispatcher.off('footnote-popup', handleFootnotePopupEvent);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -159,9 +207,6 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
}
|
||||
}, [footnoteRef]);
|
||||
|
||||
const width = viewSettings.vertical ? popupHeight : popupWidth;
|
||||
const height = viewSettings.vertical ? popupWidth : popupHeight;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showPopup && (
|
||||
@@ -172,8 +217,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
/>
|
||||
)}
|
||||
<Popup
|
||||
width={width}
|
||||
height={height}
|
||||
width={responsiveWidth}
|
||||
height={responsiveHeight}
|
||||
position={showPopup ? popupPosition! : undefined}
|
||||
trianglePosition={showPopup ? trianglePosition! : undefined}
|
||||
className='select-text overflow-y-auto'
|
||||
@@ -182,8 +227,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
className=''
|
||||
ref={footnoteRef}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
width: `${responsiveWidth}px`,
|
||||
height: `${responsiveHeight}px`,
|
||||
}}
|
||||
></div>
|
||||
</Popup>
|
||||
|
||||
@@ -86,8 +86,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
onMouseEnter={() => setHoveredBookKey(bookKey)}
|
||||
onMouseLeave={() => setHoveredBookKey('')}
|
||||
>
|
||||
<div className='sidebar-bookmark-toggler bg-base-100 z-20 flex h-full items-center space-x-4'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
<div className='sidebar-bookmark-toggler bg-base-100 z-20 flex h-full items-center gap-x-4'>
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
interface SectionInfoProps {
|
||||
bookKey: string;
|
||||
showDoubleBorder: boolean;
|
||||
isVertical: boolean;
|
||||
horizontalGap: number;
|
||||
verticalMargin: number;
|
||||
@@ -13,12 +12,11 @@ interface SectionInfoProps {
|
||||
|
||||
const HintInfo: React.FC<SectionInfoProps> = ({
|
||||
bookKey,
|
||||
showDoubleBorder,
|
||||
isVertical,
|
||||
horizontalGap,
|
||||
verticalMargin,
|
||||
}) => {
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
|
||||
const hintTimeout = useRef(2000);
|
||||
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -49,26 +47,21 @@ const HintInfo: React.FC<SectionInfoProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'sectioninfo absolute flex justify-end overflow-hidden',
|
||||
!isVertical && (isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]'),
|
||||
isVertical ? 'writing-vertical-rl w-[32px] items-center' : 'top-0 items-end',
|
||||
isVertical ? 'max-h-[50%]' : 'max-w-[50%]',
|
||||
'hintinfo absolute flex items-center justify-end overflow-hidden',
|
||||
hintMessage ? 'bg-base-100' : 'bg-transparent',
|
||||
isVertical ? 'writing-vertical-rl max-h-[50%]' : 'top-0 h-[44px] max-w-[50%]',
|
||||
)}
|
||||
style={
|
||||
isVertical
|
||||
? {
|
||||
bottom: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
|
||||
left: `calc(100% - ${horizontalGap * 2}%)`,
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
bottom: `${verticalMargin * 1.5}px`,
|
||||
left: `calc(100% - ${horizontalGap}%)`,
|
||||
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
|
||||
}
|
||||
: { right: `${horizontalGap}%` }
|
||||
: { insetInlineEnd: `${horizontalGap}%` }
|
||||
}
|
||||
>
|
||||
<h2
|
||||
className={clsx(
|
||||
'text-neutral-content line-clamp-1 text-center font-sans text-xs font-light',
|
||||
)}
|
||||
>
|
||||
<h2 className={clsx('text-neutral-content text-center font-sans text-xs font-light')}>
|
||||
{hintMessage || ''}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { PageInfo } from '@/types/book';
|
||||
|
||||
@@ -7,6 +8,8 @@ interface PageInfoProps {
|
||||
bookFormat: string;
|
||||
section?: PageInfo;
|
||||
pageinfo?: PageInfo;
|
||||
showDoubleBorder: boolean;
|
||||
isScrolled: boolean;
|
||||
isVertical: boolean;
|
||||
horizontalGap: number;
|
||||
verticalMargin: number;
|
||||
@@ -16,43 +19,46 @@ const PageInfoView: React.FC<PageInfoProps> = ({
|
||||
bookFormat,
|
||||
section,
|
||||
pageinfo,
|
||||
showDoubleBorder,
|
||||
isScrolled,
|
||||
isVertical,
|
||||
horizontalGap,
|
||||
verticalMargin,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const pageInfo =
|
||||
bookFormat === 'PDF'
|
||||
? section
|
||||
? isVertical
|
||||
? `${section.current + 1} · ${section.total}`
|
||||
: `${section.current + 1} / ${section.total}`
|
||||
: ''
|
||||
: pageinfo
|
||||
? _(
|
||||
isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}',
|
||||
{
|
||||
currentPage: pageinfo.current + 1,
|
||||
totalPage: pageinfo.total,
|
||||
},
|
||||
)
|
||||
: '';
|
||||
const { appService } = useEnv();
|
||||
const pageInfo = ['PDF', 'CBZ'].includes(bookFormat)
|
||||
? section
|
||||
? isVertical
|
||||
? `${section.current + 1} · ${section.total}`
|
||||
: `${section.current + 1} / ${section.total}`
|
||||
: ''
|
||||
: pageinfo
|
||||
? _(isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}', {
|
||||
currentPage: (pageinfo.next ?? pageinfo.current) + 1,
|
||||
totalPage: pageinfo.total,
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'pageinfo absolute bottom-0 flex items-center justify-end',
|
||||
isVertical ? 'left-0' : 'right-0',
|
||||
isVertical ? 'writing-vertical-rl w-[32px]' : 'h-12',
|
||||
isVertical ? 'writing-vertical-rl' : 'h-12 w-full',
|
||||
isScrolled && !isVertical && 'bg-base-100',
|
||||
)}
|
||||
style={
|
||||
isVertical
|
||||
? {
|
||||
bottom: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
|
||||
left: `calc(${horizontalGap * 2}% - 32px)`,
|
||||
bottom: `${verticalMargin * 1.5}px`,
|
||||
left: showDoubleBorder ? `calc(${horizontalGap}% - 32px)` : 0,
|
||||
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
}
|
||||
: { paddingRight: `${horizontalGap}%` }
|
||||
: {
|
||||
insetInlineEnd: `${horizontalGap}%`,
|
||||
paddingBottom: appService?.hasSafeAreaInset ? 'env(safe-area-inset-bottom)' : 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
|
||||
interface SectionInfoProps {
|
||||
section?: string;
|
||||
showDoubleBorder: boolean;
|
||||
isScrolled: boolean;
|
||||
isVertical: boolean;
|
||||
horizontalGap: number;
|
||||
verticalMargin: number;
|
||||
@@ -13,33 +12,34 @@ interface SectionInfoProps {
|
||||
|
||||
const SectionInfo: React.FC<SectionInfoProps> = ({
|
||||
section,
|
||||
showDoubleBorder,
|
||||
isScrolled,
|
||||
isVertical,
|
||||
horizontalGap,
|
||||
verticalMargin,
|
||||
}) => {
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'sectioninfo absolute flex overflow-hidden',
|
||||
!isVertical && (isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]'),
|
||||
isVertical ? 'writing-vertical-rl w-[32px] items-center' : 'top-0 items-end',
|
||||
isVertical ? 'max-h-[50%]' : 'max-w-[50%]',
|
||||
'sectioninfo absolute flex items-center overflow-hidden',
|
||||
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
|
||||
isScrolled && !isVertical && 'bg-base-100',
|
||||
)}
|
||||
style={
|
||||
isVertical
|
||||
? {
|
||||
top: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
|
||||
left: `calc(100% - ${horizontalGap * 2}%)`,
|
||||
top: `${verticalMargin * 1.5}px`,
|
||||
left: `calc(100% - ${horizontalGap}%)`,
|
||||
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
}
|
||||
: { left: `${horizontalGap}%` }
|
||||
: { insetInlineStart: `${horizontalGap}%`, width: `calc(100% - ${horizontalGap * 2}%)` }
|
||||
}
|
||||
>
|
||||
<h2
|
||||
className={clsx(
|
||||
'text-neutral-content line-clamp-1 text-center font-sans text-xs font-light',
|
||||
'text-neutral-content text-center font-sans text-xs font-light',
|
||||
isVertical ? '' : 'line-clamp-1',
|
||||
)}
|
||||
>
|
||||
{section || ''}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ThemeMode } from '@/styles/themes';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface ViewMenuProps {
|
||||
@@ -28,11 +29,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
onSetSettingsDialogOpen,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getView, getViews, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const { themeMode, themeCode, setThemeMode } = useThemeStore();
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
|
||||
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
|
||||
|
||||
@@ -58,13 +59,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getViews().forEach((view) => {
|
||||
view.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrolledMode === viewSettings!.scrolled) return;
|
||||
viewSettings!.scrolled = isScrolledMode;
|
||||
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
|
||||
getView(bookKey)?.renderer.setAttribute(
|
||||
@@ -77,11 +72,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
}, [isScrolledMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = getView(bookKey);
|
||||
if (!view) return;
|
||||
viewSettings!.zoomLevel = zoomLevel;
|
||||
setViewSettings(bookKey, viewSettings!);
|
||||
view.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
saveViewSettings(envConfig, bookKey, 'zoomLevel', zoomLevel, true, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [zoomLevel]);
|
||||
|
||||
@@ -132,9 +123,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
|
||||
<hr className='border-base-300 my-1' />
|
||||
|
||||
{appService?.hasRoundedWindow && (
|
||||
<MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />
|
||||
)}
|
||||
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
||||
<MenuItem
|
||||
label={
|
||||
themeMode === 'dark'
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FaHeadphones } from 'react-icons/fa6';
|
||||
import * as CFI from 'foliate-js/epubcfi.js';
|
||||
import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { BookNote, BooknoteGroup, HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { getOSPlatform, uniqueId } from '@/utils/misc';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -24,6 +24,7 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
@@ -76,9 +77,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const dictPopupHeight = Math.min(300, maxHeight);
|
||||
const transPopupWidth = Math.min(480, maxWidth);
|
||||
const transPopupHeight = Math.min(360, maxHeight);
|
||||
const annotPopupWidth = useResponsiveSize(300);
|
||||
const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth);
|
||||
const annotPopupHeight = useResponsiveSize(44);
|
||||
const androidSelectionHandlerHeight = 8;
|
||||
const androidSelectionHandlerHeight = 0;
|
||||
|
||||
const onLoad = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
@@ -240,8 +241,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
};
|
||||
|
||||
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
|
||||
eventDispatcher.on('export-annotations', handleExportMarkdown);
|
||||
return () => {
|
||||
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
|
||||
eventDispatcher.off('export-annotations', handleExportMarkdown);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -348,7 +351,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
saveConfig(envConfig, bookKey, updatedConfig, settings);
|
||||
}
|
||||
handleDismissPopupAndSelection();
|
||||
setNotebookVisible(true);
|
||||
if (!appService?.isMobile) {
|
||||
setNotebookVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHighlight = (update = false) => {
|
||||
@@ -438,6 +443,91 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
|
||||
};
|
||||
|
||||
const handleExportMarkdown = (event: CustomEvent) => {
|
||||
const { bookKey: exportBookKey } = event.detail;
|
||||
if (bookKey !== exportBookKey) return;
|
||||
|
||||
const { bookDoc, book } = bookData;
|
||||
if (!bookDoc || !book || !bookDoc.toc) return;
|
||||
|
||||
const config = getConfig(bookKey)!;
|
||||
const { booknotes: allNotes = [] } = config;
|
||||
const booknotes = allNotes.filter((note) => !note.deletedAt);
|
||||
if (booknotes.length === 0) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('No annotations to export'),
|
||||
className: 'whitespace-nowrap',
|
||||
timeout: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
|
||||
for (const booknote of booknotes) {
|
||||
const tocItem = findTocItemBS(bookDoc.toc ?? [], booknote.cfi);
|
||||
const href = tocItem?.href || '';
|
||||
const label = tocItem?.label || '';
|
||||
const id = tocItem?.id || 0;
|
||||
if (!booknoteGroups[href]) {
|
||||
booknoteGroups[href] = { id, href, label, booknotes: [] };
|
||||
}
|
||||
booknoteGroups[href].booknotes.push(booknote);
|
||||
}
|
||||
|
||||
Object.values(booknoteGroups).forEach((group) => {
|
||||
group.booknotes.sort((a, b) => {
|
||||
return CFI.compare(a.cfi, b.cfi);
|
||||
});
|
||||
});
|
||||
|
||||
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => {
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${book.title}`);
|
||||
lines.push(`**${_('Author')}**: ${book.author || ''}`);
|
||||
lines.push('');
|
||||
lines.push(`**${_('Exported from Readest')}**: ${new Date().toISOString().slice(0, 10)}`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push(`## ${_('Highlights & Annotations')}`);
|
||||
lines.push('');
|
||||
|
||||
for (const group of sortedGroups) {
|
||||
const chapterTitle = group.label || _('Untitled');
|
||||
lines.push(`### ${chapterTitle}`);
|
||||
for (const note of group.booknotes) {
|
||||
lines.push(`> "${note.text}"`);
|
||||
if (note.note) {
|
||||
lines.push(`**${_('Note')}**:: ${note.note}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const markdownContent = lines.join('\n');
|
||||
|
||||
navigator.clipboard?.writeText(markdownContent);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('Copied to clipboard'),
|
||||
className: 'whitespace-nowrap',
|
||||
timeout: 2000,
|
||||
});
|
||||
if (appService?.isMobile) return;
|
||||
const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${book.title.replace(/\s+/g, '_')}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const selectionAnnotated = selection?.annotated;
|
||||
const buttons = [
|
||||
{ tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy },
|
||||
|
||||
@@ -12,12 +12,18 @@ const LANGUAGES = {
|
||||
DE: 'German',
|
||||
FR: 'French',
|
||||
ES: 'Spanish',
|
||||
ID: 'Indonesian',
|
||||
IT: 'Italian',
|
||||
EL: 'Greek',
|
||||
PT: 'Portuguese',
|
||||
NL: 'Dutch',
|
||||
PL: 'Polish',
|
||||
UK: 'Ukrainian',
|
||||
RU: 'Russian',
|
||||
AR: 'Arabic',
|
||||
TR: 'Turkish',
|
||||
HI: 'Hindi',
|
||||
ID: 'Indonesian',
|
||||
VI: 'Vietnamese',
|
||||
KO: 'Korean',
|
||||
JA: 'Japanese',
|
||||
'ZH-HANS': 'Chinese (Simplified)',
|
||||
@@ -165,13 +171,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
) : (
|
||||
<div>
|
||||
<p className='text-base'>{translation || 'No translation available.'}</p>
|
||||
<div className='pt-4 text-sm opacity-60'>
|
||||
Translated by{' '}
|
||||
<a href='https://www.deepl.com/' target='_blank' rel='noopener noreferrer'>
|
||||
DeepL
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<div className='pt-4 text-sm opacity-60'>Translated by DeepL.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -114,11 +114,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
From <a id='link'>Wikipedia</a>, released under the{' '}
|
||||
<a href='https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License'>
|
||||
CC BY-SA License
|
||||
</a>
|
||||
.
|
||||
Source: Wikipedia (CC BY-SA)
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -169,8 +169,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
<main className='flex-grow overflow-y-auto p-4 font-sans' />
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
From <a id='link'>Wiktionary</a>, released under the{' '}
|
||||
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
|
||||
Source: Wiktionary (CC BY-SA)
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -12,43 +12,25 @@ import {
|
||||
Theme,
|
||||
themes,
|
||||
} from '@/styles/themes';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import ThemeEditor from './ThemeEditor';
|
||||
|
||||
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const ColorPanel: React.FC<{ bookKey: string }> = ({}) => {
|
||||
const _ = useTranslation();
|
||||
const {
|
||||
themeMode,
|
||||
themeColor,
|
||||
themeCode,
|
||||
isDarkMode,
|
||||
setThemeMode,
|
||||
setThemeColor,
|
||||
saveCustomTheme,
|
||||
} = useThemeStore();
|
||||
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
|
||||
useThemeStore();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { getViews, getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize24 = useResponsiveSize(24);
|
||||
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
|
||||
const [customThems, setCustomThemes] = useState<Theme[]>([]);
|
||||
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getViews().forEach((view) => {
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const customThemes = settings.globalReadSettings.customThemes ?? [];
|
||||
setCustomThemes(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { FiChevronDown } from 'react-icons/fi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface DropDownProps {
|
||||
selected: { option: string; label: string };
|
||||
options: { option: string; label: string }[];
|
||||
onSelect: (option: string) => void;
|
||||
}
|
||||
|
||||
const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const defaultIconSize = useDefaultIconSize();
|
||||
|
||||
return (
|
||||
<div className='dropdown dropdown-bottom'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<span>{selected.label}</span>
|
||||
<FiChevronDown size={iconSize16} />
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] shadow',
|
||||
'menu-vertical right-[-32px] mt-2 inline max-h-80 w-44 overflow-y-scroll sm:right-0',
|
||||
)}
|
||||
>
|
||||
{options.map(({ option, label }) => (
|
||||
<li key={option} onClick={() => onSelect(option)}>
|
||||
<div className='flex items-center px-0'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
{selected.option === option && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span>{label || option}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropDown;
|
||||
@@ -1,8 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import NumberInput from './NumberInput';
|
||||
import FontDropdown from './FontDropDown';
|
||||
import {
|
||||
ANDROID_FONTS,
|
||||
IOS_FONTS,
|
||||
@@ -14,13 +12,14 @@ import {
|
||||
WINDOWS_FONTS,
|
||||
} from '@/services/constants';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getSysFontsList } from '@/utils/font';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import NumberInput from './NumberInput';
|
||||
import FontDropdown from './FontDropDown';
|
||||
|
||||
interface FontFaceProps {
|
||||
className?: string;
|
||||
@@ -44,26 +43,27 @@ const FontFace = ({
|
||||
moreOptions,
|
||||
selected,
|
||||
onSelect,
|
||||
}: FontFaceProps) => (
|
||||
<div className={clsx('config-item', className)}>
|
||||
<span className=''>{label}</span>
|
||||
<FontDropdown
|
||||
family={family}
|
||||
options={options.map((option) => ({ option, label: option }))}
|
||||
moreOptions={moreOptions?.map((option) => ({ option, label: option })) ?? []}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onGetFontFamily={handleFontFaceFont}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}: FontFaceProps) => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<div className={clsx('config-item', className)}>
|
||||
<span className=''>{label}</span>
|
||||
<FontDropdown
|
||||
family={family}
|
||||
options={options.map((option) => ({ option, label: _(option) }))}
|
||||
moreOptions={moreOptions?.map((option) => ({ option, label: option })) ?? []}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onGetFontFamily={handleFontFaceFont}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const fontFamilyOptions = [
|
||||
@@ -118,90 +118,42 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.defaultFont = defaultFont;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.defaultFont = defaultFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'defaultFont', defaultFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultFont]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.defaultFontSize = defaultFontSize;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.defaultFontSize = defaultFontSize;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'defaultFontSize', defaultFontSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.minimumFontSize = minFontSize;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.minimumFontSize = minFontSize;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'minimumFontSize', minFontSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [minFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.fontWeight = fontWeight;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.fontWeight = fontWeight;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'fontWeight', fontWeight);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fontWeight]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.serifFont = serifFont;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.serifFont = serifFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'serifFont', serifFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serifFont]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.sansSerifFont = sansSerifFont;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.sansSerifFont = sansSerifFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'sansSerifFont', sansSerifFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sansSerifFont]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.monospaceFont = monospaceFont;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.monospaceFont = monospaceFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'monospaceFont', monospaceFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [monospaceFont]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.overrideFont = overrideFont;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.overrideFont = overrideFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'overrideFont', overrideFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideFont]);
|
||||
|
||||
@@ -225,7 +177,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Default Font Size')}
|
||||
value={defaultFontSize}
|
||||
onChange={setDefaultFontSize}
|
||||
@@ -233,7 +184,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
max={120}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-bottom'
|
||||
label={_('Minimum Font Size')}
|
||||
value={minFontSize}
|
||||
onChange={setMinFontSize}
|
||||
@@ -249,7 +199,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Font Weight')}
|
||||
value={fontWeight}
|
||||
onChange={setFontWeight}
|
||||
@@ -265,7 +214,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<h2 className='mb-2 font-medium'>{_('Font Family')}</h2>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item config-item-top'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Default Font')}</span>
|
||||
<FontDropdown
|
||||
options={fontFamilyOptions}
|
||||
@@ -275,7 +224,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item config-item-bottom'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Override Book Font')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MdOutlineAutoMode } from 'react-icons/md';
|
||||
import { MdOutlineTextRotationNone, MdTextRotateVertical } from 'react-icons/md';
|
||||
import { TbTextDirectionRtl } from 'react-icons/tb';
|
||||
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -12,11 +12,12 @@ import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
|
||||
import { MIGHT_BE_RTL_LANGS } from '@/services/constants';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { envConfig } = useEnv();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const view = getView(bookKey);
|
||||
@@ -40,102 +41,54 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
|
||||
const [doubleBorder, setDoubleBorder] = useState(viewSettings.doubleBorder!);
|
||||
const [borderColor, setBorderColor] = useState(viewSettings.borderColor!);
|
||||
const [showHeader, setShowHeader] = useState(viewSettings.showHeader!);
|
||||
const [showFooter, setShowFooter] = useState(viewSettings.showFooter!);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.paragraphMargin = paragraphMargin;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.paragraphMargin = paragraphMargin;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paragraphMargin]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.lineHeight = lineHeight;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.lineHeight = lineHeight;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'lineHeight', lineHeight);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lineHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.wordSpacing = wordSpacing;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.wordSpacing = wordSpacing;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'wordSpacing', wordSpacing);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wordSpacing]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.letterSpacing = letterSpacing;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.letterSpacing = letterSpacing;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'letterSpacing', letterSpacing);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [letterSpacing]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.textIndent = textIndent;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.textIndent = textIndent;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'textIndent', textIndent);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [textIndent]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.fullJustification = fullJustification;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.fullJustification = fullJustification;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'fullJustification', fullJustification);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fullJustification]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.hyphenation = hyphenation;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.hyphenation = hyphenation;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'hyphenation', hyphenation);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hyphenation]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.marginPx = marginPx;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.marginPx = marginPx;
|
||||
setSettings(settings);
|
||||
}
|
||||
if (marginPx === viewSettings.marginPx) return;
|
||||
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx, false, false);
|
||||
view?.renderer.setAttribute('margin', `${marginPx}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [marginPx]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.gapPercent = gapPercent;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.gapPercent = gapPercent;
|
||||
setSettings(settings);
|
||||
}
|
||||
if (gapPercent === viewSettings.gapPercent) return;
|
||||
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
|
||||
view?.renderer.setAttribute('gap', `${gapPercent}%`);
|
||||
if (viewSettings.scrolled) {
|
||||
view?.renderer.setAttribute('flow', 'scrolled');
|
||||
@@ -144,54 +97,43 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}, [gapPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.maxColumnCount = maxColumnCount;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.maxColumnCount = maxColumnCount;
|
||||
setSettings(settings);
|
||||
}
|
||||
if (maxColumnCount === viewSettings.maxColumnCount) return;
|
||||
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount, false, false);
|
||||
view?.renderer.setAttribute('max-column-count', maxColumnCount);
|
||||
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxColumnCount]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.maxInlineSize = maxInlineSize;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.maxInlineSize = maxInlineSize;
|
||||
setSettings(settings);
|
||||
}
|
||||
if (maxInlineSize === viewSettings.maxInlineSize) return;
|
||||
saveViewSettings(envConfig, bookKey, 'maxInlineSize', maxInlineSize, false, false);
|
||||
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxInlineSize]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.maxBlockSize = maxBlockSize;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.maxBlockSize = maxBlockSize;
|
||||
setSettings(settings);
|
||||
}
|
||||
if (maxBlockSize === viewSettings.maxBlockSize) return;
|
||||
saveViewSettings(envConfig, bookKey, 'maxBlockSize', maxBlockSize, false, false);
|
||||
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxBlockSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (writingMode === viewSettings.writingMode) return;
|
||||
// global settings are not supported for writing mode
|
||||
const prevWritingMode = viewSettings.writingMode;
|
||||
viewSettings.writingMode = writingMode;
|
||||
if (writingMode.includes('vertical')) {
|
||||
viewSettings.vertical = true;
|
||||
if (!writingMode.includes('vertical')) {
|
||||
viewSettings.vertical = false;
|
||||
}
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
saveViewSettings(envConfig, bookKey, 'writingMode', writingMode, true);
|
||||
if (view) {
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
view.book.dir = getBookDirFromWritingMode(writingMode);
|
||||
}
|
||||
if (
|
||||
prevWritingMode !== writingMode &&
|
||||
(writingMode === 'horizontal-rl' || prevWritingMode === 'horizontal-rl')
|
||||
(['horizontal-rl', 'vertical-rl'].includes(writingMode) ||
|
||||
['horizontal-rl', 'vertical-rl'].includes(prevWritingMode))
|
||||
) {
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
}
|
||||
@@ -199,52 +141,77 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}, [writingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.overrideLayout = overrideLayout;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.overrideLayout = overrideLayout;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
saveViewSettings(envConfig, bookKey, 'overrideLayout', overrideLayout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings!.scrolled = isScrolledMode;
|
||||
if (isScrolledMode === viewSettings.scrolled) return;
|
||||
saveViewSettings(envConfig, bookKey, 'scrolled', isScrolledMode);
|
||||
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
|
||||
getView(bookKey)?.renderer.setAttribute(
|
||||
'max-inline-size',
|
||||
`${getMaxInlineSize(viewSettings)}px`,
|
||||
);
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
setViewSettings(bookKey, viewSettings!);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.scrolled = isScrolledMode;
|
||||
setSettings(settings);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isScrolledMode]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.doubleBorder = doubleBorder;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.doubleBorder = doubleBorder;
|
||||
setSettings(settings);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.borderColor = borderColor;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.borderColor = borderColor;
|
||||
setSettings(settings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'borderColor', borderColor, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [borderColor]);
|
||||
|
||||
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);
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader, false, false);
|
||||
// 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);
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter, false, false);
|
||||
// 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();
|
||||
|
||||
@@ -308,36 +275,37 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
)}
|
||||
|
||||
{viewSettings.vertical && (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Double Border')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={doubleBorder}
|
||||
onChange={() => setDoubleBorder(!doubleBorder)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Border Frame')}</h2>
|
||||
<div className='card bg-base-100 border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Double Border')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={doubleBorder}
|
||||
onChange={() => setDoubleBorder(!doubleBorder)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Border Color')}</h2>
|
||||
<div className='flex gap-4'>
|
||||
<button
|
||||
className={`btn btn-circle btn-sm bg-red-300 hover:bg-red-500 ${borderColor === 'red' ? 'btn-active !bg-red-500' : ''}`}
|
||||
onClick={() => setBorderColor('red')}
|
||||
></button>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Border Color')}</span>
|
||||
<div className='flex gap-4'>
|
||||
<button
|
||||
className={`btn btn-circle btn-sm bg-red-300 hover:bg-red-500 ${borderColor === 'red' ? 'btn-active !bg-red-500' : ''}`}
|
||||
onClick={() => setBorderColor('red')}
|
||||
></button>
|
||||
|
||||
<button
|
||||
className={`btn btn-circle btn-sm bg-black/50 hover:bg-black ${borderColor === 'black' ? 'btn-active !bg-black' : ''}`}
|
||||
onClick={() => setBorderColor('black')}
|
||||
></button>
|
||||
<button
|
||||
className={`btn btn-circle btn-sm bg-black/50 hover:bg-black ${borderColor === 'black' ? 'btn-active !bg-black' : ''}`}
|
||||
onClick={() => setBorderColor('black')}
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='w-full'>
|
||||
@@ -345,7 +313,6 @@ 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
|
||||
className='config-item-top'
|
||||
label={_('Paragraph Margin')}
|
||||
value={paragraphMargin}
|
||||
onChange={setParagraphMargin}
|
||||
@@ -361,21 +328,23 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Word Spacing')}
|
||||
value={wordSpacing}
|
||||
onChange={setWordSpacing}
|
||||
min={-4}
|
||||
max={8}
|
||||
step={0.5}
|
||||
/>
|
||||
{langCode !== 'zh' && (
|
||||
<NumberInput
|
||||
label={_('Word Spacing')}
|
||||
value={wordSpacing}
|
||||
onChange={setWordSpacing}
|
||||
min={-4}
|
||||
max={8}
|
||||
step={0.5}
|
||||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
label={_('Letter Spacing')}
|
||||
value={letterSpacing}
|
||||
onChange={setLetterSpacing}
|
||||
min={-2}
|
||||
max={4}
|
||||
step={0.1}
|
||||
step={0.5}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Text Indent')}
|
||||
@@ -385,7 +354,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
max={4}
|
||||
step={1}
|
||||
/>
|
||||
<div className='config-item config-item-bottom'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Full Justification')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -394,7 +363,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setFullJustification(!fullJustification)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item config-item-bottom'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Hyphenation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -403,7 +372,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setHyphenation(!hyphenation)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item config-item-bottom'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Override Book Layout')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -420,12 +389,29 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<h2 className='mb-2 font-medium'>{_('Page')}</h2>
|
||||
<div className='card bg-base-100 border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Header')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showHeader}
|
||||
onChange={() => setShowHeader(!showHeader)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Footer')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showFooter}
|
||||
onChange={() => setShowFooter(!showFooter)}
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Vertical Margins (px)')}
|
||||
value={marginPx}
|
||||
onChange={setMarginPx}
|
||||
min={0}
|
||||
min={!viewSettings.vertical && (showFooter || showHeader) ? 44 : 0}
|
||||
max={88}
|
||||
step={4}
|
||||
/>
|
||||
@@ -433,7 +419,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
label={_('Horizontal Margins (%)')}
|
||||
value={gapPercent}
|
||||
onChange={setGapPercent}
|
||||
min={viewSettings.vertical ? 2 : 0}
|
||||
min={
|
||||
viewSettings.vertical && (showFooter || showHeader)
|
||||
? Math.ceil(4800 / window.innerWidth)
|
||||
: 0
|
||||
}
|
||||
max={30}
|
||||
/>
|
||||
<NumberInput
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import i18n from 'i18next';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import cssbeautify from 'cssbeautify';
|
||||
import cssValidate from '@/utils/css';
|
||||
import DropDown from './DropDown';
|
||||
|
||||
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
|
||||
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
|
||||
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const cssInput = e.target.value;
|
||||
setDraftStylesheet(cssInput);
|
||||
@@ -66,13 +76,50 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.animated = animated;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.animated = animated;
|
||||
setSettings(settings);
|
||||
const handleInputFocus = () => {
|
||||
if (appService?.isAndroidApp) {
|
||||
setInputFocusInAndroid(true);
|
||||
}
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'center',
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (appService?.isAndroidApp) {
|
||||
setInputFocusInAndroid(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentUILangOption = () => {
|
||||
const uiLanguage = viewSettings.uiLanguage;
|
||||
return {
|
||||
option: uiLanguage,
|
||||
label:
|
||||
uiLanguage === ''
|
||||
? _('Auto')
|
||||
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
|
||||
};
|
||||
};
|
||||
|
||||
const getUILangOptions = () => {
|
||||
const langs = TRANSLATED_LANGS as Record<string, string>;
|
||||
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
|
||||
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
options.unshift({ option: '', label: _('Auto') });
|
||||
return options;
|
||||
};
|
||||
|
||||
const handleSelectUILang = (option: string) => {
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
|
||||
i18n.changeLanguage(option ? option : navigator.language);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
|
||||
if (animated) {
|
||||
getView(bookKey)?.renderer.setAttribute('animated', '');
|
||||
} else {
|
||||
@@ -82,32 +129,45 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.disableClick = isDisableClick;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.disableClick = isDisableClick;
|
||||
setSettings(settings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.continuousScroll = isContinuousScroll;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.continuousScroll = isContinuousScroll;
|
||||
setSettings(settings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [swapClickArea]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isContinuousScroll]);
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div
|
||||
className={clsx('my-4 w-full space-y-6', inputFocusInAndroid && 'h-[50%] overflow-y-auto')}
|
||||
>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Language')}</span>
|
||||
<DropDown
|
||||
selected={getCurrentUILangOption()}
|
||||
options={getUILangOptions()}
|
||||
onSelect={handleSelectUILang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item config-item-top config-item-bottom'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Paging Animation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -124,7 +184,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item config-item-top'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Continuous Scroll')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -133,7 +193,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item config-item-bottom'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Disable Click-to-Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -142,6 +202,16 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setIsDisableClick(!isDisableClick)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Swap Click-to-Flip Area')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={swapClickArea}
|
||||
disabled={isDisableClick}
|
||||
onChange={() => setSwapClickArea(!swapClickArea)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,6 +223,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
>
|
||||
<div className='relative p-1'>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={clsx(
|
||||
'textarea textarea-ghost h-48 w-full border-0 p-3 text-base !outline-none sm:text-sm',
|
||||
'placeholder:text-base-content/70',
|
||||
@@ -160,6 +231,8 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
placeholder={_('Enter your custom CSS here...')}
|
||||
spellCheck='false'
|
||||
value={draftStylesheet}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleInput}
|
||||
onKeyUp={handleInput}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FiMinus, FiPlus } from 'react-icons/fi';
|
||||
|
||||
interface NumberInputProps {
|
||||
@@ -26,6 +26,10 @@ const NumberInput: React.FC<NumberInputProps> = ({
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const numberStep = step || 1;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ type TabConfig = {
|
||||
|
||||
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>('Font');
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>(
|
||||
(localStorage.getItem('lastConfigPanel') || 'Font') as SettingsPanelType,
|
||||
);
|
||||
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const tabConfig = [
|
||||
@@ -54,6 +56,11 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
},
|
||||
] as TabConfig[];
|
||||
|
||||
const handleSetActivePanel = (tab: SettingsPanelType) => {
|
||||
setActivePanel(tab);
|
||||
localStorage.setItem('lastConfigPanel', tab);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFontLayoutSettingsDialogOpen(false);
|
||||
};
|
||||
@@ -65,6 +72,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
boxClassName='sm:min-w-[520px]'
|
||||
snapHeight={window.innerWidth < 640 ? 0.7 : undefined}
|
||||
header={
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<button
|
||||
@@ -76,7 +84,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
>
|
||||
<MdArrowBackIosNew />
|
||||
</button>
|
||||
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center pl-4 gap-2'>
|
||||
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center gap-2 pl-4'>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -84,7 +92,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
'btn btn-ghost text-base-content btn-sm',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => setActivePanel(tab)}
|
||||
onClick={() => handleSetActivePanel(tab)}
|
||||
>
|
||||
<Icon className='mr-0' />
|
||||
{window.innerWidth >= 500 ? label : ''}
|
||||
|
||||
@@ -5,8 +5,10 @@ import Image from 'next/image';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
|
||||
@@ -19,6 +21,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
const _ = useTranslation();
|
||||
const { getVisibleLibrary } = useLibraryStore();
|
||||
const { openParallelView } = useBooksManager();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
|
||||
const handleParallelView = (id: string) => {
|
||||
openParallelView(id);
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -35,6 +39,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
window.open(DOWNLOAD_READEST_URL, '_blank');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handleExportAnnotations = () => {
|
||||
eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
|
||||
@@ -49,6 +57,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
<MenuItem label={_('Parallel Read')} noIcon>
|
||||
<ul className='max-h-60 overflow-y-auto'>
|
||||
{getVisibleLibrary()
|
||||
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
|
||||
.slice(0, 20)
|
||||
.map((book) => (
|
||||
<MenuItem
|
||||
@@ -72,6 +81,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
))}
|
||||
</ul>
|
||||
</MenuItem>
|
||||
<MenuItem label={_('Export Annotations')} noIcon onClick={handleExportAnnotations} />
|
||||
<MenuItem label={_('Reload Page')} noIcon shortcut='Shift+R' onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} noIcon onClick={downloadReadest} />}
|
||||
|
||||
@@ -4,16 +4,9 @@ import * as CFI from 'foliate-js/epubcfi.js';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { BookNote, BookNoteType } from '@/types/book';
|
||||
import { BooknoteGroup, BookNoteType } from '@/types/book';
|
||||
import BooknoteItem from './BooknoteItem';
|
||||
|
||||
interface BooknoteGroup {
|
||||
id: number;
|
||||
href: string;
|
||||
label: string;
|
||||
booknotes: BookNote[];
|
||||
}
|
||||
|
||||
const BooknoteView: React.FC<{
|
||||
type: BookNoteType;
|
||||
bookKey: string;
|
||||
|
||||
@@ -38,7 +38,10 @@ const SidebarHeader: React.FC<{
|
||||
>
|
||||
<MdArrowBackIosNew size={iconSize22} />
|
||||
</button>
|
||||
<button className='btn btn-ghost h-8 min-h-8 w-8 p-0' onClick={onGoToLibrary}>
|
||||
<button
|
||||
className='btn btn-ghost hidden h-8 min-h-8 w-8 p-0 sm:flex'
|
||||
onClick={onGoToLibrary}
|
||||
>
|
||||
<GiBookshelf className='fill-base-content' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ const TOCItemView: React.FC<{
|
||||
aria-selected={isActive ? 'true' : 'false'}
|
||||
data-href={item.href ? getContentMd5(item.href) : undefined}
|
||||
className={`flex w-full cursor-pointer items-center rounded-md py-2 ${
|
||||
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/85'
|
||||
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'sm:hover:bg-base-300/85'
|
||||
}`}
|
||||
>
|
||||
{item.subitems && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user