forked from akai/readest
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66d2fdf999 | |||
| d8e0ceeff1 | |||
| f3ad97b989 | |||
| 1bb49ab023 | |||
| f9a0b39586 | |||
| c533da498d | |||
| b50bc0b854 | |||
| 96c465931c | |||
| 2bd54ac236 | |||
| 6ad549d13c | |||
| 67249370c9 | |||
| fe3ab011ca | |||
| 6cb4278b98 | |||
| 51468862a2 | |||
| b3a44d066f | |||
| 68d4538d40 | |||
| 80105af839 | |||
| 3904c1e8e7 | |||
| 99be6c58e2 | |||
| 664cc772d0 | |||
| 21208adbcf | |||
| 70eb59e2d6 | |||
| 3e7b57282e | |||
| 534582b125 | |||
| 4465e6986e | |||
| a535f6419e | |||
| 65da9c1d47 | |||
| 5f71fd9e47 | |||
| dcf75e07d1 | |||
| 79ba9b3818 | |||
| 128b238bcb | |||
| e26f7e6a2c | |||
| 4c5ff59bcf | |||
| 99b2a34bd2 | |||
| 40f3268ef3 | |||
| 4dac0850c5 | |||
| 118538ba35 | |||
| 4a92cacd84 | |||
| ce53cd2b47 | |||
| b99c1bc19a | |||
| eec2c39f19 | |||
| c6ae85484e | |||
| 7a9f46e93c | |||
| b9f6578127 | |||
| e75a3d254e | |||
| ea15906acf | |||
| 15d2784725 | |||
| ae3bb9da9a | |||
| 239b32fcc2 | |||
| f64739419b | |||
| af8f036ca3 | |||
| 24a87508c6 | |||
| 548d50a882 | |||
| 1e81ab5205 | |||
| a47a480aa2 | |||
| 411f9e236d | |||
| 950bbd0821 | |||
| 2824e50b51 | |||
| 129720c916 | |||
| fd3533dba1 | |||
| 920032155b | |||
| 226bf7033e | |||
| 9444be7fcc | |||
| c9a69a922b | |||
| 9a11b05833 | |||
| 6e603ee38f | |||
| 03fd6e2e6f | |||
| 7dd11c0fb0 | |||
| 968597e52c | |||
| a534050b19 | |||
| 0be828fd66 | |||
| c6d4e2bdd6 | |||
| bf72ab86cd | |||
| fb49ddf484 | |||
| d8817a88b9 | |||
| 475becafe3 | |||
| df165576e6 | |||
| 051f2e5b13 |
@@ -11,8 +11,6 @@ target
|
||||
.gitignore
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
**/dist
|
||||
.next
|
||||
**/.next
|
||||
out
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
docker/.env
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
pnpm -C apps/readest-app lint
|
||||
pnpm -C apps/readest-app test
|
||||
+32
-12
@@ -1,20 +1,40 @@
|
||||
FROM node:22-slim
|
||||
|
||||
ENV PNPM_HOME="/root/.local/share/pnpm"
|
||||
ENV PATH="${PATH}:${PNPM_HOME}"
|
||||
|
||||
RUN npm install --global pnpm
|
||||
|
||||
COPY . /app
|
||||
|
||||
FROM docker.io/node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.29.3 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/readest-app/package.json ./apps/readest-app/
|
||||
COPY patches/ ./patches/
|
||||
COPY packages/ ./packages/
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
FROM base AS dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @readest/readest-app setup-vendors
|
||||
|
||||
FROM dependencies AS development-stage
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["pnpm", "dev-web", "-H", "0.0.0.0"]
|
||||
|
||||
FROM base AS build
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_APP_PLATFORM
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_OBJECT_STORAGE_TYPE
|
||||
ARG NEXT_PUBLIC_STORAGE_FIXED_QUOTA
|
||||
ARG NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA
|
||||
COPY --from=dependencies /app/node_modules /app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/node_modules /app/apps/readest-app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-app/public/vendor
|
||||
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
RUN pnpm build-web
|
||||
|
||||
ENTRYPOINT ["pnpm", "start-web"]
|
||||
FROM build as production-stage
|
||||
ENTRYPOINT ["pnpm", "start-web", "-H", "0.0.0.0"]
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -45,24 +45,24 @@
|
||||
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **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. | ✅ |
|
||||
| **[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. | ✅ |
|
||||
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **[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. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
|
||||
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
|
||||
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
|
||||
@@ -176,7 +176,9 @@ For Android:
|
||||
|
||||
```bash
|
||||
# Initialize the Android environment (run once)
|
||||
rm apps/readest-app/src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
git checkout apps/readest-app/src-tauri/gen/android
|
||||
|
||||
pnpm tauri android dev
|
||||
# or if you want to dev on a real device
|
||||
@@ -202,6 +204,9 @@ pnpm tauri android build
|
||||
pnpm tauri ios build
|
||||
```
|
||||
|
||||
Please refer to our release script if you experience any issues:
|
||||
https://github.com/readest/readest/blob/main/.github/workflows/release.yml
|
||||
|
||||
### 6. Setup dev environment with Nix
|
||||
|
||||
If you have Nix installed, you can leverage flake to enter a development shell
|
||||
@@ -250,6 +255,32 @@ Please check the [wiki][link-gh-wiki] of this project for more information on de
|
||||
|
||||
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps you’ve taken.
|
||||
|
||||
### 2. AppImage Launches but Only Shows a Taskbar Icon
|
||||
|
||||
On some Arch Linux systems—especially those using Wayland—the Readest AppImage may briefly show an icon in the taskbar and then exit without opening a window.
|
||||
|
||||
You might see logs such as:
|
||||
|
||||
```
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
This behavior is usually caused by compatibility issues between the bundled AppImage libraries and the system’s EGL / Wayland environment.
|
||||
|
||||
**Workaround 1: Launch with LD_PRELOAD (recommended)**
|
||||
|
||||
You can preload the system Wayland client library before launching the AppImage:
|
||||
|
||||
```
|
||||
LD_PRELOAD=/usr/lib/libwayland-client.so /path/to/Readest.AppImage
|
||||
```
|
||||
|
||||
This workaround has been confirmed to resolve the issue on affected systems.
|
||||
|
||||
**Workaround 2: Use the Flatpak Version**
|
||||
|
||||
If you prefer a more reliable out-of-the-box experience on Arch Linux, consider using the [Flatpak build on Flathub][link-flathub] instead. The Flatpak runtime helps avoid system library mismatches and tends to behave more consistently across different Wayland and X11 setups.
|
||||
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
@@ -262,12 +293,12 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
|
||||
|
||||
## Support
|
||||
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [contribute with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
|
||||
### Sponsors
|
||||
|
||||
<p align="center">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmu.ai?utm_source=readest&utm_medium=sponsor" target="_blank">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=readest" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
|
||||
</a>
|
||||
</p>
|
||||
@@ -291,7 +322,9 @@ The following libraries and frameworks are used in this software:
|
||||
|
||||
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
|
||||
|
||||
[Bitter](https://fonts.google.com/?query=Bitter), [Fira Code](https://fonts.google.com/?query=Fira+Code), [Literata](https://fonts.google.com/?query=Literata), [Merriweather](https://fonts.google.com/?query=Merriweather), [Noto Sans](https://fonts.google.com/?query=Noto+Sans), [Roboto](https://fonts.google.com/?query=Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
[Bitter](https://fonts.google.com/specimen/Bitter), [Fira Code](https://fonts.google.com/specimen/Fira+Code), [Inter](https://fonts.google.com/specimen/Inter), [Literata](https://fonts.google.com/specimen/Literata), [Merriweather](https://fonts.google.com/specimen/Merriweather), [Noto Sans](https://fonts.google.com/specimen/Noto+Sans), [Roboto](https://fonts.google.com/specimen/Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
|
||||
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ module.exports = {
|
||||
'id',
|
||||
'vi',
|
||||
'ms',
|
||||
'he',
|
||||
'ar',
|
||||
'th',
|
||||
'bo',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.99",
|
||||
"version": "0.9.101",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -126,6 +126,7 @@
|
||||
"marked": "^15.0.12",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.1.6",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nunjucks": "^3.2.4",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
|
||||
@@ -346,12 +346,12 @@
|
||||
"Fit": "ملاءمة",
|
||||
"Reset {{settings}}": "إعادة تعيين {{settings}}",
|
||||
"Reset Settings": "إعادة تعيين الإعدادات",
|
||||
"{{count}} pages left in chapter_zero": "لم يتبق أي صفحات في هذا الفصل",
|
||||
"{{count}} pages left in chapter_one": "تبقّت صفحة واحدة في هذا الفصل",
|
||||
"{{count}} pages left in chapter_two": "تبقّت صفحتان في هذا الفصل",
|
||||
"{{count}} pages left in chapter_few": "تبقّت {{count}} صفحات في هذا الفصل",
|
||||
"{{count}} pages left in chapter_many": "تبقّت {{count}} صفحة في هذا الفصل",
|
||||
"{{count}} pages left in chapter_other": "تبقّى {{count}} صفحة في هذا الفصل",
|
||||
"{{count}} pages left in chapter_zero": "<1>لم يتبق أي صفحات في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_one": "<1>تبقّت صفحة واحدة في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_two": "<1>تبقّت صفحتان في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_few": "<1>تبقّت </1><0>{{count}}</0><1> صفحات في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>تبقّت </1><0>{{count}}</0><1> صفحة في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>تبقّى </1><0>{{count}}</0><1> صفحة في هذا الفصل</1>",
|
||||
"Show Remaining Pages": "عرض الصفحات المتبقية",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -615,11 +615,10 @@
|
||||
"Size": "حجم",
|
||||
"Cover": "غطاء",
|
||||
"Contain": "احتواء",
|
||||
"{{number}} pages left in chapter": "تبقّت {{number}} صفحات في هذا الفصل",
|
||||
"{{number}} pages left in chapter": "<1>تبقّت </1><0>{{number}}</0><1> صفحات في هذا الفصل</1>",
|
||||
"Device": "الجهاز",
|
||||
"E-Ink Mode": "وضع الحبر الإلكتروني",
|
||||
"Highlight Colors": "ألوان التمييز",
|
||||
"Auto Screen Brightness": "سطوع الشاشة التلقائي",
|
||||
"Pagination": "التقسيم إلى صفحات",
|
||||
"Disable Double Tap": "تعطيل النقر المزدوج",
|
||||
"Tap to Paginate": "اضغط للتقسيم إلى صفحات",
|
||||
@@ -1024,7 +1023,6 @@
|
||||
"Unable to start RSVP": "تعذر بدء RSVP",
|
||||
"RSVP not supported for PDF": "RSVP غير مدعوم لملفات PDF",
|
||||
"Select Chapter": "اختر الفصل",
|
||||
"{{number}} WPM": "{{number}} كلمة في الدقيقة",
|
||||
"Context": "السياق",
|
||||
"Ready": "جاهز",
|
||||
"Chapter Progress": "تقدم الفصل",
|
||||
@@ -1079,5 +1077,35 @@
|
||||
"Authors": "المؤلفون",
|
||||
"Books": "الكتب",
|
||||
"Groups": "المجموعات",
|
||||
"Back to TTS Location": "العودة إلى موقع القراءة الآلية"
|
||||
"Back to TTS Location": "العودة إلى موقع القراءة الآلية",
|
||||
"Metadata": "بيانات وصفية",
|
||||
"Image viewer": "عارض الصور",
|
||||
"Previous Image": "الصورة السابقة",
|
||||
"Next Image": "الصورة التالية",
|
||||
"Zoomed": "مكبّر",
|
||||
"Zoom level": "مستوى التكبير",
|
||||
"Table viewer": "عارض الجداول",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "تعذر الاتصال بـ Readwise. يرجى التحقق من اتصال الشبكة لديك.",
|
||||
"Invalid Readwise access token": "رمز وصول Readwise غير صالح",
|
||||
"Disconnected from Readwise": "تم قطع الاتصال بـ Readwise",
|
||||
"Never": "أبداً",
|
||||
"Readwise Settings": "إعدادات Readwise",
|
||||
"Connected to Readwise": "متصل بـ Readwise",
|
||||
"Last synced: {{time}}": "آخر مزامنة: {{time}}",
|
||||
"Sync Enabled": "تم تمكين المزامنة",
|
||||
"Disconnect": "قطع الاتصال",
|
||||
"Connect your Readwise account to sync highlights.": "قم بتوصيل حساب Readwise الخاص بك لمزامنة التمييزات.",
|
||||
"Get your access token at": "احصل على رمز الوصول الخاص بك من",
|
||||
"Access Token": "رمز الوصول",
|
||||
"Paste your Readwise access token": "الصق رمز وصول Readwise الخاص بك",
|
||||
"Config": "تكوين",
|
||||
"Readwise Sync": "مزامنة Readwise",
|
||||
"Push Highlights": "إرسال التمييزات",
|
||||
"Highlights synced to Readwise": "تمت مزامنة التمييزات مع Readwise",
|
||||
"Readwise sync failed: no internet connection": "فشلت مزامنة Readwise: لا يوجد اتصال بالإنترنت",
|
||||
"Readwise sync failed: {{error}}": "فشلت مزامنة Readwise: {{error}}",
|
||||
"System Screen Brightness": "سطوع شاشة النظام",
|
||||
"Page:": "صفحة:",
|
||||
"Page: {{number}}": "صفحة: {{number}}",
|
||||
"Annotation page number": "رقم صفحة التعליق"
|
||||
}
|
||||
|
||||
@@ -186,8 +186,8 @@
|
||||
"Add your notes here...": "এখানে আপনার নোট যোগ করুন...",
|
||||
"Search notes and excerpts...": "নোট ও উদ্ধৃতি খুঁজুন...",
|
||||
"{{time}} min left in chapter": "অধ্যায়ে {{time}} মিনিট বাকি",
|
||||
"{{count}} pages left in chapter_one": "অধ্যায়ে ১ পৃষ্ঠা বাকি",
|
||||
"{{count}} pages left in chapter_other": "অধ্যায়ে {{count}} পৃষ্ঠা বাকি",
|
||||
"{{count}} pages left in chapter_one": "<1>অধ্যায়ে ১ পৃষ্ঠা বাকি</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>অধ্যায়ে </1><0>{{count}}</0><1> পৃষ্ঠা বাকি</1>",
|
||||
"Theme Mode": "থিম মোড",
|
||||
"Invert Image In Dark Mode": "ডার্ক মোডে ছবি উল্টান",
|
||||
"Override Book Color": "বইয়ের রঙ পরিবর্তন",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "আকার",
|
||||
"Cover": "ঢাকা",
|
||||
"Contain": "ধারণ",
|
||||
"{{number}} pages left in chapter": "অধ্যায়ে {{number}} পৃষ্ঠা বাকি",
|
||||
"{{number}} pages left in chapter": "<1>অধ্যায়ে </1><0>{{number}}</0><1> পৃষ্ঠা বাকি</1>",
|
||||
"Device": "যন্ত্র",
|
||||
"E-Ink Mode": "ই-ইঙ্ক মোড",
|
||||
"Highlight Colors": "হাইলাইট রঙ",
|
||||
"Auto Screen Brightness": "স্বয়ংক্রিয় স্ক্রিন উজ্জ্বলতা",
|
||||
"Pagination": "পৃষ্ঠা বিন্যাস",
|
||||
"Disable Double Tap": "ডাবল ট্যাপ অক্ষম করুন",
|
||||
"Tap to Paginate": "পৃষ্ঠায় যেতে ট্যাপ করুন",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "RSVP शुरू করতে অসমর্থ",
|
||||
"RSVP not supported for PDF": "PDF-এর জন্য RSVP সমর্থিত নয়",
|
||||
"Select Chapter": "অধ্যায় নির্বাচন করুন",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "প্রসঙ্গ",
|
||||
"Ready": "প্রস্তুত",
|
||||
"Chapter Progress": "অধ্যায় অগ্রগতি",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "লেখকগণ",
|
||||
"Books": "বই",
|
||||
"Groups": "দল",
|
||||
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান"
|
||||
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান",
|
||||
"Metadata": "মেটাডেটা",
|
||||
"Image viewer": "চিত্র প্রদর্শক",
|
||||
"Previous Image": "পূর্ববর্তী চিত্র",
|
||||
"Next Image": "পরবর্তী চিত্র",
|
||||
"Zoomed": "জুম করা হয়েছে",
|
||||
"Zoom level": "জুম স্তর",
|
||||
"Table viewer": "টেবিল প্রদর্শক",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise এর সাথে সংযোগ করতে অক্ষম। আপনার নেটওয়ার্ক সংযোগটি পরীক্ষা করুন।",
|
||||
"Invalid Readwise access token": "অকার্যকর Readwise অ্যাক্সেস টোকেন",
|
||||
"Disconnected from Readwise": "Readwise থেকে সংযোগ বিচ্ছিন্ন হয়েছে",
|
||||
"Never": "কখনো না",
|
||||
"Readwise Settings": "Readwise সেটিংস",
|
||||
"Connected to Readwise": "Readwise এর সাথে সংযুক্ত",
|
||||
"Last synced: {{time}}": "শেষ সিঙ্ক করা হয়েছে: {{time}}",
|
||||
"Sync Enabled": "সিঙ্কিং সক্ষম",
|
||||
"Disconnect": "সংযোগ বিচ্ছিন্ন করুন",
|
||||
"Connect your Readwise account to sync highlights.": "হাইলাইটগুলি সিঙ্ক করার জন্য আপনার Readwise অ্যাকাউন্টটি সংযোগ করুন।",
|
||||
"Get your access token at": "আপনার অ্যাক্সেস টোকেনটি এখানে পাবেন",
|
||||
"Access Token": "অ্যাক্সেস টোকেন",
|
||||
"Paste your Readwise access token": "আপনার Readwise অ্যাক্সেস টোকেনটি পেস্ট করুন",
|
||||
"Config": "কনফিগ",
|
||||
"Readwise Sync": "Readwise সিঙ্ক",
|
||||
"Push Highlights": "হাইলাইট পাঠান",
|
||||
"Highlights synced to Readwise": "হাইলাইটগুলি Readwise এ সিঙ্ক করা হয়েছে",
|
||||
"Readwise sync failed: no internet connection": "Readwise সিঙ্ক ব্যর্থ হয়েছে: ইন্টারনেট সংযোগ নেই",
|
||||
"Readwise sync failed: {{error}}": "Readwise সিঙ্ক ব্যর্থ হয়েছে: {{error}}",
|
||||
"System Screen Brightness": "সিস্টেম স্ক্রিন উজ্জ্বলতা",
|
||||
"Page:": "পৃষ্ঠা:",
|
||||
"Page: {{number}}": "পৃষ্ঠা: {{number}}",
|
||||
"Annotation page number": "টীকা পৃষ্ঠা নম্বর"
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@
|
||||
"Fit": "མཐུན་སྒྲིག",
|
||||
"Reset {{settings}}": "{{settings}} བསྐྱར་སྒྲིག",
|
||||
"Reset Settings": "སྒྲིག་བཀོད་བསྐྱར་སྒྲིག",
|
||||
"{{count}} pages left in chapter_other": "ལེའུ་འདིར་ད་དུང་ {{count}} ཤོག་ལྷེ་ལྷག་ཡོད།",
|
||||
"{{count}} pages left in chapter_other": "<1>ལེའུ་འདིར་ད་དུང་ </1><0>{{count}}</0><1> ཤོག་ལྷེ་ལྷག་ཡོད།</1>",
|
||||
"Show Remaining Pages": "ལྷག་མའི་ཤོག་ལྷེའི་གྲངས་ཀ་མངོན་པ།",
|
||||
"Source Han Serif CN": "སི་ཡོན་སུང་ཐི།",
|
||||
"Huiwen-MinchoGBK": "ཧུའེ་ཝུན་མིང་ཁྲའོ་ཐི།",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "ཨང་",
|
||||
"Cover": "དེབ་གྱི་སྤྱོད་བྱས་མ་ཐུབ།",
|
||||
"Contain": "འབྱོར་བ།",
|
||||
"{{number}} pages left in chapter": "དོན་ཚན་ནང་ཤོག་ཨང་ {{number}} ལོག་གི་འདུག།",
|
||||
"{{number}} pages left in chapter": "<1>དོན་ཚན་ནང་ཤོག་ཨང་ </1><0>{{number}}</0><1> ལོག་གི་འདུག།</1>",
|
||||
"Device": "རྐྱབ་སྐོར",
|
||||
"E-Ink Mode": "ཡིག་སྒྱུར་སྤོ་བ",
|
||||
"Highlight Colors": "མདོག་ཚད་ཀྱི་བསྡུར་བ།",
|
||||
"Auto Screen Brightness": "རྒྱབ་སྐོར་གྱི་འོད་ཟེར་རང་འགུལ།",
|
||||
"Pagination": "ཤོག་གདོང་།",
|
||||
"Disable Double Tap": "བརྒྱབ་གདོང་གཉིས་མ་འགྱོད།",
|
||||
"Tap to Paginate": "ཤོག་གདོང་ལ་ཐོག་འགྲོ།",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "RSVP འགོ་འཛུགས་མ་ཐུབ།",
|
||||
"RSVP not supported for PDF": "PDF ལ་ RSVP རྒྱབ་སྐྱོར་མེད།",
|
||||
"Select Chapter": "ལེའུ་འདེམས་པ།",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "བརྗོད་དོན།",
|
||||
"Ready": "གྲ་སྒྲིག་ཡོད།",
|
||||
"Chapter Progress": "ལེའུའི་འཕེལ་རིམ།",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "རྩོམ་པ་པོ།",
|
||||
"Books": "དཔེ་ཆ།",
|
||||
"Groups": "ཚོགས་པ།",
|
||||
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།"
|
||||
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།",
|
||||
"Metadata": "གནད་སྨིན་གོ་དོན།",
|
||||
"Image viewer": "པར་རིས་ལྟ་བྱེད།",
|
||||
"Previous Image": "སྔོན་མའི་པར་རིས།",
|
||||
"Next Image": "རྗེས་མའི་པར་རིས།",
|
||||
"Zoomed": "ཆེར་བསྐྱེད་ཟིན།",
|
||||
"Zoom level": "ཆེར་བསྐྱེད་རིམ་པ།",
|
||||
"Table viewer": "རེའུ་མིག་ལྟ་བྱེད།",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise ལ་མཐུད་ཐུབ་མ་སོང་། ཁྱེད་ཀྱི་དྲ་རྒྱའི་མཐུད་ལམ་ལ་བརྟག་དཔྱད་གནང་རོགས།",
|
||||
"Invalid Readwise access token": "Readwise འཛུལ་སྤྱོད་ལག་ཁྱེར་ནོར་འདུག",
|
||||
"Disconnected from Readwise": "Readwise ནས་མཐུད་ལམ་བཅད་ཟིན།",
|
||||
"Never": "ནམ་ཡང་མིན།",
|
||||
"Readwise Settings": "Readwise སྒྲིག་བཀོད།",
|
||||
"Connected to Readwise": "Readwise ལ་མཐུད་ཟིན།",
|
||||
"Last synced: {{time}}": "མཐའ་མའི་མཉམ་བྱུང་དུས་ཚོད། {{time}}",
|
||||
"Sync Enabled": "མཉམ་བྱུང་ནུས་པ་སྤར་ཟིན།",
|
||||
"Disconnect": "མཐུད་ལམ་གཅོད་པ།",
|
||||
"Connect your Readwise account to sync highlights.": "ཁྱེད་ཀྱི་ Readwise རྩིས་ཐོ་མཐུད་ནས་བཀོད་མཆན་མཉམ་བྱུང་གནང་རོགས།",
|
||||
"Get your access token at": "འཛུལ་སྤྱོད་ལག་ཁྱེར་འདི་ནས་ལེན་རོགས།",
|
||||
"Access Token": "འཛུལ་སྤྱོད་ལག་ཁྱེེར།",
|
||||
"Paste your Readwise access token": "Readwise འཛུལ་སྤྱོད་ལག་ཁྱེར་འདིར་སྦྱོར་རོགས།",
|
||||
"Config": "སྒྲིག་བཀོད།",
|
||||
"Readwise Sync": "Readwise མཉམ་བྱུང་།",
|
||||
"Push Highlights": "བཀོད་མཆན་སྐྱེལ་བ།",
|
||||
"Highlights synced to Readwise": "བཀོད་མཆན་ Readwise ལ་མཉམ་བྱུང་བྱས་ཟིན།",
|
||||
"Readwise sync failed: no internet connection": "Readwise མཉམ་བྱུང་མ་ཐུབ། དྲ་རྒྱའི་མཐུད་ལམ་མི་འདུག",
|
||||
"Readwise sync failed: {{error}}": "Readwise མཉམ་བྱུང་མ་ཐུབ། {{error}}",
|
||||
"System Screen Brightness": "མ་ལག་ཤེལ་སྒོའི་གསལ་ཚད།",
|
||||
"Page:": "ཤོག་ལྷེ།:",
|
||||
"Page: {{number}}": "ཤོག་ལྷེ།: {{number}}",
|
||||
"Annotation page number": "མཆན་འགྲེལ་ཤོག་ཨང་།"
|
||||
}
|
||||
|
||||
@@ -334,8 +334,8 @@
|
||||
"Fit": "Anpassen",
|
||||
"Reset {{settings}}": "{{settings}} zurücksetzen",
|
||||
"Reset Settings": "Einstellungen zurücksetzen",
|
||||
"{{count}} pages left in chapter_one": "{{count}} Seite verbleibend im Kapitel",
|
||||
"{{count}} pages left in chapter_other": "{{count}} Seiten verbleibend im Kapitel",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> Seite verbleibend im Kapitel</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> Seiten verbleibend im Kapitel</1>",
|
||||
"Show Remaining Pages": "Verbleibende Seiten anzeigen",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "Größe",
|
||||
"Cover": "Cover",
|
||||
"Contain": "Inhalt",
|
||||
"{{number}} pages left in chapter": "{{number}} Seiten verbleibend im Kapitel",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> Seiten verbleibend im Kapitel</1>",
|
||||
"Device": "Gerät",
|
||||
"E-Ink Mode": "E-Ink-Modus",
|
||||
"Highlight Colors": "Hervorhebungsfarben",
|
||||
"Auto Screen Brightness": "Automatische Bildschirmhelligkeit",
|
||||
"Pagination": "Seitenumbruch",
|
||||
"Disable Double Tap": "Doppeltippen deaktivieren",
|
||||
"Tap to Paginate": "Tippen zum Blättern",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "RSVP kann nicht gestartet werden",
|
||||
"RSVP not supported for PDF": "RSVP wird für PDF nicht unterstützt",
|
||||
"Select Chapter": "Kapitel auswählen",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Kontext",
|
||||
"Ready": "Bereit",
|
||||
"Chapter Progress": "Kapitelfortschritt",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "Autoren",
|
||||
"Books": "Bücher",
|
||||
"Groups": "Gruppen",
|
||||
"Back to TTS Location": "Zurück zur TTS-Position"
|
||||
"Back to TTS Location": "Zurück zur TTS-Position",
|
||||
"Metadata": "Metadaten",
|
||||
"Image viewer": "Bildbetrachter",
|
||||
"Previous Image": "Vorheriges Bild",
|
||||
"Next Image": "Nächstes Bild",
|
||||
"Zoomed": "Gezoomt",
|
||||
"Zoom level": "Zoomstufe",
|
||||
"Table viewer": "Tabellenbetrachter",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Verbindung zu Readwise nicht möglich. Bitte überprüfen Sie Ihre Netzwerkverbindung.",
|
||||
"Invalid Readwise access token": "Ungültiges Readwise-Zugriffstoken",
|
||||
"Disconnected from Readwise": "Von Readwise getrennt",
|
||||
"Never": "Nie",
|
||||
"Readwise Settings": "Readwise-Einstellungen",
|
||||
"Connected to Readwise": "Mit Readwise verbunden",
|
||||
"Last synced: {{time}}": "Zuletzt synchronisiert: {{time}}",
|
||||
"Sync Enabled": "Synchronisierung aktiviert",
|
||||
"Disconnect": "Trennen",
|
||||
"Connect your Readwise account to sync highlights.": "Verbinden Sie Ihr Readwise-Konto, um Highlights zu synchronisieren.",
|
||||
"Get your access token at": "Holen Sie sich Ihr Zugriffstoken unter",
|
||||
"Access Token": "Zugriffstoken",
|
||||
"Paste your Readwise access token": "Fügen Sie Ihr Readwise-Zugriffstoken ein",
|
||||
"Config": "Konfiguration",
|
||||
"Readwise Sync": "Readwise-Synchronisierung",
|
||||
"Push Highlights": "Highlights übertragen",
|
||||
"Highlights synced to Readwise": "Highlights mit Readwise synchronisiert",
|
||||
"Readwise sync failed: no internet connection": "Readwise-Synchronisierung fehlgeschlagen: Keine Internetverbindung",
|
||||
"Readwise sync failed: {{error}}": "Readwise-Synchronisierung fehlgeschlagen: {{error}}",
|
||||
"System Screen Brightness": "System-Bildschirmhelligkeit",
|
||||
"Page:": "Seite:",
|
||||
"Page: {{number}}": "Seite: {{number}}",
|
||||
"Annotation page number": "Seitenzahl der Anmerkung"
|
||||
}
|
||||
|
||||
@@ -335,8 +335,8 @@
|
||||
"Fit": "Προσαρμογή",
|
||||
"Reset {{settings}}": "Επαναφορά {{settings}}",
|
||||
"Reset Settings": "Επαναφορά ρυθμίσεων",
|
||||
"{{count}} pages left in chapter_one": "Μένει {{count}} σελίδα",
|
||||
"{{count}} pages left in chapter_other": "Μένουν {{count}} σελίδες",
|
||||
"{{count}} pages left in chapter_one": "<1>Μένει </1><0>{{count}}</0><1> σελίδα</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Μένουν </1><0>{{count}}</0><1> σελίδες</1>",
|
||||
"Show Remaining Pages": "Εμφάνιση υπολοίπων",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "Μέγεθος",
|
||||
"Cover": "Εξώφυλλο",
|
||||
"Contain": "Περιέχει",
|
||||
"{{number}} pages left in chapter": "Μένουν {{number}} σελίδες στο κεφάλαιο",
|
||||
"{{number}} pages left in chapter": "<1>Μένουν </1><0>{{number}}</0><1> σελίδες στο κεφάλαιο</1>",
|
||||
"Device": "Συσκευή",
|
||||
"E-Ink Mode": "Λειτουργία E-Ink",
|
||||
"Highlight Colors": "Χρώματα επισήμανσης",
|
||||
"Auto Screen Brightness": "Αυτόματη φωτεινότητα οθόνης",
|
||||
"Pagination": "Σελιδοποίηση",
|
||||
"Disable Double Tap": "Απενεργοποίηση διπλού ταπ",
|
||||
"Tap to Paginate": "Ταπ για σελιδοποίηση",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "Αδυναμία έναρξης RSVP",
|
||||
"RSVP not supported for PDF": "Το RSVP δεν υποστηρίζεται για PDF",
|
||||
"Select Chapter": "Επιλογή Κεφαλαίου",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Πλαίσιο",
|
||||
"Ready": "Έτοιμο",
|
||||
"Chapter Progress": "Πρόοδος Κεφαλαίου",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "Συγγραφείς",
|
||||
"Books": "Βιβλία",
|
||||
"Groups": "Ομάδες",
|
||||
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS"
|
||||
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS",
|
||||
"Metadata": "Μεταδεδομένα",
|
||||
"Image viewer": "Πρόγραμμα προβολής εικόνων",
|
||||
"Previous Image": "Προηγούμενη εικόνα",
|
||||
"Next Image": "Επόμενη εικόνα",
|
||||
"Zoomed": "Ζουμαρισμένο",
|
||||
"Zoom level": "Επίπεδο ζουμ",
|
||||
"Table viewer": "Πρόγραμμα προβολής πινάκων",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Αδυναμία σύνδεσης στο Readwise. Ελέγξτε τη σύνδεση του δικτύου σας.",
|
||||
"Invalid Readwise access token": "Μη έγκυρο διακριτικό πρόσβασης Readwise",
|
||||
"Disconnected from Readwise": "Αποσυνδέθηκε από το Readwise",
|
||||
"Never": "Ποτέ",
|
||||
"Readwise Settings": "Ρυθμίσεις Readwise",
|
||||
"Connected to Readwise": "Συνδέθηκε στο Readwise",
|
||||
"Last synced: {{time}}": "Τελευταίος συγχρονισμός: {{time}}",
|
||||
"Sync Enabled": "Ο συγχρονισμός ενεργοποιήθηκε",
|
||||
"Disconnect": "Αποσύνδεση",
|
||||
"Connect your Readwise account to sync highlights.": "Συνδέστε τον λογαριασμό σας Readwise για να συγχρονίσετε τις επισημάνσεις.",
|
||||
"Get your access token at": "Αποκτήστε το διακριτικό πρόσβασής σας στο",
|
||||
"Access Token": "Διακριτικό πρόσβασης",
|
||||
"Paste your Readwise access token": "Επικολλήστε το διακριτικό πρόσβασης Readwise",
|
||||
"Config": "Ρύθμιση παραμέτρων",
|
||||
"Readwise Sync": "Συγχρονισμός Readwise",
|
||||
"Push Highlights": "Προώθηση επισημάνσεων",
|
||||
"Highlights synced to Readwise": "Οι επισημάνσεις συγχρονίστηκαν στο Readwise",
|
||||
"Readwise sync failed: no internet connection": "Ο συγχρονισμός Readwise απέτυχε: δεν υπάρχει σύνδεση στο διαδίκτυο",
|
||||
"Readwise sync failed: {{error}}": "Ο συγχρονισμός Readwise απέτυχε: {{error}}",
|
||||
"System Screen Brightness": "Φωτεινότητα οθόνης συστήματος",
|
||||
"Page:": "Σελίδα:",
|
||||
"Page: {{number}}": "Σελίδα: {{number}}",
|
||||
"Annotation page number": "Αριθμός σελίδας σχολίου"
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Are you sure to delete {{count}} selected books?",
|
||||
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
|
||||
"Search in {{count}} Book(s)..._other": "Search in {{count}} books...",
|
||||
"{{count}} pages left in chapter_one": "{{count}} page left in chapter",
|
||||
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> page left in chapter</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pages left in chapter</1>",
|
||||
"Deleted {{count}} file(s)_one": "Deleted {{count}} file",
|
||||
"Deleted {{count}} file(s)_other": "Deleted {{count}} files",
|
||||
"Failed to delete {{count}} file(s)_one": "Failed to delete {{count}} file",
|
||||
|
||||
@@ -365,9 +365,9 @@
|
||||
"Fit": "Encajar",
|
||||
"Reset {{settings}}": "Restablecer {{settings}}",
|
||||
"Reset Settings": "Restablecer configuración",
|
||||
"{{count}} pages left in chapter_one": "{{count}} página restante en el capítulo",
|
||||
"{{count}} pages left in chapter_many": "{{count}} páginas restantes en el capítulo",
|
||||
"{{count}} pages left in chapter_other": "{{count}} páginas restantes en el capítulo",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> página restante en el capítulo</1>",
|
||||
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> páginas restantes en el capítulo</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> páginas restantes en el capítulo</1>",
|
||||
"Show Remaining Pages": "Mostrar resto",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -603,11 +603,10 @@
|
||||
"Size": "Tamaño",
|
||||
"Cover": "Cubierta",
|
||||
"Contain": "Contener",
|
||||
"{{number}} pages left in chapter": "{{number}} páginas restantes en el capítulo",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> páginas restantes en el capítulo</1>",
|
||||
"Device": "Dispositivo",
|
||||
"E-Ink Mode": "Modo E-Ink",
|
||||
"Highlight Colors": "Colores de resaltado",
|
||||
"Auto Screen Brightness": "Brillo automático de pantalla",
|
||||
"Pagination": "Paginación",
|
||||
"Disable Double Tap": "Deshabilitar Doble Toque",
|
||||
"Tap to Paginate": "Tocar para Paginación",
|
||||
@@ -988,7 +987,6 @@
|
||||
"Unable to start RSVP": "No se puede iniciar RSVP",
|
||||
"RSVP not supported for PDF": "RSVP no es compatible con PDF",
|
||||
"Select Chapter": "Seleccionar capítulo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contexto",
|
||||
"Ready": "Listo",
|
||||
"Chapter Progress": "Progreso del capítulo",
|
||||
@@ -1043,5 +1041,35 @@
|
||||
"Authors": "Autores",
|
||||
"Books": "Libros",
|
||||
"Groups": "Grupos",
|
||||
"Back to TTS Location": "Volver a la ubicación de TTS"
|
||||
"Back to TTS Location": "Volver a la ubicación de TTS",
|
||||
"Metadata": "Metadatos",
|
||||
"Image viewer": "Visor de imágenes",
|
||||
"Previous Image": "Imagen anterior",
|
||||
"Next Image": "Imagen siguiente",
|
||||
"Zoomed": "Con zoom",
|
||||
"Zoom level": "Nivel de zoom",
|
||||
"Table viewer": "Visor de tablas",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "No se pudo conectar a Readwise. Por favor, comprueba tu conexión a la red.",
|
||||
"Invalid Readwise access token": "Token de acceso a Readwise no válido",
|
||||
"Disconnected from Readwise": "Desconectado de Readwise",
|
||||
"Never": "Nunca",
|
||||
"Readwise Settings": "Ajustes de Readwise",
|
||||
"Connected to Readwise": "Conectado a Readwise",
|
||||
"Last synced: {{time}}": "Última sincronización: {{time}}",
|
||||
"Sync Enabled": "Sincronización habilitada",
|
||||
"Disconnect": "Desconectar",
|
||||
"Connect your Readwise account to sync highlights.": "Conecta tu cuenta de Readwise para sincronizar los resaltados.",
|
||||
"Get your access token at": "Obtén tu token de acceso en",
|
||||
"Access Token": "Token de acceso",
|
||||
"Paste your Readwise access token": "Pega tu token de acceso de Readwise",
|
||||
"Config": "Configuración",
|
||||
"Readwise Sync": "Sincronización con Readwise",
|
||||
"Push Highlights": "Enviar resaltados",
|
||||
"Highlights synced to Readwise": "Resaltados sincronizados con Readwise",
|
||||
"Readwise sync failed: no internet connection": "Error en la sincronización con Readwise: sin conexión a internet",
|
||||
"Readwise sync failed: {{error}}": "Error en la sincronización con Readwise: {{error}}",
|
||||
"System Screen Brightness": "Brillo de pantalla del sistema",
|
||||
"Page:": "Página:",
|
||||
"Page: {{number}}": "Página: {{number}}",
|
||||
"Annotation page number": "Número de página de la anotación"
|
||||
}
|
||||
|
||||
@@ -334,8 +334,8 @@
|
||||
"Fit": "متناسب",
|
||||
"Reset {{settings}}": "بازنشانی {{settings}}",
|
||||
"Reset Settings": "بازنشانی تنظیمات",
|
||||
"{{count}} pages left in chapter_one": "{{count}} صفحه تا انتهای فصل",
|
||||
"{{count}} pages left in chapter_other": "{{count}} صفحه تا انتهای فصل",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> صفحه تا انتهای فصل</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> صفحه تا انتهای فصل</1>",
|
||||
"Show Remaining Pages": "نمایش صفحات باقیمانده",
|
||||
"Source Han Serif CN": "Source Han Serif CN",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "اندازه",
|
||||
"Cover": "جلد",
|
||||
"Contain": "شامل",
|
||||
"{{number}} pages left in chapter": "{{number}} صفحه تا انتهای فصل",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> صفحه تا انتهای فصل</1>",
|
||||
"Device": "دستگاه",
|
||||
"E-Ink Mode": "حالت E-Ink",
|
||||
"Highlight Colors": "رنگهای هایلایت",
|
||||
"Auto Screen Brightness": "روشنایی خودکار صفحه",
|
||||
"Pagination": "صفحهبندی",
|
||||
"Disable Double Tap": "غیرفعال کردن کلیک دوبل",
|
||||
"Tap to Paginate": "ضربه برای صفحهبندی",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "قادر به شروع RSVP نیست",
|
||||
"RSVP not supported for PDF": "RSVP برای PDF پشتیبانی نمیشود",
|
||||
"Select Chapter": "انتخاب فصل",
|
||||
"{{number}} WPM": "{{number}} کلمه در دقیقه",
|
||||
"Context": "زمینه",
|
||||
"Ready": "آماده",
|
||||
"Chapter Progress": "پیشرفت فصل",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "نویسندگان",
|
||||
"Books": "کتابها",
|
||||
"Groups": "گروهها",
|
||||
"Back to TTS Location": "بازگشت به مکان TTS"
|
||||
"Back to TTS Location": "بازگشت به مکان TTS",
|
||||
"Metadata": "فراداده",
|
||||
"Image viewer": "نمایشگر تصویر",
|
||||
"Previous Image": "تصویر قبلی",
|
||||
"Next Image": "تصویر بعدی",
|
||||
"Zoomed": "بزرگنمایی شده",
|
||||
"Zoom level": "سطح بزرگنمایی",
|
||||
"Table viewer": "نمایشگر جدول",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "اتصال به Readwise امکانپذیر نیست. لطفاً اتصال شبکه خود را بررسی کنید.",
|
||||
"Invalid Readwise access token": "توکن دسترسی Readwise نامعتبر است",
|
||||
"Disconnected from Readwise": "اتصال از Readwise قطع شد",
|
||||
"Never": "هرگز",
|
||||
"Readwise Settings": "تنظیمات Readwise",
|
||||
"Connected to Readwise": "به Readwise متصل شد",
|
||||
"Last synced: {{time}}": "آخرین همگامسازی: {{time}}",
|
||||
"Sync Enabled": "همگامسازی فعال شد",
|
||||
"Disconnect": "قطع اتصال",
|
||||
"Connect your Readwise account to sync highlights.": "برای همگامسازی هایلایتها، حساب Readwise خود را متصل کنید.",
|
||||
"Get your access token at": "توکن دسترسی خود را از اینجا دریافت کنید:",
|
||||
"Access Token": "توکن دسترسی",
|
||||
"Paste your Readwise access token": "توکن دسترسی Readwise خود را جایگذاری کنید",
|
||||
"Config": "پیکربندی",
|
||||
"Readwise Sync": "همگامسازی Readwise",
|
||||
"Push Highlights": "ارسال هایلایتها",
|
||||
"Highlights synced to Readwise": "هایلایتها با Readwise همگامسازی شدند",
|
||||
"Readwise sync failed: no internet connection": "همگامسازی Readwise ناموفق بود: اتصال اینترنت برقرار نیست",
|
||||
"Readwise sync failed: {{error}}": "همگامسازی Readwise ناموفق بود: {{error}}",
|
||||
"System Screen Brightness": "روشنایی صفحه نمایش سیستم",
|
||||
"Page:": "صفحه:",
|
||||
"Page: {{number}}": "صفحه: {{number}}",
|
||||
"Annotation page number": "شماره صفحه یادداشت"
|
||||
}
|
||||
|
||||
@@ -337,9 +337,9 @@
|
||||
"Fit": "Adapter",
|
||||
"Reset {{settings}}": "Réinitialiser {{settings}}",
|
||||
"Reset Settings": "Réinitialiser les paramètres",
|
||||
"{{count}} pages left in chapter_one": "{{count}} page restant dans le chapitre",
|
||||
"{{count}} pages left in chapter_many": "{{count}} pages restantes dans le chapitre",
|
||||
"{{count}} pages left in chapter_other": "{{count}} pages restantes dans le chapitre",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> page restant dans le chapitre</1>",
|
||||
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> pages restantes dans le chapitre</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pages restantes dans le chapitre</1>",
|
||||
"Show Remaining Pages": "Voir restantes",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -603,11 +603,10 @@
|
||||
"Size": "Taille",
|
||||
"Cover": "Couverture",
|
||||
"Contain": "Contenir",
|
||||
"{{number}} pages left in chapter": "{{number}} pages restantes dans le chapitre",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> pages restantes dans le chapitre</1>",
|
||||
"Device": "Appareil",
|
||||
"E-Ink Mode": "Mode E-Ink",
|
||||
"Highlight Colors": "Couleurs de Surlignage",
|
||||
"Auto Screen Brightness": "Luminosité Automatique de l'Écran",
|
||||
"Pagination": "Pagination",
|
||||
"Disable Double Tap": "Désactiver le Double Tap",
|
||||
"Tap to Paginate": "Tapoter pour Paginater",
|
||||
@@ -988,7 +987,6 @@
|
||||
"Unable to start RSVP": "Impossible de démarrer RSVP",
|
||||
"RSVP not supported for PDF": "RSVP non pris en charge pour PDF",
|
||||
"Select Chapter": "Sélectionner le chapitre",
|
||||
"{{number}} WPM": "{{number}} MPM",
|
||||
"Context": "Contexte",
|
||||
"Ready": "Prêt",
|
||||
"Chapter Progress": "Progression du chapitre",
|
||||
@@ -1043,5 +1041,35 @@
|
||||
"Authors": "Auteurs",
|
||||
"Books": "Livres",
|
||||
"Groups": "Groupes",
|
||||
"Back to TTS Location": "Retour à l'emplacement TTS"
|
||||
"Back to TTS Location": "Retour à l'emplacement TTS",
|
||||
"Metadata": "Métadonnées",
|
||||
"Image viewer": "Visionneuse d'images",
|
||||
"Previous Image": "Image précédente",
|
||||
"Next Image": "Image suivante",
|
||||
"Zoomed": "Zoomé",
|
||||
"Zoom level": "Niveau de zoom",
|
||||
"Table viewer": "Visionneuse de tableaux",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Impossible de se connecter à Readwise. Veuillez vérifier votre connexion réseau.",
|
||||
"Invalid Readwise access token": "Jeton d'accès Readwise invalide",
|
||||
"Disconnected from Readwise": "Déconnecté de Readwise",
|
||||
"Never": "Jamais",
|
||||
"Readwise Settings": "Paramètres Readwise",
|
||||
"Connected to Readwise": "Connecté à Readwise",
|
||||
"Last synced: {{time}}": "Dernière synchronisation : {{time}}",
|
||||
"Sync Enabled": "Synchronisation activée",
|
||||
"Disconnect": "Déconnecter",
|
||||
"Connect your Readwise account to sync highlights.": "Connectez votre compte Readwise pour synchroniser les surlignages.",
|
||||
"Get your access token at": "Obtenez votre jeton d'accès sur",
|
||||
"Access Token": "Jeton d'accès",
|
||||
"Paste your Readwise access token": "Collez votre jeton d'accès Readwise",
|
||||
"Config": "Configuration",
|
||||
"Readwise Sync": "Synchronisation Readwise",
|
||||
"Push Highlights": "Pousser les surlignages",
|
||||
"Highlights synced to Readwise": "Surlignages synchronisés avec Readwise",
|
||||
"Readwise sync failed: no internet connection": "Échec de la synchronisation Readwise : pas de connexion internet",
|
||||
"Readwise sync failed: {{error}}": "Échec de la synchronisation Readwise : {{error}}",
|
||||
"System Screen Brightness": "Luminosité de l'écran système",
|
||||
"Page:": "Page :",
|
||||
"Page: {{number}}": "Page : {{number}}",
|
||||
"Annotation page number": "Numéro de page de l'annotation"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -335,8 +335,8 @@
|
||||
"Fit": "फिट",
|
||||
"Reset {{settings}}": "रीसेट {{settings}}",
|
||||
"Reset Settings": "सेटिंग्स रीसेट करें",
|
||||
"{{count}} pages left in chapter_one": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
|
||||
"{{count}} pages left in chapter_other": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
|
||||
"{{count}} pages left in chapter_one": "<1>इस अध्याय में </1><0>{{count}}</0><1> पृष्ठ शेष हैं</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>इस अध्याय में </1><0>{{count}}</0><1> पृष्ठ शेष हैं</1>",
|
||||
"Show Remaining Pages": "शेष पृष्ठ दिखाएँ",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "आकार",
|
||||
"Cover": "कवर",
|
||||
"Contain": "समाहित करें",
|
||||
"{{number}} pages left in chapter": "इस अध्याय में {{number}} पृष्ठ शेष हैं",
|
||||
"{{number}} pages left in chapter": "<1>इस अध्याय में </1><0>{{number}}</0><1> पृष्ठ शेष हैं</1>",
|
||||
"Device": "डिवाइस",
|
||||
"E-Ink Mode": "ई-इंक मोड",
|
||||
"Highlight Colors": "हाइलाइट रंग",
|
||||
"Auto Screen Brightness": "स्वचालित स्क्रीन ब्राइटनेस",
|
||||
"Pagination": "पृष्ठांकन",
|
||||
"Disable Double Tap": "डबल टैप अक्षम करें",
|
||||
"Tap to Paginate": "पृष्ठांकन के लिए टैप करें",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "RSVP शुरू करने में असमर्थ",
|
||||
"RSVP not supported for PDF": "PDF के लिए RSVP समर्थित नहीं है",
|
||||
"Select Chapter": "अध्याय चुनें",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "संदर्भ",
|
||||
"Ready": "तैयार",
|
||||
"Chapter Progress": "अध्याय प्रगति",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "लेखक",
|
||||
"Books": "पुस्तकें",
|
||||
"Groups": "समूह",
|
||||
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं"
|
||||
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं",
|
||||
"Metadata": "मेटाडाटा",
|
||||
"Image viewer": "छवि दर्शक",
|
||||
"Previous Image": "पिछली छवि",
|
||||
"Next Image": "अगली छवि",
|
||||
"Zoomed": "ज़ूम किया गया",
|
||||
"Zoom level": "ज़ूम स्तर",
|
||||
"Table viewer": "तालिका दर्शक",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise से कनेक्ट करने में असमर्थ। कृपया अपना नेटवर्क कनेक्शन जांचें।",
|
||||
"Invalid Readwise access token": "अमान्य Readwise एक्सेस टोकन",
|
||||
"Disconnected from Readwise": "Readwise से डिस्कनेक्ट हो गया",
|
||||
"Never": "कभी नहीं",
|
||||
"Readwise Settings": "Readwise सेटिंग्स",
|
||||
"Connected to Readwise": "Readwise से जुड़ा हुआ",
|
||||
"Last synced: {{time}}": "पिछला सिंक: {{time}}",
|
||||
"Sync Enabled": "सिंक सक्षम",
|
||||
"Disconnect": "डिस्कनेक्ट करें",
|
||||
"Connect your Readwise account to sync highlights.": "हाइलाइट्स सिंक करने के लिए अपना Readwise खाता कनेक्ट करें।",
|
||||
"Get your access token at": "अपना एक्सेस टोकन यहाँ प्राप्त करें",
|
||||
"Access Token": "एक्सेस टोकन",
|
||||
"Paste your Readwise access token": "अपना Readwise एक्सेस टोकन पेस्ट करें",
|
||||
"Config": "कॉन्फिग",
|
||||
"Readwise Sync": "Readwise सिंक",
|
||||
"Push Highlights": "हाइलाइट्स भेजें",
|
||||
"Highlights synced to Readwise": "हाइलाइट्स Readwise में सिंक हो गए",
|
||||
"Readwise sync failed: no internet connection": "Readwise सिंक विफल रहा: कोई इंटरनेट कनेक्शन नहीं",
|
||||
"Readwise sync failed: {{error}}": "Readwise सिंक विफल रहा: {{error}}",
|
||||
"System Screen Brightness": "सिस्टम स्क्रीन चमक",
|
||||
"Page:": "पृष्ठ:",
|
||||
"Page: {{number}}": "पृष्ठ: {{number}}",
|
||||
"Annotation page number": "व्याख्या पृष्ठ संख्या"
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"Fit": "Pas",
|
||||
"Reset {{settings}}": "Atur Ulang {{settings}}",
|
||||
"Reset Settings": "Atur Ulang Pengaturan",
|
||||
"{{count}} pages left in chapter_other": "{{count}} halaman tersisa di bab ini",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> halaman tersisa di bab ini</1>",
|
||||
"Show Remaining Pages": "Tampilkan halaman tersisa",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "Ukuran",
|
||||
"Cover": "Sampul",
|
||||
"Contain": "Mengandung",
|
||||
"{{number}} pages left in chapter": "{{number}} halaman tersisa di bab",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> halaman tersisa di bab</1>",
|
||||
"Device": "Perangkat",
|
||||
"E-Ink Mode": "Mode E-Ink",
|
||||
"Highlight Colors": "Warna Sorotan",
|
||||
"Auto Screen Brightness": "Auto Kecerahan Layar",
|
||||
"Pagination": "Paginasi",
|
||||
"Disable Double Tap": "Nonaktifkan Ketuk Ganda",
|
||||
"Tap to Paginate": "Ketuk untuk Paginasi",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "Tidak dapat memulai RSVP",
|
||||
"RSVP not supported for PDF": "RSVP tidak didukung untuk PDF",
|
||||
"Select Chapter": "Pilih Bab",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Konteks",
|
||||
"Ready": "Siap",
|
||||
"Chapter Progress": "Kemajuan Bab",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "Penulis",
|
||||
"Books": "Buku",
|
||||
"Groups": "Grup",
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS"
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS",
|
||||
"Metadata": "Metadata",
|
||||
"Image viewer": "Penampil gambar",
|
||||
"Previous Image": "Gambar Sebelumnya",
|
||||
"Next Image": "Gambar Berikutnya",
|
||||
"Zoomed": "Diperbesar",
|
||||
"Zoom level": "Tingkat zoom",
|
||||
"Table viewer": "Penampil tabel",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Tidak dapat terhubung ke Readwise. Silakan periksa koneksi jaringan Anda.",
|
||||
"Invalid Readwise access token": "Token akses Readwise tidak valid",
|
||||
"Disconnected from Readwise": "Terputus dari Readwise",
|
||||
"Never": "Tidak pernah",
|
||||
"Readwise Settings": "Pengaturan Readwise",
|
||||
"Connected to Readwise": "Terhubung ke Readwise",
|
||||
"Last synced: {{time}}": "Terakhir disinkronkan: {{time}}",
|
||||
"Sync Enabled": "Sinkronisasi Diaktifkan",
|
||||
"Disconnect": "Putuskan sambungan",
|
||||
"Connect your Readwise account to sync highlights.": "Hubungkan akun Readwise Anda untuk menyinkronkan sorotan.",
|
||||
"Get your access token at": "Dapatkan token akses Anda di",
|
||||
"Access Token": "Token Akses",
|
||||
"Paste your Readwise access token": "Tempelkan token akses Readwise Anda",
|
||||
"Config": "Konfigurasi",
|
||||
"Readwise Sync": "Sinkronisasi Readwise",
|
||||
"Push Highlights": "Kirim Sorotan",
|
||||
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sinkronisasi Readwise gagal: tidak ada koneksi internet",
|
||||
"Readwise sync failed: {{error}}": "Sinkronisasi Readwise gagal: {{error}}",
|
||||
"System Screen Brightness": "Kecerahan Layar Sistem",
|
||||
"Page:": "Halaman:",
|
||||
"Page: {{number}}": "Halaman: {{number}}",
|
||||
"Annotation page number": "Nomor halaman anotasi"
|
||||
}
|
||||
|
||||
@@ -338,9 +338,9 @@
|
||||
"Fit": "Adatta",
|
||||
"Reset {{settings}}": "Reimposta {{settings}}",
|
||||
"Reset Settings": "Reimposta impostazioni",
|
||||
"{{count}} pages left in chapter_one": "{{count}} pagina rimasta nel capitolo",
|
||||
"{{count}} pages left in chapter_many": "{{count}} pagine rimaste nel capitolo",
|
||||
"{{count}} pages left in chapter_other": "{{count}} pagine rimaste nel capitolo",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> pagina rimasta nel capitolo</1>",
|
||||
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> pagine rimaste nel capitolo</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pagine rimaste nel capitolo</1>",
|
||||
"Show Remaining Pages": "Mostra pagine rimanenti",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -603,11 +603,10 @@
|
||||
"Size": "Dimensione",
|
||||
"Cover": "Copertura",
|
||||
"Contain": "Contenere",
|
||||
"{{number}} pages left in chapter": "{{number}} pagine rimaste nel capitolo",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> pagine rimaste nel capitolo</1>",
|
||||
"Device": "Dispositivo",
|
||||
"E-Ink Mode": "Modalità E-Ink",
|
||||
"Highlight Colors": "Colori Evidenziazione",
|
||||
"Auto Screen Brightness": "Luminosità schermo automatica",
|
||||
"Pagination": "Impaginazione",
|
||||
"Disable Double Tap": "Disabilita Doppio Tap",
|
||||
"Tap to Paginate": "Tocca per Impaginare",
|
||||
@@ -988,7 +987,6 @@
|
||||
"Unable to start RSVP": "Impossibile avviare RSVP",
|
||||
"RSVP not supported for PDF": "RSVP non supportato per PDF",
|
||||
"Select Chapter": "Seleziona capitolo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contesto",
|
||||
"Ready": "Pronto",
|
||||
"Chapter Progress": "Progresso capitolo",
|
||||
@@ -1043,5 +1041,35 @@
|
||||
"Authors": "Autori",
|
||||
"Books": "Libri",
|
||||
"Groups": "Gruppi",
|
||||
"Back to TTS Location": "Torna alla posizione TTS"
|
||||
"Back to TTS Location": "Torna alla posizione TTS",
|
||||
"Metadata": "Metadati",
|
||||
"Image viewer": "Visualizzatore immagini",
|
||||
"Previous Image": "Immagine precedente",
|
||||
"Next Image": "Immagine successiva",
|
||||
"Zoomed": "Zoomato",
|
||||
"Zoom level": "Livello di zoom",
|
||||
"Table viewer": "Visualizzatore tabelle",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Impossibile connettersi a Readwise. Controlla la tua connessione di rete.",
|
||||
"Invalid Readwise access token": "Token di accesso Readwise non valido",
|
||||
"Disconnected from Readwise": "Disconnesso da Readwise",
|
||||
"Never": "Mai",
|
||||
"Readwise Settings": "Impostazioni Readwise",
|
||||
"Connected to Readwise": "Connesso a Readwise",
|
||||
"Last synced: {{time}}": "Ultima sincronizzazione: {{time}}",
|
||||
"Sync Enabled": "Sincronizzazione abilitata",
|
||||
"Disconnect": "Disconnetti",
|
||||
"Connect your Readwise account to sync highlights.": "Connetti il tuo account Readwise per sincronizzare le evidenziazioni.",
|
||||
"Get your access token at": "Ottieni il tuo token di accesso su",
|
||||
"Access Token": "Token di accesso",
|
||||
"Paste your Readwise access token": "Incolla il tuo token di accesso Readwise",
|
||||
"Config": "Configurazione",
|
||||
"Readwise Sync": "Sincronizzazione Readwise",
|
||||
"Push Highlights": "Invia evidenziazioni",
|
||||
"Highlights synced to Readwise": "Evidenziazioni sincronizzate su Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sincronizzazione Readwise fallita: nessuna connessione internet",
|
||||
"Readwise sync failed: {{error}}": "Sincronizzazione Readwise fallita: {{error}}",
|
||||
"System Screen Brightness": "Luminosità dello schermo del sistema",
|
||||
"Page:": "Pagina:",
|
||||
"Page: {{number}}": "Pagina: {{number}}",
|
||||
"Annotation page number": "Numero di pagina dell'annotazione"
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"Fit": "フィット",
|
||||
"Reset {{settings}}": "{{settings}}をリセット",
|
||||
"Reset Settings": "設定をリセット",
|
||||
"{{count}} pages left in chapter_other": "{{count}} ページ残り",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> ページ残り</1>",
|
||||
"Show Remaining Pages": "残りを表示",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "サイズ",
|
||||
"Cover": "カバー",
|
||||
"Contain": "含む",
|
||||
"{{number}} pages left in chapter": "章に{{number}}ページ残り",
|
||||
"{{number}} pages left in chapter": "<1>章に</1><0>{{number}}</0><1>ページ残り</1>",
|
||||
"Device": "デバイス",
|
||||
"E-Ink Mode": "E-Inkモード",
|
||||
"Highlight Colors": "ハイライトカラー",
|
||||
"Auto Screen Brightness": "自動画面の明るさ",
|
||||
"Pagination": "ページネーション",
|
||||
"Disable Double Tap": "ダブルタップを無効にする",
|
||||
"Tap to Paginate": "タップしてページを切り替える",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "RSVPを起動できません",
|
||||
"RSVP not supported for PDF": "PDFはRSVPに対応していません",
|
||||
"Select Chapter": "章を選択",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "コンテキスト",
|
||||
"Ready": "準備完了",
|
||||
"Chapter Progress": "章の進捗",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "著者",
|
||||
"Books": "書籍",
|
||||
"Groups": "グループ",
|
||||
"Back to TTS Location": "TTSの位置に戻る"
|
||||
"Back to TTS Location": "TTSの位置に戻る",
|
||||
"Metadata": "メタデータ",
|
||||
"Image viewer": "画像ビューア",
|
||||
"Previous Image": "前の画像",
|
||||
"Next Image": "次の画像",
|
||||
"Zoomed": "ズーム済み",
|
||||
"Zoom level": "ズームレベル",
|
||||
"Table viewer": "テーブルビューア",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise に接続できません。ネットワーク接続を確認してください。",
|
||||
"Invalid Readwise access token": "無効な Readwise アクセストークン",
|
||||
"Disconnected from Readwise": "Readwise から切断されました",
|
||||
"Never": "一度もなし",
|
||||
"Readwise Settings": "Readwise 設定",
|
||||
"Connected to Readwise": "Readwise に接続済み",
|
||||
"Last synced: {{time}}": "最終同期:{{time}}",
|
||||
"Sync Enabled": "同期有効",
|
||||
"Disconnect": "切断",
|
||||
"Connect your Readwise account to sync highlights.": "ハイライトを同期するには Readwise アカウントを接続してください。",
|
||||
"Get your access token at": "アクセストークンの取得先:",
|
||||
"Access Token": "アクセストークン",
|
||||
"Paste your Readwise access token": "Readwise アクセストークンを貼り付けてください",
|
||||
"Config": "設定",
|
||||
"Readwise Sync": "Readwise 同期",
|
||||
"Push Highlights": "ハイライトをプッシュ",
|
||||
"Highlights synced to Readwise": "ハイライトが Readwise に同期されました",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同期失敗:インターネット接続がありません",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同期失敗:{{error}}",
|
||||
"System Screen Brightness": "システムの画面の明るさ",
|
||||
"Page:": "ページ:",
|
||||
"Page: {{number}}": "ページ: {{number}}",
|
||||
"Annotation page number": "注釈のページ番号"
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"Fit": "맞춤",
|
||||
"Reset {{settings}}": "{{settings}} 재설정",
|
||||
"Reset Settings": "설정 재설정",
|
||||
"{{count}} pages left in chapter_other": "남은 페이지 {{count}}장",
|
||||
"{{count}} pages left in chapter_other": "<1>남은 페이지 </1><0>{{count}}</0><1>장</1>",
|
||||
"Show Remaining Pages": "남은 페이지 보기",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "크기",
|
||||
"Cover": "표지",
|
||||
"Contain": "포함",
|
||||
"{{number}} pages left in chapter": "남은 페이지 {{number}}장",
|
||||
"{{number}} pages left in chapter": "<1>남은 페이지 </1><0>{{number}}</0><1>장</1>",
|
||||
"Device": "장치",
|
||||
"E-Ink Mode": "E-Ink 모드",
|
||||
"Highlight Colors": "하이라이트 색상",
|
||||
"Auto Screen Brightness": "자동 화면 밝기",
|
||||
"Pagination": "페이지 매김",
|
||||
"Disable Double Tap": "더블 탭 비활성화",
|
||||
"Tap to Paginate": "탭하여 페이지 매김",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "RSVP를 시작할 수 없습니다",
|
||||
"RSVP not supported for PDF": "PDF는 RSVP를 지원하지 않습니다",
|
||||
"Select Chapter": "챕터 선택",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "문맥",
|
||||
"Ready": "준비됨",
|
||||
"Chapter Progress": "챕터 진행 상황",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "저자",
|
||||
"Books": "도서",
|
||||
"Groups": "그룹",
|
||||
"Back to TTS Location": "TTS 위치로 돌아가기"
|
||||
"Back to TTS Location": "TTS 위치로 돌아가기",
|
||||
"Metadata": "메타데이터",
|
||||
"Image viewer": "이미지 뷰어",
|
||||
"Previous Image": "이전 이미지",
|
||||
"Next Image": "다음 이미지",
|
||||
"Zoomed": "확대됨",
|
||||
"Zoom level": "확대 수준",
|
||||
"Table viewer": "테이블 뷰어",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise에 연결할 수 없습니다. 네트워크 연결을 확인해 주세요.",
|
||||
"Invalid Readwise access token": "유효하지 않은 Readwise 액세스 토큰입니다",
|
||||
"Disconnected from Readwise": "Readwise와 연결이 끊어졌습니다",
|
||||
"Never": "안 함",
|
||||
"Readwise Settings": "Readwise 설정",
|
||||
"Connected to Readwise": "Readwise에 연결됨",
|
||||
"Last synced: {{time}}": "마지막 동기화: {{time}}",
|
||||
"Sync Enabled": "동기화 활성화됨",
|
||||
"Disconnect": "연결 해제",
|
||||
"Connect your Readwise account to sync highlights.": "하이라이트를 동기화하려면 Readwise 계정렬 연결하세요.",
|
||||
"Get your access token at": "액세스 토큰 받기:",
|
||||
"Access Token": "액세스 토큰",
|
||||
"Paste your Readwise access token": "Readwise 액세스 토큰을 붙여넣으세요",
|
||||
"Config": "구성",
|
||||
"Readwise Sync": "Readwise 동기화",
|
||||
"Push Highlights": "하이라이트 푸시",
|
||||
"Highlights synced to Readwise": "하이라이트가 Readwise에 동기화되었습니다",
|
||||
"Readwise sync failed: no internet connection": "Readwise 동기화 실패: 인터넷 연결 없음",
|
||||
"Readwise sync failed: {{error}}": "Readwise 동기화 실패: {{error}}",
|
||||
"System Screen Brightness": "시스템 화면 밝기",
|
||||
"Page:": "페이지:",
|
||||
"Page: {{number}}": "페이지: {{number}}",
|
||||
"Annotation page number": "주석 페이지 번호"
|
||||
}
|
||||
|
||||
@@ -248,8 +248,8 @@
|
||||
"Add your notes here...": "Tambah nota anda di sini...",
|
||||
"Search notes and excerpts...": "Cari nota dan petikan...",
|
||||
"{{time}} min left in chapter": "{{time}} min lagi dalam bab",
|
||||
"{{number}} pages left in chapter": "{{number}} halaman lagi dalam bab",
|
||||
"{{count}} pages left in chapter_other": "{{count}} halaman lagi dalam bab",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> halaman lagi dalam bab</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> halaman lagi dalam bab</1>",
|
||||
"On {{current}} of {{total}} page": "Di halaman {{current}} daripada {{total}}",
|
||||
"Unable to open book": "Tidak dapat membuka buku",
|
||||
"Section Title": "Tajuk Seksyen",
|
||||
@@ -517,7 +517,6 @@
|
||||
"Paging Animation": "Animasi Halaman",
|
||||
"Device": "Peranti",
|
||||
"E-Ink Mode": "Mod E-Ink",
|
||||
"Auto Screen Brightness": "Kecerahan Skrin Auto",
|
||||
"Security": "Keselamatan",
|
||||
"Allow JavaScript": "Benarkan JavaScript",
|
||||
"Enable only if you trust the file.": "Aktifkan hanya jika anda mempercayai fail.",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "Tidak dapat memulakan RSVP",
|
||||
"RSVP not supported for PDF": "RSVP tidak disokong untuk PDF",
|
||||
"Select Chapter": "Pilih Bab",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Konteks",
|
||||
"Ready": "Sedia",
|
||||
"Chapter Progress": "Kemajuan Bab",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "Penulis",
|
||||
"Books": "Buku",
|
||||
"Groups": "Kumpulan",
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS"
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS",
|
||||
"Metadata": "Metadata",
|
||||
"Image viewer": "Penyorot imej",
|
||||
"Previous Image": "Imej Sebelumnya",
|
||||
"Next Image": "Imej Seterusnya",
|
||||
"Zoomed": "Dizum",
|
||||
"Zoom level": "Tahap zum",
|
||||
"Table viewer": "Penyorot jadual",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Tidak dapat menyambung ke Readwise. Sila periksa sambungan rangkaian anda.",
|
||||
"Invalid Readwise access token": "Token akses Readwise tidak sah",
|
||||
"Disconnected from Readwise": "Terputus sambungan daripada Readwise",
|
||||
"Never": "Tidak pernah",
|
||||
"Readwise Settings": "Tetapan Readwise",
|
||||
"Connected to Readwise": "Disambungkan ke Readwise",
|
||||
"Last synced: {{time}}": "Kali terakhir disinkronkan: {{time}}",
|
||||
"Sync Enabled": "Penyinkronan Didayakan",
|
||||
"Disconnect": "Putuskan sambungan",
|
||||
"Connect your Readwise account to sync highlights.": "Sambungkan akaun Readwise anda untuk menyinkronkan sorotan.",
|
||||
"Get your access token at": "Dapatkan token akses anda di",
|
||||
"Access Token": "Token Akses",
|
||||
"Paste your Readwise access token": "Tampal token akses Readwise anda",
|
||||
"Config": "Konfigurasi",
|
||||
"Readwise Sync": "Penyinkronan Readwise",
|
||||
"Push Highlights": "Hantar Sorotan",
|
||||
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
|
||||
"Readwise sync failed: no internet connection": "Penyinkronan Readwise gagal: tiada sambungan internet",
|
||||
"Readwise sync failed: {{error}}": "Penyinkronan Readwise gagal: {{error}}",
|
||||
"System Screen Brightness": "Kecerahan Skrin Sistem",
|
||||
"Page:": "Halaman:",
|
||||
"Page: {{number}}": "Halaman: {{number}}",
|
||||
"Annotation page number": "Nombor halaman anotasi"
|
||||
}
|
||||
|
||||
@@ -335,8 +335,8 @@
|
||||
"Fit": "Passend maken",
|
||||
"Reset {{settings}}": "Reset {{settings}}",
|
||||
"Reset Settings": "Instellingen resetten",
|
||||
"{{count}} pages left in chapter_one": "Nog {{count}} pagina in hoofdstuk",
|
||||
"{{count}} pages left in chapter_other": "Nog {{count}} pagina’s in hoofdstuk",
|
||||
"{{count}} pages left in chapter_one": "<1>Nog </1><0>{{count}}</0><1> pagina in hoofdstuk</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Nog </1><0>{{count}}</0><1> pagina’s in hoofdstuk</1>",
|
||||
"Show Remaining Pages": "Toon resterende pagina’s",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "Grootte",
|
||||
"Cover": "Omslag",
|
||||
"Contain": "Bevatten",
|
||||
"{{number}} pages left in chapter": "Nog {{number}} pagina’s in hoofdstuk",
|
||||
"{{number}} pages left in chapter": "<1>Nog </1><0>{{number}}</0><1> pagina’s in hoofdstuk</1>",
|
||||
"Device": "Apparaat",
|
||||
"E-Ink Mode": "E-Ink Modus",
|
||||
"Highlight Colors": "Markeerkleuren",
|
||||
"Auto Screen Brightness": "Automatische Schermhelderheid",
|
||||
"Pagination": "Paginering",
|
||||
"Disable Double Tap": "Schakel Dubbel Tikken Uit",
|
||||
"Tap to Paginate": "Tik om te Pagineren",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "Kan RSVP niet starten",
|
||||
"RSVP not supported for PDF": "RSVP niet ondersteund voor PDF",
|
||||
"Select Chapter": "Hoofdstuk selecteren",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Context",
|
||||
"Ready": "Klaar",
|
||||
"Chapter Progress": "Hoofdstukvoortgang",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "Auteurs",
|
||||
"Books": "Boeken",
|
||||
"Groups": "Groepen",
|
||||
"Back to TTS Location": "Terug naar TTS-locatie"
|
||||
"Back to TTS Location": "Terug naar TTS-locatie",
|
||||
"Metadata": "Metadata",
|
||||
"Image viewer": "Afbeeldingenviewer",
|
||||
"Previous Image": "Vorige afbeelding",
|
||||
"Next Image": "Volgende afbeelding",
|
||||
"Zoomed": "Gezoomd",
|
||||
"Zoom level": "Zoomniveau",
|
||||
"Table viewer": "Tabelviewer",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Kan geen verbinding maken met Readwise. Controleer uw netwerkverbinding.",
|
||||
"Invalid Readwise access token": "Ongeldige Readwise-toegangstoken",
|
||||
"Disconnected from Readwise": "Verbinding met Readwise verbroken",
|
||||
"Never": "Nooit",
|
||||
"Readwise Settings": "Readwise-instellingen",
|
||||
"Connected to Readwise": "Verbonden met Readwise",
|
||||
"Last synced: {{time}}": "Laatst gesynchroniseerd: {{time}}",
|
||||
"Sync Enabled": "Synchronisatie ingeschakeld",
|
||||
"Disconnect": "Verbinding verbreken",
|
||||
"Connect your Readwise account to sync highlights.": "Verbind uw Readwise-account om markeringen te synchroniseren.",
|
||||
"Get your access token at": "Verkrijg uw toegangstoken op",
|
||||
"Access Token": "Toegangstoken",
|
||||
"Paste your Readwise access token": "Plak uw Readwise-toegangstoken",
|
||||
"Config": "Configuratie",
|
||||
"Readwise Sync": "Readwise-synchronisatie",
|
||||
"Push Highlights": "Markeringen verzenden",
|
||||
"Highlights synced to Readwise": "Markeringen gesynchroniseerd met Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise-synchronisatie mislukt: geen internetverbinding",
|
||||
"Readwise sync failed: {{error}}": "Readwise-synchronisatie mislukt: {{error}}",
|
||||
"System Screen Brightness": "Systeemhelderheid van het scherm",
|
||||
"Page:": "Pagina:",
|
||||
"Page: {{number}}": "Pagina: {{number}}",
|
||||
"Annotation page number": "Annotatiepaginanummer"
|
||||
}
|
||||
|
||||
@@ -341,10 +341,10 @@
|
||||
"Fit": "Pasuje",
|
||||
"Reset {{settings}}": "Zresetuj {{settings}}",
|
||||
"Reset Settings": "Zresetuj ustawienia",
|
||||
"{{count}} pages left in chapter_one": "Pozostała {{count}} strona w rozdziale",
|
||||
"{{count}} pages left in chapter_few": "Pozostały {{count}} strony w rozdziale",
|
||||
"{{count}} pages left in chapter_many": "Pozostało {{count}} stron w rozdziale",
|
||||
"{{count}} pages left in chapter_other": "Pozostało {{count}} stron w rozdziale",
|
||||
"{{count}} pages left in chapter_one": "<1>Pozostała </1><0>{{count}}</0><1> strona w rozdziale</1>",
|
||||
"{{count}} pages left in chapter_few": "<1>Pozostały </1><0>{{count}}</0><1> strony w rozdziale</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>Pozostało </1><0>{{count}}</0><1> stron w rozdziale</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Pozostało </1><0>{{count}}</0><1> stron w rozdziale</1>",
|
||||
"Show Remaining Pages": "Pokaż pozostałe strony",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -607,11 +607,10 @@
|
||||
"Size": "Rozmiar",
|
||||
"Cover": "Okładka",
|
||||
"Contain": "Zawierać",
|
||||
"{{number}} pages left in chapter": "Pozostało {{number}} stron w rozdziale",
|
||||
"{{number}} pages left in chapter": "<1>Pozostało </1><0>{{number}}</0><1> stron w rozdziale</1>",
|
||||
"Device": "Urządzenie",
|
||||
"E-Ink Mode": "Tryb E-Ink",
|
||||
"Highlight Colors": "Kolory wyróżnień",
|
||||
"Auto Screen Brightness": "Automatyczna jasność ekranu",
|
||||
"Pagination": "Stronicowanie",
|
||||
"Disable Double Tap": "Wyłącz podwójne dotknięcie",
|
||||
"Tap to Paginate": "Dotknij, aby stronicować",
|
||||
@@ -1000,7 +999,6 @@
|
||||
"Unable to start RSVP": "Nie można uruchomić RSVP",
|
||||
"RSVP not supported for PDF": "RSVP nie jest obsługiwane dla PDF",
|
||||
"Select Chapter": "Wybierz rozdział",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Kontekst",
|
||||
"Ready": "Gotowy",
|
||||
"Chapter Progress": "Postęp rozdziału",
|
||||
@@ -1055,5 +1053,35 @@
|
||||
"Authors": "Autorzy",
|
||||
"Books": "Książki",
|
||||
"Groups": "Grupy",
|
||||
"Back to TTS Location": "Powrót do lokalizacji TTS"
|
||||
"Back to TTS Location": "Powrót do lokalizacji TTS",
|
||||
"Metadata": "Metadane",
|
||||
"Image viewer": "Przeglądarka obrazów",
|
||||
"Previous Image": "Poprzedni obraz",
|
||||
"Next Image": "Następny obraz",
|
||||
"Zoomed": "Powiększone",
|
||||
"Zoom level": "Poziom powiększenia",
|
||||
"Table viewer": "Przeglądarka tabel",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Nie można połączyć się z Readwise. Sprawdź połączenie sieciowe.",
|
||||
"Invalid Readwise access token": "Nieprawidłowy token dostępu Readwise",
|
||||
"Disconnected from Readwise": "Rozłączono z Readwise",
|
||||
"Never": "Nigdy",
|
||||
"Readwise Settings": "Ustawienia Readwise",
|
||||
"Connected to Readwise": "Połączono z Readwise",
|
||||
"Last synced: {{time}}": "Ostatnia synchronizacja: {{time}}",
|
||||
"Sync Enabled": "Synchronizacja włączona",
|
||||
"Disconnect": "Rozłącz",
|
||||
"Connect your Readwise account to sync highlights.": "Połącz swoje konto Readwise, aby synchronizować wyróżnienia.",
|
||||
"Get your access token at": "Pobierz token dostępu pod adresem",
|
||||
"Access Token": "Token dostępu",
|
||||
"Paste your Readwise access token": "Wklej swój token dostępu Readwise",
|
||||
"Config": "Konfiguracja",
|
||||
"Readwise Sync": "Synchronizacja Readwise",
|
||||
"Push Highlights": "Wyślij wyróżnienia",
|
||||
"Highlights synced to Readwise": "Wyróżnienia zsynchronizowane z Readwise",
|
||||
"Readwise sync failed: no internet connection": "Synchronizacja Readwise nie powiodła się: brak połączenia z Internetem",
|
||||
"Readwise sync failed: {{error}}": "Synchronizacja Readwise nie powiodła się: {{error}}",
|
||||
"System Screen Brightness": "Jasność ekranu systemowego",
|
||||
"Page:": "Strona:",
|
||||
"Page: {{number}}": "Strona: {{number}}",
|
||||
"Annotation page number": "Numer strony adnotacji"
|
||||
}
|
||||
|
||||
@@ -338,9 +338,9 @@
|
||||
"Fit": "Encaixar",
|
||||
"Reset {{settings}}": "Redefinir {{settings}}",
|
||||
"Reset Settings": "Redefinir Configurações",
|
||||
"{{count}} pages left in chapter_one": "Falta {{count}} página neste capítulo",
|
||||
"{{count}} pages left in chapter_many": "Faltam {{count}} páginas neste capítulo",
|
||||
"{{count}} pages left in chapter_other": "Faltam {{count}} páginas neste capítulo",
|
||||
"{{count}} pages left in chapter_one": "<1>Falta </1><0>{{count}}</0><1> página neste capítulo</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>Faltam </1><0>{{count}}</0><1> páginas neste capítulo</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Faltam </1><0>{{count}}</0><1> páginas neste capítulo</1>",
|
||||
"Show Remaining Pages": "Mostrar páginas restantes",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -603,11 +603,10 @@
|
||||
"Size": "Tamanho",
|
||||
"Cover": "Capa",
|
||||
"Contain": "Contém",
|
||||
"{{number}} pages left in chapter": "Faltam {{number}} páginas neste capítulo",
|
||||
"{{number}} pages left in chapter": "<1>Faltam </1><0>{{number}}</0><1> páginas neste capítulo</1>",
|
||||
"Device": "Dispositivo",
|
||||
"E-Ink Mode": "Modo E-Ink",
|
||||
"Highlight Colors": "Cores de Destaque",
|
||||
"Auto Screen Brightness": "Brilho Automático da Tela",
|
||||
"Pagination": "Paginação",
|
||||
"Disable Double Tap": "Desativar Toque Duplo",
|
||||
"Tap to Paginate": "Toque para Paginar",
|
||||
@@ -988,7 +987,6 @@
|
||||
"Unable to start RSVP": "Não foi possível iniciar o RSVP",
|
||||
"RSVP not supported for PDF": "RSVP não suportado para PDF",
|
||||
"Select Chapter": "Selecionar capítulo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contexto",
|
||||
"Ready": "Pronto",
|
||||
"Chapter Progress": "Progresso do capítulo",
|
||||
@@ -1043,5 +1041,35 @@
|
||||
"Authors": "Autores",
|
||||
"Books": "Livros",
|
||||
"Groups": "Grupos",
|
||||
"Back to TTS Location": "Voltar para a localização do TTS"
|
||||
"Back to TTS Location": "Voltar para a localização do TTS",
|
||||
"Metadata": "Metadados",
|
||||
"Image viewer": "Visualizador de imagens",
|
||||
"Previous Image": "Imagem anterior",
|
||||
"Next Image": "Próxima imagem",
|
||||
"Zoomed": "Com zoom",
|
||||
"Zoom level": "Nível de zoom",
|
||||
"Table viewer": "Visualizador de tabelas",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Não foi possível conectar ao Readwise. Por favor, verifique sua conexão de rede.",
|
||||
"Invalid Readwise access token": "Token de acesso do Readwise inválido",
|
||||
"Disconnected from Readwise": "Desconectado do Readwise",
|
||||
"Never": "Nunca",
|
||||
"Readwise Settings": "Configurações do Readwise",
|
||||
"Connected to Readwise": "Conectado ao Readwise",
|
||||
"Last synced: {{time}}": "Última sincronização: {{time}}",
|
||||
"Sync Enabled": "Sincronização ativada",
|
||||
"Disconnect": "Desconectar",
|
||||
"Connect your Readwise account to sync highlights.": "Conecte sua conta do Readwise para sincronizar os destaques.",
|
||||
"Get your access token at": "Obtenha seu token de acesso em",
|
||||
"Access Token": "Token de acesso",
|
||||
"Paste your Readwise access token": "Cole seu token de acesso do Readwise",
|
||||
"Config": "Configuração",
|
||||
"Readwise Sync": "Sincronização com o Readwise",
|
||||
"Push Highlights": "Enviar destaques",
|
||||
"Highlights synced to Readwise": "Destaques sincronizados com o Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sincronização com o Readwise falhou: sem conexão com a internet",
|
||||
"Readwise sync failed: {{error}}": "Sincronização com o Readwise falhou: {{error}}",
|
||||
"System Screen Brightness": "Brilho da tela do sistema",
|
||||
"Page:": "Página:",
|
||||
"Page: {{number}}": "Página: {{number}}",
|
||||
"Annotation page number": "Número de página da anotação"
|
||||
}
|
||||
|
||||
@@ -341,10 +341,10 @@
|
||||
"Fit": "Подогнать",
|
||||
"Reset {{settings}}": "Сбросить {{settings}}",
|
||||
"Reset Settings": "Сбросить настройки",
|
||||
"{{count}} pages left in chapter_one": "Осталась {{count}} страница в главе",
|
||||
"{{count}} pages left in chapter_few": "Осталось {{count}} страницы в главе",
|
||||
"{{count}} pages left in chapter_many": "Осталось {{count}} страниц в главе",
|
||||
"{{count}} pages left in chapter_other": "Осталось {{count}} страниц в главе",
|
||||
"{{count}} pages left in chapter_one": "<1>Осталась </1><0>{{count}}</0><1> страница в главе</1>",
|
||||
"{{count}} pages left in chapter_few": "<1>Осталось </1><0>{{count}}</0><1> страницы в главе</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>Осталось </1><0>{{count}}</0><1> страниц в главе</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Осталось </1><0>{{count}}</0><1> страниц в главе</1>",
|
||||
"Show Remaining Pages": "Показать оставшиеся страницы",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -607,11 +607,10 @@
|
||||
"Size": "Размер",
|
||||
"Cover": "Обложка",
|
||||
"Contain": "Содержать",
|
||||
"{{number}} pages left in chapter": "Осталось {{number}} страниц в главе",
|
||||
"{{number}} pages left in chapter": "<1>Осталось </1><0>{{number}}</0><1> страниц в главе</1>",
|
||||
"Device": "Устройство",
|
||||
"E-Ink Mode": "E-Ink режим",
|
||||
"Highlight Colors": "Цвета выделения",
|
||||
"Auto Screen Brightness": "Автояркость экрана",
|
||||
"Pagination": "Пагинация",
|
||||
"Disable Double Tap": "Отключить двойной тап",
|
||||
"Tap to Paginate": "Тапните, чтобы разбить на страницы",
|
||||
@@ -1000,7 +999,6 @@
|
||||
"Unable to start RSVP": "Не удалось запустить RSVP",
|
||||
"RSVP not supported for PDF": "RSVP не поддерживается для PDF",
|
||||
"Select Chapter": "Выбрать главу",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Контекст",
|
||||
"Ready": "Готово",
|
||||
"Chapter Progress": "Прогресс главы",
|
||||
@@ -1055,5 +1053,35 @@
|
||||
"Authors": "Авторы",
|
||||
"Books": "Книги",
|
||||
"Groups": "Группы",
|
||||
"Back to TTS Location": "Вернуться к местоположению TTS"
|
||||
"Back to TTS Location": "Вернуться к местоположению TTS",
|
||||
"Metadata": "Метаданные",
|
||||
"Image viewer": "Просмотрщик изображений",
|
||||
"Previous Image": "Предыдущее изображение",
|
||||
"Next Image": "Следующее изображение",
|
||||
"Zoomed": "Увеличено",
|
||||
"Zoom level": "Уровень масштаба",
|
||||
"Table viewer": "Просмотрщик таблиц",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Не удалось подключиться к Readwise. Пожалуйста, проверьте ваше сетевое соединение.",
|
||||
"Invalid Readwise access token": "Недействительный токен доступа Readwise",
|
||||
"Disconnected from Readwise": "Отключено от Readwise",
|
||||
"Never": "Никогда",
|
||||
"Readwise Settings": "Настройки Readwise",
|
||||
"Connected to Readwise": "Подключено к Readwise",
|
||||
"Last synced: {{time}}": "Последняя синхронизация: {{time}}",
|
||||
"Sync Enabled": "Синхронизация включена",
|
||||
"Disconnect": "Отключить",
|
||||
"Connect your Readwise account to sync highlights.": "Подключите ваш аккаунт Readwise для синхронизации выделений.",
|
||||
"Get your access token at": "Получите ваш токен доступа на",
|
||||
"Access Token": "Токен доступа",
|
||||
"Paste your Readwise access token": "Вставьте ваш токен доступа Readwise",
|
||||
"Config": "Конфигурация",
|
||||
"Readwise Sync": "Синхронизация Readwise",
|
||||
"Push Highlights": "Отправить выделения",
|
||||
"Highlights synced to Readwise": "Выделения синхронизированы с Readwise",
|
||||
"Readwise sync failed: no internet connection": "Синхронизация Readwise не удалась: нет интернет-соединения",
|
||||
"Readwise sync failed: {{error}}": "Синхронизация Readwise не удалась: {{error}}",
|
||||
"System Screen Brightness": "Систেমная яркость экрана",
|
||||
"Page:": "Страница:",
|
||||
"Page: {{number}}": "Страница: {{number}}",
|
||||
"Annotation page number": "Номер страницы аннотации"
|
||||
}
|
||||
|
||||
@@ -186,8 +186,8 @@
|
||||
"Add your notes here...": "මෙහි ඔබේ සටහන් එක් කරන්න...",
|
||||
"Search notes and excerpts...": "සටහන් සහ උදාහරණ සොයන්න...",
|
||||
"{{time}} min left in chapter": "පරිච්ඡේදයේ මිනිත්තු {{time}} ක් ඉතිරි",
|
||||
"{{count}} pages left in chapter_one": "පරිච්ඡේදයේ පිටුව 1 ක් ඉතිරි",
|
||||
"{{count}} pages left in chapter_other": "පරිච්ඡේදයේ පිටු {{count}} ක් ඉතිරි",
|
||||
"{{count}} pages left in chapter_one": "<1>පරිච්ඡේදයේ පිටුව 1 ක් ඉතිරි</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>පරිච්ඡේදයේ පිටු </1><0>{{count}}</0><1> ක් ඉතිරි</1>",
|
||||
"Theme Mode": "තේමා ආකාරය",
|
||||
"Invert Image In Dark Mode": "අඳුරු ආකාරයේදී රූපය පරිවර්තනය කරන්න",
|
||||
"Override Book Color": "පොතේ වර්ණය අභිබවන්න",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "ප්රමාණය",
|
||||
"Cover": "ආවරණය",
|
||||
"Contain": "අඩංගු වන්න",
|
||||
"{{number}} pages left in chapter": "පරිච්ඡේදයේ පිටු {{number}} ඉතිරිව ඇත",
|
||||
"{{number}} pages left in chapter": "<1>පරිච්ඡේදයේ පිටු </1><0>{{number}}</0><1> ඉතිරිව ඇත</1>",
|
||||
"Device": "උපකරණය",
|
||||
"E-Ink Mode": "E-Ink ආකාරය",
|
||||
"Highlight Colors": "ඉස්මතු වර්ණ",
|
||||
"Auto Screen Brightness": "ස්වයංක්රීය තිර දිදුලනුම",
|
||||
"Pagination": "පිටුගත කිරීම",
|
||||
"Disable Double Tap": "දෙවරක් ටැප් කිරීම අබල කරන්න",
|
||||
"Tap to Paginate": "පිටුගත කිරීමට ටැප් කරන්න",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "RSVP ආරම්භ කිරීමට නොහැක",
|
||||
"RSVP not supported for PDF": "PDF සඳහා RSVP සහාය නොදක්වයි",
|
||||
"Select Chapter": "පරිච්ඡේදය තෝරන්න",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "සන්දර්භය",
|
||||
"Ready": "සූදානම්",
|
||||
"Chapter Progress": "පරිච්ඡේදයේ ප්රගතිය",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "කතුවරුන්",
|
||||
"Books": "පොත්",
|
||||
"Groups": "කණ්ඩායම්",
|
||||
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න"
|
||||
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න",
|
||||
"Metadata": "මෙටාඩේටා",
|
||||
"Image viewer": "රූප නරඹන්නා",
|
||||
"Previous Image": "පූර්ව රූපය",
|
||||
"Next Image": "මීළඟ රූපය",
|
||||
"Zoomed": "විශාලනය කරන ලදී",
|
||||
"Zoom level": "විශාලන මට්ටම",
|
||||
"Table viewer": "වගු නරඹන්නා",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise වෙත සම්බන්ධ වීමට නොහැක. කරුණාකර ඔබගේ ජාල සම්බන්ධතාවය පරීක්ෂා කරන්න.",
|
||||
"Invalid Readwise access token": "අවලංගු Readwise ප්රවේශ ටෝකනය",
|
||||
"Disconnected from Readwise": "Readwise වෙතින් විසන්ධි විය",
|
||||
"Never": "කවදාවත් නැහැ",
|
||||
"Readwise Settings": "Readwise සැකසුම්",
|
||||
"Connected to Readwise": "Readwise වෙත සම්බන්ධ විය",
|
||||
"Last synced: {{time}}": "අවසානයට සමමුහුර්ත කළේ: {{time}}",
|
||||
"Sync Enabled": "සමමුහුර්ත කිරීම සබල කර ඇත",
|
||||
"Disconnect": "විසන්ධි කරන්න",
|
||||
"Connect your Readwise account to sync highlights.": "විශේෂ අවස්ථා සමමුහුර්ත කිරීමට ඔබගේ Readwise ගිණුම සම්බන්ධ කරන්න.",
|
||||
"Get your access token at": "ඔබගේ ප්රවේශ ටෝකනය මෙතැනින් ලබා ගන්න",
|
||||
"Access Token": "ප්රවේශ ටෝකනය",
|
||||
"Paste your Readwise access token": "ඔබගේ Readwise ප්රවේශ ටෝකනය මෙහි අලවන්න",
|
||||
"Config": "වින්යාසය",
|
||||
"Readwise Sync": "Readwise සමමුහුර්තකරණය",
|
||||
"Push Highlights": "විශේෂ අවස්ථා යොමු කරන්න",
|
||||
"Highlights synced to Readwise": "විශේෂ අවස්ථා Readwise සමඟ සමමුහුර්ත විය",
|
||||
"Readwise sync failed: no internet connection": "Readwise සමමුහුර්තකරණය අසාර්ථක විය: අන්තර්ජාල සම්බන්ධතාවයක් නොමැත",
|
||||
"Readwise sync failed: {{error}}": "Readwise සමමුහුර්තකරණය අසාර්ථක විය: {{error}}",
|
||||
"System Screen Brightness": "පද්ධති තිර දීප්තිය",
|
||||
"Page:": "පිටුව:",
|
||||
"Page: {{number}}": "පිටුව: {{number}}",
|
||||
"Annotation page number": "විවරණ පිටු අංකය"
|
||||
}
|
||||
|
||||
@@ -234,8 +234,8 @@
|
||||
"Add your notes here...": "Lägg till dina anteckningar här...",
|
||||
"Search notes and excerpts...": "Sök anteckningar och utdrag...",
|
||||
"{{time}} min left in chapter": "{{time}} min kvar i kapitlet",
|
||||
"{{count}} pages left in chapter_one": "{{count}} sida kvar i kapitlet",
|
||||
"{{count}} pages left in chapter_other": "{{count}} sidor kvar i kapitlet",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> sida kvar i kapitlet</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> sidor kvar i kapitlet</1>",
|
||||
"On {{current}} of {{total}} page": "På {{current}} av {{total}} sidor",
|
||||
"Section Title": "Sektionstitel",
|
||||
"More Info": "Mer info",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "Storlek",
|
||||
"Cover": "Omslag",
|
||||
"Contain": "Innehålla",
|
||||
"{{number}} pages left in chapter": "{{number}} sidor kvar i kapitlet",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> sidor kvar i kapitlet</1>",
|
||||
"Device": "Enhet",
|
||||
"E-Ink Mode": "E-Ink-läge",
|
||||
"Highlight Colors": "Markeringsfärger",
|
||||
"Auto Screen Brightness": "Auto-skärmens ljusstyrka",
|
||||
"Pagination": "Pagination",
|
||||
"Disable Double Tap": "Inaktivera dubbeltryck",
|
||||
"Tap to Paginate": "Tryck för att bläddra",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "Kunde inte starta RSVP",
|
||||
"RSVP not supported for PDF": "RSVP stöds inte för PDF",
|
||||
"Select Chapter": "Välj kapitel",
|
||||
"{{number}} WPM": "{{number}} ord/min",
|
||||
"Context": "Sammanhang",
|
||||
"Ready": "Klar",
|
||||
"Chapter Progress": "Kapitelframsteg",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "Författare",
|
||||
"Books": "Böcker",
|
||||
"Groups": "Grupper",
|
||||
"Back to TTS Location": "Tillbaka till TTS-plats"
|
||||
"Back to TTS Location": "Tillbaka till TTS-plats",
|
||||
"Metadata": "Metadata",
|
||||
"Image viewer": "Bildvisare",
|
||||
"Previous Image": "Föregående bild",
|
||||
"Next Image": "Nästa bild",
|
||||
"Zoomed": "Zoomad",
|
||||
"Zoom level": "Zoomnivå",
|
||||
"Table viewer": "Tabellvisare",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Kunde inte ansluta till Readwise. Kontrollera din nätverksanslutning.",
|
||||
"Invalid Readwise access token": "Ogiltig Readwise-åtkomsttoken",
|
||||
"Disconnected from Readwise": "Frånkopplad från Readwise",
|
||||
"Never": "Aldrig",
|
||||
"Readwise Settings": "Readwise-inställningar",
|
||||
"Connected to Readwise": "Ansluten till Readwise",
|
||||
"Last synced: {{time}}": "Senast synkroniserad: {{time}}",
|
||||
"Sync Enabled": "Synkronisering aktiverad",
|
||||
"Disconnect": "Koppla från",
|
||||
"Connect your Readwise account to sync highlights.": "Anslut ditt Readwise-konto för att synkronisera markeringar.",
|
||||
"Get your access token at": "Hämta din åtkomsttoken på",
|
||||
"Access Token": "Åtkomsttoken",
|
||||
"Paste your Readwise access token": "Klistra in din Readwise-åtkomsttoken",
|
||||
"Config": "Konfiguration",
|
||||
"Readwise Sync": "Readwise-synkronisering",
|
||||
"Push Highlights": "Skicka markeringar",
|
||||
"Highlights synced to Readwise": "Markeringar synkroniserade till Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise-synkronisering misslyckades: ingen internetanslutning",
|
||||
"Readwise sync failed: {{error}}": "Readwise-synkronisering misslyckades: {{error}}",
|
||||
"System Screen Brightness": "Systemets skärmljusstyrka",
|
||||
"Page:": "Sida:",
|
||||
"Page: {{number}}": "Sida: {{number}}",
|
||||
"Annotation page number": "Annoteringssidnummer"
|
||||
}
|
||||
|
||||
@@ -186,8 +186,8 @@
|
||||
"Add your notes here...": "உங்கள் குறிப்புகளை இங்கே சேர்க்கவும்...",
|
||||
"Search notes and excerpts...": "குறிப்புகள் மற்றும் உதாரணங்களைத் தேடவும்...",
|
||||
"{{time}} min left in chapter": "அத்தியாயத்தில் {{time}} நிமிடங்கள் மீதமுள்ளன",
|
||||
"{{count}} pages left in chapter_one": "அத்தியாயத்தில் 1 பக்கம் மீதமுள்ளது",
|
||||
"{{count}} pages left in chapter_other": "அத்தியாயத்தில் {{count}} பக்கங்கள் மீதமுள்ளன",
|
||||
"{{count}} pages left in chapter_one": "<1>அத்தியாயத்தில் 1 பக்கம் மீதமுள்ளது</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>அத்தியாயத்தில் </1><0>{{count}}</0><1> பக்கங்கள் மீதமுள்ளன</1>",
|
||||
"Theme Mode": "தீம் பயன்முறை",
|
||||
"Invert Image In Dark Mode": "இருட்டு பயன்முறையில் படத்தை தலைகீழாக மாற்றவும்",
|
||||
"Override Book Color": "புத்தக நிறத்தை மேலெழுதவும்",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "அளவு",
|
||||
"Cover": "மூடு",
|
||||
"Contain": "அடங்கும்",
|
||||
"{{number}} pages left in chapter": "அத்தியாயத்தில் {{number}} பக்கங்கள் மீதமுள்ளன",
|
||||
"{{number}} pages left in chapter": "<1>அத்தியாயத்தில் </1><0>{{number}}</0><1> பக்கங்கள் மீதமுள்ளன</1>",
|
||||
"Device": "சாதனம்",
|
||||
"E-Ink Mode": "E-Ink முறை",
|
||||
"Highlight Colors": "முத்திரை நிறங்கள்",
|
||||
"Auto Screen Brightness": "தானியங்கி திரை பிரகாசம்",
|
||||
"Pagination": "பக்கமிடுதல்",
|
||||
"Disable Double Tap": "இரட்டை தொடுதலை முடக்கு",
|
||||
"Tap to Paginate": "பக்கம் மாற்ற தொடுங்கள்",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "RSVP-ஐத் தொடங்க முடியவில்லை",
|
||||
"RSVP not supported for PDF": "PDF-க்கு RSVP ஆதரவு இல்லை",
|
||||
"Select Chapter": "அத்தியாயத்தைத் தேர்ந்தெடு",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "சூழல்",
|
||||
"Ready": "தயார்",
|
||||
"Chapter Progress": "அத்தியாய முன்னேற்றம்",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "ஆசிரியர்கள்",
|
||||
"Books": "புத்தகங்கள்",
|
||||
"Groups": "குழுக்கள்",
|
||||
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு"
|
||||
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு",
|
||||
"Metadata": "மெட்டாடேட்டா",
|
||||
"Image viewer": "படக் காட்சிப்பவர்",
|
||||
"Previous Image": "முந்தைய படம்",
|
||||
"Next Image": "அடுத்த படம்",
|
||||
"Zoomed": "பெரிதாக்கப்பட்டது",
|
||||
"Zoom level": "பெரிதாக்கும் நிலை",
|
||||
"Table viewer": "அட்டவணை காட்சிப்பவர்",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise உடன் இணைக்க முடியவில்லை. உங்கள் நெட்வொர்க் இணைப்பைச் சரிபார்க்கவும்.",
|
||||
"Invalid Readwise access token": "தவறான Readwise அணுகல் டோக்கன்",
|
||||
"Disconnected from Readwise": "Readwise இலிருந்து துண்டிக்கப்பட்டது",
|
||||
"Never": "ஒருபோதும் இல்லை",
|
||||
"Readwise Settings": "Readwise அமைப்புகள்",
|
||||
"Connected to Readwise": "Readwise உடன் இணைக்கப்பட்டது",
|
||||
"Last synced: {{time}}": "கடைசியாக ஒத்திசைக்கப்பட்டது: {{time}}",
|
||||
"Sync Enabled": "ஒத்திசைவு இயக்கப்பட்டது",
|
||||
"Disconnect": "துண்டி",
|
||||
"Connect your Readwise account to sync highlights.": "சிறப்பம்சங்களை ஒத்திசைக்க உங்கள் Readwise கணக்கை இணைக்கவும்.",
|
||||
"Get your access token at": "உங்கள் அணுகல் டோக்கனை இங்கே பெறவும்",
|
||||
"Access Token": "அணுகல் டோக்கன்",
|
||||
"Paste your Readwise access token": "உங்கள் Readwise அணுகல் டோக்கனை ஒட்டவும்",
|
||||
"Config": "கட்டமைப்பு",
|
||||
"Readwise Sync": "Readwise ஒத்திசைவு",
|
||||
"Push Highlights": "சிறப்பம்சங்களை அனுப்பு",
|
||||
"Highlights synced to Readwise": "சிறப்பம்சங்கள் Readwise உடன் ஒத்திசைக்கப்பட்டன",
|
||||
"Readwise sync failed: no internet connection": "Readwise ஒத்திசைவு தோல்வியடைந்தது: இணைய இணைப்பு இல்லை",
|
||||
"Readwise sync failed: {{error}}": "Readwise ஒத்திசைவு தோல்வியடைந்தது: {{error}}",
|
||||
"System Screen Brightness": "கணினித் திரை பிரகாசம்",
|
||||
"Page:": "பக்கம்:",
|
||||
"Page: {{number}}": "பக்கம்: {{number}}",
|
||||
"Annotation page number": "சிறுகுறிப்புப் பக்க எண்"
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
"Add your notes here...": "เพิ่มบันทึกที่นี่...",
|
||||
"Search notes and excerpts...": "ค้นหาบันทึกและข้อความ...",
|
||||
"{{time}} min left in chapter": "เหลือ {{time}} นาทีในบท",
|
||||
"{{count}} pages left in chapter_other": "เหลือ {{count}} หน้าในบท",
|
||||
"{{count}} pages left in chapter_other": "<1>เหลือ </1><0>{{count}}</0><1> หน้าในบท</1>",
|
||||
"Theme Mode": "โหมดธีม",
|
||||
"Invert Image In Dark Mode": "กลับสีรูปในโหมดมืด",
|
||||
"Override Book Color": "เขียนทับสีหนังสือ",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "ขนาด",
|
||||
"Cover": "ปก",
|
||||
"Contain": "บรรจุ",
|
||||
"{{number}} pages left in chapter": "เหลือ {{number}} หน้าในบท",
|
||||
"{{number}} pages left in chapter": "<1>เหลือ </1><0>{{number}}</0><1> หน้าในบท</1>",
|
||||
"Device": "อุปกรณ์",
|
||||
"E-Ink Mode": "โหมด E-Ink",
|
||||
"Highlight Colors": "สีไฮไลต์",
|
||||
"Auto Screen Brightness": "ความสว่างหน้าจออัตโนมัติ",
|
||||
"Pagination": "การแบ่งหน้า",
|
||||
"Disable Double Tap": "ปิดการแตะสองครั้ง",
|
||||
"Tap to Paginate": "แตะเพื่อแบ่งหน้า",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "ไม่สามารถเริ่ม RSVP ได้",
|
||||
"RSVP not supported for PDF": "ไม่รองรับ RSVP สำหรับ PDF",
|
||||
"Select Chapter": "เลือกบท",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "บริบท",
|
||||
"Ready": "พร้อม",
|
||||
"Chapter Progress": "ความคืบหน้าของบท",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "ผู้เขียน",
|
||||
"Books": "หนังสือ",
|
||||
"Groups": "กลุ่ม",
|
||||
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS"
|
||||
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS",
|
||||
"Metadata": "เมทาดาตา",
|
||||
"Image viewer": "ตัวดูรูปภาพ",
|
||||
"Previous Image": "รูปภาพก่อนหน้า",
|
||||
"Next Image": "รูปภาพถัดไป",
|
||||
"Zoomed": "ซูมแล้ว",
|
||||
"Zoom level": "ระดับการซูม",
|
||||
"Table viewer": "ตัวดูตาราง",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "ไม่สามารถเชื่อมต่อกับ Readwise ได้ โปรดตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณ",
|
||||
"Invalid Readwise access token": "โทเคนการเข้าถึง Readwise ไม่ถูกต้อง",
|
||||
"Disconnected from Readwise": "ยกเลิกการเชื่อมต่อกับ Readwise แล้ว",
|
||||
"Never": "ไม่เคย",
|
||||
"Readwise Settings": "การตั้งค่า Readwise",
|
||||
"Connected to Readwise": "เชื่อมต่อกับ Readwise แล้ว",
|
||||
"Last synced: {{time}}": "ซิงค์ล่าสุดเมื่อ: {{time}}",
|
||||
"Sync Enabled": "เปิดใช้งานการซิงค์",
|
||||
"Disconnect": "ยกเลิกการเชื่อมต่อ",
|
||||
"Connect your Readwise account to sync highlights.": "เชื่อมต่อบัญชี Readwise ของคุณเพื่อซิงค์ไฮไลต์",
|
||||
"Get your access token at": "รับโทเคนการเข้าถึงของคุณได้ที่",
|
||||
"Access Token": "โทเคนการเข้าถึง",
|
||||
"Paste your Readwise access token": "วางโทเคนการเข้าถึง Readwise ของคุณ",
|
||||
"Config": "การกำหนดค่า",
|
||||
"Readwise Sync": "การซิงค์ Readwise",
|
||||
"Push Highlights": "พุชไฮไลต์",
|
||||
"Highlights synced to Readwise": "ซิงค์ไฮไลต์ไปยัง Readwise แล้ว",
|
||||
"Readwise sync failed: no internet connection": "การซิงค์ Readwise ล้มเหลว: ไม่มีการเชื่อมต่ออินเทอร์เน็ต",
|
||||
"Readwise sync failed: {{error}}": "การซิงค์ Readwise ล้มเหลว: {{error}}",
|
||||
"System Screen Brightness": "ความสว่างหน้าจอระบบ",
|
||||
"Page:": "หน้า:",
|
||||
"Page: {{number}}": "หน้า: {{number}}",
|
||||
"Annotation page number": "หมายเลขหน้าคำอธิบายประกอบ"
|
||||
}
|
||||
|
||||
@@ -335,8 +335,8 @@
|
||||
"Fit": "Uygun",
|
||||
"Reset {{settings}}": "{{settings}}'i Sıfırla",
|
||||
"Reset Settings": "Ayarları Sıfırla",
|
||||
"{{count}} pages left in chapter_one": "Bu bölümde {{count}} sayfa kaldı",
|
||||
"{{count}} pages left in chapter_other": "Bu bölümde {{count}} sayfa kaldı",
|
||||
"{{count}} pages left in chapter_one": "<1>Bu bölümde </1><0>{{count}}</0><1> sayfa kaldı</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Bu bölümde </1><0>{{count}}</0><1> sayfa kaldı</1>",
|
||||
"Show Remaining Pages": "Kalan sayfaları göster",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "Boyut",
|
||||
"Cover": "Kapak",
|
||||
"Contain": "İçerir",
|
||||
"{{number}} pages left in chapter": "Bu bölümde {{number}} sayfa kaldı",
|
||||
"{{number}} pages left in chapter": "<1>Bu bölümde </1><0>{{number}}</0><1> sayfa kaldı</1>",
|
||||
"Device": "Cihaz",
|
||||
"E-Ink Mode": "E-Ink Modu",
|
||||
"Highlight Colors": "Vurgu Renkleri",
|
||||
"Auto Screen Brightness": "Otomatik Ekran Parlaklığı",
|
||||
"Pagination": "Sayfalama",
|
||||
"Disable Double Tap": "Çift Dokunmayı Devre Dışı Bırak",
|
||||
"Tap to Paginate": "Sayfalamak için Dokunun",
|
||||
@@ -976,7 +975,6 @@
|
||||
"Unable to start RSVP": "RSVP başlatılamadı",
|
||||
"RSVP not supported for PDF": "PDF için RSVP desteklenmiyor",
|
||||
"Select Chapter": "Bölüm Seç",
|
||||
"{{number}} WPM": "{{number}} Kelime/Dakika",
|
||||
"Context": "Bağlam",
|
||||
"Ready": "Hazır",
|
||||
"Chapter Progress": "Bölüm İlerlemesi",
|
||||
@@ -1031,5 +1029,35 @@
|
||||
"Authors": "Yazarlar",
|
||||
"Books": "Kitaplar",
|
||||
"Groups": "Gruplar",
|
||||
"Back to TTS Location": "TTS Konumuna Geri Dön"
|
||||
"Back to TTS Location": "TTS Konumuna Geri Dön",
|
||||
"Metadata": "Meta veriler",
|
||||
"Image viewer": "Resim görüntüleyici",
|
||||
"Previous Image": "Önceki Resim",
|
||||
"Next Image": "Sonraki Resim",
|
||||
"Zoomed": "Yakınlaştırıldı",
|
||||
"Zoom level": "Yakınlaştırma düzeyi",
|
||||
"Table viewer": "Tablo görüntüleyici",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise'a bağlanılamadı. Lütfen ağ bağlantınızı kontrol edin.",
|
||||
"Invalid Readwise access token": "Geçersiz Readwise erişim kodu",
|
||||
"Disconnected from Readwise": "Readwise bağlantısı kesildi",
|
||||
"Never": "Asla",
|
||||
"Readwise Settings": "Readwise Ayarları",
|
||||
"Connected to Readwise": "Readwise'a Bağlandı",
|
||||
"Last synced: {{time}}": "Son senkronizasyon: {{time}}",
|
||||
"Sync Enabled": "Senkronizasyon Etkin",
|
||||
"Disconnect": "Bağlantıyı Kes",
|
||||
"Connect your Readwise account to sync highlights.": "Vurguları senkronize etmek için Readwise hesabınızı bağlayın.",
|
||||
"Get your access token at": "Erişim kodunuzu şuradan alın:",
|
||||
"Access Token": "Erişim Kodu",
|
||||
"Paste your Readwise access token": "Readwise erişim kodunuzu yapıştırın",
|
||||
"Config": "Yapılandırma",
|
||||
"Readwise Sync": "Readwise Senkronizasyonu",
|
||||
"Push Highlights": "Vurguları Gönder",
|
||||
"Highlights synced to Readwise": "Vurgular Readwise ile senkronize edildi",
|
||||
"Readwise sync failed: no internet connection": "Readwise senkronizasyonu başarısız: internet bağlantısı yok",
|
||||
"Readwise sync failed: {{error}}": "Readwise senkronizasyonu başarısız: {{error}}",
|
||||
"System Screen Brightness": "Sistem Ekran Parlaklığı",
|
||||
"Page:": "Sayfa:",
|
||||
"Page: {{number}}": "Sayfa: {{number}}",
|
||||
"Annotation page number": "Açıklama sayfa numarası"
|
||||
}
|
||||
|
||||
@@ -341,10 +341,10 @@
|
||||
"Fit": "Припасувати",
|
||||
"Reset {{settings}}": "Скинути {{settings}}",
|
||||
"Reset Settings": "Скинути налаштування",
|
||||
"{{count}} pages left in chapter_one": "Залишилася {{count}} сторінка у розділі",
|
||||
"{{count}} pages left in chapter_few": "Залишилось {{count}} сторінки у розділі",
|
||||
"{{count}} pages left in chapter_many": "Залишилось {{count}} сторінок у розділі",
|
||||
"{{count}} pages left in chapter_other": "Залишилось {{count}} сторінок у розділі",
|
||||
"{{count}} pages left in chapter_one": "<1>Залишилася </1><0>{{count}}</0><1> сторінка у розділі</1>",
|
||||
"{{count}} pages left in chapter_few": "<1>Залишилось </1><0>{{count}}</0><1> сторінки у розділі</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>Залишилось </1><0>{{count}}</0><1> сторінок у розділі</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Залишилось </1><0>{{count}}</0><1> сторінок у розділі</1>",
|
||||
"Show Remaining Pages": "Показати залишок сторінок",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -607,11 +607,10 @@
|
||||
"Size": "Розмір",
|
||||
"Cover": "Покрити",
|
||||
"Contain": "Втиснути",
|
||||
"{{number}} pages left in chapter": "Залишилось {{number}} сторінок у розділі",
|
||||
"{{number}} pages left in chapter": "<1>Залишилось </1><0>{{number}}</0><1> сторінок у розділі</1>",
|
||||
"Device": "Пристрій",
|
||||
"E-Ink Mode": "E-Ink режим",
|
||||
"Highlight Colors": "Кольори виділення",
|
||||
"Auto Screen Brightness": "Автоматична яскравість екрану",
|
||||
"Pagination": "Розбиття на сторінки",
|
||||
"Disable Double Tap": "Вимкнути подвійне натискання",
|
||||
"Tap to Paginate": "Натисніть, щоб розбити на сторінки",
|
||||
@@ -1000,7 +999,6 @@
|
||||
"Unable to start RSVP": "Не вдалося запустити RSVP",
|
||||
"RSVP not supported for PDF": "RSVP не підтримується для PDF",
|
||||
"Select Chapter": "Вибрати розділ",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Контекст",
|
||||
"Ready": "Готово",
|
||||
"Chapter Progress": "Прогрес розділу",
|
||||
@@ -1055,5 +1053,35 @@
|
||||
"Authors": "Автори",
|
||||
"Books": "Книги",
|
||||
"Groups": "Групи",
|
||||
"Back to TTS Location": "Повернутися до розташування TTS"
|
||||
"Back to TTS Location": "Повернутися до розташування TTS",
|
||||
"Metadata": "Метадані",
|
||||
"Image viewer": "Переглядач зображень",
|
||||
"Previous Image": "Попереднє зображення",
|
||||
"Next Image": "Наступне зображення",
|
||||
"Zoomed": "Збільшено",
|
||||
"Zoom level": "Рівень масштабу",
|
||||
"Table viewer": "Переглядач таблиць",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Не вдалося підключитися до Readwise. Будь ласка, перевірте мережеве з'єднання.",
|
||||
"Invalid Readwise access token": "Недійсний токен доступу Readwise",
|
||||
"Disconnected from Readwise": "Відключено від Readwise",
|
||||
"Never": "Ніколи",
|
||||
"Readwise Settings": "Налаштування Readwise",
|
||||
"Connected to Readwise": "Підключено до Readwise",
|
||||
"Last synced: {{time}}": "Остання синхронізація: {{time}}",
|
||||
"Sync Enabled": "Синхронізацію увімкнено",
|
||||
"Disconnect": "Відключити",
|
||||
"Connect your Readwise account to sync highlights.": "Підключіть свій обліковий запис Readwise, щоб синхронізувати виділення.",
|
||||
"Get your access token at": "Отримайте токен доступу за адресою",
|
||||
"Access Token": "Токен доступу",
|
||||
"Paste your Readwise access token": "Вставте ваш токен доступу Readwise",
|
||||
"Config": "Конфігурація",
|
||||
"Readwise Sync": "Синхронізація Readwise",
|
||||
"Push Highlights": "Надіслати виділення",
|
||||
"Highlights synced to Readwise": "Виділення синхронізовано з Readwise",
|
||||
"Readwise sync failed: no internet connection": "Синхронізація Readwise не вдалася: немає інтернет-з'єднання",
|
||||
"Readwise sync failed: {{error}}": "Синхронізація Readwise не вдалася: {{error}}",
|
||||
"System Screen Brightness": "Системна яскравість екрана",
|
||||
"Page:": "Сторінка:",
|
||||
"Page: {{number}}": "Сторінка: {{number}}",
|
||||
"Annotation page number": "Номер сторінки анотації"
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"Fit": "Vừa vặn",
|
||||
"Reset {{settings}}": "Đặt lại {{settings}}",
|
||||
"Reset Settings": "Đặt lại cài đặt",
|
||||
"{{count}} pages left in chapter_other": "Còn {{count}} trang trong chương",
|
||||
"{{count}} pages left in chapter_other": "<1>Còn </1><0>{{count}}</0><1> trang trong chương</1>",
|
||||
"Show Remaining Pages": "Hiển thị trang còn lại",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "Kích thước",
|
||||
"Cover": "Bìa",
|
||||
"Contain": "Chứa",
|
||||
"{{number}} pages left in chapter": "Còn {{number}} trang trong chương",
|
||||
"{{number}} pages left in chapter": "<1>Còn </1><0>{{number}}</0><1> trang trong chương</1>",
|
||||
"Device": "Thiết bị",
|
||||
"E-Ink Mode": "Chế độ E-Ink",
|
||||
"Highlight Colors": "Màu nổi bật",
|
||||
"Auto Screen Brightness": "Độ sáng màn hình tự động",
|
||||
"Pagination": "Phân trang",
|
||||
"Disable Double Tap": "Vô hiệu hóa chạm hai lần",
|
||||
"Tap to Paginate": "Chạm để phân trang",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "Không thể bắt đầu RSVP",
|
||||
"RSVP not supported for PDF": "RSVP không được hỗ trợ cho PDF",
|
||||
"Select Chapter": "Chọn chương",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Ngữ cảnh",
|
||||
"Ready": "Sẵn sàng",
|
||||
"Chapter Progress": "Tiến độ chương",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "Tác giả",
|
||||
"Books": "Sách",
|
||||
"Groups": "Nhóm",
|
||||
"Back to TTS Location": "Quay lại vị trí TTS"
|
||||
"Back to TTS Location": "Quay lại vị trí TTS",
|
||||
"Metadata": "Siêu dữ liệu",
|
||||
"Image viewer": "Trình xem ảnh",
|
||||
"Previous Image": "Ảnh trước",
|
||||
"Next Image": "Ảnh sau",
|
||||
"Zoomed": "Đã thu phóng",
|
||||
"Zoom level": "Mức thu phóng",
|
||||
"Table viewer": "Trình xem bảng",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Không thể kết nối với Readwise. Vui lòng kiểm tra kết nối mạng của bạn.",
|
||||
"Invalid Readwise access token": "Mã xác thực Readwise không hợp lệ",
|
||||
"Disconnected from Readwise": "Đã ngắt kết nối với Readwise",
|
||||
"Never": "Không bao giờ",
|
||||
"Readwise Settings": "Cài đặt Readwise",
|
||||
"Connected to Readwise": "Đã kết nối với Readwise",
|
||||
"Last synced: {{time}}": "Lần đồng bộ cuối: {{time}}",
|
||||
"Sync Enabled": "Đã bật đồng bộ",
|
||||
"Disconnect": "Ngắt kết nối",
|
||||
"Connect your Readwise account to sync highlights.": "Kết nối tài khoản Readwise của bạn để đồng bộ các phần đánh dấu.",
|
||||
"Get your access token at": "Lấy mã xác thực tại",
|
||||
"Access Token": "Mã xác thực",
|
||||
"Paste your Readwise access token": "Dán mã xác thực Readwise của bạn vào đây",
|
||||
"Config": "Cấu hình",
|
||||
"Readwise Sync": "Đồng bộ Readwise",
|
||||
"Push Highlights": "Đẩy các phần đánh dấu",
|
||||
"Highlights synced to Readwise": "Các phần đánh dấu đã được đồng bộ với Readwise",
|
||||
"Readwise sync failed: no internet connection": "Đồng bộ Readwise thất bại: không có kết nối internet",
|
||||
"Readwise sync failed: {{error}}": "Đồng bộ Readwise thất bại: {{error}}",
|
||||
"System Screen Brightness": "Độ sáng màn hình hệ thống",
|
||||
"Page:": "Trang:",
|
||||
"Page: {{number}}": "Trang: {{number}}",
|
||||
"Annotation page number": "Số trang chú thích"
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"Fit": "适应",
|
||||
"Reset {{settings}}": "重置{{settings}}",
|
||||
"Reset Settings": "重置设置",
|
||||
"{{count}} pages left in chapter_other": "本章剩余 {{count}} 页",
|
||||
"{{count}} pages left in chapter_other": "<1>本章剩余 </1><0>{{count}}</0><1> 页</1>",
|
||||
"Show Remaining Pages": "显示剩余页数",
|
||||
"Source Han Serif CN": "思源宋体",
|
||||
"Huiwen-MinchoGBK": "汇文明朝体",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "大小",
|
||||
"Cover": "覆盖",
|
||||
"Contain": "适应",
|
||||
"{{number}} pages left in chapter": "本章剩余{{number}}页",
|
||||
"{{number}} pages left in chapter": "<1>本章剩余</1><0>{{number}}</0><1>页</1>",
|
||||
"Device": "设备",
|
||||
"E-Ink Mode": "E-Ink 模式",
|
||||
"Highlight Colors": "高亮颜色",
|
||||
"Auto Screen Brightness": "自动屏幕亮度",
|
||||
"Pagination": "翻页",
|
||||
"Disable Double Tap": "禁用双击",
|
||||
"Tap to Paginate": "点击翻页",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "无法启动 RSVP",
|
||||
"RSVP not supported for PDF": "PDF 不支持 RSVP",
|
||||
"Select Chapter": "选择章节",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "上下文",
|
||||
"Ready": "准备就绪",
|
||||
"Chapter Progress": "章节进度",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "作者",
|
||||
"Books": "书籍",
|
||||
"Groups": "分组",
|
||||
"Back to TTS Location": "回到朗读位置"
|
||||
"Back to TTS Location": "回到朗读位置",
|
||||
"Metadata": "元数据",
|
||||
"Image viewer": "图片查看器",
|
||||
"Previous Image": "上一张图片",
|
||||
"Next Image": "下一张图片",
|
||||
"Zoomed": "已缩放",
|
||||
"Zoom level": "缩放级别",
|
||||
"Table viewer": "表格查看器",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "无法连接到 Readwise。请检查您的网络连接。",
|
||||
"Invalid Readwise access token": "无效的 Readwise 访问令牌",
|
||||
"Disconnected from Readwise": "已断开与 Readwise 的连接",
|
||||
"Never": "从不",
|
||||
"Readwise Settings": "Readwise 设置",
|
||||
"Connected to Readwise": "已连接到 Readwise",
|
||||
"Last synced: {{time}}": "上次同步时间:{{time}}",
|
||||
"Sync Enabled": "同步已启用",
|
||||
"Disconnect": "断开连接",
|
||||
"Connect your Readwise account to sync highlights.": "连接您的 Readwise 账户以同步高亮内容。",
|
||||
"Get your access token at": "获取您的访问令牌:",
|
||||
"Access Token": "访问令牌",
|
||||
"Paste your Readwise access token": "粘贴您的 Readwise 访问令牌",
|
||||
"Config": "配置",
|
||||
"Readwise Sync": "Readwise 同步",
|
||||
"Push Highlights": "推送高亮内容",
|
||||
"Highlights synced to Readwise": "高亮内容已同步到 Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同步失败:无网络连接",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同步失败:{{error}}",
|
||||
"System Screen Brightness": "系统屏幕亮度",
|
||||
"Page:": "页面:",
|
||||
"Page: {{number}}": "页面: {{number}}",
|
||||
"Annotation page number": "批注页码"
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"Fit": "適應",
|
||||
"Reset {{settings}}": "重置{{settings}}",
|
||||
"Reset Settings": "重置設置",
|
||||
"{{count}} pages left in chapter_other": "本章剩餘 {{count}} 頁",
|
||||
"{{count}} pages left in chapter_other": "<1>本章剩餘 </1><0>{{count}}</0><1> 頁</1>",
|
||||
"Show Remaining Pages": "顯示剩餘頁數",
|
||||
"Source Han Serif CN": "思源宋體",
|
||||
"Huiwen-MinchoGBK": "匯文明朝體",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "大小",
|
||||
"Cover": "覆蓋",
|
||||
"Contain": "適應",
|
||||
"{{number}} pages left in chapter": "本章剩餘{{number}}頁",
|
||||
"{{number}} pages left in chapter": "<1>本章剩餘</1><0>{{number}}</0><1>頁</1>",
|
||||
"Device": "設備",
|
||||
"E-Ink Mode": "E-Ink 模式",
|
||||
"Highlight Colors": "高亮顏色",
|
||||
"Auto Screen Brightness": "自動螢幕亮度",
|
||||
"Pagination": "翻頁",
|
||||
"Disable Double Tap": "禁用雙擊",
|
||||
"Tap to Paginate": "點擊翻頁",
|
||||
@@ -964,7 +963,6 @@
|
||||
"Unable to start RSVP": "無法啟動 RSVP",
|
||||
"RSVP not supported for PDF": "PDF 不支援 RSVP",
|
||||
"Select Chapter": "選擇章節",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "上下文",
|
||||
"Ready": "準備就緒",
|
||||
"Chapter Progress": "章節進度",
|
||||
@@ -1019,5 +1017,35 @@
|
||||
"Authors": "作者",
|
||||
"Books": "書籍",
|
||||
"Groups": "分組",
|
||||
"Back to TTS Location": "回到朗讀位置"
|
||||
"Back to TTS Location": "回到朗讀位置",
|
||||
"Metadata": "元數據",
|
||||
"Image viewer": "圖片檢視器",
|
||||
"Previous Image": "上一張圖片",
|
||||
"Next Image": "下一張圖片",
|
||||
"Zoomed": "已縮放",
|
||||
"Zoom level": "縮放級別",
|
||||
"Table viewer": "表格檢視器",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "無法連接到 Readwise。請檢查您的網路連線。",
|
||||
"Invalid Readwise access token": "無效的 Readwise 存取權杖",
|
||||
"Disconnected from Readwise": "已斷開與 Readwise 的連線",
|
||||
"Never": "從不",
|
||||
"Readwise Settings": "Readwise 設定",
|
||||
"Connected to Readwise": "已連接到 Readwise",
|
||||
"Last synced: {{time}}": "上次同步時間:{{time}}",
|
||||
"Sync Enabled": "同步已啟用",
|
||||
"Disconnect": "斷開連線",
|
||||
"Connect your Readwise account to sync highlights.": "連接您的 Readwise 帳戶以同步高亮內容。",
|
||||
"Get your access token at": "獲取您的存取權杖:",
|
||||
"Access Token": "存取權杖",
|
||||
"Paste your Readwise access token": "貼上您的 Readwise 存取權杖",
|
||||
"Config": "配置",
|
||||
"Readwise Sync": "Readwise 同步",
|
||||
"Push Highlights": "推送高亮內容",
|
||||
"Highlights synced to Readwise": "高亮內容已同步到 Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同步失敗:無網路連線",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同步失敗:{{error}}",
|
||||
"System Screen Brightness": "系統螢幕亮度",
|
||||
"Page:": "頁面:",
|
||||
"Page: {{number}}": "頁面: {{number}}",
|
||||
"Annotation page number": "註解頁碼"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.101": {
|
||||
"date": "2026-02-28",
|
||||
"notes": [
|
||||
"Text-to-Speech: Fixed an issue where TTS could not continue to the next chapter",
|
||||
"Reading: Fixed an issue importing very large TXT files on iOS",
|
||||
"Reading: Fixed the layout of the reading ruler in scrolled mode",
|
||||
"Reading: Smoother page-turn animations for a more fluid experience",
|
||||
"Reading: Added a gallery mode to zoom and browse images within books",
|
||||
"Annotations: Page numbers are now displayed and exported with highlights and notes",
|
||||
"Annotations: Added support for exporting highlights to Readwise",
|
||||
"OPDS: Fixed missing book descriptions in OPDS feeds",
|
||||
"Android: Improved compatibility with older WebView versions on Android"
|
||||
]
|
||||
},
|
||||
"0.9.100": {
|
||||
"date": "2026-02-13",
|
||||
"notes": [
|
||||
"Text-to-Speech: Automatically moves to the next chapter when the current chapter finishes",
|
||||
"Reading: Fixed the reading ruler on Android",
|
||||
"Reading: Improved RSVP layout and stability",
|
||||
"Reading: Fixed an issue where some EPUB books could not adjust font size",
|
||||
"Annotations: Added a magnifier (loupe) when adjusting text selection",
|
||||
"Annotations: Text selection now snaps to word boundaries for better accuracy",
|
||||
"Annotations: Custom highlight colors now apply correctly in the sidebar",
|
||||
"Dictionary: Added a back button for easier navigation",
|
||||
"Dictionary: Fixed an issue where Dictionary and Wikipedia could not open on Android",
|
||||
"Metadata: Added collapsible sections in Book Details",
|
||||
"Metadata: Added support for parsing series information from Calibre-exported EPUB files",
|
||||
"Eink: Improved component layout and colors in E-ink mode",
|
||||
"Layout: Sidebar toggle button position is now persistent"
|
||||
]
|
||||
},
|
||||
"0.9.99": {
|
||||
"date": "2026-02-07",
|
||||
"notes": [
|
||||
|
||||
@@ -54,17 +54,6 @@
|
||||
|
||||
DetailPrint "Thumbnail provider registered successfully."
|
||||
|
||||
Delete "$DESKTOP\Readest.lnk"
|
||||
Delete "$SMPROGRAMS\Readest\Readest.lnk"
|
||||
RMDir "$SMPROGRAMS\Readest"
|
||||
|
||||
; Create new shortcuts pointing to current installation
|
||||
CreateShortcut "$DESKTOP\Readest.lnk" "$INSTDIR\Readest.exe"
|
||||
CreateDirectory "$SMPROGRAMS\Readest"
|
||||
CreateShortcut "$SMPROGRAMS\Readest\Readest.lnk" "$INSTDIR\Readest.exe"
|
||||
|
||||
DetailPrint "Shortcuts updated."
|
||||
|
||||
; Refresh shell to apply changes - SHCNE_ASSOCCHANGED
|
||||
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
|
||||
!macroend
|
||||
|
||||
+22
@@ -889,6 +889,28 @@ class NativeBridgePlugin: Plugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func get_safe_area_insets(_ invoke: Invoke) {
|
||||
DispatchQueue.main.async {
|
||||
if let window = UIApplication.shared.windows.first {
|
||||
let insets = window.safeAreaInsets
|
||||
invoke.resolve([
|
||||
"top": insets.top,
|
||||
"left": insets.left,
|
||||
"bottom": insets.bottom,
|
||||
"right": insets.right
|
||||
])
|
||||
} else {
|
||||
invoke.resolve([
|
||||
"error": "No window found",
|
||||
"top": 0,
|
||||
"left": 0,
|
||||
"bottom": 0,
|
||||
"right": 0
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_native_bridge")
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' data: blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
|
||||
},
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock('@/services/environment', async (importOriginal) => {
|
||||
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { DEFAULT_SYSTEM_SETTINGS } from '@/services/constants';
|
||||
|
||||
function renderWithProviders(ui: React.ReactNode) {
|
||||
return render(
|
||||
@@ -81,12 +82,7 @@ describe('ProofreadRulesManager', () => {
|
||||
beforeEach(() => {
|
||||
// Reset stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SYSTEM_SETTINGS,
|
||||
});
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
|
||||
useSidebarStore.setState({ sideBarBookKey: null });
|
||||
@@ -101,6 +97,7 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange: populate stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [
|
||||
{
|
||||
@@ -127,7 +124,6 @@ describe('ProofreadRulesManager', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -164,8 +160,8 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange: populate stores with a selection rule persisted in book config
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -284,10 +280,10 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [libraryRule],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -364,8 +360,8 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -418,8 +414,8 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
@@ -453,8 +449,8 @@ describe('ProofreadRulesManager', () => {
|
||||
it('shows empty state messages when no rules exist', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -509,10 +505,10 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [libraryRule],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { FoliateView } from '@/types/view';
|
||||
import { wrappedFoliateView } from '@/types/view';
|
||||
|
||||
// jsdom's Blob doesn't implement arrayBuffer(), polyfill it via FileReader
|
||||
if (typeof Blob.prototype.arrayBuffer !== 'function') {
|
||||
Blob.prototype.arrayBuffer = function () {
|
||||
return new Promise((res, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => res(reader.result as ArrayBuffer);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(this);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
|
||||
if (!customElements.get('foliate-paginator')) {
|
||||
customElements.define(
|
||||
'foliate-paginator',
|
||||
class extends HTMLElement {
|
||||
override setAttribute() {}
|
||||
override addEventListener() {}
|
||||
open() {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Mock the paginator module import so View doesn't try to load the real one
|
||||
vi.mock('foliate-js/paginator.js', () => ({}));
|
||||
|
||||
let book: BookDoc;
|
||||
let view: FoliateView;
|
||||
let totalSections: number;
|
||||
|
||||
const loadEPUB = async () => {
|
||||
const epubPath = resolve(__dirname, '../fixtures/sample-alice.epub');
|
||||
const buffer = readFileSync(epubPath);
|
||||
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const { book } = await loader.open();
|
||||
return book;
|
||||
};
|
||||
|
||||
describe('getCFIProgress with real EPUB', () => {
|
||||
beforeAll(async () => {
|
||||
book = await loadEPUB();
|
||||
totalSections = book.sections!.length;
|
||||
|
||||
// Import View and create the element, wrapping it like the app does
|
||||
await import('foliate-js/view.js');
|
||||
const rawView = document.createElement('foliate-view') as FoliateView;
|
||||
view = wrappedFoliateView(rawView);
|
||||
await view.open(book);
|
||||
}, 30000);
|
||||
|
||||
it('should load the EPUB with sections', () => {
|
||||
expect(book.sections!.length).toBeGreaterThan(0);
|
||||
expect(view.book).toBe(book);
|
||||
});
|
||||
|
||||
it('should return a valid progress object with all fields', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
expect(firstTextNode).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode!, 0);
|
||||
range.setEnd(firstTextNode!, 0);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// fraction is the overall book progress
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.fraction).toBeLessThanOrEqual(1);
|
||||
// section info
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
expect(result!.section.total).toBe(totalSections);
|
||||
// location info
|
||||
expect(result!.location.current).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.location.next).toBeGreaterThanOrEqual(result!.location.current);
|
||||
expect(result!.location.total).toBeGreaterThan(0);
|
||||
// time info
|
||||
expect(result!.time.section).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.time.total).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return the smallest fraction for a CFI at the start of a section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
expect(firstTextNode).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode!, 0);
|
||||
range.setEnd(firstTextNode!, 0);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
// At the start of a section, fraction should equal the section's start fraction
|
||||
// (i.e. only prior sections' sizes contribute)
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return a larger fraction for a CFI at the end of a section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
// Get start CFI
|
||||
const walkerStart = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walkerStart.nextNode()!;
|
||||
const startRange = doc.createRange();
|
||||
startRange.setStart(firstTextNode, 0);
|
||||
startRange.setEnd(firstTextNode, 0);
|
||||
const startCfi = view.getCFI(sectionIndex, startRange);
|
||||
const startResult = await view.getCFIProgress(startCfi);
|
||||
|
||||
// Get end CFI
|
||||
const walkerEnd = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
let lastTextNode: Node | null = null;
|
||||
for (let node = walkerEnd.nextNode(); node; node = walkerEnd.nextNode()) {
|
||||
lastTextNode = node;
|
||||
}
|
||||
expect(lastTextNode).not.toBeNull();
|
||||
const endRange = doc.createRange();
|
||||
endRange.setStart(lastTextNode!, lastTextNode!.textContent!.length);
|
||||
endRange.setEnd(lastTextNode!, lastTextNode!.textContent!.length);
|
||||
const endCfi = view.getCFI(sectionIndex, endRange);
|
||||
const endResult = await view.getCFIProgress(endCfi);
|
||||
|
||||
expect(startResult).not.toBeNull();
|
||||
expect(endResult).not.toBeNull();
|
||||
expect(endResult!.section.current).toBe(sectionIndex);
|
||||
expect(endResult!.fraction).toBeGreaterThan(startResult!.fraction);
|
||||
});
|
||||
|
||||
it('should return a fraction between start and end for a midpoint CFI', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Node[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
expect(textNodes.length).toBeGreaterThan(0);
|
||||
|
||||
const midNodeIndex = Math.floor(textNodes.length / 2);
|
||||
const midNode = textNodes[midNodeIndex]!;
|
||||
const midOffset = Math.floor((midNode.textContent?.length ?? 0) / 2);
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(midNode, midOffset);
|
||||
range.setEnd(midNode, midOffset);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
expect(result!.fraction).toBeGreaterThan(0);
|
||||
expect(result!.fraction).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('should produce consistent results for the same CFI', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 500);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const node = walker.nextNode();
|
||||
expect(node).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
const offset = Math.min(3, node!.textContent!.length);
|
||||
range.setStart(node!, offset);
|
||||
range.setEnd(node!, offset);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
|
||||
const result1 = await view.getCFIProgress(cfi);
|
||||
const result2 = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
expect(result1!.fraction).toBe(result2!.fraction);
|
||||
expect(result1!.section.current).toBe(result2!.section.current);
|
||||
expect(result1!.location.current).toBe(result2!.location.current);
|
||||
});
|
||||
|
||||
it('should return increasing fractions for successive CFIs in the same section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Node[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
const positions = [0, Math.floor(textNodes.length / 4), Math.floor((textNodes.length * 3) / 4)];
|
||||
const fractions: number[] = [];
|
||||
|
||||
for (const pos of positions) {
|
||||
const node = textNodes[pos]!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(node, 0);
|
||||
range.setEnd(node, 0);
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
fractions.push(result!.fraction);
|
||||
}
|
||||
|
||||
for (let i = 1; i < fractions.length; i++) {
|
||||
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return increasing fractions across different sections', async () => {
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no' && s.size > 0);
|
||||
|
||||
expect(linearSections.length).toBeGreaterThan(1);
|
||||
|
||||
const fractions: number[] = [];
|
||||
|
||||
for (const { s, i } of linearSections.slice(0, 3)) {
|
||||
const doc = await s.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
if (!firstTextNode) continue;
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode, 0);
|
||||
range.setEnd(firstTextNode, 0);
|
||||
|
||||
const cfi = view.getCFI(i, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(i);
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.fraction).toBeLessThanOrEqual(1);
|
||||
fractions.push(result!.fraction);
|
||||
}
|
||||
|
||||
// Fractions across sections should be increasing
|
||||
for (let i = 1; i < fractions.length; i++) {
|
||||
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for an invalid CFI', async () => {
|
||||
const result = await view.getCFIProgress('epubcfi(/99/999!/0)');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return progress for real CFIs', async () => {
|
||||
const expectedData = [
|
||||
{ cfi: 'epubcfi(/99/999!/0)', fraction: null },
|
||||
{ cfi: 'epubcfi(/6/4!/4/2/6,/1:0,/1:13)', fraction: 0.009, page: 1 },
|
||||
{ cfi: 'epubcfi(/6/8!/4/2/2[chapter_458]/4/18,/1:0,/1:681)', fraction: 0.045, page: 5 },
|
||||
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/2,/3:1,/3:18)', fraction: 0.098, page: 11 },
|
||||
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/4/70,/1:0,/1:261)', fraction: 0.161, page: 19 },
|
||||
{ cfi: 'epubcfi(/6/12!/4/2/2[chapter_462]/4/22,/1:0,/1:156)', fraction: 0.179, page: 21 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/6,/1:0,/1:65)', fraction: 0.411, page: 46 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/94,/1:1,/1:30)', fraction: 0.446, page: 51 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/134,/1:0,/1:58)', fraction: 0.473, page: 54 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/170,/1:0,/1:124)', fraction: 0.491, page: 55 },
|
||||
{ cfi: 'epubcfi(/6/26!/4/2/2[chapter_476]/4/120,/1:0,/1:117)', fraction: 0.804, page: 91 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4,/82/1:0,/86/1:26)', fraction: 0.955, page: 107 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:1,/1:118)', fraction: 0.955, page: 108 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:119,/1:358)', fraction: 0.955, page: 108 },
|
||||
];
|
||||
|
||||
for (const { cfi, fraction, page } of expectedData) {
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
if (fraction === null) {
|
||||
expect(result).toBeNull();
|
||||
} else {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(fraction, 1);
|
||||
expect(result!.location.current).toBe(page! - 1);
|
||||
expect(Math.abs(result!.fraction - fraction)).toBeLessThan(0.006);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PageProgress } from 'foliate-js/progress.js';
|
||||
|
||||
const createHTML = (html: string): Document => {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(`<!DOCTYPE html><html><body>${html}</body></html>`, 'text/html');
|
||||
};
|
||||
|
||||
const makeBook = (sections: { html: string }[]) => ({
|
||||
sections: sections.map(({ html }) => ({
|
||||
createDocument: () => Promise.resolve(createHTML(html)),
|
||||
})),
|
||||
});
|
||||
|
||||
describe('PageProgress', () => {
|
||||
describe('getProgress', () => {
|
||||
it('should return fraction 0 when anchor targets the start of a section', async () => {
|
||||
const book = makeBook([{ html: '<p>Hello world</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-start');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return fraction 1 when anchor targets the end of a section', async () => {
|
||||
const book = makeBook([{ html: '<p>Hello world</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, text.textContent!.length);
|
||||
range.setEnd(text, text.textContent!.length);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-end');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(1);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct fraction for a midpoint in a section', async () => {
|
||||
const book = makeBook([{ html: '<p>abcdefghij</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 5);
|
||||
range.setEnd(text, 5);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-mid');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0.5);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple text nodes across elements', async () => {
|
||||
const book = makeBook([{ html: '<p>aaaa</p><p>bbbb</p><p>cccc</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
// target start of second <p>, which is at offset 4 of 12 total chars
|
||||
const text = doc.body.querySelectorAll('p')[1]!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-multi');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(4 / 12);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle anchor returning an Element instead of a Range', async () => {
|
||||
const book = makeBook([{ html: '<p>first</p><p id="target">second</p><p>third</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => doc.getElementById('target')!,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-element');
|
||||
expect(result).not.toBeNull();
|
||||
// "first" is 5 chars, "second" starts at offset 5 of 16 total
|
||||
expect(result!.fraction).toBeCloseTo(5 / 16);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct index for non-zero section', async () => {
|
||||
const book = makeBook([
|
||||
{ html: '<p>chapter one</p>' },
|
||||
{ html: '<p>chapter two content</p>' },
|
||||
]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 1,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-section1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
expect(result!.index).toBe(1);
|
||||
});
|
||||
|
||||
it('should return null when resolveNavigation returns no index', async () => {
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = (): { index: undefined; anchor: undefined } => ({
|
||||
index: undefined,
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('bad-cfi');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when resolveNavigation returns no anchor', async () => {
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = (): { index: number; anchor: undefined } => ({
|
||||
index: 0,
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('no-anchor');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when section has no createDocument', async () => {
|
||||
const book = { sections: [{}] };
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: () => null,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('no-doc');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return fraction 0 for an empty document', async () => {
|
||||
const book = makeBook([{ html: '' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const range = doc.createRange();
|
||||
range.selectNodeContents(doc.body);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-empty');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
});
|
||||
|
||||
it('should return null and log error when resolveNavigation throws', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = () => {
|
||||
throw new Error('bad cfi');
|
||||
};
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('invalid');
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle nested elements correctly', async () => {
|
||||
const book = makeBook([{ html: '<div><span>ab</span><em>cd</em></div><p>ef</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
// target the <em> text node "cd" at offset 0, which is at char 2 of 6
|
||||
const text = doc.body.querySelector('em')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 2);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-nested');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(2 / 6);
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ParagraphIterator } from '@/utils/paragraph';
|
||||
|
||||
const createDoc = (body: string): Document =>
|
||||
new DOMParser().parseFromString(`<html><body>${body}</body></html>`, 'text/html');
|
||||
|
||||
describe('ParagraphIterator', () => {
|
||||
it('skips whitespace-only paragraphs', async () => {
|
||||
const doc = createDoc(`
|
||||
<p>First</p>
|
||||
<p> </p>
|
||||
<p> </p>
|
||||
<p>\u200b</p>
|
||||
<p><span>\u00a0</span></p>
|
||||
<p><br /></p>
|
||||
<p>Second</p>
|
||||
`);
|
||||
|
||||
const iterator = await ParagraphIterator.createAsync(doc, 1000);
|
||||
|
||||
expect(iterator.length).toBe(2);
|
||||
expect(iterator.first()?.toString()).toContain('First');
|
||||
expect(iterator.next()?.toString()).toContain('Second');
|
||||
});
|
||||
|
||||
it('keeps paragraphs with non-text content', async () => {
|
||||
const doc = createDoc(`
|
||||
<p>Intro</p>
|
||||
<p><img src="cover.png" alt="" /></p>
|
||||
<p>Outro</p>
|
||||
`);
|
||||
|
||||
const iterator = await ParagraphIterator.createAsync(doc, 1000);
|
||||
|
||||
expect(iterator.length).toBe(3);
|
||||
|
||||
iterator.first();
|
||||
expect(iterator.current()?.toString()).toContain('Intro');
|
||||
|
||||
const imageRange = iterator.next();
|
||||
const fragment = imageRange?.cloneContents();
|
||||
expect(fragment?.querySelector('img')).not.toBeNull();
|
||||
|
||||
expect(iterator.next()?.toString()).toContain('Outro');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { estimateTTSTime } from '@/utils/ttsTime';
|
||||
import { BookProgress } from '@/types/book';
|
||||
|
||||
const createProgress = (values: {
|
||||
sectionMin: number;
|
||||
totalMin: number;
|
||||
pageCurrent: number;
|
||||
pageTotal: number;
|
||||
}) => {
|
||||
return {
|
||||
timeinfo: {
|
||||
section: values.sectionMin,
|
||||
total: values.totalMin,
|
||||
},
|
||||
pageinfo: {
|
||||
current: values.pageCurrent,
|
||||
total: values.pageTotal,
|
||||
},
|
||||
} as BookProgress;
|
||||
};
|
||||
|
||||
describe('estimateTTSTime', () => {
|
||||
it('estimates chapter and book remaining time at 1x', () => {
|
||||
const progress = createProgress({
|
||||
sectionMin: 10,
|
||||
totalMin: 60,
|
||||
pageCurrent: 49,
|
||||
pageTotal: 100,
|
||||
});
|
||||
|
||||
const result = estimateTTSTime(progress, 1, 1_700_000_000_000);
|
||||
|
||||
expect(result.chapterRemainingSec).toBe(600);
|
||||
expect(result.bookRemainingSec).toBe(3600);
|
||||
expect(result.finishAtTimestamp).toBe(1_700_003_600_000);
|
||||
});
|
||||
|
||||
it('scales estimates by playback rate', () => {
|
||||
const progress = createProgress({
|
||||
sectionMin: 12,
|
||||
totalMin: 90,
|
||||
pageCurrent: 44,
|
||||
pageTotal: 90,
|
||||
});
|
||||
|
||||
const result = estimateTTSTime(progress, 1.5, 1000);
|
||||
|
||||
expect(result.chapterRemainingSec).toBe(480);
|
||||
expect(result.bookRemainingSec).toBe(3600);
|
||||
expect(result.finishAtTimestamp).toBe(3_601_000);
|
||||
});
|
||||
|
||||
it('falls back safely for missing progress', () => {
|
||||
const result = estimateTTSTime(null, 2, 1000);
|
||||
|
||||
expect(result.chapterRemainingSec).toBeNull();
|
||||
expect(result.bookRemainingSec).toBeNull();
|
||||
expect(result.finishAtTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('uses book remaining to compute finish time', () => {
|
||||
const progress = createProgress({
|
||||
sectionMin: 1,
|
||||
totalMin: 2,
|
||||
pageCurrent: 99,
|
||||
pageTotal: 100,
|
||||
});
|
||||
|
||||
const result = estimateTTSTime(progress, 1, 1000);
|
||||
|
||||
expect(result.bookRemainingSec).toBe(120);
|
||||
expect(result.finishAtTimestamp).toBe(121000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TxtToEpubConverter } from '@/utils/txt';
|
||||
|
||||
type TestChapter = {
|
||||
title: string;
|
||||
content: string;
|
||||
isVolume: boolean;
|
||||
};
|
||||
|
||||
type TestMetadata = {
|
||||
bookTitle: string;
|
||||
author: string;
|
||||
language: string;
|
||||
identifier: string;
|
||||
};
|
||||
|
||||
type TxtConverterPrivateAPI = {
|
||||
detectEncoding(buffer: ArrayBuffer): string | undefined;
|
||||
createEpub(chapters: TestChapter[], metadata: TestMetadata): Promise<Blob>;
|
||||
};
|
||||
|
||||
type TxtConverterFlowPrivateAPI = TxtConverterPrivateAPI & {
|
||||
convert(options: { file: File; author?: string; language?: string }): Promise<{
|
||||
chapterCount: number;
|
||||
}>;
|
||||
extractChapters(
|
||||
txtContent: string,
|
||||
metadata: TestMetadata,
|
||||
option: { linesBetweenSegments: number; fallbackParagraphsPerChapter: number },
|
||||
): TestChapter[];
|
||||
probeChapterCount(
|
||||
txtContent: string,
|
||||
metadata: TestMetadata,
|
||||
option: { linesBetweenSegments: number; fallbackParagraphsPerChapter: number },
|
||||
): number;
|
||||
iterateSegmentsFromTextChunks(
|
||||
chunks: Iterable<string>,
|
||||
linesBetweenSegments: number,
|
||||
): Generator<string>;
|
||||
detectEncodingFromFile(file: File): Promise<string | undefined>;
|
||||
extractChaptersFromFileBySegments(
|
||||
file: File,
|
||||
encoding: string,
|
||||
metadata: TestMetadata,
|
||||
option: { linesBetweenSegments: number; fallbackParagraphsPerChapter: number },
|
||||
): Promise<TestChapter[]>;
|
||||
probeChapterCountFromFileBySegments(
|
||||
file: File,
|
||||
encoding: string,
|
||||
metadata: TestMetadata,
|
||||
option: { linesBetweenSegments: number; fallbackParagraphsPerChapter: number },
|
||||
): Promise<number>;
|
||||
};
|
||||
|
||||
const getBufferSize = (input?: BufferSource): number => {
|
||||
if (!input) return 0;
|
||||
return input instanceof ArrayBuffer ? input.byteLength : input.byteLength;
|
||||
};
|
||||
|
||||
describe('TxtToEpubConverter', () => {
|
||||
it('convert should choose 8 -> 7 when probe detects multiple chapters', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const calls: number[] = [];
|
||||
|
||||
converter.detectEncoding = () => 'utf-8';
|
||||
converter.createEpub = async () => new Blob();
|
||||
converter.extractChapters = (_, __, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
if (option.linesBetweenSegments === 8) {
|
||||
return [{ title: 'Only', content: 'c', isVolume: false }];
|
||||
}
|
||||
if (option.linesBetweenSegments === 7) {
|
||||
return [
|
||||
{ title: 'A', content: 'a', isVolume: false },
|
||||
{ title: 'B', content: 'b', isVolume: false },
|
||||
];
|
||||
}
|
||||
return [{ title: 'Fallback', content: 'f', isVolume: false }];
|
||||
};
|
||||
converter.probeChapterCount = (_, __, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
return 2;
|
||||
};
|
||||
|
||||
const file = new File(['dummy content'], 'sample.txt');
|
||||
const result = await converter.convert({ file });
|
||||
|
||||
expect(calls).toEqual([8, 7, 7]);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('convert should choose 8 -> 6 when probe does not detect multiple chapters', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const calls: number[] = [];
|
||||
|
||||
converter.detectEncoding = () => 'utf-8';
|
||||
converter.createEpub = async () => new Blob();
|
||||
converter.extractChapters = (_, __, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
if (option.linesBetweenSegments === 8) {
|
||||
return [{ title: 'Only', content: 'c', isVolume: false }];
|
||||
}
|
||||
if (option.linesBetweenSegments === 6) {
|
||||
return [
|
||||
{ title: 'A', content: 'a', isVolume: false },
|
||||
{ title: 'B', content: 'b', isVolume: false },
|
||||
];
|
||||
}
|
||||
return [{ title: 'Single', content: 's', isVolume: false }];
|
||||
};
|
||||
converter.probeChapterCount = (_, __, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
return 1;
|
||||
};
|
||||
|
||||
const file = new File(['dummy content'], 'sample.txt');
|
||||
const result = await converter.convert({ file });
|
||||
|
||||
expect(calls).toEqual([8, 7, 6]);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('detectEncoding should probe UTF-8 with sampled buffers only', () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterPrivateAPI;
|
||||
const fullSize = 220 * 1024;
|
||||
const buffer = new TextEncoder().encode('a'.repeat(fullSize)).buffer;
|
||||
|
||||
const OriginalTextDecoder = globalThis.TextDecoder;
|
||||
const decodeSizes: number[] = [];
|
||||
|
||||
class RecordingTextDecoder extends OriginalTextDecoder {
|
||||
override decode(input?: BufferSource, options?: TextDecodeOptions): string {
|
||||
decodeSizes.push(getBufferSize(input));
|
||||
return super.decode(input, options);
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as { TextDecoder: typeof TextDecoder }).TextDecoder =
|
||||
RecordingTextDecoder as typeof TextDecoder;
|
||||
try {
|
||||
expect(converter.detectEncoding(buffer)).toBe('utf-8');
|
||||
} finally {
|
||||
(globalThis as { TextDecoder: typeof TextDecoder }).TextDecoder = OriginalTextDecoder;
|
||||
}
|
||||
|
||||
expect(Math.max(...decodeSizes)).toBeLessThanOrEqual(64 * 1024);
|
||||
expect(decodeSizes).toContain(8192);
|
||||
expect(decodeSizes).not.toContain(fullSize);
|
||||
});
|
||||
|
||||
it('createEpub should use metadata language for chapter lang attributes', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterPrivateAPI;
|
||||
const chapters: TestChapter[] = [
|
||||
{
|
||||
title: 'Chapter 1',
|
||||
content: '<h2>Chapter 1</h2><p>Hello world</p>',
|
||||
isVolume: false,
|
||||
},
|
||||
];
|
||||
const metadata: TestMetadata = {
|
||||
bookTitle: 'Sample Book',
|
||||
author: 'Sample Author',
|
||||
language: 'zh',
|
||||
identifier: 'sample-id',
|
||||
};
|
||||
|
||||
const blob = await converter.createEpub(chapters, metadata);
|
||||
const { ZipReader, BlobReader, TextWriter } = await import('@zip.js/zip.js');
|
||||
const reader = new ZipReader(new BlobReader(blob));
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
const chapterEntry = entries.find((entry) => entry.filename === 'OEBPS/chapter1.xhtml');
|
||||
expect(chapterEntry).toBeDefined();
|
||||
const chapterContent = await chapterEntry!.getData(new TextWriter());
|
||||
expect(chapterContent).toContain('lang="zh"');
|
||||
expect(chapterContent).toContain('xml:lang="zh"');
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('iterateSegmentsFromTextChunks should split by 8 newlines across chunk boundaries', () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const chunks = ['Segment A\n\n\n\n', '\n\n\n\nSegment B'];
|
||||
|
||||
const segments = Array.from(converter.iterateSegmentsFromTextChunks(chunks, 8));
|
||||
|
||||
expect(segments).toEqual(['Segment A', 'Segment B']);
|
||||
});
|
||||
|
||||
it('convert should use chunked path for large files without calling file.arrayBuffer', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const calls: number[] = [];
|
||||
let arrayBufferCalled = false;
|
||||
const backingBlob = new Blob(['Header line\n\n\n\n\n\n\n\nChapter content']);
|
||||
|
||||
const largeFile = {
|
||||
name: 'large.txt',
|
||||
size: 9 * 1024 * 1024,
|
||||
slice: (start?: number, end?: number) => backingBlob.slice(start, end),
|
||||
stream: () => backingBlob.stream(),
|
||||
arrayBuffer: async () => {
|
||||
arrayBufferCalled = true;
|
||||
throw new Error('large path should not call file.arrayBuffer');
|
||||
},
|
||||
} as unknown as File;
|
||||
|
||||
converter.detectEncodingFromFile = async () => 'utf-8';
|
||||
converter.createEpub = async () => new Blob();
|
||||
converter.extractChaptersFromFileBySegments = async (_, __, ___, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
if (option.linesBetweenSegments === 8) {
|
||||
return [{ title: 'Only', content: 'c', isVolume: false }];
|
||||
}
|
||||
if (option.linesBetweenSegments === 7) {
|
||||
return [
|
||||
{ title: 'A', content: 'a', isVolume: false },
|
||||
{ title: 'B', content: 'b', isVolume: false },
|
||||
];
|
||||
}
|
||||
return [{ title: 'Fallback', content: 'f', isVolume: false }];
|
||||
};
|
||||
converter.probeChapterCountFromFileBySegments = async (_, __, ___, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
return 2;
|
||||
};
|
||||
|
||||
const result = await converter.convert({ file: largeFile });
|
||||
|
||||
expect(arrayBufferCalled).toBe(false);
|
||||
expect(calls).toEqual([8, 7, 7]);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('convert large file should execute real chunked extraction without file.arrayBuffer', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
let arrayBufferCalled = false;
|
||||
const backingBlob = new Blob(['Segment A\n\n\n\n\n\n\n\nSegment B']);
|
||||
|
||||
const largeFile = {
|
||||
name: 'large.txt',
|
||||
size: 9 * 1024 * 1024,
|
||||
slice: (start?: number, end?: number) => backingBlob.slice(start, end),
|
||||
stream: () => backingBlob.stream(),
|
||||
arrayBuffer: async () => {
|
||||
arrayBufferCalled = true;
|
||||
throw new Error('large path should not call file.arrayBuffer');
|
||||
},
|
||||
} as unknown as File;
|
||||
|
||||
converter.createEpub = async () => new Blob();
|
||||
|
||||
const result = await converter.convert({ file: largeFile });
|
||||
|
||||
expect(arrayBufferCalled).toBe(false);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('iterateSegmentsFromFile should cancel stream on early return', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI & {
|
||||
iterateSegmentsFromFile(
|
||||
file: File,
|
||||
encoding: string,
|
||||
linesBetweenSegments: number,
|
||||
): AsyncGenerator<string>;
|
||||
};
|
||||
const encoder = new TextEncoder();
|
||||
let cancelled = false;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('Segment A\n\n\n\n\n\n\n\nSegment B'));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
|
||||
const file = {
|
||||
stream: () => stream,
|
||||
} as unknown as File;
|
||||
|
||||
const iterator = converter.iterateSegmentsFromFile(file, 'utf-8', 8);
|
||||
const first = await iterator.next();
|
||||
expect(first.value).toBe('Segment A');
|
||||
await iterator.return(undefined);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { ViewTransitions } from 'next-view-transitions';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
@@ -72,9 +73,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta name='twitter:image' content={previewImage} />
|
||||
</head>
|
||||
<body>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
<ViewTransitions>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
</ViewTransitions>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ interface BookshelfProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handlePushLibrary: () => Promise<void>;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
}
|
||||
@@ -69,6 +70,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleLibraryNavigation,
|
||||
handlePushLibrary,
|
||||
booksTransferProgress,
|
||||
}) => {
|
||||
@@ -426,6 +428,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
|
||||
@@ -434,7 +437,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
))}
|
||||
{viewMode === 'grid' && currentBookshelfItems.length > 0 && (
|
||||
<div
|
||||
className={clsx('mx-0 my-2 sm:mx-4 sm:my-4')}
|
||||
className={clsx('bookshelf-import-item mx-0 my-2 sm:mx-4 sm:my-4')}
|
||||
style={
|
||||
coverFit === 'fit' && viewMode === 'grid'
|
||||
? {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useTransitionRouter } from 'next-view-transitions';
|
||||
import { navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -99,6 +99,7 @@ interface BookshelfItemProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
|
||||
}
|
||||
|
||||
@@ -116,11 +117,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleBookDownload,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useTransitionRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
@@ -179,15 +180,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
if (isSelectMode) {
|
||||
toggleSelection(group.id);
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('group', group.id);
|
||||
setTimeout(() => {
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}, 0);
|
||||
handleLibraryNavigation(group.id);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isSelectMode, searchParams],
|
||||
[isSelectMode, handleLibraryNavigation],
|
||||
);
|
||||
|
||||
const bookContextMenuHandler = async (book: Book) => {
|
||||
|
||||
@@ -103,7 +103,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
style={{
|
||||
marginTop: appService?.hasSafeAreaInset
|
||||
? `max(${insets.top}px, ${systemUIVisible ? statusBarHeight : 0}px)`
|
||||
: '0px',
|
||||
: appService?.hasTrafficLight
|
||||
? '-2px'
|
||||
: '0px',
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
|
||||
|
||||
@@ -26,17 +26,19 @@ const SetStatusAlert: React.FC<SetStatusAlertProps> = ({
|
||||
label: _('Mark as Unread'),
|
||||
status: 'unread' as ReadingStatus,
|
||||
className:
|
||||
'bg-amber-500/15 text-amber-600 dark:text-amber-400 hover:bg-amber-500/25 border-amber-500/20',
|
||||
'not-eink:bg-amber-500/15 not-eink:text-amber-600 dark:not-eink:text-amber-400 not-eink:border-amber-500/20 eink-bordered',
|
||||
},
|
||||
{
|
||||
label: _('Mark as Finished'),
|
||||
status: 'finished' as ReadingStatus,
|
||||
className: 'bg-success/15 text-success hover:bg-success/25 border-success/20',
|
||||
className:
|
||||
'not-eink:bg-success/15 not-eink:text-success not-eink:border-success/20 eink-bordered',
|
||||
},
|
||||
{
|
||||
label: _('Clear Status'),
|
||||
status: undefined,
|
||||
className: 'bg-base-300 text-base-content hover:bg-base-content/10 border-base-content/10',
|
||||
className:
|
||||
'not-eink:bg-base-300 not-eink:text-base-content not-eink:border-base-content/10 eink-bordered',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -89,9 +91,10 @@ const SetStatusAlert: React.FC<SetStatusAlertProps> = ({
|
||||
))}
|
||||
<button
|
||||
className={clsx(
|
||||
'border-base-content/10 hidden items-center gap-2 rounded-full border px-4 py-2',
|
||||
'bg-base-300 text-base-content shadow-sm',
|
||||
'hover:bg-base-content/10 transition-all duration-200 ease-out active:scale-[0.97]',
|
||||
'hidden items-center gap-2 rounded-full border px-4 py-2',
|
||||
'not-eink:bg-base-300 not-eink:text-base-content not-eink:border-base-content/10 not-eink:shadow-sm',
|
||||
'eink-bordered',
|
||||
'transition-all duration-200 ease-out active:scale-[0.97]',
|
||||
'sm:flex',
|
||||
)}
|
||||
onClick={onCancel}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { setMigrateDataDirDialogVisible } from '@/app/library/components/Migrate
|
||||
import { requestStoragePermission } from '@/utils/permission';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { selectDirectory } from '@/utils/bridge';
|
||||
import { formatLocaleDateTime } from '@/utils/book';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
@@ -277,7 +278,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
label={
|
||||
settings.lastSyncedAtBooks
|
||||
? _('Synced at {{time}}', {
|
||||
time: new Date(settings.lastSyncedAtBooks).toLocaleString(),
|
||||
time: formatLocaleDateTime(settings.lastSyncedAtBooks),
|
||||
})
|
||||
: _('Never synced')
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
{ label: _('Title'), value: LibrarySortByType.Title },
|
||||
{ label: _('Author'), value: LibrarySortByType.Author },
|
||||
{ label: _('Format'), value: LibrarySortByType.Format },
|
||||
{ label: _('Series'), value: LibrarySortByType.Series },
|
||||
{ label: _('Date Read'), value: LibrarySortByType.Updated },
|
||||
{ label: _('Date Added'), value: LibrarySortByType.Created },
|
||||
{ label: _('Date Published'), value: LibrarySortByType.Published },
|
||||
|
||||
@@ -4,7 +4,8 @@ import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { MdChevronRight } from 'react-icons/md';
|
||||
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
|
||||
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
|
||||
import { useTransitionRouter } from 'next-view-transitions';
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
@@ -82,7 +83,7 @@ const LibraryPageWithSearchParams = () => {
|
||||
};
|
||||
|
||||
const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => {
|
||||
const router = useRouter();
|
||||
const router = useTransitionRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const {
|
||||
@@ -134,6 +135,53 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`;
|
||||
|
||||
const saveScrollPosition = (group: string) => {
|
||||
const viewport = osRef.current?.osInstance()?.elements().viewport;
|
||||
if (viewport) {
|
||||
const scrollTop = viewport.scrollTop;
|
||||
sessionStorage.setItem(getScrollKey(group), scrollTop.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const restoreScrollPosition = useCallback((group: string) => {
|
||||
const savedPosition = sessionStorage.getItem(getScrollKey(group));
|
||||
if (savedPosition) {
|
||||
const scrollTop = parseInt(savedPosition, 10);
|
||||
const viewport = osRef.current?.osInstance()?.elements().viewport;
|
||||
if (viewport) {
|
||||
viewport.scrollTop = scrollTop;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Unified navigation function that handles scroll position and direction
|
||||
const handleLibraryNavigation = useCallback(
|
||||
(targetGroup: string) => {
|
||||
const currentGroup = searchParams?.get('group') || '';
|
||||
|
||||
// Save current scroll position BEFORE navigation
|
||||
saveScrollPosition(currentGroup);
|
||||
|
||||
// Detect and set navigation direction
|
||||
const direction = currentGroup && !targetGroup ? 'back' : 'forward';
|
||||
document.documentElement.setAttribute('data-nav-direction', direction);
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (targetGroup) {
|
||||
params.set('group', targetGroup);
|
||||
} else {
|
||||
params.delete('group');
|
||||
}
|
||||
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[searchParams, router],
|
||||
);
|
||||
|
||||
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
|
||||
useUICSS();
|
||||
|
||||
@@ -390,6 +438,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setCurrentGroupPath(groupName);
|
||||
}, [libraryBooks, searchParams, getGroupName]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
restoreScrollPosition(group);
|
||||
}, [searchParams, restoreScrollPosition]);
|
||||
|
||||
// Track current series/author group for navigation header
|
||||
useEffect(() => {
|
||||
const groupId = searchParams?.get('group') || '';
|
||||
@@ -746,19 +799,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
};
|
||||
|
||||
const handleNavigateToPath = (path: string | undefined) => {
|
||||
const group = path ? getGroupId(path) : '';
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (group) {
|
||||
params.set('group', group);
|
||||
} else {
|
||||
params.delete('group');
|
||||
}
|
||||
const group = path ? getGroupId(path) || '' : '';
|
||||
setIsSelectAll(false);
|
||||
setIsSelectNone(false);
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
setTimeout(() => {
|
||||
setCurrentGroupPath(path);
|
||||
}, 300);
|
||||
handleLibraryNavigation(group);
|
||||
};
|
||||
|
||||
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
|
||||
@@ -890,6 +934,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
handleBookDelete={handleBookDelete('both')}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
|
||||
@@ -105,6 +105,8 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
case LibrarySortByType.Format:
|
||||
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
|
||||
case LibrarySortByType.Series:
|
||||
return (a.metadata?.seriesIndex || 0) - (b.metadata?.seriesIndex || 0);
|
||||
case LibrarySortByType.Published:
|
||||
const aPublished = a.metadata?.published || '0001-01-01';
|
||||
const bPublished = b.metadata?.published || '0001-01-01';
|
||||
|
||||
@@ -115,7 +115,7 @@ export function PublicationView({
|
||||
return _('Download');
|
||||
};
|
||||
|
||||
const content = publication.metadata?.[SYMBOL.CONTENT];
|
||||
const content = publication.metadata?.[SYMBOL.CONTENT] || publication.metadata?.content;
|
||||
const description = publication.metadata?.description;
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,6 +47,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
cfi,
|
||||
text: truncatedText ? truncatedText : `${getCurrentPage(bookData.book!, progress)}`,
|
||||
note: '',
|
||||
page: progress.page,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -57,6 +58,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
existingBookmark.deletedAt = null;
|
||||
existingBookmark.updatedAt = Date.now();
|
||||
existingBookmark.text = bookmark.text;
|
||||
existingBookmark.page = bookmark.page;
|
||||
} else {
|
||||
bookmarks.push(bookmark);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ import DoubleBorder from './DoubleBorder';
|
||||
interface BooksGridProps {
|
||||
bookKeys: string[];
|
||||
onCloseBook: (bookKey: string) => void;
|
||||
onGoToLibrary: () => void;
|
||||
}
|
||||
|
||||
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibrary }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
@@ -124,6 +125,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
isTopLeft={index === 0}
|
||||
isHoveredAnim={bookKeys.length > 2}
|
||||
onCloseBook={onCloseBook}
|
||||
onGoToLibrary={onGoToLibrary}
|
||||
gridInsets={gridInsets}
|
||||
onDropdownOpenChange={(isOpen) => setDropdownOpenBook(isOpen ? bookKey : '')}
|
||||
/>
|
||||
@@ -210,7 +212,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
{showFooter && (
|
||||
<ProgressInfoView
|
||||
bookKey={bookKey}
|
||||
sections={bookDoc.sections || []}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookDoc, getDirection } from '@/libs/document';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { convertBlobUrlToDataUrl, BookDoc, getDirection } from '@/libs/document';
|
||||
import { BookConfig, PageInfo } from '@/types/book';
|
||||
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -10,7 +10,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
|
||||
import { useMouseEvent, useTouchEvent, useLongPressEvent } from '../hooks/useIframeEvents';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
addLongPressListeners,
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
@@ -59,9 +60,11 @@ import { getViewInsets } from '@/utils/insets';
|
||||
import { handleA11yNavigation } from '@/utils/a11y';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { ParagraphControl } from './paragraph';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import KOSyncConflictResolver from './KOSyncResolver';
|
||||
import { ParagraphControl } from './paragraph';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import TableViewer from './TableViewer';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -121,12 +124,16 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const atEnd = viewRef.current?.renderer.atEnd || false;
|
||||
const { current, next, total } = detail.location as PageInfo;
|
||||
const currentPage = atEnd && total > 0 ? total - 1 : current;
|
||||
const pageInfo = { current: currentPage, next, total };
|
||||
setProgress(
|
||||
bookKey,
|
||||
detail.cfi,
|
||||
detail.tocItem,
|
||||
detail.section,
|
||||
detail.location,
|
||||
pageInfo,
|
||||
detail.time,
|
||||
detail.range,
|
||||
);
|
||||
@@ -247,6 +254,7 @@ const FoliateViewer: React.FC<{
|
||||
detail.doc.addEventListener('touchstart', handleTouchStart.bind(null, bookKey));
|
||||
detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey));
|
||||
detail.doc.addEventListener('touchend', handleTouchEnd.bind(null, bookKey));
|
||||
addLongPressListeners(bookKey, detail.doc);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -288,6 +296,88 @@ const FoliateViewer: React.FC<{
|
||||
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip, handleContinuousScroll);
|
||||
const touchHandlers = useTouchEvent(bookKey, handlePageFlip, handleContinuousScroll);
|
||||
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
|
||||
const [imageList, setImageList] = useState<{ src: string; cfi: string | null }[]>([]);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState<number>(0);
|
||||
|
||||
const handleImagePress = useCallback(async (src: string) => {
|
||||
try {
|
||||
// Get all images from the current document
|
||||
const docs = viewRef.current?.renderer.getContents();
|
||||
const allImages: { src: string; cfi: string | null }[] = [];
|
||||
|
||||
docs?.forEach(({ doc, index }) => {
|
||||
const images = doc.querySelectorAll('img');
|
||||
images.forEach((img) => {
|
||||
if (img.src && index !== undefined && img.parentNode) {
|
||||
const range = doc.createRange();
|
||||
range.selectNodeContents(img);
|
||||
const cfi = viewRef.current?.getCFI(index, range) || null;
|
||||
allImages.push({ src: img.src, cfi });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Find the index of the pressed image
|
||||
const index = allImages.findIndex((img) => img.src === src);
|
||||
|
||||
setImageList(allImages);
|
||||
setCurrentImageIndex(index >= 0 ? index : 0);
|
||||
|
||||
const dataUrl = await convertBlobUrlToDataUrl(src);
|
||||
setSelectedImage(dataUrl);
|
||||
} catch (error) {
|
||||
console.error('Failed to load image:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTablePress = useCallback((html: string) => {
|
||||
setSelectedTableHtml(html);
|
||||
}, []);
|
||||
|
||||
const handlePreviousImage = useCallback(async () => {
|
||||
if (currentImageIndex > 0 && imageList.length > 0) {
|
||||
const newIndex = currentImageIndex - 1;
|
||||
setCurrentImageIndex(newIndex);
|
||||
try {
|
||||
const { src, cfi } = imageList[newIndex]!;
|
||||
const dataUrl = await convertBlobUrlToDataUrl(src);
|
||||
setSelectedImage(dataUrl);
|
||||
if (cfi && viewRef.current) {
|
||||
viewRef.current?.goTo(cfi);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load previous image:', error);
|
||||
}
|
||||
}
|
||||
}, [currentImageIndex, imageList]);
|
||||
|
||||
const handleNextImage = useCallback(async () => {
|
||||
if (currentImageIndex < imageList.length - 1 && imageList.length > 0) {
|
||||
const newIndex = currentImageIndex + 1;
|
||||
setCurrentImageIndex(newIndex);
|
||||
try {
|
||||
const { src, cfi } = imageList[newIndex]!;
|
||||
const dataUrl = await convertBlobUrlToDataUrl(src);
|
||||
setSelectedImage(dataUrl);
|
||||
if (cfi && viewRef.current) {
|
||||
viewRef.current?.goTo(cfi);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load next image:', error);
|
||||
}
|
||||
}
|
||||
}, [currentImageIndex, imageList]);
|
||||
|
||||
const handleCloseImage = useCallback(() => {
|
||||
setSelectedImage(null);
|
||||
setImageList([]);
|
||||
setCurrentImageIndex(0);
|
||||
}, []);
|
||||
|
||||
useLongPressEvent(bookKey, handleImagePress, handleTablePress);
|
||||
|
||||
useFoliateEvents(viewRef.current, {
|
||||
onLoad: docLoadHandler,
|
||||
onRelocate: progressRelocateHandler,
|
||||
@@ -501,6 +591,21 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedImage && (
|
||||
<ImageViewer
|
||||
src={selectedImage}
|
||||
onClose={handleCloseImage}
|
||||
onPrevious={currentImageIndex > 0 ? handlePreviousImage : undefined}
|
||||
onNext={currentImageIndex < imageList.length - 1 ? handleNextImage : undefined}
|
||||
/>
|
||||
)}
|
||||
{selectedTableHtml && (
|
||||
<TableViewer
|
||||
html={selectedTableHtml}
|
||||
isDarkMode={isDarkMode}
|
||||
onClose={() => setSelectedTableHtml(null)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { VscLibrary } from 'react-icons/vsc';
|
||||
|
||||
import { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -34,6 +35,7 @@ interface HeaderBarProps {
|
||||
isHoveredAnim: boolean;
|
||||
gridInsets: Insets;
|
||||
onCloseBook: (bookKey: string) => void;
|
||||
onGoToLibrary: () => void;
|
||||
onDropdownOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -44,6 +46,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
isHoveredAnim,
|
||||
gridInsets,
|
||||
onCloseBook,
|
||||
onGoToLibrary,
|
||||
onDropdownOpenChange,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
@@ -60,6 +63,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const view = getView(bookKey);
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
|
||||
@@ -147,7 +151,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
className={clsx(
|
||||
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
|
||||
`shadow-xs transition-[opacity,margin-top] duration-300`,
|
||||
trafficLightInHeader ? 'pl-20' : 'pl-4',
|
||||
trafficLightInHeader ? 'pl-20' : isSideBarVisible ? 'ps-4' : 'ps-4 sm:ps-1.5',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
@@ -167,9 +171,18 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className='header-tools-start bg-base-100 sidebar-bookmark-toggler z-20 flex h-full items-center gap-x-4 pe-2 max-[350px]:gap-x-2'>
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
{!isSideBarVisible && (
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
title={_('Go to Library')}
|
||||
className='btn btn-ghost hidden h-8 min-h-8 w-8 p-0 sm:flex'
|
||||
onClick={onGoToLibrary}
|
||||
>
|
||||
<VscLibrary size={iconSize18} className='fill-base-content' />
|
||||
</button>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
{enableAnnotationQuickActions && (
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import ZoomControls from './ZoomControls';
|
||||
|
||||
interface ImageViewerProps {
|
||||
src: string | null;
|
||||
onClose: () => void;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 8;
|
||||
const ZOOM_SPEED = 0.1;
|
||||
const MOBILE_ZOOM_SPEED = 0.001;
|
||||
const ZOOM_BIAS = 1.05;
|
||||
|
||||
const ImageViewer: React.FC<ImageViewerProps> = ({ src, onClose, onPrevious, onNext }) => {
|
||||
const _ = useTranslation();
|
||||
const [scale, setScale] = useState(1);
|
||||
const [zoomSpeed, setZoomSpeed] = useState(0.1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showZoomLabel, setShowZoomLabel] = useState(true);
|
||||
const lastTouchDistance = useRef<number>(0);
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
const wasDragging = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const hideZoomLabelAfterDelay = () => {
|
||||
if (zoomLabelTimeoutRef.current) {
|
||||
clearTimeout(zoomLabelTimeoutRef.current);
|
||||
}
|
||||
setShowZoomLabel(true);
|
||||
zoomLabelTimeoutRef.current = setTimeout(() => {
|
||||
setShowZoomLabel(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const newScale = Math.min(scale + ZOOM_SPEED, MAX_SCALE);
|
||||
setScale(newScale);
|
||||
setZoomSpeed(ZOOM_SPEED * ZOOM_BIAS * newScale);
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
const newScale = Math.max(scale - ZOOM_SPEED, MIN_SCALE);
|
||||
if (newScale <= 1) {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
setScale(newScale);
|
||||
setZoomSpeed(ZOOM_SPEED);
|
||||
} else {
|
||||
setScale(newScale);
|
||||
setZoomSpeed(ZOOM_SPEED * ZOOM_BIAS * newScale);
|
||||
}
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const handleResetZoom = () => {
|
||||
setScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
setZoomSpeed(ZOOM_SPEED);
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow key navigation
|
||||
if (e.key === 'ArrowLeft' && onPrevious) {
|
||||
e.preventDefault();
|
||||
handlePreviousImage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight' && onNext) {
|
||||
e.preventDefault();
|
||||
handleNextImage();
|
||||
return;
|
||||
}
|
||||
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
|
||||
if (isCtrlOrCmd) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
handleZoomIn();
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
handleZoomOut();
|
||||
} else if (e.key === '0') {
|
||||
handleResetZoom();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousImage = () => {
|
||||
if (onPrevious) {
|
||||
onPrevious();
|
||||
hideZoomLabelAfterDelay();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextImage = () => {
|
||||
if (onNext) {
|
||||
onNext();
|
||||
hideZoomLabelAfterDelay();
|
||||
}
|
||||
};
|
||||
|
||||
const getZoomedOffset = (
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
currentScale: number,
|
||||
nextScale: number,
|
||||
currentPos: { x: number; y: number },
|
||||
) => {
|
||||
const scaleChange = nextScale / currentScale;
|
||||
return {
|
||||
x: anchorX - (anchorX - currentPos.x) * scaleChange,
|
||||
y: anchorY - (anchorY - currentPos.y) * scaleChange,
|
||||
};
|
||||
};
|
||||
|
||||
// Grab Focus of modal and set up initial zoom label timeout
|
||||
useEffect(() => {
|
||||
containerRef.current?.focus();
|
||||
setTimeout(() => {
|
||||
hideZoomLabelAfterDelay();
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
if (zoomLabelTimeoutRef.current) {
|
||||
clearTimeout(zoomLabelTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
// Only zoom when Ctrl/Cmd is pressed for consistency with browser behavior
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
|
||||
if (!isCtrlOrCmd) {
|
||||
// Allow default behavior (no zoom without modifier)
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const delta = e.deltaY > 0 ? -zoomSpeed : zoomSpeed;
|
||||
const newScale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE);
|
||||
const newZoom = ZOOM_SPEED * ZOOM_BIAS * newScale;
|
||||
|
||||
if (newScale <= 1) {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
setScale(newScale);
|
||||
setZoomSpeed(ZOOM_SPEED);
|
||||
hideZoomLabelAfterDelay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mouse position relative to the container element
|
||||
const mouseX = e.clientX - rect.left - rect.width / 2;
|
||||
const mouseY = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
setPosition((prevPos) => {
|
||||
return getZoomedOffset(mouseX, mouseY, scale, newScale, prevPos);
|
||||
});
|
||||
|
||||
setScale(newScale);
|
||||
setZoomSpeed(newZoom);
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const handleImageMouseDown = (e: React.MouseEvent) => {
|
||||
if (isDragging || scale <= 1) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setIsDragging(true);
|
||||
wasDragging.current = false;
|
||||
dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y };
|
||||
};
|
||||
|
||||
const handleImageMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || scale <= 1) return;
|
||||
e.preventDefault();
|
||||
|
||||
wasDragging.current = true;
|
||||
const newX = e.clientX - dragStart.current.x;
|
||||
const newY = e.clientY - dragStart.current.y;
|
||||
|
||||
setPosition({ x: newX, y: newY });
|
||||
};
|
||||
|
||||
const handleImageMouseUp = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
const touches = e.touches;
|
||||
|
||||
if (touches.length === 1 && scale > 1) {
|
||||
// Pan Start
|
||||
setIsDragging(true);
|
||||
wasDragging.current = false;
|
||||
const touch = touches[0];
|
||||
if (!touch) return;
|
||||
dragStart.current = {
|
||||
x: touch.clientX - position.x,
|
||||
y: touch.clientY - position.y,
|
||||
};
|
||||
} else if (touches.length === 2) {
|
||||
// Pinch Start
|
||||
setIsDragging(true);
|
||||
wasDragging.current = false;
|
||||
const touch1 = touches[0];
|
||||
const touch2 = touches[1];
|
||||
if (!touch1 || !touch2) return;
|
||||
lastTouchDistance.current = Math.hypot(
|
||||
touch1.clientX - touch2.clientX,
|
||||
touch1.clientY - touch2.clientY,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
const touches = e.touches;
|
||||
|
||||
if (touches.length === 1 && scale > 1 && isDragging) {
|
||||
// Pan
|
||||
wasDragging.current = true;
|
||||
const touch = touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const newX = touch.clientX - dragStart.current.x;
|
||||
const newY = touch.clientY - dragStart.current.y;
|
||||
|
||||
setPosition({ x: newX, y: newY });
|
||||
});
|
||||
} else if (touches.length === 2) {
|
||||
// Pinch
|
||||
wasDragging.current = true;
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const touch1 = touches[0];
|
||||
const touch2 = touches[1];
|
||||
if (!touch1 || !touch2) return;
|
||||
const currentDistance = Math.hypot(
|
||||
touch1.clientX - touch2.clientX,
|
||||
touch1.clientY - touch2.clientY,
|
||||
);
|
||||
const distanceChange = currentDistance / lastTouchDistance.current;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const newScale = Math.min(Math.max(scale * distanceChange, MIN_SCALE), MAX_SCALE);
|
||||
const newZoom = MOBILE_ZOOM_SPEED * ZOOM_BIAS * distanceChange;
|
||||
|
||||
if (newScale <= 1) {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
setScale(newScale);
|
||||
setZoomSpeed(ZOOM_SPEED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Touch position relative to the container element
|
||||
const touchX = (touch1.clientX + touch2.clientX) / 2 - rect.left - rect.width / 2;
|
||||
const touchY = (touch1.clientY + touch2.clientY) / 2 - rect.top - rect.height / 2;
|
||||
|
||||
setPosition((prevPos) => {
|
||||
return getZoomedOffset(touchX, touchY, scale, newScale, prevPos);
|
||||
});
|
||||
|
||||
setScale(newScale);
|
||||
setZoomSpeed(newZoom);
|
||||
|
||||
lastTouchDistance.current = currentDistance;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
const touches = e.touches;
|
||||
if (touches.length === 1) {
|
||||
const touch = touches[0];
|
||||
if (!touch) return;
|
||||
dragStart.current = {
|
||||
x: touch.clientX - position.x,
|
||||
y: touch.clientY - position.y,
|
||||
};
|
||||
}
|
||||
if (touches.length === 0) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
setZoomSpeed(ZOOM_SPEED);
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
if (scale === 1) {
|
||||
const mouseX = e.clientX - rect.left - rect.width / 2;
|
||||
const mouseY = e.clientY - rect.top - rect.height / 2;
|
||||
const newScale = 2;
|
||||
|
||||
setPosition((prevPos) => {
|
||||
return getZoomedOffset(mouseX, mouseY, scale, newScale, prevPos);
|
||||
});
|
||||
setScale(newScale);
|
||||
hideZoomLabelAfterDelay();
|
||||
} else {
|
||||
handleReset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (wasDragging.current) {
|
||||
wasDragging.current = false;
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleImageClick = (e: React.MouseEvent) => {
|
||||
// Stop propagation to prevent closing when clicking on the image
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't toggle label if user was dragging
|
||||
if (wasDragging.current) {
|
||||
wasDragging.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setShowZoomLabel((prev) => !prev);
|
||||
};
|
||||
|
||||
const cursorStyle = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default';
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
role='button'
|
||||
aria-label={_('Image viewer')}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center outline-none'
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={handleWheel}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md absolute inset-0'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ZoomControls
|
||||
onClose={onClose}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
{onPrevious && showZoomLabel && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviousImage();
|
||||
}}
|
||||
className='eink-bordered absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white transition-all duration-300 hover:bg-black/70'
|
||||
aria-label={_('Previous Image')}
|
||||
title={_('Previous Image')}
|
||||
>
|
||||
<IoChevronBack className='h-8 w-8' />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onNext && showZoomLabel && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNextImage();
|
||||
}}
|
||||
className='eink-bordered absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white transition-all duration-300 hover:bg-black/70'
|
||||
aria-label={_('Next Image')}
|
||||
title={_('Next Image')}
|
||||
>
|
||||
<IoChevronForward className='h-8 w-8' />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
role='none'
|
||||
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden')}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
<Image
|
||||
src={decodeURIComponent(src)}
|
||||
ref={imageRef}
|
||||
alt={_('Zoomed')}
|
||||
className='transform-gpu select-none object-contain'
|
||||
draggable={false}
|
||||
width={0}
|
||||
height={0}
|
||||
sizes='100vw'
|
||||
onClick={handleImageClick}
|
||||
onMouseDown={handleImageMouseDown}
|
||||
onMouseMove={handleImageMouseMove}
|
||||
onMouseUp={handleImageMouseUp}
|
||||
onMouseLeave={handleImageMouseUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
style={{
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`,
|
||||
transition: 'transform 0.05s ease-out',
|
||||
cursor: cursorStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showZoomLabel && (
|
||||
<div
|
||||
aria-label={_('Zoom level')}
|
||||
className='zoom-level-label eink-bordered not-eink:text-white not-eink:bg-black/50 pointer-events-none absolute left-1/2 top-12 -translate-x-1/2 rounded-full px-3 py-1 text-sm transition-opacity duration-300'
|
||||
>
|
||||
{Math.round((scale * 100) / 5) * 5}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageViewer;
|
||||
@@ -67,7 +67,7 @@ const PageNavigationButtons: React.FC<PageNavigationButtonsProps> = ({
|
||||
'absolute left-2 -translate-y-1/2',
|
||||
'flex h-20 w-20 items-center justify-center',
|
||||
'transition-opacity duration-300 focus:outline-none',
|
||||
isPageNavigationButtonsVisible ? 'top-1/2 opacity-100' : 'bottom-12 opacity-0',
|
||||
isPageNavigationButtonsVisible ? 'top-1/2 z-10 opacity-100' : 'bottom-12 opacity-0',
|
||||
)}
|
||||
aria-label={getLeftPageLabel()}
|
||||
tabIndex={0}
|
||||
@@ -90,7 +90,7 @@ const PageNavigationButtons: React.FC<PageNavigationButtonsProps> = ({
|
||||
'absolute right-2 -translate-y-1/2',
|
||||
'flex h-20 w-20 items-center justify-center',
|
||||
'transition-opacity duration-300 focus:outline-none',
|
||||
isPageNavigationButtonsVisible ? 'top-1/2 opacity-100' : 'bottom-12 opacity-0',
|
||||
isPageNavigationButtonsVisible ? 'top-1/2 z-10 opacity-100' : 'bottom-12 opacity-0',
|
||||
)}
|
||||
aria-label={getRightPageLabel()}
|
||||
tabIndex={0}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
@@ -7,12 +8,10 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { formatNumber, formatProgress } from '@/utils/progress';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
import { SectionItem } from '@/libs/document';
|
||||
import { SIZE_PER_LOC, SIZE_PER_TIME_UNIT } from '@/services/constants';
|
||||
|
||||
interface PageInfoProps {
|
||||
bookKey: string;
|
||||
sections: SectionItem[];
|
||||
horizontalGap: number;
|
||||
contentInsets: Insets;
|
||||
gridInsets: Insets;
|
||||
@@ -20,7 +19,6 @@ interface PageInfoProps {
|
||||
|
||||
const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
bookKey,
|
||||
sections,
|
||||
horizontalGap,
|
||||
contentInsets,
|
||||
gridInsets,
|
||||
@@ -28,7 +26,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getProgress, getViewSettings } = useReaderStore();
|
||||
const { getProgress, getViewSettings, getView } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const progress = getProgress(bookKey);
|
||||
@@ -52,41 +51,33 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
const pageInfo = bookData?.isFixedLayout ? section : pageinfo;
|
||||
const progressInfo = formatProgress(pageInfo?.current, pageInfo?.total, template, localize, lang);
|
||||
|
||||
const activeHref = useMemo(() => progress?.sectionHref || null, [progress?.sectionHref]);
|
||||
const activeSection = useMemo(() => {
|
||||
if (!activeHref) return null;
|
||||
for (const section of sections) {
|
||||
if (section.id === activeHref) return section;
|
||||
const subitem = section.subitems?.find((sub) => sub.id === activeHref);
|
||||
if (subitem) return section;
|
||||
}
|
||||
return null;
|
||||
}, [activeHref, sections]);
|
||||
|
||||
const current = pageInfo?.current || 0;
|
||||
const total = activeSection?.location ? activeSection.location.next : pageInfo?.total || 0;
|
||||
const pages = Math.max(total - current, 0);
|
||||
const timeLeft =
|
||||
total - 1 >= current
|
||||
const { page = 0, pages = 0 } = view?.renderer || {};
|
||||
const current = page;
|
||||
const total = pages;
|
||||
const pagesLeft = Math.max(total - current - 1, 0);
|
||||
const timeLeftStr =
|
||||
total - 1 > current
|
||||
? _('{{time}} min left in chapter', {
|
||||
time: formatNumber(
|
||||
Math.round((pages * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
|
||||
Math.round((pagesLeft * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
|
||||
localize,
|
||||
lang,
|
||||
),
|
||||
})
|
||||
: '';
|
||||
const pageLeft =
|
||||
total - 1 >= current
|
||||
const pagesLeftStr =
|
||||
total - 1 > current
|
||||
? localize
|
||||
? _('{{number}} pages left in chapter', {
|
||||
number: formatNumber(pages, localize, lang),
|
||||
number: formatNumber(pagesLeft, localize, lang),
|
||||
})
|
||||
: _('{{count}} pages left in chapter', {
|
||||
count: pages,
|
||||
count: pagesLeft,
|
||||
})
|
||||
: '';
|
||||
|
||||
const showPagesLeft = total - 1 > current;
|
||||
|
||||
const [progressInfoMode, setProgressInfoMode] = useState(viewSettings.progressInfoMode);
|
||||
|
||||
const cycleProgressInfoModes = () => {
|
||||
@@ -151,8 +142,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
total: total,
|
||||
})
|
||||
: '',
|
||||
timeLeft,
|
||||
pageLeft,
|
||||
timeLeftStr,
|
||||
pagesLeftStr,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
@@ -183,9 +174,24 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
{(progressInfoMode === 'all' || progressInfoMode === 'remaining') && (
|
||||
<>
|
||||
{viewSettings.showRemainingTime ? (
|
||||
<span className='text-start'>{timeLeft}</span>
|
||||
) : viewSettings.showRemainingPages ? (
|
||||
<span className='text-start'>{pageLeft}</span>
|
||||
<span className='time-left-label text-start'>{timeLeftStr}</span>
|
||||
) : viewSettings.showRemainingPages && showPagesLeft ? (
|
||||
<span className='text-start'>
|
||||
{localize ? (
|
||||
<Trans
|
||||
i18nKey='{{number}} pages left in chapter'
|
||||
values={{ number: formatNumber(pagesLeft, localize, lang) }}
|
||||
>
|
||||
<span className='pages-left-number'>{'{{number}}'}</span>
|
||||
<span className='pages-left-label'>{' pages left in chapter'}</span>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans i18nKey='{{count}} pages left in chapter' count={pagesLeft}>
|
||||
<span className='pages-left-number'>{'{{count}}'}</span>
|
||||
<span className='pages-left-label'>{' pages left in chapter'}</span>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
@@ -193,7 +199,9 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
{(progressInfoMode === 'all' || progressInfoMode === 'progress') && (
|
||||
<>
|
||||
{viewSettings.showProgressInfo && (
|
||||
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
|
||||
<span
|
||||
className={clsx('progress-info-label text-end', isVertical ? 'mt-auto' : 'ms-auto')}
|
||||
>
|
||||
{progressInfo}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { KOSyncSettingsWindow } from './KOSyncSettings';
|
||||
import { ReadwiseSettingsWindow } from './ReadwiseSettings';
|
||||
import { ProofreadRulesManager } from './ProofreadRules';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
@@ -169,6 +170,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
<AboutWindow />
|
||||
<UpdaterWindow />
|
||||
<KOSyncSettingsWindow />
|
||||
<ReadwiseSettingsWindow />
|
||||
<ProofreadRulesManager />
|
||||
<Toast />
|
||||
</Suspense>
|
||||
|
||||
@@ -224,8 +224,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
return (
|
||||
<div className='reader-content full-height flex'>
|
||||
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
|
||||
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
|
||||
<SideBar />
|
||||
<BooksGrid
|
||||
bookKeys={bookKeys}
|
||||
onCloseBook={handleCloseBook}
|
||||
onGoToLibrary={handleCloseBooksToLibrary}
|
||||
/>
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
|
||||
<Notebook />
|
||||
{showDetailsBook && (
|
||||
|
||||
@@ -46,7 +46,6 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
color,
|
||||
bookFormat,
|
||||
viewSettings,
|
||||
gridInsets,
|
||||
}) => {
|
||||
const { envConfig } = useEnv();
|
||||
const { getProgress } = useReaderStore();
|
||||
@@ -69,6 +68,18 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
const rulerSize = calculateRulerSize(lines, viewSettings, bookFormat);
|
||||
const baseColor = READING_RULER_COLORS[color] || READING_RULER_COLORS['yellow'];
|
||||
|
||||
const clampPosition = useCallback(
|
||||
(pos: number, dimension: number) => {
|
||||
if (dimension <= 0) return Math.max(0, Math.min(100, pos));
|
||||
const halfPct = (rulerSize / 2 / dimension) * 100;
|
||||
if (halfPct >= 50) return 50;
|
||||
const min = halfPct;
|
||||
const max = 100 - halfPct;
|
||||
return Math.max(min, Math.min(max, pos));
|
||||
},
|
||||
[rulerSize],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledSave = useCallback(
|
||||
throttle((pos: number) => {
|
||||
@@ -106,7 +117,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
|
||||
// Auto-move ruler to first visible text on page change
|
||||
useEffect(() => {
|
||||
if (!progress?.pageinfo) return;
|
||||
if (!progress?.pageinfo || viewSettings.scrolled) return;
|
||||
|
||||
/**
|
||||
* Get the position of the first visible text element.
|
||||
@@ -187,9 +198,9 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
: (viewSettings.marginTopPx ?? 44);
|
||||
|
||||
const offset = textPosition ?? defaultOffset;
|
||||
const targetPosition = Math.max(
|
||||
5,
|
||||
Math.min(95, ((offset + rulerSize / 2) / containerDimension) * 100),
|
||||
const targetPosition = clampPosition(
|
||||
((offset + rulerSize / 2) / containerDimension) * 100,
|
||||
containerDimension,
|
||||
);
|
||||
|
||||
// Clear any existing animation timeout
|
||||
@@ -222,6 +233,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
progress?.pageinfo?.current,
|
||||
viewSettings.scrolled,
|
||||
isVertical,
|
||||
rtl,
|
||||
viewSettings.marginTopPx,
|
||||
@@ -237,7 +249,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
isDragging.current = true;
|
||||
// Disable animation during manual drag for immediate feedback
|
||||
setShouldAnimate(false);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
@@ -251,27 +263,38 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
|
||||
if (isVertical) {
|
||||
const relativeX = e.clientX - rect.left;
|
||||
newPosition = Math.max(0, Math.min(100, (relativeX / rect.width) * 100));
|
||||
newPosition = clampPosition((relativeX / rect.width) * 100, rect.width);
|
||||
} else {
|
||||
const relativeY = e.clientY - rect.top;
|
||||
newPosition = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
newPosition = clampPosition((relativeY / rect.height) * 100, rect.height);
|
||||
}
|
||||
setCurrentPosition(newPosition);
|
||||
currentPositionRef.current = newPosition;
|
||||
},
|
||||
[isVertical],
|
||||
[isVertical, clampPosition],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
throttledSave(currentPosition);
|
||||
},
|
||||
[currentPosition, throttledSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const dimension = isVertical ? containerSize.width : containerSize.height;
|
||||
if (!dimension || isDragging.current) return;
|
||||
const clamped = clampPosition(currentPositionRef.current, dimension);
|
||||
if (clamped !== currentPositionRef.current) {
|
||||
setCurrentPosition(clamped);
|
||||
currentPositionRef.current = clamped;
|
||||
throttledSave(clamped);
|
||||
}
|
||||
}, [containerSize.width, containerSize.height, isVertical, clampPosition, throttledSave]);
|
||||
|
||||
const fadeOpacity = Math.min(0.9, opacity);
|
||||
|
||||
// Calculate dimensions based on orientation
|
||||
@@ -291,11 +314,6 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
|
||||
const cssFilter = colorToFilter[color] || colorToFilter['yellow'];
|
||||
|
||||
// Insets based on orientation
|
||||
const containerStyle = isVertical
|
||||
? { left: `${gridInsets.left}px`, right: `${gridInsets.right}px` }
|
||||
: { top: `${gridInsets.top}px`, bottom: `${gridInsets.bottom}px` };
|
||||
|
||||
const backdropFilterStyle = {
|
||||
backdropFilter: cssFilter,
|
||||
WebkitBackdropFilter: cssFilter,
|
||||
@@ -314,7 +332,6 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
'pointer-events-none absolute inset-0 z-[5] transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={containerStyle}
|
||||
>
|
||||
{/* Left overlay */}
|
||||
<div
|
||||
@@ -337,7 +354,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
{/* Vertical ruler */}
|
||||
<div
|
||||
className={clsx(
|
||||
'ruler pointer-events-auto absolute bottom-0 top-0 my-2 cursor-col-resize rounded-2xl',
|
||||
'ruler pointer-events-auto absolute bottom-0 top-0 my-2 cursor-col-resize touch-none rounded-2xl',
|
||||
color === 'transparent' ? 'border-base-content/55 border' : '',
|
||||
)}
|
||||
style={{
|
||||
@@ -357,7 +374,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{/* extended touch area */}
|
||||
<div className='absolute inset-y-0 -left-2 -right-2' />
|
||||
<div className='absolute inset-y-0 -left-2 -right-2 touch-none' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -371,7 +388,6 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
'pointer-events-none absolute inset-0 z-[5] transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={containerStyle}
|
||||
>
|
||||
{/* Top overlay */}
|
||||
<div
|
||||
@@ -394,7 +410,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
{/* Horizontal ruler */}
|
||||
<div
|
||||
className={clsx(
|
||||
'ruler pointer-events-auto absolute left-0 right-0 mx-2 cursor-row-resize rounded-2xl',
|
||||
'ruler pointer-events-auto absolute left-0 right-0 mx-2 cursor-row-resize touch-none rounded-2xl',
|
||||
color === 'transparent' ? 'border-base-content/55 border' : '',
|
||||
)}
|
||||
style={{
|
||||
@@ -414,7 +430,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{/* Extended touch area */}
|
||||
<div className='absolute inset-x-0 -bottom-2 -top-2' />
|
||||
<div className='absolute inset-x-0 -bottom-2 -top-2 touch-none' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { ReadwiseClient } from '@/services/readwise';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export const setReadwiseSettingsWindowVisible = (visible: boolean) => {
|
||||
const dialog = document.getElementById('readwise_settings_window');
|
||||
if (dialog) {
|
||||
const event = new CustomEvent('setReadwiseSettingsVisibility', {
|
||||
detail: { visible },
|
||||
});
|
||||
dialog.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
export const ReadwiseSettingsWindow: React.FC = () => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
const isConfigured = !!settings.readwise?.accessToken;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCustomEvent = (event: CustomEvent) => {
|
||||
setIsOpen(event.detail.visible);
|
||||
if (event.detail.visible) {
|
||||
setAccessToken('');
|
||||
}
|
||||
};
|
||||
const el = document.getElementById('readwise_settings_window');
|
||||
el?.addEventListener('setReadwiseSettingsVisibility', handleCustomEvent as EventListener);
|
||||
return () => {
|
||||
el?.removeEventListener('setReadwiseSettingsVisibility', handleCustomEvent as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const client = new ReadwiseClient({ enabled: true, accessToken, lastSyncedAt: 0 });
|
||||
const { valid, isNetworkError } = await client.validateToken();
|
||||
if (valid) {
|
||||
const newSettings = {
|
||||
...settings,
|
||||
readwise: {
|
||||
enabled: true,
|
||||
accessToken,
|
||||
lastSyncedAt: settings.readwise?.lastSyncedAt ?? 0,
|
||||
},
|
||||
};
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
} else if (isNetworkError) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Unable to connect to Readwise. Please check your network connection.'),
|
||||
type: 'error',
|
||||
});
|
||||
} else {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Invalid Readwise access token'),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
setAccessToken('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
const newSettings = {
|
||||
...settings,
|
||||
readwise: { enabled: false, accessToken: '', lastSyncedAt: 0 },
|
||||
};
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
eventDispatcher.dispatch('toast', { message: _('Disconnected from Readwise'), type: 'info' });
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
const newSettings = {
|
||||
...settings,
|
||||
readwise: { ...settings.readwise, enabled: !settings.readwise?.enabled },
|
||||
};
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
};
|
||||
|
||||
const lastSyncedAt = settings.readwise?.lastSyncedAt ?? 0;
|
||||
const lastSyncedLabel = lastSyncedAt ? new Date(lastSyncedAt).toLocaleString() : _('Never');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id='readwise_settings_window'
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={_('Readwise Settings')}
|
||||
boxClassName='sm:!min-w-[520px] sm:h-auto'
|
||||
>
|
||||
{isOpen && (
|
||||
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
|
||||
{isConfigured ? (
|
||||
<>
|
||||
<div className='text-center'>
|
||||
<p className='text-base-content/80 text-sm'>{_('Connected to Readwise')}</p>
|
||||
<p className='text-base-content/60 mt-1 text-xs'>
|
||||
{_('Last synced: {{time}}', { time: lastSyncedLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex h-14 items-center justify-between'>
|
||||
<span className='text-base-content/80'>{_('Sync Enabled')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={settings.readwise?.enabled ?? false}
|
||||
onChange={handleToggleEnabled}
|
||||
/>
|
||||
</div>
|
||||
<button className='btn btn-outline btn-sm mt-2' onClick={handleDisconnect}>
|
||||
{_('Disconnect')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className='text-base-content/70 text-center text-sm'>
|
||||
{_('Connect your Readwise account to sync highlights.')}
|
||||
</p>
|
||||
<p className='text-base-content/60 text-center text-xs'>
|
||||
{_('Get your access token at')}{' '}
|
||||
<a
|
||||
href='https://readwise.io/access_token'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='link link-primary'
|
||||
>
|
||||
readwise.io/access_token
|
||||
</a>
|
||||
</p>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Access Token')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder={_('Paste your Readwise access token')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
spellCheck='false'
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !accessToken}
|
||||
>
|
||||
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { TbLayoutSidebar, TbLayoutSidebarFilled } from 'react-icons/tb';
|
||||
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import Button from '@/components/Button';
|
||||
@@ -13,7 +12,6 @@ interface SidebarTogglerProps {
|
||||
const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { sideBarBookKey, isSideBarVisible, setSideBarBookKey, toggleSideBar } = useSidebarStore();
|
||||
const { setHoveredBookKey } = useReaderStore();
|
||||
const handleToggleSidebar = () => {
|
||||
if (sideBarBookKey === bookKey) {
|
||||
toggleSideBar();
|
||||
@@ -21,7 +19,6 @@ const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
|
||||
setSideBarBookKey(bookKey);
|
||||
if (!isSideBarVisible) toggleSideBar();
|
||||
}
|
||||
setHoveredBookKey('');
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import ZoomControls from './ZoomControls';
|
||||
|
||||
interface TableViewerProps {
|
||||
html: string | null;
|
||||
isDarkMode: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 4;
|
||||
const ZOOM_SPEED = 0.1;
|
||||
|
||||
const TableViewer: React.FC<TableViewerProps> = ({ html, isDarkMode, onClose }) => {
|
||||
const _ = useTranslation();
|
||||
const [scale, setScale] = useState(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showZoomLabel, setShowZoomLabel] = useState(true);
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
const wasDragging = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const hideZoomLabelAfterDelay = () => {
|
||||
if (zoomLabelTimeoutRef.current) {
|
||||
clearTimeout(zoomLabelTimeoutRef.current);
|
||||
}
|
||||
setShowZoomLabel(true);
|
||||
zoomLabelTimeoutRef.current = setTimeout(() => {
|
||||
setShowZoomLabel(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const newScale = Math.min(scale + ZOOM_SPEED, MAX_SCALE);
|
||||
setScale(newScale);
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
const newScale = Math.max(scale - ZOOM_SPEED, MIN_SCALE);
|
||||
if (newScale <= 1) {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
setScale(newScale);
|
||||
} else {
|
||||
setScale(newScale);
|
||||
}
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const handleResetZoom = () => {
|
||||
setScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
|
||||
if (isCtrlOrCmd) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
handleZoomIn();
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
handleZoomOut();
|
||||
} else if (e.key === '0') {
|
||||
handleResetZoom();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
containerRef.current?.focus();
|
||||
setTimeout(() => {
|
||||
hideZoomLabelAfterDelay();
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
if (zoomLabelTimeoutRef.current) {
|
||||
clearTimeout(zoomLabelTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
if (!isCtrlOrCmd) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const delta = e.deltaY > 0 ? -ZOOM_SPEED : ZOOM_SPEED;
|
||||
const newScale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE);
|
||||
|
||||
if (newScale <= 1) {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
setScale(newScale);
|
||||
hideZoomLabelAfterDelay();
|
||||
return;
|
||||
}
|
||||
|
||||
setScale(newScale);
|
||||
hideZoomLabelAfterDelay();
|
||||
};
|
||||
|
||||
const handleContentMouseDown = (e: React.MouseEvent) => {
|
||||
if (isDragging || scale <= 1) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setIsDragging(true);
|
||||
wasDragging.current = false;
|
||||
dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y };
|
||||
};
|
||||
|
||||
const handleContentMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || scale <= 1) return;
|
||||
e.preventDefault();
|
||||
|
||||
wasDragging.current = true;
|
||||
const newX = e.clientX - dragStart.current.x;
|
||||
const newY = e.clientY - dragStart.current.y;
|
||||
|
||||
setPosition({ x: newX, y: newY });
|
||||
};
|
||||
|
||||
const handleContentMouseUp = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (wasDragging.current) {
|
||||
wasDragging.current = false;
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleContentClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (wasDragging.current) {
|
||||
wasDragging.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setShowZoomLabel((prev) => !prev);
|
||||
};
|
||||
|
||||
const cursorStyle = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default';
|
||||
|
||||
if (!html) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
role='button'
|
||||
aria-label={_('Table viewer')}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center outline-none'
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='absolute inset-0 bg-black/50 backdrop-blur-md'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ZoomControls
|
||||
onClose={onClose}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onReset={handleResetZoom}
|
||||
/>
|
||||
|
||||
<div
|
||||
role='presentation'
|
||||
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden')}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
<div
|
||||
role='presentation'
|
||||
ref={contentRef}
|
||||
className='table-viewer-content max-h-full max-w-full transform-gpu select-none overflow-auto rounded-lg shadow-2xl'
|
||||
onClick={handleContentClick}
|
||||
onMouseDown={handleContentMouseDown}
|
||||
onMouseMove={handleContentMouseMove}
|
||||
onMouseUp={handleContentMouseUp}
|
||||
onMouseLeave={handleContentMouseUp}
|
||||
style={{
|
||||
transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`,
|
||||
transition: 'transform 0.05s ease-out',
|
||||
cursor: cursorStyle,
|
||||
backgroundColor: isDarkMode ? '#1e1e1e' : '#ffffff',
|
||||
color: isDarkMode ? '#ffffff' : '#000000',
|
||||
padding: '24px',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showZoomLabel && (
|
||||
<div
|
||||
aria-label={_('Zoom level')}
|
||||
className='zoom-level-label eink-bordered not-eink:bg-black/50 not-eink:text-white pointer-events-none absolute left-1/2 top-12 -translate-x-1/2 rounded-full px-3 py-1 text-sm transition-opacity duration-300'
|
||||
>
|
||||
{Math.round((scale * 100) / 5) * 5}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.table-viewer-content :global(table) {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid ${isDarkMode ? '#444444' : '#cccccc'};
|
||||
}
|
||||
.table-viewer-content :global(td),
|
||||
.table-viewer-content :global(th) {
|
||||
border: 1px solid ${isDarkMode ? '#444444' : '#cccccc'};
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.table-viewer-content :global(th) {
|
||||
background-color: ${isDarkMode ? '#2a2a2a' : '#f5f5f5'};
|
||||
font-weight: 600;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableViewer;
|
||||
@@ -22,6 +22,7 @@ import { getStyles } from '@/utils/style';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { formatLocaleDateTime } from '@/utils/book';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
@@ -299,7 +300,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
|
||||
? _('Sign in to Sync')
|
||||
: lastSyncTime
|
||||
? _('Synced at {{time}}', {
|
||||
time: new Date(lastSyncTime).toLocaleString(),
|
||||
time: formatLocaleDateTime(lastSyncTime),
|
||||
})
|
||||
: _('Never synced')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { IoClose, IoExpand, IoAdd, IoRemove } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface ZoomControlsProps {
|
||||
onClose: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const ZoomControls: React.FC<ZoomControlsProps> = ({ onClose, onZoomIn, onZoomOut, onReset }) => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<div className='absolute right-4 top-4 z-10 grid grid-cols-1 gap-4 text-white'>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
|
||||
aria-label={_('Close')}
|
||||
title={_('Close')}
|
||||
>
|
||||
<IoClose className='h-6 w-6' />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onZoomIn}
|
||||
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
|
||||
aria-label={_('Zoom In')}
|
||||
title={_('Zoom In')}
|
||||
>
|
||||
<IoAdd className='h-6 w-6' />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onZoomOut}
|
||||
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
|
||||
aria-label={_('Zoom Out')}
|
||||
title={_('Zoom Out')}
|
||||
>
|
||||
<IoRemove className='h-6 w-6' />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
|
||||
aria-label={_('Reset Zoom')}
|
||||
title={_('Reset Zoom')}
|
||||
>
|
||||
<IoExpand className='h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZoomControls;
|
||||
@@ -3,23 +3,28 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookNote, HighlightColor } from '@/types/book';
|
||||
import { Point, TextSelection } from '@/utils/sel';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useAnnotationEditor } from '../../hooks/useAnnotationEditor';
|
||||
import { getHighlightColorHex } from '../../utils/annotatorUtil';
|
||||
import MagnifierLoupe from './MagnifierLoupe';
|
||||
|
||||
interface HandleProps {
|
||||
hidden?: boolean;
|
||||
position: Point;
|
||||
isVertical: boolean;
|
||||
type: 'start' | 'end';
|
||||
color: string;
|
||||
onDragStart: () => void;
|
||||
onDragStart: (pointerType: string) => void;
|
||||
onDrag: (point: Point) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
const Handle: React.FC<HandleProps> = ({
|
||||
hidden,
|
||||
position,
|
||||
isVertical,
|
||||
type,
|
||||
@@ -38,7 +43,7 @@ const Handle: React.FC<HandleProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging.current = true;
|
||||
onDragStart();
|
||||
onDragStart(e.pointerType);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[onDragStart],
|
||||
@@ -68,7 +73,10 @@ const Handle: React.FC<HandleProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-auto absolute z-50 cursor-grab touch-none active:cursor-grabbing'
|
||||
className={clsx(
|
||||
'pointer-events-auto absolute z-50 cursor-grab touch-none active:cursor-grabbing',
|
||||
hidden && 'hidden',
|
||||
)}
|
||||
style={{
|
||||
left: isVertical
|
||||
? type === 'start'
|
||||
@@ -143,25 +151,27 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
setSelection,
|
||||
onStartEdit,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const isEink = settings.globalViewSettings.isEink;
|
||||
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
|
||||
const { handlePositions, getHandlePositionsFromRange, handleAnnotationRangeChange } =
|
||||
useAnnotationEditor({ bookKey, annotation, getAnnotationText, setSelection });
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
const handleColorHex = getHighlightColorHex(settings, handleColor) ?? '#FFFF00';
|
||||
const draggingRef = useRef<'start' | 'end' | null>(null);
|
||||
const dragPointerTypeRef = useRef<string>('');
|
||||
const startRef = useRef<Point>({ x: 0, y: 0 });
|
||||
const endRef = useRef<Point>({ x: 0, y: 0 });
|
||||
const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | null>(null);
|
||||
const [currentStart, setCurrentStart] = useState<Point>({ x: 0, y: 0 });
|
||||
const [currentEnd, setCurrentEnd] = useState<Point>({ x: 0, y: 0 });
|
||||
const [loupePoint, setLoupePoint] = useState<Point | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
const range = selection.range;
|
||||
const positions = getHandlePositionsFromRange(range, isVertical);
|
||||
if (positions) {
|
||||
@@ -184,19 +194,32 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
endRef.current = handlePositions.end;
|
||||
}, [handlePositions]);
|
||||
|
||||
const handleStartDragStart = useCallback(() => {
|
||||
draggingRef.current = 'start';
|
||||
onStartEdit();
|
||||
}, [onStartEdit]);
|
||||
const handleStartDragStart = useCallback(
|
||||
(pointerType: string) => {
|
||||
draggingRef.current = 'start';
|
||||
dragPointerTypeRef.current = pointerType;
|
||||
setDraggingHandle('start');
|
||||
setLoupePoint({ ...startRef.current });
|
||||
onStartEdit();
|
||||
},
|
||||
[onStartEdit],
|
||||
);
|
||||
|
||||
const handleEndDragStart = useCallback(() => {
|
||||
draggingRef.current = 'end';
|
||||
onStartEdit();
|
||||
}, [onStartEdit]);
|
||||
const handleEndDragStart = useCallback(
|
||||
(pointerType: string) => {
|
||||
draggingRef.current = 'end';
|
||||
dragPointerTypeRef.current = pointerType;
|
||||
setDraggingHandle('end');
|
||||
setLoupePoint({ ...endRef.current });
|
||||
onStartEdit();
|
||||
},
|
||||
[onStartEdit],
|
||||
);
|
||||
|
||||
const handleStartDrag = useCallback(
|
||||
(point: Point) => {
|
||||
setCurrentStart(point);
|
||||
setLoupePoint(point);
|
||||
startRef.current = point;
|
||||
handleAnnotationRangeChange(point, endRef.current, isVertical, true);
|
||||
},
|
||||
@@ -206,6 +229,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
const handleEndDrag = useCallback(
|
||||
(point: Point) => {
|
||||
setCurrentEnd(point);
|
||||
setLoupePoint(point);
|
||||
endRef.current = point;
|
||||
handleAnnotationRangeChange(startRef.current, point, isVertical, true);
|
||||
},
|
||||
@@ -214,6 +238,8 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
draggingRef.current = null;
|
||||
setDraggingHandle(null);
|
||||
setLoupePoint(null);
|
||||
handleAnnotationRangeChange(startRef.current, endRef.current, isVertical, false);
|
||||
}, [isVertical, handleAnnotationRangeChange]);
|
||||
|
||||
@@ -221,9 +247,12 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const showLoupe = loupePoint !== null && appService?.isMobile && !viewSettings?.vertical;
|
||||
|
||||
return (
|
||||
<div className='pointer-events-none fixed inset-0 z-50'>
|
||||
<Handle
|
||||
hidden={draggingHandle === 'end'}
|
||||
position={currentStart}
|
||||
isVertical={isVertical}
|
||||
type='start'
|
||||
@@ -233,6 +262,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
<Handle
|
||||
hidden={draggingHandle === 'start'}
|
||||
position={currentEnd}
|
||||
isVertical={isVertical}
|
||||
type='end'
|
||||
@@ -241,6 +271,14 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
onDrag={handleEndDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
{showLoupe && (
|
||||
<MagnifierLoupe
|
||||
bookKey={bookKey}
|
||||
dragPoint={loupePoint}
|
||||
isVertical={isVertical}
|
||||
color={handleColorHex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { useReadwiseSync } from '../../hooks/useReadwiseSync';
|
||||
import { useTextSelector } from '../../hooks/useTextSelector';
|
||||
import { Position, TextSelection } from '@/utils/sel';
|
||||
import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel';
|
||||
@@ -51,6 +52,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { listenToNativeTouchEvents } = useDeviceControlStore();
|
||||
|
||||
useNotesSync(bookKey);
|
||||
useReadwiseSync(bookKey);
|
||||
|
||||
const osPlatform = getOSPlatform();
|
||||
const config = getConfig(bookKey)!;
|
||||
@@ -161,13 +163,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}, [selection, bookKey, viewSettings.vertical]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStyle(settings.globalReadSettings.highlightStyle);
|
||||
const highlightStyle = settings.globalReadSettings.highlightStyle;
|
||||
setSelectedStyle(highlightStyle);
|
||||
setSelectedColor(settings.globalReadSettings.highlightStyles[highlightStyle]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.globalReadSettings.highlightStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedColor(settings.globalReadSettings.highlightStyles[selectedStyle]);
|
||||
}, [settings.globalReadSettings.highlightStyles, selectedStyle]);
|
||||
|
||||
const transformCtx: TransformContext = useMemo(
|
||||
() => ({
|
||||
bookKey,
|
||||
@@ -318,7 +319,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { writingMode } = defaultView.getComputedStyle(el);
|
||||
draw(Overlayer.bubble, { writingMode });
|
||||
} else if (style === 'highlight') {
|
||||
draw(Overlayer.highlight, { color: isBwEink ? einkBgColor : hexColor });
|
||||
draw(Overlayer.highlight, {
|
||||
color: isBwEink ? einkBgColor : hexColor,
|
||||
vertical: viewSettings.vertical,
|
||||
});
|
||||
} else if (['underline', 'squiggly'].includes(style as string)) {
|
||||
const { defaultView } = doc;
|
||||
const node = range.startContainer;
|
||||
@@ -370,6 +374,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setHighlightOptionsVisible(false);
|
||||
setEditingAnnotation(null);
|
||||
} else {
|
||||
setShowAnnotPopup(false);
|
||||
setEditingAnnotation(null);
|
||||
setShowAnnotationNotes(false);
|
||||
setAnnotationNotes([]);
|
||||
if (style && color) {
|
||||
@@ -415,6 +421,31 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updateBooknotesPage = async () => {
|
||||
const config = getConfig(bookKey);
|
||||
const view = getView(bookKey);
|
||||
if (!config || !view) return;
|
||||
const { booknotes: annotations = [] } = config;
|
||||
annotations.sort((a, b) => {
|
||||
return CFI.compare(a.cfi, b.cfi);
|
||||
});
|
||||
for (const annotation of annotations) {
|
||||
if (annotation.deletedAt || annotation.page || !annotation.cfi) continue;
|
||||
const progress = await view.getCFIProgress(annotation.cfi);
|
||||
if (progress) {
|
||||
annotation.page = progress.location.current + 1;
|
||||
}
|
||||
}
|
||||
const updatedConfig = updateBooknotes(bookKey, annotations);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, bookKey, updatedConfig, settings);
|
||||
}
|
||||
};
|
||||
setTimeout(updateBooknotesPage, 3000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleQuickAction = () => {
|
||||
const action = viewSettings.annotationQuickAction;
|
||||
if (appService?.isAndroidApp && !androidTouchEndRef.current) return;
|
||||
@@ -579,6 +610,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
cfi,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
page: progress.page,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -609,6 +641,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
if (!cfi) return;
|
||||
const style = highlightStyle || settings.globalReadSettings.highlightStyle;
|
||||
const color = settings.globalReadSettings.highlightStyles[style];
|
||||
setSelectedStyle(style);
|
||||
setSelectedColor(color);
|
||||
const annotation: BookNote = {
|
||||
id: uniqueId(),
|
||||
type: 'annotation',
|
||||
@@ -617,6 +651,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
color,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
page: progress.page,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -693,6 +728,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const handleSpeakText = async (oneTime = false) => {
|
||||
if (!selection || !selection.text) return;
|
||||
setShowAnnotPopup(false);
|
||||
setEditingAnnotation(null);
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range, oneTime });
|
||||
};
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
{% if annotation.note %}
|
||||
**${_('Note:')}** {{ annotation.note }}
|
||||
{% endif %}
|
||||
*${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}*
|
||||
*${_('Page:')} {{ annotation.page }} · ${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}*
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
@@ -122,10 +122,12 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
chapters: sortedGroups.map((group) => ({
|
||||
title: group.label || _('Untitled'),
|
||||
annotations: group.booknotes.map((note) => ({
|
||||
...note,
|
||||
text: note.text || '',
|
||||
note: note.note || '',
|
||||
style: note.style,
|
||||
color: note.color,
|
||||
page: note.page,
|
||||
timestamp: note.updatedAt,
|
||||
})),
|
||||
})),
|
||||
@@ -182,11 +184,19 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
lines.push(`**${_('Note')}**: ${note.note}`);
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
let pageStr = '';
|
||||
if (exportConfig.includePageNumber && note.page) {
|
||||
pageStr = `${_('Page: {{number}}', { number: note.page })}`;
|
||||
}
|
||||
let timestampStr = '';
|
||||
if (exportConfig.includeTimestamp && note.updatedAt) {
|
||||
const timestamp = new Date(note.updatedAt).toLocaleString();
|
||||
timestampStr = `${_('Time:')} ${timestamp}`;
|
||||
}
|
||||
if (pageStr || timestampStr) {
|
||||
lines.push('');
|
||||
lines.push(`*${_('Time:')} ${timestamp}*`);
|
||||
const infoStr = pageStr ? `${pageStr} · ${timestampStr}`.trim() : timestampStr;
|
||||
lines.push(`*${infoStr}*`);
|
||||
}
|
||||
|
||||
lines.push(exportConfig.noteSeparator);
|
||||
@@ -322,6 +332,17 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
<span className='text-sm'>{_('Notes')}</span>
|
||||
</label>
|
||||
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={exportConfig.includePageNumber}
|
||||
onChange={() => handleToggle('includePageNumber')}
|
||||
className='checkbox checkbox-sm'
|
||||
disabled={exportConfig.useCustomTemplate}
|
||||
/>
|
||||
<span className='text-sm'>{_('Page Number')}</span>
|
||||
</label>
|
||||
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -461,6 +482,10 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
<code className='bg-base-300 rounded px-1'>annotation.color</code> -{' '}
|
||||
{_('Annotation color')}: yellow | red | green | blue | violet
|
||||
</li>
|
||||
<li className='ml-8'>
|
||||
<code className='bg-base-300 rounded px-1'>annotation.page</code> -{' '}
|
||||
{_('Annotation page number')}
|
||||
</li>
|
||||
<li className='ml-8'>
|
||||
<code className='bg-base-300 rounded px-1'>annotation.timestamp</code> -{' '}
|
||||
{_('Annotation time')}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Point } from '@/utils/sel';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface MagnifierLoupeProps {
|
||||
bookKey: string;
|
||||
dragPoint: Point;
|
||||
isVertical: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const MagnifierLoupe: React.FC<MagnifierLoupeProps> = ({
|
||||
bookKey,
|
||||
dragPoint,
|
||||
isVertical,
|
||||
color,
|
||||
}) => {
|
||||
const { getView } = useReaderStore();
|
||||
const radius = useResponsiveSize(52);
|
||||
|
||||
useEffect(() => {
|
||||
const view = getView(bookKey);
|
||||
if (!view) return;
|
||||
view.renderer.showLoupe?.(dragPoint.x, dragPoint.y, { isVertical, color, radius });
|
||||
return () => view.renderer.hideLoupe?.();
|
||||
}, [bookKey, dragPoint, getView, isVertical, color, radius]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default MagnifierLoupe;
|
||||
@@ -93,7 +93,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
errorMsg.innerHTML = _('Unable to load the article. Try searching directly on {{link}}.', {
|
||||
link: `<a href="https://${language}.wikipedia.org/w/index.php?search=${encodeURIComponent(
|
||||
query,
|
||||
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wikipedia</a>`,
|
||||
)}" target="_blank" rel="noopener noreferrer" class="not-eink:text-primary underline">Wikipedia</a>`,
|
||||
});
|
||||
|
||||
errorDiv.append(h1, errorMsg);
|
||||
@@ -120,7 +120,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
<div className='text-base-content flex h-full flex-col pt-2'>
|
||||
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
<div className='not-eink:opacity-60 flex items-center px-4 py-2 text-sm'>
|
||||
Source: Wikipedia (CC BY-SA)
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { MdArrowBack } from 'react-icons/md';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import Popup from '@/components/Popup';
|
||||
@@ -34,8 +35,118 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
onDismiss,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const [lookupWord, setLookupWord] = useState(word);
|
||||
const isLookingUp = useRef(false);
|
||||
const [history, setHistory] = useState<{ items: string[]; index: number }>({
|
||||
items: [word],
|
||||
index: 0,
|
||||
});
|
||||
const lastLookupRef = useRef('');
|
||||
const mainRef = useRef<HTMLElement | null>(null);
|
||||
const footerRef = useRef<HTMLElement | null>(null);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const lastDirectionRef = useRef<'up' | 'down' | null>(null);
|
||||
const scrollDeltaRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [isBackVisible, setIsBackVisible] = useState(false);
|
||||
const lookupWord = history.items[history.index] ?? word;
|
||||
const canGoBack = history.index > 0;
|
||||
const showBackButton = canGoBack && isBackVisible;
|
||||
|
||||
useEffect(() => {
|
||||
setHistory({ items: [word], index: 0 });
|
||||
}, [word]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGoBack) {
|
||||
setIsBackVisible(false);
|
||||
lastScrollTopRef.current = 0;
|
||||
lastDirectionRef.current = null;
|
||||
scrollDeltaRef.current = 0;
|
||||
return;
|
||||
}
|
||||
setIsBackVisible(true);
|
||||
}, [canGoBack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGoBack) return;
|
||||
const main = mainRef.current;
|
||||
if (!main) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafRef.current !== null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const currentScrollTop = main.scrollTop;
|
||||
const delta = currentScrollTop - lastScrollTopRef.current;
|
||||
if (delta === 0) return;
|
||||
|
||||
if (currentScrollTop <= 4) {
|
||||
setIsBackVisible(true);
|
||||
lastDirectionRef.current = null;
|
||||
scrollDeltaRef.current = 0;
|
||||
lastScrollTopRef.current = currentScrollTop;
|
||||
return;
|
||||
}
|
||||
|
||||
const direction: 'up' | 'down' = delta > 0 ? 'down' : 'up';
|
||||
if (direction !== lastDirectionRef.current) {
|
||||
lastDirectionRef.current = direction;
|
||||
scrollDeltaRef.current = 0;
|
||||
}
|
||||
|
||||
scrollDeltaRef.current += Math.abs(delta);
|
||||
const hideThreshold = 14;
|
||||
const showThreshold = 8;
|
||||
|
||||
if (direction === 'down' && scrollDeltaRef.current >= hideThreshold) {
|
||||
setIsBackVisible(false);
|
||||
scrollDeltaRef.current = 0;
|
||||
} else if (direction === 'up' && scrollDeltaRef.current >= showThreshold) {
|
||||
setIsBackVisible(true);
|
||||
scrollDeltaRef.current = 0;
|
||||
}
|
||||
|
||||
lastScrollTopRef.current = currentScrollTop;
|
||||
});
|
||||
};
|
||||
|
||||
lastScrollTopRef.current = main.scrollTop;
|
||||
main.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
main.removeEventListener('scroll', handleScroll);
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [canGoBack]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsBackVisible(true);
|
||||
lastScrollTopRef.current = 0;
|
||||
lastDirectionRef.current = null;
|
||||
scrollDeltaRef.current = 0;
|
||||
if (mainRef.current) {
|
||||
mainRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [lookupWord]);
|
||||
|
||||
const pushHistory = (nextWord: string) => {
|
||||
const trimmedWord = nextWord.trim();
|
||||
if (!trimmedWord) return;
|
||||
setHistory((prev) => {
|
||||
const currentWord = prev.items[prev.index];
|
||||
if (currentWord === trimmedWord) return prev;
|
||||
const items = [...prev.items.slice(0, prev.index + 1), trimmedWord];
|
||||
return { items, index: items.length - 1 };
|
||||
});
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setHistory((prev) => {
|
||||
if (prev.index === 0) return prev;
|
||||
return { ...prev, index: prev.index - 1 };
|
||||
});
|
||||
};
|
||||
|
||||
const interceptDictLinks = (definition: string): HTMLElement[] => {
|
||||
const container = document.createElement('div');
|
||||
@@ -48,11 +159,10 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
if (title) {
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
setLookupWord(title);
|
||||
isLookingUp.current = false;
|
||||
pushHistory(title);
|
||||
});
|
||||
|
||||
link.className = 'text-primary underline cursor-pointer';
|
||||
link.className = 'not-eink:text-primary underline cursor-pointer';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,12 +170,13 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLookingUp.current) {
|
||||
return;
|
||||
}
|
||||
isLookingUp.current = true;
|
||||
const main = document.querySelector('main') as HTMLElement;
|
||||
const footer = document.querySelector('footer') as HTMLElement;
|
||||
const langCode = typeof lang === 'string' ? lang : lang?.[0];
|
||||
const lookupKey = `${lookupWord}::${langCode || ''}`;
|
||||
if (lastLookupRef.current === lookupKey) return;
|
||||
lastLookupRef.current = lookupKey;
|
||||
const main = mainRef.current;
|
||||
const footer = footerRef.current;
|
||||
if (!main || !footer) return;
|
||||
|
||||
const fetchDefinitions = async (word: string, language?: string) => {
|
||||
main.innerHTML = '';
|
||||
@@ -95,7 +206,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.innerText = results[0]!.language;
|
||||
p.className = 'text-sm italic opacity-75';
|
||||
p.className = 'text-sm italic not-eink:opacity-75';
|
||||
hgroup.append(h1, p);
|
||||
main.append(hgroup);
|
||||
|
||||
@@ -115,7 +226,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
|
||||
if (examples) {
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'pl-8 list-disc text-sm italic opacity-75';
|
||||
ul.className = 'pl-8 list-disc text-sm italic not-eink:opacity-75';
|
||||
|
||||
examples.forEach((example) => {
|
||||
const exampleLi = document.createElement('li');
|
||||
@@ -150,7 +261,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
p.innerHTML = _('Unable to load the word. Try searching directly on {{link}}.', {
|
||||
link: `<a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(
|
||||
word,
|
||||
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wiktionary</a>`,
|
||||
)}" target="_blank" rel="noopener noreferrer" class="not-eink:text-primary underline">Wiktionary</a>`,
|
||||
});
|
||||
|
||||
div.append(h1, p);
|
||||
@@ -158,8 +269,8 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const langCode = typeof lang === 'string' ? lang : lang?.[0];
|
||||
fetchDefinitions(lookupWord, langCode);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [_, lookupWord, lang]);
|
||||
|
||||
return (
|
||||
@@ -172,10 +283,34 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
className='select-text'
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
<main className='flex-grow overflow-y-auto p-4 font-sans' />
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
<div className='relative flex h-full flex-col'>
|
||||
{canGoBack && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleBack}
|
||||
aria-label={_('Back')}
|
||||
className={`btn btn-ghost btn-circle text-base-content bg-base-200/80 hover:bg-base-200 absolute left-2 top-2 h-8 min-h-8 w-8 p-0 shadow-sm transition-[opacity,transform] duration-200 ease-out ${
|
||||
showBackButton
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'pointer-events-none -translate-y-1 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<MdArrowBack size={18} />
|
||||
</button>
|
||||
)}
|
||||
<main
|
||||
ref={mainRef}
|
||||
className='flex-grow overflow-y-auto px-4 pb-4 font-sans'
|
||||
style={{
|
||||
paddingTop: showBackButton ? 48 : 16,
|
||||
transition: 'padding-top 180ms ease-out',
|
||||
}}
|
||||
/>
|
||||
<footer
|
||||
ref={footerRef}
|
||||
className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'
|
||||
>
|
||||
<div className='not-eink:opacity-60 flex items-center px-4 py-2 text-sm'>
|
||||
Source: Wiktionary (CC BY-SA)
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -250,7 +250,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
)}
|
||||
|
||||
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
|
||||
<RSVPControl bookKey={bookKey} />
|
||||
<RSVPControl bookKey={bookKey} gridInsets={gridInsets} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,15 +32,15 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
|
||||
|
||||
const Notebook: React.FC = ({}) => {
|
||||
const _ = useTranslation();
|
||||
const { updateAppTheme, safeAreaInsets } = useThemeStore();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateAppTheme, safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { notebookWidth, isNotebookVisible, isNotebookPinned, notebookActiveTab } =
|
||||
useNotebookStore();
|
||||
const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore();
|
||||
const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { getNotebookWidth, setNotebookWidth, setNotebookVisible, toggleNotebookPin } =
|
||||
useNotebookStore();
|
||||
const { setNotebookNewAnnotation, setNotebookEditAnnotation, setNotebookActiveTab } =
|
||||
@@ -92,6 +92,14 @@ const Notebook: React.FC = ({}) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNotebookVisible || notebookNewAnnotation || notebookEditAnnotation) {
|
||||
setIsSearchBarVisible(false);
|
||||
setSearchResults(null);
|
||||
setSearchTerm('');
|
||||
}
|
||||
}, [isNotebookVisible, notebookNewAnnotation, notebookEditAnnotation]);
|
||||
|
||||
const handleNotebookResize = (newWidth: string) => {
|
||||
setNotebookWidth(newWidth);
|
||||
settings.globalReadSettings.notebookWidth = newWidth;
|
||||
@@ -121,6 +129,7 @@ const Notebook: React.FC = ({}) => {
|
||||
if (!sideBarBookKey) return;
|
||||
const view = getView(sideBarBookKey);
|
||||
const config = getConfig(sideBarBookKey)!;
|
||||
const progress = getProgress(sideBarBookKey)!;
|
||||
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
@@ -131,6 +140,7 @@ const Notebook: React.FC = ({}) => {
|
||||
type: 'annotation',
|
||||
cfi,
|
||||
note,
|
||||
page: progress.page,
|
||||
text: selection.text,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
@@ -148,6 +158,7 @@ const Notebook: React.FC = ({}) => {
|
||||
if (!sideBarBookKey) return;
|
||||
const view = getView(sideBarBookKey);
|
||||
const config = getConfig(sideBarBookKey)!;
|
||||
const progress = getProgress(sideBarBookKey)!;
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const existingIndex = annotations.findIndex((item) => item.id === note.id);
|
||||
if (existingIndex === -1) return;
|
||||
@@ -156,6 +167,7 @@ const Notebook: React.FC = ({}) => {
|
||||
} else {
|
||||
note.updatedAt = Date.now();
|
||||
}
|
||||
note.page = progress.page;
|
||||
annotations[existingIndex] = note;
|
||||
view?.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true);
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||
@@ -255,7 +267,9 @@ const Notebook: React.FC = ({}) => {
|
||||
width: `${notebookWidth}`,
|
||||
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
|
||||
position: isNotebookPinned ? 'relative' : 'absolute',
|
||||
paddingTop: `${safeAreaInsets?.top || 0}px`,
|
||||
paddingTop: systemUIVisible
|
||||
? `${Math.max(safeAreaInsets?.top || 0, statusBarHeight)}px`
|
||||
: `${safeAreaInsets?.top || 0}px`,
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
|
||||
@@ -182,9 +182,9 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded-full px-3 py-1.5',
|
||||
'bg-base-300 text-base-content',
|
||||
'border-base-content/10 border',
|
||||
'text-base-content flex items-center gap-1 rounded-full px-3 py-1.5',
|
||||
'not-eink:bg-base-300 eink-bordered',
|
||||
'not-eink:border-base-content/10 not-eink:border',
|
||||
'shadow-sm backdrop-blur-md',
|
||||
'transition-all duration-200 ease-out',
|
||||
)}
|
||||
@@ -195,7 +195,7 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
|
||||
className={clsx(
|
||||
'flex items-center justify-center rounded-full p-1.5',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:bg-base-content/10 active:scale-90',
|
||||
'not-eink:hover:bg-base-200 active:scale-90',
|
||||
isLoading && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
title={_('Previous Paragraph')}
|
||||
@@ -238,7 +238,7 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
|
||||
className={clsx(
|
||||
'flex items-center justify-center rounded-full p-1.5',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:bg-base-content/10 active:scale-90',
|
||||
'not-eink:hover:bg-base-200 active:scale-90',
|
||||
isLoading && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
title={_('Next Paragraph')}
|
||||
@@ -253,7 +253,7 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
|
||||
className={clsx(
|
||||
'flex items-center justify-center rounded-full p-1.5',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:bg-base-content/10 active:scale-90',
|
||||
'not-eink:hover:bg-base-200 active:scale-90',
|
||||
isLoading && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
title={_('Exit Paragraph Mode')}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { RSVPController, RsvpStartChoice, RsvpStopPosition } from '@/services/rs
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { Insets } from '@/types/misc';
|
||||
import RSVPOverlay from './RSVPOverlay';
|
||||
import RSVPStartDialog from './RSVPStartDialog';
|
||||
|
||||
interface RSVPControlProps {
|
||||
bookKey: string;
|
||||
gridInsets: Insets;
|
||||
}
|
||||
|
||||
// Helper to expand a range to include the full sentence
|
||||
@@ -99,7 +101,7 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => {
|
||||
return range;
|
||||
};
|
||||
|
||||
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
|
||||
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
|
||||
const _ = useTranslation();
|
||||
const {
|
||||
getView,
|
||||
@@ -461,6 +463,7 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
|
||||
portalContainer &&
|
||||
createPortal(
|
||||
<RSVPOverlay
|
||||
gridInsets={gridInsets}
|
||||
controller={controllerRef.current}
|
||||
chapters={chapters}
|
||||
currentChapterHref={currentChapterHref}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { RsvpState, RsvpWord, RSVPController } from '@/services/rsvp';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
IoAdd,
|
||||
} from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
|
||||
interface FlatChapter {
|
||||
label: string;
|
||||
@@ -23,6 +25,7 @@ interface FlatChapter {
|
||||
}
|
||||
|
||||
interface RSVPOverlayProps {
|
||||
gridInsets: Insets;
|
||||
controller: RSVPController;
|
||||
chapters: TOCItem[];
|
||||
currentChapterHref: string | null;
|
||||
@@ -32,6 +35,7 @@ interface RSVPOverlayProps {
|
||||
}
|
||||
|
||||
const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
gridInsets,
|
||||
controller,
|
||||
chapters,
|
||||
currentChapterHref,
|
||||
@@ -293,9 +297,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
aria-label={_('Speed Reading')}
|
||||
className='fixed inset-0 z-[10000] flex select-none flex-col'
|
||||
style={{
|
||||
paddingTop: `${gridInsets.top}px`,
|
||||
paddingBottom: `${gridInsets.bottom * 0.33}px`,
|
||||
backgroundColor: bgColor,
|
||||
color: fgColor,
|
||||
// Ensure solid background - no transparency
|
||||
backdropFilter: 'none',
|
||||
// @ts-expect-error CSS custom properties
|
||||
'--rsvp-accent': accentColor,
|
||||
@@ -305,64 +310,66 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className='rsvp-header flex shrink-0 items-center justify-between gap-2 p-3 md:gap-4 md:p-4'>
|
||||
{/* ── Header ── */}
|
||||
<div className='rsvp-header flex shrink-0 items-center gap-2 px-3 py-2 md:gap-3 md:px-5 md:py-3'>
|
||||
<button
|
||||
aria-label={_('Close Speed Reading')}
|
||||
className='flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 md:h-11 md:w-11'
|
||||
onClick={onClose}
|
||||
title={_('Close')}
|
||||
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-gray-500/20 active:scale-95'
|
||||
onClick={onClose}
|
||||
>
|
||||
<IoClose className='h-5 w-5 md:h-6 md:w-6' />
|
||||
<IoClose className='h-5 w-5' />
|
||||
</button>
|
||||
|
||||
{/* Chapter selector */}
|
||||
<div className='relative mx-2 min-w-0 max-w-[200px] flex-1 md:mx-4 md:max-w-[400px]'>
|
||||
<div className='relative min-w-0 flex-1'>
|
||||
<button
|
||||
className='flex w-full cursor-pointer items-center justify-between gap-1 rounded-lg border border-gray-500/30 bg-gray-500/15 px-2 py-1.5 text-xs transition-colors hover:bg-gray-500/25 md:gap-2 md:px-3 md:py-2 md:text-sm'
|
||||
className='flex w-full items-center gap-1.5 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm transition-colors hover:bg-gray-500/20 active:scale-[0.98]'
|
||||
onClick={() => setShowChapterDropdown(!showChapterDropdown)}
|
||||
title={_('Select Chapter')}
|
||||
>
|
||||
<span className='overflow-hidden text-ellipsis whitespace-nowrap'>
|
||||
<span className='min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left'>
|
||||
{getCurrentChapterLabel()}
|
||||
</span>
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
className='shrink-0 md:h-4 md:w-4'
|
||||
strokeWidth='2.5'
|
||||
className='h-3.5 w-3.5 shrink-0 opacity-50'
|
||||
>
|
||||
<path d='M6 9l6 6 6-6' />
|
||||
</svg>
|
||||
</button>
|
||||
{showChapterDropdown && (
|
||||
<div
|
||||
className='absolute left-0 right-0 top-full z-[100] mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-gray-500/30 shadow-lg'
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{flatChapters.map((chapter, idx) => (
|
||||
<button
|
||||
key={`${chapter.href}-${idx}`}
|
||||
className={clsx(
|
||||
'block w-full cursor-pointer border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors hover:bg-gray-500/20',
|
||||
isChapterActive(chapter.href) &&
|
||||
'bg-[color-mix(in_srgb,var(--rsvp-accent)_20%,transparent)] font-semibold',
|
||||
)}
|
||||
style={{ paddingLeft: `${0.75 + chapter.level * 1}rem` }}
|
||||
onClick={() => handleChapterSelect(chapter.href)}
|
||||
>
|
||||
{chapter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<Overlay onDismiss={() => setShowChapterDropdown(false)} />
|
||||
<div
|
||||
className='absolute left-0 right-0 top-full z-[100] mt-1.5 max-h-64 overflow-y-auto rounded-2xl border border-gray-500/20 px-2 shadow-2xl'
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{flatChapters.map((chapter, idx) => (
|
||||
<button
|
||||
key={`${chapter.href}-${idx}`}
|
||||
className={clsx(
|
||||
'block w-full rounded-md border-none bg-transparent px-4 py-2.5 text-left text-sm transition-colors first:rounded-t-2xl last:rounded-b-2xl hover:bg-gray-500/15',
|
||||
isChapterActive(chapter.href) &&
|
||||
'bg-[color-mix(in_srgb,var(--rsvp-accent)_15%,transparent)] font-semibold',
|
||||
)}
|
||||
style={{ paddingLeft: `${1 + chapter.level * 0.875}rem` }}
|
||||
onClick={() => handleChapterSelect(chapter.href)}
|
||||
>
|
||||
{chapter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='shrink-0 text-sm font-medium opacity-70 md:text-base'>
|
||||
{_('{{number}} WPM', { number: state.wpm })}
|
||||
{/* WPM badge */}
|
||||
<div className='shrink-0 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm tabular-nums'>
|
||||
<span className='font-semibold'>{state.wpm}</span>
|
||||
<span className='ml-0.5 text-xs opacity-50'>WPM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
|
||||
import { setReadwiseSettingsWindowVisible } from '@/app/reader/components/ReadwiseSettings';
|
||||
import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules';
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
@@ -99,6 +100,14 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
eventDispatcher.dispatch('push-kosync', { bookKey: sideBarBookKey });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const showReadwiseSettingsWindow = () => {
|
||||
setReadwiseSettingsWindowVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handlePushReadwise = () => {
|
||||
eventDispatcher.dispatch('readwise-push-all', { bookKey: sideBarBookKey });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const toggleDiscordPresence = () => {
|
||||
const discordRichPresenceEnabled = !settings.discordRichPresenceEnabled;
|
||||
saveSysSettings(envConfig, 'discordRichPresenceEnabled', discordRichPresenceEnabled);
|
||||
@@ -160,12 +169,26 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
<MenuItem label={_('Enter Parallel Read')} onClick={handleSetParallel} />
|
||||
))}
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
|
||||
{settings.kosync.enabled && (
|
||||
<>
|
||||
<MenuItem label={_('Push Progress')} onClick={handlePushKOSync} />
|
||||
<MenuItem label={_('Pull Progress')} onClick={handlePullKOSync} />
|
||||
</>
|
||||
{settings.kosync.enabled ? (
|
||||
<MenuItem label={_('KOReader Sync')} detailsOpen={false} buttonClass='py-2'>
|
||||
<ul className='flex flex-col ps-1'>
|
||||
<MenuItem label={_('Config')} noIcon onClick={showKoSyncSettingsWindow} />
|
||||
<MenuItem label={_('Push Progress')} noIcon onClick={handlePushKOSync} />
|
||||
<MenuItem label={_('Pull Progress')} noIcon onClick={handlePullKOSync} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
|
||||
)}
|
||||
{settings.readwise.enabled ? (
|
||||
<MenuItem label={_('Readwise Sync')} detailsOpen={false} buttonClass='py-2'>
|
||||
<ul className='flex flex-col ps-1'>
|
||||
<MenuItem label={_('Config')} noIcon onClick={showReadwiseSettingsWindow} />
|
||||
<MenuItem label={_('Push Highlights')} noIcon onClick={handlePushReadwise} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<MenuItem label={_('Readwise Sync')} onClick={showReadwiseSettingsWindow} />
|
||||
)}
|
||||
{appService?.isDesktopApp && (
|
||||
<>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { MdEdit, MdDelete } from 'react-icons/md';
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { BookNote, HighlightColor } from '@/types/book';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
@@ -31,11 +32,15 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
const { getProgress, getView, getViewsById } = useReaderStore();
|
||||
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
|
||||
|
||||
const globalReadSettings = settings.globalReadSettings;
|
||||
const customColors = globalReadSettings.customHighlightColors;
|
||||
|
||||
const { text, cfi, note } = item;
|
||||
const editorRef = useRef<TextEditorRef>(null);
|
||||
const [editorDraft, setEditorDraft] = useState(text || '');
|
||||
const [inlineEditMode, setInlineEditMode] = useState(false);
|
||||
const separatorWidth = useResponsiveSize(3);
|
||||
const size18 = useResponsiveSize(18);
|
||||
|
||||
const progress = getProgress(bookKey);
|
||||
const { isCurrent, viewRef } = useScrollToItem(cfi, progress);
|
||||
@@ -127,6 +132,8 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
);
|
||||
}
|
||||
|
||||
const isEditable = item.note || item.type === 'bookmark';
|
||||
|
||||
return (
|
||||
<li
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
||||
@@ -181,10 +188,23 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
item.note && 'content font-size-xs text-gray-500',
|
||||
(item.style === 'underline' || item.style === 'squiggly') &&
|
||||
'underline decoration-2',
|
||||
item.style === 'highlight' && `bg-${item.color}-500 bg-opacity-40`,
|
||||
item.style === 'underline' && `decoration-${item.color}-400`,
|
||||
item.style === 'squiggly' && `decoration-wavy decoration-${item.color}-400`,
|
||||
item.style === 'highlight' && 'rounded-[4px] px-[2px] py-[1px]',
|
||||
item.style === 'squiggly' && 'decoration-wavy',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...(item.style === 'highlight'
|
||||
? {
|
||||
backgroundColor: `color-mix(in srgb, ${customColors[item.color as HighlightColor] || item.color} calc(var(--overlayer-highlight-opacity, 0.3) * 100%), transparent)`,
|
||||
}
|
||||
: {}),
|
||||
...(item.style === 'underline' || item.style === 'squiggly'
|
||||
? {
|
||||
textDecorationColor: `color-mix(in srgb, ${customColors[item.color as HighlightColor] || item.color} 80%, transparent)`,
|
||||
}
|
||||
: {}),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{text || ''}
|
||||
</span>
|
||||
@@ -196,8 +216,10 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
className={clsx(
|
||||
'max-h-0 overflow-hidden p-0',
|
||||
'transition-[max-height] duration-300 ease-in-out',
|
||||
'group-hover:max-h-8 group-hover:overflow-visible',
|
||||
'group-focus-within:max-h-8 group-focus-within:overflow-visible',
|
||||
'group-focus-within:overflow-visible group-hover:overflow-visible',
|
||||
isEditable
|
||||
? 'group-focus-within:max-h-12 group-hover:max-h-12'
|
||||
: 'group-focus-within:max-h-8 group-hover:max-h-8',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
@@ -207,30 +229,41 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
// This is needed to prevent the parent onClick from being triggered
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex cursor-default items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<span className='text-sm text-gray-500 sm:text-xs'>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex cursor-default items-center justify-between py-2',
|
||||
isEditable && 'flex-col',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-full items-center gap-1 truncate'>
|
||||
<span className='truncate text-sm text-gray-500 sm:text-xs'>
|
||||
{item.page ? _('p {{page}}' + ' · ', { page: item.page }) : ''}
|
||||
</span>
|
||||
<span className='truncate text-sm text-gray-500 sm:text-xs'>
|
||||
{dayjs(item.createdAt).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-end space-x-3 p-2' dir='ltr'>
|
||||
{(item.note || item.type === 'bookmark') && (
|
||||
<TextButton
|
||||
<div
|
||||
className={clsx('flex items-center justify-end gap-3', isEditable && 'w-full')}
|
||||
dir='ltr'
|
||||
>
|
||||
{isEditable && (
|
||||
<button
|
||||
onClick={item.type === 'bookmark' ? editBookmark : editNote.bind(null, item)}
|
||||
variant='primary'
|
||||
className='opacity-0 transition duration-300 ease-in-out group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
className='btn btn-ghost btn-xs p-0 text-blue-500 opacity-0 transition duration-300 ease-in-out hover:bg-transparent group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
aria-label={_('Edit')}
|
||||
>
|
||||
{_('Edit')}
|
||||
</TextButton>
|
||||
<MdEdit size={size18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<TextButton
|
||||
<button
|
||||
onClick={deleteNote.bind(null, item)}
|
||||
variant='danger'
|
||||
className='opacity-0 transition duration-300 ease-in-out group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
className='btn btn-ghost btn-xs p-0 text-red-500 opacity-0 transition duration-300 ease-in-out hover:bg-transparent group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
aria-label={_('Delete')}
|
||||
>
|
||||
{_('Delete')}
|
||||
</TextButton>
|
||||
<MdDelete size={size18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBooknotesNav } from '../../hooks/useBooknotesNav';
|
||||
import ContentNavBar from './ContentNavBar';
|
||||
|
||||
@@ -25,8 +26,9 @@ const BooknotesNav: React.FC<BooknotesNavProps> = ({ bookKey, gridInsets, toc })
|
||||
handleNext,
|
||||
} = useBooknotesNav(bookKey, toc);
|
||||
const _ = useTranslation();
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
|
||||
if (!showBooknotesNav) {
|
||||
if (!showBooknotesNav || hoveredBookKey === bookKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ const ContentNavBar: React.FC<ContentNavBarProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className='results-nav-bar pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-end'
|
||||
className='results-nav-bar pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-end'
|
||||
style={{
|
||||
top: gridInsets.top,
|
||||
right: gridInsets.right,
|
||||
@@ -67,7 +67,7 @@ const ContentNavBar: React.FC<ContentNavBarProps> = ({
|
||||
left: gridInsets.left,
|
||||
}}
|
||||
>
|
||||
<div className='bg-base-100 mx-auto flex w-full items-center justify-center'>
|
||||
<div className='mx-auto flex items-center justify-center px-4'>
|
||||
{/* Bottom bar: Navigation buttons and Info */}
|
||||
<div className='pointer-events-auto flex h-[52px] max-w-3xl items-center gap-2'>
|
||||
{/* Previous button */}
|
||||
@@ -78,10 +78,12 @@ const ContentNavBar: React.FC<ContentNavBarProps> = ({
|
||||
className={clsx(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full shadow-lg transition-all disabled:opacity-40',
|
||||
'bg-base-200 hover:bg-base-300 hover:disabled:bg-base-200',
|
||||
!hasPrevious && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<MdChevronLeft size={iconSize20} className='text-base-content' />
|
||||
<MdChevronLeft
|
||||
size={iconSize20}
|
||||
className={clsx('text-base-content', !hasPrevious && 'opacity-40')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Info bar */}
|
||||
@@ -129,10 +131,12 @@ const ContentNavBar: React.FC<ContentNavBarProps> = ({
|
||||
className={clsx(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full shadow-lg transition-all',
|
||||
'bg-base-200 hover:bg-base-300 hover:disabled:bg-base-200',
|
||||
!hasNext && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<MdChevronRight size={iconSize20} className='text-base-content' />
|
||||
<MdChevronRight
|
||||
size={iconSize20}
|
||||
className={clsx('text-base-content', !hasNext && 'opacity-40')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { GiBookshelf } from 'react-icons/gi';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
@@ -9,15 +8,16 @@ import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import BookMenu from './BookMenu';
|
||||
import SidebarToggler from '../SidebarToggler';
|
||||
|
||||
const SidebarHeader: React.FC<{
|
||||
bookKey: string;
|
||||
isPinned: boolean;
|
||||
isSearchBarVisible: boolean;
|
||||
onGoToLibrary: () => void;
|
||||
onClose: () => void;
|
||||
onTogglePin: () => void;
|
||||
onToggleSearchBar: () => void;
|
||||
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
}> = ({ bookKey, isPinned, isSearchBarVisible, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
const _ = useTranslation();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
@@ -40,13 +40,9 @@ const SidebarHeader: React.FC<{
|
||||
>
|
||||
<MdArrowBackIosNew size={iconSize22} />
|
||||
</button>
|
||||
<button
|
||||
title={_('Go to Library')}
|
||||
className='btn btn-ghost hidden h-8 min-h-8 w-8 p-0 sm:flex'
|
||||
onClick={onGoToLibrary}
|
||||
>
|
||||
<GiBookshelf className='fill-base-content' />
|
||||
</button>
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-w-24 max-w-32 items-center justify-between sm:size-[70%]'>
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { BookSearchMatch, BookSearchResult } from '@/types/book';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSearchNav } from '../../hooks/useSearchNav';
|
||||
import ContentNavBar from './ContentNavBar';
|
||||
|
||||
@@ -25,8 +26,9 @@ const SearchResultsNav: React.FC<SearchResultsNavProps> = ({ bookKey, gridInsets
|
||||
handleNextResult,
|
||||
} = useSearchNav(bookKey);
|
||||
const _ = useTranslation();
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
|
||||
if (!showSearchNav) {
|
||||
if (!showSearchNav || hoveredBookKey === bookKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,11 @@ const MAX_SIDEBAR_WIDTH = 0.45;
|
||||
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
const SideBar: React.FC<{
|
||||
onGoToLibrary: () => void;
|
||||
}> = ({ onGoToLibrary }) => {
|
||||
const SideBar = ({}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { updateAppTheme, safeAreaInsets } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateAppTheme, safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const { sideBarBookKey, setSideBarBookKey, getSearchNavState, setSearchTerm, clearSearch } =
|
||||
useSidebarStore();
|
||||
const searchNavState = sideBarBookKey ? getSearchNavState(sideBarBookKey) : null;
|
||||
@@ -253,7 +251,9 @@ const SideBar: React.FC<{
|
||||
width: `${sideBarWidth}`,
|
||||
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
|
||||
position: isSideBarPinned ? 'relative' : 'absolute',
|
||||
paddingTop: `${safeAreaInsets?.top || 0}px`,
|
||||
paddingTop: systemUIVisible
|
||||
? `${Math.max(safeAreaInsets?.top || 0, statusBarHeight)}px`
|
||||
: `${safeAreaInsets?.top || 0}px`,
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
@@ -302,9 +302,9 @@ const SideBar: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
<SidebarHeader
|
||||
bookKey={sideBarBookKey!}
|
||||
isPinned={isSideBarPinned}
|
||||
isSearchBarVisible={isSearchBarVisible}
|
||||
onGoToLibrary={onGoToLibrary}
|
||||
onClose={() => setSideBarVisible(false)}
|
||||
onTogglePin={handleSideBarTogglePin}
|
||||
onToggleSearchBar={handleToggleSearchBar}
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useProofreadStore } from '@/store/proofreadStore';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { proofreadTransformer } from '@/services/transformers/proofread';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { TTSController, SILENCE_DATA, TTSMark, TTSHighlightOptions } from '@/services/tts';
|
||||
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
|
||||
import { useTTSControl } from '@/app/reader/hooks/useTTSControl';
|
||||
import { getPopupPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import { fetchImageAsBase64 } from '@/utils/image';
|
||||
import { invokeUseBackgroundAudio } from '@/utils/bridge';
|
||||
import { isCfiInLocation } from '@/utils/cfi';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import Popup from '@/components/Popup';
|
||||
import TTSPanel from './TTSPanel';
|
||||
import TTSIcon from './TTSIcon';
|
||||
@@ -40,688 +26,51 @@ interface TTSControlProps {
|
||||
const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { safeAreaInsets, isDarkMode } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { setViewSettings, setTTSEnabled } = useReaderStore();
|
||||
const { getMergedRules } = useProofreadStore();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const { hoveredBookKey, getViewSettings } = useReaderStore();
|
||||
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const [ttsLang, setTtsLang] = useState<string>('en');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [showIndicator, setShowIndicator] = useState(false);
|
||||
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [showTTSBar, setShowTTSBar] = useState(() => !!viewSettings?.showTTSBar);
|
||||
const [panelPosition, setPanelPosition] = useState<Position>();
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position>();
|
||||
|
||||
const [timeoutOption, setTimeoutOption] = useState(0);
|
||||
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
|
||||
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const backButtonTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [showIndicatorWithinTimeout, setShowIndicatorWithinTimeout] = useState(true);
|
||||
const [showBackToCurrentTTSLocation, setShowBackToCurrentTTSLocation] = useState(false);
|
||||
|
||||
const [shouldMountBackButton, setShouldMountBackButton] = useState(false);
|
||||
const [isBackButtonVisible, setIsBackButtonVisible] = useState(false);
|
||||
const backButtonTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const followingTTSLocationRef = useRef(true);
|
||||
|
||||
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 unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
|
||||
const [ttsController, setTtsController] = useState<TTSController | null>(null);
|
||||
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
|
||||
const ttsOnRef = useRef(false);
|
||||
|
||||
// this enables WebAudio to play even when the mute toggle switch is ON
|
||||
const unblockAudio = () => {
|
||||
if (unblockerAudioRef.current) return;
|
||||
unblockerAudioRef.current = document.createElement('audio');
|
||||
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
|
||||
unblockerAudioRef.current.addEventListener('play', () => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
}
|
||||
});
|
||||
unblockerAudioRef.current.preload = 'auto';
|
||||
unblockerAudioRef.current.loop = true;
|
||||
unblockerAudioRef.current.src = SILENCE_DATA;
|
||||
unblockerAudioRef.current.play();
|
||||
};
|
||||
|
||||
const releaseUnblockAudio = () => {
|
||||
if (!unblockerAudioRef.current) return;
|
||||
try {
|
||||
unblockerAudioRef.current.pause();
|
||||
unblockerAudioRef.current.currentTime = 0;
|
||||
unblockerAudioRef.current.removeAttribute('src');
|
||||
unblockerAudioRef.current.src = '';
|
||||
unblockerAudioRef.current.load();
|
||||
unblockerAudioRef.current = null;
|
||||
console.log('Unblock audio released');
|
||||
} catch (err) {
|
||||
console.warn('Error releasing unblock audio:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const initMediaSession = async () => {
|
||||
const mediaSession = getMediaSession();
|
||||
if (!mediaSession) return;
|
||||
|
||||
mediaSessionRef.current = mediaSession;
|
||||
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
const bookData = getBookData(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
const { sectionLabel } = progress || {};
|
||||
|
||||
let artworkImage = '/icon.png';
|
||||
try {
|
||||
artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png');
|
||||
} catch {
|
||||
artworkImage = await fetchImageAsBase64('/icon.png');
|
||||
}
|
||||
|
||||
await mediaSession.setActive({
|
||||
active: true,
|
||||
keepAppInForeground: settings.alwaysInForeground,
|
||||
notificationTitle: _('Read Aloud'),
|
||||
notificationText: _('Ready to read aloud'),
|
||||
foregroundServiceTitle: _('Read Aloud'),
|
||||
foregroundServiceText: _('Ready to read aloud'),
|
||||
});
|
||||
mediaSession.updateMetadata({
|
||||
title: title,
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: artworkImage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deinitMediaSession = async () => {
|
||||
if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) {
|
||||
await mediaSessionRef.current.setActive({
|
||||
active: false,
|
||||
keepAppInForeground: settings.alwaysInForeground,
|
||||
});
|
||||
}
|
||||
mediaSessionRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.on('tts-stop', handleTTSStop);
|
||||
return () => {
|
||||
eventDispatcher.off('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.off('tts-stop', handleTTSStop);
|
||||
};
|
||||
// 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 handleNeedAuth = () => {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Please log in to use advanced TTS features'),
|
||||
type: 'error',
|
||||
timeout: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSpeakMark = (e: Event) => {
|
||||
const progress = getProgress(bookKey);
|
||||
const { sectionLabel } = progress || {};
|
||||
const mark = (e as CustomEvent<TTSMark>).detail;
|
||||
|
||||
if (mediaSessionRef.current) {
|
||||
const mediaSession = mediaSessionRef.current;
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
mediaSession.updateMetadata({
|
||||
title: mark?.text || '',
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: '',
|
||||
});
|
||||
} else {
|
||||
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 { cfi } = (e as CustomEvent<{ cfi: string }>).detail;
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (!cfi || !view || !viewSettings) return;
|
||||
|
||||
viewSettings.ttsLocation = cfi;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
|
||||
if (!followingTTSLocationRef.current) return;
|
||||
|
||||
const docs = view.renderer.getContents();
|
||||
if (docs.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { doc, index: viewSectionIndex } = view.renderer.getContents()[0] as {
|
||||
doc: Document;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
const { anchor, index: ttsSectionIndex } = view.resolveCFI(cfi);
|
||||
if (viewSectionIndex !== ttsSectionIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = anchor(doc);
|
||||
if (!view.renderer.scrolled) {
|
||||
view.renderer.scrollToAnchor(range);
|
||||
} else {
|
||||
const rect = range.getBoundingClientRect();
|
||||
const { start, size, viewSize, sideProp } = view.renderer;
|
||||
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
|
||||
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
|
||||
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
|
||||
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
|
||||
|
||||
const showHeader = viewSettings.showHeader;
|
||||
const showFooter = viewSettings.showFooter;
|
||||
const showBarsOnScroll = viewSettings.showBarsOnScroll;
|
||||
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
|
||||
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
|
||||
const scrollingOverlap = viewSettings.scrollingOverlap;
|
||||
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
|
||||
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
|
||||
if (endInNextView || startInPrevView) {
|
||||
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
|
||||
view.renderer.scrollToAnchor(scrollTo / viewSize);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ttsController.addEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
return () => {
|
||||
ttsController.removeEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ttsController, bookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsLocation = viewSettings?.ttsLocation;
|
||||
const { location } = progress || {};
|
||||
if (!location || !ttsLocation) return;
|
||||
|
||||
const ttsInCurrentLocation = isCfiInLocation(ttsLocation, location);
|
||||
if (ttsInCurrentLocation) {
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
const { doc, index: viewSectionIndex } = view?.renderer.getContents()[0] as {
|
||||
doc: Document;
|
||||
index?: number;
|
||||
};
|
||||
const { anchor, index: ttsSectionIndex } = view?.resolveCFI(ttsLocation) || {};
|
||||
if (viewSectionIndex !== ttsSectionIndex) {
|
||||
return;
|
||||
}
|
||||
const range = anchor?.(doc);
|
||||
if (range) {
|
||||
view?.tts?.highlight(range);
|
||||
}
|
||||
} else {
|
||||
setShowBackToCurrentTTSLocation(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
const handleBackToCurrentTTSLocation = () => {
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsLocation = viewSettings?.ttsLocation;
|
||||
if (!view || !ttsLocation) return;
|
||||
|
||||
const resolved = view.resolveNavigation(ttsLocation);
|
||||
view.renderer.goTo?.(resolved);
|
||||
};
|
||||
|
||||
const getTTSTargetLang = useCallback((): string | null => {
|
||||
const ttsReadAloudText = viewSettings?.ttsReadAloudText;
|
||||
if (viewSettings?.translationEnabled && ttsReadAloudText === 'translated') {
|
||||
return viewSettings?.translateTargetLang || getLocale();
|
||||
} else if (viewSettings?.translationEnabled && ttsReadAloudText === 'source') {
|
||||
const bookData = getBookData(bookKey);
|
||||
return bookData?.book?.primaryLanguage || '';
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
const tts = useTTSControl({
|
||||
bookKey,
|
||||
getBookData,
|
||||
viewSettings?.translationEnabled,
|
||||
viewSettings?.ttsReadAloudText,
|
||||
viewSettings?.translateTargetLang,
|
||||
]);
|
||||
onRequestHidePanel: () => setShowPanel(false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
|
||||
}, [getTTSTargetLang]);
|
||||
|
||||
const transformCtx: TransformContext = useMemo(
|
||||
() => ({
|
||||
bookKey,
|
||||
viewSettings: getViewSettings(bookKey)!,
|
||||
userLocale: getLocale(),
|
||||
content: '',
|
||||
transformers: [],
|
||||
reversePunctuationTransform: true,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const preprocessSSMLForTTS = useCallback(
|
||||
async (ssml: string) => {
|
||||
const rules = getMergedRules(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const ttsOnlyRules = rules.filter(
|
||||
(rule) =>
|
||||
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
|
||||
);
|
||||
if (ttsOnlyRules.length === 0) return ssml;
|
||||
|
||||
transformCtx['content'] = ssml;
|
||||
transformCtx['viewSettings'] = viewSettings;
|
||||
ssml = await proofreadTransformer.transform(transformCtx, {
|
||||
docType: 'text/xml',
|
||||
onlyForTTS: true,
|
||||
});
|
||||
return ssml;
|
||||
},
|
||||
[bookKey, getMergedRules, getViewSettings, transformCtx],
|
||||
);
|
||||
|
||||
const getTTSHighlightOptions = useCallback(
|
||||
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
|
||||
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
|
||||
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
|
||||
return {
|
||||
...ttsHighlightOptions,
|
||||
color,
|
||||
};
|
||||
},
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
|
||||
if (ttsControllerRef.current && ttsHighlightOptions) {
|
||||
ttsControllerRef.current.initViewTTS(
|
||||
getTTSHighlightOptions(ttsHighlightOptions, viewSettings.isEink),
|
||||
);
|
||||
}
|
||||
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
|
||||
|
||||
const handleTTSSpeak = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
|
||||
if (bookKey !== ttsBookKey) return;
|
||||
if (ttsOnRef.current) return;
|
||||
ttsOnRef.current = true;
|
||||
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
|
||||
if (bookData.book?.format === 'PDF') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for PDF'),
|
||||
type: 'warning',
|
||||
});
|
||||
if (tts.showBackToCurrentTTSLocation) {
|
||||
setShouldMountBackButton(true);
|
||||
const fadeInTimeout = setTimeout(() => {
|
||||
setIsBackButtonVisible(true);
|
||||
}, 10);
|
||||
return () => clearTimeout(fadeInTimeout);
|
||||
} else {
|
||||
setIsBackButtonVisible(false);
|
||||
if (backButtonTimeoutRef.current) {
|
||||
clearTimeout(backButtonTimeoutRef.current);
|
||||
}
|
||||
backButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShouldMountBackButton(false);
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
const ttsSpeakRange = range as Range | null;
|
||||
let ttsFromRange = ttsSpeakRange;
|
||||
if (!ttsFromRange && viewSettings.ttsLocation) {
|
||||
const { location } = progress;
|
||||
const ttsCfi = viewSettings.ttsLocation;
|
||||
if (isCfiInLocation(ttsCfi, location)) {
|
||||
const { index, anchor } = view.resolveCFI(ttsCfi);
|
||||
const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
|
||||
if (doc) {
|
||||
ttsFromRange = anchor(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ttsFromRange) {
|
||||
ttsFromRange = progress.range;
|
||||
}
|
||||
|
||||
const primaryLang = bookData.book.primaryLanguage;
|
||||
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.stop();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: true });
|
||||
}
|
||||
if (appService?.isMobile) {
|
||||
unblockAudio();
|
||||
}
|
||||
await initMediaSession();
|
||||
setTtsClientsInitialized(false);
|
||||
|
||||
setShowIndicator(true);
|
||||
const ttsController = new TTSController(appService, view, !!user?.id, preprocessSSMLForTTS);
|
||||
ttsControllerRef.current = ttsController;
|
||||
setTtsController(ttsController);
|
||||
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS(
|
||||
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
|
||||
);
|
||||
const ssml =
|
||||
oneTime && ttsSpeakRange
|
||||
? genSSMLRaw(ttsSpeakRange.toString().trim())
|
||||
: view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
setIsPlaying(true);
|
||||
setTtsLang(lang);
|
||||
|
||||
ttsController.setLang(lang);
|
||||
ttsController.setRate(viewSettings.ttsRate);
|
||||
ttsController.speak(ssml, oneTime, () => handleStop(bookKey));
|
||||
ttsController.setTargetLang(getTTSTargetLang() || '');
|
||||
}
|
||||
setTtsClientsInitialized(true);
|
||||
setTTSEnabled(bookKey, true);
|
||||
} catch (error) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for this document'),
|
||||
type: 'error',
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTTSStop = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey } = event.detail;
|
||||
if (ttsControllerRef.current && bookKey === ttsBookKey) {
|
||||
handleStop(bookKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePlay = useCallback(async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
} else if (isPaused) {
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
// start for forward/backward/setvoice-paused
|
||||
// set rate don't pause the tts
|
||||
if (ttsController.state === 'paused') {
|
||||
await ttsController.resume();
|
||||
} else {
|
||||
await ttsController.start();
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSessionRef.current) {
|
||||
const mediaSession = mediaSessionRef.current;
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
await mediaSession.updatePlaybackState({ playing: !isPlaying });
|
||||
} else {
|
||||
mediaSession.playbackState = isPlaying ? 'paused' : 'playing';
|
||||
}
|
||||
}
|
||||
}, [isPlaying, isPaused]);
|
||||
|
||||
const handleBackward = useCallback(async (byMark = false) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.backward(byMark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleForward = useCallback(async (byMark = false) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.forward(byMark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(
|
||||
async (bookKey: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
setTtsController(null);
|
||||
getView(bookKey)?.deselect();
|
||||
setIsPlaying(false);
|
||||
setShowPanel(false);
|
||||
setShowIndicator(false);
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
}
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: false });
|
||||
}
|
||||
if (appService?.isMobile) {
|
||||
releaseUnblockAudio();
|
||||
}
|
||||
await deinitMediaSession();
|
||||
setTTSEnabled(bookKey, false);
|
||||
ttsOnRef.current = false;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[appService],
|
||||
);
|
||||
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetRate = useCallback(
|
||||
throttle(async (rate: number) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setRate(rate);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setRate(rate);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetVoice = useCallback(
|
||||
throttle(async (voice: string, lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setVoice(voice, lang);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice, lang);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGetVoices = async (lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
return ttsController.getVoices(lang);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleGetVoiceId = () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
return ttsController.getVoiceId();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSelectTimeout = (bookKey: string, value: number) => {
|
||||
setTimeoutOption(value);
|
||||
if (timeoutFunc) {
|
||||
clearTimeout(timeoutFunc);
|
||||
}
|
||||
if (value > 0) {
|
||||
setTimeoutFunc(
|
||||
setTimeout(() => {
|
||||
handleStop(bookKey);
|
||||
}, value * 1000),
|
||||
);
|
||||
setTimeoutTimestamp(Date.now() + value * 1000);
|
||||
} else {
|
||||
setTimeoutTimestamp(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTTSBar = () => {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.showTTSBar = !viewSettings.showTTSBar;
|
||||
setShowTTSBar(viewSettings.showTTSBar);
|
||||
if (viewSettings.showTTSBar) {
|
||||
setShowPanel(false);
|
||||
}
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
};
|
||||
|
||||
const updatePanelPosition = () => {
|
||||
if (iconRef.current) {
|
||||
const rect = iconRef.current.getBoundingClientRect();
|
||||
const parentRect =
|
||||
iconRef.current.parentElement?.getBoundingClientRect() ||
|
||||
document.documentElement.getBoundingClientRect();
|
||||
|
||||
const trianglePos = {
|
||||
dir: 'up',
|
||||
point: { x: rect.left + rect.width / 2 - parentRect.left, y: rect.top - 12 },
|
||||
} as Position;
|
||||
|
||||
const popupPos = getPopupPosition(
|
||||
trianglePos,
|
||||
parentRect,
|
||||
popupWidth,
|
||||
popupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
|
||||
setPanelPosition(popupPos);
|
||||
setTrianglePosition(trianglePos);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePopup = () => {
|
||||
updatePanelPosition();
|
||||
if (!showPanel && ttsControllerRef.current) {
|
||||
const speakingLang = ttsControllerRef.current.getSpeakingLang() || ttsLang;
|
||||
setTtsLang(speakingLang);
|
||||
}
|
||||
setShowPanel((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
setShowPanel(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { current: mediaSession } = mediaSessionRef;
|
||||
if (mediaSession) {
|
||||
mediaSession.setActionHandler('play', () => {
|
||||
handleTogglePlay();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('pause', () => {
|
||||
handleTogglePlay();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('stop', () => {
|
||||
handlePause();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('seekforward', () => {
|
||||
handleForward(true);
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('seekbackward', () => {
|
||||
handleBackward(true);
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('nexttrack', () => {
|
||||
handleForward();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('previoustrack', () => {
|
||||
handleBackward();
|
||||
});
|
||||
}
|
||||
}, [handleTogglePlay, handlePause, handleForward, handleBackward]);
|
||||
}, [tts.showBackToCurrentTTSLocation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iconRef.current || !showPanel) return;
|
||||
@@ -767,25 +116,47 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
}, [hoveredBookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showBackToCurrentTTSLocation) {
|
||||
followingTTSLocationRef.current = false;
|
||||
setShouldMountBackButton(true);
|
||||
const fadeInTimeout = setTimeout(() => {
|
||||
setIsBackButtonVisible(true);
|
||||
}, 10);
|
||||
return () => clearTimeout(fadeInTimeout);
|
||||
} else {
|
||||
followingTTSLocationRef.current = true;
|
||||
setIsBackButtonVisible(false);
|
||||
if (backButtonTimeoutRef.current) {
|
||||
clearTimeout(backButtonTimeoutRef.current);
|
||||
}
|
||||
backButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShouldMountBackButton(false);
|
||||
}, 300);
|
||||
return;
|
||||
if (tts.showTTSBar) {
|
||||
setShowPanel(false);
|
||||
}
|
||||
}, [showBackToCurrentTTSLocation]);
|
||||
}, [tts.showTTSBar]);
|
||||
|
||||
const updatePanelPosition = () => {
|
||||
if (iconRef.current) {
|
||||
const rect = iconRef.current.getBoundingClientRect();
|
||||
const parentRect =
|
||||
iconRef.current.parentElement?.getBoundingClientRect() ||
|
||||
document.documentElement.getBoundingClientRect();
|
||||
|
||||
const trianglePos = {
|
||||
dir: 'up',
|
||||
point: { x: rect.left + rect.width / 2 - parentRect.left, y: rect.top - 12 },
|
||||
} as Position;
|
||||
|
||||
const popupPos = getPopupPosition(
|
||||
trianglePos,
|
||||
parentRect,
|
||||
popupWidth,
|
||||
popupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
|
||||
setPanelPosition(popupPos);
|
||||
setTrianglePosition(trianglePos);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePopup = () => {
|
||||
updatePanelPosition();
|
||||
if (!showPanel && tts.isTTSActive) {
|
||||
tts.refreshTtsLang();
|
||||
}
|
||||
setShowPanel((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
setShowPanel(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -802,9 +173,9 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleBackToCurrentTTSLocation}
|
||||
onClick={tts.handleBackToCurrentTTSLocation}
|
||||
className={clsx(
|
||||
'bg-base-300 rounded-full px-4 py-2 font-sans text-sm shadow-lg',
|
||||
'not-eink:bg-base-300 eink-bordered rounded-full px-4 py-2 font-sans text-sm shadow-lg',
|
||||
safeAreaInsets?.top ? 'h-11' : 'h-9',
|
||||
)}
|
||||
>
|
||||
@@ -813,7 +184,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
</div>
|
||||
)}
|
||||
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
|
||||
{(showPanel || (showIndicator && showIndicatorWithinTimeout)) && (
|
||||
{(showPanel || (tts.showIndicator && showIndicatorWithinTimeout)) && (
|
||||
<div
|
||||
ref={iconRef}
|
||||
className={clsx(
|
||||
@@ -828,10 +199,14 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
|
||||
<TTSIcon
|
||||
isPlaying={tts.isPlaying}
|
||||
ttsInited={tts.ttsClientsInited}
|
||||
onClick={togglePopup}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPanel && panelPosition && trianglePosition && ttsClientsInited && (
|
||||
{showPanel && panelPosition && trianglePosition && tts.ttsClientsInited && (
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
@@ -842,29 +217,29 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
>
|
||||
<TTSPanel
|
||||
bookKey={bookKey}
|
||||
ttsLang={ttsLang}
|
||||
isPlaying={isPlaying}
|
||||
timeoutOption={timeoutOption}
|
||||
timeoutTimestamp={timeoutTimestamp}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onBackward={handleBackward}
|
||||
onForward={handleForward}
|
||||
onSetRate={handleSetRate}
|
||||
onGetVoices={handleGetVoices}
|
||||
onSetVoice={handleSetVoice}
|
||||
onGetVoiceId={handleGetVoiceId}
|
||||
onSelectTimeout={handleSelectTimeout}
|
||||
onToogleTTSBar={handleToggleTTSBar}
|
||||
ttsLang={tts.ttsLang}
|
||||
isPlaying={tts.isPlaying}
|
||||
timeoutOption={tts.timeoutOption}
|
||||
timeoutTimestamp={tts.timeoutTimestamp}
|
||||
onTogglePlay={tts.handleTogglePlay}
|
||||
onBackward={tts.handleBackward}
|
||||
onForward={tts.handleForward}
|
||||
onSetRate={tts.handleSetRate}
|
||||
onGetVoices={tts.handleGetVoices}
|
||||
onSetVoice={tts.handleSetVoice}
|
||||
onGetVoiceId={tts.handleGetVoiceId}
|
||||
onSelectTimeout={tts.handleSelectTimeout}
|
||||
onToogleTTSBar={tts.handleToggleTTSBar}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
{showIndicator && showTTSBar && ttsClientsInited && (
|
||||
{tts.showIndicator && tts.showTTSBar && tts.ttsClientsInited && (
|
||||
<TTSBar
|
||||
bookKey={bookKey}
|
||||
isPlaying={isPlaying}
|
||||
onBackward={handleBackward}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onForward={handleForward}
|
||||
isPlaying={tts.isPlaying}
|
||||
onBackward={tts.handleBackward}
|
||||
onTogglePlay={tts.handleTogglePlay}
|
||||
onForward={tts.handleForward}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { Point, TextSelection } from '@/utils/sel';
|
||||
import { Point, TextSelection, snapRangeToWords } from '@/utils/sel';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -127,6 +127,8 @@ export const useAnnotationEditor = ({
|
||||
console.warn('Range is collapsed');
|
||||
return;
|
||||
}
|
||||
|
||||
snapRangeToWords(newRange);
|
||||
} catch (e) {
|
||||
console.warn('Failed to create range:', e);
|
||||
return;
|
||||
|
||||
@@ -55,6 +55,30 @@ export const useMouseEvent = (
|
||||
};
|
||||
};
|
||||
|
||||
export const useLongPressEvent = (
|
||||
bookKey: string,
|
||||
handleImagePress: (src: string) => void,
|
||||
handleTablePress: (html: string) => void,
|
||||
) => {
|
||||
const handleLongPress = (msg: MessageEvent) => {
|
||||
if (msg.data && msg.data.bookKey === bookKey && msg.data.type === 'iframe-long-press') {
|
||||
if (msg.data.elementType === 'image') {
|
||||
handleImagePress(msg.data.src);
|
||||
} else if (msg.data.elementType === 'table') {
|
||||
handleTablePress(msg.data.html);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleLongPress);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleLongPress);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey]);
|
||||
};
|
||||
|
||||
interface IframeTouch {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { Point, TextSelection } from '@/utils/sel';
|
||||
import { Point, TextSelection, snapRangeToWords } from '@/utils/sel';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
@@ -17,7 +17,7 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getView, getViewsById, getViewSettings } = useReaderStore();
|
||||
const { getView, getViewsById, getViewSettings, getProgress } = useReaderStore();
|
||||
|
||||
const startPointRef = useRef<Point | null>(null);
|
||||
const startDocRef = useRef<Document | null>(null);
|
||||
@@ -120,6 +120,8 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
|
||||
if (newRange.collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
snapRangeToWords(newRange);
|
||||
return newRange;
|
||||
} catch (e) {
|
||||
console.warn('Failed to create range:', e);
|
||||
@@ -259,6 +261,7 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
|
||||
views.forEach((v) => v?.addAnnotation(annotation));
|
||||
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const existingIndex = annotations.findIndex(
|
||||
(a) => a.cfi === cfi && a.type === 'annotation' && a.style && !a.deletedAt,
|
||||
@@ -268,10 +271,11 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
|
||||
annotations[existingIndex] = {
|
||||
...annotations[existingIndex]!,
|
||||
...annotation,
|
||||
page: progress.page,
|
||||
id: annotations[existingIndex]!.id,
|
||||
};
|
||||
} else {
|
||||
annotations.push(annotation);
|
||||
annotations.push({ ...annotation, page: progress.page });
|
||||
}
|
||||
|
||||
const updatedConfig = updateBooknotes(bookKey, annotations);
|
||||
|
||||
@@ -187,9 +187,9 @@ export const usePagination = (
|
||||
viewPagination(viewRef.current, viewSettings, 'down');
|
||||
} else if (deltaY < 0) {
|
||||
viewPagination(viewRef.current, viewSettings, 'up');
|
||||
} else if (deltaX > 0) {
|
||||
viewPagination(viewRef.current, viewSettings, 'left');
|
||||
} else if (deltaX < 0) {
|
||||
viewPagination(viewRef.current, viewSettings, 'left');
|
||||
} else if (deltaX > 0) {
|
||||
viewPagination(viewRef.current, viewSettings, 'right');
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-mouseup') {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { ReadwiseClient } from '@/services/readwise';
|
||||
|
||||
const READWISE_SYNC_DEBOUNCE_MS = 5000;
|
||||
|
||||
export const useReadwiseSync = (bookKey: string) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
|
||||
// Read settings from store at call time to avoid stale closures
|
||||
const updateLastSyncedAt = useCallback(
|
||||
async (timestamp: number) => {
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore.getState();
|
||||
const newSettings = {
|
||||
...settings,
|
||||
readwise: { ...settings.readwise, lastSyncedAt: timestamp },
|
||||
};
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
},
|
||||
[envConfig],
|
||||
);
|
||||
|
||||
// useMemo (not useCallback) so the debounce timer isn't reset on every render
|
||||
const debouncedPush = useMemo(
|
||||
() =>
|
||||
debounce(async () => {
|
||||
const { settings } = useSettingsStore.getState();
|
||||
if (!settings.readwise?.enabled || !settings.readwise?.accessToken) return;
|
||||
const client = new ReadwiseClient(settings.readwise);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
const config = getConfig(bookKey);
|
||||
if (!book || !config?.booknotes) return;
|
||||
|
||||
const lastSyncedAt = settings.readwise.lastSyncedAt ?? 0;
|
||||
const newNotes = config.booknotes.filter(
|
||||
(n) => n.updatedAt > lastSyncedAt || (n.deletedAt ?? 0) > lastSyncedAt,
|
||||
);
|
||||
if (newNotes.length === 0) return;
|
||||
|
||||
const result = await client.pushHighlights(newNotes, book);
|
||||
if (result.success) {
|
||||
await updateLastSyncedAt(Date.now());
|
||||
} else if (!result.isNetworkError) {
|
||||
console.error('Readwise sync failed:', result.message);
|
||||
}
|
||||
}, READWISE_SYNC_DEBOUNCE_MS),
|
||||
[bookKey, getBookData, getConfig, updateLastSyncedAt],
|
||||
);
|
||||
|
||||
// Manual "Push All": sends every annotation/excerpt regardless of sync timestamp
|
||||
const pushAllHighlights = useCallback(async () => {
|
||||
const { settings } = useSettingsStore.getState();
|
||||
if (!settings.readwise?.enabled || !settings.readwise?.accessToken) return;
|
||||
const client = new ReadwiseClient(settings.readwise);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
const config = getConfig(bookKey);
|
||||
if (!book || !config?.booknotes) return;
|
||||
|
||||
const result = await client.pushHighlights(config.booknotes, book);
|
||||
if (result.success) {
|
||||
await updateLastSyncedAt(Date.now());
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Highlights synced to Readwise'),
|
||||
type: 'success',
|
||||
});
|
||||
} else {
|
||||
const message = result.isNetworkError
|
||||
? _('Readwise sync failed: no internet connection')
|
||||
: _('Readwise sync failed: {{error}}', { error: result.message });
|
||||
eventDispatcher.dispatch('toast', { message, type: 'error' });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey, getBookData, getConfig, updateLastSyncedAt]);
|
||||
|
||||
// Cancel any pending debounced sync on unmount to avoid background network requests
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedPush.cancel();
|
||||
};
|
||||
}, [debouncedPush]);
|
||||
|
||||
// Listen for manual push-all events dispatched from BookMenu / BooknoteView
|
||||
useEffect(() => {
|
||||
const handlePushAll = async (e: CustomEvent) => {
|
||||
if (e.detail.bookKey !== bookKey) return;
|
||||
await pushAllHighlights();
|
||||
};
|
||||
eventDispatcher.on('readwise-push-all', handlePushAll);
|
||||
return () => {
|
||||
eventDispatcher.off('readwise-push-all', handlePushAll);
|
||||
};
|
||||
}, [bookKey, pushAllHighlights]);
|
||||
|
||||
// Auto-sync whenever booknotes change; debouncedPush reads enabled state internally
|
||||
const config = getConfig(bookKey);
|
||||
useEffect(() => {
|
||||
debouncedPush();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config?.booknotes]);
|
||||
|
||||
return { pushAllHighlights };
|
||||
};
|
||||
@@ -0,0 +1,666 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useProofreadStore } from '@/store/proofreadStore';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { proofreadTransformer } from '@/services/transformers/proofread';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TTSController, TTSMark, TTSHighlightOptions, TTSVoicesGroup } from '@/services/tts';
|
||||
import { TauriMediaSession } from '@/libs/mediaSession';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { isCfiInLocation } from '@/utils/cfi';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { invokeUseBackgroundAudio } from '@/utils/bridge';
|
||||
import { estimateTTSTime } from '@/utils/ttsTime';
|
||||
import { useTTSMediaSession } from './useTTSMediaSession';
|
||||
|
||||
interface UseTTSControlProps {
|
||||
bookKey: string;
|
||||
onRequestHidePanel?: () => void;
|
||||
}
|
||||
|
||||
export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProps) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { setViewSettings, setTTSEnabled } = useReaderStore();
|
||||
const { getMergedRules } = useProofreadStore();
|
||||
|
||||
const [ttsLang, setTtsLang] = useState<string>('en');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [showIndicator, setShowIndicator] = useState(false);
|
||||
const [showTTSBar, setShowTTSBar] = useState(() => !!getViewSettings(bookKey)?.showTTSBar);
|
||||
const [showBackToCurrentTTSLocation, setShowBackToCurrentTTSLocation] = useState(false);
|
||||
|
||||
const [timeoutOption, setTimeoutOption] = useState(0);
|
||||
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
|
||||
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const followingTTSLocationRef = useRef(true);
|
||||
const sectionChangingTimestampRef = useRef(0);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const [ttsController, setTtsController] = useState<TTSController | null>(null);
|
||||
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
|
||||
|
||||
const {
|
||||
mediaSessionRef,
|
||||
unblockAudio,
|
||||
releaseUnblockAudio,
|
||||
initMediaSession,
|
||||
deinitMediaSession,
|
||||
} = useTTSMediaSession({ bookKey });
|
||||
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.on('tts-stop', handleTTSStop);
|
||||
return () => {
|
||||
eventDispatcher.off('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.off('tts-stop', handleTTSStop);
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Controller event listeners (re-registered when ttsController changes)
|
||||
useEffect(() => {
|
||||
if (!ttsController || !bookKey) return;
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
|
||||
const handleNeedAuth = () => {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Please log in to use advanced TTS features'),
|
||||
type: 'error',
|
||||
timeout: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSpeakMark = (e: Event) => {
|
||||
const progress = getProgress(bookKey);
|
||||
const { sectionLabel } = progress || {};
|
||||
const mark = (e as CustomEvent<TTSMark>).detail;
|
||||
|
||||
if (mediaSessionRef.current) {
|
||||
const mediaSession = mediaSessionRef.current;
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
mediaSession.updateMetadata({
|
||||
title: mark?.text || '',
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: '',
|
||||
});
|
||||
} else {
|
||||
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 { cfi } = (e as CustomEvent<{ cfi: string }>).detail;
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const { location } = progress || {};
|
||||
if (!cfi || !view || !location || !viewSettings) return;
|
||||
|
||||
viewSettings.ttsLocation = cfi;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
|
||||
if (!followingTTSLocationRef.current) return;
|
||||
|
||||
const docs = view.renderer.getContents();
|
||||
if (docs.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { doc, index: viewSectionIndex } = view.renderer.getContents()[0] as {
|
||||
doc: Document;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
const { anchor, index: ttsSectionIndex } = view.resolveCFI(cfi);
|
||||
if (viewSectionIndex !== ttsSectionIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = anchor(doc);
|
||||
if (!view.renderer.scrolled) {
|
||||
view.renderer.scrollToAnchor(range);
|
||||
} else {
|
||||
const rect = range.getBoundingClientRect();
|
||||
const { start, size, viewSize, sideProp } = view.renderer;
|
||||
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
|
||||
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
|
||||
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
|
||||
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
|
||||
|
||||
const showHeader = viewSettings.showHeader;
|
||||
const showFooter = viewSettings.showFooter;
|
||||
const showBarsOnScroll = viewSettings.showBarsOnScroll;
|
||||
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
|
||||
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
|
||||
const scrollingOverlap = viewSettings.scrollingOverlap;
|
||||
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
|
||||
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
|
||||
if (endInNextView || startInPrevView) {
|
||||
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
|
||||
view.renderer.scrollToAnchor(scrollTo / viewSize);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ttsController.addEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
return () => {
|
||||
ttsController.removeEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ttsController, bookKey]);
|
||||
|
||||
// Location tracking — re-highlight when progress changes
|
||||
const progress = getProgress(bookKey);
|
||||
useEffect(() => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsLocation = viewSettings?.ttsLocation;
|
||||
const { location } = progress || {};
|
||||
if (!location || !ttsLocation) return;
|
||||
|
||||
if (isCfiInLocation(ttsLocation, location)) {
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
const range = view?.tts?.getLastRange() as Range | null;
|
||||
if (range) {
|
||||
view?.tts?.highlight(range);
|
||||
}
|
||||
} else {
|
||||
const msSinceSectionChange = Date.now() - sectionChangingTimestampRef.current;
|
||||
if (msSinceSectionChange < 2000) return;
|
||||
setShowBackToCurrentTTSLocation(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
// Location tracking — keep followingTTSLocationRef in sync with showBackToCurrentTTSLocation
|
||||
useEffect(() => {
|
||||
if (showBackToCurrentTTSLocation) {
|
||||
followingTTSLocationRef.current = false;
|
||||
} else {
|
||||
followingTTSLocationRef.current = true;
|
||||
}
|
||||
}, [showBackToCurrentTTSLocation]);
|
||||
|
||||
// Location tracking — handleBackToCurrentTTSLocation
|
||||
const handleBackToCurrentTTSLocation = () => {
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsLocation = viewSettings?.ttsLocation;
|
||||
if (!view || !ttsLocation) return;
|
||||
|
||||
const resolved = view.resolveNavigation(ttsLocation);
|
||||
view.renderer.goTo?.(resolved);
|
||||
};
|
||||
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsTime = useMemo(() => {
|
||||
const rate = viewSettings?.ttsRate ?? 1;
|
||||
return estimateTTSTime(progress, rate);
|
||||
}, [progress, viewSettings?.ttsRate]);
|
||||
|
||||
const getTTSTargetLang = useCallback((): string | null => {
|
||||
const vs = getViewSettings(bookKey);
|
||||
const ttsReadAloudText = vs?.ttsReadAloudText;
|
||||
if (vs?.translationEnabled && ttsReadAloudText === 'translated') {
|
||||
return vs?.translateTargetLang || getLocale();
|
||||
} else if (vs?.translationEnabled && ttsReadAloudText === 'source') {
|
||||
const bookData = getBookData(bookKey);
|
||||
return bookData?.book?.primaryLanguage || '';
|
||||
}
|
||||
return null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
bookKey,
|
||||
getBookData,
|
||||
getViewSettings,
|
||||
viewSettings?.translationEnabled,
|
||||
viewSettings?.ttsReadAloudText,
|
||||
viewSettings?.translateTargetLang,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
|
||||
}, [getTTSTargetLang]);
|
||||
|
||||
// SSML preprocessing
|
||||
const transformCtx: TransformContext = useMemo(
|
||||
() => ({
|
||||
bookKey,
|
||||
viewSettings: getViewSettings(bookKey)!,
|
||||
userLocale: getLocale(),
|
||||
content: '',
|
||||
transformers: [],
|
||||
reversePunctuationTransform: true,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const preprocessSSMLForTTS = useCallback(
|
||||
async (ssml: string) => {
|
||||
const rules = getMergedRules(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const ttsOnlyRules = rules.filter(
|
||||
(rule) =>
|
||||
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
|
||||
);
|
||||
if (ttsOnlyRules.length === 0) return ssml;
|
||||
|
||||
transformCtx['content'] = ssml;
|
||||
transformCtx['viewSettings'] = viewSettings;
|
||||
ssml = await proofreadTransformer.transform(transformCtx, {
|
||||
docType: 'text/xml',
|
||||
onlyForTTS: true,
|
||||
});
|
||||
return ssml;
|
||||
},
|
||||
[bookKey, getMergedRules, getViewSettings, transformCtx],
|
||||
);
|
||||
|
||||
// Section change callback
|
||||
const handleSectionChange = useCallback(
|
||||
async (sectionIndex: number) => {
|
||||
if (!followingTTSLocationRef.current) return;
|
||||
const view = getView(bookKey);
|
||||
const sections = view?.book.sections;
|
||||
if (!sections || sectionIndex < 0 || sectionIndex >= sections.length) return;
|
||||
sectionChangingTimestampRef.current = Date.now();
|
||||
const resolved = view.resolveNavigation(sectionIndex);
|
||||
view.renderer.goTo?.(resolved);
|
||||
},
|
||||
[bookKey, getView],
|
||||
);
|
||||
|
||||
// TTS highlight options
|
||||
const getTTSHighlightOptions = useCallback(
|
||||
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
|
||||
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
|
||||
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
|
||||
return {
|
||||
...ttsHighlightOptions,
|
||||
color,
|
||||
};
|
||||
},
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
|
||||
if (ttsControllerRef.current && ttsHighlightOptions) {
|
||||
ttsControllerRef.current.initViewTTS(
|
||||
getTTSHighlightOptions(ttsHighlightOptions, viewSettings!.isEink),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
|
||||
|
||||
// handleStop (defined before handleTTSSpeak/handleTTSStop which reference it)
|
||||
const handleStop = useCallback(
|
||||
async (bookKey: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
setTtsController(null);
|
||||
getView(bookKey)?.deselect();
|
||||
setIsPlaying(false);
|
||||
onRequestHidePanel?.();
|
||||
setShowIndicator(false);
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
}
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: false });
|
||||
}
|
||||
if (appService?.isMobile) {
|
||||
releaseUnblockAudio();
|
||||
}
|
||||
await deinitMediaSession();
|
||||
setTTSEnabled(bookKey, false);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[appService],
|
||||
);
|
||||
|
||||
// handleTTSSpeak / handleTTSStop (plain functions, registered once at mount via closure)
|
||||
const handleTTSSpeak = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
|
||||
if (bookKey !== ttsBookKey) return;
|
||||
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
const { location } = progress || {};
|
||||
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
|
||||
if (bookData.book?.format === 'PDF') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for PDF'),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ttsSpeakRange = range as Range | null;
|
||||
let ttsFromRange = ttsSpeakRange;
|
||||
if (!ttsFromRange && viewSettings.ttsLocation) {
|
||||
const ttsCfi = viewSettings.ttsLocation;
|
||||
if (isCfiInLocation(ttsCfi, location)) {
|
||||
const { index, anchor } = view.resolveCFI(ttsCfi);
|
||||
const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
|
||||
if (doc) {
|
||||
ttsFromRange = anchor(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ttsFromRange) {
|
||||
ttsFromRange = progress.range;
|
||||
}
|
||||
|
||||
const currentSection = view.renderer.getContents()[0];
|
||||
if (ttsFromRange && currentSection) {
|
||||
const ttsLocation = view.getCFI(currentSection?.index || 0, ttsFromRange);
|
||||
viewSettings.ttsLocation = ttsLocation;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isCfiInLocation(ttsLocation, location)) {
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
}
|
||||
}
|
||||
|
||||
const primaryLang = bookData.book.primaryLanguage;
|
||||
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.stop();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: true });
|
||||
}
|
||||
if (appService?.isMobile) {
|
||||
unblockAudio();
|
||||
}
|
||||
await initMediaSession();
|
||||
setTtsClientsInitialized(false);
|
||||
|
||||
setShowIndicator(true);
|
||||
const ttsController = new TTSController(
|
||||
appService,
|
||||
view,
|
||||
!!user?.id,
|
||||
preprocessSSMLForTTS,
|
||||
handleSectionChange,
|
||||
);
|
||||
ttsControllerRef.current = ttsController;
|
||||
setTtsController(ttsController);
|
||||
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS(
|
||||
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
|
||||
);
|
||||
const ssml =
|
||||
oneTime && ttsSpeakRange
|
||||
? genSSMLRaw(ttsSpeakRange.toString().trim())
|
||||
: view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
setIsPlaying(true);
|
||||
setTtsLang(lang);
|
||||
|
||||
ttsController.setLang(lang);
|
||||
ttsController.setRate(viewSettings.ttsRate);
|
||||
ttsController.speak(ssml, oneTime, () => handleStop(bookKey));
|
||||
ttsController.setTargetLang(getTTSTargetLang() || '');
|
||||
}
|
||||
setTtsClientsInitialized(true);
|
||||
setTTSEnabled(bookKey, true);
|
||||
} catch (error) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for this document'),
|
||||
type: 'error',
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTTSStop = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey } = event.detail;
|
||||
if (ttsControllerRef.current && bookKey === ttsBookKey) {
|
||||
handleStop(bookKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Playback callbacks
|
||||
const handleTogglePlay = useCallback(async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
} else if (isPaused) {
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
// start for forward/backward/setvoice-paused
|
||||
// set rate don't pause the tts
|
||||
if (ttsController.state === 'paused') {
|
||||
await ttsController.resume();
|
||||
} else {
|
||||
await ttsController.start();
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSessionRef.current) {
|
||||
const mediaSession = mediaSessionRef.current;
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
await mediaSession.updatePlaybackState({ playing: !isPlaying });
|
||||
} else {
|
||||
mediaSession.playbackState = isPlaying ? 'paused' : 'playing';
|
||||
}
|
||||
}
|
||||
}, [isPlaying, isPaused, mediaSessionRef]);
|
||||
|
||||
const handleBackward = useCallback(async (byMark = false) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.backward(byMark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleForward = useCallback(async (byMark = false) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.forward(byMark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Rate/voice/timeout/bar controls
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetRate = useCallback(
|
||||
throttle(async (rate: number) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setRate(rate);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setRate(rate);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetVoice = useCallback(
|
||||
throttle(async (voice: string, lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setVoice(voice, lang);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice, lang);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGetVoices = async (lang: string): Promise<TTSVoicesGroup[]> => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
return ttsController.getVoices(lang);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleGetVoiceId = () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
return ttsController.getVoiceId();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSelectTimeout = (bookKey: string, value: number) => {
|
||||
setTimeoutOption(value);
|
||||
if (timeoutFunc) {
|
||||
clearTimeout(timeoutFunc);
|
||||
}
|
||||
if (value > 0) {
|
||||
setTimeoutFunc(
|
||||
setTimeout(() => {
|
||||
handleStop(bookKey);
|
||||
}, value * 1000),
|
||||
);
|
||||
setTimeoutTimestamp(Date.now() + value * 1000);
|
||||
} else {
|
||||
setTimeoutTimestamp(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTTSBar = () => {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.showTTSBar = !viewSettings.showTTSBar;
|
||||
setShowTTSBar(viewSettings.showTTSBar);
|
||||
if (viewSettings.showTTSBar) {
|
||||
onRequestHidePanel?.();
|
||||
}
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
};
|
||||
|
||||
const refreshTtsLang = useCallback(() => {
|
||||
const speakingLang = ttsControllerRef.current?.getSpeakingLang();
|
||||
if (speakingLang) {
|
||||
setTtsLang(speakingLang);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Media session action handler effect
|
||||
useEffect(() => {
|
||||
const { current: mediaSession } = mediaSessionRef;
|
||||
if (mediaSession) {
|
||||
mediaSession.setActionHandler('play', () => {
|
||||
handleTogglePlay();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('pause', () => {
|
||||
handleTogglePlay();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('stop', () => {
|
||||
handlePause();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('seekforward', () => {
|
||||
handleForward(true);
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('seekbackward', () => {
|
||||
handleBackward(true);
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('nexttrack', () => {
|
||||
handleForward();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('previoustrack', () => {
|
||||
handleBackward();
|
||||
});
|
||||
}
|
||||
}, [handleTogglePlay, handlePause, handleForward, handleBackward, mediaSessionRef]);
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
isPaused,
|
||||
ttsLang,
|
||||
ttsClientsInited,
|
||||
isTTSActive: ttsController !== null,
|
||||
showIndicator,
|
||||
showTTSBar,
|
||||
showBackToCurrentTTSLocation,
|
||||
timeoutOption,
|
||||
timeoutTimestamp,
|
||||
chapterRemainingSec: ttsTime.chapterRemainingSec,
|
||||
bookRemainingSec: ttsTime.bookRemainingSec,
|
||||
finishAtTimestamp: ttsTime.finishAtTimestamp,
|
||||
handleTogglePlay,
|
||||
handleBackward,
|
||||
handleForward,
|
||||
handlePause,
|
||||
handleSetRate,
|
||||
handleSetVoice,
|
||||
handleGetVoices,
|
||||
handleGetVoiceId,
|
||||
handleSelectTimeout,
|
||||
handleToggleTTSBar,
|
||||
handleBackToCurrentTTSLocation,
|
||||
refreshTtsLang,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useRef } from 'react';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { SILENCE_DATA } from '@/services/tts';
|
||||
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
|
||||
import { fetchImageAsBase64 } from '@/utils/image';
|
||||
|
||||
interface UseTTSMediaSessionProps {
|
||||
bookKey: string;
|
||||
}
|
||||
|
||||
export const useTTSMediaSession = ({ bookKey }: UseTTSMediaSessionProps) => {
|
||||
const _ = useTranslation();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getProgress } = useReaderStore();
|
||||
|
||||
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
|
||||
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// this enables WebAudio to play even when the mute toggle switch is ON
|
||||
const unblockAudio = () => {
|
||||
if (unblockerAudioRef.current) return;
|
||||
unblockerAudioRef.current = document.createElement('audio');
|
||||
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
|
||||
unblockerAudioRef.current.addEventListener('play', () => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
}
|
||||
});
|
||||
unblockerAudioRef.current.preload = 'auto';
|
||||
unblockerAudioRef.current.loop = true;
|
||||
unblockerAudioRef.current.src = SILENCE_DATA;
|
||||
unblockerAudioRef.current.play();
|
||||
};
|
||||
|
||||
const releaseUnblockAudio = () => {
|
||||
if (!unblockerAudioRef.current) return;
|
||||
try {
|
||||
unblockerAudioRef.current.pause();
|
||||
unblockerAudioRef.current.currentTime = 0;
|
||||
unblockerAudioRef.current.removeAttribute('src');
|
||||
unblockerAudioRef.current.src = '';
|
||||
unblockerAudioRef.current.load();
|
||||
unblockerAudioRef.current = null;
|
||||
console.log('Unblock audio released');
|
||||
} catch (err) {
|
||||
console.warn('Error releasing unblock audio:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const initMediaSession = async () => {
|
||||
const mediaSession = getMediaSession();
|
||||
if (!mediaSession) return;
|
||||
|
||||
mediaSessionRef.current = mediaSession;
|
||||
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
const bookData = getBookData(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
const { sectionLabel } = progress || {};
|
||||
|
||||
let artworkImage = '/icon.png';
|
||||
try {
|
||||
artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png');
|
||||
} catch {
|
||||
artworkImage = await fetchImageAsBase64('/icon.png');
|
||||
}
|
||||
|
||||
await mediaSession.setActive({
|
||||
active: true,
|
||||
keepAppInForeground: settings.alwaysInForeground,
|
||||
notificationTitle: _('Read Aloud'),
|
||||
notificationText: _('Ready to read aloud'),
|
||||
foregroundServiceTitle: _('Read Aloud'),
|
||||
foregroundServiceText: _('Ready to read aloud'),
|
||||
});
|
||||
mediaSession.updateMetadata({
|
||||
title: title,
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: artworkImage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deinitMediaSession = async () => {
|
||||
if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) {
|
||||
await mediaSessionRef.current.setActive({
|
||||
active: false,
|
||||
keepAppInForeground: settings.alwaysInForeground,
|
||||
});
|
||||
}
|
||||
mediaSessionRef.current = null;
|
||||
};
|
||||
|
||||
return {
|
||||
mediaSessionRef,
|
||||
unblockerAudioRef,
|
||||
unblockAudio,
|
||||
releaseUnblockAudio,
|
||||
initMediaSession,
|
||||
deinitMediaSession,
|
||||
};
|
||||
};
|
||||
@@ -147,7 +147,6 @@ export const useTextSelector = (
|
||||
makeSelection(sel, index, true);
|
||||
} else if (appService?.isAndroidApp) {
|
||||
isUpToPopup.current = false;
|
||||
makeSelection(sel, index, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user