Compare commits

...

53 Commits

Author SHA1 Message Date
Huang Xin d6962055b9 release: version 0.9.22 (#614) 2025-03-15 19:43:44 +01:00
Huang Xin 05489282df cjk: always show vertical layout options in CJK locale (#613) 2025-03-15 19:37:05 +01:00
Huang Xin 58f1b5833f feat: add border frame for vertical layout, also closes #563 (#612) 2025-03-15 18:55:12 +01:00
Huang Xin c66911b060 fix: hiding scrollbars may cause layout changes, closes #600 (#610) 2025-03-15 06:38:48 +01:00
Huang Xin 54debbfe86 api: load balance of translation requests (#609) 2025-03-15 05:24:34 +01:00
Huang Xin 6d580565fd fix: use deepl jsonrpc for free plan translation (#608) 2025-03-14 18:31:51 +01:00
Huang Xin f0270eec4e fix: handle system theme change and set color scheme, closes #571 (#607) 2025-03-14 16:55:27 +01:00
Huang Xin cd9290240a fix: force focus on dropdown menu on older Safari, closes #574 (#606) 2025-03-14 10:14:24 +01:00
Huang Xin 2fc7bc4744 fix: don't handle iframe wheel events for scrolled mode, closes #604 (#605) 2025-03-14 08:43:50 +01:00
Huang Xin 5370a9e7f6 bump: bump update next.js to 15.2.2 (#601) 2025-03-13 19:03:21 +01:00
Huang Xin 08f348ada5 fix: don't hide horizontal scroll bar at the bottom, closes #570 (#599) 2025-03-13 17:45:41 +01:00
Huang Xin 4452f0b810 fix: prevent the footer bar from popping up by tapping at the bottom on mobile devices, closes #597 (#598) 2025-03-13 17:19:31 +01:00
Huang Xin b7dc880a5e feat: add scrolled mode toggler in font/layout settings dialog, closes #580 (#596) 2025-03-13 15:07:17 +01:00
Huang Xin 35596094c2 fix: clear select mode when importing files, closes #581 (#595) 2025-03-13 14:41:19 +01:00
Huang Xin e8eb409f9f feat: add theme editor for custom theme colors, closes #462 (#594) 2025-03-13 14:23:58 +01:00
Huang Xin 6a74a0c5b8 fix: following ordinary link in footnotes, closes #559 (#560) 2025-03-10 18:09:47 +01:00
Huang Xin dfa0468d24 release: hot fix version 0.9.21 (#551)
* sync: update partial config to sync more actively

* rtl: make the order of go back/forward buttons consistent

* fix: wheel events conflict with touchpad scroll on macOS

* release: hot fix version 0.9.21
2025-03-09 20:22:47 +01:00
Huang Xin 4869b756ea release: version 0.9.20 (#549)
* fix: occasionally traffic lights disappear in library page

* release: version 0.9.20
2025-03-09 17:40:30 +01:00
Huang Xin 693c79c9b7 fix: remove seldomly used option to invert colors in dark mode (#548)
This option is hard to understand and explain. Most hardcoded color
in dark mode that affects readability has already been fixed in the
reader so there is no necessity to have this option.
2025-03-09 17:02:01 +01:00
Huang Xin e846875973 doc: update screenshots in readme (#546) 2025-03-09 14:15:41 +01:00
Huang Xin b1f2941247 fix: various fixes for updater, footerbar and note item (#545)
* fix: don't open book if failed to download book

* fix: various fixes for updater, footerbar and note item
1. now footerbar when invisible won't process pointer events
2. manually check updater won't be prevented by check interval
3. get rid of clamped text in note item
2025-03-09 11:33:52 +01:00
Huang Xin 6688c813e6 fix: don't open book if failed to download book (#541) 2025-03-09 10:29:16 +01:00
Huang Xin 664dc45fcc fix: search options menu in mobile & user id in posthog (#539) 2025-03-09 07:07:42 +01:00
Huang Xin 27fa753486 feat: support drag and drop to import books, closes #524 (#536) 2025-03-08 19:12:00 +01:00
Huang Xin 71e392544c fix: rtl for notebook, closes #451 (#535) 2025-03-08 16:51:24 +01:00
Huang Xin 7f24081205 feat: add fullscreen option for desktop app, closes #493 (#534) 2025-03-08 22:36:25 +08:00
Huang Xin 89f1c43984 fix: don't hide horizontal scrollbar at the bottom, closes #515 (#533) 2025-03-08 21:26:10 +08:00
Huang Xin f841004e7a chore: bump update of tauri (#532) 2025-03-08 10:53:31 +01:00
Huang Xin 0922f557bb fix: make the height of the drag handle in dialog stable (#530) 2025-03-08 10:03:00 +01:00
Huang Xin 0b72ab156d refactor: move useTheme hook into separate store, also closes #528 (#529) 2025-03-08 09:12:36 +01:00
Huang Xin dfc6cc9c8a Update feature-request template 2025-03-08 04:02:02 +01:00
Huang Xin 83b2908266 compat: replace absolute font sizes with rem units in css, closes #521 (#523) 2025-03-07 18:37:46 +01:00
Huang Xin c25f2a7abc fix: make continuous scroll an option and disabled by default, closes #516, #373 (#522) 2025-03-07 17:24:01 +01:00
Huang Xin de1e396f6e fix: switch label for column width/height for vertical layout and fix column size calculation (#520)
* fix: scroll mode should be updated when calculating column size

* fix: switch label for column width/height for vertical layout, closes #517
2025-03-07 16:42:40 +01:00
Huajin 80a3861816 Fixes the scrollbar gutter issue for DaisyUI (#514)
* Fixes the scrollbar gutter issue for DaisyUI
https://github.com/saadeghi/daisyui/issues/3040#issuecomment-2250530354
2025-03-07 15:15:52 +01:00
Huang Xin fd8eeb07c2 fix: set sidebar rtl only for mandatory rtl languages, closes #512 (#519) 2025-03-07 15:06:12 +01:00
Huang Xin 93d1fe6946 release: version 0.9.19 (#508) 2025-03-06 17:30:48 +01:00
Huang Xin 3c52a9e0cb feat: customize foliate view styles with css, closes #167 (#507) 2025-03-06 17:22:01 +01:00
Huang Xin 3056a29860 compat: handle epub files without dc metadata, closes #490 (#506) 2025-03-06 16:44:07 +01:00
Huang Xin bd6738e54c fix: release wakelock when window or tab lost focus, closes #502 (#505) 2025-03-06 16:23:58 +01:00
Huang Xin 8c3f49de25 rtl: support rtl for more widgets in sidebar (#504) 2025-03-06 15:38:29 +01:00
Huang Xin 0f44fff7cd feat: support styled reader UI via custom CSS, closes #240 (#503) 2025-03-06 13:56:43 +01:00
Huang Xin 77bba7bd0c fix: use section info for PDF progress bar, closes #158 (#501) 2025-03-06 11:22:06 +01:00
Huang Xin d30d1c17ae rtl: rtl page layout from settings and book language, closes #359 (#500) 2025-03-06 10:58:32 +01:00
nnecec 088c7d2c49 fix: various tweaks on style and layout (#499) 2025-03-06 10:51:06 +01:00
Huang Xin d0e6f3ad50 fix: ensure the progress bar direction is consistent with the book’s writing direction, closes #451 (#498) 2025-03-06 09:14:33 +01:00
Huang Xin 8a83389164 fix: hide traffic lights when sidebar is invisible on macOS, closes #297 (#497) 2025-03-06 07:14:23 +01:00
Huang Xin 04d07856f5 fix: use client timestamp for updated records, closes #468 (#488) 2025-03-04 14:26:30 +01:00
Huang Xin cc8d0ea457 fix: filter deleted books in parallel read menu, closes #476 (#487) 2025-03-04 07:18:54 +01:00
Huang Xin 29e6af7768 fix: handle hardcoded border color of chapter header for Feedbooks, closes #470 (#485) 2025-03-04 06:02:18 +01:00
Huang Xin bc70d07749 fix: support footnotes with links inside of the lists, closes #472 (#479) 2025-03-03 16:27:03 +01:00
Huang Xin 2db83b8569 ui: responsive dialog size for portrait screens (#473)
* fix: handle network error in sync API

* fix: reasonable default column width for iPad

* ui: responsive dialog size for portrait screens
2025-03-01 17:57:29 +01:00
Huang Xin 2f29295c6c fix: open with new files from file browsers when there is already an instance running, closes #208 (#466) 2025-02-28 08:42:49 +01:00
112 changed files with 3429 additions and 1505 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Feature request
about: Share an idea or suggestion
title: 'FR: [a handful of words describing the FR]'
title: 'FR: describing your feature request'
labels: enhancement
assignees: ''
---
+2 -2
View File
@@ -1,6 +1,6 @@
[submodule "packages/foliate-js"]
path = packages/foliate-js
url = https://github.com/chrox/foliate-js.git
url = https://github.com/readest/foliate-js.git
[submodule "packages/tauri"]
path = packages/tauri
url = https://github.com/chrox/tauri.git
url = https://github.com/tauri-apps/tauri.git
Generated
+675 -651
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -32,7 +32,7 @@
<div align="center">
<a href="https://readest.com" target="_blank">
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
<img src="./data/screenshots/landing_all_platforms.png" alt="Readest Banner" width="100%" />
</a>
</div>
@@ -80,7 +80,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![DeepL](./data/screenshots/deepl.png)
![TTS](./data/screenshots/tts_control.png)
![TTS](./data/screenshots/tts_speak_aloud.png)
![Footnote](./data/screenshots/footnote_popover.png)
@@ -205,7 +205,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
<a href="https://github.com/readest/readest/graphs/contributors">
<p align="left">
<img width="300" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
+1 -3
View File
@@ -13,9 +13,7 @@ const nextConfig = {
images: {
unoptimized: true,
},
devIndicators: {
appIsrStatus: false,
},
devIndicators: false,
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: '',
reactStrictMode: true,
+10 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.18",
"version": "0.9.22",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -42,19 +42,19 @@
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.2.0",
"@tauri-apps/api": "2.3.0",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-haptics": "^2.2.3",
"@tauri-apps/plugin-http": "^2.3.0",
"@tauri-apps/plugin-log": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.5",
"@tauri-apps/plugin-log": "^2.2.3",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "~2.2.0",
"@tauri-apps/plugin-updater": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.5.1",
"@zip.js/zip.js": "^2.7.53",
"clsx": "^2.1.1",
"cors": "^2.8.5",
@@ -65,9 +65,10 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"next": "15.1.6",
"next": "15.2.2",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-color": "^2.19.3",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
@@ -76,16 +77,17 @@
"zustand": "5.0.1"
},
"devDependencies": {
"@tauri-apps/cli": "2.2.7",
"@tauri-apps/cli": "2.3.1",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.23",
"daisyui": "^4.12.24",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
@@ -53,19 +53,15 @@
"Hyphenation": "الواصلة",
"Identifier:": "المعرف:",
"Import Books": "استيراد الكتب",
"Invert Colors in Dark Mode": "عكس الألوان في الوضع المظلم",
"Language:": "اللغة:",
"Layout": "التخطيط",
"Light Mode": "الوضع الفاتح",
"Loading...": "جارٍ التحميل...",
"Loc. {{currentPage}} / {{totalPage}}": "الصفحة {{currentPage}} \\ {{totalPage}}",
"Logged in": "تم تسجيل الدخول",
"Logged in as {{userDisplayName}}": "تم تسجيل الدخول باسم {{userDisplayName}}",
"Match Case": "مطابقة حالة الأحرف",
"Match Diacritics": "مطابقة التشكيل",
"Match Whole Words": "مطابقة الكلمات الكاملة",
"Maximum Block Size": "أقصى مساحة رأسية",
"Maximum Inline Size": "أقصى مساحة أفقية",
"Maximum Number of Columns": "العدد الأقصى للأعمدة",
"Minimum Font Size": "الحد الأدنى لحجم الخط",
"Misc": "أخرى",
@@ -230,5 +226,23 @@
"Delete Permanently": "حذف نهائي",
"Free Tier": "الفئة المجانية",
"Plus Tier": "فئة Plus",
"Pro Tier": "فئة Pro"
"Pro Tier": "فئة Pro",
"RTL Direction": "الاتجاه من اليمين إلى اليسار",
"Maximum Column Height": "الارتفاع الأقصى للعمود",
"Maximum Column Width": "العرض الأقصى للعمود",
"Continuous Scroll": "التمرير المستمر",
"Fullscreen": "ملء الشاشة",
"No supported files found. Supported formats: {{formats}}": "لم يتم العثور على ملفات مدعومة. الصيغ المدعومة: {{formats}}",
"Drop to Import Books": "اسحب لاستيراد الكتب",
"Custom": "مخصص",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "العالم مسرح،\nوالناس فيه ممثلون؛\nلهم مخارج ومداخل،\nوالإنسان في حياته يلعب دورًا كثيرًا،\nوأفعاله تمر بسبع مراحل.\n\n— ويليام شكسبير",
"Custom Theme": "سمة مخصصة",
"Theme Name": "اسم السمة",
"Text Color": "لون النص",
"Background Color": "لون الخلفية",
"Preview": "معاينة",
"Contrast": "التباين",
"Sunset": "غروب الشمس",
"Double Border": "حدود مزدوجة",
"Border Color": "لون الحدود"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Silbentrennung",
"Identifier:": "Kennung:",
"Import Books": "Bücher importieren",
"Invert Colors in Dark Mode": "Farben im Dunkelmodus invertieren",
"Language:": "Sprache:",
"Layout": "Layout",
"Light Mode": "Hellmodus",
"Loading...": "Wird geladen...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Angemeldet",
"Logged in as {{userDisplayName}}": "Angemeldet als {{userDisplayName}}",
"Match Case": "Groß-/Kleinschreibung beachten",
"Match Diacritics": "Akzente beachten",
"Match Whole Words": "Ganze Wörter",
"Maximum Block Size": "Maximale Blockgröße",
"Maximum Inline Size": "Maximale Zeilengröße",
"Maximum Number of Columns": "Maximale Spaltenanzahl",
"Minimum Font Size": "Minimale Schriftgröße",
"Misc": "Sonstiges",
@@ -230,5 +226,23 @@
"Delete Permanently": "Dauerhaft löschen",
"Free Tier": "Kostenlose Stufe",
"Plus Tier": "Plus-Stufe",
"Pro Tier": "Pro-Stufe"
"Pro Tier": "Pro-Stufe",
"RTL Direction": "RTL-Richtung",
"Maximum Column Height": "Maximale Spaltenhöhe",
"Maximum Column Width": "Maximale Spaltenbreite",
"Continuous Scroll": "Kontinuierliches Scrollen",
"Fullscreen": "Vollbild",
"No supported files found. Supported formats: {{formats}}": "Keine unterstützten Dateien gefunden. Unterstützte Formate: {{formats}}",
"Drop to Import Books": "Zum Importieren von Büchern ablegen",
"Custom": "Benutzerdefiniert",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Die ganze Welt ist eine Bühne,\nUnd alle Männer und Frauen sind bloß Spieler;\nSie haben ihre Abgänge und ihre Auftritte,\nUnd ein Mann spielt in seinem Leben viele Rollen,\nSeine Taten sind sieben Zeitalter.\n\n— William Shakespeare",
"Custom Theme": "Benutzerdefiniertes Thema",
"Theme Name": "Themenname",
"Text Color": "Textfarbe",
"Background Color": "Hintergrundfarbe",
"Preview": "Vorschau",
"Contrast": "Kontrast",
"Sunset": "Sonnenuntergang",
"Double Border": "Doppelter Rand",
"Border Color": "Randfarbe"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Συλλαβισμός",
"Identifier:": "Αναγνωριστικό:",
"Import Books": "Εισαγωγή βιβλίων",
"Invert Colors in Dark Mode": "Αντιστροφή χρωμάτων στη σκοτεινή λειτουργία",
"Language:": "Γλώσσα:",
"Layout": "Διάταξη",
"Light Mode": "Φωτεινή λειτουργία",
"Loading...": "Φόρτωση...",
"Loc. {{currentPage}} / {{totalPage}}": "Θέση {{currentPage}} / {{totalPage}}",
"Logged in": "Συνδεδεμένος",
"Logged in as {{userDisplayName}}": "Συνδεδεμένος ως {{userDisplayName}}",
"Match Case": "Ταίριασμα πεζών-κεφαλαίων",
"Match Diacritics": "Ταίριασμα διακριτικών σημείων",
"Match Whole Words": "Ταίριασμα ολόκληρων λέξεων",
"Maximum Block Size": "Μέγιστο μέγεθος μπλοκ",
"Maximum Inline Size": "Μέγιστο μέγεθος γραμμής",
"Maximum Number of Columns": "Μέγιστος αριθμός στηλών",
"Minimum Font Size": "Ελάχιστο μέγεθος γραμματοσειράς",
"Misc": "Διάφορα",
@@ -230,5 +226,23 @@
"Delete Permanently": "Μόνιμη διαγραφή",
"Free Tier": "Δωρεάν βαθμίδα",
"Plus Tier": "Βαθμίδα Plus",
"Pro Tier": "Βαθμίδα Pro"
"Pro Tier": "Βαθμίδα Pro",
"RTL Direction": "Κατεύθυνση RTL",
"Maximum Column Height": "Μέγιστο ύψος στήλης",
"Maximum Column Width": "Μέγιστο πλάτος στήλης",
"Continuous Scroll": "Συνεχής κύλιση",
"Fullscreen": "Πλήρης οθόνη",
"No supported files found. Supported formats: {{formats}}": "Δεν βρέθηκαν υποστηριζόμενα αρχεία. Υποστηριζόμενες μορφές: {{formats}}",
"Drop to Import Books": "Ρίξτε για εισαγωγή βιβλίων",
"Custom": "Προσαρμοσμένο",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Όλος ο κόσμος είναι σκηνή,\nΚαι όλοι οι άνδρες και οι γυναίκες απλώς ηθοποιοί·\nΈχουν τις εξόδους και τις εισόδους τους,\nΚαι ένας άνθρωπος στον χρόνο του παίζει πολλούς ρόλους,\nΟι πράξεις του υπάρχουν σε επτά ηλικίες.\n\n— William Shakespeare",
"Custom Theme": "Προσαρμοσμένο θέμα",
"Theme Name": "Όνομα θέματος",
"Text Color": "Χρώμα κειμένου",
"Background Color": "Χρώμα φόντου",
"Preview": "Προεπισκόπηση",
"Contrast": "Αντίθεση",
"Sunset": "Ηλιοβασίλεμα",
"Double Border": "Διπλό περιθώριο",
"Border Color": "Χρώμα περιγράμματος"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Guionización",
"Identifier:": "Identificador:",
"Import Books": "Importar libros",
"Invert Colors in Dark Mode": "Invertir colores en modo oscuro",
"Language:": "Idioma:",
"Layout": "Diseño",
"Light Mode": "Modo claro",
"Loading...": "Cargando...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Sesión iniciada",
"Logged in as {{userDisplayName}}": "Sesión iniciada como {{userDisplayName}}",
"Match Case": "Coincidir mayúsculas y minúsculas",
"Match Diacritics": "Coincidir diacríticos",
"Match Whole Words": "Coincidir palabras completas",
"Maximum Block Size": "Tamaño máximo de bloque",
"Maximum Inline Size": "Tamaño máximo en línea",
"Maximum Number of Columns": "Número máximo de columnas",
"Minimum Font Size": "Tamaño de fuente mínimo",
"Misc": "Varios",
@@ -230,5 +226,23 @@
"Delete Permanently": "Eliminar permanentemente",
"Free Tier": "Nivel gratuito",
"Plus Tier": "Nivel Plus",
"Pro Tier": "Nivel Pro"
"Pro Tier": "Nivel Pro",
"RTL Direction": "Dirección RTL",
"Maximum Column Height": "Altura máxima de columna",
"Maximum Column Width": "Ancho máximo de columna",
"Continuous Scroll": "Desplazamiento continuo",
"Fullscreen": "Pantalla completa",
"No supported files found. Supported formats: {{formats}}": "No se encontraron archivos compatibles. Formatos compatibles: {{formats}}",
"Drop to Import Books": "Soltar para importar libros",
"Custom": "Personalizado",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo el mundo es un escenario,\nY todos los hombres y mujeres son meros actores;\nTienen sus salidas y sus entradas,\nY un hombre en su tiempo interpreta muchos papeles,\nSus actos son siete edades.\n\n— William Shakespeare",
"Custom Theme": "Tema personalizado",
"Theme Name": "Nombre del tema",
"Text Color": "Color del texto",
"Background Color": "Color de fondo",
"Preview": "Vista previa",
"Contrast": "Contraste",
"Sunset": "Atardecer",
"Double Border": "Doble borde",
"Border Color": "Color del borde"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Césure",
"Identifier:": "Identifiant :",
"Import Books": "Importer des livres",
"Invert Colors in Dark Mode": "Inverser les couleurs en mode sombre",
"Language:": "Langue :",
"Layout": "Mise en page",
"Light Mode": "Mode clair",
"Loading...": "Chargement...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Connecté",
"Logged in as {{userDisplayName}}": "Connecté en tant que {{userDisplayName}}",
"Match Case": "Respecter la casse",
"Match Diacritics": "Respecter les accents",
"Match Whole Words": "Mots entiers",
"Maximum Block Size": "Taille maximale du bloc",
"Maximum Inline Size": "Taille maximale en ligne",
"Maximum Number of Columns": "Nombre maximal de colonnes",
"Minimum Font Size": "Taille minimale de police",
"Misc": "Divers",
@@ -230,5 +226,23 @@
"Delete Permanently": "Supprimer définitivement",
"Free Tier": "Niveau gratuit",
"Plus Tier": "Niveau Plus",
"Pro Tier": "Niveau Pro"
"Pro Tier": "Niveau Pro",
"RTL Direction": "Direction RTL",
"Maximum Column Height": "Hauteur maximale de colonne",
"Maximum Column Width": "Largeur maximale de colonne",
"Continuous Scroll": "Défilement continu",
"Fullscreen": "Plein écran",
"No supported files found. Supported formats: {{formats}}": "Aucun fichier pris en charge trouvé. Formats pris en charge : {{formats}}",
"Drop to Import Books": "Déposez pour importer des livres",
"Custom": "Personnalisé",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Le monde entier est un théâtre,\nEt tous les hommes et les femmes ne sont que des acteurs ;\nIls ont leurs sorties et leurs entrées,\nEt un homme, dans sa vie, joue de nombreux rôles,\nSes actes étant sept âges.\n\n— William Shakespeare",
"Custom Theme": "Thème personnalisé",
"Theme Name": "Nom du thème",
"Text Color": "Couleur du texte",
"Background Color": "Couleur de fond",
"Preview": "Aperçu",
"Contrast": "Contraste",
"Sunset": "Coucher de soleil",
"Double Border": "Double bordure",
"Border Color": "Couleur de la bordure"
}
@@ -53,19 +53,15 @@
"Hyphenation": "हाइफ़नेशन",
"Identifier:": "पहचानकर्ता:",
"Import Books": "पुस्तकें आयात करें",
"Invert Colors in Dark Mode": "डार्क मोड में रंग उलटें",
"Language:": "भाषा:",
"Layout": "लेआउट",
"Light Mode": "लाइट मोड",
"Loading...": "लोड हो रहा है...",
"Loc. {{currentPage}} / {{totalPage}}": "स्थान {{currentPage}} / {{totalPage}}",
"Logged in": "लॉग इन किया",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} के रूप में लॉग इन किया",
"Match Case": "केस मैच करें",
"Match Diacritics": "डायाक्रिटिक्स मैच करें",
"Match Whole Words": "पूरे शब्द मैच करें",
"Maximum Block Size": "अधिकतम ब्लॉक साइज़",
"Maximum Inline Size": "अधिकतम इनलाइन साइज़",
"Maximum Number of Columns": "कॉलम की अधिकतम संख्या",
"Minimum Font Size": "न्यूनतम फ़ॉन्ट साइज़",
"Misc": "विविध",
@@ -230,5 +226,23 @@
"Delete Permanently": "स्थायी रूप से हटाएं",
"Free Tier": "फ्री टियर",
"Plus Tier": "प्लस टियर",
"Pro Tier": "प्रो टियर"
"Pro Tier": "प्रो टियर",
"RTL Direction": "RTL दिशा",
"Maximum Column Height": "कॉलम की अधिकतम ऊँचाई",
"Maximum Column Width": "कॉलम की अधिकतम चौड़ाई",
"Continuous Scroll": "निरंतर स्क्रॉल",
"Fullscreen": "पूर्णस्क्रीन",
"No supported files found. Supported formats: {{formats}}": "कोई समर्थित फ़ाइलें नहीं मिलीं। समर्थित प्रारूप: {{formats}}",
"Drop to Import Books": "पुस्तकें आयात करने के लिए छोड़ें",
"Custom": "कस्टम",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "पूरी दुनिया एक स्टेज है,\nऔर सभी आदमी और महिलाएँ केवल अभिनेता हैं;\nउनके निकास और प्रवेश होते हैं,\nऔर एक आदमी अपने समय में कई भूमिकाएँ निभाता है,\nउसके कार्य सात युग होते हैं।\n\n— विलियम शेक्सपियर",
"Custom Theme": "कस्टम थीम",
"Theme Name": "थीम का नाम",
"Text Color": "टेक्स्ट रंग",
"Background Color": "पृष्ठभूमि रंग",
"Preview": "पूर्वावलोकन",
"Contrast": "विरोधाभास",
"Sunset": "सनसेट",
"Double Border": "डबल बॉर्डर",
"Border Color": "बॉर्डर रंग"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Pemenggalan Kata",
"Identifier:": "Pengenal:",
"Import Books": "Impor Buku",
"Invert Colors in Dark Mode": "Balik Warna di Mode Gelap",
"Language:": "Bahasa:",
"Layout": "Tata Letak",
"Light Mode": "Mode Terang",
"Loading...": "Memuat...",
"Loc. {{currentPage}} / {{totalPage}}": "Lok. {{currentPage}} / {{totalPage}}",
"Logged in": "Sudah Masuk",
"Logged in as {{userDisplayName}}": "Masuk sebagai {{userDisplayName}}",
"Match Case": "Cocokkan Huruf Besar/Kecil",
"Match Diacritics": "Cocokkan Diakritik",
"Match Whole Words": "Cocokkan Kata Utuh",
"Maximum Block Size": "Ukuran Blok Maksimum",
"Maximum Inline Size": "Ukuran Sebaris Maksimum",
"Maximum Number of Columns": "Jumlah Kolom Maksimum",
"Minimum Font Size": "Ukuran Font Minimum",
"Misc": "Lain-lain",
@@ -230,5 +226,23 @@
"Delete Permanently": "Hapus Secara Permanen",
"Free Tier": "Tingkat Gratis",
"Plus Tier": "Tingkat Plus",
"Pro Tier": "Tingkat Pro"
"Pro Tier": "Tingkat Pro",
"RTL Direction": "Arah RTL",
"Maximum Column Height": "Tinggi Kolom Maksimum",
"Maximum Column Width": "Lebar Kolom Maksimum",
"Continuous Scroll": "Gulir Terus",
"Fullscreen": "Layar Penuh",
"No supported files found. Supported formats: {{formats}}": "Tidak ada file yang didukung ditemukan. Format yang didukung: {{formats}}",
"Drop to Import Books": "Jatuhkan untuk Mengimpor Buku",
"Custom": "Kustom",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Seluruh dunia adalah panggung,\nDan semua pria dan wanita hanyalah pemain;\nMereka memiliki keluar dan masuk mereka,\nDan seorang pria dalam hidupnya memainkan banyak peran,\nTindakannya ada tujuh zaman.\n\n— William Shakespeare",
"Custom Theme": "Tema Kustom",
"Theme Name": "Nama Tema",
"Text Color": "Warna Teks",
"Background Color": "Warna Latar Belakang",
"Preview": "Pratinjau",
"Contrast": "Kontras",
"Sunset": "Senja",
"Double Border": "Garis Ganda",
"Border Color": "Warna Garis"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Sillabazione",
"Identifier:": "Identificatore:",
"Import Books": "Importa libri",
"Invert Colors in Dark Mode": "Inverti colori in modalità scura",
"Language:": "Lingua:",
"Layout": "Layout",
"Light Mode": "Modalità chiara",
"Loading...": "Caricamento...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Accesso effettuato",
"Logged in as {{userDisplayName}}": "Accesso effettuato come {{userDisplayName}}",
"Match Case": "Maiuscole/minuscole",
"Match Diacritics": "Corrispondenza diacritici",
"Match Whole Words": "Parole intere",
"Maximum Block Size": "Dimensione massima blocco",
"Maximum Inline Size": "Dimensione massima in linea",
"Maximum Number of Columns": "Numero massimo di colonne",
"Minimum Font Size": "Dimensione minima font",
"Misc": "Varie",
@@ -230,5 +226,23 @@
"Delete Permanently": "Elimina definitivamente",
"Free Tier": "Piano gratuito",
"Plus Tier": "Piano Plus",
"Pro Tier": "Piano Pro"
"Pro Tier": "Piano Pro",
"RTL Direction": "Direzione RTL",
"Maximum Column Height": "Altezza massima colonna",
"Maximum Column Width": "Larghezza massima colonna",
"Continuous Scroll": "Scorrimento continuo",
"Fullscreen": "Schermo intero",
"No supported files found. Supported formats: {{formats}}": "Nessun file supportato trovato. Formati supportati: {{formats}}",
"Drop to Import Books": "Rilascia per importare libri",
"Custom": "Personalizzato",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tutto il mondo è un palcoscenico,\nE tutti gli uomini e le donne sono solo attori;\nHanno le loro uscite e le loro entrate,\nE un uomo nella sua vita interpreta molte parti,\nI suoi atti sono sette età.\n\n— William Shakespeare",
"Custom Theme": "Tema personalizzato",
"Theme Name": "Nome tema",
"Text Color": "Colore testo",
"Background Color": "Colore sfondo",
"Preview": "Anteprima",
"Contrast": "Contrasto",
"Sunset": "Tramonto",
"Double Border": "Doppio bordo",
"Border Color": "Colore bordo"
}
@@ -53,19 +53,15 @@
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
"Invert Colors in Dark Mode": "ダークモードで色を反転",
"Language:": "言語:",
"Layout": "レイアウト",
"Light Mode": "ライトモード",
"Loading...": "読み込み中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "ログイン済み",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}としてログイン中",
"Match Case": "大文字・小文字を区別",
"Match Diacritics": "アクセント記号を区別",
"Match Whole Words": "単語全体を一致",
"Maximum Block Size": "最大ブロックサイズ",
"Maximum Inline Size": "最大インラインサイズ",
"Maximum Number of Columns": "最大列数",
"Minimum Font Size": "最小フォントサイズ",
"Misc": "その他",
@@ -230,5 +226,23 @@
"Delete Permanently": "完全に削除",
"Free Tier": "無料プラン",
"Plus Tier": "Plusプラン",
"Pro Tier": "Proプラン"
"Pro Tier": "Proプラン",
"RTL Direction": "RTL方向",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列幅",
"Continuous Scroll": "連続スクロール",
"Fullscreen": "全画面",
"No supported files found. Supported formats: {{formats}}": "サポートされているファイルが見つかりません。サポートされている形式:{{formats}}",
"Drop to Import Books": "書籍をインポートするにはドロップ",
"Custom": "カスタム",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "全世界は舞台であり、\nそしてすべての男性と女性は単なる役者です。\n彼らは出入り口を持っており、\nそして一人の男性は時に多くの役を演じます。\n彼の行為は七つの時代です。\n\n— ウィリアム・シェイクスピア",
"Custom Theme": "カスタムテーマ",
"Theme Name": "テーマ名",
"Text Color": "テキストカラー",
"Background Color": "背景色",
"Preview": "プレビュー",
"Contrast": "コントラスト",
"Sunset": "サンセット",
"Double Border": "二重枠",
"Border Color": "ボーダーカラー"
}
@@ -53,19 +53,15 @@
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
"Invert Colors in Dark Mode": "다크 모드에서 색상 반전",
"Language:": "언어:",
"Layout": "레이아웃",
"Light Mode": "라이트 모드",
"Loading...": "로딩 중...",
"Loc. {{currentPage}} / {{totalPage}}": "위치 {{currentPage}} / {{totalPage}}",
"Logged in": "로그인됨",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}(으)로 로그인됨",
"Match Case": "대소문자 구분",
"Match Diacritics": "발음 구별 부호 구분",
"Match Whole Words": "전체 단어 일치",
"Maximum Block Size": "최대 블록 크기",
"Maximum Inline Size": "최대 인라인 크기",
"Maximum Number of Columns": "최대 열 수",
"Minimum Font Size": "최소 글꼴 크기",
"Misc": "기타",
@@ -230,5 +226,23 @@
"Delete Permanently": "영구 삭제",
"Free Tier": "무료 등급",
"Plus Tier": "플러스 등급",
"Pro Tier": "프로 등급"
"Pro Tier": "프로 등급",
"RTL Direction": "RTL 방향",
"Maximum Column Height": "최대 열 높이",
"Maximum Column Width": "최대 열 너비",
"Continuous Scroll": "연속 스크롤",
"Fullscreen": "전체 화면",
"No supported files found. Supported formats: {{formats}}": "지원되는 파일이 없습니다. 지원되는 형식: {{formats}}",
"Drop to Import Books": "책 가져오기 위해 드롭",
"Custom": "사용자 정의",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "세상은 무대이며,\n그리고 모든 남자와 여자는 단지 배우일 뿐입니다;\n그들은 나가는 곳과 들어오는 곳이 있으며,\n그리고 한 남자는 그의 시간에 많은 역할을 합니다,\n그의 행동은 일곱 시대입니다.\n\n— 윌리엄 셰익스피어",
"Custom Theme": "사용자 정의 테마",
"Theme Name": "테마 이름",
"Text Color": "텍스트 색상",
"Background Color": "배경 색상",
"Preview": "미리보기",
"Contrast": "대비",
"Sunset": "일몰",
"Double Border": "이중 테두리",
"Border Color": "테두리 색상"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Dzielenie wyrazów",
"Identifier:": "Identyfikator:",
"Import Books": "Importuj książki",
"Invert Colors in Dark Mode": "Odwróć kolory w trybie ciemnym",
"Language:": "Język:",
"Layout": "Układ",
"Light Mode": "Tryb jasny",
"Loading...": "Ładowanie...",
"Loc. {{currentPage}} / {{totalPage}}": "Poz. {{currentPage}} / {{totalPage}}",
"Logged in": "Zalogowano",
"Logged in as {{userDisplayName}}": "Zalogowano jako {{userDisplayName}}",
"Match Case": "Uwzględnij wielkość liter",
"Match Diacritics": "Uwzględnij znaki diakrytyczne",
"Match Whole Words": "Uwzględnij całe słowa",
"Maximum Block Size": "Maksymalny rozmiar bloku",
"Maximum Inline Size": "Maksymalny rozmiar linii",
"Maximum Number of Columns": "Maksymalna liczba kolumn",
"Minimum Font Size": "Minimalny rozmiar czcionki",
"Misc": "Różne",
@@ -230,5 +226,23 @@
"Delete Permanently": "Usuń trwale",
"Free Tier": "Plan darmowy",
"Plus Tier": "Plan Plus",
"Pro Tier": "Plan Pro"
"Pro Tier": "Plan Pro",
"RTL Direction": "Kierunek RTL",
"Maximum Column Height": "Maksymalna wysokość kolumny",
"Maximum Column Width": "Maksymalna szerokość kolumny",
"Continuous Scroll": "Przewijanie ciągłe",
"Fullscreen": "Pełny ekran",
"No supported files found. Supported formats: {{formats}}": "Nie znaleziono obsługiwanych plików. Obsługiwane formaty: {{formats}}",
"Drop to Import Books": "Upuść, aby zaimportować książki",
"Custom": "Niestandardowy",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cały świat jest sceną,\nA wszyscy mężczyźni i kobiety są tylko aktorami;\nMają swoje wyjścia i wejścia,\nI jeden człowiek w swoim czasie gra wiele ról,\nJego czyny to siedem wieków.\n\n— William Shakespeare",
"Custom Theme": "Niestandardowy motyw",
"Theme Name": "Nazwa motywu",
"Text Color": "Kolor tekstu",
"Background Color": "Kolor tła",
"Preview": "Podgląd",
"Contrast": "Kontrast",
"Sunset": "Zachód słońca",
"Double Border": "Podwójna ramka",
"Border Color": "Kolor ramki"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Hifenização",
"Identifier:": "Identificador:",
"Import Books": "Importar Livros",
"Invert Colors in Dark Mode": "Inverter Cores no Modo Escuro",
"Language:": "Idioma:",
"Layout": "Layout",
"Light Mode": "Modo Claro",
"Loading...": "Carregando...",
"Loc. {{currentPage}} / {{totalPage}}": "Loc. {{currentPage}} / {{totalPage}}",
"Logged in": "Conectado",
"Logged in as {{userDisplayName}}": "Conectado como {{userDisplayName}}",
"Match Case": "Diferenciar Maiúsculas e Minúsculas",
"Match Diacritics": "Corresponder Acentos",
"Match Whole Words": "Corresponder Palavras Inteiras",
"Maximum Block Size": "Tamanho Máximo do Bloco",
"Maximum Inline Size": "Tamanho Máximo em Linha",
"Maximum Number of Columns": "Número Máximo de Colunas",
"Minimum Font Size": "Tamanho Mínimo da Fonte",
"Misc": "Diversos",
@@ -230,5 +226,23 @@
"Delete Permanently": "Excluir permanentemente",
"Free Tier": "Plano gratuito",
"Plus Tier": "Plano Plus",
"Pro Tier": "Plano Pro"
"Pro Tier": "Plano Pro",
"RTL Direction": "Direção RTL",
"Maximum Column Height": "Altura Máxima da Coluna",
"Maximum Column Width": "Largura Máxima da Coluna",
"Continuous Scroll": "Rolagem Contínua",
"Fullscreen": "Tela Cheia",
"No supported files found. Supported formats: {{formats}}": "Nenhum arquivo suportado encontrado. Formatos suportados: {{formats}}",
"Drop to Import Books": "Arraste para Importar Livros",
"Custom": "Personalizado",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo o mundo é um palco,\nE todos os homens e mulheres são meros atores;\nEles têm suas saídas e suas entradas,\nE um homem em seu tempo desempenha muitos papéis,\nSeus atos sendo sete idades.\n\n— William Shakespeare",
"Custom Theme": "Tema Personalizado",
"Theme Name": "Nome do Tema",
"Text Color": "Cor do Texto",
"Background Color": "Cor de Fundo",
"Preview": "Visualizar",
"Contrast": "Contraste",
"Sunset": "Pôr do Sol",
"Double Border": "Borda Dupla",
"Border Color": "Cor da Borda"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
"Invert Colors in Dark Mode": "Инвертировать цвета в тёмной теме",
"Language:": "Язык:",
"Layout": "Макет",
"Light Mode": "Светлая тема",
"Loading...": "Загрузка...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Выполнен вход",
"Logged in as {{userDisplayName}}": "Вход выполнен как {{userDisplayName}}",
"Match Case": "Учитывать регистр",
"Match Diacritics": "Учитывать диакритические знаки",
"Match Whole Words": "Только целые слова",
"Maximum Block Size": "Максимальный размер блока",
"Maximum Inline Size": "Максимальный размер строки",
"Maximum Number of Columns": "Максимальное количество колонок",
"Minimum Font Size": "Минимальный размер шрифта",
"Misc": "Разное",
@@ -230,5 +226,23 @@
"Delete Permanently": "Удалить навсегда",
"Free Tier": "Бесплатный тариф",
"Plus Tier": "Тариф Plus",
"Pro Tier": "Тариф Pro"
"Pro Tier": "Тариф Pro",
"RTL Direction": "Направление RTL",
"Maximum Column Height": "Максимальная высота колонки",
"Maximum Column Width": "Максимальная ширина колонки",
"Continuous Scroll": "Непрерывная прокрутка",
"Fullscreen": "Полноэкранный режим",
"No supported files found. Supported formats: {{formats}}": "Поддерживаемые файлы не найдены. Поддерживаемые форматы: {{formats}}",
"Drop to Import Books": "Перетащите сюда книги для импорта",
"Custom": "Пользовательский",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь мир — театр,\nИ все мужчины и женщины — просто актёры;\nУ них есть свои выходы и входы,\nИ один человек в своё время играет много ролей,\nЕго поступки — это семь веков.\n\n— Уильям Шекспир",
"Custom Theme": "Пользовательская тема",
"Theme Name": "Название темы",
"Text Color": "Цвет текста",
"Background Color": "Цвет фона",
"Preview": "Предпросмотр",
"Contrast": "Контраст",
"Sunset": "Закат",
"Double Border": "Двойная граница",
"Border Color": "Цвет границы"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Heceleme",
"Identifier:": "Tanımlayıcı:",
"Import Books": "Kitapları İçe Aktar",
"Invert Colors in Dark Mode": "Karanlık Modda Renkleri Ters Çevir",
"Language:": "Dil:",
"Layout": "Düzen",
"Light Mode": "Aydınlık Mod",
"Loading...": "Yükleniyor...",
"Loc. {{currentPage}} / {{totalPage}}": "Konum {{currentPage}} / {{totalPage}}",
"Logged in": "Giriş Yapıldı",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} olarak giriş yapıldı",
"Match Case": "Büyük/Küçük Harf Eşleştir",
"Match Diacritics": "Aksan İşaretlerini Eşleştir",
"Match Whole Words": "Tam Kelimeleri Eşleştir",
"Maximum Block Size": "Maksimum Blok Boyutu",
"Maximum Inline Size": "Maksimum Satır İçi Boyut",
"Maximum Number of Columns": "Maksimum Sütun Sayısı",
"Minimum Font Size": "Minimum Yazı Boyutu",
"Misc": "Diğer",
@@ -230,5 +226,23 @@
"Delete Permanently": "Kalıcı Olarak Sil",
"Free Tier": "Ücretsiz Paket",
"Plus Tier": "Plus Paket",
"Pro Tier": "Pro Paket"
"Pro Tier": "Pro Paket",
"RTL Direction": "RTL Yönü",
"Maximum Column Height": "Maksimum Sütun Yüksekliği",
"Maximum Column Width": "Maksimum Sütun Genişliği",
"Continuous Scroll": "Sürekli Kaydırma",
"Fullscreen": "Tam Ekran",
"No supported files found. Supported formats: {{formats}}": "Desteklenen dosya bulunamadı. Desteklenen formatlar: {{formats}}",
"Drop to Import Books": "Kitapları İçe Aktarmak İçin Bırak",
"Custom": "Özel",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tüm dünya bir sahnedir,\nVe tüm erkekler ve kadınlar sadece oyunculardır;\nOnların çıkışları ve girişleri vardır,\nVe bir adam zamanında birçok rol oynar,\nOnun eylemleri yedi yaşındadır.\n\n— William Shakespeare",
"Custom Theme": "Özel Tema",
"Theme Name": "Tema Adı",
"Text Color": "Metin Rengi",
"Background Color": "Arka Plan Rengi",
"Preview": "Önizleme",
"Contrast": "Kontrast",
"Sunset": "Gün Batımı",
"Double Border": "Çift Sınır",
"Border Color": "Sınır Rengi"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Перенесення слів",
"Identifier:": "Ідентифікатор:",
"Import Books": "Імпортувати книги",
"Invert Colors in Dark Mode": "Інвертувати кольори в темному режимі",
"Language:": "Мова:",
"Layout": "Макет",
"Light Mode": "Світлий режим",
"Loading...": "Завантаження...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Увійшли",
"Logged in as {{userDisplayName}}": "Увійшли як {{userDisplayName}}",
"Match Case": "Враховувати регістр",
"Match Diacritics": "Враховувати діакритичні знаки",
"Match Whole Words": "Шукати цілі слова",
"Maximum Block Size": "Максимальний розмір блоку",
"Maximum Inline Size": "Максимальний розмір рядка",
"Maximum Number of Columns": "Максимальна кількість колонок",
"Minimum Font Size": "Мінімальний розмір шрифту",
"Misc": "Різне",
@@ -230,5 +226,23 @@
"Delete Permanently": "Видалити назавжди",
"Free Tier": "Безкоштовний тариф",
"Plus Tier": "Тариф Plus",
"Pro Tier": "Тариф Pro"
"Pro Tier": "Тариф Pro",
"RTL Direction": "Напрямок RTL",
"Maximum Column Height": "Максимальна висота колонки",
"Maximum Column Width": "Максимальна ширина колонки",
"Continuous Scroll": "Постійна прокрутка",
"Fullscreen": "Повноекранний",
"No supported files found. Supported formats: {{formats}}": "Не знайдено підтримуваних файлів. Підтримувані формати: {{formats}}",
"Drop to Import Books": "Перетягніть для імпорту книг",
"Custom": "Користувацький",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь світ — театр,\nІ всі чоловіки та жінки — просто актори;\nУ них є свої виходи та входи,\nІ один чоловік у свій час грає багато ролей,\nЙого вчинки — це сім віков.\n\n— Вільям Шекспір",
"Custom Theme": "Користувацька тема",
"Theme Name": "Назва теми",
"Text Color": "Колір тексту",
"Background Color": "Колір фону",
"Preview": "Попередній перегляд",
"Contrast": "Контраст",
"Sunset": "Захід сонця",
"Double Border": "Подвійна рамка",
"Border Color": "Колір рамки"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Gạch nối từ",
"Identifier:": "Định danh:",
"Import Books": "Nhập sách",
"Invert Colors in Dark Mode": "Đảo màu trong chế độ tối",
"Language:": "Ngôn ngữ:",
"Layout": "Bố cục",
"Light Mode": "Chế độ sáng",
"Loading...": "Đang tải...",
"Loc. {{currentPage}} / {{totalPage}}": "Vị trí {{currentPage}} / {{totalPage}}",
"Logged in": "Đã đăng nhập",
"Logged in as {{userDisplayName}}": "Đăng nhập với tên {{userDisplayName}}",
"Match Case": "Phân biệt chữ hoa/thường",
"Match Diacritics": "Phân biệt dấu",
"Match Whole Words": "Khớp toàn bộ từ",
"Maximum Block Size": "Kích thước khối tối đa",
"Maximum Inline Size": "Kích thước nội tuyến tối đa",
"Maximum Number of Columns": "Số cột tối đa",
"Minimum Font Size": "Cỡ chữ tối thiểu",
"Misc": "Khác",
@@ -230,5 +226,23 @@
"Delete Permanently": "Xóa vĩnh viễn",
"Free Tier": "Gói miễn phí",
"Plus Tier": "Gói Plus",
"Pro Tier": "Gói Pro"
"Pro Tier": "Gói Pro",
"RTL Direction": "Hướng RTL",
"Maximum Column Height": "Chiều cao cột tối đa",
"Maximum Column Width": "Chiều rộng cột tối đa",
"Continuous Scroll": "Cuộn liên tục",
"Fullscreen": "Toàn màn hình",
"No supported files found. Supported formats: {{formats}}": "Không tìm thấy tệp được hỗ trợ. Định dạng được hỗ trợ: {{formats}}",
"Drop to Import Books": "Kéo và thả để nhập sách",
"Custom": "Tùy chỉnh",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cả thế giới này là một sân khấu,\nVà tất cả đàn ông và phụ nữ chỉ là những diễn viên;\nHọ có cửa ra và cửa vào của mình,\nVà một người trong cuộc đời mình đóng nhiều vai,\nHành động của mình là bảy tuổi.\n\n— William Shakespeare",
"Custom Theme": "Chủ đề tùy chỉnh",
"Theme Name": "Tên chủ đề",
"Text Color": "Màu văn bản",
"Background Color": "Màu nền",
"Preview": "Xem trước",
"Contrast": "Tương phản",
"Sunset": "Hoàng hôn",
"Double Border": "Đôi viền",
"Border Color": "Màu viền"
}
@@ -53,19 +53,15 @@
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
"Invert Colors in Dark Mode": "深色主题下反色",
"Language:": "语言",
"Layout": "布局",
"Light Mode": "浅色主题",
"Loading...": "加载中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登录",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登录",
"Match Case": "匹配大小写",
"Match Diacritics": "匹配重音符号",
"Match Whole Words": "匹配整个单词",
"Maximum Block Size": "内容最大宽度",
"Maximum Inline Size": "每栏最大宽度",
"Maximum Number of Columns": "分栏数",
"Minimum Font Size": "最小字号",
"Misc": "杂项",
@@ -230,5 +226,23 @@
"Delete Permanently": "永久删除",
"Free Tier": "免费版",
"Plus Tier": "增强版",
"Pro Tier": "专业版"
"Pro Tier": "专业版",
"RTL Direction": "从右向左",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列宽",
"Continuous Scroll": "连续滚动",
"Fullscreen": "全屏",
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
"Drop to Import Books": "拖放导入书籍",
"Custom": "自定义",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是座舞台, \n所有男女都只是演员; \n各有其出场和入场; \n每个人皆扮演许多角色, \n而演出共分为七个阶段。\n\n——威廉·莎士比亚",
"Custom Theme": "自定义主题",
"Theme Name": "主题名称",
"Text Color": "文本颜色",
"Background Color": "背景颜色",
"Preview": "预览",
"Contrast": "对比",
"Sunset": "日落",
"Double Border": "页面版框",
"Border Color": "版框颜色"
}
@@ -53,19 +53,15 @@
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
"Invert Colors in Dark Mode": "深色主題下反色",
"Language:": "語言",
"Layout": "版面",
"Light Mode": "淺色主題",
"Loading...": "載入中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登入",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登入",
"Match Case": "匹配大小寫",
"Match Diacritics": "匹配重音符號",
"Match Whole Words": "匹配整個單詞",
"Maximum Block Size": "內容最大寬度",
"Maximum Inline Size": "每欄最大寬度",
"Maximum Number of Columns": "分欄數",
"Minimum Font Size": "最小字號",
"Misc": "雜項",
@@ -230,5 +226,23 @@
"Delete Permanently": "永久刪除",
"Free Tier": "免費方案",
"Plus Tier": "Plus方案",
"Pro Tier": "專業方案"
"Pro Tier": "專業方案",
"RTL Direction": "從右向左",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列寬",
"Continuous Scroll": "連續滾動",
"Fullscreen": "全螢幕",
"No supported files found. Supported formats: {{formats}}": "未找到支援的檔案。支援的格式:{{formats}}",
"Drop to Import Books": "拖放導入書籍",
"Custom": "自定義",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是一個舞台,\n所有的男人和女人都只是演員:\n他們有出口,也有入口;\n一個人在他的時代扮演許多角​​色。 \n\n——威廉·莎士比亞",
"Custom Theme": "自定義主題",
"Theme Name": "主題名稱",
"Text Color": "文字顏色",
"Background Color": "背景顏色",
"Preview": "預覽",
"Contrast": "對比",
"Sunset": "日落",
"Double Border": "頁面邊框",
"Border Color": "邊框顏色"
}
+37
View File
@@ -1,5 +1,42 @@
{
"releases": {
"0.9.22": {
"date": "2025-03-16",
"notes": [
"Add theme color editor for more customization",
"Add border frame for vertical layout",
"Fix layout glitches on macOS in scroll mode",
"Various fixes and enhancements on translator, footnote, and scrollbar"
]
},
"0.9.21": {
"date": "2025-03-10",
"notes": [
"Fix column height in vertical layout on mobile",
"Fix drag handle height not constant on mobile",
"Add fullscreen option on desktop",
"Add drag and drop to import books on desktop",
"Various fixes and enhancements on updater, footerbar and note"
]
},
"0.9.20": {
"date": "2025-03-10",
"notes": [
"Fix column height in vertical layout on mobile",
"Fix drag handle height not constant on mobile",
"Add fullscreen option on desktop",
"Add drag and drop to import books on desktop",
"Various fixes and enhancements on updater, footerbar and note"
]
},
"0.9.19": {
"date": "2025-03-07",
"notes": [
"Support custom CSS for Reader UI",
"Initial support of RTL layout for Arabic and Hebrew books",
"Various fixes and enhancements on layout and sync"
]
},
"0.9.18": {
"date": "2025-02-26",
"notes": [
@@ -62,6 +62,7 @@
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"shell:default",
"process:default",
"process:allow-exit",
+4
View File
@@ -10,6 +10,8 @@ extern crate objc;
mod menu;
#[cfg(target_os = "macos")]
mod traffic_light;
#[cfg(target_os = "macos")]
use traffic_light::set_traffic_lights;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -98,6 +100,8 @@ pub fn run() {
start_server,
download_file,
upload_file,
#[cfg(target_os = "macos")]
set_traffic_lights,
#[cfg(desktop)]
list_fonts
])
+38 -12
View File
@@ -1,13 +1,14 @@
use objc::{msg_send, sel, sel_impl};
use rand::{distributions::Alphanumeric, Rng};
use tauri::Emitter;
use tauri::{
command,
plugin::{Builder, TauriPlugin},
Runtime, Window,
}; // 0.8
Emitter, Runtime, Window,
};
const WINDOW_CONTROL_PAD_X: f64 = 10.0;
const WINDOW_CONTROL_PAD_Y: f64 = 22.0;
static mut WINDOW_CONTROL_PAD_X: f64 = 10.0;
static mut WINDOW_CONTROL_PAD_Y: f64 = 22.0;
static mut TRAFFIC_LIGHTS_VISIBLE: bool = true;
struct UnsafeWindowHandle(*mut std::ffi::c_void);
unsafe impl Send for UnsafeWindowHandle {}
@@ -30,7 +31,24 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64) {
#[command]
pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) {
unsafe {
TRAFFIC_LIGHTS_VISIBLE = visible;
WINDOW_CONTROL_PAD_X = x;
WINDOW_CONTROL_PAD_Y = y;
position_traffic_lights(
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
}
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool, x: f64, y: f64) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
let ns_window = ns_window_handle.0 as cocoa::base::id;
@@ -45,7 +63,10 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64)
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height;
let title_bar_frame_height = button_height + y;
let mut title_bar_frame_height = button_height + y;
if !visible {
title_bar_frame_height = 0.0;
}
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;
@@ -77,11 +98,14 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
use std::ffi::c_void;
// Do the initial positioning
position_traffic_lights(
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
unsafe {
position_traffic_lights(
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
}
// Ensure they stay in place while resizing the window.
fn with_window_state<R: Runtime, F: FnOnce(&mut WindowState<R>) -> T, T>(
@@ -127,6 +151,7 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
#[cfg(target_os = "macos")]
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
@@ -258,6 +283,7 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
let id = state.window.ns_window().expect("Failed to emit event") as id;
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
+3 -3
View File
@@ -12,8 +12,8 @@ import { IoArrowBack } from 'react-icons/io5';
import { useAuth } from '@/context/AuthContext';
import { supabase } from '@/utils/supabase';
import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isTauriAppPlatform } from '@/services/environment';
@@ -61,7 +61,7 @@ export default function AuthPage() {
const router = useRouter();
const { login } = useAuth();
const { envConfig, appService } = useEnv();
const { isDarkMode } = useTheme();
const { isDarkMode } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [port, setPort] = useState<number | null>(null);
const isOAuthServerRunning = useRef(false);
@@ -129,7 +129,7 @@ export default function AuthPage() {
};
const handleOAuthUrl = async (url: string) => {
console.log('Received OAuth URL:', url);
console.log('Handle OAuth URL:', url);
const hashMatch = url.match(/#(.*)/);
if (hashMatch) {
const hash = hashMatch[1];
@@ -21,9 +21,9 @@ interface BookshelfProps {
libraryBooks: Book[];
isSelectMode: boolean;
handleImportBooks: () => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDelete: (book: Book) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
booksTransferProgress: { [key: string]: number | null };
@@ -143,7 +143,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<div
className={clsx(
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8',
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
)}
>
{currentBookshelfItems.map((item, index) => (
@@ -72,9 +72,9 @@ interface BookshelfItemProps {
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDelete: (book: Book) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
}
@@ -109,9 +109,8 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
await handleBookDownload(book);
available = await handleBookDownload(book);
updateBook(envConfig, book);
available = true;
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
@@ -10,8 +10,8 @@ import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
@@ -33,7 +33,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
initializeTrafficLightListeners,
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
const iconSize20 = useResponsiveSize(20);
@@ -42,6 +49,18 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode,
});
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
@@ -14,6 +14,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { QuotaType } from '@/types/user';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -25,7 +26,7 @@ interface BookMenuProps {
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
@@ -57,6 +58,11 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const handleFullScreen = () => {
tauriHandleToggleFullScreen();
setIsDropdownOpen?.(false);
};
const toggleAutoUploadBooks = () => {
settings.autoUpload = !settings.autoUpload;
setSettings(settings);
@@ -157,6 +163,9 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleScreenWakeLock}
/>
<hr className='border-base-200 my-1' />
{appService?.hasRoundedWindow && (
<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} />}
+139 -16
View File
@@ -2,8 +2,8 @@
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useRef, useEffect, Suspense } from 'react';
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
@@ -17,17 +17,20 @@ import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useTheme } from '@/hooks/useTheme';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { tauriQuitApp } from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
@@ -37,8 +40,14 @@ import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import BookDetailModal from '@/components/BookDetailModal';
import useShortcuts from '@/hooks/useShortcuts';
import DropIndicator from '@/components/DropIndicator';
const LibraryPage = () => {
const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams();
return <LibraryPageContent searchParams={searchParams} />;
};
const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
@@ -47,10 +56,11 @@ const LibraryPage = () => {
updateBook,
setLibrary,
checkOpenWithBooks,
clearOpenWithBooks,
setCheckOpenWithBooks,
} = useLibraryStore();
const _ = useTranslation();
const { updateAppTheme } = useTheme();
useTheme();
const { updateAppTheme } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
@@ -60,8 +70,12 @@ const LibraryPage = () => {
const [booksTransferProgress, setBooksTransferProgress] = useState<{
[key: string]: number | null;
}>({});
const [isDragging, setIsDragging] = useState(false);
const demoBooks = useDemoBooks();
const containerRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
useOpenWithBooks();
const { pullLibrary, pushLibrary } = useBooksSync({
onSyncStart: () => setLoading(true),
@@ -94,6 +108,87 @@ const LibraryPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
const handleDropedFiles = async (files: File[] | string[]) => {
if (files.length === 0) return;
const supportedFiles = files.filter((file) => {
let fileExt;
if (typeof file === 'string') {
fileExt = file.split('.').pop()?.toLowerCase();
} else {
fileExt = file.name.split('.').pop()?.toLowerCase();
}
return FILE_ACCEPT_FORMATS.includes(`.${fileExt}`);
});
if (supportedFiles.length === 0) {
eventDispatcher.dispatch('toast', {
message: _('No supported files found. Supported formats: {{formats}}', {
formats: FILE_ACCEPT_FORMATS,
}),
type: 'error',
});
return;
}
if (appService?.hasHaptics) {
impactFeedback('medium');
}
await importBooks(supportedFiles);
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
const files = Array.from(event.dataTransfer.files);
handleDropedFiles(files);
}
};
useEffect(() => {
const libraryPage = document.querySelector('.library-page');
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
if (isTauriAppPlatform()) {
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === 'over') {
setIsDragging(true);
} else if (event.payload.type === 'drop') {
setIsDragging(false);
handleDropedFiles(event.payload.paths);
} else {
setIsDragging(false);
}
});
return () => {
unlisten.then((fn) => fn());
};
}
return () => {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageRef.current]);
const processOpenWithFiles = React.useCallback(
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const bookIds: string[] = [];
@@ -113,7 +208,9 @@ const LibraryPage = () => {
console.log('Opening books:', bookIds);
if (bookIds.length > 0) {
navigateToReader(router, bookIds);
setTimeout(() => {
navigateToReader(router, bookIds);
}, 0);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -148,7 +245,7 @@ const LibraryPage = () => {
if (checkOpenWithBooks && isTauriAppPlatform()) {
await handleOpenWithBooks(appService, libraryBooks);
} else {
clearOpenWithBooks();
setCheckOpenWithBooks(false);
setLibrary(libraryBooks);
}
@@ -163,7 +260,7 @@ const LibraryPage = () => {
if (openWithFiles.length > 0) {
await processOpenWithFiles(appService, openWithFiles, libraryBooks);
} else {
clearOpenWithBooks();
setCheckOpenWithBooks(false);
setLibrary(libraryBooks);
}
};
@@ -171,10 +268,11 @@ const LibraryPage = () => {
initLogin();
initLibrary();
return () => {
clearOpenWithBooks();
setCheckOpenWithBooks(false);
isInitiating.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [searchParams]);
useEffect(() => {
if (demoBooks.length > 0 && libraryLoaded) {
@@ -193,7 +291,7 @@ const LibraryPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [demoBooks, libraryLoaded]);
const importBooks = async (files: [string | File]) => {
const importBooks = async (files: (string | File)[]) => {
setLoading(true);
const failedFiles = [];
for (const file of files) {
@@ -262,17 +360,18 @@ const LibraryPage = () => {
title: book.title,
}),
});
return true;
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
navigateToLogin(router);
return;
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return;
return false;
}
}
eventDispatcher.dispatch('toast', {
@@ -281,6 +380,7 @@ const LibraryPage = () => {
title: book.title,
}),
});
return false;
}
};
@@ -297,6 +397,7 @@ const LibraryPage = () => {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
@@ -304,6 +405,7 @@ const LibraryPage = () => {
}),
type: 'error',
});
return false;
}
};
@@ -319,6 +421,7 @@ const LibraryPage = () => {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to delete book: {{title}}', {
@@ -326,10 +429,12 @@ const LibraryPage = () => {
}),
type: 'error',
});
return false;
}
};
const handleImportBooks = async () => {
setIsSelectMode(false);
console.log('Importing books...');
let files;
@@ -379,6 +484,7 @@ const LibraryPage = () => {
return (
<div
ref={pageRef}
className={clsx(
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
@@ -402,11 +508,13 @@ const LibraryPage = () => {
<div
ref={containerRef}
className={clsx(
'scroll-container mt-12 flex-grow overflow-auto px-4 sm:px-2',
'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))]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
>
<DropIndicator />
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
@@ -420,7 +528,8 @@ const LibraryPage = () => {
/>
</div>
) : (
<div className='hero h-screen items-center justify-center'>
<div className='hero drop-zone h-screen items-center justify-center'>
<DropIndicator />
<div className='hero-content text-neutral-content text-center'>
<div className='max-w-md'>
<h1 className='mb-5 text-5xl font-bold'>{_('Your Library')}</h1>
@@ -449,4 +558,18 @@ const LibraryPage = () => {
);
};
const LibraryPage = () => {
return (
<Suspense
fallback={
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
}
>
<LibraryPageWithSearchParams />
</Suspense>
);
};
export default LibraryPage;
@@ -17,6 +17,7 @@ import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
import DoubleBorder from './DoubleBorder';
interface BooksGridProps {
bookKeys: string[];
@@ -60,7 +61,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { section, pageinfo, sectionLabel } = progress || {};
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
const marginGap = `${viewSettings.gapPercent}%`;
const horizontalGapPercent = viewSettings.gapPercent;
const verticalMarginPixels = viewSettings.marginPx;
return (
<div
@@ -68,7 +70,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
key={bookKey}
className={`${appService?.hasRoundedWindow ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
>
{isBookmarked && <Ribbon width={marginGap} />}
{isBookmarked && <Ribbon width={`${horizontalGapPercent}%`} />}
<HeaderBar
bookKey={bookKey}
bookTitle={book.title}
@@ -81,18 +83,43 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.scrolled ? null : (
<>
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
<HintInfo bookKey={bookKey} gapRight={marginGap} />
{viewSettings.vertical && viewSettings.doubleBorder && (
<DoubleBorder
borderColor={viewSettings.borderColor}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
)}
<SectionInfo
section={sectionLabel}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
<HintInfo
bookKey={bookKey}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
<PageInfoView
bookFormat={book.format}
section={section ?? null}
pageinfo={pageinfo ?? null}
gapRight={marginGap}
section={section}
pageinfo={pageinfo}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
</>
)}
<Annotator bookKey={bookKey} />
<FooterBar bookKey={bookKey} pageinfo={pageinfo} isHoveredAnim={false} />
<FooterBar
bookKey={bookKey}
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
isHoveredAnim={false}
/>
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
</div>
);
@@ -0,0 +1,62 @@
interface DoubleBorderProps {
borderColor: string;
horizontalGap: number;
verticalMargin: number;
}
const paddingPx = 10;
const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderColor,
horizontalGap,
verticalMargin,
}) => {
return (
<>
<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)`,
}}
></div>
<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',
}}
/>
</>
);
};
export default DoubleBorder;
@@ -2,17 +2,16 @@ import React, { useEffect, useRef, useState } from 'react';
import { BookDoc, getDirection } from '@/libs/document';
import { BookConfig } from '@/types/book';
import { FoliateView, wrappedFoliateView } from '@/types/view';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { getBookDirFromWritingMode } from '@/utils/book';
import { useTheme } from '@/hooks/useTheme';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
handleKeydown,
handleMousedown,
@@ -23,6 +22,7 @@ import {
handleTouchMove,
handleTouchEnd,
} from '../utils/iframeEventHandlers';
import { getMaxInlineSize } from '@/utils/config';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -35,7 +35,8 @@ const FoliateViewer: React.FC<{
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { themeCode } = useTheme();
const { themeCode, isDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey)!;
const [toastMessage, setToastMessage] = useState('');
useEffect(() => {
@@ -43,6 +44,7 @@ const FoliateViewer: React.FC<{
return () => clearTimeout(timer);
}, [toastMessage]);
useUICSS(bookKey, viewSettings);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
@@ -51,18 +53,16 @@ const FoliateViewer: React.FC<{
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar();
const docLoadHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc loaded:', detail);
if (detail.doc) {
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.vertical = writingDir?.vertical || false;
viewSettings.vertical =
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
viewSettings.rtl = writingDir?.rtl || viewSettings.writingMode.includes('rl') || false;
setViewSettings(bookKey, viewSettings);
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
handleScrollbarAutoHide(detail.doc);
}
mountAdditionalFonts(detail.doc);
@@ -86,7 +86,8 @@ const FoliateViewer: React.FC<{
if (detail.reason === 'scroll') {
const renderer = viewRef.current?.renderer;
if (renderer) {
const viewSettings = getViewSettings(bookKey)!;
if (renderer && viewSettings.continuousScroll) {
if (renderer.start <= 0) {
viewRef.current?.prev(1);
// sometimes viewSize has subpixel value that the end never reaches
@@ -120,10 +121,10 @@ const FoliateViewer: React.FC<{
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings, themeCode));
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
}, [themeCode, isDarkMode]);
useEffect(() => {
if (isViewCreated.current) return;
@@ -133,31 +134,34 @@ const FoliateViewer: React.FC<{
console.log('Opening book', bookKey);
await import('foliate-js/view.js');
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
view.id = `foliate-view-${bookKey}`;
document.body.append(view);
containerRef.current?.appendChild(view);
const writingMode = viewSettings.writingMode;
if (writingMode) {
const settingsDir = getBookDirFromWritingMode(writingMode);
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
if (settingsDir !== 'auto') {
bookDoc.dir = settingsDir;
} else if (languageDir !== 'auto') {
bookDoc.dir = languageDir;
}
}
await view.open(bookDoc);
// make sure we can listen renderer events after opening book
viewRef.current = view;
setFoliateView(bookKey, view);
const viewSettings = getViewSettings(bookKey)!;
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
const writingMode = viewSettings.writingMode;
if (writingMode) {
view.book.dir = getBookDirFromWritingMode(writingMode);
}
view.renderer.setStyles?.(getStyles(viewSettings));
const isScrolled = viewSettings.scrolled!;
const marginPx = viewSettings.marginPx!;
const gapPercent = viewSettings.gapPercent!;
const animated = viewSettings.animated!;
const maxColumnCount = viewSettings.maxColumnCount!;
const maxInlineSize =
maxColumnCount === 1 || isScrolled
? ONE_COLUMN_MAX_INLINE_SIZE
: viewSettings.maxInlineSize!;
const maxInlineSize = getMaxInlineSize(viewSettings);
const maxBlockSize = viewSettings.maxBlockSize!;
if (animated) {
view.renderer.setAttribute('animated', '');
@@ -9,21 +9,32 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { PageInfo } from '@/types/book';
import Button from '@/components/Button';
interface FooterBarProps {
bookKey: string;
pageinfo: { current: number; total: number } | undefined;
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
isHoveredAnim: boolean;
}
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
bookFormat,
section,
pageinfo,
isHoveredAnim,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
useReaderStore();
const { isSideBarVisible } = useSidebarStore();
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);
@@ -56,53 +67,76 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
}
};
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
const isVisible = hoveredBookKey === bookKey;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction = progressValid ? (progressInfo!.current + 1) / progressInfo!.total : 0;
return (
<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)]',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
)}
onMouseEnter={() => setHoveredBookKey(bookKey)}
onMouseLeave={() => setHoveredBookKey('')}
>
<div className='hidden sm:flex'>
<Button icon={<RiArrowLeftWideLine />} onClick={handleGoPrev} tooltip={_('Go Left')} />
<>
<div
className={clsx(
'absolute bottom-0 left-0 z-10 h-12 w-full',
viewSettings?.vertical && viewSettings?.scrolled ? 'bottom-3 !h-6' : '',
)}
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)]',
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`,
)}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
onMouseLeave={() => setHoveredBookKey('')}
aria-hidden={!isVisible}
>
<div className='hidden sm:flex'>
<Button
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
/>
</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'>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
/>
</div>
</div>
<Button
icon={<RiArrowGoBackLine />}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={<RiArrowGoForwardLine />}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<span className='mx-2 text-center text-sm'>
{pageinfoValid ? `${Math.round(progressFraction * 100)}%` : ''}
</span>
<input
type='range'
className='text-base-content mx-2 w-full'
min={0}
max={100}
value={pageinfoValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<div className='hidden sm:flex'>
<Button icon={<RiArrowRightWideLine />} onClick={handleGoNext} tooltip={_('Go Right')} />
</div>
</div>
</>
);
};
@@ -1,9 +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 { useTheme } from '@/hooks/useTheme';
import { getFootnoteStyles, getStyles } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
@@ -27,7 +27,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const [popupPosition, setPopupPosition] = useState<Position | null>();
const [showPopup, setShowPopup] = useState(false);
const { getView, getViewSettings } = useReaderStore();
const { themeCode } = useTheme();
const { themeCode } = useThemeStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const footnoteHandler = new FootnoteHandler();
@@ -36,6 +36,15 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const handleBeforeRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
const { view } = detail;
view.addEventListener('link', (e: Event) => {
e.preventDefault();
const { detail: popupLinkDetail } = e as CustomEvent;
popupLinkDetail['follow'] = true;
footnoteHandler.handle(bookDoc, e)?.catch((err) => {
console.warn(err);
getView(bookKey)?.goTo(popupLinkDetail.href);
});
});
footnoteViewRef.current = view;
footnoteRef.current?.replaceChildren(view);
const { renderer } = view;
@@ -1,12 +1,12 @@
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SidebarToggler from './SidebarToggler';
@@ -34,7 +34,13 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
}) => {
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const { isTrafficLightVisible } = useTrafficLight();
const {
isTrafficLightVisible,
setTrafficLightVisibility,
initializeTrafficLightStore,
initializeTrafficLightListeners,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { hoveredBookKey, setHoveredBookKey, bookKeys } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
@@ -45,6 +51,25 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
if (!isOpen) setHoveredBookKey('');
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!appService?.hasTrafficLight) return;
setTrafficLightVisibility(isSideBarVisible);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible]);
return (
<div
ref={headerRef}
@@ -1,17 +1,24 @@
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';
import useTrafficLight from '@/hooks/useTrafficLight';
interface SectionInfoProps {
bookKey: string;
gapRight: string;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
const HintInfo: React.FC<SectionInfoProps> = ({
bookKey,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const { isSideBarVisible } = useSidebarStore();
const { isTrafficLightVisible } = useTrafficLight();
const { isTrafficLightVisible } = useTrafficLightStore();
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
const hintTimeout = useRef(2000);
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -42,13 +49,26 @@ const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
return (
<div
className={clsx(
'pageinfo absolute right-0 top-0 flex max-w-[50%] items-end',
isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]',
hintMessage ? '' : '',
'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%]',
)}
style={{ right: gapRight }}
style={
isVertical
? {
bottom: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
left: `calc(100% - ${horizontalGap * 2}%)`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { right: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
<h2
className={clsx(
'text-neutral-content line-clamp-1 text-center font-sans text-xs font-light',
)}
>
{hintMessage || ''}
</h2>
</div>
@@ -1,32 +1,59 @@
import clsx from 'clsx';
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { PageInfo } from '@/types/book';
interface PageInfoProps {
bookFormat: string;
section: PageInfo | null;
pageinfo: PageInfo | null;
gapRight: string;
section?: PageInfo;
pageinfo?: PageInfo;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const PageInfoView: React.FC<PageInfoProps> = ({ bookFormat, section, pageinfo, gapRight }) => {
const PageInfoView: React.FC<PageInfoProps> = ({
bookFormat,
section,
pageinfo,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const _ = useTranslation();
const pageInfo =
bookFormat === 'PDF'
? section
? `${section.current + 1} / ${section.total}`
? isVertical
? `${section.current + 1} · ${section.total}`
: `${section.current + 1} / ${section.total}`
: ''
: pageinfo
? _('Loc. {{currentPage}} / {{totalPage}}', {
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
})
? _(
isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}',
{
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
},
)
: '';
return (
<div
className='pageinfo absolute bottom-0 left-0 right-0 flex h-12 items-center justify-end'
style={{ paddingRight: gapRight }}
className={clsx(
'pageinfo absolute bottom-0 flex items-center justify-end',
isVertical ? 'left-0' : 'right-0',
isVertical ? 'writing-vertical-rl w-[32px]' : 'h-12',
)}
style={
isVertical
? {
bottom: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
left: `calc(${horizontalGap * 2}% - 32px)`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { paddingRight: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
{pageInfo}
@@ -6,6 +6,7 @@ import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
@@ -16,10 +17,11 @@ import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { library, setLibrary } = useLibraryStore();
const { getVisibleLibrary, setLibrary } = useLibraryStore();
const isInitiating = useRef(false);
const { updateAppTheme } = useTheme();
const { updateAppTheme } = useThemeStore();
useTheme();
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
@@ -38,7 +40,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}, []);
return (
library.length > 0 &&
getVisibleLibrary().length > 0 &&
settings.globalReadSettings && (
<div
className={clsx(
@@ -2,25 +2,46 @@ import clsx from 'clsx';
import React from 'react';
import { useSidebarStore } from '@/store/sidebarStore';
import useTrafficLight from '@/hooks/useTrafficLight';
import { useTrafficLightStore } from '@/store/trafficLightStore';
interface SectionInfoProps {
section?: string;
gapLeft: string;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const SectionInfo: React.FC<SectionInfoProps> = ({ section, gapLeft }) => {
const SectionInfo: React.FC<SectionInfoProps> = ({
section,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const { isSideBarVisible } = useSidebarStore();
const { isTrafficLightVisible } = useTrafficLight();
const { isTrafficLightVisible } = useTrafficLightStore();
return (
<div
className={clsx(
'pageinfo absolute right-0 top-0 flex max-w-[50%] items-end',
isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]',
'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%]',
)}
style={{ left: gapLeft }}
style={
isVertical
? {
top: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
left: `calc(100% - ${horizontalGap * 2}%)`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { left: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
<h2
className={clsx(
'text-neutral-content line-clamp-1 text-center font-sans text-xs font-light',
)}
>
{section || ''}
</h2>
</div>
@@ -5,17 +5,16 @@ import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import {
MAX_ZOOM_LEVEL,
MIN_ZOOM_LEVEL,
ONE_COLUMN_MAX_INLINE_SIZE,
ZOOM_STEP,
} from '@/services/constants';
import MenuItem from '@/components/MenuItem';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme, ThemeMode } from '@/hooks/useTheme';
import { ThemeMode } from '@/styles/themes';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import MenuItem from '@/components/MenuItem';
interface ViewMenuProps {
bookKey: string;
@@ -29,19 +28,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onSetSettingsDialogOpen,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getView, getViews, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, isDarkMode, themeCode, updateThemeMode } = useTheme();
const { themeMode, themeCode, setThemeMode } = useThemeStore();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
const [isInvertedColors, setInvertedColors] = useState(viewSettings!.invert);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
const resetZoom = () => setZoomLevel(100);
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
const toggleInvertedColors = () => setInvertedColors(!isInvertedColors);
const openFontLayoutMenu = () => {
setIsDropdownOpen?.(false);
@@ -51,40 +49,39 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const cycleThemeMode = () => {
const nextMode: ThemeMode =
themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
updateThemeMode(nextMode);
setThemeMode(nextMode);
};
const handleFullScreen = () => {
tauriHandleToggleFullScreen();
setIsDropdownOpen?.(false);
};
useEffect(() => {
getViews().forEach((view) => {
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings!));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
useEffect(() => {
viewSettings!.scrolled = isScrolledMode;
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
`${viewSettings.maxColumnCount === 1 || isScrolledMode ? ONE_COLUMN_MAX_INLINE_SIZE : viewSettings.maxInlineSize}px`,
`${getMaxInlineSize(viewSettings)}px`,
);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
viewSettings!.scrolled = isScrolledMode;
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
setViewSettings(bookKey, viewSettings!);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
document.body.classList.toggle('invert', isInvertedColors);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isInvertedColors]);
useEffect(() => {
const view = getView(bookKey);
if (!view) return;
viewSettings!.zoomLevel = zoomLevel;
setViewSettings(bookKey, viewSettings!);
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings!));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
@@ -135,6 +132,9 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<hr className='border-base-300 my-1' />
{appService?.hasRoundedWindow && (
<MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />
)}
<MenuItem
label={
themeMode === 'dark'
@@ -146,12 +146,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
icon={themeMode === 'dark' ? <BiMoon /> : themeMode === 'light' ? <BiSun /> : <TbSunMoon />}
onClick={cycleThemeMode}
/>
<MenuItem
label={_('Invert Colors in Dark Mode')}
icon={isInvertedColors ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleInvertedColors}
disabled={!isDarkMode}
/>
</div>
);
};
@@ -7,6 +7,7 @@ import { HighlightColor, HighlightStyle } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface AnnotationPopupProps {
dir: 'ltr' | 'rtl';
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
position: Position;
trianglePosition: Position;
@@ -22,6 +23,7 @@ const OPTIONS_HEIGHT_PIX = 28;
const OPTIONS_PADDING_PIX = 16;
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
dir,
buttons,
position,
trianglePosition,
@@ -35,7 +37,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
return (
<div>
<div dir={dir}>
<Popup
width={popupWidth}
height={popupHeight}
@@ -76,7 +76,7 @@ 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(280);
const annotPopupWidth = useResponsiveSize(300);
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 8;
@@ -487,6 +487,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
)}
{showAnnotPopup && trianglePosition && annotPopupPosition && (
<AnnotationPopup
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
buttons={buttons}
position={annotPopupPosition}
trianglePosition={trianglePosition}
@@ -15,10 +15,10 @@ const NotebookHeader: React.FC<{
const _ = useTranslation();
const iconSize14 = useResponsiveSize(14);
return (
<div className='notebook-header relative flex h-11 items-center px-3'>
<div className='notebook-header relative flex h-11 items-center px-3' dir='ltr'>
<div className='absolute inset-0 flex items-center justify-center space-x-2'>
<LuNotebookPen />
<div className='notebook-title text-sm font-medium'>{_('Notebook')}</div>
<div className='notebook-title hidden text-sm font-medium sm:flex'>{_('Notebook')}</div>
</div>
<div className='z-10 flex items-center gap-x-4'>
<button
@@ -114,6 +114,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
'content font-size-sm',
)}
dir='auto'
ref={editorRef}
value={note}
rows={1}
@@ -130,7 +131,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
<span className='content font-size-xs inline text-gray-500'>{getAnnotationText()}</span>
</div>
</div>
<div className='flex justify-end p-2'>
<div className='flex justify-end p-2' dir='ltr'>
<button
className={clsx(
'content btn btn-ghost font-size-sm hover:bg-transparent',
@@ -7,13 +7,14 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
import NoteEditor from './NoteEditor';
@@ -23,14 +24,14 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
const Notebook: React.FC = ({}) => {
const _ = useTranslation();
const { updateAppTheme } = useTheme();
const { updateAppTheme } = useThemeStore();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { notebookWidth, isNotebookVisible, isNotebookPinned } = useNotebookStore();
const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore();
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getView } = useReaderStore();
const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
@@ -136,6 +137,14 @@ const Notebook: React.FC = ({}) => {
if (!sideBarBookKey) return null;
const bookData = getBookData(sideBarBookKey);
const viewSettings = getViewSettings(sideBarBookKey);
if (!bookData || !bookData.bookDoc) {
return null;
}
const { bookDoc } = bookData;
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
const config = getConfig(sideBarBookKey);
const { booknotes: allNotes = [] } = config || {};
const annotationNotes = allNotes
@@ -159,6 +168,7 @@ const Notebook: React.FC = ({}) => {
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${notebookWidth}`,
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
@@ -183,7 +193,7 @@ const Notebook: React.FC = ({}) => {
handleTogglePin={handleTogglePin}
/>
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
<div>
<div dir='ltr'>
{excerptNotes.length > 0 && (
<p className='content font-size-base pt-1'>{_('Excerpts')}</p>
)}
@@ -196,7 +206,7 @@ const Notebook: React.FC = ({}) => {
className='collapse-arrow border-base-300 bg-base-100 collapse border'
>
<div
className='collapse-title font-size-sm h-9 min-h-9 p-2 pr-8 font-medium'
className='collapse-title font-size-sm h-9 min-h-9 p-2 pe-8 font-medium'
style={
{
'--top-override': '1.2rem',
@@ -208,7 +218,7 @@ const Notebook: React.FC = ({}) => {
</div>
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end'>
<div className='flex justify-end' dir='ltr'>
<div
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
onClick={handleEditNote.bind(null, item, true)}
@@ -221,7 +231,7 @@ const Notebook: React.FC = ({}) => {
</li>
))}
</ul>
<div>
<div dir='ltr'>
{(notebookNewAnnotation || annotationNotes.length > 0) && (
<p className='content font-size-base pt-1'>{_('Notes')}</p>
)}
@@ -0,0 +1,66 @@
import React, { useState, useEffect, useRef } from 'react';
import { SketchPicker, ColorResult } from 'react-color';
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
const handlePickerChange = (colorResult: ColorResult) => {
onChange(colorResult.hex);
};
return (
<div className='mb-3'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div className='flex items-center'>
<div
className='border-base-200 relative mr-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
style={{ backgroundColor: value }}
onClick={() => setIsOpen(!isOpen)}
/>
<input
type='text'
value={value}
onChange={(e) => onChange(e.target.value)}
className='bg-base-100 text-base-content border-base-200 min-w-4 max-w-36 flex-1 rounded border p-1 font-mono text-sm'
/>
</div>
{isOpen && (
<div ref={pickerRef} className='relative z-50 mt-2'>
<div className='absolute'>
<SketchPicker
width='100%'
color={value}
onChange={handlePickerChange}
disableAlpha={true}
/>
</div>
</div>
)}
</div>
);
};
export default ColorInput;
@@ -1,96 +1,183 @@
import React, { useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
import { CgColorPicker } from 'react-icons/cg';
import { TbSunMoon } from 'react-icons/tb';
import { useTheme } from '@/hooks/useTheme';
import { themes } from '@/styles/themes';
import { PiPlus } from 'react-icons/pi';
import {
applyCustomTheme,
CustomTheme,
generateDarkPalette,
generateLightPalette,
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 _ = useTranslation();
const { themeMode, themeColor, themeCode, isDarkMode, updateThemeMode, updateThemeColor } =
useTheme();
const {
themeMode,
themeColor,
themeCode,
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!, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
useEffect(() => {
const customThemes = settings.globalReadSettings.customThemes ?? [];
setCustomThemes(
customThemes.map((customTheme) => ({
name: customTheme.name,
label: customTheme.label,
colors: {
light: generateLightPalette(customTheme.colors.light),
dark: generateDarkPalette(customTheme.colors.dark),
},
isCustomizale: true,
})),
);
}, [settings]);
const handleSaveCustomTheme = (customTheme: CustomTheme) => {
applyCustomTheme(customTheme);
saveCustomTheme(envConfig, settings, customTheme);
setSettings({ ...settings });
setThemeColor(customTheme.name);
setShowCustomThemeEditor(false);
};
const handleDeleteCustomTheme = (customTheme: CustomTheme) => {
saveCustomTheme(envConfig, settings, customTheme, true);
setSettings({ ...settings });
setThemeColor('default');
setShowCustomThemeEditor(false);
};
const handleEditTheme = (name: string) => {
const customTheme = settings.globalReadSettings.customThemes.find((t) => t.name === name);
if (customTheme) {
setEditTheme(customTheme);
setShowCustomThemeEditor(true);
}
};
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-2'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('auto')}
>
<TbSunMoon />
</button>
{showCustomThemeEditor ? (
<ThemeEditor
customTheme={editTheme}
onSave={handleSaveCustomTheme}
onDelete={handleDeleteCustomTheme}
onCancel={() => setShowCustomThemeEditor(false)}
/>
) : (
<>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('auto')}
>
<TbSunMoon />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('light')}
>
<MdOutlineLightMode />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('dark')}
>
<MdOutlineDarkMode />
</button>
</div>
</div>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('light')}
>
<MdOutlineLightMode />
</button>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.concat(customThems).map(({ name, label, colors, isCustomizale }) => (
<label
key={name}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
style={{
backgroundColor: isDarkMode
? colors.dark['base-100']
: colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<input
type='radio'
name='theme'
value={name}
checked={themeColor === name}
onChange={() => setThemeColor(name)}
className='hidden'
/>
{themeColor === name ? (
<MdRadioButtonChecked size={iconSize24} />
) : (
<MdRadioButtonUnchecked size={iconSize24} />
)}
<span>{_(label)}</span>
{isCustomizale && themeColor === name && (
<button onClick={() => handleEditTheme(name)}>
<CgColorPicker size={iconSize16} className='absolute right-2 top-2' />
</button>
)}
</label>
))}
<label
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-4 shadow-md`}
onClick={() => setShowCustomThemeEditor(true)}
>
<PiPlus size={iconSize24} />
<span>{_('Custom')}</span>
</label>
</div>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('dark')}
>
<MdOutlineDarkMode />
</button>
</div>
</div>
</div>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.map(({ name, label, colors }) => (
<label
key={name}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
style={{
backgroundColor: isDarkMode ? colors.dark['base-100'] : colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<input
type='radio'
name='theme'
value={name}
checked={themeColor === name}
onChange={() => updateThemeColor(name)}
className='hidden'
/>
{themeColor === name ? (
<MdRadioButtonChecked size={iconSize24} />
) : (
<MdRadioButtonUnchecked size={iconSize24} />
)}
<span>{_(label)}</span>
</label>
))}
</div>
</div>
</>
)}
</div>
);
};
@@ -32,6 +32,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
onClick={(e) => e.currentTarget.focus()}
>
<span style={{ fontFamily: onGetFontFamily(selectedOption.option, family ?? '') }}>
{selectedOption.label}
@@ -16,10 +16,10 @@ import {
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { getStyles } from '@/utils/style';
import { getOSPlatform } from '@/utils/misc';
import { FONT_ENUM_SUPPORTED_OS_PLATFORMS, getSysFontsList } from '@/utils/font';
import { getSysFontsList } from '@/utils/font';
import { isTauriAppPlatform } from '@/services/environment';
interface FontFaceProps {
@@ -60,11 +60,11 @@ const FontFace = ({
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 viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const fontFamilyOptions = [
{
@@ -109,7 +109,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
useEffect(() => {
if (isTauriAppPlatform() && FONT_ENUM_SUPPORTED_OS_PLATFORMS.includes(osPlatform)) {
if (isTauriAppPlatform() && appService?.hasSysFontsList) {
getSysFontsList().then((fonts) => {
setSysFonts(fonts);
});
@@ -124,7 +124,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.defaultFont = defaultFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFont]);
@@ -135,7 +135,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.defaultFontSize = defaultFontSize;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFontSize]);
@@ -146,7 +146,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.minimumFontSize = minFontSize;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minFontSize]);
@@ -157,7 +157,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.fontWeight = fontWeight;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fontWeight]);
@@ -168,7 +168,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.serifFont = serifFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serifFont]);
@@ -179,7 +179,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.sansSerifFont = sansSerifFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sansSerifFont]);
@@ -190,7 +190,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.monospaceFont = monospaceFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [monospaceFont]);
@@ -201,7 +201,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.overrideFont = overrideFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideFont]);
@@ -1,15 +1,17 @@
import React, { useEffect, useState } from 'react';
import { MdOutlineAutoMode } from 'react-icons/md';
import { MdOutlineTextRotationDown, MdOutlineTextRotationNone } from 'react-icons/md';
import { MdOutlineTextRotationNone, MdTextRotateVertical } from 'react-icons/md';
import { TbTextDirectionRtl } from 'react-icons/tb';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { isCJKEnv } from '@/utils/misc';
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 NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
@@ -20,7 +22,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const [paragraphMargin, setParagraphMargin] = useState(viewSettings.paragraphMargin!);
const [lineHeight, setLineHeight] = useState(viewSettings.lineHeight!);
@@ -36,6 +37,9 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
const [overrideLayout, setOverrideLayout] = useState(viewSettings.overrideLayout!);
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
const [doubleBorder, setDoubleBorder] = useState(viewSettings.doubleBorder!);
const [borderColor, setBorderColor] = useState(viewSettings.borderColor!);
useEffect(() => {
viewSettings.paragraphMargin = paragraphMargin;
@@ -44,7 +48,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.paragraphMargin = paragraphMargin;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paragraphMargin]);
@@ -55,7 +59,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.lineHeight = lineHeight;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lineHeight]);
@@ -66,7 +70,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.wordSpacing = wordSpacing;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordSpacing]);
@@ -77,7 +81,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.letterSpacing = letterSpacing;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [letterSpacing]);
@@ -88,7 +92,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.textIndent = textIndent;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textIndent]);
@@ -99,7 +103,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.fullJustification = fullJustification;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fullJustification]);
@@ -110,7 +114,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.hyphenation = hyphenation;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hyphenation]);
@@ -147,10 +151,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSettings(settings);
}
view?.renderer.setAttribute('max-column-count', maxColumnCount);
view?.renderer.setAttribute(
'max-inline-size',
`${maxColumnCount === 1 || viewSettings.scrolled ? ONE_COLUMN_MAX_INLINE_SIZE : maxInlineSize}px`,
);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxColumnCount]);
@@ -161,10 +162,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.maxInlineSize = maxInlineSize;
setSettings(settings);
}
view?.renderer.setAttribute(
'max-inline-size',
`${maxColumnCount === 1 || viewSettings.scrolled ? ONE_COLUMN_MAX_INLINE_SIZE : maxInlineSize}px`,
);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
@@ -181,12 +179,22 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
// global settings are not supported for writing mode
const prevWritingMode = viewSettings.writingMode;
viewSettings.writingMode = writingMode;
if (writingMode.includes('vertical')) {
viewSettings.vertical = true;
}
setViewSettings(bookKey, viewSettings);
if (view) {
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings));
view.book.dir = getBookDirFromWritingMode(writingMode);
}
if (
prevWritingMode !== writingMode &&
(writingMode === 'horizontal-rl' || prevWritingMode === 'horizontal-rl')
) {
setTimeout(() => window.location.reload(), 100);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writingMode]);
@@ -197,23 +205,71 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.overrideLayout = overrideLayout;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideLayout]);
useEffect(() => {
viewSettings!.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);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doubleBorder]);
useEffect(() => {
viewSettings.borderColor = borderColor;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.borderColor = borderColor;
setSettings(settings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [borderColor]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
const mightBeRTLBook = MIGHT_BE_RTL_LANGS.includes(langCode) || isCJKEnv();
return (
<div className='my-4 w-full space-y-6'>
{isCJKBook && (
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Scrolled Mode')}</h2>
<input
type='checkbox'
className='toggle'
checked={isScrolledMode}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
</div>
{mightBeRTLBook && (
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<div className='flex gap-2'>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('auto')}
>
<MdOutlineAutoMode />
@@ -222,7 +278,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-tb')}
>
<MdOutlineTextRotationNone />
@@ -231,10 +287,19 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('vertical-rl')}
>
<MdOutlineTextRotationDown />
<MdTextRotateVertical />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('RTL Direction')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-rl')}
>
<TbTextDirectionRtl />
</button>
</div>
</div>
@@ -242,6 +307,39 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
)}
{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'>
<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>
<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 className='w-full'>
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
@@ -256,7 +354,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
step={0.5}
/>
<NumberInput
className='config-item-top'
label={_('Line Spacing')}
value={lineHeight}
onChange={setLineHeight}
@@ -265,7 +362,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
step={0.1}
/>
<NumberInput
className='config-item-top'
label={_('Word Spacing')}
value={wordSpacing}
onChange={setWordSpacing}
@@ -274,7 +370,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
step={0.5}
/>
<NumberInput
className='config-item-top'
label={_('Letter Spacing')}
value={letterSpacing}
onChange={setLetterSpacing}
@@ -283,7 +378,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
step={0.1}
/>
<NumberInput
className='config-item-top'
label={_('Text Indent')}
value={textIndent}
onChange={setTextIndent}
@@ -339,7 +433,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
label={_('Horizontal Margins (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
min={viewSettings.vertical ? 2 : 0}
max={30}
/>
<NumberInput
@@ -350,18 +444,20 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
max={4}
/>
<NumberInput
label={_('Maximum Inline Size')}
label={viewSettings.vertical ? _('Maximum Column Height') : _('Maximum Column Width')}
value={maxInlineSize}
onChange={setMaxInlineSize}
min={500}
disabled={maxColumnCount === 1 || viewSettings.scrolled}
min={400}
max={9999}
step={100}
/>
<NumberInput
label={_('Maximum Block Size')}
label={viewSettings.vertical ? _('Maximum Column Width') : _('Maximum Column Height')}
value={maxBlockSize}
onChange={setMaxBlockSize}
min={500}
disabled={maxColumnCount === 1 || viewSettings.scrolled}
min={400}
max={9999}
step={100}
/>
@@ -4,7 +4,6 @@ import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
@@ -13,10 +12,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -52,14 +51,14 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setDraftStylesheet(formattedCSS);
setDraftStylesheetSaved(true);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
setViewSettings(bookKey, { ...viewSettings });
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings));
};
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
@@ -92,12 +91,22 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableClick]);
useEffect(() => {
viewSettings.continuousScroll = isContinuousScroll;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.continuousScroll = isContinuousScroll;
setSettings(settings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isContinuousScroll]);
return (
<div className='my-4 w-full space-y-6'>
<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-y'>
<div className='divide-base-200 divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className=''>{_('Paging Animation')}</span>
<input
@@ -114,8 +123,17 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<div className='divide-base-200 divide-y'>
<div className='config-item config-item-top'>
<span className=''>{_('Continuous Scroll')}</span>
<input
type='checkbox'
className='toggle'
checked={isContinuousScroll}
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
<div className='config-item config-item-bottom'>
<span className=''>{_('Disable Click-to-Flip')}</span>
<input
type='checkbox'
@@ -9,6 +9,7 @@ interface NumberInputProps {
min: number;
max: number;
step?: number;
disabled?: boolean;
onChange: (value: number) => void;
}
@@ -20,6 +21,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
min,
max,
step,
disabled,
}) => {
const [localValue, setLocalValue] = useState(value);
const numberStep = step || 1;
@@ -74,13 +76,13 @@ const NumberInput: React.FC<NumberInputProps> = ({
/>
<button
onClick={decrement}
className={`btn btn-circle btn-sm ${value === min ? 'btn-disabled !bg-opacity-5' : ''}`}
className={`btn btn-circle btn-sm ${value <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiMinus className='h-4 w-4' />
</button>
<button
onClick={increment}
className={`btn btn-circle btn-sm ${value === max ? 'btn-disabled !bg-opacity-5' : ''}`}
className={`btn btn-circle btn-sm ${value >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiPlus className='h-4 w-4' />
</button>
@@ -64,7 +64,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
isOpen={true}
onClose={handleClose}
className='modal-open'
boxClassName='sm:w-1/2 sm:min-w-[480px] sm:h-[65%]'
boxClassName='sm:min-w-[520px]'
header={
<div className='flex w-full items-center justify-between'>
<button
@@ -76,12 +76,12 @@ 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 justify-around pl-4'>
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center pl-4 gap-2'>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
className={clsx(
'btn btn-ghost text-base-content h-8 min-h-8',
'btn btn-ghost text-base-content btn-sm',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => setActivePanel(tab)}
@@ -0,0 +1,161 @@
import { useState } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { CustomTheme } from '@/styles/themes';
import { md5Fingerprint } from '@/utils/md5';
import { CUSTOM_THEME_TEMPLATES } from '@/services/constants';
import { useSettingsStore } from '@/store/settingsStore';
import clsx from 'clsx';
import ColorInput from './ColorInput';
type ThemeEditorProps = {
customTheme: CustomTheme | null;
onSave: (customTheme: CustomTheme) => void;
onDelete: (customTheme: CustomTheme) => void;
onCancel: () => void;
};
const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete, onCancel }) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const template =
CUSTOM_THEME_TEMPLATES[Math.floor(Math.random() * CUSTOM_THEME_TEMPLATES.length)]!;
const [lightTextColor, setLightTextColor] = useState(
customTheme?.colors.light.fg || template.light.fg,
);
const [lightBackgroundColor, setLightBackgroundColor] = useState(
customTheme?.colors.light.bg || template.light.bg,
);
const [darkTextColor, setDarkTextColor] = useState(
customTheme?.colors.dark.fg || template.dark.fg,
);
const [darkBackgroundColor, setDarkBackgroundColor] = useState(
customTheme?.colors.dark.bg || template.dark.bg,
);
const [themeName, setThemeName] = useState(customTheme?.label || _('Custom'));
const ThemePreview: React.FC<{
textColor: string;
backgroundColor: string;
label: string;
}> = ({ textColor, backgroundColor, label }) => (
<div className='mb-2 mt-4'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div
className='border-base-300 overflow-hidden rounded border p-3'
style={{
backgroundColor: backgroundColor,
color: textColor,
}}
>
<p className='mb-2 whitespace-pre-line text-sm'>
{_(
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare",
)}
</p>
</div>
</div>
);
const getCustomTheme = () => {
return {
name: md5Fingerprint(themeName),
label: themeName,
colors: {
light: {
fg: lightTextColor,
bg: lightBackgroundColor,
primary: '#3b82f6',
},
dark: {
fg: darkTextColor,
bg: darkBackgroundColor,
primary: '#60a5fa',
},
},
};
};
return (
<div className='mt-6 rounded-lg'>
<div className='mb-4'>
<div className='mb-4 flex items-center justify-between'>
<label className='font-medium'>{_('Custom Theme')}</label>
<div className='flex w-[calc(50%-12px)] justify-between'>
<button
className='btn btn-ghost btn-sm text-base-content px-2'
onClick={() => onSave(getCustomTheme())}
>
{_('Save')}
</button>
<button
className={clsx(
'btn btn-ghost btn-sm px-2',
!settings.globalReadSettings.customThemes.find(
(theme) => theme.name === md5Fingerprint(themeName),
) && 'btn-disabled',
)}
onClick={() => onDelete(getCustomTheme())}
>
{_('Delete')}
</button>
<button className='btn btn-ghost btn-sm px-2' onClick={onCancel}>
{_('Cancel')}
</button>
</div>
</div>
<div className='mb-4 flex items-center justify-between'>
<label className='font-medium'>{_('Theme Name')}</label>
<input
type='text'
value={themeName}
onChange={(e) => setThemeName(e.target.value)}
className='bg-base-100 text-base-content border-base-200 w-[calc(50%-12px)] rounded border p-2 text-sm'
/>
</div>
</div>
<div className='grid grid-cols-2 gap-6'>
<div className='bg-base-200 rounded-lg p-3'>
<h3 className='mb-3 text-center font-medium'>{_('Light Mode')}</h3>
<ColorInput label={_('Text Color')} value={lightTextColor} onChange={setLightTextColor} />
<ColorInput
label={_('Background Color')}
value={lightBackgroundColor}
onChange={setLightBackgroundColor}
/>
<ThemePreview
textColor={lightTextColor}
backgroundColor={lightBackgroundColor}
label={_('Preview')}
/>
</div>
<div className='bg-base-300 rounded-lg p-3'>
<h3 className='mb-3 text-center font-medium'>{_('Dark Mode')}</h3>
<ColorInput label={_('Text Color')} value={darkTextColor} onChange={setDarkTextColor} />
<ColorInput
label={_('Background Color')}
value={darkBackgroundColor}
onChange={setDarkBackgroundColor}
/>
<ThemePreview
textColor={darkTextColor}
backgroundColor={darkBackgroundColor}
label={_('Preview')}
/>
</div>
</div>
</div>
);
};
export default ThemeEditor;
@@ -1,7 +1,9 @@
import clsx from 'clsx';
import React from 'react';
import Image from 'next/image';
import { MdInfoOutline } from 'react-icons/md';
import { Book } from '@/types/book';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -10,6 +12,7 @@ import { formatAuthors, formatTitle } from '@/utils/book';
const BookCard = ({ book }: { book: Book }) => {
const { coverImageUrl, title, author } = book;
const _ = useTranslation();
const { isDarkMode } = useThemeStore();
const iconSize18 = useResponsiveSize(18);
const showBookDetails = () => {
@@ -23,14 +26,17 @@ const BookCard = ({ book }: { book: Book }) => {
alt={_('Book Cover')}
width={56}
height={80}
className='mr-4 aspect-auto max-h-16 w-[15%] max-w-12 rounded-sm object-cover shadow-md'
className={clsx(
'me-4 aspect-auto max-h-16 w-[15%] max-w-12 rounded-sm object-cover shadow-md',
isDarkMode ? 'mix-blend-screen' : 'mix-blend-multiply',
)}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{formatTitle(title)}</h4>
<p className='text-neutral-content truncate text-xs'>{formatAuthors(author)}</p>
<p className='truncate text-xs opacity-75'>{formatAuthors(author)}</p>
</div>
<button
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
@@ -17,7 +17,7 @@ interface BookMenuProps {
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
const _ = useTranslation();
const { library } = useLibraryStore();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
const handleParallelView = (id: string) => {
openParallelView(id);
@@ -48,26 +48,28 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
>
<MenuItem label={_('Parallel Read')} noIcon>
<ul className='max-h-60 overflow-y-auto'>
{library.slice(0, 20).map((book) => (
<MenuItem
key={book.hash}
icon={
<Image
src={book.coverImageUrl!}
alt={book.title}
width={56}
height={80}
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
}
label={book.title}
labelClass='max-w-36'
onClick={() => handleParallelView(book.hash)}
/>
))}
{getVisibleLibrary()
.slice(0, 20)
.map((book) => (
<MenuItem
key={book.hash}
icon={
<Image
src={book.coverImageUrl!}
alt={book.title}
width={56}
height={80}
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
}
label={book.title}
labelClass='max-w-36'
onClick={() => handleParallelView(book.hash)}
/>
))}
</ul>
</MenuItem>
<MenuItem label={_('Reload Page')} noIcon shortcut='Shift+R' onClick={handleReloadPage} />
@@ -81,12 +81,16 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
} as React.CSSProperties
}
>
{item.note && <span className='content font-size-sm font-normal'>{item.note}</span>}
{item.note && (
<span className='content font-size-sm font-normal' dir='auto'>
{item.note}
</span>
)}
<div className='flex items-start'>
{item.note && (
<div className='my-1 mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
<div className='my-1 me-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
)}
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'py-2')}>
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'my-2')}>
<span
className={clsx(
'inline',
@@ -116,7 +120,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
}
onClick={(e) => e.stopPropagation()}
>
<div className='flex justify-end space-x-3 p-2'>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
{item.note && (
<button
className={clsx(
@@ -5,8 +5,8 @@ import { FiSearch } from 'react-icons/fi';
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import useTrafficLight from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import Dropdown from '@/components/Dropdown';
import BookMenu from './BookMenu';
@@ -18,16 +18,18 @@ const SidebarHeader: React.FC<{
onTogglePin: () => void;
onToggleSearchBar: () => void;
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
const { isTrafficLightVisible } = useTrafficLight();
const { isTrafficLightVisible } = useTrafficLightStore();
const iconSize14 = useResponsiveSize(14);
const iconSize18 = useResponsiveSize(18);
const iconSize22 = useResponsiveSize(22);
return (
<div
className={clsx(
'sidebar-header flex h-11 items-center justify-between pr-2',
isTrafficLightVisible ? 'pl-20' : 'pl-1.5',
'sidebar-header flex h-11 items-center justify-between pe-2',
isTrafficLightVisible ? 'pl-20' : 'ps-1.5',
)}
dir='ltr'
>
<div className='flex items-center gap-x-8'>
<button
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { FaSearch, FaChevronDown } from 'react-icons/fa';
@@ -7,9 +8,9 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
const MINIMUM_SEARCH_TERM_LENGTH_DEFAULT = 2;
const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
@@ -172,7 +173,11 @@ const SearchBar: React.FC<SearchBarProps> = ({
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
<Dropdown
className='dropdown-bottom flex justify-center'
className={clsx(
window.innerWidth < 640 && 'dropdown-end',
'dropdown-bottom flex justify-center',
)}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 rounded-none rounded-r-lg'
toggleButton={<FaChevronDown size={iconSize12} className='text-gray-500' />}
>
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { BookSearchConfig } from '@/types/book';
@@ -6,6 +7,7 @@ import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
interface SearchOptionsProps {
searchConfig: BookSearchConfig;
menuClassName?: string;
onSearchConfigChanged: (searchConfig: BookSearchConfig) => void;
setIsDropdownOpen?: (isOpen: boolean) => void;
}
@@ -32,6 +34,7 @@ const Option: React.FC<OptionProps> = ({ label, isActive, onClick }) => (
const SearchOptions: React.FC<SearchOptionsProps> = ({
searchConfig,
menuClassName,
onSearchConfigChanged,
setIsDropdownOpen,
}) => {
@@ -44,7 +47,10 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
return (
<div
tabIndex={0}
className='book-menu dropdown-content dropdown-center border-base-200 z-20 mt-3 w-56 border shadow-2xl'
className={clsx(
'book-menu dropdown-content dropdown-center border-base-200 z-20 w-56 border shadow-2xl',
menuClassName,
)}
>
<Option
label={_('Book')}
@@ -8,9 +8,10 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { BookSearchResult } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useDrag } from '@/hooks/useDrag';
import { useThemeStore } from '@/store/themeStore';
import SidebarHeader from './Header';
import SidebarContent from './Content';
import BookCard from './BookCard';
@@ -28,11 +29,11 @@ const SideBar: React.FC<{
onGoToLibrary: () => void;
}> = ({ onGoToLibrary }) => {
const { appService } = useEnv();
const { updateAppTheme } = useTheme();
const { updateAppTheme } = useThemeStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { getBookData } = useBookDataStore();
const { getView } = useReaderStore();
const { getView, getViewSettings } = useReaderStore();
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
@@ -162,11 +163,13 @@ const SideBar: React.FC<{
if (!sideBarBookKey) return null;
const viewSettings = getViewSettings(sideBarBookKey);
const bookData = getBookData(sideBarBookKey);
if (!bookData || !bookData.book || !bookData.bookDoc) {
return null;
}
const { book, bookDoc } = bookData;
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
return isSideBarVisible ? (
<>
@@ -178,6 +181,7 @@ const SideBar: React.FC<{
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${sideBarWidth}`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
@@ -15,10 +15,11 @@ const TabNavigation: React.FC<{
return (
<div
className={clsx('bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t')}
dir='ltr'
>
<div
className={clsx(
'bg-base-300 absolute bottom-1.5 left-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'bg-base-300 absolute bottom-1.5 start-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'transform transition-transform duration-300',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
@@ -1,5 +1,8 @@
import { getOSPlatform } from '@/utils/misc';
// NOTE: Be careful to use this hook. When the scrollbar is set always visible in macOS,
// hide it will change the layout. Make sure to only use it in constrained widgets.
// See https://github.com/readest/readest/issues/600
export const useAutoHideScrollbar = () => {
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
const handleScrollbarAutoHide = (doc: Document) => {
@@ -5,7 +5,6 @@ import useShortcuts from '@/hooks/useShortcuts';
import useBooksManager from './useBooksManager';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { tauriQuitApp } from '@/utils/window';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
@@ -21,7 +20,6 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const { toggleNotebook } = useNotebookStore();
const { getNextBookKey } = useBooksManager();
const { themeCode } = useTheme();
const viewSettings = getViewSettings(sideBarBookKey ?? '');
const fontSize = viewSettings?.defaultFontSize ?? 16;
const lineHeight = viewSettings?.lineHeight ?? 1.6;
@@ -100,7 +98,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const zoomLevel = viewSettings!.zoomLevel + ZOOM_STEP;
viewSettings!.zoomLevel = Math.min(zoomLevel, MAX_ZOOM_LEVEL);
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const zoomOut = () => {
@@ -111,7 +109,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const zoomLevel = viewSettings!.zoomLevel - ZOOM_STEP;
viewSettings!.zoomLevel = Math.max(zoomLevel, MIN_ZOOM_LEVEL);
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const resetZoom = () => {
@@ -121,7 +119,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const viewSettings = getViewSettings(sideBarBookKey)!;
viewSettings!.zoomLevel = 100;
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
useShortcuts(
@@ -59,6 +59,7 @@ export const useClickEvent = (
}
}
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
// The wheel event is handled by the iframe itself in scrolled mode.
const { deltaY } = msg.data;
if (deltaY > 0) {
viewRef.current?.next(1);
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useSync } from '@/hooks/useSync';
import { BookNote } from '@/types/book';
import { useBookDataStore } from '@/store/bookDataStore';
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
@@ -12,12 +13,6 @@ export const useNotesSync = (bookKey: string) => {
const config = getConfig(bookKey);
const bookHash = bookKey.split('-')[0]!;
useEffect(() => {
if (!user) return;
syncNotes([], bookHash, 'pull');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const lastSyncTime = useRef<number>(0);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -57,15 +52,27 @@ export const useNotesSync = (bookKey: string) => {
}, [config]);
useEffect(() => {
if (syncedNotes?.length && config?.location) {
const processNewNote = (note: BookNote) => {
const oldNotes = config?.booknotes ?? [];
const existingNote = oldNotes.find((oldNote) => oldNote.id === note.id);
if (existingNote) {
if (existingNote.updatedAt < note.updatedAt) {
return { ...existingNote, ...note };
} else {
return { ...note, ...existingNote };
}
}
return note;
};
if (syncedNotes?.length && config) {
const newNotes = syncedNotes.filter((note) => note.bookHash === bookHash);
if (!newNotes.length) return;
const oldNotes = config.booknotes ?? [];
const mergedNotes = [
...oldNotes.filter((oldNote) => !newNotes.some((newNote) => newNote.id === oldNote.id)),
...newNotes,
...newNotes.map(processNewNote),
];
setConfig(bookKey, { ...config, booknotes: mergedNotes });
setConfig(bookKey, { booknotes: mergedNotes });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncedNotes]);
@@ -6,7 +6,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { throttle } from '@/utils/throttle';
export const useProgressAutoSave = (bookKey: string) => {
const { envConfig, appService } = useEnv();
const { envConfig } = useEnv();
const { getConfig, saveConfig } = useBookDataStore();
const { getProgress } = useReaderStore();
const progress = getProgress(bookKey);
@@ -22,9 +22,6 @@ export const useProgressAutoSave = (bookKey: string) => {
);
useEffect(() => {
// FIXME: On Android and iOS we need a better way to be notified
// when the app is about to go in background
if (!appService?.isMobile || !progress) return;
saveBookConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress, bookKey]);
@@ -6,7 +6,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { serializeConfig } from '@/utils/serializer';
import { CFI } from '@/libs/document';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
@@ -106,14 +106,9 @@ export const useProgressSync = (bookKey: string) => {
configSynced.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const newConfig = deserializeConfig(
JSON.stringify(syncedConfig),
settings.globalViewSettings,
DEFAULT_BOOK_SEARCH_CONFIG,
);
setConfig(bookKey, { ...config, ...newConfig });
const syncedCFI = syncedConfig.location;
const configCFI = config?.location;
const syncedCFI = syncedConfig.location;
setConfig(bookKey, syncedConfig);
if (syncedCFI && configCFI) {
if (CFI.compare(configCFI, syncedCFI) < 0) {
if (view) {
+2 -2
View File
@@ -1,10 +1,10 @@
'use client';
import { useEffect } from 'react';
import { useTheme } from '@/hooks/useTheme';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { useTranslation } from '@/hooks/useTranslation';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useSettingsStore } from '@/store/settingsStore';
import Reader from './components/Reader';
@@ -12,7 +12,7 @@ export default function Page() {
const _ = useTranslation();
const { settings } = useSettingsStore();
useTheme();
useOpenWithBooks();
useEffect(() => {
const doCheckAppUpdates = async () => {
@@ -20,7 +20,7 @@ export const AboutWindow = () => {
const [isUpdated, setIsUpdated] = React.useState(false);
const handleCheckUpdate = async () => {
const update = await checkForAppUpdates(_);
const update = await checkForAppUpdates(_, false);
if (!update) {
setIsUpdated(true);
}
+5 -2
View File
@@ -110,14 +110,17 @@ const Dialog: React.FC<DialogProps> = ({
<div
className={clsx(
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
'h-full max-h-full w-full max-w-full sm:w-[65%] sm:max-w-[600px]',
'h-full max-h-full w-full max-w-full',
window.innerWidth < window.innerHeight
? 'sm:h-[50%] sm:w-3/4'
: 'sm:h-[65%] sm:w-1/2 sm:max-w-[600px]',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
boxClassName,
)}
>
{window.innerWidth < 640 && (
<div
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
className='drag-handle flex h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center'
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
>
@@ -0,0 +1,20 @@
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { HiArrowDownTray } from 'react-icons/hi2';
const DropIndicator: React.FC = () => {
const _ = useTranslation();
return (
<>
<div className='drag-overlay'></div>
<div className='drop-indicator'>
<div className='flex flex-col items-center justify-center'>
<HiArrowDownTray className='h-12 w-12' />
<p className='mt-2 font-medium'>{_('Drop to Import Books')}</p>
</div>
</div>
</>
);
};
export default DropIndicator;
@@ -3,6 +3,7 @@
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
import { User } from '@supabase/supabase-js';
import { supabase } from '@/utils/supabase';
import posthog from 'posthog-js';
interface AuthContextType {
token: string | null;
@@ -38,6 +39,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
localStorage.setItem('token', access_token);
localStorage.setItem('refresh_token', refresh_token);
localStorage.setItem('user', JSON.stringify(user));
posthog.identify(user.id);
setToken(access_token);
setUser(user);
} else {
+3 -2
View File
@@ -6,10 +6,11 @@ import { TranslationFunc } from '@/hooks/useTranslation';
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
export const checkForAppUpdates = async (_: TranslationFunc) => {
export const checkForAppUpdates = async (_: TranslationFunc, autoCheck = true) => {
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
const now = Date.now();
if (lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000) return;
if (autoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000)
return;
localStorage.setItem(LAST_CHECK_KEY, now.toString());
console.log('Checking for updates');
@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { isTauriAppPlatform } from '@/services/environment';
import { useLibraryStore } from '@/store/libraryStore';
import { navigateToLibrary } from '@/utils/nav';
export function useOpenWithBooks() {
const router = useRouter();
const { setCheckOpenWithBooks } = useLibraryStore();
const listenedOpenWithBooks = useRef(false);
const handleOpenWithFileUrl = (url: string) => {
let filePath = url;
if (filePath.startsWith('file://')) {
filePath = decodeURI(filePath.replace('file://', ''));
}
if (filePath.startsWith('/')) {
window.OPEN_WITH_FILES = [filePath];
setCheckOpenWithBooks(true);
navigateToLibrary(router, `reload=${Date.now()}`);
}
};
useEffect(() => {
if (!isTauriAppPlatform()) return;
if (listenedOpenWithBooks.current) return;
listenedOpenWithBooks.current = true;
const listenOpenWithFiles = async () => {
return await onOpenUrl((urls) => {
urls.forEach((url) => {
handleOpenWithFileUrl(url);
});
});
};
const unlisten = listenOpenWithFiles();
return () => {
unlisten.then((f) => f());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
@@ -1,7 +1,10 @@
import { useEffect, useRef } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
export const useScreenWakeLock = (lock: boolean) => {
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
const unlistenOnFocusChangedRef = useRef<Promise<() => void> | null>(null);
useEffect(() => {
const requestWakeLock = async () => {
@@ -20,20 +23,50 @@ export const useScreenWakeLock = (lock: boolean) => {
}
};
if (lock) {
requestWakeLock();
} else if (wakeLockRef.current) {
wakeLockRef.current.release();
wakeLockRef.current = null;
console.log('Wake lock released');
}
return () => {
const releaseWakeLock = () => {
if (wakeLockRef.current) {
wakeLockRef.current.release();
wakeLockRef.current = null;
console.log('Wake lock released');
}
};
const handleVisibilityChange = () => {
if (document.hidden) {
releaseWakeLock();
} else {
requestWakeLock();
}
};
if (lock) {
requestWakeLock();
} else if (wakeLockRef.current) {
releaseWakeLock();
}
if (isWebAppPlatform() && lock) {
document.addEventListener('visibilitychange', handleVisibilityChange);
} else if (isTauriAppPlatform() && lock) {
unlistenOnFocusChangedRef.current = getCurrentWindow().onFocusChanged(
({ payload: focused }) => {
if (focused) {
requestWakeLock();
} else {
releaseWakeLock();
}
},
);
}
return () => {
releaseWakeLock();
if (isWebAppPlatform() && lock) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (unlistenOnFocusChangedRef.current) {
unlistenOnFocusChangedRef.current.then((f) => f());
}
};
}, [lock]);
};
+15 -103
View File
@@ -1,109 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
import { ThemeCode } from '@/utils/style';
import { themes, Palette } from '@/styles/themes';
import { isWebAppPlatform } from '@/services/environment';
export type ThemeMode = 'auto' | 'light' | 'dark';
import { useEffect } from 'react';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme } from '@/styles/themes';
export const useTheme = () => {
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
if (typeof window !== 'undefined' && localStorage) {
return (localStorage.getItem('themeMode') as ThemeMode) || 'auto';
}
return 'auto';
});
const [themeColor, setThemeColor] = useState(() => {
if (typeof window !== 'undefined' && localStorage) {
return localStorage.getItem('themeColor') || 'default';
}
return 'default';
});
const [systemIsDarkMode, setSystemIsDarkMode] = useState(() => {
return (
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
);
});
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
const [themeCode, setThemeCode] = useState<ThemeCode>(() => {
let themeMode = 'auto';
let themeColor = 'default';
let systemIsDarkMode = false;
if (typeof window !== 'undefined') {
themeColor = localStorage.getItem('themeColor') || 'default';
themeMode = localStorage.getItem('themeMode') as ThemeMode;
systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
const defaultTheme = themes.find((theme) => theme.name === themeColor);
const defaultPalette = isDarkMode ? defaultTheme!.colors.dark : defaultTheme!.colors.light;
return {
bg: defaultPalette['base-100'],
fg: defaultPalette['base-content'],
primary: defaultPalette.primary,
palette: defaultPalette,
};
});
const { settings } = useSettingsStore();
const { themeColor, isDarkMode } = useThemeStore();
useEffect(() => {
const customThemes = settings.globalReadSettings?.customThemes ?? [];
customThemes.forEach((customTheme) => {
applyCustomTheme(customTheme);
});
}, [settings]);
useEffect(() => {
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
setSystemIsDarkMode(mediaQuery.matches);
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange);
}
return undefined;
}, []);
useEffect(() => {
document.documentElement.setAttribute(
'data-theme',
`${themeColor}-${isDarkMode ? 'dark' : 'light'}`,
);
const colorScheme = isDarkMode ? 'dark' : 'light';
document.documentElement.style.setProperty('color-scheme', colorScheme);
document.documentElement.setAttribute('data-theme', `${themeColor}-${colorScheme}`);
}, [themeColor, isDarkMode]);
useEffect(() => {
if (typeof window !== 'undefined' && localStorage) {
localStorage.setItem('themeMode', themeMode);
localStorage.setItem('themeColor', themeColor);
}
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
const theme = themes.find((t) => t.name === themeColor);
const palette = isDarkMode ? theme!.colors.dark : theme!.colors.light;
const bg = palette['base-100'];
const fg = palette['base-content'];
const primary = palette.primary;
if (isWebAppPlatform()) {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg);
}
setThemeCode({ bg, fg, primary, palette });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeMode, themeColor, isDarkMode]);
const updateThemeMode = (mode: ThemeMode) => setThemeMode(mode);
const updateThemeColor = (color: string) => setThemeColor(color);
const updateAppTheme = (color: keyof Palette) => {
if (isWebAppPlatform()) {
const { palette } = themeCode;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', palette[color]);
}
};
return {
themeMode,
themeColor,
themeCode,
isDarkMode,
updateThemeMode,
updateThemeColor,
updateAppTheme,
};
};
@@ -1,47 +0,0 @@
import { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
/**
* Custom hook to get the visibility of the traffic light (window control buttons on macOS)
* based on the fullscreen state of the application window.
*
* @returns {Object} An object containing:
* - `isTrafficLightVisible` (boolean): A state indicating whether the traffic light is visible.
*/
const useTrafficLight = () => {
const { appService } = useEnv();
const [isTrafficLightVisible, setVisible] = useState(appService?.hasTrafficLight ?? false);
let unlistenEnterFullScreen: () => void;
let unlistenExitFullScreen: () => void;
const handleSwitchFullScreen = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
const isFullscreen = await currentWindow.isFullscreen();
if (appService?.hasTrafficLight) setVisible(!isFullscreen);
unlistenEnterFullScreen = await currentWindow.listen('will-enter-fullscreen', () => {
if (appService?.hasTrafficLight) setVisible(false);
});
unlistenExitFullScreen = await currentWindow.listen('will-exit-fullscreen', () => {
if (appService?.hasTrafficLight) setVisible(true);
});
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
handleSwitchFullScreen();
return () => {
unlistenEnterFullScreen?.();
unlistenExitFullScreen?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { isTrafficLightVisible };
};
export default useTrafficLight;
+26
View File
@@ -0,0 +1,26 @@
import { ViewSettings } from '@/types/book';
import { useEffect, useState } from 'react';
// This hook allows you to inject custom CSS into the reader UI.
// Note that the book content is rendered in an iframe, so UI CSS won't affect book rendering.
export const useUICSS = (bookKey: string, viewSettings: ViewSettings) => {
const [styleElement, setStyleElement] = useState<HTMLStyleElement | null>(null);
useEffect(() => {
if (!viewSettings) return;
if (styleElement) {
styleElement.remove();
}
const rawCSS = viewSettings.userStylesheet;
const newStyleEl = document.createElement('style');
newStyleEl.textContent = rawCSS.replace('foliate-view', `#foliate-view-${bookKey}`);
document.head.appendChild(newStyleEl);
setStyleElement(newStyleEl);
return () => {
newStyleEl.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewSettings]);
};
@@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { supabase } from '@/utils/supabase';
import { getUserPlan } from '@/utils/access';
import { query as deeplQuery } from '@/utils/deepl';
const DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
@@ -27,8 +28,9 @@ const getDeepLAPIKey = (keys: string | undefined) => {
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { user, token } = await getUserAndToken(req.headers['authorization']);
let deeplApiUrl = DEEPL_FREE_API;
let userPlan = 'free';
if (user && token) {
const userPlan = getUserPlan(token);
userPlan = getUserPlan(token);
if (userPlan !== 'free') deeplApiUrl = DEEPL_PRO_API;
}
const deeplAuthKey =
@@ -38,17 +40,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await runMiddleware(req, res, corsAllMethods);
const {
text,
source_lang: sourceLang = 'auto',
target_lang: targetLang = 'en',
}: { text: string[]; source_lang: string; target_lang: string } = req.body;
try {
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'Content-Type': 'application/json',
},
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
});
res.status(response.status);
res.json(await response.json());
if (targetLang.toLowerCase().includes('zh')) {
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'Content-Type': 'application/json',
},
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
});
res.status(response.status);
res.json(await response.json());
} else {
const result = await deeplQuery({
text: text[0] ?? '',
sourceLang,
targetLang,
});
res.status(200).json(result);
}
} catch (error) {
console.error('Error proxying DeepL request:', error);
res.status(500).json({ error: 'Internal Server Error' });
+20 -15
View File
@@ -30,19 +30,26 @@ const getUserAndToken = async (req: NextRequest) => {
if (!authHeader) return {};
const token = authHeader.replace('Bearer ', '');
const {
data: { user },
error,
} = await supabase.auth.getUser(token);
if (error || !user) return {};
return { user, token };
try {
const {
data: { user },
error,
} = await supabase.auth.getUser(token);
if (error?.message === 'fetch failed') {
return { error: 'Network error' };
} else if (error || !user) {
return { error: 'Not authenticated' };
}
return { user, token };
} catch {
return { error: 'Network error' };
}
};
export async function GET(req: NextRequest) {
const { user, token } = await getUserAndToken(req);
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const { user, token, error } = await getUserAndToken(req);
if (!user || !token || error) {
return NextResponse.json({ error: error || 'Unknown error' }, { status: 401 });
}
const supabase = createSupabaseClient(token);
@@ -114,9 +121,9 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const { user, token } = await getUserAndToken(req);
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const { user, token, error } = await getUserAndToken(req);
if (!user || !token || error) {
return NextResponse.json({ error: error || 'Unknown error' }, { status: 401 });
}
const supabase = createSupabaseClient(token);
@@ -173,8 +180,6 @@ export async function POST(req: NextRequest) {
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
if (clientIsNewer) {
// use server updated_at for updated records
dbRec.updated_at = new Date().toISOString();
const { data: updated, error: updateError } = await supabase
.from(table)
.update(dbRec)
@@ -53,6 +53,7 @@ export abstract class BaseAppService implements AppService {
abstract hasRoundedWindow: boolean;
abstract hasSafeAreaInset: boolean;
abstract hasHaptics: boolean;
abstract hasSysFontsList: boolean;
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
abstract getCoverImageUrl(book: Book): string;
+62 -4
View File
@@ -10,6 +10,7 @@ import {
} from '@/types/book';
import { ReadSettings, SystemSettings } from '@/types/settings';
import { UserStorageQuota } from '@/types/user';
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
@@ -38,6 +39,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
autohideCursor: true,
translateTargetLang: 'EN',
customThemes: [],
highlightStyle: 'highlight',
highlightStyles: {
highlight: 'yellow',
@@ -61,12 +63,16 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
gapPercent: 5,
scrolled: false,
disableClick: false,
continuousScroll: false,
maxColumnCount: 2,
maxInlineSize: 720,
maxBlockSize: 1440,
maxInlineSize: getDefaultMaxInlineSize(),
maxBlockSize: getDefaultMaxBlockSize(),
animated: false,
writingMode: 'auto',
vertical: false,
rtl: false,
doubleBorder: true,
borderColor: 'red',
};
export const DEFAULT_BOOK_STYLE: BookStyle = {
@@ -381,8 +387,6 @@ export const ANDROID_FONTS = [
'XiHeiti',
];
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
export const BOOK_IDS_SEPARATOR = '+';
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
@@ -415,3 +419,57 @@ export const HIGHLIGHT_COLOR_HEX: Record<HighlightColor, string> = {
blue: '#60a5fa', // blue-400
violet: '#a78bfa', // violet-400
};
export const CUSTOM_THEME_TEMPLATES = [
{
light: {
fg: '#2b2b2b',
bg: '#f3f3f3',
primary: '#3c5a72',
},
dark: {
fg: '#d0d0d0',
bg: '#1a1c1f',
primary: '#486e8a',
},
},
{
light: {
fg: '#3f2f3c',
bg: '#f5ecf8',
primary: '#7b5291',
},
dark: {
fg: '#d6cadd',
bg: '#3a2c3d',
primary: '#bda0cc',
},
},
{
light: {
fg: '#2b2b2b',
bg: '#defcd9',
primary: '#00796b',
},
dark: {
fg: '#c8e6c9',
bg: '#273c33',
primary: '#26a69a',
},
},
];
export const MIGHT_BE_RTL_LANGS = [
'zh',
'ja',
'ko',
'ar',
'he',
'fa',
'ur',
'dv',
'ps',
'sd',
'yi',
'',
];
@@ -127,6 +127,7 @@ export class NativeAppService extends BaseAppService {
hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
hasSysFontsList = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
@@ -189,6 +189,7 @@ export class WebAppService extends BaseAppService {
hasRoundedWindow = false;
hasSafeAreaInset = isPWA();
hasHaptics = false;
hasSysFontsList = false;
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
+4 -2
View File
@@ -17,7 +17,7 @@ interface BookData {
interface BookDataState {
booksData: { [id: string]: BookData };
getConfig: (key: string | null) => BookConfig | null;
setConfig: (key: string, config: BookConfig) => void;
setConfig: (key: string, partialConfig: Partial<BookConfig>) => void;
saveConfig: (
envConfig: EnvConfigType,
bookKey: string,
@@ -39,9 +39,11 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
const id = key.split('-')[0]!;
return get().booksData[id]?.config || null;
},
setConfig: (key: string, config: BookConfig) => {
setConfig: (key: string, partialConfig: Partial<BookConfig>) => {
set((state: BookDataState) => {
const id = key.split('-')[0]!;
const config = (state.booksData[id]?.config || {}) as BookConfig;
Object.assign(config, partialConfig);
return {
booksData: {
...state.booksData,
+5 -3
View File
@@ -3,9 +3,10 @@ import { Book } from '@/types/book';
import { EnvConfigType } from '@/services/environment';
interface LibraryState {
library: Book[];
library: Book[]; // might contain deleted books
checkOpenWithBooks: boolean;
clearOpenWithBooks: () => void;
getVisibleLibrary: () => Book[];
setCheckOpenWithBooks: (check: boolean) => void;
setLibrary: (books: Book[]) => void;
updateBook: (envConfig: EnvConfigType, book: Book) => void;
}
@@ -13,7 +14,8 @@ interface LibraryState {
export const useLibraryStore = create<LibraryState>((set, get) => ({
library: [],
checkOpenWithBooks: true,
clearOpenWithBooks: () => set({ checkOpenWithBooks: false }),
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
setCheckOpenWithBooks: (check) => set({ checkOpenWithBooks: check }),
setLibrary: (books) => set({ library: books }),
updateBook: async (envConfig: EnvConfigType, book: Book) => {
const appService = await envConfig.getAppService();
+113
View File
@@ -0,0 +1,113 @@
import { create } from 'zustand';
import { getThemeCode, ThemeCode } from '@/utils/style';
import { CustomTheme, Palette, ThemeMode } from '@/styles/themes';
import { EnvConfigType, isWebAppPlatform } from '@/services/environment';
import { SystemSettings } from '@/types/settings';
interface ThemeState {
themeMode: ThemeMode;
themeColor: string;
systemIsDarkMode: boolean;
themeCode: ThemeCode;
isDarkMode: boolean;
setThemeMode: (mode: ThemeMode) => void;
setThemeColor: (color: string) => void;
updateAppTheme: (color: keyof Palette) => void;
saveCustomTheme: (
envConfig: EnvConfigType,
settings: SystemSettings,
theme: CustomTheme,
isDelete?: boolean,
) => void;
}
const getInitialThemeMode = (): ThemeMode => {
if (typeof window !== 'undefined' && localStorage) {
return (localStorage.getItem('themeMode') as ThemeMode) || 'auto';
}
return 'auto';
};
const getInitialThemeColor = (): string => {
if (typeof window !== 'undefined' && localStorage) {
return localStorage.getItem('themeColor') || 'default';
}
return 'default';
};
export const useThemeStore = create<ThemeState>((set, get) => {
const initialThemeMode = getInitialThemeMode();
const initialThemeColor = getInitialThemeColor();
const systemIsDarkMode =
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDarkMode =
initialThemeMode === 'dark' || (initialThemeMode === 'auto' && systemIsDarkMode);
const themeCode = getThemeCode();
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
const mode = get().themeMode;
const isDarkMode = mode === 'dark' || (mode === 'auto' && mediaQuery.matches);
set({ systemIsDarkMode: mediaQuery.matches, isDarkMode });
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
}
return {
themeMode: initialThemeMode,
themeColor: initialThemeColor,
systemIsDarkMode,
isDarkMode,
themeCode,
setThemeMode: (mode) => {
if (typeof window !== 'undefined' && localStorage) {
localStorage.setItem('themeMode', mode);
}
const isDarkMode = mode === 'dark' || (mode === 'auto' && get().systemIsDarkMode);
document.documentElement.setAttribute(
'data-theme',
`${get().themeColor}-${isDarkMode ? 'dark' : 'light'}`,
);
set({ themeMode: mode, isDarkMode });
set({ themeCode: getThemeCode() });
},
setThemeColor: (color) => {
if (typeof window !== 'undefined' && localStorage) {
localStorage.setItem('themeColor', color);
}
document.documentElement.setAttribute(
'data-theme',
`${color}-${get().isDarkMode ? 'dark' : 'light'}`,
);
set({ themeColor: color });
set({ themeCode: getThemeCode() });
},
updateAppTheme: (color) => {
if (isWebAppPlatform()) {
const { palette } = get().themeCode;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', palette[color]);
}
},
saveCustomTheme: async (envConfig, settings, theme, isDelete) => {
const customThemes = settings.globalReadSettings.customThemes || [];
const index = customThemes.findIndex((t) => t.name === theme.name);
if (isDelete) {
if (index > -1) {
customThemes.splice(index, 1);
}
} else {
if (index > -1) {
customThemes[index] = theme;
} else {
customThemes.push(theme);
}
}
settings.globalReadSettings.customThemes = customThemes;
localStorage.setItem('customThemes', JSON.stringify(customThemes));
const appService = await envConfig.getAppService();
await appService.saveSettings(settings);
},
};
});
@@ -0,0 +1,69 @@
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { AppService } from '@/types/system';
const WINDOW_CONTROL_PAD_X = 10.0;
const WINDOW_CONTROL_PAD_Y = 22.0;
interface TrafficLightState {
appService?: AppService;
isTrafficLightVisible: boolean;
shouldShowTrafficLight: boolean;
initializeTrafficLightStore: (appService: AppService) => void;
setTrafficLightVisibility: (visible: boolean) => void;
initializeTrafficLightListeners: () => Promise<void>;
cleanupTrafficLightListeners: () => void;
unlistenEnterFullScreen?: () => void;
unlistenExitFullScreen?: () => void;
}
export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
return {
appService: undefined,
isTrafficLightVisible: false,
shouldShowTrafficLight: false,
initializeTrafficLightStore: (appService: AppService) => {
set({
appService,
isTrafficLightVisible: appService.hasTrafficLight,
shouldShowTrafficLight: appService.hasTrafficLight,
});
},
setTrafficLightVisibility: async (visible: boolean) => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
const isFullscreen = await currentWindow.isFullscreen();
set({ isTrafficLightVisible: !isFullscreen && visible, shouldShowTrafficLight: visible });
invoke('set_traffic_lights', {
visible: visible,
x: WINDOW_CONTROL_PAD_X,
y: WINDOW_CONTROL_PAD_Y,
});
},
initializeTrafficLightListeners: async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
const unlistenEnterFullScreen = await currentWindow.listen('will-enter-fullscreen', () => {
set({ isTrafficLightVisible: false });
});
const unlistenExitFullScreen = await currentWindow.listen('will-exit-fullscreen', () => {
const { shouldShowTrafficLight } = get();
set({ isTrafficLightVisible: shouldShowTrafficLight });
});
set({ unlistenEnterFullScreen, unlistenExitFullScreen });
},
cleanupTrafficLightListeners: () => {
const { unlistenEnterFullScreen, unlistenExitFullScreen } = get();
if (unlistenEnterFullScreen) unlistenEnterFullScreen();
if (unlistenExitFullScreen) unlistenExitFullScreen();
set({ unlistenEnterFullScreen: undefined, unlistenExitFullScreen: undefined });
},
};
});
+67
View File
@@ -7,6 +7,20 @@
--foreground: #171717;
border-radius: 10px;
scrollbar-gutter: auto !important;
overscroll-behavior: none;
}
/* Fixes the scrollbar gutter issue for DaisyUI */
/* https://github.com/saadeghi/daisyui/issues/3040#issuecomment-2250530354 */
:root:has(
:is(
.modal-open,
.modal:target,
.modal-toggle:checked + .modal,
.modal[open]
)
) {
scrollbar-gutter: unset !important;
}
@media (prefers-color-scheme: dark) {
@@ -35,6 +49,7 @@ body {
sans-serif;
border-radius: 10px;
background-color: transparent;
cursor: default;
}
@layer utilities {
@@ -262,3 +277,55 @@ foliate-view {
font-size: 0.75em !important;
line-height: 1em !important;
}
.writing-vertical-rl {
writing-mode: vertical-rl;
}
.direction-rtl {
direction: rtl;
}
.drop-zone {
position: relative;
}
.drag-over {
border: 2px dashed #4a90e2 !important;
background-color: rgba(74, 144, 226, 0.1) !important;
transition: all 0.2s ease-in-out;
}
.drag-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 20;
pointer-events: none;
}
.drop-zone.drag-over .drag-overlay {
display: block;
}
.drop-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 50;
padding: 20px;
background-color: theme('colors.base-300');
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
pointer-events: none;
}
.drop-zone.drag-over .drop-indicator {
display: block;
}
+111 -4
View File
@@ -1,12 +1,15 @@
import tinycolor from 'tinycolor2';
import { stubTranslation as _ } from '../utils/misc';
import { getContrastOklch, hexToOklch } from '../utils/color';
type BaseColor = {
export type BaseColor = {
bg: string;
fg: string;
primary: string;
};
export type ThemeMode = 'auto' | 'light' | 'dark';
export type Palette = {
'base-100': string;
'base-200': string;
@@ -19,7 +22,26 @@ export type Palette = {
accent: string;
};
const generateLightPalette = ({ bg, fg, primary }: BaseColor) => {
export type Theme = {
name: string;
label: string;
colors: {
light: Palette;
dark: Palette;
};
isCustomizale?: boolean;
};
export type CustomTheme = {
name: string;
label: string;
colors: {
light: BaseColor;
dark: BaseColor;
};
};
export const generateLightPalette = ({ bg, fg, primary }: BaseColor) => {
return {
'base-100': bg, // Main background
'base-200': tinycolor(bg).darken(5).toHexString(), // Slightly darker
@@ -33,7 +55,7 @@ const generateLightPalette = ({ bg, fg, primary }: BaseColor) => {
} as Palette;
};
const generateDarkPalette = ({ bg, fg, primary }: BaseColor) => {
export const generateDarkPalette = ({ bg, fg, primary }: BaseColor) => {
return {
'base-100': bg, // Main background
'base-200': tinycolor(bg).lighten(5).toHexString(), // Slightly lighter
@@ -120,4 +142,89 @@ export const themes = [
dark: generateDarkPalette({ fg: '#d8dee9', bg: '#2e3440', primary: '#88c0d0' }),
},
},
];
{
name: 'contrast',
label: _('Contrast'),
colors: {
light: generateLightPalette({ fg: '#000000', bg: '#ffffff', primary: '#4488cc' }),
dark: generateDarkPalette({ fg: '#ffffff', bg: '#000000', primary: '#88ccee' }),
},
},
{
name: 'sunset',
label: _('Sunset'),
colors: {
light: generateLightPalette({ fg: '#423126', bg: '#fff7f0', primary: '#fe6b64' }),
dark: generateDarkPalette({ fg: '#f6e1d7', bg: '#3c2b25', primary: '#ff9c94' }),
},
},
] as Theme[];
const generateCustomThemeVariables = (palette: Palette): string => {
return `
--b1: ${hexToOklch(palette['base-100'])};
--b2: ${hexToOklch(palette['base-200'])};
--b3: ${hexToOklch(palette['base-300'])};
--bc: ${hexToOklch(palette['base-content'])};
--p: ${hexToOklch(palette.primary)};
--pc: ${getContrastOklch(palette.primary)};
--s: ${hexToOklch(palette.secondary)};
--sc: ${getContrastOklch(palette.secondary)};
--a: ${hexToOklch(palette.accent)};
--ac: ${getContrastOklch(palette.accent)};
--n: ${hexToOklch(palette.neutral)};
--nc: ${hexToOklch(palette['neutral-content'])};
--in: 69.37% 0.047 231;
--inc: 100% 0 0;
--su: 78.15% 0.12 160;
--suc: 100% 0 0;
--wa: 90.69% 0.123 84;
--wac: 0% 0 0;
--er: 70.9% 0.184 22;
--erc: 100% 0 0;
`;
};
export const applyCustomTheme = (customTheme: CustomTheme) => {
const lightPalette = generateLightPalette(customTheme.colors.light);
const darkPalette = generateDarkPalette(customTheme.colors.dark);
const lightThemeName = `${customTheme.name}-light`;
const darkThemeName = `${customTheme.name}-dark`;
const css = `
[data-theme="${lightThemeName}"] {
${generateCustomThemeVariables(lightPalette)}
}
[data-theme="${darkThemeName}"] {
${generateCustomThemeVariables(darkPalette)}
}
:root {
--${lightThemeName}: 1;
--${darkThemeName}: 1;
}
`;
const styleElement = document.createElement('style');
styleElement.id = `theme-${lightThemeName}-styles`;
styleElement.textContent = css;
const existingStyle = document.getElementById(styleElement.id);
if (existingStyle) {
existingStyle.remove();
}
document.head.appendChild(styleElement);
return {
light: lightThemeName,
dark: darkThemeName,
};
};
+4
View File
@@ -60,12 +60,16 @@ export interface BookLayout {
gapPercent: number;
scrolled: boolean;
disableClick: boolean;
continuousScroll: boolean;
maxColumnCount: number;
maxInlineSize: number;
maxBlockSize: number;
animated: boolean;
writingMode: WritingMode;
vertical: boolean;
rtl: boolean;
doubleBorder: boolean;
borderColor: string;
}
export interface BookStyle {
+2
View File
@@ -1,3 +1,4 @@
import { CustomTheme } from '@/styles/themes';
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
export type ThemeType = 'light' | 'dark' | 'auto';
@@ -12,6 +13,7 @@ export interface ReadSettings {
highlightStyle: HighlightStyle;
highlightStyles: Record<HighlightStyle, HighlightColor>;
customThemes: CustomTheme[];
}
export interface SystemSettings {
+1
View File
@@ -30,6 +30,7 @@ export interface AppService {
hasRoundedWindow: boolean;
hasSafeAreaInset: boolean;
hasHaptics: boolean;
hasSysFontsList: boolean;
isMobile: boolean;
isAppDataSandbox: boolean;
isAndroidApp: boolean;
+15 -3
View File
@@ -57,9 +57,9 @@ export const listFormater = (narrow = false, lang = userLang) => {
export const getBookLangCode = (lang: string | string[] | undefined) => {
try {
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
return bookLang ? bookLang.split('-')[0]! : 'en';
return bookLang ? bookLang.split('-')[0]! : '';
} catch {
return 'en';
return '';
}
};
@@ -67,7 +67,7 @@ export const formatAuthors = (
contributors: string | Contributor | [string | Contributor],
bookLang?: string | string[],
) => {
const langCode = getBookLangCode(bookLang);
const langCode = getBookLangCode(bookLang) || 'en';
return Array.isArray(contributors)
? listFormater(langCode === 'zh', langCode).format(
contributors.map((contributor) =>
@@ -90,6 +90,10 @@ export const formatLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang.join(', ') : lang;
};
export const primaryLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang[0] : lang;
};
export const formatDate = (date: string | number | Date | undefined) => {
if (!date) return;
try {
@@ -131,3 +135,11 @@ export const getBookDirFromWritingMode = (writingMode: WritingMode) => {
return 'auto';
}
};
export const getBookDirFromLanguage = (language: string | string[] | undefined) => {
const lang = primaryLanguage(language);
if (!lang) return 'auto';
const rtlLanguages = new Set(['ar', 'he', 'fa', 'ur', 'dv', 'ps', 'sd', 'yi']);
const primaryLang = lang.split('-')[0]!.toLowerCase();
return rtlLanguages.has(primaryLang) ? 'rtl' : 'auto';
};

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