Compare commits

..

125 Commits

Author SHA1 Message Date
Huang Xin 1fe188814d release: version 0.9.51 (#1296) 2025-05-31 18:33:21 +02:00
Huang Xin 6ec3a5b332 feat: add an option to enable JavaScript in EPUB, closes #1278 (#1295) 2025-05-31 18:25:25 +02:00
Huang Xin 1acba2e08c fix: notebook now won't prevent pagination, closes #1285 (#1294) 2025-05-31 15:02:36 +02:00
Huang Xin 32f9346f84 tts: more robust way to save last speaking location for tts (#1293) 2025-05-31 13:34:35 +02:00
Huang Xin 69c503026a bump: catch up foliate-js upstream (#1292) 2025-05-31 13:31:27 +02:00
Huang Xin 84fc4cec5e tts: read from last read sentence in tts (#1291) 2025-05-31 10:53:36 +02:00
Huang Xin 5c9c11fafd translator: disable translator if the system language is the same with the book language (#1290)
Closes #1288
2025-05-31 09:34:52 +02:00
Huang Xin 77fa57438e tts: show speaking sentence and chapter info in tts media session (#1289) 2025-05-31 09:21:17 +02:00
Huang Xin e4d217f3aa config: add theme mode options in the library page (#1283) 2025-05-30 09:10:48 +02:00
Huang Xin c7a583c53a translator: lazy load translation observer (#1282) 2025-05-30 09:08:45 +02:00
Huang Xin 6c86917098 translator: add daily translation quota for deepl (#1275) 2025-05-29 19:24:09 +02:00
Huang Xin 7c21f48d68 layout: hide the header bar only on mobile platforms when translation is enabled (#1274) 2025-05-29 10:31:02 +02:00
Huang Xin 5820191c26 translator: also translate table of contents (#1273) 2025-05-29 10:08:01 +02:00
Huang Xin b37a804192 css: enhanced compatibility with the text-indent CSS property, closes #1267 (#1271) 2025-05-29 08:53:58 +02:00
Huang Xin ebdfc39ef6 release: version 0.9.50 (#1264) 2025-05-28 17:05:02 +02:00
Huang Xin 9f0d8b5c12 tts: select voice in bilingual TTS mode (#1263) 2025-05-28 16:59:45 +02:00
Huang Xin a267671671 layout: more responsive layout for the header in settings dialog (#1257) 2025-05-28 09:28:51 +02:00
Huang Xin aa62ebd3f0 css: unchange color for anchor children, unchange only image dimension in span, closes #1251 and #1252 (#1256) 2025-05-28 08:04:09 +02:00
Huang Xin cc85e5c304 release: version 0.9.49 (#1247) 2025-05-27 14:47:09 +02:00
Huang Xin b0cc0b6f1a layout: better organized panels in settings dialog (#1246) 2025-05-27 14:37:32 +02:00
Huang Xin 6b033f7509 translator: post-process translated text for punctuation spacing (#1245) 2025-05-27 14:32:25 +02:00
Huang Xin 5c0670031a fix: use updated library when dropfile to import on web browser (#1244) 2025-05-26 16:33:18 +02:00
Huang Xin 35735cd931 fix: skip speech when the text is empty at the end of some chapters, closes #1231 (#1243) 2025-05-26 16:03:43 +02:00
Huang Xin 699a01c78f feat: support full book translation to bilingual books, closes #398 (#1240) 2025-05-26 12:23:33 +02:00
Huang Xin 033e161455 layout: overlay the footer of the translator popup and less sensitive page flip with mouse wheel (#1238)
* layout: overlay the footer of the translator popup

* fix: less sensitive page flip with mouse wheel, addresses #1165
2025-05-25 09:32:00 +02:00
Huang Xin e6428656c2 fix: select filtered books when activating select all (#1237) 2025-05-25 08:02:34 +02:00
Huang Xin 68c3c45da3 feat: add an option to opt-out telemetry (#1236) 2025-05-25 07:48:57 +02:00
Huang Xin 36e87d4eb6 layout: display full publisher field in book details, closes #1224 (#1234) 2025-05-24 07:14:22 +02:00
Huang Xin 7f5059a8ea tts: always infer language code from script in bilingual TTS (#1233) 2025-05-24 07:01:41 +02:00
Huang Xin 8dc04f4095 feat: add bilingual TTS support for bilingual books, closes #1226 (#1230) 2025-05-23 18:18:24 +02:00
Huang Xin 6a48f3feb4 fix: use external browsers to open links in epub file, closes #1211 (#1228) 2025-05-23 08:15:57 +02:00
Huang Xin facf5fba75 fix: reorder icons in the ico file for Windows, closes #1175 (#1227) 2025-05-22 16:47:21 +02:00
Huang Xin 9700942e24 fix: transient navigation bar should work now for Android 8-15, closes #1199 (#1225) 2025-05-22 16:12:59 +02:00
Huang Xin e238b49fbf feat: add an option to invert image color in dark mode (#1223) 2025-05-22 10:38:09 +02:00
Huang Xin 32dfadf182 doc: update screenshots (#1222) 2025-05-22 09:34:16 +02:00
Huang Xin 66744108e2 css: unset link color in footnotes in dark mode for safari (#1221) 2025-05-22 09:09:02 +02:00
Huang Xin 401f28847c fix: add location only when toc item is at the same level as the section (#1220) 2025-05-22 08:08:52 +02:00
Huang Xin 9674b45cf0 layout: align text end for select labels on safari (#1219) 2025-05-22 07:03:10 +02:00
Huang Xin a4bc88d305 feat: add azure and google translator providers, closes #1135 and closes #1215 (#1218) 2025-05-21 17:25:00 +02:00
Huang Xin b6b8b17760 fix: normalize input text by removing newlines to prevent translation glitches (#1210) 2025-05-21 05:26:20 +02:00
Huang Xin 14810d6737 feat: add select all button in select mode, closes #1202 (#1209) 2025-05-20 16:58:04 +02:00
Huang Xin ed6c92a65a css: don't override body background, closes #1206 (#1208)
Revisit #1151.
2025-05-20 15:11:36 +02:00
Huang Xin 20d7a1185e layout: grouping modal now should stay on top of the header bar (#1207) 2025-05-20 14:12:17 +02:00
Huang Xin 32bcfda9ad fix: reimporting books will update the import time, being consistent with update time, closes #1200 (#1203) 2025-05-20 04:40:41 +02:00
Huang Xin def157cacb release: version 0.9.43 (#1196) 2025-05-19 16:51:34 +02:00
Huang Xin 7c464b9a8d feat: add prev/next section buttons in footer bar, closes #1125 (#1195) 2025-05-19 16:40:11 +02:00
Huang Xin 90f3512ae2 feat: add scrolling overlap in pixels option to paginate in scrolled mode (#1194) 2025-05-19 15:21:06 +02:00
Huang Xin 12d93ba749 fix: adjust scroll offset to account for header/footer bars when paginating in scroll mode, closes #1181 (#1193) 2025-05-19 10:34:05 +02:00
Huang Xin 554d786105 fix: update library in store when deleting book in bookshelf, closes #1178 (#1191) 2025-05-19 08:31:25 +02:00
Huang Xin eb71c59524 chore: config wrangler before deployment in Cloudflare (#1189) 2025-05-19 07:55:11 +02:00
Huang Xin 93e81ee05b release: remove deprecated MSI installer, closes #1174 (#1187) 2025-05-19 03:29:44 +02:00
Huang Xin b83972e366 refactor: use translator hook to support more translator providers (#1186) 2025-05-18 18:35:37 +02:00
Huang Xin 967536aa23 refactor: update wrangler config (#1185) 2025-05-18 17:52:50 +02:00
Huang Xin 4523437e34 refactor: add kv cache for translation backend (#1184) 2025-05-18 17:16:44 +02:00
Huang Xin e49dd4accb feat: show cloud backup status for each book on mobile and desktop, closes #1167 (#1173) 2025-05-17 17:02:42 +02:00
Huang Xin b7c1f5703c fix: less sensitive magic trackpad and mouse when flipping page, closes #1165 (#1172) 2025-05-17 08:56:22 +02:00
Huang Xin 5373b67aae css: get rid of most overriding for background color, closes #1162 (#1164) 2025-05-16 08:23:34 +02:00
Huang Xin ddc9673260 css: compat for background colors in chapter headers of Feedbooks eBooks (#1163) 2025-05-16 03:29:07 +02:00
Huang Xin 16b0821eab i18n: add translations of Nederlands (#1161) 2025-05-15 18:32:38 +02:00
Huang Xin 93876a73cc layout: responsive translator popup, also closes #1157 (#1160) 2025-05-15 18:19:32 +02:00
Huang Xin 048867e595 settings: disable spell check in color input (#1159) 2025-05-15 05:33:35 +02:00
Huang Xin 602be376a1 css: include additional weight variants for built-in fonts (#1158) 2025-05-15 05:04:16 +02:00
Huang Xin c52ae43cd9 settings: avoid horizontal margin default to zero, fixes #1151 (#1155) 2025-05-14 15:25:32 +02:00
Huang Xin 348d743e82 css: styling adjustments for better compatibility with Gutenberg eBooks (#1154)
This also closes #1153, #1148, #1147 and the css problem in #1151.
2025-05-14 14:55:07 +02:00
Huang Xin c7e495c379 settings: keep screen awake is disabled by default, closes #1145 (#1149)
* layout: hint info now takes up full width in scrolled mode

* settings: keep screen awake is disabled by default, closes #1145
2025-05-14 11:11:32 +02:00
Huang Xin e368076cac chore: update bundleVersion for macOS AppStore build (#1146) 2025-05-14 03:45:00 +02:00
Huang Xin 382e0ca3c6 release: version 0.9.41 (#1143) 2025-05-13 14:17:01 +02:00
Huang Xin d96fb32bcf fix: apply layout styles to div tag, closes #1130 (#1142) 2025-05-13 13:48:21 +02:00
Huang Xin 4779e2638f fix: disable updater for non-appimage app on linux, closes #1129 (#1141) 2025-05-13 12:50:40 +02:00
Huang Xin d094ba9916 chore: cache avatar image for offline usage (#1140) 2025-05-13 10:42:00 +02:00
Huang Xin e4ba0c302e fix: segment txt with paragraph count if no chapter titles are parsed, closes #1134 (#1139) 2025-05-13 10:21:47 +02:00
Huang Xin 0150177a9d chore: bump tauri to version 2.5.1 (#1138) 2025-05-13 09:30:34 +02:00
Huang Xin 74e32d47b0 release: add linux arch armhf (#1133) 2025-05-13 14:57:27 +08:00
Huang Xin 2dd7cf54e6 refactor: use text selector hook in annotator, closes #1020 (#1131) 2025-05-12 18:00:45 +02:00
Huang Xin a5cbfe97db fix: replace background when attribute changed, closes #1124 (#1126) 2025-05-11 17:07:17 +02:00
Huang Xin da49526f4c fix: now auto orientation follows system settings, closes #1093 and closes #1098 (#1122) 2025-05-11 14:57:05 +02:00
Huang Xin 2e46bc1627 css: get rid of hardcoded font color only when overriding font, closes #1095 (#1120) 2025-05-11 14:34:51 +02:00
Huang Xin 7e542e8694 fix: reorganize TTS control view hierarchy, closes #1080 (#1119) 2025-05-11 12:06:15 +02:00
Huang Xin 2a624ef57a css: only override background color in dark mode, closes #1066 (#1117) 2025-05-11 09:33:03 +02:00
Huang Xin 60ae0c9ac3 feat: show book file size in book details (#1114) 2025-05-10 18:28:08 +02:00
Huang Xin 110b4b37cd ui: various UI fixes and enhancements on mobile platforms (#1113) 2025-05-10 18:18:15 +02:00
Huang Xin f0edbf14a9 fix: padding top of the drag handler with the cutouts, closes #1101 (#1110) 2025-05-10 08:27:40 +02:00
Huang Xin 20669a9cb3 fix: show updating status in the about window, closes #1079 (#1109) 2025-05-10 08:00:25 +02:00
Huang Xin 3756bec66c css: special case for align center and zero text indent (#1105) 2025-05-09 14:40:55 +02:00
Huang Xin 3e1d03dc17 css: override text align and fix hardcoded text intent (#1104) 2025-05-09 14:20:38 +02:00
Huang Xin 4aebbf679f fix: various on styles of UI and footnote visibility (#1099)
* fix: footnote now should be hidden in srcdoc iframes

* fix: eliminate white flash on startup when using dark mode

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

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

* fix: padding drag handler on Android, closes #1036
2025-05-05 17:42:57 +02:00
Huang Xin 27df89e261 fix: override text-indent when overriding book layout, closes #1026 (#1038) 2025-05-05 11:58:49 +02:00
Huang Xin ddcee6b3df fix: TTS timeout options should popup now in iOS (#1037) 2025-05-05 11:43:10 +02:00
Huang Xin 5e04f6ae03 feat: support locking screen orientation, closes #860 (#1034) 2025-05-05 10:55:50 +02:00
Huang Xin 6299ea09b7 fix: escape special characters when converting from txt to epub, closes #1018 (#1024) 2025-05-04 13:46:08 +02:00
Huang Xin 4f6f45fe8a fix: don't preview font style for system fonts on Linux, closes #1015 (#1023) 2025-05-04 12:23:01 +02:00
Huang Xin 6b290f09f5 refactor: unify hooks for pagination and continuous scroll (#1021) 2025-05-04 10:58:44 +02:00
Huang Xin c9e08f2c01 fix: more robust continuous scroll, closes #993 (#1017) 2025-05-03 12:07:32 +02:00
Huang Xin 8167f27156 feat: add location info for each entry in table of contents (#1016) 2025-05-02 15:08:20 +02:00
Huang Xin 77c7176526 fix: volume retained when app is in background for iOS (#1014) 2025-05-02 11:15:42 +02:00
Huang Xin a8d657414d fix: more robust txt parser (#1005) 2025-04-30 18:01:47 +02:00
Huang Xin ed368e1205 fix: skip rt elements in annotation and search for Japanese books, closes #904 (#1004) 2025-04-30 11:36:52 +02:00
Huang Xin 7de1bdf824 fix: more robust method to detect language for TTS (#1003) 2025-04-30 09:36:51 +02:00
Huang Xin 141c502c76 fix: handle full CJK Unified Ideographs in search terms, closes #989 (#1002) 2025-04-30 08:52:19 +02:00
Huang Xin b1ea729a54 fix: also overriding anchor color when overriding font in dark mode, closes #995 (#1000) 2025-04-30 06:19:32 +02:00
Huang Xin d3eff19ab9 fix: filter out some non-free fonts on Android and Linux, closes #994 (#999) 2025-04-29 20:02:54 +02:00
Huang Xin 59f0cede39 fix: import system fonts list on Android (#998)
Note that the system fonts might not be available in Android webview
2025-04-29 19:14:03 +02:00
Huang Xin 946d17882b fix: reactivating volume keys interception when resumed from background on iOS (#997) 2025-04-29 18:39:36 +02:00
178 changed files with 7148 additions and 2866 deletions
+8 -4
View File
@@ -7,13 +7,17 @@ assignees: ''
---
**Does your feature request involve difficulty for you to complete a task? Please describe.**
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
**Describe the solution you'd like**
A clear and concise description of what you'd like to happen.
> A clear and concise description of what you'd like to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
> A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any additional context or screenshots about the feature request here.
> Add any additional context or screenshots about the feature request here.
+10 -19
View File
@@ -11,22 +11,16 @@ assignees: ''
A clear and concise description of what the current behavior is.
Please also add **screenshots** of the existing application.
**Example:**
```
In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
[screenshot]
```
> **Example:**
> In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
> [screenshot]
## Expected behavior
A clear and concise description of what the expected behavior is.
**Example:**
```
The blank square should be transparent (invisible)
```
> **Example:**
> The blank square should be transparent (invisible)
## Technical inputs
@@ -34,11 +28,8 @@ Operating System:
Readest Version:
**Example:**
```
Operating System: macOS 14.3.1
Readest Version: 0.9.0
We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
Probably fixable with CSS
```
> **Example:**
> Operating System: Android 14 (WebView 135.0)
> Readest Version: 0.9.0
> We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
> Probably fixable with CSS
+18 -2
View File
@@ -67,6 +67,11 @@ jobs:
release: linux
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
- os: ubuntu-22.04-arm
release: linux
arch: armhf
rust_target: arm-unknown-linux-gnueabihf
args: '--target arm-unknown-linux-gnueabihf --bundles deb'
- os: macos-latest
release: macos
arch: aarch64
@@ -76,7 +81,7 @@ jobs:
release: windows
arch: x86_64
rust_target: x86_64-pc-windows-msvc
args: '--target x86_64-pc-windows-msvc'
args: '--target x86_64-pc-windows-msvc --bundles nsis'
- os: windows-latest
release: windows
arch: aarch64
@@ -133,11 +138,22 @@ jobs:
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: install dependencies (ubuntu only - armhf specific)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
run: |
sudo dpkg --add-architecture armhf
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev:armhf libgtk-3-dev:armhf libwebkit2gtk-4.1-dev:armhf libappindicator3-dev:armhf librsvg2-dev:armhf gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
echo 'PKG_CONFIG_ALLOW_CROSS=1' >> $GITHUB_ENV
echo 'PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/share/pkgconfig' >> $GITHUB_ENV
echo 'PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf' >> $GITHUB_ENV
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc' >> $GITHUB_ENV
- name: create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
+1 -1
View File
@@ -3,4 +3,4 @@
url = https://github.com/readest/foliate-js.git
[submodule "packages/tauri"]
path = packages/tauri
url = https://github.com/tauri-apps/tauri.git
url = https://github.com/readest/tauri.git
Generated
+490 -446
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -48,12 +48,12 @@
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
## Planned Features
@@ -78,10 +78,10 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![Annotations](./data/screenshots/annotations.png)
![DeepL](./data/screenshots/deepl.png)
![TTS](./data/screenshots/tts_speak_aloud.png)
![DeepL](./data/screenshots/deepl.png)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
@@ -17,6 +17,7 @@ module.exports = {
'el',
'ko',
'uk',
'nl',
'pl',
'pt',
'ru',
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.38",
"version": "0.9.51",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -32,6 +32,7 @@
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler dev",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler deploy",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
},
"dependencies": {
@@ -42,19 +43,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.4.1",
"@tauri-apps/api": "2.5.0",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-haptics": "^2.2.4",
"@tauri-apps/plugin-http": "^2.4.3",
"@tauri-apps/plugin-log": "^2.3.1",
"@tauri-apps/plugin-log": "^2.4.0",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "~2.2.1",
"@tauri-apps/plugin-updater": "^2.7.0",
"@tauri-apps/plugin-updater": "^2.7.1",
"@zip.js/zip.js": "^2.7.53",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
@@ -67,7 +68,7 @@
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"next": "15.2.4",
"posthog-js": "^1.205.0",
"posthog-js": "^1.246.0",
"react": "19.0.0",
"react-color": "^2.19.3",
"react-dom": "19.0.0",
@@ -80,7 +81,7 @@
},
"devDependencies": {
"@opennextjs/cloudflare": "^0.5.12",
"@tauri-apps/cli": "2.4.1",
"@tauri-apps/cli": "2.5.0",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
@@ -5,7 +5,6 @@
"Animation": "الرسوم المتحركة",
"Annotate": "إضافة ملاحظة",
"Apply": "تطبيق",
"Are you sure to delete the selected books?": "هل أنت متأكد من رغبتك في حذف الكتب المحددة؟",
"Auto Mode": "الوضع التلقائي",
"Behavior": "السلوك",
"Book": "الكتاب",
@@ -42,15 +41,12 @@
"Global Settings": "الإعدادات العامة",
"Go Back": "العودة",
"Go Forward": "التقدم",
"Go Left": "الذهاب لليسار",
"Go Right": "الذهاب لليمين",
"Grass": "عشبي",
"Gray": "رمادي",
"Gruvbox": "چروفبوكس",
"Highlight": "تمييز",
"Horizontal Direction": "الاتجاه الأفقي",
"Hyphenation": "الواصلة",
"Identifier:": "المعرف:",
"Import Books": "استيراد الكتب",
"Language:": "اللغة:",
"Layout": "التخطيط",
@@ -63,7 +59,6 @@
"Match Whole Words": "مطابقة الكلمات الكاملة",
"Maximum Number of Columns": "العدد الأقصى للأعمدة",
"Minimum Font Size": "الحد الأدنى لحجم الخط",
"Misc": "أخرى",
"Monospace Font": "خط أحادي المسافة (Monospace)",
"More Info": "مزيد من المعلومات",
"Nord": "نورد",
@@ -111,7 +106,6 @@
"Unknown": "غير معروف",
"Untitled": "بدون عنوان",
"Updated:": "تم التحديث:",
"User avatar": "صورة المستخدم",
"Version {{version}}": "الإصدار {{version}}",
"Vertical Direction": "الاتجاه العمودي",
"Welcome to your library. You can import your books here and read them anytime.": "مرحبًا بكم في مكتبتكم. يمكنكم استيراد كتبكم هنا وقراءتها في أي وقت.",
@@ -299,5 +293,49 @@
"Link Color": "لون الرابط",
"Volume Keys for Page Flip": "مفاتيح الصوت لتقليب الصفحات",
"Clicks for Page Flip": "نقرات لتقليب الصفحات",
"Swap Clicks Area": "تبديل منطقة النقرات"
"Swap Clicks Area": "تبديل منطقة النقرات",
"Screen": "الشاشة",
"Orientation": "الاتجاه",
"Portrait": "العمودي",
"Landscape": "الأفقي",
"Open Last Book on Start": "فتح آخر كتاب عند البدء",
"Checking for updates...": "جارٍ التحقق من التحديثات...",
"Error checking for updates": "خطأ في التحقق من التحديثات",
"Details": "التفاصيل",
"File Size:": "حجم الملف:",
"Auto Detect": "الكشف التلقائي",
"Next Section": "القسم التالي",
"Previous Section": "القسم السابق",
"Next Page": "الصفحة التالية",
"Previous Page": "الصفحة السابقة",
"Are you sure to delete {{count}} selected book(s)?_zero": "هل أنت متأكد من حذف الكتب المحددة؟",
"Are you sure to delete {{count}} selected book(s)?_one": "هل أنت متأكد من حذف الكتاب المحدد؟",
"Are you sure to delete {{count}} selected book(s)?_two": "هل أنت متأكد من حذف الكتابين المحددين؟",
"Are you sure to delete {{count}} selected book(s)?_few": "هل أنت متأكد من حذف {{count}} كتب محددة؟",
"Are you sure to delete {{count}} selected book(s)?_many": "هل أنت متأكد من حذف {{count}} كتابًا محددًا؟",
"Are you sure to delete {{count}} selected book(s)?_other": "هل أنت متأكد من حذف {{count}} من الكتب المحددة؟",
"Are you sure to delete the selected book?": "هل أنت متأكد من حذف الكتاب المحدد؟",
"Deselect": "إلغاء",
"Select All": "الكل",
"No translation available.": "لا توجد ترجمة متاحة.",
"Translated by {{provider}}.": "ترجم بواسطة {{provider}}.",
"DeepL": "ديب إل",
"Google Translate": "ترجمة جوجل",
"Azure Translator": "مترجم أزور",
"Invert Image In Dark Mode": "عكس الصورة في الوضع المظلم",
"Help improve Readest": "ساعد في تحسين ريديست",
"Sharing anonymized statistics": "مشاركة إحصائيات مجهولة الهوية",
"Interface Language": "لغة الواجهة",
"Translation": "الترجمة",
"Enable Translation": "تمكين الترجمة",
"Translation Service": "خدمة الترجمة",
"Translate To": "ترجمة إلى",
"Disable Translation": "تعطيل الترجمة",
"Scroll": "تمرير",
"Overlap Pixels": "تداخل البكسلات",
"Click": "نقر",
"System Language": "لغة النظام",
"Security": "الأمان",
"Allow JavaScript": "السماح بـ JavaScript",
"Enable only if you trust the file.": "تمكين فقط إذا كنت تثق في الملف."
}
@@ -5,7 +5,6 @@
"Animation": "Animation",
"Annotate": "Annotieren",
"Apply": "Anwenden",
"Are you sure to delete the selected books?": "Möchten Sie die ausgewählten Bücher wirklich löschen?",
"Auto Mode": "Automatischer Modus",
"Behavior": "Verhalten",
"Book": "Buch",
@@ -42,15 +41,12 @@
"Global Settings": "Globale Einstellungen",
"Go Back": "Zurück",
"Go Forward": "Vorwärts",
"Go Left": "Nach links",
"Go Right": "Nach rechts",
"Grass": "Grasgrün",
"Gray": "Grau",
"Gruvbox": "Gruvbox",
"Highlight": "Hervorheben",
"Horizontal Direction": "Horizontale Richtung",
"Hyphenation": "Silbentrennung",
"Identifier:": "Kennung:",
"Import Books": "Bücher importieren",
"Language:": "Sprache:",
"Layout": "Layout",
@@ -63,7 +59,6 @@
"Match Whole Words": "Ganze Wörter",
"Maximum Number of Columns": "Maximale Spaltenanzahl",
"Minimum Font Size": "Minimale Schriftgröße",
"Misc": "Sonstiges",
"Monospace Font": "Monospace-Schriftart",
"More Info": "Weitere Informationen",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Unbekannt",
"Untitled": "Ohne Titel",
"Updated:": "Aktualisiert:",
"User avatar": "Benutzer-Avatar",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Vertikale Richtung",
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
@@ -135,7 +129,7 @@
"Book downloaded: {{title}}": "Buch heruntergeladen: {{title}}",
"Failed to download book: {{title}}": "Fehler beim Herunterladen des Buches: {{title}}",
"Upload Book": "Buch hochladen",
"Auto Upload Books to Cloud": "Bücher automatisch in die Cloud hochladen",
"Auto Upload Books to Cloud": "Bücher automatisch hochladen",
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% des Cloud-Speichers verwendet.",
"Storage": "Speicher",
"Book deleted: {{title}}": "Buch gelöscht: {{title}}",
@@ -299,5 +293,45 @@
"Link Color": "Linkfarbe",
"Volume Keys for Page Flip": "Volumen-Tasten für Seitenwechsel",
"Clicks for Page Flip": "Tippen zum Blättern",
"Swap Clicks Area": "Tippbereiche tauschen"
"Swap Clicks Area": "Tippbereiche tauschen",
"Screen": "Bildschirm",
"Orientation": "Orientierung",
"Portrait": "Hochformat",
"Landscape": "Querformat",
"Open Last Book on Start": "Letztes Buch beim Start öffnen",
"Checking for updates...": "Auf Updates prüfen...",
"Error checking for updates": "Fehler beim Überprüfen auf Updates",
"Details": "Details",
"File Size:": "Dateigröße:",
"Auto Detect": "Automatisch erkennen",
"Next Section": "Nächster Abschnitt",
"Previous Section": "Vorheriger Abschnitt",
"Next Page": "Nächste Seite",
"Previous Page": "Vorherige Seite",
"Are you sure to delete {{count}} selected book(s)?_one": "Möchtest du das ausgewählte Buch wirklich löschen?",
"Are you sure to delete {{count}} selected book(s)?_other": "Möchtest du die {{count}} ausgewählten Bücher wirklich löschen?",
"Are you sure to delete the selected book?": "Möchtest du das ausgewählte Buch wirklich löschen?",
"Deselect": "Aufheben",
"Select All": "Alle",
"No translation available.": "Keine Übersetzung verfügbar.",
"Translated by {{provider}}.": "Übersetzt von {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Übersetzer",
"Azure Translator": "Azure Übersetzer",
"Invert Image In Dark Mode": "Invertiere Bild im Dunkeln",
"Help improve Readest": "Readest verbessern helfen",
"Sharing anonymized statistics": "Anonymisierte Statistiken teilen",
"Interface Language": "Benutzeroberflächensprache",
"Translation": "Übersetzung",
"Enable Translation": "Übersetzung aktivieren",
"Translation Service": "Übersetzungsdienst",
"Translate To": "Übersetzen nach",
"Disable Translation": "Übersetzung deaktivieren",
"Scroll": "Scrollen",
"Overlap Pixels": "Überlappungspixel",
"Click": "Tippen",
"System Language": "Systemsprache",
"Security": "Sicherheit",
"Allow JavaScript": "JavaScript zulassen",
"Enable only if you trust the file.": "Nur aktivieren, wenn Sie der Datei vertrauen."
}
@@ -5,7 +5,6 @@
"Animation": "Κινούμενα σχέδια",
"Annotate": "Σχολιασμός",
"Apply": "Εφαρμογή",
"Are you sure to delete the selected books?": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τα επιλεγμένα βιβλία;",
"Auto Mode": "Αυτόματη λειτουργία",
"Behavior": "Συμπεριφορά",
"Book": "Βιβλίο",
@@ -42,15 +41,12 @@
"Global Settings": "Γενικές ρυθμίσεις",
"Go Back": "Πίσω",
"Go Forward": "Μπροστά",
"Go Left": "Αριστερά",
"Go Right": "Δεξιά",
"Grass": "Γρασίδι",
"Gray": "Γκρι",
"Gruvbox": "Gruvbox",
"Highlight": "Επισήμανση",
"Horizontal Direction": "Οριζόντια κατεύθυνση",
"Hyphenation": "Συλλαβισμός",
"Identifier:": "Αναγνωριστικό:",
"Import Books": "Εισαγωγή βιβλίων",
"Language:": "Γλώσσα:",
"Layout": "Διάταξη",
@@ -63,7 +59,6 @@
"Match Whole Words": "Ταίριασμα ολόκληρων λέξεων",
"Maximum Number of Columns": "Μέγιστος αριθμός στηλών",
"Minimum Font Size": "Ελάχιστο μέγεθος γραμματοσειράς",
"Misc": "Διάφορα",
"Monospace Font": "Γραμματοσειρά σταθερού πλάτους",
"More Info": "Περισσότερες πληροφορίες",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Άγνωστο",
"Untitled": "Χωρίς τίτλο",
"Updated:": "Ενημερώθηκε:",
"User avatar": "Εικόνα χρήστη",
"Version {{version}}": "Έκδοση {{version}}",
"Vertical Direction": "Κάθετη κατεύθυνση",
"Welcome to your library. You can import your books here and read them anytime.": "Καλώς ήρθατε στη βιβλιοθήκη σας. Μπορείτε να εισάγετε τα βιβλία σας εδώ και να τα διαβάσετε οποιαδήποτε στιγμή.",
@@ -135,12 +129,12 @@
"Book downloaded: {{title}}": "Το βιβλίο με τίτλο {{title}} κατέβηκε",
"Failed to download book: {{title}}": "Αποτυχία λήψης βιβλίου: {{title}}",
"Upload Book": "Ανέβασμα βιβλίου",
"Auto Upload Books to Cloud": "Αυτόματο ανέβασμα βιβλίων στο cloud",
"Auto Upload Books to Cloud": "Αυτόματη αποθήκευση στο cloud",
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% της αποθήκευσης στο cloud χρησιμοποιήθηκε.",
"Storage": "Αποθήκευση",
"Book deleted: {{title}}": "Το βιβλίο με τίτλο {{title}} διαγράφηκε",
"Failed to delete book: {{title}}": "Αποτυχία διαγραφής βιβλίου: {{title}}",
"Check Updates on Start": "Έλεγχος ενημερώσεων κατά την εκκίνηση",
"Check Updates on Start": "Αυτόματος έλεγχος ενημερώσεων",
"Insufficient storage quota": "Ανεπαρκές όριο αποθήκευσης",
"Font Weight": "Βάρος γραμματοσειράς",
"Line Spacing": "Διάκενο γραμμών",
@@ -297,7 +291,47 @@
"Grid": "Πλέγμα",
"(from 'As You Like It', Act II)": "('Όπως σας αρέσει', Πράξη II)",
"Link Color": "Χρώμα συνδέσμου",
"Volume Keys for Page Flip": "Πλήκτρα έντασης για ανατροπή σελίδας",
"Volume Keys for Page Flip": "Ανατροπή με πλήκτρα έντασης",
"Clicks for Page Flip": "Κλικ για ανατροπή σελίδας",
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ"
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ",
"Screen": "Οθόνη",
"Orientation": "Προσανατολισμός",
"Portrait": "Πορτραίτο",
"Landscape": "Τοπίο",
"Open Last Book on Start": "Άνοιγμα τελευταίου βιβλίου",
"Checking for updates...": "Έλεγχος για ενημερώσεις...",
"Error checking for updates": "Σφάλμα κατά τον έλεγχο για ενημερώσεις",
"Details": "Πληρ.",
"File Size:": "Μέγεθος αρχείου:",
"Auto Detect": "Αυτόματη ανίχνευση",
"Next Section": "Επόμενη ενότητα",
"Previous Section": "Προηγούμενη ενότητα",
"Next Page": "Επόμενη σελίδα",
"Previous Page": "Προηγούμενη σελίδα",
"Are you sure to delete {{count}} selected book(s)?_one": "Είστε σίγουροι ότι θέλετε να διαγράψετε το επιλεγμένο βιβλίο;",
"Are you sure to delete {{count}} selected book(s)?_other": "Είστε σίγουροι ότι θέλετε να διαγράψετε τα {{count}} επιλεγμένα βιβλία;",
"Are you sure to delete the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το επιλεγμένο βιβλίο;",
"Deselect": "Άρση",
"Select All": "Όλα",
"No translation available.": "Δεν είναι διαθέσιμη καμία μετάφραση.",
"Translated by {{provider}}.": "Μεταφρασμένο από {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Μετάφραση Google",
"Azure Translator": "Μεταφραστής Azure",
"Invert Image In Dark Mode": "Αντιστροφή σε σκούρο",
"Help improve Readest": "Βοηθήστε στη βελτίωση του Readest",
"Sharing anonymized statistics": "Μοιράζομαι ανώνυμες στατιστικές",
"Interface Language": "Γλώσσα διεπαφής",
"Translation": "Μετάφραση",
"Enable Translation": "Ενεργοποίηση μετάφρασης",
"Translation Service": "Υπηρεσία μετάφρασης",
"Translate To": "Μετάφραση σε",
"Disable Translation": "Απενεργοποίηση μετάφρασης",
"Scroll": "Κύλιση",
"Overlap Pixels": "Επικάλυψη εικονοστοιχείων",
"Click": "Κλικ",
"System Language": "Γλώσσα συστήματος",
"Security": "Ασφάλεια",
"Allow JavaScript": "Επιτρέψτε JavaScript",
"Enable only if you trust the file.": "Ενεργοποιήστε μόνο εάν εμπιστεύεστε το αρχείο."
}
@@ -5,7 +5,6 @@
"Animation": "Animación",
"Annotate": "Anotar",
"Apply": "Aplicar",
"Are you sure to delete the selected books?": "¿Estás seguro de eliminar los libros seleccionados?",
"Auto Mode": "Modo automático",
"Behavior": "Comportamiento",
"Book": "Libro",
@@ -42,15 +41,12 @@
"Global Settings": "Configuración global",
"Go Back": "Volver",
"Go Forward": "Avanzar",
"Go Left": "Ir a la izquierda",
"Go Right": "Ir a la derecha",
"Grass": "Hierba",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Resaltar",
"Horizontal Direction": "Dirección horizontal",
"Hyphenation": "Guionización",
"Identifier:": "Identificador:",
"Import Books": "Importar libros",
"Language:": "Idioma:",
"Layout": "Diseño",
@@ -63,7 +59,6 @@
"Match Whole Words": "Coincidir palabras completas",
"Maximum Number of Columns": "Número máximo de columnas",
"Minimum Font Size": "Tamaño de fuente mínimo",
"Misc": "Varios",
"Monospace Font": "Fuente monoespaciada",
"More Info": "Más información",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Desconocido",
"Untitled": "Sin título",
"Updated:": "Actualizado:",
"User avatar": "Avatar del usuario",
"Version {{version}}": "Versión {{version}}",
"Vertical Direction": "Dirección vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
@@ -299,5 +293,46 @@
"Link Color": "Color del enlace",
"Volume Keys for Page Flip": "Botones de volumen para pasar página",
"Clicks for Page Flip": "Toques para pasar página",
"Swap Clicks Area": "Invertir zonas de toque"
"Swap Clicks Area": "Invertir zonas de toque",
"Screen": "Pantalla",
"Orientation": "Orientación",
"Portrait": "Vertical",
"Landscape": "Horizontal",
"Open Last Book on Start": "Abrir último libro al iniciar",
"Checking for updates...": "Comprobando actualizaciones...",
"Error checking for updates": "Error al comprobar actualizaciones",
"Details": "Detalles",
"File Size:": "Peso del archivo:",
"Auto Detect": "Detección automática",
"Next Section": "Siguiente sección",
"Previous Section": "Sección anterior",
"Next Page": "Página siguiente",
"Previous Page": "Página anterior",
"Are you sure to delete {{count}} selected book(s)?_one": "¿Seguro que quieres eliminar el libro seleccionado?",
"Are you sure to delete {{count}} selected book(s)?_many": "¿Seguro que quieres eliminar los {{count}} libros seleccionados?",
"Are you sure to delete {{count}} selected book(s)?_other": "¿Seguro que quieres eliminar los {{count}} libros seleccionados?",
"Are you sure to delete the selected book?": "¿Seguro que quieres eliminar el libro seleccionado?",
"Deselect": "Quitar",
"Select All": "Todo",
"No translation available.": "No hay traducción disponible.",
"Translated by {{provider}}.": "Traducido por {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Invertir imagen en oscuro",
"Help improve Readest": "Ayuda a mejorar Readest",
"Sharing anonymized statistics": "Compartiendo estadísticas anónimas",
"Interface Language": "Idioma de la interfaz",
"Translation": "Traducción",
"Enable Translation": "Habilitar traducción",
"Translation Service": "Servicio de traducción",
"Translate To": "Traducir a",
"Disable Translation": "Deshabilitar traducción",
"Scroll": "Desplazar",
"Overlap Pixels": "Superponer píxeles",
"Click": "Click",
"System Language": "Idioma del sistema",
"Security": "Seguridad",
"Allow JavaScript": "Permitir JavaScript",
"Enable only if you trust the file.": "Permitir solo si confías en el archivo."
}
@@ -5,7 +5,6 @@
"Animation": "Animation",
"Annotate": "Annoter",
"Apply": "Appliquer",
"Are you sure to delete the selected books?": "Êtes-vous sûr de vouloir supprimer les livres sélectionnés ?",
"Auto Mode": "Mode automatique",
"Behavior": "Comportement",
"Book": "Livre",
@@ -42,15 +41,12 @@
"Global Settings": "Paramètres globaux",
"Go Back": "Retour",
"Go Forward": "Avancer",
"Go Left": "Aller à gauche",
"Go Right": "Aller à droite",
"Grass": "Herbe",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Surligner",
"Horizontal Direction": "Direction horizontale",
"Hyphenation": "Césure",
"Identifier:": "Identifiant :",
"Import Books": "Importer des livres",
"Language:": "Langue :",
"Layout": "Mise en page",
@@ -63,7 +59,6 @@
"Match Whole Words": "Mots entiers",
"Maximum Number of Columns": "Nombre maximal de colonnes",
"Minimum Font Size": "Taille minimale de police",
"Misc": "Divers",
"Monospace Font": "Police monospace",
"More Info": "Plus d'informations",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Inconnu",
"Untitled": "Sans titre",
"Updated:": "Mis à jour :",
"User avatar": "Avatar de l'utilisateur",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Direction verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
@@ -299,5 +293,46 @@
"Link Color": "Couleur du lien",
"Volume Keys for Page Flip": "Boutons volume pour tourner la page",
"Clicks for Page Flip": "Taps pour tourner la page",
"Swap Clicks Area": "Inverser zones de tap"
"Swap Clicks Area": "Inverser zones de tap",
"Screen": "Écran",
"Orientation": "Orientation",
"Portrait": "Portrait",
"Landscape": "Paysage",
"Open Last Book on Start": "Ouvrir le dernier livre au démarrage",
"Checking for updates...": "Vérification des mises à jour...",
"Error checking for updates": "Erreur lors de la vérification des mises à jour",
"Details": "Détails",
"File Size:": "Taille du fichier :",
"Auto Detect": "Détection automatique",
"Next Section": "Section suivante",
"Previous Section": "Section précédente",
"Next Page": "Page suivante",
"Previous Page": "Page précédente",
"Are you sure to delete {{count}} selected book(s)?_one": "Supprimer le livre sélectionné ?",
"Are you sure to delete {{count}} selected book(s)?_many": "Supprimer les {{count}} livres sélectionnés ?",
"Are you sure to delete {{count}} selected book(s)?_other": "Supprimer les {{count}} livres sélectionnés ?",
"Are you sure to delete the selected book?": "Supprimer le livre sélectionné ?",
"Deselect": "Annuler",
"Select All": "Tous",
"No translation available.": "Aucune traduction disponible.",
"Translated by {{provider}}.": "Traduit par {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Traduction",
"Azure Translator": "Traducteur Azure",
"Invert Image In Dark Mode": "Inverser l'image en mode sombre",
"Help improve Readest": "Contribuer à améliorer Readest",
"Sharing anonymized statistics": "Partage de statistiques anonymisées",
"Interface Language": "Langue de l'interface",
"Translation": "Traduction",
"Enable Translation": "Activer la traduction",
"Translation Service": "Service de traduction",
"Translate To": "Traduire vers",
"Disable Translation": " désactiver la traduction",
"Scroll": "Défilement",
"Overlap Pixels": "Pixels de chevauchement",
"Click": "Cliquer",
"System Language": "Langue du système",
"Security": "Sécurité",
"Allow JavaScript": "Autoriser JavaScript",
"Enable only if you trust the file.": "Activer uniquement si vous faites confiance au fichier."
}
@@ -5,7 +5,6 @@
"Animation": "एनीमेशन",
"Annotate": "टिप्पणी",
"Apply": "लागू करें",
"Are you sure to delete the selected books?": "क्या आप चयनित पुस्तकों को हटाना चाहते हैं?",
"Auto Mode": "स्वचालित मोड",
"Behavior": "व्यवहार",
"Book": "पुस्तक",
@@ -42,15 +41,12 @@
"Global Settings": "वैश्विक सेटिंग्स",
"Go Back": "वापस जाएं",
"Go Forward": "आगे जाएं",
"Go Left": "बाएं जाएं",
"Go Right": "दाएं जाएं",
"Grass": "घास",
"Gray": "ग्रे",
"Gruvbox": "ग्रूवबॉक्स",
"Highlight": "हाइलाइट",
"Horizontal Direction": "क्षैतिज दिशा",
"Hyphenation": "हाइफ़नेशन",
"Identifier:": "पहचानकर्ता:",
"Import Books": "पुस्तकें आयात करें",
"Language:": "भाषा:",
"Layout": "लेआउट",
@@ -63,7 +59,6 @@
"Match Whole Words": "पूरे शब्द मैच करें",
"Maximum Number of Columns": "कॉलम की अधिकतम संख्या",
"Minimum Font Size": "न्यूनतम फ़ॉन्ट साइज़",
"Misc": "विविध",
"Monospace Font": "मोनोस्पेस फ़ॉन्ट",
"More Info": "अधिक जानकारी",
"Nord": "नॉर्ड",
@@ -111,7 +106,6 @@
"Unknown": "अज्ञात",
"Untitled": "शीर्षकहीन",
"Updated:": "अपडेट किया गया:",
"User avatar": "उपयोगकर्ता अवतार",
"Version {{version}}": "संस्करण {{version}}",
"Vertical Direction": "ऊर्ध्वाधर दिशा",
"Welcome to your library. You can import your books here and read them anytime.": "आपकी लाइब्रेरी में आपका स्वागत है। आप यहां अपनी पुस्तकें आयात कर सकते हैं और कभी भी पढ़ सकते हैं।",
@@ -299,5 +293,45 @@
"Link Color": "लिंक रंग",
"Volume Keys for Page Flip": "पृष्ठ फ़्लिप के लिए वॉल्यूम कुंजियाँ",
"Clicks for Page Flip": "पृष्ठ फ़्लिप के लिए क्लिक करें",
"Swap Clicks Area": "क्लिक क्षेत्र स्वैप करें"
"Swap Clicks Area": "क्लिक क्षेत्र स्वैप करें",
"Screen": "स्क्रीन",
"Orientation": "अवयव",
"Portrait": "पोर्ट्रेट",
"Landscape": "लैंडस्केप",
"Open Last Book on Start": "शुरू में अंतिम पुस्तक खोलें",
"Checking for updates...": "अपडेट की जांच कर रहा है...",
"Error checking for updates": "अपडेट की जांच करते समय त्रुटि",
"Details": "विवरण",
"File Size:": "फ़ाइल आकार:",
"Auto Detect": "स्वचालित पहचान",
"Next Section": "अगला अनुभाग",
"Previous Section": "पिछला अनुभाग",
"Next Page": "अगला पृष्ठ",
"Previous Page": "पिछला पृष्ठ",
"Are you sure to delete {{count}} selected book(s)?_one": "क्या आप चयनित पुस्तक हटाना चाहते हैं?",
"Are you sure to delete {{count}} selected book(s)?_other": "क्या आप चयनित {{count}} पुस्तकें हटाना चाहते हैं?",
"Are you sure to delete the selected book?": "क्या आप चयनित पुस्तक हटाना चाहते हैं?",
"Deselect": "रद्द करें",
"Select All": "सब चुनें",
"No translation available.": "कोई अनुवाद उपलब्ध नहीं है।",
"Translated by {{provider}}.": "{{provider}} द्वारा अनुवादित।",
"DeepL": "डीपएल",
"Google Translate": "गूगल अनुवाद",
"Azure Translator": "एज़्योर अनुवादक",
"Invert Image In Dark Mode": "अंधेरे में छवि को उलटें",
"Help improve Readest": "Readest में सुधार करने में मदद करें",
"Sharing anonymized statistics": "सांख्यिकी साझा करना",
"Interface Language": "इंटरफ़ेस भाषा",
"Translation": "अनुवाद",
"Enable Translation": "अनुवाद सक्षम करें",
"Translation Service": "अनुवाद सेवा",
"Translate To": "अनुवाद करें",
"Disable Translation": "अनुवाद अक्षम करें",
"Scroll": "स्क्रॉल",
"Overlap Pixels": "ओवरलैप पिक्सेल",
"Click": "क्लिक करें",
"System Language": "सिस्टम भाषा",
"Security": "सुरक्षा",
"Allow JavaScript": "जावास्क्रिप्ट की अनुमति दें",
"Enable only if you trust the file.": "केवल तभी सक्षम करें जब आप फ़ाइल पर भरोसा करते हों।"
}
@@ -5,7 +5,6 @@
"Animation": "Animasi",
"Annotate": "Anotasi",
"Apply": "Terapkan",
"Are you sure to delete the selected books?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
"Auto Mode": "Mode Otomatis",
"Behavior": "Perilaku",
"Book": "Buku",
@@ -42,15 +41,12 @@
"Global Settings": "Pengaturan Global",
"Go Back": "Kembali",
"Go Forward": "Maju",
"Go Left": "Ke Kiri",
"Go Right": "Ke Kanan",
"Grass": "Rumput",
"Gray": "Abu-abu",
"Gruvbox": "Gruvbox",
"Highlight": "Sorot",
"Horizontal Direction": "Arah Horizontal",
"Hyphenation": "Pemenggalan Kata",
"Identifier:": "Pengenal:",
"Import Books": "Impor Buku",
"Language:": "Bahasa:",
"Layout": "Tata Letak",
@@ -63,7 +59,6 @@
"Match Whole Words": "Cocokkan Kata Utuh",
"Maximum Number of Columns": "Jumlah Kolom Maksimum",
"Minimum Font Size": "Ukuran Font Minimum",
"Misc": "Lain-lain",
"Monospace Font": "Font Monospace",
"More Info": "Info Lebih Lanjut",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Tidak Diketahui",
"Untitled": "Tanpa Judul",
"Updated:": "Diperbarui:",
"User avatar": "Avatar Pengguna",
"Version {{version}}": "Versi {{version}}",
"Vertical Direction": "Arah Vertikal",
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
@@ -299,5 +293,44 @@
"Link Color": "Warna Tautan",
"Volume Keys for Page Flip": "Volume Keys untuk Pembalikan Halaman",
"Clicks for Page Flip": "Klik untuk Pembalikan Halaman",
"Swap Clicks Area": "Area Klik Tukar"
"Swap Clicks Area": "Area Klik Tukar",
"Screen": "Screen",
"Orientation": "Orientasi",
"Portrait": "Potret",
"Landscape": "Lanskap",
"Open Last Book on Start": "Buka Buku Terakhir saat Memulai",
"Checking for updates...": "Memeriksa pembaruan...",
"Error checking for updates": "Kesalahan saat memeriksa pembaruan",
"Details": "Detail",
"File Size:": "Ukuran File:",
"Auto Detect": "Deteksi Otomatis",
"Next Section": "Bagian Selanjutnya",
"Previous Section": "Bagian Sebelumnya",
"Next Page": "Halaman Berikutnya",
"Previous Page": "Halaman Sebelumnya",
"Are you sure to delete {{count}} selected book(s)?_other": "Apakah Anda yakin ingin menghapus {{count}} buku yang dipilih?",
"Are you sure to delete the selected book?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
"Deselect": "Batal",
"Select All": "Pilih Semua",
"No translation available.": "Tidak ada terjemahan yang tersedia.",
"Translated by {{provider}}.": "Terjemahan oleh {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Inversi Gambar di Gelap",
"Help improve Readest": "Bantu tingkatkan Readest",
"Sharing anonymized statistics": "Berbagi statistik anonim",
"Interface Language": "Bahasa Antarmuka",
"Translation": "Terjemahan",
"Enable Translation": "Aktifkan Terjemahan",
"Translation Service": "Layanan Terjemahan",
"Translate To": "Terjemahkan Ke",
"Disable Translation": "Nonaktifkan Terjemahan",
"Scroll": "Scroll",
"Overlap Pixels": "Overlap Piksel",
"Click": "Klik",
"System Language": "Bahasa Sistem",
"Security": "Keamanan",
"Allow JavaScript": "Izinkan JavaScript",
"Enable only if you trust the file.": "Aktifkan hanya jika Anda mempercayai file tersebut."
}
@@ -5,7 +5,6 @@
"Animation": "Animazione",
"Annotate": "Annota",
"Apply": "Applica",
"Are you sure to delete the selected books?": "Sei sicuro di voler eliminare i libri selezionati?",
"Auto Mode": "Modalità automatica",
"Behavior": "Comportamento",
"Book": "Libro",
@@ -42,15 +41,12 @@
"Global Settings": "Impostazioni globali",
"Go Back": "Indietro",
"Go Forward": "Avanti",
"Go Left": "Vai a sinistra",
"Go Right": "Vai a destra",
"Grass": "Erba",
"Gray": "Grigio",
"Gruvbox": "Gruvbox",
"Highlight": "Evidenzia",
"Horizontal Direction": "Direzione orizzontale",
"Hyphenation": "Sillabazione",
"Identifier:": "Identificatore:",
"Import Books": "Importa libri",
"Language:": "Lingua:",
"Layout": "Layout",
@@ -63,7 +59,6 @@
"Match Whole Words": "Parole intere",
"Maximum Number of Columns": "Numero massimo di colonne",
"Minimum Font Size": "Dimensione minima font",
"Misc": "Varie",
"Monospace Font": "Font monospazio",
"More Info": "Maggiori informazioni",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Sconosciuto",
"Untitled": "Senza titolo",
"Updated:": "Aggiornato:",
"User avatar": "Avatar utente",
"Version {{version}}": "Versione {{version}}",
"Vertical Direction": "Direzione verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
@@ -299,5 +293,46 @@
"Link Color": "Colore link",
"Volume Keys for Page Flip": "Tasti volume per girare pagina",
"Clicks for Page Flip": "Clic per girare pagina",
"Swap Clicks Area": "Inverti area dei clic"
"Swap Clicks Area": "Inverti area dei clic",
"Screen": "Schermo",
"Orientation": "Orientamento",
"Portrait": "Ritratto",
"Landscape": "Paesaggio",
"Open Last Book on Start": "Apri ultimo libro all'avvio",
"Checking for updates...": "Controllo aggiornamenti...",
"Error checking for updates": "Errore durante il controllo degli aggiornamenti",
"Details": "Dettagli",
"File Size:": "Dimensione file:",
"Auto Detect": "Rilevamento automatico",
"Next Section": "Sezione successiva",
"Previous Section": "Sezione precedente",
"Next Page": "Pagina successiva",
"Previous Page": "Pagina precedente",
"Are you sure to delete {{count}} selected book(s)?_one": "Sei sicuro di voler eliminare {{count}} libro selezionato?",
"Are you sure to delete {{count}} selected book(s)?_many": "Sei sicuro di voler eliminare {{count}} libri selezionati?",
"Are you sure to delete {{count}} selected book(s)?_other": "Sei sicuro di voler eliminare {{count}} libri selezionati?",
"Are you sure to delete the selected book?": "Sei sicuro di voler eliminare il libro selezionato?",
"Deselect": "Annulla",
"Select All": "Tutti",
"No translation available.": "Nessuna traduzione disponibile.",
"Translated by {{provider}}.": "Tradotto da {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Traduttore Azure",
"Invert Image In Dark Mode": "Inverti immagine in scuro",
"Help improve Readest": "Aiuta a migliorare Readest",
"Sharing anonymized statistics": "Condivisione di statistiche anonime",
"Interface Language": "Lingua dell'interfaccia",
"Translation": "Traduzione",
"Enable Translation": "Abilita traduzione",
"Translation Service": "Servizio di traduzione",
"Translate To": "Traduci in",
"Disable Translation": "Disabilita traduzione",
"Scroll": "Scorri",
"Overlap Pixels": "Pixel di sovrapposizione",
"Click": "Fai clic",
"System Language": "Lingua di sistema",
"Security": "Security",
"Allow JavaScript": "Consenti JavaScript",
"Enable only if you trust the file.": "Abilita solo se ti fidi del file."
}
@@ -5,7 +5,6 @@
"Animation": "アニメーション",
"Annotate": "注釈",
"Apply": "適用",
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
"Auto Mode": "自動モード",
"Behavior": "動作",
"Book": "書籍",
@@ -42,15 +41,12 @@
"Global Settings": "全体設定",
"Go Back": "戻る",
"Go Forward": "進む",
"Go Left": "左へ",
"Go Right": "右へ",
"Grass": "グリーン",
"Gray": "グレー",
"Gruvbox": "グルーブボックス",
"Highlight": "ハイライト",
"Horizontal Direction": "横書",
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
"Language:": "言語:",
"Layout": "レイアウト",
@@ -63,7 +59,6 @@
"Match Whole Words": "単語全体を一致",
"Maximum Number of Columns": "最大列数",
"Minimum Font Size": "最小フォントサイズ",
"Misc": "その他",
"Monospace Font": "等幅フォント",
"More Info": "詳細情報",
"Nord": "ノルド",
@@ -111,7 +106,6 @@
"Unknown": "不明",
"Untitled": "無題",
"Updated:": "更新日:",
"User avatar": "ユーザーアバター",
"Version {{version}}": "バージョン {{version}}",
"Vertical Direction": "縦書",
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
@@ -299,5 +293,44 @@
"Link Color": "リンクカラー",
"Volume Keys for Page Flip": "音量キーでページめくり",
"Clicks for Page Flip": "タップでページめくり",
"Swap Clicks Area": "タップ領域を入れ替え"
"Swap Clicks Area": "タップ領域を入れ替え",
"Screen": "画面",
"Orientation": "向き",
"Portrait": "縦",
"Landscape": "横",
"Open Last Book on Start": "開始時に最後の書籍を開く",
"Checking for updates...": "更新を確認中...",
"Error checking for updates": "更新の確認中にエラーが発生しました",
"Details": "詳細",
"File Size:": "ファイルサイズ:",
"Auto Detect": "自動検出",
"Next Section": "次のセクション",
"Previous Section": "前のセクション",
"Next Page": "次のページ",
"Previous Page": "前のページ",
"Are you sure to delete {{count}} selected book(s)?_other": "選択した{{count}}冊の本を削除してもよろしいですか?",
"Are you sure to delete the selected book?": "選択した本を削除してもよろしいですか?",
"Deselect": "解除",
"Select All": "全選択",
"No translation available.": "翻訳はありません。",
"Translated by {{provider}}.": "{{provider}}によって翻訳されました。",
"DeepL": "DeepL",
"Google Translate": "Google翻訳",
"Azure Translator": "Azure翻訳",
"Invert Image In Dark Mode": "ダークモードで画像を反転",
"Help improve Readest": "Readestを改善する手助け",
"Sharing anonymized statistics": "匿名化された統計情報を共有",
"Interface Language": "インターフェースの言語",
"Translation": "翻訳",
"Enable Translation": "翻訳を有効にする",
"Translation Service": "翻訳サービス",
"Translate To": "翻訳先",
"Disable Translation": "翻訳を無効にする",
"Scroll": "スクロール",
"Overlap Pixels": "重なりピクセル",
"Click": "クリック",
"System Language": "システム言語",
"Security": "セキュリティ",
"Allow JavaScript": "JavaScriptを許可",
"Enable only if you trust the file.": "ファイルを信頼する場合にのみ有効にしてください。"
}
@@ -5,7 +5,6 @@
"Animation": "애니메이션",
"Annotate": "주석",
"Apply": "적용",
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
"Auto Mode": "자동 모드",
"Behavior": "동작",
"Book": "책",
@@ -42,15 +41,12 @@
"Global Settings": "전역 설정",
"Go Back": "뒤로",
"Go Forward": "앞으로",
"Go Left": "왼쪽으로",
"Go Right": "오른쪽으로",
"Grass": "잔디색",
"Gray": "회색",
"Gruvbox": "그러브박스",
"Highlight": "하이라이트",
"Horizontal Direction": "수평 방향",
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
"Language:": "언어:",
"Layout": "레이아웃",
@@ -63,7 +59,6 @@
"Match Whole Words": "전체 단어 일치",
"Maximum Number of Columns": "최대 열 수",
"Minimum Font Size": "최소 글꼴 크기",
"Misc": "기타",
"Monospace Font": "고정폭 글꼴",
"More Info": "추가 정보",
"Nord": "노드",
@@ -111,7 +106,6 @@
"Unknown": "알 수 없음",
"Untitled": "제목 없음",
"Updated:": "업데이트일:",
"User avatar": "사용자 아바타",
"Version {{version}}": "버전 {{version}}",
"Vertical Direction": "수직 방향",
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
@@ -299,5 +293,44 @@
"Link Color": "링크 색상",
"Volume Keys for Page Flip": "페이지 넘김을 위한 볼륨 키",
"Clicks for Page Flip": "페이지 넘김을 위한 클릭",
"Swap Clicks Area": "클릭 영역 교환"
"Swap Clicks Area": "클릭 영역 교환",
"Screen": "화면",
"Orientation": "방향",
"Portrait": "세로",
"Landscape": "가로",
"Open Last Book on Start": "시작 시 마지막 책 열기",
"Checking for updates...": "업데이트 확인 중...",
"Error checking for updates": "업데이트 확인 중 오류 발생",
"Details": "세부정보",
"File Size:": "파일 크기:",
"Auto Detect": "자동 감지",
"Next Section": "다음 섹션",
"Previous Section": "이전 섹션",
"Next Page": "다음 페이지",
"Previous Page": "이전 페이지",
"Are you sure to delete {{count}} selected book(s)?_other": "선택한 {{count}}권의 책을 삭제하시겠습니까?",
"Are you sure to delete the selected book?": "선택한 책을 삭제하시겠습니까?",
"Deselect": "해제",
"Select All": "전체선택",
"No translation available.": "번역이 없습니다.",
"Translated by {{provider}}.": "{{provider}}에 의해 번역됨.",
"DeepL": "DeepL",
"Google Translate": "구글 번역",
"Azure Translator": "Azure 번역기",
"Invert Image In Dark Mode": "어두운 곳에서 이미지 반전",
"Help improve Readest": "Readest 개선에 도움을 주세요",
"Sharing anonymized statistics": "익명화된 통계 공유",
"Interface Language": "인터페이스 언어",
"Translation": "번역",
"Enable Translation": "번역 활성화",
"Translation Service": "번역 서비스",
"Translate To": "번역 대상 언어",
"Disable Translation": "번역 비활성화",
"Scroll": "스크롤",
"Overlap Pixels": "겹치는 픽셀",
"Click": "클릭",
"System Language": "시스템 언어",
"Security": "보안",
"Allow JavaScript": "JavaScript 허용",
"Enable only if you trust the file.": "파일을 신뢰하는 경우에만 활성화하세요."
}
@@ -0,0 +1,337 @@
{
"Email address": "E-mailadres",
"Your Password": "Uw wachtwoord",
"Your email address": "Uw e-mailadres",
"Your password": "Uw wachtwoord",
"Sign in": "Inloggen",
"Signing in...": "Bezig met inloggen...",
"Sign in with {{provider}}": "Inloggen met {{provider}}",
"Already have an account? Sign in": "Heeft u al een account? Log in",
"Create a Password": "Maak een wachtwoord aan",
"Sign up": "Registreren",
"Signing up...": "Bezig met registreren...",
"Check your email for the confirmation link": "Controleer uw e-mail voor de bevestigingslink",
"Signing in ...": "Bezig met inloggen ...",
"Send a magic link email": "Stuur een magic link e-mail",
"Check your email for the magic link": "Controleer uw e-mail voor de magic link",
"Send reset password instructions": "Stuur instructies voor wachtwoordherstel",
"Sending reset instructions ...": "Bezig met versturen van herstelinstructies ...",
"Forgot your password?": "Wachtwoord vergeten?",
"Check your email for the password reset link": "Controleer uw e-mail voor de link om uw wachtwoord te herstellen",
"Phone number": "Telefoonnummer",
"Your phone number": "Uw telefoonnummer",
"Token": "Token",
"Your OTP token": "Uw OTP-token",
"Verify token": "Token verifiëren",
"Sign in with Google": "Inloggen met Google",
"Sign in with Apple": "Inloggen met Apple",
"Sign in with GitHub": "Inloggen met GitHub",
"New Password": "Nieuw wachtwoord",
"Your new password": "Uw nieuwe wachtwoord",
"Update password": "Wachtwoord bijwerken",
"Updating password ...": "Wachtwoord bijwerken ...",
"Your password has been updated": "Uw wachtwoord is bijgewerkt",
"Open": "Openen",
"Group": "Groep",
"Details": "Details",
"Delete": "Verwijderen",
"Cancel": "Annuleren",
"Confirm Deletion": "Verwijdering bevestigen",
"Deselect Book": "Boek deselecteren",
"Select Book": "Boek selecteren",
"Show Book Details": "Boekdetails weergeven",
"Download Book": "Boek downloaden",
"Upload Book": "Boek uploaden",
"Deselect Group": "Groep deselecteren",
"Select Group": "Groep selecteren",
"Untitled Group": "Groep zonder titel",
"Group Books": "Boeken groeperen",
"Remove From Group": "Uit groep verwijderen",
"Create New Group": "Nieuwe groep maken",
"Save": "Opslaan",
"Confirm": "Bevestigen",
"From Local File": "Van lokaal bestand",
"Go Back": "Terug",
"Search Books...": "Boeken zoeken...",
"Clear Search": "Zoekopdracht wissen",
"Import Books": "Boeken importeren",
"Select Multiple Books": "Meerdere boeken selecteren",
"Select Books": "Boeken selecteren",
"Storage": "Opslag",
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% van cloudopslag gebruikt.",
"Logged in as {{userDisplayName}}": "Ingelogd als {{userDisplayName}}",
"Logged in": "Ingelogd",
"Account": "Account",
"Sign In": "Inloggen",
"Auto Upload Books to Cloud": "Boeken automatisch uploaden naar cloud",
"Auto Import on File Open": "Automatisch importeren bij bestand openen",
"Open Last Book on Start": "Open laatste boek bij opstarten",
"Check Updates on Start": "Controleer updates bij opstarten",
"Fullscreen": "Volledig scherm",
"Always on Top": "Altijd bovenaan",
"Keep Screen Awake": "Scherm wakker houden",
"Reload Page": "Pagina herladen",
"Download Readest": "Readest downloaden",
"About Readest": "Over Readest",
"List": "Lijst",
"Grid": "Raster",
"Title": "Titel",
"Author": "Auteur",
"Format": "Formaat",
"Date Read": "Datum gelezen",
"Date Added": "Datum toegevoegd",
"Ascending": "Oplopend",
"Descending": "Aflopend",
"Sort by...": "Sorteren op...",
"No supported files found. Supported formats: {{formats}}": "Geen ondersteunde bestanden gevonden. Ondersteunde formaten: {{formats}}",
"No chapters detected.": "Geen hoofdstukken gedetecteerd.",
"Failed to parse the EPUB file.": "EPUB-bestand kon niet worden verwerkt.",
"This book format is not supported.": "Dit boekformaat wordt niet ondersteund.",
"Failed to import book(s): {{filenames}}": "Importeren van boek(en) mislukt: {{filenames}}",
"Book uploaded: {{title}}": "Boek geüpload: {{title}}",
"Insufficient storage quota": "Onvoldoende opslagruimte",
"Failed to upload book: {{title}}": "Uploaden van boek mislukt: {{title}}",
"Book downloaded: {{title}}": "Boek gedownload: {{title}}",
"Failed to download book: {{title}}": "Downloaden van boek mislukt: {{title}}",
"Book deleted: {{title}}": "Boek verwijderd: {{title}}",
"Failed to delete book: {{title}}": "Verwijderen van boek mislukt: {{title}}",
"Your Library": "Uw bibliotheek",
"Welcome to your library. You can import your books here and read them anytime.": "Welkom bij uw bibliotheek. U kunt hier uw boeken importeren en ze op elk moment lezen.",
"Copied to notebook": "Gekopieerd naar notitieblok",
"No annotations to export": "Geen annotaties om te exporteren",
"Exported from Readest": "Geëxporteerd uit Readest",
"Highlights & Annotations": "Markeringen & annotaties",
"Untitled": "Zonder titel",
"Note": "Notitie",
"Copied to clipboard": "Gekopieerd naar klembord",
"Copy": "Kopiëren",
"Delete Highlight": "Markering verwijderen",
"Highlight": "Markeren",
"Annotate": "Annoteren",
"Search": "Zoeken",
"Dictionary": "Woordenboek",
"Wikipedia": "Wikipedia",
"Translate": "Vertalen",
"Speak": "Voorlezen",
"Unable to fetch the translation. Please log in first and try again.": "Kan de vertaling niet ophalen. Log eerst in en probeer het opnieuw.",
"Unable to fetch the translation. Try again later.": "Kan de vertaling niet ophalen. Probeer het later opnieuw.",
"Original Text": "Originele tekst",
"Auto Detect": "Automatisch detecteren",
"(detected)": "(gedetecteerd)",
"Translated Text": "Vertaalde tekst",
"Loading...": "Laden...",
"Bookmark": "Bladwijzer",
"Go Forward": "Vooruit",
"Small": "Klein",
"Large": "Groot",
"Notebook": "Notitieblok",
"Excerpts": "Fragmenten",
"Notes": "Notities",
"Add your notes here...": "Voeg hier uw notities toe...",
"Theme Mode": "Thema modus",
"Auto Mode": "Automatische modus",
"Light Mode": "Lichte modus",
"Dark Mode": "Donkere modus",
"Theme Color": "Themakleur",
"Custom": "Aangepast",
"Apply to All Books": "Toepassen op alle boeken",
"Apply to This Book": "Toepassen op dit boek",
"Global Settings": "Algemene instellingen",
"System Fonts": "Systeemlettertypen",
"Serif Font": "Serif lettertype",
"Sans-Serif Font": "Sans-serif lettertype",
"Font Size": "Lettergrootte",
"Default Font Size": "Standaard lettergrootte",
"Minimum Font Size": "Minimale lettergrootte",
"Font Weight": "Letterdikte",
"Font Family": "Lettertypefamilie",
"Default Font": "Standaard lettertype",
"CJK Font": "CJK lettertype",
"Override Book Font": "Boek lettertype overschrijven",
"Font Face": "Lettertype",
"Monospace Font": "Monospace lettertype",
"Scrolled Mode": "Scroll-modus",
"Writing Mode": "Schrijfmodus",
"Default": "Standaard",
"Horizontal Direction": "Horizontale richting",
"Vertical Direction": "Verticale richting",
"RTL Direction": "RTL-richting",
"Border Frame": "Randkader",
"Double Border": "Dubbele rand",
"Border Color": "Randkleur",
"Paragraph": "Alinea",
"Paragraph Margin": "Alinea marge",
"Line Spacing": "Regelafstand",
"Word Spacing": "Woordafstand",
"Letter Spacing": "Letterafstand",
"Text Indent": "Tekstinspringing",
"Full Justification": "Volledig uitvullen",
"Hyphenation": "Woordafbreking",
"Override Book Layout": "Boekopmaak overschrijven",
"Page": "Pagina",
"Vertical Margins (px)": "Verticale marges (px)",
"Horizontal Margins (%)": "Horizontale marges (%)",
"Maximum Number of Columns": "Maximum aantal kolommen",
"Maximum Column Height": "Maximale kolomhoogte",
"Maximum Column Width": "Maximale kolombreedte",
"Header & Footer": "Koptekst & voettekst",
"Show Header": "Koptekst weergeven",
"Show Footer": "Voettekst weergeven",
"Apply also in Scrolled Mode": "Ook toepassen in scroll-modus",
"Auto": "Automatisch",
"Language": "Taal",
"Animation": "Animatie",
"Paging Animation": "Paginering animatie",
"Screen": "Scherm",
"Orientation": "Oriëntatie",
"Portrait": "Portret",
"Landscape": "Landschap",
"Behavior": "Gedrag",
"Continuous Scroll": "Doorlopend scrollen",
"Volume Keys for Page Flip": "Volumetoetsen voor pagina omslaan",
"Clicks for Page Flip": "Klikken voor pagina omslaan",
"Swap Clicks Area": "Klikgebieden omwisselen",
"Custom CSS": "Aangepaste CSS",
"Enter your custom CSS here...": "Voer hier uw aangepaste CSS in...",
"Apply": "Toepassen",
"Font": "Lettertype",
"Layout": "Opmaak",
"Color": "Kleur",
"(from 'As You Like It', Act II)": "(uit 'As You Like It', Akte II)",
"Custom Theme": "Aangepast thema",
"Theme Name": "Themanaam",
"Text Color": "Tekstkleur",
"Background Color": "Achtergrondkleur",
"Link Color": "Linkkleur",
"Preview": "Voorbeeld",
"Font & Layout": "Lettertype & opmaak",
"Book Cover": "Boekomslag",
"More Info": "Meer informatie",
"Parallel Read": "Parallel lezen",
"Export Annotations": "Annotaties exporteren",
"Edit": "Bewerken",
"Search...": "Zoeken...",
"Book": "Boek",
"Chapter": "Hoofdstuk",
"Match Case": "Hoofdlettergevoelig",
"Match Whole Words": "Hele woorden",
"Match Diacritics": "Diakritische tekens",
"TOC": "Inhoudsopgave",
"Sidebar": "Zijbalk",
"TTS not supported for PDF": "TTS niet ondersteund voor PDF",
"TTS not supported in this device": "TTS niet ondersteund op dit apparaat",
"No Timeout": "Geen time-out",
"{{value}} minute": "{{value}} minuut",
"{{value}} minutes": "{{value}} minuten",
"{{value}} hour": "{{value}} uur",
"{{value}} hours": "{{value}} uren",
"Slow": "Langzaam",
"Fast": "Snel",
"Reading Progress Synced": "Leesvoortgang gesynchroniseerd",
"Cloud Storage": "Cloudopslag",
"Failed to delete user. Please try again later.": "Gebruiker verwijderen mislukt. Probeer het later opnieuw.",
"Free Tier": "Gratis niveau",
"Community Support": "Community-ondersteuning",
"DeepL Free Access": "DeepL gratis toegang",
"Essential Text-to-Speech Voices": "Essentiële tekst-naar-spraak stemmen",
"Unlimited Offline/Online Reading": "Onbeperkt offline/online lezen",
"Unlimited Cloud Sync Devices": "Onbeperkt aantal cloud-synchronisatie apparaten",
"500 MB Cloud Sync Space": "500 MB cloud-synchronisatieruimte",
"Plus Tier": "Plus niveau",
"Includes All Free Tier Benefits": "Inclusief alle voordelen van het gratis niveau",
"Priority Support": "Prioriteitsondersteuning",
"AI Summaries": "AI-samenvattingen",
"AI Translations": "AI-vertalingen",
"DeepL Pro Access": "DeepL Pro toegang",
"Expanded Text-to-Speech Voices": "Uitgebreide tekst-naar-spraak stemmen",
"2000 MB Cloud Sync Space": "2000 MB cloud-synchronisatieruimte",
"Pro Tier": "Pro niveau",
"Includes All Plus Tier Benefits": "Inclusief alle voordelen van het Plus niveau",
"Unlimited AI Summaries": "Onbeperkte AI-samenvattingen",
"Unlimited AI Translations": "Onbeperkte AI-vertalingen",
"Unlimited DeepL Pro Access": "Onbeperkte DeepL Pro toegang",
"Advanced AI Tools": "Geavanceerde AI-tools",
"Early Feature Access": "Vroege toegang tot functies",
"10 GB Cloud Sync Space": "10 GB cloud-synchronisatieruimte",
"Loading profile...": "Profiel laden...",
"Features": "Functies",
"Sign Out": "Uitloggen",
"Delete Account": "Account verwijderen",
"Delete Your Account?": "Uw account verwijderen?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Deze actie kan niet ongedaan worden gemaakt. Al uw gegevens in de cloud worden permanent verwijderd.",
"Delete Permanently": "Permanent verwijderen",
"Version {{version}}": "Versie {{version}}",
"Check Update": "Update controleren",
"Already the latest version": "Reeds de nieuwste versie",
"Checking for updates...": "Controleren op updates...",
"Error checking for updates": "Fout bij controleren op updates",
"Book Details": "Boekdetails",
"Unknown": "Onbekend",
"Publisher:": "Uitgever:",
"Published:": "Gepubliceerd:",
"Updated:": "Bijgewerkt:",
"Added:": "Toegevoegd:",
"Language:": "Taal:",
"Subjects:": "Onderwerpen:",
"Format:": "Formaat:",
"File Size:": "Bestandsgrootte:",
"Description:": "Beschrijving:",
"No description available": "Geen beschrijving beschikbaar",
"Drop to Import Books": "Sleep om boeken te importeren",
"A new version of Readest is Available!": "Een nieuwe versie van Readest is beschikbaar!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} is beschikbaar (geïnstalleerde versie {{currentVersion}}).",
"Download and install now?": "Nu downloaden en installeren?",
"Downloading {{downloaded}} of {{contentLength}}": "Downloaden {{downloaded}} van {{contentLength}}",
"Download finished": "Download voltooid",
"DOWNLOAD & INSTALL": "DOWNLOADEN & INSTALLEREN",
"Changelog:": "Wijzigingslogboek:",
"Software Update": "Software-update",
"LXGW WenKai GB Screen": "LXGW WenKai GB Scherm",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Gray": "Grijs",
"Sepia": "Sepia",
"Grass": "Gras",
"Cherry": "Kers",
"Sky": "Hemel",
"Solarized": "Gesolariseerd",
"Gruvbox": "Gruvbox",
"Nord": "Noord",
"Contrast": "Contrast",
"Sunset": "Zonsondergang",
"Reveal in Finder": "Tonen in Finder",
"Reveal in File Explorer": "Tonen in Verkenner",
"Reveal in Folder": "Tonen in map",
"Dont have an account? Sign up": "Nog geen account? Registreer u",
"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": "De hele wereld is een toneel,\nEn alle mannen en vrouwen slechts spelers;\nZij hebben hun uitgangen en hun entrees,\nEn één man speelt in zijn tijd vele rollen,\nZijn daden zijnde zeven leeftijden.\n\n— William Shakespeare",
"Next Section": "Volgende sectie",
"Previous Section": "Vorige sectie",
"Next Page": "Volgende pagina",
"Previous Page": "Vorige pagina",
"Are you sure to delete {{count}} selected book(s)?_one": "Weet je zeker dat je {{count}} geselecteerd boek wilt verwijderen?",
"Are you sure to delete {{count}} selected book(s)?_other": "Weet je zeker dat je {{count}} geselecteerde boeken wilt verwijderen?",
"Are you sure to delete the selected book?": "Weet je zeker dat je het geselecteerde boek wilt verwijderen?",
"Deselect": "Deselecteer",
"Select All": "Alles",
"No translation available.": "Geen vertaling beschikbaar.",
"Translated by {{provider}}.": "Vertaald door {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Vertalen",
"Azure Translator": "Azure Vertaler",
"Invert Image In Dark Mode": "Inverteer afbeelding in het donker",
"Help improve Readest": "Help Readest verbeteren",
"Sharing anonymized statistics": "Anonieme statistieken delen",
"Interface Language": "Interface-taal",
"Translation": "Vertaling",
"Enable Translation": "Vertaling inschakelen",
"Translation Service": "Vertaalservice",
"Translate To": "Vertalen naar",
"Disable Translation": "Vertaling uitschakelen",
"Scroll": "Scrollen",
"Overlap Pixels": "Overlap pixels",
"Click": "Klik",
"System Language": "Systemtaal",
"Security": "Beveiliging",
"Allow JavaScript": "JavaScript toestaan",
"Enable only if you trust the file.": "Schakel alleen in als u het bestand vertrouwt."
}
@@ -5,7 +5,6 @@
"Animation": "Animacja",
"Annotate": "Adnotacja",
"Apply": "Zastosuj",
"Are you sure to delete the selected books?": "Czy na pewno chcesz usunąć wybrane książki?",
"Auto Mode": "Tryb automatyczny",
"Behavior": "Zachowanie",
"Book": "Książka",
@@ -42,15 +41,12 @@
"Global Settings": "Ustawienia globalne",
"Go Back": "Wstecz",
"Go Forward": "Dalej",
"Go Left": "W lewo",
"Go Right": "W prawo",
"Grass": "Trawiasty",
"Gray": "Szary",
"Gruvbox": "Ciepły pomarańczowy",
"Highlight": "Zaznacz",
"Horizontal Direction": "Kierunek poziomy",
"Hyphenation": "Dzielenie wyrazów",
"Identifier:": "Identyfikator:",
"Import Books": "Importuj książki",
"Language:": "Język:",
"Layout": "Układ",
@@ -63,7 +59,6 @@
"Match Whole Words": "Uwzględnij całe słowa",
"Maximum Number of Columns": "Maksymalna liczba kolumn",
"Minimum Font Size": "Minimalny rozmiar czcionki",
"Misc": "Różne",
"Monospace Font": "Czcionka o stałej szerokości",
"More Info": "Więcej informacji",
"Nord": "Północny",
@@ -111,7 +106,6 @@
"Unknown": "Nieznane",
"Untitled": "Bez tytułu",
"Updated:": "Zaktualizowano:",
"User avatar": "Awatar użytkownika",
"Version {{version}}": "Wersja {{version}}",
"Vertical Direction": "Kierunek pionowy",
"Welcome to your library. You can import your books here and read them anytime.": "Witaj w swojej bibliotece. Możesz tutaj zaimportować swoje książki i czytać je w dowolnym momencie.",
@@ -299,5 +293,47 @@
"Link Color": "Kolor linku",
"Volume Keys for Page Flip": "Klucze głośności do przewracania stron",
"Clicks for Page Flip": "Kliknięcia do przewracania stron",
"Swap Clicks Area": "Zamień obszar kliknięcia"
"Swap Clicks Area": "Zamień obszar kliknięcia",
"Screen": "Ekran",
"Orientation": "Orientacja",
"Portrait": "Portret",
"Landscape": "Krajobraz",
"Open Last Book on Start": "Otwórz ostatnią książkę przy starcie",
"Checking for updates...": "Sprawdzanie aktualizacji...",
"Error checking for updates": "Błąd podczas sprawdzania aktualizacji",
"Details": "Szczegóły",
"File Size:": "Rozmiar pliku:",
"Auto Detect": "Automatyczne wykrywanie",
"Next Section": "Następna sekcja",
"Previous Section": "Poprzednia sekcja",
"Next Page": "Następna strona",
"Previous Page": "Poprzednia strona",
"Are you sure to delete {{count}} selected book(s)?_one": "Czy na pewno chcesz usunąć {{count}} wybraną książkę?",
"Are you sure to delete {{count}} selected book(s)?_few": "Czy na pewno chcesz usunąć {{count}} wybrane książki?",
"Are you sure to delete {{count}} selected book(s)?_many": "Czy na pewno chcesz usunąć {{count}} wybranych książek?",
"Are you sure to delete {{count}} selected book(s)?_other": "Czy na pewno chcesz usunąć {{count}} wybranych książek?",
"Are you sure to delete the selected book?": "Czy na pewno chcesz usunąć wybraną książkę?",
"Deselect": "Odznacz",
"Select All": "Zaznacz",
"No translation available.": "Brak dostępnego tłumaczenia.",
"Translated by {{provider}}.": "Przetłumaczone przez {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Tłumacz Google",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Odwróć obraz w ciemności",
"Help improve Readest": "Pomóż ulepszyć Readest",
"Sharing anonymized statistics": "Udostępnianie zanonimizowanych statystyk",
"Interface Language": "Język interfejsu",
"Translation": "Tłumaczenie",
"Enable Translation": "Włącz tłumaczenie",
"Translation Service": "Usługa tłumaczenia",
"Translate To": "Przetłumacz na",
"Disable Translation": "Wyłącz tłumaczenie",
"Scroll": "Przewijaj",
"Overlap Pixels": "Przesunięcie pikseli",
"Click": "Kliknij",
"System Language": "Język systemu",
"Security": "Bezpieczeństwo",
"Allow JavaScript": "Zezwól na JavaScript",
"Enable only if you trust the file.": "Zezwól tylko wtedy, gdy ufasz plikowi."
}
@@ -5,7 +5,6 @@
"Animation": "Animação",
"Annotate": "Anotar",
"Apply": "Aplicar",
"Are you sure to delete the selected books?": "Tem certeza de que deseja excluir os livros selecionados?",
"Auto Mode": "Modo Automático",
"Behavior": "Comportamento",
"Book": "Livro",
@@ -42,15 +41,12 @@
"Global Settings": "Configurações Globais",
"Go Back": "Voltar",
"Go Forward": "Avançar",
"Go Left": "Ir para Esquerda",
"Go Right": "Ir para Direita",
"Grass": "Grama",
"Gray": "Cinza",
"Gruvbox": "Gruvbox",
"Highlight": "Destaque",
"Horizontal Direction": "Direção Horizontal",
"Hyphenation": "Hifenização",
"Identifier:": "Identificador:",
"Import Books": "Importar Livros",
"Language:": "Idioma:",
"Layout": "Layout",
@@ -63,7 +59,6 @@
"Match Whole Words": "Corresponder Palavras Inteiras",
"Maximum Number of Columns": "Número Máximo de Colunas",
"Minimum Font Size": "Tamanho Mínimo da Fonte",
"Misc": "Diversos",
"Monospace Font": "Fonte Monoespaçada",
"More Info": "Mais Informações",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Desconhecido",
"Untitled": "Sem Título",
"Updated:": "Atualizado:",
"User avatar": "Avatar do usuário",
"Version {{version}}": "Versão {{version}}",
"Vertical Direction": "Direção Vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
@@ -299,5 +293,46 @@
"Link Color": "Cor do Link",
"Volume Keys for Page Flip": "Teclas de Volume para Virar Página",
"Clicks for Page Flip": "Cliques para Virar Página",
"Swap Clicks Area": "Trocar Área de Cliques"
"Swap Clicks Area": "Trocar Área de Cliques",
"Screen": "Tela",
"Orientation": "Orientação",
"Portrait": "Retrato",
"Landscape": "Paisagem",
"Open Last Book on Start": "Abrir o Último Livro ao Iniciar",
"Checking for updates...": "Verificando atualizações...",
"Error checking for updates": "Erro ao verificar atualizações",
"Details": "Detalhes",
"File Size:": "Tamanho do arquivo:",
"Auto Detect": "Detectar Automaticamente",
"Next Section": "Próxima seção",
"Previous Section": "Seção anterior",
"Next Page": "Próxima página",
"Previous Page": "Página anterior",
"Are you sure to delete {{count}} selected book(s)?_one": "Tem certeza que deseja excluir {{count}} livro selecionado?",
"Are you sure to delete {{count}} selected book(s)?_many": "Tem certeza que deseja excluir {{count}} livros selecionados?",
"Are you sure to delete {{count}} selected book(s)?_other": "Tem certeza que deseja excluir {{count}} livros selecionados?",
"Are you sure to delete the selected book?": "Tem certeza que deseja excluir o livro selecionado?",
"Deselect": "Desmarcar",
"Select All": "Marcar tudo",
"No translation available.": "Sem tradução disponível.",
"Translated by {{provider}}.": "Traduzido por {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Tradutor",
"Azure Translator": "Tradutor Azure",
"Invert Image In Dark Mode": "Inverter Imagem no Escuro",
"Help improve Readest": "Ajude a melhorar o Readest",
"Sharing anonymized statistics": "Compartilhando estatísticas anonimizadas",
"Interface Language": "Idioma da interface",
"Translation": "Tradução",
"Enable Translation": "Ativar tradução",
"Translation Service": "Serviço de tradução",
"Translate To": "Traduzir para",
"Disable Translation": "Desativar tradução",
"Scroll": "Rolar",
"Overlap Pixels": "Sobreposição de Pixels",
"Click": "Clique",
"System Language": "Idioma do Sistema",
"Security": "Segurança",
"Allow JavaScript": "Permitir JavaScript",
"Enable only if you trust the file.": "Ativar apenas se você confiar no arquivo."
}
@@ -5,7 +5,6 @@
"Animation": "Анимация",
"Annotate": "Аннотация",
"Apply": "Применить",
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
"Auto Mode": "Автоматический режим",
"Behavior": "Поведение",
"Book": "Книга",
@@ -42,15 +41,12 @@
"Global Settings": "Общие настройки",
"Go Back": "Назад",
"Go Forward": "Вперёд",
"Go Left": "Влево",
"Go Right": "Вправо",
"Grass": "Трава",
"Gray": "Серый",
"Gruvbox": "Gruvbox",
"Highlight": "Выделить",
"Horizontal Direction": "Горизонтальное направление",
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
"Language:": "Язык:",
"Layout": "Макет",
@@ -63,7 +59,6 @@
"Match Whole Words": "Только целые слова",
"Maximum Number of Columns": "Максимальное количество колонок",
"Minimum Font Size": "Минимальный размер шрифта",
"Misc": "Разное",
"Monospace Font": "Моноширинный шрифт",
"More Info": "Подробнее",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Неизвестно",
"Untitled": "Без названия",
"Updated:": "Обновлено:",
"User avatar": "Аватар пользователя",
"Version {{version}}": "Версия {{version}}",
"Vertical Direction": "Вертикальное направление",
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
@@ -299,5 +293,47 @@
"Link Color": "Цвет ссылки",
"Volume Keys for Page Flip": "Клавиши громкости для перелистывания страниц",
"Clicks for Page Flip": "Клики для перелистывания страниц",
"Swap Clicks Area": "Переключить область кликов"
"Swap Clicks Area": "Переключить область кликов",
"Screen": "Экран",
"Orientation": "Ориентация",
"Portrait": "Портрет",
"Landscape": "Ландшафт",
"Open Last Book on Start": "Открыть последнюю книгу при запуске",
"Checking for updates...": "Проверка обновлений...",
"Error checking for updates": "Ошибка при проверке обновлений",
"Details": "Инфо",
"File Size:": "Размер файла:",
"Auto Detect": "Автоопределение",
"Next Section": "Следующий раздел",
"Previous Section": "Предыдущий раздел",
"Next Page": "Следующая страница",
"Previous Page": "Предыдущая страница",
"Are you sure to delete {{count}} selected book(s)?_one": "Вы уверены, что хотите удалить {{count}} выбранную книгу?",
"Are you sure to delete {{count}} selected book(s)?_few": "Вы уверены, что хотите удалить {{count}} выбранные книги?",
"Are you sure to delete {{count}} selected book(s)?_many": "Вы уверены, что хотите удалить {{count}} выбранных книг?",
"Are you sure to delete {{count}} selected book(s)?_other": "Вы уверены, что хотите удалить {{count}} выбранных книг?",
"Are you sure to delete the selected book?": "Вы уверены, что хотите удалить выбранную книгу?",
"Deselect": "Снять",
"Select All": "Выбрать все",
"No translation available.": "Нет доступного перевода.",
"Translated by {{provider}}.": "Переведено {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Инвертировать в тёмной теме",
"Help improve Readest": "Помогите улучшить Readest",
"Sharing anonymized statistics": "Отправка анонимной статистики",
"Interface Language": "Язык интерфейса",
"Translation": "Перевод",
"Enable Translation": "Включить перевод",
"Translation Service": "Сервис перевода",
"Translate To": "Перевести на",
"Disable Translation": "Отключить перевод",
"Scroll": "Прокрутка",
"Overlap Pixels": "Перекрывающиеся пиксели",
"Click": "Клик",
"System Language": "Системный язык",
"Security": "Безопасность",
"Allow JavaScript": "Разрешить JavaScript",
"Enable only if you trust the file.": "Включите только если доверяете файлу."
}
@@ -5,7 +5,6 @@
"Animation": "Animasyon",
"Annotate": "Not Ekle",
"Apply": "Uygula",
"Are you sure to delete the selected books?": "Seçili kitapları silmek istediğinizden emin misiniz?",
"Auto Mode": "Otomatik Mod",
"Behavior": "Davranış",
"Book": "Kitap",
@@ -42,15 +41,12 @@
"Global Settings": "Genel Ayarlar",
"Go Back": "Geri Git",
"Go Forward": "İleri Git",
"Go Left": "Sola Git",
"Go Right": "Sağa Git",
"Grass": "Çimen",
"Gray": "Gri",
"Gruvbox": "Gruvbox",
"Highlight": "Vurgula",
"Horizontal Direction": "Yatay Yön",
"Hyphenation": "Heceleme",
"Identifier:": "Tanımlayıcı:",
"Import Books": "Kitapları İçe Aktar",
"Language:": "Dil:",
"Layout": "Düzen",
@@ -63,7 +59,6 @@
"Match Whole Words": "Tam Kelimeleri Eşleştir",
"Maximum Number of Columns": "Maksimum Sütun Sayısı",
"Minimum Font Size": "Minimum Yazı Boyutu",
"Misc": "Diğer",
"Monospace Font": "Eş Aralıklı Yazı Tipi",
"More Info": "Daha Fazla Bilgi",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Bilinmiyor",
"Untitled": "Başlıksız",
"Updated:": "Güncellendi:",
"User avatar": "Kullanıcı avatarı",
"Version {{version}}": "Sürüm {{version}}",
"Vertical Direction": "Dikey Yön",
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
@@ -299,5 +293,45 @@
"Link Color": "Bağlantı Rengi",
"Volume Keys for Page Flip": "Sayfa Çevirme için Ses Seviyesi Tuşları",
"Clicks for Page Flip": "Sayfa Çevirme için Tıklamalar",
"Swap Clicks Area": "Tıklama Alanını Değiştir"
"Swap Clicks Area": "Tıklama Alanını Değiştir",
"Screen": "Ekran",
"Orientation": "Yönlendirme",
"Portrait": "Portre",
"Landscape": "Yatay",
"Open Last Book on Start": "Açılışta Son Kitabı Aç",
"Checking for updates...": "Yenilikler kontrol ediliyor...",
"Error checking for updates": "Güncellemeleri kontrol ederken hata oluştu",
"Details": "Detaylar",
"File Size:": "Dosya Boyutu:",
"Auto Detect": "Algıla",
"Next Section": "Sonraki Bölüm",
"Previous Section": "Önceki Bölüm",
"Next Page": "Sonraki Sayfa",
"Previous Page": "Önceki Sayfa",
"Are you sure to delete {{count}} selected book(s)?_one": "{{count}} seçili kitabı silmek istediğinize emin misiniz?",
"Are you sure to delete {{count}} selected book(s)?_other": "{{count}} seçili kitabı silmek istediğinize emin misiniz?",
"Are you sure to delete the selected book?": "Seçili kitabı silmek istediğinize emin misiniz?",
"Deselect": "Kaldır",
"Select All": "Hepsi",
"No translation available.": "Çeviri mevcut değil.",
"Translated by {{provider}}.": "{{provider}} tarafından çevrildi.",
"DeepL": "DeepL",
"Google Translate": "Google Çeviri",
"Azure Translator": "Azure Çevirmen",
"Invert Image In Dark Mode": "Karartılmış Görüntüyü Ters Çevir",
"Help improve Readest": "Readesti geliştirin",
"Sharing anonymized statistics": "Anonim verileri paylaş",
"Interface Language": "Arayüz Dili",
"Translation": "Çeviri",
"Enable Translation": "Çeviriyi Etkinleştir",
"Translation Service": "Çeviri Hizmeti",
"Translate To": "Şuna Çevir",
"Disable Translation": "Çeviriyi Devre Dışı Bırak",
"Scroll": "Kaydır",
"Overlap Pixels": "Örtüşen Pikseller",
"Click": "Klik",
"System Language": "System Dili",
"Security": "Security",
"Allow JavaScript": "JavaScript'e İzin Ver",
"Enable only if you trust the file.": "Yalnızca dosyaya güveniyorsanız etkinleştirin."
}
@@ -5,7 +5,6 @@
"Animation": "Анімація",
"Annotate": "Анотація",
"Apply": "Застосувати",
"Are you sure to delete the selected books?": "Ви впевнені, що хочете видалити вибрані книги?",
"Auto Mode": "Автоматичний режим",
"Behavior": "Поведінка",
"Book": "Книга",
@@ -42,15 +41,12 @@
"Global Settings": "Глобальні налаштування",
"Go Back": "Назад",
"Go Forward": "Вперед",
"Go Left": "Вліво",
"Go Right": "Вправо",
"Grass": "Трав'яний",
"Gray": "Сірий",
"Gruvbox": "Теплий помаранчевий",
"Highlight": "Виділення",
"Horizontal Direction": "Горизонтальний напрямок",
"Hyphenation": "Перенесення слів",
"Identifier:": "Ідентифікатор:",
"Import Books": "Імпортувати книги",
"Language:": "Мова:",
"Layout": "Макет",
@@ -63,7 +59,6 @@
"Match Whole Words": "Шукати цілі слова",
"Maximum Number of Columns": "Максимальна кількість колонок",
"Minimum Font Size": "Мінімальний розмір шрифту",
"Misc": "Різне",
"Monospace Font": "Моноширинний шрифт",
"More Info": "Більше інформації",
"Nord": "Північний",
@@ -111,7 +106,6 @@
"Unknown": "Невідомо",
"Untitled": "Без назви",
"Updated:": "Оновлено:",
"User avatar": "Аватар користувача",
"Version {{version}}": "Версія {{version}}",
"Vertical Direction": "Вертикальний напрямок",
"Welcome to your library. You can import your books here and read them anytime.": "Ласкаво просимо до вашої бібліотеки. Ви можете імпортувати сюди свої книги та читати їх будь-коли.",
@@ -299,5 +293,47 @@
"Link Color": "Колір посилання",
"Volume Keys for Page Flip": "Клавіші гучності для перевороту сторінки",
"Clicks for Page Flip": "Клацання для перевороту сторінки",
"Swap Clicks Area": "Змінити область клацання"
"Swap Clicks Area": "Змінити область клацання",
"Screen": "Екран",
"Orientation": "Орієнтація",
"Portrait": "Портрет",
"Landscape": "Ландшафт",
"Open Last Book on Start": "Відкрити останню книгу при запуску",
"Checking for updates...": "Перевірка наявності оновлень...",
"Error checking for updates": "Помилка перевірки наявності оновлень",
"Details": "Деталі",
"File Size:": "Розмір файлу:",
"Auto Detect": "Авто виявлення",
"Next Section": "Наступний розділ",
"Previous Section": "Попередній розділ",
"Next Page": "Наступна сторінка",
"Previous Page": "Попередня сторінка",
"Are you sure to delete {{count}} selected book(s)?_one": "Ви впевнені, що хочете видалити {{count}} вибрану книгу?",
"Are you sure to delete {{count}} selected book(s)?_few": "Ви впевнені, що хочете видалити {{count}} вибрані книги?",
"Are you sure to delete {{count}} selected book(s)?_many": "Ви впевнені, що хочете видалити {{count}} вибраних книг?",
"Are you sure to delete {{count}} selected book(s)?_other": "Ви впевнені, що хочете видалити {{count}} вибраних книг?",
"Are you sure to delete the selected book?": "Ви впевнені, що хочете видалити вибрану книгу?",
"Deselect": "Зняти",
"Select All": "Усі",
"No translation available.": "Переклад недоступний.",
"Translated by {{provider}}.": "Перекладено {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Інвертувати зображення в темряві",
"Help improve Readest": "Допоможіть покращити Readest",
"Sharing anonymized statistics": "Поділ анонімною статистикою",
"Interface Language": "Мова інтерфейсу",
"Translation": "Переклад",
"Enable Translation": "Увімкнути переклад",
"Translation Service": "Служба перекладу",
"Translate To": "Перекласти на",
"Disable Translation": "Вимкнути переклад",
"Scroll": "Прокрутка",
"Overlap Pixels": "Пікселі перекриття",
"Click": "Клацнути",
"System Language": "Системна мова",
"Security": "Безпека",
"Allow JavaScript": "Дозволити JavaScript",
"Enable only if you trust the file.": "Увімкніть лише якщо довіряєте файлу."
}
@@ -5,7 +5,6 @@
"Animation": "Hiệu ứng động",
"Annotate": "Chú thích",
"Apply": "Áp dụng",
"Are you sure to delete the selected books?": "Bạn có chắc chắn muốn xóa các sách đã chọn?",
"Auto Mode": "Chế độ tự động",
"Behavior": "Hành vi",
"Book": "Sách",
@@ -42,15 +41,12 @@
"Global Settings": "Cài đặt chung",
"Go Back": "Quay lại",
"Go Forward": "Tiến tới",
"Go Left": "Sang trái",
"Go Right": "Sang phải",
"Grass": "Xanh cỏ",
"Gray": "Xám",
"Gruvbox": "Gruvbox",
"Highlight": "Đánh dấu",
"Horizontal Direction": "Hướng ngang",
"Hyphenation": "Gạch nối từ",
"Identifier:": "Định danh:",
"Import Books": "Nhập sách",
"Language:": "Ngôn ngữ:",
"Layout": "Bố cục",
@@ -63,7 +59,6 @@
"Match Whole Words": "Khớp toàn bộ từ",
"Maximum Number of Columns": "Số cột tối đa",
"Minimum Font Size": "Cỡ chữ tối thiểu",
"Misc": "Khác",
"Monospace Font": "Phông chữ đơn cách",
"More Info": "Thông tin thêm",
"Nord": "Nord",
@@ -111,7 +106,6 @@
"Unknown": "Không xác định",
"Untitled": "Không có tiêu đề",
"Updated:": "Cập nhật:",
"User avatar": "Ảnh đại diện",
"Version {{version}}": "Phiên bản {{version}}",
"Vertical Direction": "Hướng dọc",
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
@@ -299,5 +293,44 @@
"Link Color": "Màu liên kết",
"Volume Keys for Page Flip": "Phím âm lượng để lật trang",
"Clicks for Page Flip": "Nhấp để lật trang",
"Swap Clicks Area": "Đổi khu vực nhấp"
"Swap Clicks Area": "Đổi khu vực nhấp",
"Screen": "Màn hình",
"Orientation": "Định hướng",
"Portrait": "Chân dung",
"Landscape": "Phong cảnh",
"Open Last Book on Start": "Mở sách cuối cùng khi khởi động",
"Checking for updates...": "Đang kiểm tra cập nhật...",
"Error checking for updates": "Lỗi khi kiểm tra cập nhật",
"Details": "Chi tiết",
"File Size:": "Kích thước tệp:",
"Auto Detect": "Phát hiện tự động",
"Next Section": "Phần tiếp theo",
"Previous Section": "Phần trước",
"Next Page": "Trang tiếp theo",
"Previous Page": "Trang trước",
"Are you sure to delete {{count}} selected book(s)?_other": "Bạn có chắc muốn xóa {{count}} cuốn sách đã chọn không?",
"Are you sure to delete the selected book?": "Bạn có chắc muốn xóa cuốn sách đã chọn không?",
"Deselect": "Bỏ chọn",
"Select All": "Chọn tất",
"No translation available.": "Không có bản dịch nào.",
"Translated by {{provider}}.": "Được dịch bởi {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "Google Dịch",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Đảo ảnh chế độ tối",
"Help improve Readest": "Giúp cải thiện Readest",
"Sharing anonymized statistics": "Chia sẻ thống kê ẩn danh",
"Interface Language": "Ngôn ngữ giao diện",
"Translation": "Dịch",
"Enable Translation": "Bật dịch",
"Translation Service": "Dịch vụ dịch thuật",
"Translate To": "Dịch sang",
"Disable Translation": "Tắt dịch",
"Scroll": "Cuộn",
"Overlap Pixels": "Chồng lấn pixel",
"Click": "Nhấp",
"System Language": "Ngôn ngữ hệ thống",
"Security": "An ninh",
"Allow JavaScript": "Cho phép JavaScript",
"Enable only if you trust the file.": "Chỉ bật nếu bạn tin tưởng tệp."
}
@@ -5,7 +5,6 @@
"Animation": "动画",
"Annotate": "笔记",
"Apply": "应用",
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
"Auto Mode": "自动主题",
"Behavior": "行为",
"Book": "书籍",
@@ -42,15 +41,12 @@
"Global Settings": "全局设置",
"Go Back": "返回",
"Go Forward": "前进",
"Go Left": "向左",
"Go Right": "向右",
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "划线",
"Horizontal Direction": "横排",
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
"Language:": "语言",
"Layout": "布局",
@@ -63,7 +59,6 @@
"Match Whole Words": "匹配整个单词",
"Maximum Number of Columns": "分栏数",
"Minimum Font Size": "最小字号",
"Misc": "杂项",
"Monospace Font": "等宽字体",
"More Info": "更多信息",
"Nord": "极光",
@@ -111,7 +106,6 @@
"Unknown": "未知",
"Untitled": "无标题",
"Updated:": "更新日期",
"User avatar": "用户头像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "竖排",
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
@@ -299,5 +293,44 @@
"Link Color": "链接颜色",
"Volume Keys for Page Flip": "音量键翻页",
"Clicks for Page Flip": "点击翻页",
"Swap Clicks Area": "交换点击区域"
"Swap Clicks Area": "交换点击区域",
"Screen": "屏幕",
"Orientation": "屏幕方向",
"Portrait": "竖屏",
"Landscape": "横屏",
"Open Last Book on Start": "启动时打开上次阅读的书籍",
"Checking for updates...": "检查更新中...",
"Error checking for updates": "检查更新时出错",
"Details": "详情",
"File Size:": "文件大小",
"Auto Detect": "自动检测",
"Next Section": "下一章",
"Previous Section": "上一章",
"Next Page": "下一页",
"Previous Page": "上一页",
"Are you sure to delete {{count}} selected book(s)?_other": "您确定要删除 {{count}} 本选中的书籍吗?",
"Are you sure to delete the selected book?": "您确定要删除选中的书籍吗?",
"Deselect": "取消选择",
"Select All": "全选",
"No translation available.": "暂无可用的翻译",
"Translated by {{provider}}.": "由 {{provider}} 提供翻译",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "深色主题下图片反色",
"Help improve Readest": "帮助改进 Readest",
"Sharing anonymized statistics": "分享匿名统计数据",
"Interface Language": "界面语言",
"Translation": "翻译",
"Enable Translation": "启用翻译",
"Translation Service": "翻译服务",
"Translate To": "翻译到",
"Disable Translation": "禁用翻译",
"Scroll": "滚动",
"Overlap Pixels": "重叠像素",
"Click": "点击",
"System Language": "系统语言",
"Security": "安全",
"Allow JavaScript": "允许 JavaScript",
"Enable only if you trust the file.": "建議僅在您信任該檔案時啟用"
}
@@ -5,7 +5,6 @@
"Animation": "動畫",
"Annotate": "筆記",
"Apply": "應用",
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
"Auto Mode": "自動主題",
"Behavior": "行為",
"Book": "書籍",
@@ -42,15 +41,12 @@
"Global Settings": "全局設置",
"Go Back": "返回",
"Go Forward": "前進",
"Go Left": "向左",
"Go Right": "向右",
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "劃線",
"Horizontal Direction": "橫排",
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
"Language:": "語言",
"Layout": "版面",
@@ -63,7 +59,6 @@
"Match Whole Words": "匹配整個單詞",
"Maximum Number of Columns": "分欄數",
"Minimum Font Size": "最小字號",
"Misc": "雜項",
"Monospace Font": "等寬字體",
"More Info": "更多信息",
"Nord": "極光",
@@ -111,7 +106,6 @@
"Unknown": "未知",
"Untitled": "無標題",
"Updated:": "更新日期",
"User avatar": "用戶頭像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "豎排",
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
@@ -299,5 +293,44 @@
"Link Color": "連結顏色",
"Volume Keys for Page Flip": "音量鍵翻頁",
"Clicks for Page Flip": "點擊翻頁",
"Swap Clicks Area": "交換點擊區域"
"Swap Clicks Area": "交換點擊區域",
"Screen": "螢幕",
"Orientation": "螢幕方向",
"Portrait": "豎屏",
"Landscape": "橫屏",
"Open Last Book on Start": "啟動時打開上次閱讀的書籍",
"Checking for updates...": "檢查更新中...",
"Error checking for updates": "檢查更新時出錯",
"Details": "詳情",
"File Size:": "檔案大小",
"Auto Detect": "自動檢測",
"Next Section": "下一章",
"Previous Section": "上一章",
"Next Page": "下一頁",
"Previous Page": "上一頁",
"Are you sure to delete {{count}} selected book(s)?_other": "確定要刪除已選擇的 {{count}} 本書嗎?",
"Are you sure to delete the selected book?": "確定要刪除已選擇的書籍嗎?",
"Deselect": "取消選取",
"Select All": "全選",
"No translation available.": "暫無翻譯",
"Translated by {{provider}}.": "由 {{provider}} 提供翻譯",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "深色主題下圖片反相",
"Help improve Readest": "幫助改進 Readest",
"Sharing anonymized statistics": "分享匿名統計數據",
"Interface Language": "介面語言",
"Translation": "翻譯",
"Enable Translation": "啟用翻譯",
"Translation Service": "翻譯服務",
"Translate To": "翻譯至",
"Disable Translation": "停用翻譯",
"Scroll": "滾動",
"Overlap Pixels": "重疊像素",
"Click": "點擊",
"System Language": "系統語言",
"Security": "安全",
"Allow JavaScript": "允許 JavaScript",
"Enable only if you trust the file.": "僅在您信任該文件時啟用"
}
+83
View File
@@ -1,5 +1,88 @@
{
"releases": {
"0.9.51": {
"date": "2025-06-01",
"notes": [
"Translator: Table of contents is now included in translations",
"Library Theme: Added light, dark, and system theme options to the library page",
"TTS: Now shows the current sentence and chapter in the media session",
"TTS: Text-to-speech continues from the last read sentence",
"Pagination Fix: Taking notes no longer breaks page turning",
"EPUB Scripts: Added a setting to allow JavaScript in EPUB files"
]
},
"0.9.50": {
"date": "2025-05-28",
"notes": [
"Bilingual TTS: You can now choose voices for each language in bilingual text-to-speech mode",
"Settings Layout: Improved header layout in the settings dialog for different screen sizes",
"CSS Fixes: Corrected anchor text color and fixed image dimensions within paragraphs"
]
},
"0.9.49": {
"date": "2025-05-27",
"notes": [
"Select All in Library: You can now select all books at once when in selection mode",
"More Translation Options: Added support for Azure and Google Translate services",
"Dark Mode Enhancement: New option to invert image colors for better visibility in dark mode",
"Bilingual TTS: Enjoy bilingual text-to-speech for books with content in two languages",
"Full Book Translation: Translate books in any language into bilingual ebooks",
"Privacy Settings: Added a new option to opt-out of telemetry"
]
},
"0.9.43": {
"date": "2025-05-19",
"notes": [
"Enhanced compatibility with Gutenberg and Feedbooks ebooks for better styling",
"Reduced sensitivity of trackpad and mouse during page flips",
"Cloud backup status is now visible for each book on mobile and desktop",
"New option to control scroll overlap when paginating in scroll mode",
"Added Dutch language translations",
"Added more font weight options for built-in fonts",
"Added previous and next section buttons in the footer bar for easier navigation"
]
},
"0.9.41": {
"date": "2025-05-13",
"notes": [
"Improved UI and layout on mobile devices",
"Avatar images are now cached for offline use",
"Book file size is now displayed in the book details view",
"Update status is now correctly shown in the About window",
"TTS control positioning improved for notebook and RTL modes",
"TXT files are segmented by paragraph count if no chapter titles are found",
"Android: App now respects system auto-rotation settings",
"Linux: Updater is disabled on Linux when not using AppImage",
"Linux: Added support for Linux ARMHF builds",
"CSS: Resolved layout and color issues caused by hardcoded CSS"
]
},
"0.9.40": {
"date": "2025-05-08",
"notes": [
"Prevented oversized images from causing layout issues",
"Resolved issue with transient navigation bar on Android 9",
"Action bar now closes properly when the footbar is dismissed",
"Smoother experience when expanding Table of Contents sections",
"Reverted unintended style overrides"
]
},
"0.9.39": {
"date": "2025-05-07",
"notes": [
"Enhanced Navigation: Added location information to the table of contents",
"Enhanced Navigation: Larger touch targets for table of contents expansion icons",
"Screen Orientation Lock: You can now lock your device's screen orientation when reading",
"Quick Access to Recent Books: Option to automatically open your last read book when starting the app",
"Better Language Support: Improved handling of CJK characters in search",
"Enhanced Text-to-Speech: More accurate language detection for TTS",
"Enhanced Text-to-Speech: Text-to-Speech now maintains your position in scroll mode",
"Enhanced Annotations: Better handling of annotations with ruby elements in Japanese books",
"Smoother Scrolling: We've made continuous scrolling more reliable for a seamless reading experience",
"Compact View: Reading view is more compact when header and footer are hidden",
"TXT Parsing: More robust handling of TXT book format"
]
},
"0.9.38": {
"date": "2025-04-29",
"notes": [
@@ -1,3 +1,22 @@
#!/bin/bash
set -e
echo "Updating bundleVersion in tauri.appstore.conf.json..."
CONFIG_FILE="src-tauri/tauri.appstore.conf.json"
CURRENT_DATE=$(date "+%Y%m%d.%H%M%S")
if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: Config file $CONFIG_FILE not found!"
exit 1
fi
TMP_FILE=$(mktemp)
cat "$CONFIG_FILE" | jq --arg version "$CURRENT_DATE" '.bundle.macOS.bundleVersion = $version' > "$TMP_FILE"
mv "$TMP_FILE" "$CONFIG_FILE"
echo "Updated bundleVersion to $CURRENT_DATE"
echo "Building macOS universal app for App Store..."
pnpm run build-macos-universial-appstore
BUNDLE_DIR=../../target/universal-apple-darwin/release/bundle/macos
+2 -2
View File
@@ -31,9 +31,9 @@ reqwest = { version = "0.12", default-features = false, features = [
"stream",
] }
# FIXME: remove the devtools feature in production
tauri = { version = "2.4.1", features = [ "protocol-asset", "devtools"] }
tauri = { version = "2.5.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.1.1"
tauri-plugin-log = "2.3.1"
tauri-plugin-log = "2.4.0"
tauri-plugin-fs = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-os = "2.2.1"
@@ -61,6 +61,15 @@
},
{
"url": "https://*.cloudflarestorage.com"
},
{
"url": "https://edge.microsoft.com"
},
{
"url": "https://*.microsofttranslator.com"
},
{
"url": "https://translate.googleapis.com"
}
]
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 87 KiB

@@ -12,6 +12,7 @@ import android.view.WindowManager
import android.view.WindowInsetsController
import android.graphics.Color
import android.webkit.WebView
import android.content.pm.ActivityInfo
import android.graphics.fonts.SystemFonts
import android.graphics.fonts.Font
import androidx.core.view.WindowCompat
@@ -56,6 +57,11 @@ class InterceptKeysRequestArgs {
var backKey: Boolean? = null
}
@InvokeArg
class LockScreenOrientationRequestArgs {
var orientation: String? = null
}
interface KeyDownInterceptor {
fun interceptVolumeKeys(enabled: Boolean)
fun interceptBackKey(enabled: Boolean)
@@ -215,11 +221,12 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
if (visible) {
controller.show(WindowInsets.Type.statusBars())
controller.hide(WindowInsets.Type.navigationBars())
} else {
controller.hide(WindowInsets.Type.systemBars())
}
}
} else {
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val compatController = WindowCompat.getInsetsController(window, decorView)
compatController?.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
@@ -230,10 +237,27 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
if (visible) {
it.show(WindowInsetsCompat.Type.statusBars())
it.hide(WindowInsetsCompat.Type.navigationBars())
} else {
it.hide(WindowInsetsCompat.Type.systemBars())
}
}
} else {
@Suppress("DEPRECATION")
decorView.systemUiVisibility = buildList {
add(View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
add(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
add(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
add(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
add(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
if (!visible) {
add(View.SYSTEM_UI_FLAG_FULLSCREEN)
}
if (visible && !isDarkMode) {
add(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)
}
}.reduce { acc, flag -> acc or flag }
}
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
@@ -294,8 +318,13 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
var fontName = fileFileName
.replace(Regex("\\.(ttf|otf)$", RegexOption.IGNORE_CASE), "")
.trim()
fontList.add(fontName)
}
ret.put("fonts", JSONArray(fontList))
var fontDict = JSObject()
for (fontName in fontList) {
fontDict.put(fontName, fontName)
}
ret.put("fonts", fontDict)
} catch (e: Exception) {
ret.put("error", e.message)
}
@@ -321,4 +350,20 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve()
}
@Command
fun lock_screen_orientation(invoke: Invoke) {
val args = invoke.parseArgs(LockScreenOrientationRequestArgs::class.java)
val orientation = args.orientation ?: "auto"
when (orientation) {
"portrait" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
"landscape" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
"auto" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
else -> {
invoke.reject("Invalid orientation mode")
return
}
}
invoke.resolve()
}
}
@@ -8,6 +8,7 @@ const COMMANDS: &[&str] = &[
"get_status_bar_height",
"get_sys_fonts_list",
"intercept_keys",
"lock_screen_orientation",
];
fn main() {
@@ -4,29 +4,29 @@
import PackageDescription
let package = Package(
name: "tauri-plugin-native-bridge",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-bridge",
type: .static,
targets: ["tauri-plugin-native-bridge"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-bridge",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
name: "tauri-plugin-native-bridge",
platforms: [
.macOS(.v10_13),
.iOS(.v14),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-bridge",
type: .static,
targets: ["tauri-plugin-native-bridge"])
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-bridge",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
)
@@ -7,6 +7,9 @@ import SwiftRs
import Tauri
import UIKit
import WebKit
import os
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NativeBridge")
func getLocalizedDisplayName(familyName: String) -> String? {
let fontDescriptor = CTFontDescriptorCreateWithAttributes(
@@ -41,33 +44,53 @@ class InterceptKeysRequestArgs: Decodable {
let volumeKeys: Bool?
}
class LockScreenOrientationRequestArgs: Decodable {
let orientation: String?
}
class VolumeKeyHandler: NSObject {
private var audioSession: AVAudioSession?
private var originalVolume: Float = 0.0
private var referenceVolume: Float = 0.5
private var previousVolume: Float = 0.5
private var volumeView: MPVolumeView?
private var isIntercepting = false
private(set) var isIntercepting = false
private var webView: WKWebView?
private var volumeSlider: UISlider?
func startInterception(webView: WKWebView) {
if isIntercepting {
return
stopInterception()
}
logger.log("Starting volume key interception")
self.webView = webView
isIntercepting = true
audioSession = AVAudioSession.sharedInstance()
do {
try audioSession?.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try audioSession?.setActive(true)
} catch {
print("Failed to activate audio session: \(error)")
logger.error("Failed to activate audio session: \(error)")
}
originalVolume = audioSession?.outputVolume ?? 0.1
DispatchQueue.main.async { [weak self] in
self?.setupHiddenVolumeView()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
self.originalVolume = self.audioSession?.outputVolume ?? 0.1
if self.originalVolume > 0.9 {
self.referenceVolume = 0.9
} else if self.originalVolume < 0.1 {
self.referenceVolume = 0.1
} else {
self.referenceVolume = self.originalVolume
}
logger.log("Reference volume set to \(self.referenceVolume)")
self.previousVolume = self.referenceVolume
self.setSessionVolume(self.referenceVolume)
self.setupHiddenVolumeView()
self.audioSession?.addObserver(
self, forKeyPath: "outputVolume", options: [.new], context: nil)
}
audioSession?.addObserver(self, forKeyPath: "outputVolume", options: [.new], context: nil)
@@ -78,23 +101,18 @@ class VolumeKeyHandler: NSObject {
return
}
logger.log("Stopping volume key interception")
isIntercepting = false
audioSession?.removeObserver(self, forKeyPath: "outputVolume")
DispatchQueue.main.async { [weak self] in
self?.setSystemVolume(self?.originalVolume ?? 0.1)
self?.setSessionVolume(self?.originalVolume ?? 0.1)
self?.volumeView?.removeFromSuperview()
self?.volumeView = nil
self?.volumeSlider = nil
}
do {
try audioSession?.setActive(false)
} catch {
print("Failed to deactivate audio session: \(error)")
}
}
private func setSystemVolume(_ volume: Float) {
private func setSessionVolume(_ volume: Float) {
DispatchQueue.main.async { [weak self] in
self?.volumeSlider?.value = volume
}
@@ -102,14 +120,12 @@ class VolumeKeyHandler: NSObject {
private func setupHiddenVolumeView() {
assert(Thread.isMainThread, "setupHiddenVolumeView must be called on main thread")
let frame = CGRect(x: -1000, y: -1000, width: 1, height: 1)
volumeView = MPVolumeView(frame: frame)
volumeSlider = volumeView?.subviews.first(where: { $0 is UISlider }) as? UISlider
if let window = UIApplication.shared.windows.first {
window.addSubview(volumeView!)
}
setSystemVolume(originalVolume)
}
override func observeValue(
@@ -118,31 +134,80 @@ class VolumeKeyHandler: NSObject {
) {
if keyPath == "outputVolume", let audioSession = self.audioSession, isIntercepting {
let currentVolume = audioSession.outputVolume
if currentVolume > originalVolume {
if currentVolume > self.previousVolume {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"window.onNativeKeyDown('VolumeUp');", completionHandler: nil)
self?.setSystemVolume(self?.originalVolume ?? 0.1)
}
} else if currentVolume < originalVolume {
} else if currentVolume < self.previousVolume {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"window.onNativeKeyDown('VolumeDown');", completionHandler: nil)
self?.setSystemVolume(self?.originalVolume ?? 0.1)
}
}
self.previousVolume = currentVolume
self.setSessionVolume(self.referenceVolume)
}
}
}
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
private var webView: WKWebView?
private var authSession: ASWebAuthenticationSession?
private var isOrientationLocked = false
private var currentOrientationMask: UIInterfaceOrientationMask = .all
private var orientationObserver: NSObjectProtocol?
@objc public override func load(webview: WKWebView) {
self.webView = webview
print("NativeBridgePlugin loaded")
logger.log("NativeBridgePlugin loaded")
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
@objc func appDidBecomeActive() {
if volumeKeyHandler != nil {
activateVolumeKeyInterception()
}
if orientationObserver != nil {
self.setupOrientationObserver()
}
}
@objc func appDidEnterBackground() {
if let handler = volumeKeyHandler, handler.isIntercepting {
handler.stopInterception()
}
}
func activateVolumeKeyInterception() {
if volumeKeyHandler == nil {
volumeKeyHandler = VolumeKeyHandler()
}
if let webView = self.webView {
volumeKeyHandler?.stopInterception()
volumeKeyHandler?.startInterception(webView: webView)
} else {
logger.warning("Cannot activate volume key interception: webView is nil")
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private struct AssociatedKeys {
@@ -179,15 +244,15 @@ class NativeBridgePlugin: Plugin {
if enabled {
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try session.setActive(true)
print("AVAudioSession activated")
logger.log("AVAudioSession activated")
} else {
try session.setActive(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("AVAudioSession deactivated")
logger.log("AVAudioSession deactivated")
}
invoke.resolve()
} catch {
print("Failed to set up audio session:", error)
logger.error("Failed to set up audio session: \(error)")
}
}
@@ -200,13 +265,12 @@ class NativeBridgePlugin: Plugin {
guard let strongSelf = self else { return }
if let error = error {
Logger.error("Auth session error: \(error.localizedDescription)")
logger.error("Auth session error: \(error.localizedDescription)")
invoke.reject(error.localizedDescription)
return
}
if let callbackURL = callbackURL {
Logger.info("Auth session callback URL: \(callbackURL.absoluteString)")
strongSelf.authSession?.cancel()
strongSelf.authSession = nil
invoke.resolve(["redirectUrl": callbackURL.absoluteString])
@@ -218,7 +282,7 @@ class NativeBridgePlugin: Plugin {
}
let started = authSession?.start() ?? false
Logger.info("Auth session start result: \(started)")
logger.log("Auth session start result: \(started)")
}
@objc public func set_system_ui_visibility(_ invoke: Invoke) throws {
@@ -239,45 +303,31 @@ class NativeBridgePlugin: Plugin {
keyWindow.overrideUserInterfaceStyle = darkMode ? .dark : .light
keyWindow.layoutIfNeeded()
} else {
print("No key window found")
logger.error("No key window found")
}
}
invoke.resolve(["success": true])
}
@objc public func get_sys_fonts_list(_ invoke: Invoke) throws {
var fontList: [String] = []
var fontDict: [String: String] = [:]
for family in UIFont.familyNames.sorted() {
if let localized = getLocalizedDisplayName(familyName: family) {
fontList.append(localized)
fontDict[family] = localized
} else {
let fontNames = UIFont.fontNames(forFamilyName: family)
if fontNames.isEmpty {
fontList.append(family)
fontDict[family] = family
} else {
fontList.append(contentsOf: fontNames)
for fontName in fontNames {
fontDict[fontName] = family
}
}
}
}
invoke.resolve(["fonts": fontList])
}
private func interceptVolumeKeys(_ intercept: Bool) {
interceptingVolumeKeys = intercept
if intercept {
if volumeKeyHandler == nil {
volumeKeyHandler = VolumeKeyHandler()
}
if let webView = self.webView {
volumeKeyHandler?.startInterception(webView: webView)
}
} else {
volumeKeyHandler?.stopInterception()
}
invoke.resolve(["fonts": fontDict])
}
@objc public func intercept_keys(_ invoke: Invoke) {
@@ -285,16 +335,89 @@ class NativeBridgePlugin: Plugin {
let args = try invoke.parseArgs(InterceptKeysRequestArgs.self)
if let volumeKeys = args.volumeKeys {
DispatchQueue.main.async { [weak self] in
self?.interceptVolumeKeys(volumeKeys)
if volumeKeys {
self.activateVolumeKeyInterception()
} else {
self.volumeKeyHandler?.stopInterception()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.volumeKeyHandler = nil
}
}
}
invoke.resolve()
} catch {
invoke.reject(error.localizedDescription)
}
}
@objc public func lock_screen_orientation(_ invoke: Invoke) throws {
guard let args = try? invoke.parseArgs(LockScreenOrientationRequestArgs.self) else {
return invoke.reject("Invalid arguments")
}
DispatchQueue.main.async {
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
let orientation = args.orientation ?? "auto"
switch orientation.lowercased() {
case "portrait":
self.isOrientationLocked = true
self.currentOrientationMask = .portrait
self.forceOrientation(.portrait)
self.setupOrientationObserver()
case "landscape":
self.isOrientationLocked = true
self.currentOrientationMask = .landscape
self.forceOrientation(.landscapeRight)
self.setupOrientationObserver()
case "auto":
self.isOrientationLocked = false
self.currentOrientationMask = .all
default:
invoke.reject("Invalid orientation mode")
return
}
invoke.resolve()
}
}
private func forceOrientation(_ orientation: UIInterfaceOrientation) {
if #available(iOS 16.0, *) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
let orientationMask: UIInterfaceOrientationMask =
orientation.isPortrait ? .portrait : .landscape
for window in windowScene.windows {
if let rootVC = window.rootViewController {
rootVC.setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask)) { error in
print("Orientation update error: \(error.localizedDescription)")
}
}
} else {
UIDevice.current.setValue(orientation.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
}
private func setupOrientationObserver() {
orientationObserver = NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self = self, self.isOrientationLocked else { return }
if self.currentOrientationMask == .portrait {
self.forceOrientation(.portrait)
} else if self.currentOrientationMask == .landscape {
self.forceOrientation(.landscapeRight)
}
}
}
}
@_cdecl("init_plugin_native_bridge")
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-lock-screen-orientation"
description = "Enables the lock_screen_orientation command without any pre-configured scope."
commands.allow = ["lock_screen_orientation"]
[[permission]]
identifier = "deny-lock-screen-orientation"
description = "Denies the lock_screen_orientation command without any pre-configured scope."
commands.deny = ["lock_screen_orientation"]
@@ -13,6 +13,7 @@ Default permissions for the plugin
- `allow-get-status-bar-height`
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
- `allow-lock-screen-orientation`
## Permission Table
@@ -208,6 +209,32 @@ Denies the intercept_keys command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-lock-screen-orientation`
</td>
<td>
Enables the lock_screen_orientation command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-lock-screen-orientation`
</td>
<td>
Denies the lock_screen_orientation command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-set-system-ui-visibility`
</td>
@@ -10,4 +10,5 @@ permissions = [
"allow-get-status-bar-height",
"allow-get-sys-fonts-list",
"allow-intercept-keys",
"allow-lock-screen-orientation",
]
@@ -378,6 +378,18 @@
"const": "deny-intercept-keys",
"markdownDescription": "Denies the intercept_keys command without any pre-configured scope."
},
{
"description": "Enables the lock_screen_orientation command without any pre-configured scope.",
"type": "string",
"const": "allow-lock-screen-orientation",
"markdownDescription": "Enables the lock_screen_orientation command without any pre-configured scope."
},
{
"description": "Denies the lock_screen_orientation command without any pre-configured scope.",
"type": "string",
"const": "deny-lock-screen-orientation",
"markdownDescription": "Denies the lock_screen_orientation command without any pre-configured scope."
},
{
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
@@ -403,10 +415,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`"
}
]
}
@@ -73,3 +73,11 @@ pub(crate) async fn intercept_keys<R: Runtime>(
) -> Result<()> {
app.native_bridge().intercept_keys(payload)
}
#[command]
pub(crate) async fn lock_screen_orientation<R: Runtime>(
app: AppHandle<R>,
payload: LockScreenOrientationRequest,
) -> Result<()> {
app.native_bridge().lock_screen_orientation(payload)
}
@@ -1,4 +1,5 @@
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
@@ -50,9 +51,14 @@ impl<R: Runtime> NativeBridge<R> {
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
let font_collection = font_enumeration::Collection::new().unwrap();
let mut fonts = Vec::new();
let mut fonts = HashMap::new();
for font in font_collection.all() {
fonts.push(font.font_name.clone());
if cfg!(target_os = "windows") {
// FIXME: temporarily disable font name with style for windows
fonts.insert(font.family_name.clone(), font.family_name.clone());
} else {
fonts.insert(font.font_name.clone(), font.family_name.clone());
}
}
Ok(GetSysFontsListResponse { fonts, error: None })
}
@@ -60,4 +66,11 @@ impl<R: Runtime> NativeBridge<R> {
pub fn intercept_keys(&self, _payload: InterceptKeysRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn lock_screen_orientation(
&self,
_payload: LockScreenOrientationRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -46,6 +46,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::get_status_bar_height,
commands::get_sys_fonts_list,
commands::intercept_keys,
commands::lock_screen_orientation,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -101,3 +101,14 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn lock_screen_orientation(
&self,
payload: LockScreenOrientationRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("lock_screen_orientation", payload)
.map_err(Into::into)
}
}
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -69,7 +70,7 @@ pub struct GetStatusBarHeightResponse {
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSysFontsListResponse {
pub fonts: Vec<String>,
pub fonts: HashMap<String, String>,
pub error: Option<String>,
}
@@ -79,3 +80,9 @@ pub struct InterceptKeysRequest {
pub volume_keys: Option<bool>,
pub back_key: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LockScreenOrientationRequest {
pub orientation: String,
}
+15 -3
View File
@@ -191,9 +191,8 @@ pub fn run() {
let app_handle = app.handle().clone();
app.listen("window-ready", move |_| {
app_handle
.get_webview_window("main")
.unwrap()
let webview = app_handle.get_webview_window("main").unwrap();
webview
.eval("window.__READEST_CLI_ACCESS = true;")
.expect("Failed to set cli access config");
@@ -204,6 +203,19 @@ pub fn run() {
{
set_rounded_window(&app_handle, false);
}
#[cfg(target_os = "linux")]
{
let is_appimage = std::env::var("APPIMAGE").is_ok()
|| std::env::current_exe()
.map(|path| path.to_string_lossy().contains("/tmp/.mount_"))
.unwrap_or(false);
let script =
format!("window.__READEST_UPDATER_DISABLED = {};", !is_appimage);
webview
.eval(&script)
.expect("Failed to set updater disabled config");
}
});
}
+3 -2
View File
@@ -15,7 +15,7 @@
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://*.microsofttranslator.com https://edge.microsoft.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://fontsapi.zeoseven.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://fontsapi.zeoseven.com",
@@ -65,7 +65,8 @@
"minSdkVersion": 26
},
"iOS": {
"developmentTeam": "J5W48D69VR"
"developmentTeam": "J5W48D69VR",
"minimumSystemVersion": "14.0"
},
"fileAssociations": [
{
+3 -3
View File
@@ -78,8 +78,8 @@ export default function AuthPage() {
useTheme({ systemUIVisible: false });
const getTauriRedirectTo = (isOAuth: boolean) => {
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
if (appService?.isMobile) {
if (process.env.NODE_ENV === 'production' || appService?.isMobileApp || USE_APPLE_SIGN_IN) {
if (appService?.isMobileApp) {
return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK;
}
return DEEPLINK_CALLBACK;
@@ -172,7 +172,7 @@ export default function AuthPage() {
const startTauriOAuth = async () => {
try {
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
if (process.env.NODE_ENV === 'production' || appService?.isMobileApp || USE_APPLE_SIGN_IN) {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
currentWindow.listen('single-instance', ({ event, payload }) => {
-1
View File
@@ -2,7 +2,6 @@ import * as React from 'react';
import Providers from '@/components/Providers';
import '../styles/globals.css';
import '../styles/fonts.css';
const url = 'https://web.readest.com/';
const title = 'Readest — Where You Read, Digest and Get Insight';
@@ -1,15 +1,21 @@
import clsx from 'clsx';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { CiCircleMore } from 'react-icons/ci';
import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/lia';
import {
LiaCloudUploadAltSolid,
LiaCloudDownloadAltSolid,
LiaInfoCircleSolid,
} from 'react-icons/lia';
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/navigation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryViewModeType } from '@/types/settings';
import { navigateToLogin } from '@/utils/nav';
import { formatAuthors } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
import BookCover from '@/components/BookCover';
interface BookItemProps {
mode: LibraryViewModeType;
@@ -32,8 +38,10 @@ const BookItem: React.FC<BookItemProps> = ({
handleBookDownload,
showBookDetailsModal,
}) => {
const iconSize15 = useResponsiveSize(15);
const router = useRouter();
const { user } = useAuth();
const { appService } = useEnv();
const iconSize15 = useResponsiveSize(15);
const stopEvent = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
@@ -96,11 +104,26 @@ const BookItem: React.FC<BookItemProps> = ({
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
>
{book.progress && <ReadingProgress book={book} />}
<div className='flex items-center gap-x-1'>
<div className='flex items-center justify-center gap-x-2'>
{!appService?.isMobile && (
<button
className='show-detail-button -m-2 p-2 sm:opacity-0 sm:group-hover:opacity-100'
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
onClick={() => showBookDetailsModal(book)}
>
<div className='pt-[1px]'>
<LiaInfoCircleSolid size={iconSize15} />
</div>
</button>
)}
{transferProgress !== null ? (
transferProgress === 100 ? null : (
<div
className='radial-progress opacity-0 group-hover:opacity-100'
className='radial-progress'
style={
{
'--value': transferProgress,
@@ -112,38 +135,33 @@ const BookItem: React.FC<BookItemProps> = ({
></div>
)
) : (
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
onClick={() => {
if (!book.uploadedAt) {
handleBookUpload(book);
} else if (!book.downloadedAt) {
handleBookDownload(book);
}
}}
>
{!book.uploadedAt && <LiaCloudUploadAltSolid size={iconSize15} />}
{book.uploadedAt && !book.downloadedAt && (
<LiaCloudDownloadAltSolid size={iconSize15} />
)}
</button>
(!book.uploadedAt || (book.uploadedAt && !book.downloadedAt)) && (
<button
className='show-cloud-button -m-2 p-2'
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
onClick={() => {
if (!user) {
navigateToLogin(router);
return;
}
if (!book.uploadedAt) {
handleBookUpload(book);
} else if (!book.downloadedAt) {
handleBookDownload(book);
}
}}
>
{!book.uploadedAt && <LiaCloudUploadAltSolid size={iconSize15} />}
{book.uploadedAt && !book.downloadedAt && (
<LiaCloudDownloadAltSolid size={iconSize15} />
)}
</button>
)
)}
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
onClick={() => showBookDetailsModal(book)}
>
<CiCircleMore size={iconSize15} />
</button>
</div>
</div>
</div>
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew, MdOutlineCancel } from 'react-icons/md';
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
@@ -17,12 +17,15 @@ import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateGridItems, generateListItems } from './BookshelfItem';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
libraryBooks: Book[];
isSelectMode: boolean;
isSelectAll: boolean;
isSelectNone: boolean;
handleImportBooks: () => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
@@ -35,6 +38,8 @@ interface BookshelfProps {
const Bookshelf: React.FC<BookshelfProps> = ({
libraryBooks,
isSelectMode,
isSelectAll,
isSelectNone,
handleImportBooks,
handleBookUpload,
handleBookDownload,
@@ -67,14 +72,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
useEffect(() => {
if (isSelectMode) {
setShowSelectModeActions(true);
} else {
setSelectedBooks([]);
setShowSelectModeActions(false);
}
}, [isSelectMode]);
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
useEffect(() => {
if (isImportingBook.current) return;
@@ -162,14 +160,29 @@ const Bookshelf: React.FC<BookshelfProps> = ({
navigateToReader(router, selectedBooks);
};
const confirmDelete = async () => {
const openBookDetails = () => {
const book = libraryBooks.find((book) => book.hash === selectedBooks[0]);
if (book) {
handleShowDetailsBook(book);
}
};
const getBooksToDelete = () => {
const booksToDelete: Book[] = [];
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (book && !book.deletedAt) {
handleBookDelete(book);
booksToDelete.push(book);
}
}
});
return booksToDelete;
};
const confirmDelete = async () => {
for (const book of getBooksToDelete()) {
handleBookDelete(book);
}
setSelectedBooks([]);
setShowDeleteAlert(false);
setShowSelectModeActions(true);
@@ -185,7 +198,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowGroupingModal(true);
};
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const bookFilter = (item: Book, queryTerm: string) => {
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
@@ -238,6 +250,23 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
});
useEffect(() => {
if (isSelectMode) {
setShowSelectModeActions(true);
if (isSelectAll) {
setSelectedBooks(
filteredBookshelfItems.map((item) => ('hash' in item ? item.hash : item.id)),
);
} else if (isSelectNone) {
setSelectedBooks([]);
}
} else {
setSelectedBooks([]);
setShowSelectModeActions(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSelectMode, isSelectAll, isSelectNone]);
return (
<div className='bookshelf'>
<div
@@ -290,14 +319,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<div
className={clsx(
'flex items-center justify-center shadow-lg',
'text-base-content bg-base-300 text-sm',
'bg-gray-600 text-xs text-white',
'mx-auto w-fit space-x-6 rounded-lg p-4',
)}
>
<button
onClick={openSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
'flex flex-col items-center justify-center gap-1',
(!selectedBooks.length || !selectedBooks.every((id) => isMd5(id))) &&
'btn-disabled opacity-50',
)}
@@ -308,17 +337,28 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<button
onClick={groupSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
'flex flex-col items-center justify-center gap-1',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<LuFolderPlus />
<div>{_('Group')}</div>
</button>
<button
onClick={openBookDetails}
className={clsx(
'flex flex-col items-center justify-center gap-1',
(selectedBooks.length !== 1 || !selectedBooks.every((id) => isMd5(id))) &&
'btn-disabled opacity-50',
)}
>
<MdInfoOutline />
<div>{_('Details')}</div>
</button>
<button
onClick={deleteSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
'flex flex-col items-center justify-center gap-1',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
@@ -327,7 +367,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
</button>
<button
onClick={() => handleSetSelectMode(false)}
className={clsx('flex flex-col items-center justify-center')}
className={clsx('flex flex-col items-center justify-center gap-1')}
>
<MdOutlineCancel />
<div>{_('Cancel')}</div>
@@ -336,7 +376,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
</div>
{showGroupingModal && (
<div>
<ModalPortal>
<GroupingModal
libraryBooks={libraryBooks}
selectedBooks={selectedBooks}
@@ -349,7 +389,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleSetSelectMode(false);
}}
/>
</div>
</ModalPortal>
)}
{showDeleteAlert && (
<div
@@ -360,7 +400,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected books?')}
message={_('Are you sure to delete {{count}} selected book(s)?', {
count: getBooksToDelete().length,
})}
onCancel={() => {
setShowDeleteAlert(false);
setShowSelectModeActions(true);
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { BooksGroup } from '@/types/book';
import BookCover from './BookCover';
import BookCover from '@/components/BookCover';
interface GroupItemProps {
group: BooksGroup;
@@ -140,7 +140,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
}, [selectedBooks]);
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'>
<div className='fixed inset-0 flex items-center justify-center'>
<div
className={clsx(
'modal-box bg-base-100 overflow-y-auto rounded-2xl shadow-xl',
@@ -1,3 +1,5 @@
import clsx from 'clsx';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import MenuItem from '@/components/MenuItem';
@@ -8,6 +10,7 @@ interface ImportMenuProps {
const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBooks }) => {
const _ = useTranslation();
const { appService } = useEnv();
const handleImportBooks = () => {
onImportBooks();
@@ -17,7 +20,10 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
return (
<ul
tabIndex={-1}
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
className={clsx(
'dropdown-content bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow',
appService?.isMobile ? 'no-triangle' : 'dropdown-center',
)}
>
<MenuItem label={_('From Local File')} onClick={handleImportBooks} />
</ul>
@@ -24,14 +24,20 @@ import SortMenu from './SortMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
isSelectAll: boolean;
onImportBooks: () => void;
onToggleSelectMode: () => void;
onSelectAll: () => void;
onDeselectAll: () => void;
}
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
isSelectMode,
isSelectAll,
onImportBooks,
onToggleSelectMode,
onSelectAll,
onDeselectAll,
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -142,7 +148,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
setSearchQuery('');
throttledUpdateQueryParam('');
}}
className='text-gray-400 hover:text-gray-600'
className='pe-1 text-gray-400 hover:text-gray-600'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
@@ -150,7 +156,10 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
)}
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
className={clsx(
'exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center',
appService?.isMobile ? 'dropdown-end' : 'dropdown-center',
)}
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center'
toggleButton={
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Import Books')}>
@@ -160,47 +169,68 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
>
<ImportMenu onImportBooks={onImportBooks} />
</Dropdown>
<button
onClick={onToggleSelectMode}
aria-label={_('Select Multiple Books')}
className='h-6'
>
<div
className='lg:tooltip lg:tooltip-bottom cursor-pointer'
data-tip={_('Select Books')}
{appService?.isMobile ? null : (
<button
onClick={onToggleSelectMode}
aria-label={_('Select Multiple Books')}
className='h-6'
>
<PiSelectionAllDuotone
role='button'
className={`h-6 w-6 ${isSelectMode ? 'fill-gray-400' : 'fill-gray-500'}`}
/>
</div>
</button>
<div
className='lg:tooltip lg:tooltip-bottom cursor-pointer'
data-tip={_('Select Books')}
>
<PiSelectionAllDuotone
role='button'
className={`h-6 w-6 ${isSelectMode ? 'fill-gray-400' : 'fill-gray-500'}`}
/>
</div>
</button>
)}
</div>
</div>
<div className='flex h-full items-center gap-x-2 sm:gap-x-4'>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
{isSelectMode ? (
<div
className={clsx(
'flex h-full items-center',
'w-max-[72px] w-min-[72px] sm:w-max-[80px] sm:w-min-[80px]',
)}
>
<SortMenu />
</Dropdown>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={iconSize18} />}
>
<SettingsMenu />
</Dropdown>
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={windowButtonVisible}
showMaximize={windowButtonVisible}
showClose={windowButtonVisible}
/>
)}
</div>
<button
onClick={isSelectAll ? onDeselectAll : onSelectAll}
className='btn btn-ghost text-base-content/85 h-8 min-h-8 w-[72px] p-0 sm:w-[80px]'
aria-label={isSelectAll ? _('Deselect') : _('Select All')}
>
<span className='font-sans text-base font-normal sm:text-sm'>
{isSelectAll ? _('Deselect') : _('Select All')}
</span>
</button>
</div>
) : (
<div className='flex h-full items-center gap-x-2 sm:gap-x-4'>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
>
<SortMenu />
</Dropdown>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={iconSize18} />}
>
<SettingsMenu />
</Dropdown>
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={windowButtonVisible}
showMaximize={windowButtonVisible}
showClose={windowButtonVisible}
/>
)}
</div>
)}
</div>
</div>
);
@@ -1,22 +1,27 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { PiUserCircle } from 'react-icons/pi';
import { PiUserCircleCheck } from 'react-icons/pi';
import { MdCheck } from 'react-icons/md';
import { TbSunMoon } from 'react-icons/tb';
import { BiMoon, BiSun } from 'react-icons/bi';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
import { QuotaType } from '@/types/user';
import UserAvatar from '@/components/UserAvatar';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -29,21 +34,25 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const { themeMode, setThemeMode } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks);
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
);
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
const iconSize = useResponsiveSize(16);
const showAboutReadest = () => {
setAboutDialogVisible(true);
setIsDropdownOpen?.(false);
};
const downloadReadest = () => {
window.open(DOWNLOAD_READEST_URL, '_blank');
setIsDropdownOpen?.(false);
@@ -59,6 +68,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const cycleThemeMode = () => {
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
setThemeMode(nextMode);
};
const handleReloadPage = () => {
window.location.reload();
setIsDropdownOpen?.(false);
@@ -110,6 +124,25 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsScreenWakeLock(settings.screenWakeLock);
};
const toggleOpenLastBooks = () => {
settings.openLastBooks = !settings.openLastBooks;
setSettings(settings);
saveSettings(envConfig, settings);
setIsOpenLastBooks(settings.openLastBooks);
};
const toggleTelemetry = () => {
settings.telemetryEnabled = !settings.telemetryEnabled;
if (settings.telemetryEnabled) {
optInTelemetry();
} else {
optOutTelemetry();
}
setSettings(settings);
saveSettings(envConfig, settings);
setIsTelemetryEnabled(settings.telemetryEnabled);
};
useEffect(() => {
if (!user || !token) return;
const storagPlan = getStoragePlanData(token);
@@ -126,7 +159,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const isWebApp = isWebAppPlatform();
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
const userFullName = user?.user_metadata?.['full_name'];
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
@@ -134,7 +166,10 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
className={clsx(
'settings-menu dropdown-content no-triangle border-base-100',
'z-20 mt-2 max-w-[90vw] shadow-2xl',
)}
>
{user ? (
<MenuItem
@@ -146,14 +181,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
labelClass='!max-w-40'
Icon={
avatarUrl ? (
<Image
src={avatarUrl}
alt={_('User avatar')}
className='rounded-full'
referrerPolicy='no-referrer'
width={iconSize}
height={iconSize}
/>
<UserAvatar url={avatarUrl} size={iconSize} DefaultIcon={PiUserCircleCheck} />
) : (
PiUserCircleCheck
)
@@ -179,6 +207,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleAutoImportBooksOnOpen}
/>
)}
{isTauriAppPlatform() && (
<MenuItem
label={_('Open Last Book on Start')}
Icon={isOpenLastBooks ? MdCheck : undefined}
onClick={toggleOpenLastBooks}
/>
)}
{appService?.hasUpdater && (
<MenuItem
label={_('Check Updates on Start')}
@@ -201,9 +236,26 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleScreenWakeLock}
/>
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<MenuItem
label={
themeMode === 'dark'
? _('Dark Mode')
: themeMode === 'light'
? _('Light Mode')
: _('Auto Mode')
}
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
<MenuItem
label={_('Help improve Readest')}
description={isTelemetryEnabled ? _('Sharing anonymized statistics') : ''}
Icon={isTelemetryEnabled ? MdCheck : undefined}
onClick={toggleTelemetry}
/>
</div>
);
};
+87 -27
View File
@@ -31,6 +31,8 @@ import { useBooksSync } from './hooks/useBooksSync';
import { useThemeStore } from '@/store/themeStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { lockScreenOrientation } from '@/utils/bridge';
import { mountAdditionalFonts } from '@/utils/style';
import {
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
@@ -61,7 +63,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
updateBook,
setLibrary,
checkOpenWithBooks,
checkLastOpenBooks,
setCheckOpenWithBooks,
setCheckLastOpenBooks,
} = useLibraryStore();
const _ = useTranslation();
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
@@ -71,10 +75,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
const [isSelectMode, setIsSelectMode] = useState(false);
const [isSelectAll, setIsSelectAll] = useState(false);
const [isSelectNone, setIsSelectNone] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [booksTransferProgress, setBooksTransferProgress] = useState<{
[key: string]: number | null;
}>({});
const [pendingNavigationBookIds, setPendingNavigationBookIds] = useState<string[] | null>(null);
const [isDragging, setIsDragging] = useState(false);
const demoBooks = useDemoBooks();
const containerRef = useRef<HTMLDivElement>(null);
@@ -103,6 +110,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
},
});
useEffect(() => {
mountAdditionalFonts(document);
}, []);
useEffect(() => {
const doCheckAppUpdates = async () => {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
@@ -116,6 +127,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
useEffect(() => {
if (appService?.isMobileApp) {
lockScreenOrientation({ orientation: 'auto' });
}
}, [appService]);
const handleDropedFiles = async (files: File[] | string[]) => {
if (files.length === 0) return;
const supportedFiles = files.filter((file) => {
@@ -222,15 +239,46 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
console.log('Opening books:', bookIds);
if (bookIds.length > 0) {
setTimeout(() => {
navigateToReader(router, bookIds);
}, 0);
setPendingNavigationBookIds(bookIds);
return true;
}
return false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleOpenLastBooks = async (
appService: AppService,
lastBookIds: string[],
libraryBooks: Book[],
) => {
if (lastBookIds.length === 0) return false;
const bookIds: string[] = [];
for (const bookId of lastBookIds) {
const book = libraryBooks.find((b) => b.hash === bookId);
if (book && (await appService.isBookAvailable(book))) {
bookIds.push(book.hash);
}
}
console.log('Opening last books:', bookIds);
if (bookIds.length > 0) {
setPendingNavigationBookIds(bookIds);
return true;
}
return false;
};
useEffect(() => {
if (pendingNavigationBookIds) {
const bookIds = pendingNavigationBookIds;
setPendingNavigationBookIds(null);
if (bookIds.length > 0) {
navigateToReader(router, bookIds);
}
}
}, [pendingNavigationBookIds, router]);
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
@@ -255,34 +303,35 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const settings = await appService.loadSettings();
setSettings(settings);
const libraryBooks = await appService.loadLibraryBooks();
if (checkOpenWithBooks && isTauriAppPlatform()) {
await handleOpenWithBooks(appService, libraryBooks);
} else {
setCheckOpenWithBooks(false);
setLibrary(libraryBooks);
}
// Reuse the library from the store when we return from the reader
const library = libraryBooks.length > 0 ? libraryBooks : await appService.loadLibraryBooks();
setCheckOpenWithBooks(checkOpenWithBooks && (await handleOpenWithBooks(appService, library)));
setCheckLastOpenBooks(
checkLastOpenBooks &&
settings.openLastBooks &&
(await handleOpenLastBooks(appService, settings.lastOpenBooks, library)),
);
setLibrary(library);
setLibraryLoaded(true);
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
};
const handleOpenWithBooks = async (appService: AppService, libraryBooks: Book[]) => {
const handleOpenWithBooks = async (appService: AppService, library: Book[]) => {
const openWithFiles = (await parseOpenWithFiles()) || [];
if (openWithFiles.length > 0) {
await processOpenWithFiles(appService, openWithFiles, libraryBooks);
} else {
setCheckOpenWithBooks(false);
setLibrary(libraryBooks);
return await processOpenWithFiles(appService, openWithFiles, library);
}
return false;
};
initLogin();
initLibrary();
return () => {
setCheckOpenWithBooks(false);
setCheckLastOpenBooks(false);
isInitiating.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -313,10 +362,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
['Unsupported format.', _('This book format is not supported.')],
];
const { library } = useLibraryStore.getState();
for (const file of files) {
try {
const book = await appService?.importBook(file, libraryBooks);
setLibrary(libraryBooks);
const book = await appService?.importBook(file, library);
setLibrary(library);
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book);
@@ -339,7 +389,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
console.error('Failed to import book:', filename, error);
}
}
appService?.saveLibraryBooks(libraryBooks);
appService?.saveLibraryBooks(library);
setLoading(false);
};
@@ -479,18 +529,23 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
importBooks(files);
};
const handleToggleSelectMode = () => {
if (!isSelectMode && appService?.hasHaptics) {
impactFeedback('medium');
}
setIsSelectMode((pre) => !pre);
};
const handleSetSelectMode = (selectMode: boolean) => {
if (selectMode && appService?.hasHaptics) {
impactFeedback('medium');
}
setIsSelectMode(selectMode);
setIsSelectAll(false);
setIsSelectNone(false);
};
const handleSelectAll = () => {
setIsSelectAll(true);
setIsSelectNone(false);
};
const handleDeselectAll = () => {
setIsSelectNone(true);
setIsSelectAll(false);
};
const handleShowDetailsBook = (book: Book) => {
@@ -501,7 +556,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return null;
}
if (checkOpenWithBooks) {
if (checkOpenWithBooks || checkLastOpenBooks) {
return (
loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
@@ -523,8 +578,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<div className='fixed top-0 z-40 w-full'>
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
onImportBooks={handleImportBooks}
onToggleSelectMode={handleToggleSelectMode}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
/>
</div>
{loading && (
@@ -552,6 +610,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
handleImportBooks={handleImportBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
+12 -5
View File
@@ -1,7 +1,14 @@
import { redirectToLibrary } from '@/utils/nav';
'use client';
const HomePage = () => {
redirectToLibrary();
};
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default HomePage;
export default function HomePage() {
const router = useRouter();
useEffect(() => {
router.replace('/library');
}, [router]);
return null;
}
@@ -18,6 +18,7 @@ import Annotator from './annotator/Annotator';
import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
import DoubleBorder from './DoubleBorder';
import TTSControl from './tts/TTSControl';
interface BooksGridProps {
bookKeys: string[];
@@ -42,7 +43,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
return (
<div
className={clsx('grid h-full flex-grow')}
className={clsx('relative grid h-full flex-grow')}
style={{
gridTemplateColumns: gridTemplate.columns,
gridTemplateRows: gridTemplate.rows,
@@ -132,6 +133,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<HintInfo
bookKey={bookKey}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
@@ -161,6 +163,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
</div>
);
})}
<TTSControl />
</div>
);
};
@@ -2,11 +2,12 @@ import React, { useEffect, useRef, useState } from 'react';
import { BookDoc, getDirection } from '@/libs/document';
import { BookConfig } from '@/types/book';
import { FoliateView, wrappedFoliateView } from '@/types/view';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePageFlip } from '../hooks/usePageFlip';
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
@@ -26,12 +27,15 @@ import {
import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
config: BookConfig;
}> = ({ bookKey, bookDoc, config }) => {
const { appService } = useEnv();
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const isViewCreated = useRef(false);
@@ -39,7 +43,6 @@ const FoliateViewer: React.FC<{
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { themeCode, isDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey)!;
const [toastMessage, setToastMessage] = useState('');
useEffect(() => {
@@ -47,36 +50,40 @@ const FoliateViewer: React.FC<{
return () => clearTimeout(timer);
}, [toastMessage]);
useUICSS(bookKey, viewSettings);
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const docTransformHandler = (event: Event) => {
const { detail } = event as CustomEvent;
detail.data = Promise.resolve(detail.data)
.then((data) => {
const viewSettings = getViewSettings(bookKey);
if (detail.type === 'text/css') return transformStylesheet(data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
viewSettings,
content: data,
transformers: ['punctuation'],
};
return Promise.resolve(transformContent(ctx));
}
return data;
})
.catch((e) => {
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
return '';
});
const getDocTransformHandler = ({ width, height }: { width: number; height: number }) => {
return (event: Event) => {
const { detail } = event as CustomEvent;
detail.data = Promise.resolve(detail.data)
.then((data) => {
const viewSettings = getViewSettings(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(viewSettings, width, height, data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
viewSettings,
content: data,
transformers: ['punctuation', 'footnote'],
};
return Promise.resolve(transformContent(ctx));
}
return data;
})
.catch((e) => {
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
return '';
});
};
};
const docLoadHandler = (event: Event) => {
@@ -97,6 +104,9 @@ const FoliateViewer: React.FC<{
mountAdditionalFonts(detail.doc);
if (!detail.doc.isEventListenersAdded) {
// listened events in iframes are posted to the main window
// and then used by useMouseEvent and useTouchEvent
// and more gesture events can be detected in the iframeEventHandlers
detail.doc.isEventListenersAdded = true;
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
@@ -114,18 +124,6 @@ const FoliateViewer: React.FC<{
const detail = (event as CustomEvent).detail;
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
if (detail.reason === 'scroll') {
const renderer = viewRef.current?.renderer;
const viewSettings = getViewSettings(bookKey)!;
if (renderer && viewSettings.continuousScroll) {
if (renderer.start <= 0) {
viewRef.current?.prev(1);
// sometimes viewSize has subpixel value that the end never reaches
} else if (renderer.end + 1 >= renderer.viewSize) {
viewRef.current?.next(1);
}
}
}
const parallelViews = getParallels(bookKey);
if (parallelViews && parallelViews.size > 0) {
parallelViews.forEach((key) => {
@@ -139,9 +137,9 @@ const FoliateViewer: React.FC<{
}
};
const { handlePageFlip } = usePageFlip(bookKey, viewRef, containerRef);
useTouchEvent(bookKey, viewRef);
useClickEvent(bookKey, handlePageFlip);
const { handlePageFlip, handleContinuousScroll } = usePagination(bookKey, viewRef, containerRef);
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip, handleContinuousScroll);
const touchHandlers = useTouchEvent(bookKey, handleContinuousScroll);
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
@@ -169,6 +167,11 @@ const FoliateViewer: React.FC<{
document.body.append(view);
containerRef.current?.appendChild(view);
const containerRect = containerRef.current?.getBoundingClientRect();
const width = containerRect?.width || window.innerWidth;
const height = containerRect?.height || window.innerHeight;
const viewSettings = getViewSettings(bookKey)!;
const writingMode = viewSettings.writingMode;
if (writingMode) {
const settingsDir = getBookDirFromWritingMode(writingMode);
@@ -187,16 +190,23 @@ const FoliateViewer: React.FC<{
const { book } = view;
book.transformTarget?.addEventListener('data', docTransformHandler);
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
const isScrolled = viewSettings.scrolled!;
const marginPx = viewSettings.marginPx!;
const gapPercent = viewSettings.gapPercent!;
const showHeader = viewSettings.showHeader!;
const showFooter = viewSettings.showFooter!;
const isCompact = !showHeader && !showFooter;
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent;
const animated = viewSettings.animated!;
const maxColumnCount = viewSettings.maxColumnCount!;
const maxInlineSize = getMaxInlineSize(viewSettings);
const maxBlockSize = viewSettings.maxBlockSize!;
const screenOrientation = viewSettings.screenOrientation!;
if (appService?.isMobileApp) {
await lockScreenOrientation({ orientation: screenOrientation });
}
if (animated) {
view.renderer.setAttribute('animated', '');
} else {
@@ -224,9 +234,10 @@ const FoliateViewer: React.FC<{
return (
<>
<div
className='foliate-viewer h-[100%] w-[100%]'
onClick={(event) => handlePageFlip(event)}
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
</>
);
@@ -1,7 +1,8 @@
import React from 'react';
import React, { useEffect } from 'react';
import clsx from 'clsx';
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
@@ -17,6 +18,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import { viewPagination } from '../hooks/usePagination';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { PageInfo } from '@/types/book';
import Button from '@/components/Button';
@@ -39,8 +41,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = React.useState('');
const sliderHeight = useResponsiveSize(28);
@@ -51,6 +53,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
const handleProgressChange = (value: number) => {
view?.goToFraction(value / 100.0);
@@ -76,12 +79,24 @@ const FooterBar: React.FC<FooterBarProps> = ({
saveViewSettings(envConfig, bookKey, 'lineHeight', value / 10);
};
const handleGoPrev = () => {
view?.goLeft();
const handleGoPrevPage = () => {
viewPagination(view, viewSettings, 'left');
};
const handleGoNext = () => {
view?.goRight();
const handleGoNextPage = () => {
viewPagination(view, viewSettings, 'right');
};
const handleGoPrevSection = () => {
if (view?.renderer.prevSection) {
view?.renderer.prevSection();
}
};
const handleGoNextSection = () => {
if (view?.renderer.nextSection) {
view?.renderer.nextSection();
}
};
const handleGoBack = () => {
@@ -94,11 +109,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey, range });
eventDispatcher.dispatch('tts-speak', { bookKey });
}
};
@@ -122,16 +136,23 @@ const FooterBar: React.FC<FooterBarProps> = ({
}
};
useEffect(() => {
if (hoveredBookKey !== bookKey) {
setActionTab('');
}
}, [hoveredBookKey, bookKey]);
const getMarginProgressValue = (marginPx: number, gapPercent: number) => {
return (marginPx / 88 + gapPercent / 10) * 50;
};
const isVisible = hoveredBookKey === bookKey;
const ttsEnabled = viewState?.ttsEnabled;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction =
progressValid && progressInfo?.total > 0
? ((progressInfo!.next ?? progressInfo!.current) + 1) / progressInfo!.total || 0
? (progressInfo!.current + 1) / progressInfo!.total || 0
: 0;
return (
@@ -167,7 +188,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
{/* Mobile footer bar */}
<div
className={clsx(
'bg-base-200 absolute bottom-16 flex w-full items-center gap-x-2 px-4 transition-all sm:hidden',
'bg-base-200 absolute bottom-16 flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
actionTab === 'progress'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
@@ -178,34 +199,48 @@ const FooterBar: React.FC<FooterBarProps> = ({
: '64px',
}}
>
<Button
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<Slider
heightPx={sliderHeight}
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
initialValue={progressValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
/>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
heightPx={sliderHeight}
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
initialValue={progressValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
</div>
<div className='flex w-full items-center justify-between gap-x-6'>
<Button
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
tooltip={viewSettings?.rtl ? _('Next Section') : _('Previous Section')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowRightSLine /> : <RiArrowLeftSLine />}
onClick={viewSettings?.rtl ? handleGoNextPage : handleGoPrevPage}
tooltip={viewSettings?.rtl ? _('Next Page') : _('Previous Page')}
/>
<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}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
tooltip={viewSettings?.rtl ? _('Previous Page') : _('Next Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftDoubleLine /> : <RiArrowRightDoubleLine />}
onClick={viewSettings?.rtl ? handleGoPrevSection : handleGoNextSection}
tooltip={viewSettings?.rtl ? _('Previous Section') : _('Next Section')}
/>
</div>
</div>
<div
className={clsx(
@@ -278,14 +313,22 @@ const FooterBar: React.FC<FooterBarProps> = ({
}
onClick={() => handleSetActionTab('font')}
/>
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
<Button
icon={<TTSIcon className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={() => handleSetActionTab('tts')}
/>
</div>
{/* Desktop footer bar */}
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
<Button
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
tooltip={viewSettings?.rtl ? _('Next Section') : _('Previous Section')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowRightSLine /> : <RiArrowLeftSLine />}
onClick={viewSettings?.rtl ? handleGoNextPage : handleGoPrevPage}
tooltip={viewSettings?.rtl ? _('Next Page') : _('Previous Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
@@ -312,11 +355,20 @@ const FooterBar: React.FC<FooterBarProps> = ({
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
icon={<FaHeadphones className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={handleSpeakText}
tooltip={_('Speak')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
tooltip={viewSettings?.rtl ? _('Previous Page') : _('Next Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftDoubleLine /> : <RiArrowRightDoubleLine />}
onClick={viewSettings?.rtl ? handleGoPrevSection : handleGoNextSection}
tooltip={viewSettings?.rtl ? _('Previous Section') : _('Next Section')}
/>
</div>
</div>
@@ -14,6 +14,7 @@ import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler';
import NotebookToggler from './NotebookToggler';
import SettingsToggler from './SettingsToggler';
import TranslationToggler from './TranslationToggler';
import ViewMenu from './ViewMenu';
interface HeaderBarProps {
@@ -119,6 +120,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<SidebarToggler bookKey={bookKey} />
</div>
<BookmarkToggler bookKey={bookKey} />
<TranslationToggler bookKey={bookKey} />
</div>
<div className='header-title z-15 bg-base-100 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
@@ -5,6 +5,7 @@ import { eventDispatcher } from '@/utils/event';
interface SectionInfoProps {
bookKey: string;
showDoubleBorder: boolean;
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
@@ -13,6 +14,7 @@ interface SectionInfoProps {
const HintInfo: React.FC<SectionInfoProps> = ({
bookKey,
showDoubleBorder,
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
@@ -57,7 +59,14 @@ const HintInfo: React.FC<SectionInfoProps> = ({
'hintinfo absolute flex items-center justify-end overflow-hidden ps-2',
'mt-[env(safe-area-inset-top)]',
hintMessage ? 'bg-base-100' : 'bg-transparent',
isVertical ? 'writing-vertical-rl max-h-[50%]' : 'top-0 h-[44px] max-w-[50%]',
isVertical ? 'writing-vertical-rl' : 'top-0 h-[44px]',
isScrolled
? isVertical
? 'h-full'
: 'w-full'
: isVertical
? 'max-h-[50%]'
: 'max-w-[50%]',
)}
style={
isVertical
@@ -35,7 +35,7 @@ const PageInfoView: React.FC<PageInfoProps> = ({
: ''
: pageinfo
? _(isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}', {
currentPage: (pageinfo.next ?? pageinfo.current) + 1,
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
})
: '';
@@ -15,6 +15,8 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { eventDispatcher } from '@/utils/event';
import { interceptGlobalOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/utils/style';
import { setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
@@ -36,6 +38,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useTheme({ systemUIVisible: false, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
mountAdditionalFonts(document);
interceptGlobalOpen();
}, []);
useEffect(() => {
const handleVisibilityChange = () => {
if (appService?.isMobileApp && !document.hidden) {
@@ -94,7 +101,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}, []);
useEffect(() => {
if (!appService?.isMobile) return;
if (!appService?.isMobileApp) return;
const systemUIVisible = !!hoveredBookKey;
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
if (systemUIVisible) {
@@ -27,7 +27,6 @@ import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import TTSControl from './tts/TTSControl';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
@@ -77,6 +76,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}, []);
useEffect(() => {
if (bookKeys && bookKeys.length > 0) {
const settings = useSettingsStore.getState().settings;
settings.lastOpenBooks = bookKeys.map((key) => key.split('-')[0]!);
saveSettings(envConfig, settings);
}
if (isTauriAppPlatform()) tauriHandleOnCloseWindow(handleCloseBooks);
window.addEventListener('beforeunload', handleCloseBooks);
eventDispatcher.on('quit-app', handleCloseBooks);
@@ -160,7 +165,6 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
<div className={clsx('flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<TTSControl />
<Notebook />
{showDetailsBook && (
<BookDetailModal
@@ -1,14 +1,17 @@
import React from 'react';
import { RiFontSize } from 'react-icons/ri';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import Button from '@/components/Button';
const SettingsToggler = () => {
const _ = useTranslation();
const { setHoveredBookKey } = useReaderStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const handleToggleSettings = () => {
setHoveredBookKey('');
setFontLayoutSettingsDialogOpen(!isFontLayoutSettingsDialogOpen);
};
return (
@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react';
import { RiTranslateAi } from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { isSameLang } from '@/utils/lang';
import Button from '@/components/Button';
import { getLocale } from '@/utils/misc';
const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getViewSettings, setViewSettings, setHoveredBookKey } = useReaderStore();
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
const primaryLanguage = bookData?.book?.primaryLanguage;
const targetLanguage = viewSettings.translateTargetLang;
useEffect(() => {
if (translationEnabled === viewSettings.translationEnabled) return;
if (appService?.isMobile) {
setHoveredBookKey('');
}
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
viewSettings.translationEnabled = translationEnabled;
setViewSettings(bookKey, { ...viewSettings });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translationEnabled]);
return (
<Button
icon={
<RiTranslateAi className={translationEnabled ? 'text-blue-500' : 'text-base-content'} />
}
disabled={
!bookData ||
bookData.book?.format === 'PDF' ||
isSameLang(primaryLanguage, targetLanguage) ||
(!targetLanguage && isSameLang(primaryLanguage, getLocale()))
}
onClick={() => setTranslationEnabled(!translationEnabled)}
tooltip={translationEnabled ? _('Disable Translation') : _('Enable Translation')}
tooltipDirection='bottom'
></Button>
);
};
export default TranslationToggler;
@@ -10,7 +10,6 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { ThemeMode } from '@/styles/themes';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { tauriHandleToggleFullScreen } from '@/utils/window';
@@ -33,9 +32,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, setThemeMode } = useThemeStore();
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
viewSettings!.invertImgColorInDark,
);
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
@@ -48,8 +50,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
};
const cycleThemeMode = () => {
const nextMode: ThemeMode =
themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
setThemeMode(nextMode);
};
@@ -76,6 +77,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
useEffect(() => {
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark, true, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
return (
<div
tabIndex={0}
@@ -135,6 +142,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
<MenuItem
label={_('Invert Image In Dark Mode')}
disabled={!isDarkMode}
Icon={invertImgColorInDark ? MdCheck : undefined}
onClick={() => setInvertImgColorInDark(!invertImgColorInDark)}
/>
</div>
);
};
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import { FiSearch } from 'react-icons/fi';
import { FiCopy } from 'react-icons/fi';
import { PiHighlighterFill } from 'react-icons/pi';
@@ -22,15 +22,15 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { useTextSelector } from '../../hooks/useTextSelector';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/utils/toc';
import { transformContent } from '@/services/transformService';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import DeepLPopup from './DeepLPopup';
import TranslatorPopup from './TranslatorPopup';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -49,13 +49,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const isShowingPopup = useRef(false);
const isTextSelected = useRef(false);
const isUpToShowPopup = useRef(false);
const isTouchstarted = useRef(false);
const selectionStartRef = useRef<number | null>(null);
const selectionAnchorRef = useRef<{ node: Node; offset: number } | null>(null);
const [selection, setSelection] = useState<TextSelection | null>();
const [selection, setSelection] = useState<TextSelection | null>(null);
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
const [showWikipediaPopup, setShowWikipediaPopup] = useState(false);
@@ -79,139 +73,50 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const dictPopupWidth = Math.min(480, maxWidth);
const dictPopupHeight = Math.min(300, maxHeight);
const transPopupWidth = Math.min(480, maxWidth);
const transPopupHeight = Math.min(360, maxHeight);
const transPopupHeight = Math.min(265, maxHeight);
const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth);
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 0;
const handleDismissPopup = () => {
setSelection(null);
setShowAnnotPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
};
const handleDismissPopupAndSelection = () => {
handleDismissPopup();
view?.deselect();
};
const {
handleScroll,
handleTouchStart,
handleTouchEnd,
handlePointerup,
handleSelectionchange,
handleShowPopup,
handleUpToPopup,
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
const onLoad = (event: Event) => {
const detail = (event as CustomEvent).detail;
const { doc, index } = detail;
const isValidSelection = (sel: Selection) => {
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
};
const transformCtx = {
bookKey,
viewSettings: getViewSettings(bookKey)!,
content: '',
transformers: ['punctuation'],
reversePunctuationTransform: true,
};
const makeSelection = async (sel: Selection, rebuildRange = false) => {
isTextSelected.current = true;
const range = sel.getRangeAt(0);
if (rebuildRange) {
sel.removeAllRanges();
sel.addRange(range);
}
transformCtx['content'] = sel.toString();
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
};
// FIXME: extremely hacky way to dismiss system selection tools on iOS
const makeSelectionOnIOS = async (sel: Selection) => {
isTextSelected.current = true;
const range = sel.getRangeAt(0);
setTimeout(() => {
sel.removeAllRanges();
setTimeout(async () => {
if (!isTextSelected.current) return;
sel.addRange(range);
transformCtx['content'] = range.toString();
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
}, 40);
}, 0);
};
const handleSelectstart = () => {
selectionAnchorRef.current = null;
};
const handleSelectionchange = () => {
// Available on iOS, Android and Desktop, fired when the selection is changed
// Ideally the popup only shows when the selection is done,
// but on Android no proper events are fired to notify selection done or I didn't find it,
// we make the popup show when the selection is changed
if (osPlatform === 'ios' || appService?.isIOSApp) return;
const sel = doc.getSelection();
if (isValidSelection(sel)) {
if (osPlatform === 'android' && isTouchstarted.current) {
if (!selectionAnchorRef.current) {
const range = sel.getRangeAt(0);
selectionAnchorRef.current = {
node: range.startContainer,
offset: range.startOffset,
};
}
if (selectionAnchorRef.current) {
const currentRange = sel.getRangeAt(0);
if (
currentRange.startContainer !== selectionAnchorRef.current.node ||
currentRange.startOffset !== selectionAnchorRef.current.offset
) {
const newRange = doc.createRange();
newRange.setStart(selectionAnchorRef.current.node, selectionAnchorRef.current.offset);
newRange.setEnd(currentRange.endContainer, currentRange.endOffset);
sel.removeAllRanges();
sel.addRange(newRange);
}
}
makeSelection(sel, false);
}
} else if (!isUpToShowPopup.current) {
isTextSelected.current = false;
setShowAnnotPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
}
};
const handlePointerup = () => {
// Available on iOS and Desktop, fired when release the long press
// Note that on Android, pointerup event is fired after an additional touch event
const sel = doc.getSelection();
if (isValidSelection(sel)) {
if (osPlatform === 'ios' || appService?.isIOSApp) {
makeSelectionOnIOS(sel);
} else {
makeSelection(sel, true);
}
}
};
const handleTouchstart = () => {
// Available on iOS and Android for the initial touch event
isTouchstarted.current = true;
};
const handleTouchmove = () => {
// Available on iOS, on Android not fired
// To make the popup not to follow the selection
setShowAnnotPopup(false);
};
const handleTouchend = () => {
// Available on iOS, on Android fired after an additional touch event
isTouchstarted.current = false;
};
const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode
// This is a workaround for the issue #873
// TODO: support text selection across pages
if (
!viewSettings?.scrolled &&
isTextSelected.current &&
view?.renderer?.containerPosition &&
selectionStartRef.current
) {
view.renderer.containerPosition = selectionStartRef.current;
}
};
if (bookData.book?.format !== 'PDF') {
view?.renderer?.addEventListener('scroll', handleScroll);
detail.doc?.addEventListener('pointerup', handlePointerup);
detail.doc?.addEventListener('touchstart', handleTouchstart);
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchend);
detail.doc?.addEventListener('selectstart', handleSelectstart);
detail.doc?.addEventListener('selectionchange', handleSelectionchange);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerup', () => handlePointerup(doc, index));
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
// Disable the default context menu on mobile devices,
// although it should but doesn't work on iOS
@@ -259,60 +164,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const annotation = annotations.find((annotation) => annotation.cfi === cfi);
if (!annotation) return;
const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index };
isUpToShowPopup.current = true;
setSelectedStyle(annotation.style!);
setSelectedColor(annotation.color!);
setSelection(selection);
handleUpToPopup();
};
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
const handleDismissPopup = () => {
setSelection(null);
setShowAnnotPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
isShowingPopup.current = false;
};
const handleDismissPopupAndSelection = () => {
handleDismissPopup();
view?.deselect();
isTextSelected.current = false;
};
useEffect(() => {
if (isTextSelected.current && !selectionStartRef.current) {
selectionStartRef.current = view?.renderer?.start || null;
} else if (!isTextSelected.current) {
selectionStartRef.current = null;
selectionAnchorRef.current = null;
}
handleShowPopup(showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTextSelected.current]);
}, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup]);
useEffect(() => {
const handleSingleClick = (): boolean => {
if (isUpToShowPopup.current) {
isUpToShowPopup.current = false;
return true;
}
if (isTextSelected.current) {
handleDismissPopupAndSelection();
return true;
}
if (showAnnotPopup || isShowingPopup.current) {
handleDismissPopup();
return true;
}
return false;
};
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
eventDispatcher.on('export-annotations', handleExportMarkdown);
return () => {
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
eventDispatcher.off('export-annotations', handleExportMarkdown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -332,7 +199,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
viewSettings.vertical ? annotPopupWidth : annotPopupHeight,
popupPadding,
);
if (isTextSelected.current && annotPopupPos.dir === 'down' && osPlatform === 'android') {
if (annotPopupPos.dir === 'down' && osPlatform === 'android') {
triangPos.point.y += androidSelectionHandlerHeight;
annotPopupPos.point.y += androidSelectionHandlerHeight;
}
@@ -351,12 +218,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
popupPadding,
);
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
setShowAnnotPopup(true);
setAnnotPopupPosition(annotPopupPos);
setDictPopupPosition(dictPopupPos);
setTranslatorPopupPosition(transPopupPos);
setTrianglePosition(triangPos);
isShowingPopup.current = true;
handleShowAnnotPopup();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
@@ -383,6 +249,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
const handleShowAnnotPopup = () => {
setShowAnnotPopup(true);
setShowDeepLPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
};
const handleCopy = () => {
if (!selection || !selection.text) return;
eventDispatcher.dispatch('toast', {
@@ -635,7 +508,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
)}
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
<DeepLPopup
<TranslatorPopup
text={selection?.text as string}
position={translatorPopupPosition}
trianglePosition={trianglePosition}
@@ -1,185 +0,0 @@
import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { getAPIBaseUrl } from '@/services/environment';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useAuth } from '@/context/AuthContext';
const LANGUAGES = {
AUTO: 'Auto Detect',
EN: 'English',
DE: 'German',
FR: 'French',
ES: 'Spanish',
IT: 'Italian',
EL: 'Greek',
PT: 'Portuguese',
NL: 'Dutch',
PL: 'Polish',
UK: 'Ukrainian',
RU: 'Russian',
AR: 'Arabic',
TR: 'Turkish',
ID: 'Indonesian',
KO: 'Korean',
JA: 'Japanese',
'ZH-HANS': 'Chinese (Simplified)',
'ZH-HANT': 'Chinese (Traditional)',
};
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
interface DeepLPopupProps {
text: string;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
}
const DeepLPopup: React.FC<DeepLPopupProps> = ({
text,
position,
trianglePosition,
popupWidth,
popupHeight,
}) => {
const _ = useTranslation();
const { token } = useAuth();
const { settings, setSettings } = useSettingsStore();
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
const [translation, setTranslation] = useState<string | null>(null);
const [detectedSourceLang, setDetectedSourceLang] = useState<keyof typeof LANGUAGES | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSourceLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceLang(event.target.value);
};
const handleTargetLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
settings.globalReadSettings.translateTargetLang = event.target.value;
setSettings(settings);
setTargetLang(event.target.value);
};
useEffect(() => {
const fetchTranslation = async () => {
setLoading(true);
setError(null);
setTranslation(null);
try {
const response = await fetch(DEEPL_API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token ?? ''}`,
},
body: JSON.stringify({
text: [text],
target_lang: targetLang.toUpperCase(),
source_lang: sourceLang === 'AUTO' ? undefined : sourceLang.toUpperCase(),
}),
});
if (!response.ok) {
throw new Error('Failed to fetch translation');
}
const data = await response.json();
const translatedText = data.translations[0]?.text;
const detectedSource = data.translations[0]?.detected_source_language;
if (!translatedText) {
throw new Error('No translation found');
}
if (sourceLang === 'AUTO' && detectedSource) {
setDetectedSourceLang(detectedSource);
}
setTranslation(translatedText);
} catch (err) {
console.error(err);
if (!token) {
setError(_('Unable to fetch the translation. Please log in first and try again.'));
} else {
setError(_('Unable to fetch the translation. Try again later.'));
}
} finally {
setLoading(false);
}
};
fetchTranslation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, token, sourceLang, targetLang]);
return (
<div>
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
height={popupHeight}
position={position}
className='select-text'
>
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-base font-semibold'>{_('Original Text')}</h1>
<select
value={sourceLang}
onChange={handleSourceLangChange}
className='select text-neutral-content h-8 min-h-8 rounded-md border-none bg-neutral-200/50 text-sm focus:outline-none focus:ring-0'
>
{Object.entries(LANGUAGES).map(([code, name]) => {
return (
<option key={code} value={code}>
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} ` + _('(detected)')
: name}
</option>
);
})}
</select>
</div>
<p className='text-base'>{text}</p>
</div>
<div className='text-neutral-content relative h-[50%] overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-base font-semibold'>{_('Translated Text')}</h2>
<select
value={targetLang}
onChange={handleTargetLangChange}
className='select text-neutral-content h-8 min-h-8 rounded-md border-none bg-neutral-200/50 text-sm focus:outline-none focus:ring-0'
>
{Object.entries(LANGUAGES)
.filter(([code]) => code !== 'AUTO')
.map(([code, name]) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
</div>
{loading ? (
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
) : error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
<div>
<p className='text-base'>{translation || 'No translation available.'}</p>
<div className='pt-4 text-sm opacity-60'>Translated by DeepL.</div>
</div>
)}
</div>
</Popup>
</div>
);
};
export default DeepLPopup;
@@ -0,0 +1,215 @@
import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useAuth } from '@/context/AuthContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATED_LANGS } from '@/services/constants';
import { UseTranslatorOptions } from '@/services/translators';
import { localeToLang } from '@/utils/lang';
import Select from '@/components/Select';
const notSupportedLangs = ['hi', 'vi'];
const generateTranslatorLangs = () => {
const langs = { ...TRANSLATED_LANGS };
const result: Record<string, string> = {};
for (const [code, name] of Object.entries(langs)) {
if (notSupportedLangs.includes(code)) continue;
const newCode = localeToLang(code).toUpperCase();
result[newCode] = name;
}
return result;
};
const TRANSLATOR_LANGS = generateTranslatorLangs();
interface TranslatorPopupProps {
text: string;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
}
interface TranslatorType {
name: string;
label: string;
}
const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
text,
position,
trianglePosition,
popupWidth,
popupHeight,
}) => {
const _ = useTranslation();
const { token } = useAuth();
const { settings, setSettings } = useSettingsStore();
const [providers, setProviders] = useState<TranslatorType[]>([]);
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
const [provider, setProvider] = useState(settings.globalReadSettings.translationProvider);
const [translation, setTranslation] = useState<string | null>(null);
const [detectedSourceLang, setDetectedSourceLang] = useState<
keyof typeof TRANSLATOR_LANGS | null
>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { translate, translators } = useTranslator({
provider,
sourceLang,
targetLang,
} as UseTranslatorOptions);
const handleSourceLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceLang(event.target.value);
};
const handleTargetLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
settings.globalReadSettings.translateTargetLang = event.target.value;
setSettings(settings);
setTargetLang(event.target.value);
};
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedTranslator = translators.find((t) => t.name === event.target.value);
if (selectedTranslator) {
settings.globalReadSettings.translationProvider = selectedTranslator.name;
setSettings(settings);
setProvider(selectedTranslator.name);
}
};
useEffect(() => {
const availableProviders = translators.map((t) => {
return { name: t.name, label: t.label };
});
setProviders(availableProviders);
}, [translators]);
useEffect(() => {
setLoading(true);
const fetchTranslation = async () => {
setError(null);
setTranslation(null);
try {
const input = text.replaceAll('\n', '').trim();
const result = await translate([input]);
const translatedText = result[0];
const detectedSource = null;
if (!translatedText) {
throw new Error('No translation found');
}
setTranslation(translatedText);
if (sourceLang === 'AUTO' && detectedSource) {
setDetectedSourceLang(detectedSource);
}
} catch (err) {
console.error(err);
if (!token) {
setError(_('Unable to fetch the translation. Please log in first and try again.'));
} else {
setError(_('Unable to fetch the translation. Try again later.'));
}
} finally {
setLoading(false);
}
};
fetchTranslation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, token, sourceLang, targetLang, provider]);
return (
<div>
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
minHeight={popupHeight}
maxHeight={720}
position={position}
className='grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600 text-white'
triangleClassName='text-gray-600'
>
<div className='overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-sm font-normal'>{_('Original Text')}</h1>
<Select
value={sourceLang}
onChange={handleSourceLangChange}
options={[
{ value: 'AUTO', label: _('Auto Detect') },
...Object.entries(TRANSLATOR_LANGS)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => {
const label =
detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${TRANSLATOR_LANGS[detectedSourceLang] || detectedSourceLang} ` +
_('(detected)')
: name;
return { value: code, label };
}),
]}
/>
</div>
<p className='text-base text-white/90'>{text}</p>
</div>
<div className='mx-4 flex-shrink-0 border-t border-gray-500/30'></div>
<div className='overflow-y-auto px-4 pb-8 pt-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
<Select
value={targetLang}
onChange={handleTargetLangChange}
options={Object.entries(TRANSLATOR_LANGS)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => ({ value: code, label: name }))}
/>
</div>
{loading ? (
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
) : (
<div>
{error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
<p className='text-base text-white/90'>
{translation || _('No translation available.')}
</p>
)}
</div>
)}
</div>
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between bg-gray-600 px-4'>
{provider && !loading && (
<div className='text-xs opacity-60'>
{error
? ''
: _('Translated by {{provider}}.', {
provider: providers.find((p) => p.name === provider)?.label,
})}
</div>
)}
<div className='ml-auto'>
<Select
value={provider}
onChange={handleProviderChange}
options={providers.map(({ name: value, label }) => ({ value, label }))}
/>
</div>
</div>
</Popup>
</div>
);
};
export default TranslatorPopup;
@@ -16,11 +16,11 @@ const NotebookHeader: React.FC<{
const iconSize14 = useResponsiveSize(14);
return (
<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'>
<div className='absolute inset-0 z-[-1] flex items-center justify-center space-x-2'>
<LuNotebookPen />
<div className='notebook-title hidden text-sm font-medium sm:flex'>{_('Notebook')}</div>
</div>
<div className='z-10 flex items-center gap-x-4'>
<div className='flex w-full items-center gap-x-4'>
<button
onClick={handleTogglePin}
className={clsx(
@@ -36,6 +36,8 @@ const NotebookHeader: React.FC<{
>
<MdArrowBackIosNew />
</button>
</div>
<div className='flex items-center justify-end gap-x-4'>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<FiSearch />
</button>
@@ -42,6 +42,7 @@ const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
<input
type='text'
value={value}
spellCheck={false}
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'
/>
@@ -13,24 +13,38 @@ import {
themes,
} from '@/styles/themes';
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 { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import ThemeEditor from './ThemeEditor';
const ColorPanel: React.FC<{ bookKey: string }> = ({}) => {
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
useThemeStore();
const { envConfig } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
viewSettings.invertImgColorInDark,
);
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(() => {
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
useEffect(() => {
const customThemes = settings.globalReadSettings.customThemes ?? [];
setCustomThemes(
@@ -112,6 +126,17 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({}) => {
</div>
</div>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Invert Image In Dark Mode')}</h2>
<input
type='checkbox'
className='toggle'
checked={invertImgColorInDark}
disabled={!isDarkMode}
onChange={() => setInvertImgColorInDark(!invertImgColorInDark)}
/>
</div>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
@@ -0,0 +1,208 @@
import React, { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import NumberInput from './NumberInput';
const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
const [scrollingOverlap, setScrollingOverlap] = useState(viewSettings.scrollingOverlap!);
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
const [animated, setAnimated] = useState(viewSettings.animated!);
const [allowScript, setAllowScript] = useState(viewSettings.allowScript!);
useEffect(() => {
if (isScrolledMode === viewSettings.scrolled) return;
saveViewSettings(envConfig, bookKey, 'scrolled', isScrolledMode);
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
`${getMaxInlineSize(viewSettings)}px`,
);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isContinuousScroll]);
useEffect(() => {
if (scrollingOverlap === viewSettings.scrollingOverlap) return;
saveViewSettings(envConfig, bookKey, 'scrollingOverlap', scrollingOverlap, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollingOverlap]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
if (appService?.isMobileApp) {
if (volumeKeysToFlip) {
acquireVolumeKeyInterception();
} else {
releaseVolumeKeyInterception();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeKeysToFlip]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableClick]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapClickArea]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
if (animated) {
getView(bookKey)?.renderer.setAttribute('animated', '');
} else {
getView(bookKey)?.renderer.removeAttribute('animated');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animated]);
useEffect(() => {
if (viewSettings.allowScript === allowScript) return;
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
setTimeout(() => window.location.reload(), 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowScript]);
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Scroll')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Scrolled Mode')}</span>
<input
type='checkbox'
className='toggle'
checked={isScrolledMode}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Continuous Scroll')}</span>
<input
type='checkbox'
className='toggle'
checked={isContinuousScroll}
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
<NumberInput
label={_('Overlap Pixels')}
value={scrollingOverlap}
onChange={setScrollingOverlap}
disabled={!viewSettings.scrolled}
min={0}
max={200}
step={10}
/>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Click')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span className=''>{_('Clicks for Page Flip')}</span>
<input
type='checkbox'
className='toggle'
checked={!isDisableClick}
onChange={() => setIsDisableClick(!isDisableClick)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Swap Clicks Area')}</span>
<input
type='checkbox'
className='toggle'
checked={swapClickArea}
disabled={isDisableClick}
onChange={() => setSwapClickArea(!swapClickArea)}
/>
</div>
{appService?.isMobileApp && (
<div className='config-item'>
<span className=''>{_('Volume Keys for Page Flip')}</span>
<input
type='checkbox'
className='toggle'
checked={volumeKeysToFlip}
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
/>
</div>
)}
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
checked={animated}
onChange={() => setAnimated(!animated)}
/>
</div>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Security')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-16'>
<div className='flex flex-col gap-1'>
<span className=''>{_('Allow JavaScript')}</span>
<span className='text-xs'>{_('Enable only if you trust the file.')}</span>
</div>
<input
type='checkbox'
className='toggle'
checked={allowScript}
disabled={bookData.book?.format !== 'EPUB'}
onChange={() => setAllowScript(!allowScript)}
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default ControlPanel;
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { useSettingsStore } from '@/store/settingsStore';
@@ -21,10 +22,13 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
return (
<div
tabIndex={0}
className='dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl'
className={clsx(
'dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl',
'text-base sm:text-sm',
)}
>
<button
className='hover:bg-base-200 text-base-content flex w-full items-center justify-between rounded-md p-2'
className='hover:bg-base-200 text-base-content flex w-full items-center justify-between rounded-md p-1'
onClick={handleToggleGlobal}
>
<div className='flex items-center'>
@@ -8,17 +8,30 @@ interface DropDownProps {
selected: { option: string; label: string };
options: { option: string; label: string }[];
onSelect: (option: string) => void;
disabled?: boolean;
className?: string;
listClassName?: string;
}
const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
const DropDown: React.FC<DropDownProps> = ({
selected,
options,
onSelect,
className,
listClassName,
disabled = false,
}) => {
const iconSize16 = useResponsiveSize(16);
const defaultIconSize = useDefaultIconSize();
return (
<div className='dropdown dropdown-bottom'>
<div className={clsx('dropdown dropdown-bottom', className)}>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
className={clsx(
'btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case',
disabled && 'btn-disabled',
)}
onClick={(e) => e.currentTarget.focus()}
>
<span>{selected.label}</span>
@@ -29,6 +42,7 @@ const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] shadow',
'menu-vertical right-[-32px] mt-2 inline max-h-80 w-44 overflow-y-scroll sm:right-0',
listClassName,
)}
>
{options.map(({ option, label }) => (
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React from 'react';
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -23,6 +24,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
onGetFontFamily,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const iconSize16 = useResponsiveSize(16);
const allOptions = [...options, ...(moreOptions ?? [])];
const selectedOption = allOptions.find((option) => option.option === selected) ?? allOptions[0]!;
@@ -88,7 +90,13 @@ const FontDropdown: React.FC<DropdownProps> = ({
<MdCheck className='text-base-content' size={iconSize16} />
)}
</span>
<span style={{ fontFamily: onGetFontFamily(option.option, family ?? '') }}>
<span
style={
!appService?.isLinuxApp
? { fontFamily: onGetFontFamily(option.option, family ?? '') }
: {}
}
>
{option.label || option.option}
</span>
</div>
@@ -12,6 +12,7 @@ import {
LINUX_FONTS,
MACOS_FONTS,
MONOSPACE_FONTS,
NON_FREE_FONTS,
SANS_SERIF_FONTS,
SERIF_FONTS,
WINDOWS_FONTS,
@@ -49,6 +50,10 @@ const handleFontFaceFont = (option: string, family: string) => {
return `'${option}', ${family}`;
};
const filterNonFreeFonts = (font: string) => {
return !['android', 'linux'].includes(getOSPlatform()) || !NON_FREE_FONTS.includes(font);
};
const FontFace = ({
className,
family,
@@ -137,12 +142,26 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
if (isTauriAppPlatform()) {
getSysFontsList().then((res) => {
if (res.error) {
if (res.error || Object.keys(res.fonts).length === 0) {
console.error('Failed to get system fonts list:', res.error);
return;
}
const fonts = res.fonts.filter((font) => font && !isSymbolicFontName(font));
setSysFonts([...new Set(fonts)].sort((a, b) => a.localeCompare(b)));
const processedFonts: string[] = [];
Object.entries(res.fonts).forEach(([fontName, fontFamily]) => {
if (!fontName || isSymbolicFontName(fontName)) return;
const fontsInFamily = Object.entries(res.fonts).filter(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([_, family]) => family === fontFamily,
);
if (fontsInFamily.length === 1) {
processedFonts.push(fontFamily);
} else {
processedFonts.push(fontName);
}
});
setSysFonts([...new Set(processedFonts)].sort((a, b) => a.localeCompare(b)));
});
}
}, []);
@@ -207,6 +226,16 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Font')}</h2>
<input
type='checkbox'
className='toggle'
checked={overrideFont}
onChange={() => setOverrideFont(!overrideFont)}
/>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Size')}</h2>
<div className='card border-base-200 border shadow'>
@@ -269,16 +298,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onSelect={setDefaultCJKFont}
/>
)}
<div className='config-item'>
<span className=''>{_('Override Book Font')}</span>
<input
type='checkbox'
className='toggle'
checked={overrideFont}
onChange={() => setOverrideFont(!overrideFont)}
/>
</div>
</div>
</div>
</div>
@@ -291,7 +310,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
className='config-item-top'
family='serif'
label={_('Serif Font')}
options={[...SERIF_FONTS, ...CJK_SERIF_FONTS]}
options={[...SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SERIF_FONTS]}
moreOptions={sysFonts}
selected={serifFont}
onSelect={setSerifFont}
@@ -299,7 +318,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<FontFace
family='sans-serif'
label={_('Sans-Serif Font')}
options={[...SANS_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]}
options={[...SANS_SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SANS_SERIF_FONTS]}
moreOptions={sysFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
@@ -0,0 +1,151 @@
import clsx from 'clsx';
import i18n from 'i18next';
import React, { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { getTranslators } from '@/services/translators';
import { TRANSLATED_LANGS } from '@/services/constants';
import DropDown from './DropDown';
const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
const [translationProvider, setTranslationProvider] = useState(viewSettings.translationProvider!);
const [translateTargetLang, setTranslateTargetLang] = useState(viewSettings.translateTargetLang!);
const getCurrentUILangOption = () => {
const uiLanguage = viewSettings.uiLanguage;
return {
option: uiLanguage,
label:
uiLanguage === ''
? _('Auto')
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
};
};
const getLangOptions = () => {
const langs = TRANSLATED_LANGS as Record<string, string>;
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
options.sort((a, b) => a.label.localeCompare(b.label));
options.unshift({ option: '', label: _('System Language') });
return options;
};
const handleSelectUILang = (option: string) => {
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
i18n.changeLanguage(option ? option : navigator.language);
};
const getTranslationProviderOptions = () => {
const translators = getTranslators();
const availableProviders = translators.map((t) => {
return { option: t.name, label: t.label };
});
return availableProviders;
};
const getCurrentTranslationProviderOption = () => {
const option = translationProvider;
const availableProviders = getTranslationProviderOptions();
return availableProviders.find((p) => p.option === option) || availableProviders[0]!;
};
const handleSelectTranslationProvider = (option: string) => {
setTranslationProvider(option);
saveViewSettings(envConfig, bookKey, 'translationProvider', option, false, false);
viewSettings.translationProvider = option;
setViewSettings(bookKey, { ...viewSettings });
};
const getCurrentTargetLangOption = () => {
const option = translateTargetLang;
const availableOptions = getLangOptions();
return availableOptions.find((o) => o.option === option) || availableOptions[0]!;
};
const handleSelectTargetLang = (option: string) => {
setTranslateTargetLang(option);
saveViewSettings(envConfig, bookKey, 'translateTargetLang', option, false, false);
viewSettings.translateTargetLang = option;
setViewSettings(bookKey, { ...viewSettings });
};
useEffect(() => {
if (translationEnabled === viewSettings.translationEnabled) return;
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
viewSettings.translationEnabled = translationEnabled;
setViewSettings(bookKey, { ...viewSettings });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translationEnabled]);
return (
<div className={clsx('my-4 w-full space-y-6')}>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Interface Language')}</span>
<DropDown
options={getLangOptions()}
selected={getCurrentUILangOption()}
onSelect={handleSelectUILang}
className='dropdown-bottom'
listClassName='!max-h-60'
/>
</div>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Translation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span className=''>{_('Enable Translation')}</span>
<input
type='checkbox'
className='toggle'
checked={translationEnabled}
onChange={() => setTranslationEnabled(!translationEnabled)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Translation Service')}</span>
<DropDown
selected={getCurrentTranslationProviderOption()}
options={getTranslationProviderOptions()}
onSelect={handleSelectTranslationProvider}
disabled={!translationEnabled}
className='dropdown-top'
/>
</div>
<div className='config-item'>
<span className=''>{_('Translate To')}</span>
<DropDown
options={getLangOptions()}
selected={getCurrentTargetLangOption()}
onSelect={handleSelectTargetLang}
disabled={!translationEnabled}
className='dropdown-top'
listClassName='!max-h-60'
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default LangPanel;
@@ -33,12 +33,13 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [hyphenation, setHyphenation] = useState(viewSettings.hyphenation!);
const [marginPx, setMarginPx] = useState(viewSettings.marginPx!);
const [gapPercent, setGapPercent] = useState(viewSettings.gapPercent!);
const [compactMarginPx, setCompactMarginPx] = useState(viewSettings.compactMarginPx!);
const [compactGapPercent, setCompactGapPercent] = useState(viewSettings.compactGapPercent!);
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
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!);
const [showHeader, setShowHeader] = useState(viewSettings.showHeader!);
@@ -87,6 +88,13 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marginPx]);
useEffect(() => {
if (compactMarginPx === viewSettings.compactMarginPx) return;
saveViewSettings(envConfig, bookKey, 'compactMarginPx', compactMarginPx, false, false);
view?.renderer.setAttribute('margin', `${compactMarginPx}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactMarginPx]);
useEffect(() => {
if (gapPercent === viewSettings.gapPercent) return;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
@@ -97,6 +105,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gapPercent]);
useEffect(() => {
if (compactGapPercent === viewSettings.compactGapPercent) return;
saveViewSettings(envConfig, bookKey, 'compactGapPercent', compactGapPercent, false, false);
view?.renderer.setAttribute('gap', `${compactGapPercent}%`);
if (viewSettings.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compactGapPercent]);
useEffect(() => {
if (maxColumnCount === viewSettings.maxColumnCount) return;
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount, false, false);
@@ -144,22 +162,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [writingMode]);
useEffect(() => {
if (overrideLayout === viewSettings.overrideLayout) return;
saveViewSettings(envConfig, bookKey, 'overrideLayout', overrideLayout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideLayout]);
useEffect(() => {
if (isScrolledMode === viewSettings.scrolled) return;
saveViewSettings(envConfig, bookKey, 'scrolled', isScrolledMode);
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
`${getMaxInlineSize(viewSettings)}px`,
);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
if (doubleBorder === viewSettings.doubleBorder) return;
if (doubleBorder && viewSettings.vertical) {
@@ -179,6 +186,22 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [borderColor]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showBarsOnScroll', showBarsOnScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showBarsOnScroll]);
const applyMarginAndGap = () => {
const isCompact = !showHeader && !showFooter;
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent;
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
};
useEffect(() => {
if (showHeader === viewSettings.showHeader) return;
if (showHeader && !viewSettings.vertical) {
@@ -194,14 +217,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader, false, false);
applyMarginAndGap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showHeader]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showBarsOnScroll', showBarsOnScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showBarsOnScroll]);
useEffect(() => {
if (showFooter === viewSettings.showFooter) return;
if (showFooter && !viewSettings.vertical) {
@@ -217,6 +237,8 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter, false, false);
applyMarginAndGap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showFooter]);
@@ -225,58 +247,53 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return (
<div className='my-4 w-full space-y-6'>
<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 className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Layout')}</h2>
<input
type='checkbox'
className='toggle'
checked={overrideLayout}
onChange={() => setOverrideLayout(!overrideLayout)}
/>
</div>
{mightBeRTLBook && (
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('auto')}
>
<MdOutlineAutoMode />
</button>
</div>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('auto')}
>
<MdOutlineAutoMode />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-tb')}
>
<MdOutlineTextRotationNone />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-tb')}
>
<MdOutlineTextRotationNone />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('vertical-rl')}
>
<MdTextRotateVertical />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('vertical-rl')}
>
<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 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>
</div>
@@ -326,7 +343,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={setParagraphMargin}
min={0}
max={4}
step={0.5}
step={0.2}
/>
<NumberInput
label={_('Line Spacing')}
@@ -380,15 +397,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setHyphenation(!hyphenation)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Override Book Layout')}</span>
<input
type='checkbox'
className='toggle'
checked={overrideLayout}
onChange={() => setOverrideLayout(!overrideLayout)}
/>
</div>
</div>
</div>
</div>
@@ -399,16 +407,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='divide-base-200 divide-y'>
<NumberInput
label={_('Vertical Margins (px)')}
value={marginPx}
onChange={setMarginPx}
value={showFooter || showHeader ? marginPx : compactMarginPx}
onChange={showFooter || showHeader ? setMarginPx : setCompactMarginPx}
min={!viewSettings.vertical && (showFooter || showHeader) ? 44 : 0}
max={88}
step={4}
/>
<NumberInput
label={_('Horizontal Margins (%)')}
value={gapPercent}
onChange={setGapPercent}
value={showFooter || showHeader ? gapPercent : compactGapPercent}
onChange={showFooter || showHeader ? setGapPercent : setCompactGapPercent}
min={
viewSettings.vertical && (showFooter || showHeader)
? Math.ceil(4800 / window.innerWidth)
@@ -1,35 +1,29 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import i18n from 'i18next';
import { IoPhoneLandscapeOutline, IoPhonePortraitOutline } from 'react-icons/io5';
import { MdOutlineScreenRotation } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useDeviceControlStore } from '@/store/deviceStore';
import { getStyles } from '@/utils/style';
import { lockScreenOrientation } from '@/utils/bridge';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { TRANSLATED_LANGS } from '@/services/constants';
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
import DropDown from './DropDown';
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [error, setError] = useState<string | null>(null);
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
const [error, setError] = useState<string | null>(null);
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -99,66 +93,13 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
};
const getCurrentUILangOption = () => {
const uiLanguage = viewSettings.uiLanguage;
return {
option: uiLanguage,
label:
uiLanguage === ''
? _('Auto')
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
};
};
const getUILangOptions = () => {
const langs = TRANSLATED_LANGS as Record<string, string>;
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
options.sort((a, b) => a.label.localeCompare(b.label));
options.unshift({ option: '', label: _('Auto') });
return options;
};
const handleSelectUILang = (option: string) => {
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
i18n.changeLanguage(option ? option : navigator.language);
};
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
if (animated) {
getView(bookKey)?.renderer.setAttribute('animated', '');
} else {
getView(bookKey)?.renderer.removeAttribute('animated');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animated]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableClick]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapClickArea]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
saveViewSettings(envConfig, bookKey, 'screenOrientation', screenOrientation, false, false);
if (appService?.isMobileApp) {
if (volumeKeysToFlip) {
acquireVolumeKeyInterception();
} else {
releaseVolumeKeyInterception();
}
lockScreenOrientation({ orientation: screenOrientation });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeKeysToFlip]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isContinuousScroll]);
}, [screenOrientation]);
return (
<div
@@ -167,85 +108,46 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
inputFocusInAndroid && 'h-[50%] overflow-y-auto pb-[200px]',
)}
>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Language')}</span>
<DropDown
selected={getCurrentUILangOption()}
options={getUILangOptions()}
onSelect={handleSelectUILang}
/>
</div>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
checked={animated}
onChange={() => setAnimated(!animated)}
/>
</div>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Continuous Scroll')}</span>
<input
type='checkbox'
className='toggle'
checked={isContinuousScroll}
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
{appService?.isMobileApp && (
{appService?.isMobileApp && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Screen')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Volume Keys for Page Flip')}</span>
<input
type='checkbox'
className='toggle'
checked={volumeKeysToFlip}
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
/>
<span className=''>{_('Orientation')}</span>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('auto')}
>
<MdOutlineScreenRotation />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Portrait')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'portrait' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('portrait')}
>
<IoPhonePortraitOutline />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Landscape')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'landscape' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('landscape')}
>
<IoPhoneLandscapeOutline />
</button>
</div>
</div>
</div>
)}
<div className='config-item'>
<span className=''>{_('Clicks for Page Flip')}</span>
<input
type='checkbox'
className='toggle'
checked={!isDisableClick}
onChange={() => setIsDisableClick(!isDisableClick)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Swap Clicks Area')}</span>
<input
type='checkbox'
className='toggle'
checked={swapClickArea}
disabled={isDisableClick}
onChange={() => setSwapClickArea(!swapClickArea)}
/>
</div>
</div>
</div>
</div>
)}
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Custom CSS')}</h2>
@@ -1,12 +1,14 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { BookConfig } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { RiFontSize } from 'react-icons/ri';
import { RiDashboardLine } from 'react-icons/ri';
import { RiDashboardLine, RiTranslate } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
import { getDirFromUILanguage } from '@/utils/rtl';
@@ -16,9 +18,11 @@ import ColorPanel from './ColorPanel';
import Dropdown from '@/components/Dropdown';
import Dialog from '@/components/Dialog';
import DialogMenu from './DialogMenu';
import ControlPanel from './ControlPanel';
import LangPanel from './LangPanel';
import MiscPanel from './MiscPanel';
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Misc';
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Control' | 'Language' | 'Custom';
type TabConfig = {
tab: SettingsPanelType;
@@ -28,10 +32,10 @@ type TabConfig = {
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const [activePanel, setActivePanel] = useState<SettingsPanelType>(
(localStorage.getItem('lastConfigPanel') || 'Font') as SettingsPanelType,
);
const tabsRef = useRef<HTMLDivElement | null>(null);
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const tabConfig = [
@@ -51,12 +55,30 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
label: _('Color'),
},
{
tab: 'Misc',
tab: 'Control',
icon: LiaHandPointerSolid,
label: _('Behavior'),
},
{
tab: 'Language',
icon: RiTranslate,
label: _('Language'),
},
{
tab: 'Custom',
icon: IoAccessibilityOutline,
label: _('Misc'),
label: _('Custom'),
},
] as TabConfig[];
const [activePanel, setActivePanel] = useState<SettingsPanelType>(() => {
const lastPanel = localStorage.getItem('lastConfigPanel');
if (lastPanel && tabConfig.some((tab) => tab.tab === lastPanel)) {
return lastPanel as SettingsPanelType;
}
return 'Font' as SettingsPanelType;
});
const handleSetActivePanel = (tab: SettingsPanelType) => {
setActivePanel(tab);
localStorage.setItem('lastConfigPanel', tab);
@@ -66,16 +88,61 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
setFontLayoutSettingsDialogOpen(false);
};
useEffect(() => {
const container = tabsRef.current;
if (!container) return;
const checkButtonWidths = () => {
const threshold = (container.clientWidth - 64) * 0.22;
const hideLabel = Array.from(container.querySelectorAll('button')).some((button) => {
const labelSpan = button.querySelector('span');
const labelText = labelSpan?.textContent || '';
const clone = button.cloneNode(true) as HTMLButtonElement;
clone.style.position = 'absolute';
clone.style.visibility = 'hidden';
clone.style.width = 'auto';
const cloneSpan = clone.querySelector('span');
if (cloneSpan) {
cloneSpan.classList.remove('hidden');
cloneSpan.textContent = labelText;
}
document.body.appendChild(clone);
const fullWidth = clone.scrollWidth;
document.body.removeChild(clone);
return fullWidth > threshold;
});
setShowAllTabLabels(!hideLabel);
};
checkButtonWidths();
const resizeObserver = new ResizeObserver(checkButtonWidths);
resizeObserver.observe(container);
const mutationObserver = new MutationObserver(checkButtonWidths);
mutationObserver.observe(container, {
subtree: true,
characterData: true,
});
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, []);
return (
<>
<Dialog
isOpen={true}
onClose={handleClose}
className='modal-open'
boxClassName='sm:min-w-[520px]'
snapHeight={window.innerWidth < 640 ? 0.7 : undefined}
header={
<div className='flex w-full items-center justify-between'>
<Dialog
isOpen={true}
onClose={handleClose}
className='modal-open'
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
snapHeight={appService?.isMobile ? 0.7 : undefined}
header={
<div className='flex w-full flex-col items-center'>
<div className='tab-title flex pb-2 text-base font-semibold sm:hidden'>
{tabConfig.find((tab) => tab.tab === activePanel)?.label || ''}
</div>
<div className='flex w-full flex-row items-center justify-between'>
<button
tabIndex={-1}
onClick={handleClose}
@@ -85,32 +152,45 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
>
{isRtl ? <MdArrowForwardIos /> : <MdArrowBackIosNew />}
</button>
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center gap-2 pl-4'>
<div
ref={tabsRef}
className={clsx('dialog-tabs ms-1 flex h-10 w-full items-center gap-1 sm:ms-0')}
>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
data-tab={tab}
className={clsx(
'btn btn-ghost text-base-content btn-sm',
'btn btn-ghost text-base-content btn-sm gap-1 px-2',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => handleSetActivePanel(tab)}
>
<Icon className='mr-0' />
{window.innerWidth >= 500 ? label : ''}
<span
className={clsx(
window.innerWidth < 640 && 'hidden',
!(showAllTabLabels || activePanel === tab) && 'hidden',
)}
>
{label}
</span>
</button>
))}
</div>
<div className='flex h-full items-center justify-end gap-x-2'>
<Dropdown
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 flex items-center justify-center'
toggleButton={<PiDotsThreeVerticalBold />}
>
<DialogMenu />
</Dropdown>
<button
onClick={handleClose}
className={'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 sm:flex'}
className={
'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 p-0 sm:flex'
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
@@ -126,14 +206,16 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
</button>
</div>
</div>
}
>
{activePanel === 'Font' && <FontPanel bookKey={bookKey} />}
{activePanel === 'Layout' && <LayoutPanel bookKey={bookKey} />}
{activePanel === 'Color' && <ColorPanel bookKey={bookKey} />}
{activePanel === 'Misc' && <MiscPanel bookKey={bookKey} />}
</Dialog>
</>
</div>
}
>
{activePanel === 'Font' && <FontPanel bookKey={bookKey} />}
{activePanel === 'Layout' && <LayoutPanel bookKey={bookKey} />}
{activePanel === 'Color' && <ColorPanel bookKey={bookKey} />}
{activePanel === 'Control' && <ControlPanel bookKey={bookKey} />}
{activePanel === 'Language' && <LangPanel bookKey={bookKey} />}
{activePanel === 'Custom' && <MiscPanel bookKey={bookKey} />}
</Dialog>
);
};
@@ -59,14 +59,15 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
{_(
"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
{'\n\n'}
<span
className='mt-4 cursor-pointer italic'
style={{
color: primaryColor,
}}
>
{_("(from 'As You Like It', Act II)")}
</p>
</span>
</p>
</div>
</div>
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { isCJKStr } from '@/utils/lang';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
@@ -31,6 +32,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { getConfig, saveConfig } = useBookDataStore();
const { getView, getProgress } = useReaderStore();
const [searchTerm, setSearchTerm] = useState(term);
@@ -38,7 +40,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
const view = getView(bookKey)!;
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
const progress = getProgress(bookKey)!;
const primaryLang = bookData.book?.primaryLanguage || 'en';
const searchConfig = config.searchConfig! as BookSearchConfig;
const queuedSearchTerm = useRef('');
@@ -104,8 +108,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
};
const exceedMinSearchTermLength = (searchTerm: string) => {
const isCJK = /[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/.test(searchTerm);
const minLength = isCJK ? MINIMUM_SEARCH_TERM_LENGTH_CJK : MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
const minLength = isCJKStr(searchTerm)
? MINIMUM_SEARCH_TERM_LENGTH_CJK
: MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
return searchTerm.length >= minLength;
};
@@ -118,12 +123,30 @@ const SearchBar: React.FC<SearchBarProps> = ({
}
};
const createAcceptNode = ({ withRT = true } = {}) => {
return (node: Node): number => {
if (node.nodeType === Node.ELEMENT_NODE) {
const name = (node as Element).tagName.toLowerCase();
if (name === 'script' || name === 'style' || (!withRT && name === 'rt')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_SKIP;
}
return NodeFilter.FILTER_ACCEPT;
};
};
const handleSearch = async (term: string) => {
console.log('searching for:', term);
isSearchPending.current = true;
const { section } = progress;
const index = searchConfig.scope === 'section' ? section.current : undefined;
const generator = await view.search({ ...searchConfig, query: term, index });
const generator = await view.search({
...searchConfig,
index,
query: term,
acceptNode: createAcceptNode({ withRT: !primaryLang.startsWith('ja') }),
});
const results: BookSearchResult[] = [];
for await (const result of generator) {
if (typeof result === 'string') {
@@ -32,7 +32,7 @@ const SearchResultItem: React.FC<SearchResultItemProps> = ({
>
<div className='line-clamp-3'>
<span className=''>{excerpt.pre}</span>
<span className='font-semibold'>{excerpt.match}</span>
<span className='font-bold text-red-500'>{excerpt.match}</span>
<span className=''>{excerpt.post}</span>
</div>
</li>
@@ -8,6 +8,7 @@ import { findParentPath } from '@/utils/toc';
import { getContentMd5 } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { BookProgress } from '@/types/book';
import { useTextTranslation } from '../../hooks/useTextTranslation';
const createExpanderIcon = (isExpanded: boolean) => {
return (
@@ -58,7 +59,7 @@ const TOCItemView: React.FC<{
}, [expandedItems, item.href]);
return (
<li className='w-full' style={{ paddingTop: '1px' }}>
<li className='border-base-300 w-full border-b sm:border-none sm:pt-[1px]'>
<span
role='treeitem'
tabIndex={-1}
@@ -67,17 +68,26 @@ const TOCItemView: React.FC<{
aria-expanded={isExpanded ? 'true' : 'false'}
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href ? getContentMd5(item.href) : undefined}
className={`flex w-full cursor-pointer items-center rounded-md py-2 ${
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'sm:hover:bg-base-300/85'
className={`flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2 ${
isActive
? 'sm:bg-base-300/85 sm:hover:bg-base-300 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/85'
}`}
>
{item.subitems && (
<span onClick={handleToggleExpand} className='inline-block cursor-pointer'>
<span
onClick={handleToggleExpand}
className='inline-block cursor-pointer'
style={{
padding: '12px',
margin: '-12px',
}}
>
{createExpanderIcon(isExpanded)}
</span>
)}
<span
className='ml-2 truncate text-ellipsis'
className='ms-2 truncate text-ellipsis'
style={{
maxWidth: 'calc(100% - 24px)',
whiteSpace: 'nowrap',
@@ -86,6 +96,11 @@ const TOCItemView: React.FC<{
>
{item.label}
</span>
{item.location && (
<span className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
{item.location.current + 1}
</span>
)}
</span>
{item.subitems && isExpanded && (
<ol role='group'>
@@ -109,12 +124,14 @@ const TOCView: React.FC<{
toc: TOCItem[];
}> = ({ bookKey, toc }) => {
const { getProgress } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { sideBarBookKey, isSideBarVisible } = useSidebarStore();
const progress = getProgress(bookKey);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const viewRef = useRef<HTMLUListElement | null>(null);
useTextTranslation(bookKey, viewRef.current);
const expandParents = (toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
setExpandedItems(parentPath.filter(Boolean) as string[]);
@@ -132,9 +149,6 @@ const TOCView: React.FC<{
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
if (currentHref) {
expandParents(toc, currentHref);
}
};
useEffect(() => {
@@ -151,17 +165,23 @@ const TOCView: React.FC<{
}
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewRef]);
}, [viewRef.current]);
useEffect(() => {
if (!progress || eventDispatcher.dispatchSync('tts-is-speaking')) return;
if (sideBarBookKey !== bookKey) return;
if (!isSideBarVisible) return;
const { sectionHref: currentHref } = progress;
if (currentHref) {
expandParents(toc, currentHref);
}
scrollToProgress(progress);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toc, progress, sideBarBookKey]);
}, [toc, progress, sideBarBookKey, isSideBarVisible]);
return (
<div className='rounded pt-2'>
<ul role='tree' ref={viewRef} className='px-2'>
<ul role='tree' ref={viewRef} className='pe-4 ps-2 sm:pe-2'>
{toc &&
toc.map((item, index) => (
<TOCItemView
@@ -5,13 +5,14 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TTSController, SILENCE_DATA } from '@/services/tts';
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { CFI } from '@/libs/document';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -24,7 +25,8 @@ const TTSControl = () => {
const _ = useTranslation();
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
@@ -38,14 +40,17 @@ const TTSControl = () => {
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
const viewSettings = getViewSettings(bookKey);
const popupPadding = useResponsiveSize(POPUP_PADDING);
const maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
// this enables WebAudio to play even when the mute toggle switch is ON
const unblockAudio = () => {
@@ -94,12 +99,58 @@ const TTSControl = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!ttsController || !bookKey) return;
const bookData = getBookData(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const handleSpeakMark = (e: Event) => {
const progress = getProgress(bookKey);
const { sectionLabel } = progress || {};
const mark = (e as CustomEvent<TTSMark>).detail;
if (appService?.isMobileApp && 'mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: mark.text,
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
}
};
const handleHighlightMark = (e: Event) => {
const range = (e as CustomEvent<Range>).detail;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
if (!range || !view || !progress || !viewSettings) return;
const { location } = progress;
const { index } = view.resolveCFI(location);
const cfi = view?.getCFI(index, range);
if (cfi) {
viewSettings.ttsLocation = cfi || '';
setViewSettings(bookKey, viewSettings);
}
};
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
return () => {
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsController, bookKey]);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey, range } = event.detail;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !viewSettings || !bookData || !bookData.book) return;
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
@@ -108,6 +159,21 @@ const TTSControl = () => {
return;
}
let ttsFromRange = range || progress.range;
if (viewSettings.ttsLocation) {
const { location } = progress;
const ttsCfi = viewSettings.ttsLocation;
const start = CFI.collapse(location);
const end = CFI.collapse(location, true);
if (CFI.compare(start, ttsCfi) * CFI.compare(end, ttsCfi) <= 0) {
const { index, anchor } = view.resolveCFI(ttsCfi);
const { doc } = view.renderer.getContents().find((x) => (x.index = index)) || {};
if (doc) {
ttsFromRange = anchor(doc) || ttsFromRange;
}
}
}
const primaryLang = bookData.book.primaryLanguage;
setBookKey(bookKey);
@@ -115,6 +181,8 @@ const TTSControl = () => {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
setTTSEnabled(bookKey, true);
setShowIndicator(true);
try {
@@ -124,10 +192,11 @@ const TTSControl = () => {
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
unblockAudio();
}
setTtsClientsInitialized(false);
const ttsController = new TTSController(view);
await ttsController.init();
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
const ssml = view.tts?.from(ttsFromRange);
if (ssml) {
let lang = parseSSMLLang(ssml) || 'en';
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
@@ -139,10 +208,11 @@ const TTSControl = () => {
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
ttsController.setVoice(viewSettings.ttsVoice);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
}
setTtsClientsInitialized(true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
@@ -152,8 +222,11 @@ const TTSControl = () => {
}
};
const handleTTSStop = async () => {
handleStop();
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey } = event.detail;
if (ttsControllerRef.current) {
handleStop(bookKey);
}
};
const handleQueryIsSpeaking = () => {
@@ -195,11 +268,12 @@ const TTSControl = () => {
}
};
const handleStop = async () => {
const handleStop = async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.stop();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
@@ -211,6 +285,7 @@ const TTSControl = () => {
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
};
// rate range: 0.5 - 3, 1.0 is normal speed
@@ -233,15 +308,15 @@ const TTSControl = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetVoice = useCallback(
throttle(async (voice: string) => {
throttle(async (voice: string, lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setVoice(voice);
await ttsController.setVoice(voice, lang);
await ttsController.start();
} else {
await ttsController.setVoice(voice);
await ttsController.setVoice(voice, lang);
}
}
}, 3000),
@@ -264,7 +339,7 @@ const TTSControl = () => {
return '';
};
const handleSelectTimeout = (value: number) => {
const handleSelectTimeout = (bookKey: string, value: number) => {
setTimeoutOption(value);
if (timeoutFunc) {
clearTimeout(timeoutFunc);
@@ -272,7 +347,7 @@ const TTSControl = () => {
if (value > 0) {
setTimeoutFunc(
setTimeout(() => {
handleStop();
handleStop(bookKey);
}, value * 1000),
);
setTimeoutTimestamp(Date.now() + value * 1000);
@@ -284,16 +359,18 @@ const TTSControl = () => {
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const windowRect = document.documentElement.getBoundingClientRect();
const parentRect =
iconRef.current.parentElement?.getBoundingClientRect() ||
document.documentElement.getBoundingClientRect();
const trianglePos = {
dir: 'up',
point: { x: rect.left + rect.width / 2, y: rect.top - 12 },
point: { x: rect.left + rect.width / 2 - parentRect.left, y: rect.top - 12 },
} as Position;
const popupPos = getPopupPosition(
trianglePos,
windowRect,
parentRect,
popupWidth,
popupHeight,
popupPadding,
@@ -306,6 +383,10 @@ const TTSControl = () => {
const togglePopup = () => {
updatePanelPosition();
if (!showPanel && ttsControllerRef.current) {
const speakingLang = ttsControllerRef.current.getSpeakingLang() || ttsLang;
setTtsLang(speakingLang);
}
setShowPanel((prev) => !prev);
};
@@ -313,8 +394,23 @@ const TTSControl = () => {
setShowPanel(false);
};
useEffect(() => {
if (!iconRef.current || !showPanel) return;
const parentElement = iconRef.current.parentElement;
if (!parentElement) return;
const resizeObserver = new ResizeObserver(() => {
updatePanelPosition();
});
resizeObserver.observe(parentElement);
return () => {
resizeObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPanel]);
return (
<div>
<>
{showPanel && (
<div
className='fixed inset-0'
@@ -326,22 +422,23 @@ const TTSControl = () => {
<div
ref={iconRef}
className={clsx(
'fixed right-6 h-12 w-12',
'absolute h-12 w-12',
viewSettings?.rtl ? 'left-6' : 'right-6',
appService?.hasSafeAreaInset
? 'bottom-[calc(env(safe-area-inset-bottom)+70px)]'
: 'bottom-[70px] sm:bottom-14',
)}
>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
</div>
)}
{showPanel && panelPosition && trianglePosition && (
{showPanel && panelPosition && trianglePosition && ttsClientsInited && (
<Popup
width={popupWidth}
height={popupHeight}
position={panelPosition}
trianglePosition={trianglePosition}
className='bg-base-200 absolute flex shadow-lg'
className='bg-base-200 flex shadow-lg'
>
<TTSPanel
bookKey={bookKey}
@@ -360,7 +457,7 @@ const TTSControl = () => {
/>
</Popup>
)}
</div>
</>
);
};
@@ -1,20 +1,28 @@
import clsx from 'clsx';
import React from 'react';
type TTSIconProps = {
isPlaying: boolean;
ttsInited: boolean;
onClick: () => void;
};
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, onClick }) => {
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
const bars = [1, 2, 3, 4];
return (
<div className='relative h-full w-full cursor-pointer' onClick={onClick}>
<div
className={clsx(
'relative h-full w-full',
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
)}
onClick={onClick}
>
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
<div
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
style={{
animation: isPlaying ? 'moveGradient 2s alternate infinite' : 'none',
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
}}
/>
</div>
@@ -21,9 +21,9 @@ type TTSPanelProps = {
onForward: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string) => void;
onSetVoice: (voice: string, lang: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (value: number) => void;
onSelectTimeout: (bookKey: string, value: number) => void;
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
@@ -144,8 +144,8 @@ const TTSPanel = ({
saveSettings(envConfig, settings);
};
const handleSelectVoice = (voice: string) => {
onSetVoice(voice);
const handleSelectVoice = (voice: string, lang: string) => {
onSetVoice(voice, lang);
setSelectedVoice(voice);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsVoice = voice;
@@ -155,7 +155,7 @@ const TTSPanel = ({
const updateTimeout = (timeout: number) => {
const now = Date.now();
if (timeout > 0 && timeout < now) {
onSelectTimeout(0);
onSelectTimeout(bookKey, 0);
setTimeoutCountdown('');
} else if (timeout > 0) {
setTimeoutCountdown(getCountdownTime(timeout));
@@ -232,7 +232,10 @@ const TTSPanel = ({
<MdFastForward size={iconSize32} />
</button>
<div className='dropdown dropdown-top'>
<button className='flex flex-col items-center justify-center rounded-full p-1'>
<button
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1'
>
<MdAlarm size={iconSize32} />
{timeoutCountdown && (
<span
@@ -253,7 +256,10 @@ const TTSPanel = ({
)}
>
{timeoutOptions.map((option, index) => (
<li key={`${index}-${option.value}`} onClick={() => onSelectTimeout(option.value)}>
<li
key={`${index}-${option.value}`}
onClick={() => onSelectTimeout(bookKey, option.value)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
@@ -279,7 +285,7 @@ const TTSPanel = ({
{voices.map((voice, index) => (
<li
key={`${index}-${voice.id}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id)}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
@@ -9,6 +9,7 @@ import { getStyles } from '@/utils/style';
import { tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { viewPagination } from './usePagination';
interface UseBookShortcutsProps {
sideBarBookKey: string | null;
@@ -41,11 +42,13 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
};
const goLeft = () => {
getView(sideBarBookKey)?.goLeft();
const viewSettings = getViewSettings(sideBarBookKey ?? '');
viewPagination(getView(sideBarBookKey), viewSettings, 'left');
};
const goRight = () => {
getView(sideBarBookKey)?.goRight();
const viewSettings = getViewSettings(sideBarBookKey ?? '');
viewPagination(getView(sideBarBookKey), viewSettings, 'right');
};
const goPrev = () => {
@@ -1,16 +1,48 @@
import { useEffect } from 'react';
import { FoliateView } from '@/types/view';
import { useReaderStore } from '@/store/readerStore';
import { debounce } from '@/utils/debounce';
import { ScrollSource } from './usePagination';
export const useClickEvent = (bookKey: string, handlePageFlip: (msg: MessageEvent) => void) => {
export const useMouseEvent = (
bookKey: string,
handlePageFlip: (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => void,
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
) => {
const { hoveredBookKey } = useReaderStore();
const debounceScroll = debounce(handleContinuousScroll, 500);
const debounceFlip = debounce(handlePageFlip, 100);
const handleMouseEvent = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (msg instanceof MessageEvent) {
if (msg.data && msg.data.bookKey === bookKey) {
if (msg.data.type === 'iframe-wheel') {
debounceScroll('mouse', -msg.data.deltaY, 0);
}
if (msg.data.type === 'iframe-wheel') {
debounceFlip(msg);
} else {
handlePageFlip(msg);
}
}
} else if (msg.type === 'wheel') {
const event = msg as React.WheelEvent<HTMLDivElement>;
debounceScroll('mouse', -event.deltaY, 0);
} else {
handlePageFlip(msg);
}
};
useEffect(() => {
window.addEventListener('message', handlePageFlip);
window.addEventListener('message', handleMouseEvent);
return () => {
window.removeEventListener('message', handlePageFlip);
window.removeEventListener('message', handleMouseEvent);
};
}, [bookKey, hoveredBookKey, handlePageFlip]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey, hoveredBookKey]);
return {
onClick: handlePageFlip,
onWheel: handleMouseEvent,
};
};
interface IframeTouch {
@@ -26,28 +58,28 @@ interface IframeTouchEvent {
export const useTouchEvent = (
bookKey: string,
viewRef: React.MutableRefObject<FoliateView | null>,
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
) => {
const { hoveredBookKey, setHoveredBookKey, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
let touchStart: IframeTouch | null = null;
let touchEnd: IframeTouch | null = null;
const onTouchStart = (e: IframeTouchEvent) => {
const onTouchStart = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
touchEnd = null;
const touch = e.targetTouches[0];
if (!touch) return;
touchStart = touch;
};
const onTouchMove = (e: IframeTouchEvent) => {
const onTouchMove = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
if (!touchStart) return;
const touch = e.targetTouches[0];
if (touch) {
touchEnd = touch;
}
if (hoveredBookKey && touchEnd) {
const viewSettings = getViewSettings(bookKey)!;
const deltaY = touchEnd.screenY - touchStart.screenY;
const deltaX = touchEnd.screenX - touchStart.screenX;
if (!viewSettings!.scrolled && !viewSettings!.vertical) {
@@ -60,7 +92,7 @@ export const useTouchEvent = (
}
};
const onTouchEnd = (e: IframeTouchEvent) => {
const onTouchEnd = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
if (!touchStart) return;
const touch = e.targetTouches[0];
@@ -70,6 +102,7 @@ export const useTouchEvent = (
const windowWidth = window.innerWidth;
if (touchEnd) {
const viewSettings = getViewSettings(bookKey)!;
const deltaY = touchEnd.screenY - touchStart.screenY;
const deltaX = touchEnd.screenX - touchStart.screenX;
// also check for deltaX to prevent swipe page turn from triggering the toggle
@@ -87,6 +120,7 @@ export const useTouchEvent = (
setHoveredBookKey(null);
}
}
handleContinuousScroll('touch', deltaY, 30);
}
touchStart = null;
@@ -111,5 +145,11 @@ export const useTouchEvent = (
window.removeEventListener('message', handleTouch);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef.current]);
}, [hoveredBookKey]);
return {
onTouchStart,
onTouchMove,
onTouchEnd,
};
};
@@ -1,13 +1,38 @@
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { FoliateView } from '@/types/view';
import { ViewSettings } from '@/types/book';
import { useReaderStore } from '@/store/readerStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { tauriGetWindowLogicalPosition } from '@/utils/window';
export const usePageFlip = (
export type ScrollSource = 'touch' | 'mouse';
export const viewPagination = (
view: FoliateView | null,
viewSettings: ViewSettings | null | undefined,
side: 'left' | 'right',
) => {
if (!view || !viewSettings) return;
const renderer = view.renderer;
if (renderer.scrolled) {
if (view.book.dir === 'rtl') {
side = side === 'left' ? 'right' : 'left';
}
const { size } = renderer;
const showHeader = viewSettings.showHeader && viewSettings.showBarsOnScroll;
const showFooter = viewSettings.showFooter && viewSettings.showBarsOnScroll;
const scrollingOverlap = viewSettings.scrollingOverlap;
const distance = size - scrollingOverlap - (showHeader ? 44 : 0) - (showFooter ? 44 : 0);
return side === 'left' ? view.prev(distance) : view.next(distance);
} else {
return side === 'left' ? view.goLeft() : view.goRight();
}
};
export const usePagination = (
bookKey: string,
viewRef: React.MutableRefObject<FoliateView | null>,
containerRef: React.RefObject<HTMLDivElement>,
@@ -16,6 +41,7 @@ export const usePageFlip = (
const { getViewSettings } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const handlePageFlip = async (
msg: MessageEvent | CustomEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
@@ -61,15 +87,15 @@ export const usePageFlip = (
}
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
if (viewSettings.swapClickArea) {
viewRef.current?.goLeft();
viewPagination(viewRef.current, viewSettings, 'left');
} else {
viewRef.current?.goRight();
viewPagination(viewRef.current, viewSettings, 'right');
}
} else if (!viewSettings.disableClick! && screenX < viewCenterX) {
if (viewSettings.swapClickArea) {
viewRef.current?.goRight();
viewPagination(viewRef.current, viewSettings, 'right');
} else {
viewRef.current?.goLeft();
viewPagination(viewRef.current, viewSettings, 'left');
}
}
}
@@ -97,20 +123,58 @@ export const usePageFlip = (
if (viewSettings?.volumeKeysToFlip) {
setHoveredBookKey('');
if (keyName === 'VolumeUp') {
viewRef.current?.goLeft();
viewPagination(viewRef.current, viewSettings, 'left');
} else if (keyName === 'VolumeDown') {
viewRef.current?.goRight();
viewPagination(viewRef.current, viewSettings, 'right');
}
}
} else {
const { clientX } = msg;
const width = window.innerWidth;
const leftThreshold = width * 0.5;
const rightThreshold = width * 0.5;
if (clientX < leftThreshold) {
viewRef.current?.goLeft();
} else if (clientX > rightThreshold) {
viewRef.current?.goRight();
if (msg.type === 'click') {
const { clientX } = msg;
const width = window.innerWidth;
const leftThreshold = width * 0.5;
const rightThreshold = width * 0.5;
const viewSettings = getViewSettings(bookKey);
if (clientX < leftThreshold) {
viewPagination(viewRef.current, viewSettings, 'left');
} else if (clientX > rightThreshold) {
viewPagination(viewRef.current, viewSettings, 'right');
}
}
}
};
const handleContinuousScroll = (mode: ScrollSource, scrollDelta: number, threshold: number) => {
const renderer = viewRef.current?.renderer;
const viewSettings = getViewSettings(bookKey)!;
if (renderer && viewSettings.scrolled && viewSettings.continuousScroll) {
const doScroll = () => {
// may have overscroll where the start is greater than 0
if (renderer.start <= scrollDelta && scrollDelta > threshold) {
setTimeout(() => {
viewRef.current?.prev(renderer.start + 1);
}, 100);
// sometimes viewSize has subpixel value that the end never reaches
} else if (
Math.ceil(renderer.end) - scrollDelta >= renderer.viewSize &&
scrollDelta < -threshold
) {
setTimeout(() => {
viewRef.current?.next(renderer.viewSize - Math.floor(renderer.end) + 1);
}, 100);
}
};
if (mode === 'mouse') {
// we can always get mouse wheel events
doScroll();
} else if (mode === 'touch') {
// when the document height is less than the viewport height, we can't get the relocate event
if (renderer.size >= renderer.viewSize) {
doScroll();
} else {
// scroll after the relocate event
renderer.addEventListener('relocate', () => doScroll(), { once: true });
}
}
}
};
@@ -135,5 +199,6 @@ export const usePageFlip = (
return {
handlePageFlip,
handleContinuousScroll,
};
};
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { getOSPlatform } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { getTextFromRange, TextSelection } from '@/utils/sel';
import { transformContent } from '@/services/transformService';
export const useTextSelector = (
bookKey: string,
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>,
handleDismissPopup: () => void,
) => {
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const primaryLang = bookData.book?.primaryLanguage || 'en';
const osPlatform = getOSPlatform();
const isPopuped = useRef(false);
const isUpToPopup = useRef(false);
const isTextSelected = useRef(false);
const isTouchStarted = useRef(false);
const selectionPosition = useRef<number | null>(null);
const [textSelected, setTextSelected] = useState(false);
const isValidSelection = (sel: Selection) => {
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
};
const transformCtx = {
bookKey,
viewSettings: getViewSettings(bookKey)!,
content: '',
transformers: ['punctuation'],
reversePunctuationTransform: true,
};
const getAnnotationText = async (range: Range) => {
transformCtx['content'] = getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []);
return await transformContent(transformCtx);
};
const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => {
isTextSelected.current = true;
setTextSelected(true);
const range = sel.getRangeAt(0);
if (rebuildRange) {
sel.removeAllRanges();
sel.addRange(range);
}
setSelection({ key: bookKey, text: await getAnnotationText(range), range, index });
};
// FIXME: extremely hacky way to dismiss system selection tools on iOS
const makeSelectionOnIOS = async (sel: Selection, index: number) => {
isTextSelected.current = true;
setTextSelected(true);
const range = sel.getRangeAt(0);
setTimeout(() => {
sel.removeAllRanges();
setTimeout(async () => {
if (!isTextSelected.current) return;
sel.addRange(range);
setSelection({ key: bookKey, text: await getAnnotationText(range), range, index });
}, 40);
}, 0);
};
const handleSelectionchange = (doc: Document, index: number) => {
// Available on iOS, Android and Desktop, fired when the selection is changed
// Ideally the popup only shows when the selection is done,
const sel = doc.getSelection() as Selection;
if (osPlatform === 'ios') return;
if (!isValidSelection(sel)) {
if (!isUpToPopup.current) {
handleDismissPopup();
isTextSelected.current = false;
setTextSelected(false);
}
if (isPopuped.current) {
isUpToPopup.current = false;
}
return;
}
// On Android no proper events are fired to notify selection done,
// we make the popup show when the selection is changed
// note that selection may be initiated by a tts speak
if (isTouchStarted.current && osPlatform === 'android') {
makeSelection(sel, index, false);
}
isUpToPopup.current = true;
};
const handlePointerup = (doc: Document, index: number) => {
// Available on iOS and Desktop, fired at touchend or mouseup
// Note that on Android, pointerup event is fired after an additional touch event
const sel = doc.getSelection() as Selection;
if (isValidSelection(sel)) {
if (osPlatform === 'ios') {
makeSelectionOnIOS(sel, index);
} else {
makeSelection(sel, index, true);
}
}
};
const handleTouchStart = () => {
isTouchStarted.current = true;
};
const handleTouchEnd = () => {
isTouchStarted.current = false;
};
const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode
// This is a workaround for the issue #873
// TODO: support text selection across pages
const viewSettings = getViewSettings(bookKey);
if (!viewSettings?.scrolled && view?.renderer?.containerPosition && selectionPosition.current) {
console.warn('Keep container position', selectionPosition.current);
view.renderer.containerPosition = selectionPosition.current;
}
};
const handleShowPopup = (showPopup: boolean) => {
setTimeout(
() => {
if (showPopup && !isPopuped.current) {
isUpToPopup.current = false;
}
isPopuped.current = showPopup;
},
['android', 'ios'].includes(osPlatform) ? 0 : 500,
);
};
const handleUpToPopup = () => {
isUpToPopup.current = true;
};
useEffect(() => {
if (isTextSelected.current && !selectionPosition.current) {
selectionPosition.current = view?.renderer?.start || null;
} else if (!isTextSelected.current) {
selectionPosition.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textSelected]);
useEffect(() => {
const handleSingleClick = (): boolean => {
if (isUpToPopup.current) {
isUpToPopup.current = false;
return true;
}
if (isTextSelected.current) {
handleDismissPopup();
isTextSelected.current = false;
setTextSelected(false);
view?.deselect();
return true;
}
if (isPopuped.current) {
handleDismissPopup();
return true;
}
return false;
};
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
return () => {
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
handleScroll,
handleTouchStart,
handleTouchEnd,
handlePointerup,
handleSelectionchange,
handleShowPopup,
handleUpToPopup,
};
};
@@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from 'react';
import { FoliateView } from '@/types/view';
import { UseTranslatorOptions } from '@/services/translators';
import { useReaderStore } from '@/store/readerStore';
import { useTranslator } from '@/hooks/useTranslator';
import { walkTextNodes } from '@/utils/walk';
import { localeToLang } from '@/utils/lang';
import { getLocale } from '@/utils/misc';
export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElement | null) {
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const enabled = useRef(viewSettings?.translationEnabled);
const [provider, setProvider] = useState(viewSettings?.translationProvider);
const [targetLang, setTargetLang] = useState(viewSettings?.translateTargetLang);
const { translate } = useTranslator({
provider,
targetLang: localeToLang(targetLang || getLocale()),
} as UseTranslatorOptions);
const translateRef = useRef(translate);
const observerRef = useRef<IntersectionObserver | null>(null);
const translatedElements = useRef<HTMLElement[]>([]);
const allTextNodes = useRef<HTMLElement[]>([]);
const toggleTranslationVisibility = (visible: boolean) => {
translatedElements.current.forEach((element) => {
const translationTargets = element.querySelectorAll('.translation-target');
translationTargets.forEach((target) => {
if (visible) {
target.classList.remove('hidden');
} else {
target.classList.add('hidden');
}
});
});
};
useEffect(() => {
translateRef.current = translate;
}, [translate]);
const observeTextNodes = () => {
if (!view || !enabled.current) return;
const observer = createTranslationObserver();
observerRef.current = observer;
const nodes = walkTextNodes(view);
console.log('Observing text nodes for translation:', nodes.length);
allTextNodes.current = nodes;
nodes.forEach((el) => observer.observe(el));
};
const updateTranslation = () => {
translatedElements.current.forEach((element) => {
const translationTargets = element.querySelectorAll('.translation-target');
translationTargets.forEach((target) => target.remove());
});
translatedElements.current = [];
if (viewSettings?.translationEnabled && view) {
recreateTranslationObserver();
}
};
const createTranslationObserver = () => {
return new IntersectionObserver(
(entries) => {
let beforeIntersectedElement: HTMLElement | null = null;
let lastIntersectedElement: HTMLElement | null = null;
for (const entry of entries) {
if (!entry.isIntersecting) {
if (!lastIntersectedElement) {
beforeIntersectedElement = entry.target as HTMLElement;
}
continue;
}
const currentElement = entry.target as HTMLElement;
translateElement(currentElement);
lastIntersectedElement = currentElement;
}
if (beforeIntersectedElement) {
translateElement(beforeIntersectedElement);
}
if (lastIntersectedElement) {
preTranslateNextElements(lastIntersectedElement, 2);
}
},
{
rootMargin: '1280px',
threshold: 0,
},
);
};
const preTranslateNextElements = (currentElement: HTMLElement, count: number) => {
if (!allTextNodes.current || count <= 0) return;
const currentIndex = allTextNodes.current.indexOf(currentElement);
if (currentIndex === -1) {
return;
}
const nextElements = allTextNodes.current.slice(currentIndex + 1, currentIndex + 1 + count);
nextElements.forEach((element, index) => {
setTimeout(() => {
translateElement(element);
}, index * 500);
});
};
const recreateTranslationObserver = () => {
const observer = createTranslationObserver();
observerRef.current?.disconnect();
observerRef.current = observer;
allTextNodes.current.forEach((el) => observer.observe(el));
};
const translateElement = async (el: HTMLElement) => {
if (!enabled.current) return;
const text = el.textContent?.trim();
if (!text) return;
if (el.querySelector('.translation-target')) {
return;
}
try {
const translated = await translateRef.current([text]);
const translatedText = translated[0];
if (!translatedText || text === translatedText) return;
const wrapper = document.createElement('font');
wrapper.className = `translation-target ${!enabled.current ? 'hidden' : ''}`;
wrapper.setAttribute('translation-element-mark', '1');
wrapper.setAttribute('lang', targetLang || getLocale());
wrapper.appendChild(document.createElement('br'));
const blockWrapper = document.createElement('font');
blockWrapper.className = 'translation-target translation-block-wrapper';
const inner = document.createElement('font');
inner.className = 'translation-target target-inner target-inner-theme-none';
inner.textContent = translatedText;
blockWrapper.appendChild(inner);
wrapper.appendChild(blockWrapper);
if (el.querySelector('.translation-target')) {
return;
}
el.appendChild(wrapper);
translatedElements.current.push(el);
} catch (err) {
console.warn('Translation failed:', err);
}
};
useEffect(() => {
if (!viewSettings) return;
const enabledChanged = enabled.current !== viewSettings.translationEnabled;
const providerChanged = provider !== viewSettings.translationProvider;
const targetLangChanged = targetLang !== viewSettings.translateTargetLang;
if (enabledChanged) {
enabled.current = viewSettings.translationEnabled;
}
if (providerChanged) {
setProvider(viewSettings.translationProvider);
}
if (targetLangChanged) {
setTargetLang(viewSettings.translateTargetLang);
}
if (enabledChanged) {
toggleTranslationVisibility(viewSettings.translationEnabled);
if (enabled.current) {
observeTextNodes();
}
} else if (providerChanged || targetLangChanged) {
updateTranslation();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey, viewSettings, provider, targetLang]);
useEffect(() => {
if (!view || !enabled.current) return;
if ('renderer' in view) {
view.addEventListener('load', observeTextNodes);
} else {
observeTextNodes();
}
return () => {
if ('renderer' in view) {
view.removeEventListener('load', observeTextNodes);
}
observerRef.current?.disconnect();
translatedElements.current = [];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
}
+1
View File
@@ -8,6 +8,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { checkForAppUpdates } from '@/helpers/updater';
import Reader from './components/Reader';
// This is only used for the Tauri app in the app router
export default function Page() {
const _ = useTranslation();
const { appService } = useEnv();
@@ -122,11 +122,15 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
return;
}
if (element.classList.contains('js_readerFooterNote')) {
if (
element.classList.contains('js_readerFooterNote') ||
element.classList.contains('zhangyue-footnote')
) {
eventDispatcher.dispatch('footnote-popup', {
bookKey,
element,
footnote: element.getAttribute('data-wr-footernote') || '',
footnote:
element.getAttribute('data-wr-footernote') || element.getAttribute('zy-footnote') || '',
});
return;
}
+7 -9
View File
@@ -2,7 +2,6 @@
import clsx from 'clsx';
import React, { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { IoArrowBack } from 'react-icons/io5';
import { PiUserCircle } from 'react-icons/pi';
@@ -20,6 +19,7 @@ import { eventDispatcher } from '@/utils/event';
import { Toast } from '@/components/Toast';
import WindowButtons from '@/components/WindowButtons';
import Quota from '@/components/Quota';
import UserAvatar from '@/components/UserAvatar';
const ProfilePage = () => {
const _ = useTranslation();
@@ -191,14 +191,12 @@ const ProfilePage = () => {
<div className='mb-8 flex flex-col items-center gap-x-6 gap-y-4 md:flex-row md:items-start'>
<div className='flex-shrink-0'>
{avatarUrl ? (
<Image
src={avatarUrl}
alt={_('User avatar')}
className='border-base-100 h-16 w-16 rounded-full border-4 md:h-24 md:w-24'
referrerPolicy='no-referrer'
width={128}
height={128}
priority
<UserAvatar
url={avatarUrl}
size={128}
DefaultIcon={PiUserCircle}
className='h-16 w-16 md:h-24 md:w-24'
borderClassName='border-base-100 border-4'
/>
) : (
<PiUserCircle className='h-16 w-16 md:h-24 md:w-24' />
+34 -15
View File
@@ -18,10 +18,12 @@ export const setAboutDialogVisible = (visible: boolean) => {
}
};
type UpdateStatus = 'checking' | 'updating' | 'updated' | 'error';
export const AboutWindow = () => {
const _ = useTranslation();
const { appService } = useEnv();
const [isUpdated, setIsUpdated] = useState(false);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [browserInfo, setBrowserInfo] = useState('');
const [isOpen, setIsOpen] = useState(false);
@@ -46,46 +48,63 @@ export const AboutWindow = () => {
}, []);
const handleCheckUpdate = async () => {
const update = await checkForAppUpdates(_, false);
if (update) {
setIsOpen(false);
} else {
setIsUpdated(true);
setUpdateStatus('checking');
try {
const update = await checkForAppUpdates(_, false);
if (update) {
setIsOpen(false);
} else {
setUpdateStatus('updated');
}
} catch (error) {
console.info('Error checking for updates:', error);
setUpdateStatus('error');
}
};
const handleClose = () => {
setIsOpen(false);
setUpdateStatus(null);
};
return (
<Dialog
id='about_window'
isOpen={isOpen}
title={_('About Readest')}
onClose={() => setIsOpen(false)}
onClose={handleClose}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center px-8'>
<div className='mb-3'>
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
<div className='flex flex-col items-center gap-2 px-8'>
<div className='mb-2 mt-8'>
<Image src='/icon.png' alt='App Logo' className='h-20 w-20' width={64} height={64} />
</div>
<div className='flex select-text flex-col items-center'>
<h2 className='text-2xl font-bold'>Readest</h2>
<h2 className='mb-2 text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-center text-sm'>
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
</p>
</div>
<div className='h-5'>
{appService?.hasUpdater && !isUpdated && (
<span className='badge badge-primary mt-2 cursor-pointer' onClick={handleCheckUpdate}>
{appService?.hasUpdater && !updateStatus && (
<span className='badge badge-primary cursor-pointer p-1' onClick={handleCheckUpdate}>
{_('Check Update')}
</span>
)}
{isUpdated && (
{updateStatus === 'updated' && (
<p className='text-neutral-content mt-2 text-xs'>{_('Already the latest version')}</p>
)}
{updateStatus === 'checking' && (
<p className='text-neutral-content mt-2 text-xs'>{_('Checking for updates...')}</p>
)}
{updateStatus === 'error' && (
<p className='text-error mt-2 text-xs'>{_('Error checking for updates')}</p>
)}
</div>
</div>
<div className='divider py-12 sm:py-2'></div>
<div className='divider py-16 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<p className='text-neutral-content text-sm'>

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