Compare commits

...

49 Commits

Author SHA1 Message Date
Huang Xin 66d2fdf999 release: version 0.9.101 (#3410) 2026-02-28 09:50:23 +01:00
Huang Xin d8e0ceeff1 fix(annotator): add page number in highlight export to Readwise (#3409) 2026-02-28 09:45:09 +01:00
Huang Xin f3ad97b989 fix(opds): add missing book description in OPDS feed (#3408) 2026-02-28 09:30:56 +01:00
Huang Xin 1bb49ab023 fix(tts): dispose of the TTS view when shutting down the TTS controller, closes #3400 (#3406) 2026-02-28 08:58:02 +01:00
bfcs f9a0b39586 fix: respect fixed translation quota in UI stats and DeepL provider (#3404) 2026-02-28 08:10:31 +01:00
Huang Xin c533da498d feat(annotator): add page number for annotations, closes #3082 (#3405)
* feat(annotator): add page number in annotation

* feat: add page number for annotations, closes #3082
2026-02-28 08:08:56 +01:00
Huang Xin b50bc0b854 fix: make touchpad scrolling respect the system’s natural scrolling settings, closes #3127 (#3398) 2026-02-27 07:24:49 +01:00
Huang Xin 96c465931c fix(toc): fix phantom subchapter TOC item (#3397) 2026-02-27 07:23:12 +01:00
Huang Xin 2bd54ac236 fix(tts): also show highlight when navigating in paused mode and improve abbreviations processing (#3396)
Closes #3317.
2026-02-27 06:46:27 +01:00
Huang Xin 6ad549d13c fix(iOS): correct sidebar insets on iPad and resolve occasional stale safe area inset on iOS (#3395) 2026-02-27 05:43:45 +01:00
Blyrium 67249370c9 fix(font): fix generic font family keywords bypassing user font settings (#3394)
* Fix generic font families resolving incorrectly

Replace `serif`, `sans-serif`, and `monospace` keywords in epub stylesheets with their corresponding CSS variables (`var(--serif)`, `var(--sans-serif)`, `var(--monospace)`), ensuring the user's configured fonts are always used.
Fixes https://github.com/readest/readest/issues/3334

* Got rid of the lookbehind

But now we're using a placeholder.
2026-02-27 08:55:24 +08:00
Huang Xin fe3ab011ca fix(tts): set document lang attribute when missing or invalid for TTS, closes #3291 (#3393) 2026-02-26 17:17:51 +01:00
Huang Xin 6cb4278b98 fix: fixed all progress at the last page of the book, closes #3383 (#3391) 2026-02-26 14:01:42 +01:00
Huang Xin 51468862a2 fix(ui): show progress 100% at the last page, closes #3383 (#3390) 2026-02-26 11:08:21 +01:00
Huang Xin b3a44d066f fix(layout): enlarge hit area for slider input on iOS, closes #3382 (#3389) 2026-02-26 09:26:28 +01:00
Huang Xin 68d4538d40 fix(layout): float the annotation navigation bar, closes #3386 (#3387) 2026-02-26 06:56:29 +01:00
Roy Zhu 80105af839 fix(txt): stabilize iOS large TXT import and decode picker paths (#3320)
* fix: add missing TXT worker files for large import path

* fix(ios): decode import paths and stabilize large TXT conversion

* style(txt): run prettier on TXT conversion files

* fix(txt): restore chunk iterator API and stream cancel behavior

* fix(txt): avoid unused private segment iterator

* chore: clean up log for unit tests

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-02-26 05:30:13 +01:00
Huang Xin 3904c1e8e7 perf: use GPU-accelerated scroll for smoother paging animation (#3385) 2026-02-26 03:39:37 +01:00
Alan 99be6c58e2 fix: close searchbar when adding a new annotation (#3384) 2026-02-26 09:20:27 +08:00
Huang Xin 664cc772d0 fix: more sensitive paging without snapping animation, closes #3310 (#3381) 2026-02-25 17:47:55 +01:00
Huang Xin 21208adbcf compat: add target class to hide import icon in the bookshelf, closes #3376 (#3380) 2026-02-25 14:13:10 +01:00
Huang Xin 70eb59e2d6 compat(css): only override img background when overriding book color, closes #3377 (#3379) 2026-02-25 11:43:02 +01:00
Huang Xin 3e7b57282e fix: delay context menu to prevent broken input loop on macOS, closes #3324 (#3378)
When a native context menu is triggered via right-click, the menu.popup()
call immediately takes control and disrupts the browser's pointer event
flow. This prevents the pointerUp event from being delivered, leaving the
input event loop in an inconsistent state where the first tap after
dismissing the menu is not captured.

The fix adds a 100ms delay before showing the context menu, allowing the
browser's input loop to complete the pointerUp event for the right-click
before the native menu interferes.
2026-02-25 11:22:25 +01:00
Huang Xin 534582b125 compat(css): unset none user-select in some EPUBs, closes #3370 (#3374) 2026-02-25 07:10:06 +01:00
Huang Xin 4465e6986e chore: add husky pre-commit and pre-push hooks (#3372) 2026-02-25 06:39:11 +01:00
Blyrium a535f6419e fix: allow CSS targeting of the "NUMBER pages left in chapter" label via <Trans> component (#3368)
* Allow users to change the "remaining pages" label

Makes the remaining page number and the label text that comes with it targetable with CSS.
Solves https://github.com/readest/readest/issues/3343.

* Fixed formatting
2026-02-25 06:11:45 +01:00
Huang Xin 65da9c1d47 compat(webview): compat with older webview for iterating gamepads (#3366) 2026-02-24 16:59:52 +01:00
Huang Xin 5f71fd9e47 fix(css): override inline image background color only when overriding book color, closes #3316 (#3365) 2026-02-24 16:04:28 +01:00
Huang Xin dcf75e07d1 feat(translator): add Khmer in translator target languages, closes #3323 (#3364) 2026-02-24 15:51:50 +01:00
Huang Xin 79ba9b3818 compat(css): unset font-family for body when set to serif or sans-serif, closes #3334 (#3363) 2026-02-24 15:18:12 +01:00
Huang Xin 128b238bcb feat: add directional view transitions with scroll preservation in library view, closes #3357 (#3362) 2026-02-24 14:56:45 +01:00
Mohammed Efaz e26f7e6a2c fix: empty paragraphs not skipped in paragraph mode (#3361)
* fix(paragraph): skip empty paragraph ranges

* test(paragraph): cover empty paragraph filtering
2026-02-24 09:05:35 +01:00
Huang Xin 4c5ff59bcf fix(layout): also scale table with parent width, closes #3284 (#3359) 2026-02-24 08:11:45 +01:00
Huang Xin 99b2a34bd2 compat(layout): fix insane block display for tables, closes #3351 (#3358) 2026-02-24 07:26:03 +01:00
Huang Xin 40f3268ef3 fix(layout): consistent padding and radius for the dialog header, closes #3352 (#3356) 2026-02-24 06:55:47 +01:00
Huang Xin 4dac0850c5 fix: add classes for progress info labels, closes #3343 (#3353) 2026-02-24 05:24:01 +01:00
Huang Xin 118538ba35 fix(epub): replace background also for scrolled mode, closes #3344 (#3350) 2026-02-24 04:37:44 +01:00
JustADeer 4a92cacd84 fix: changed tagName comparision to localName for case-insensitive in iframeEventHandlers (XHTML xmlns bug fix) (#3349) 2026-02-24 09:21:03 +08:00
Matt Vogel ce53cd2b47 feat: Readwise highlights sync (#3311) 2026-02-24 00:08:59 +08:00
JustADeer b99c1bc19a feat(ui): image viewing mode support (#3328)
* feat(ui): image viewing mode support

* Revert foliate-js submodule pointer to 72fda6a (CI-compatible)

* Fix long-press image viewing in foliateViewer instead of foliate-js and other refactors

* feat: add images navigation buttons and table viewer

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-02-22 17:28:48 +01:00
Aniket Kotal eec2c39f19 feat(docker/podman): self-hosting with docker/podman compose (#3312)
* initial files

* added testing files

* removed unused files

* cleaned additional mounts

* fixed sql init

* removed more unused files

* moved to docker folder

* revert package.json

* gitignore update

* env example comments and compose necessary healthcheck

* ghcr package impl

* updated dockerfile steps  for layer caching

* added development-stage to dockerfile to dev environment

* added documentation on how to use dockerfile and compose.yml

* fixed prettier issues

* fixed image tag

* removed workflow for later
2026-02-18 14:28:10 +01:00
Mohammed Efaz c6ae85484e fix(reader): clamp reading ruler within viewport (#3314) 2026-02-18 14:13:06 +08:00
Huang Xin 7a9f46e93c fix(layout): container layout for dimmed area of reading ruler, closes #3304 (#3313) 2026-02-17 18:34:48 +01:00
Mohammed Efaz b9f6578127 feat: remaining time in TTS mode (#3300)
* feat: add test

* chore: update test

* feat: add translations

* feat: add tts time

* feat: add tts time components

* feat: update test

* feat: fixes and update ui components

* refactor: revert translations and ui
2026-02-14 17:42:37 +01:00
Huang Xin e75a3d254e fix(tts): fixed an issue where starting TTS from the annotation tool did not work, closes #3292 (#3303) 2026-02-14 17:22:46 +01:00
Huang Xin ea15906acf fix(txt): fixed TXT import on platforms that CompressionStream is not working, closes #3255 (#3302) 2026-02-14 23:30:38 +08:00
Mohammed Efaz 15d2784725 fix: highlight in dark mode eink (#3299)
* fix: highlight in dark mode eink

* refactor: deduplicate

* fix: bw eink highlights to use the fg colour so saved highlights are visible

* fix: avoid grayscale color in E-ink mode for better legibility

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-02-14 13:32:52 +01:00
Huang Xin ae3bb9da9a fix(annotator): update highlight style, color and range handlers when showing different annotations, closes #3286 (#3289) 2026-02-13 16:46:44 +01:00
Mohammed Efaz 239b32fcc2 fix: reader ruler disabled in scroll mode (#3288) 2026-02-13 16:35:25 +01:00
124 changed files with 5705 additions and 532 deletions
+1
View File
@@ -1,4 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
docker/.env
# dependencies
/node_modules
+1
View File
@@ -0,0 +1 @@
pnpm exec lint-staged
+2
View File
@@ -0,0 +1,2 @@
pnpm -C apps/readest-app lint
pnpm -C apps/readest-app test
+32 -12
View File
@@ -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
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.100",
"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": "اضغط للتقسيم إلى صفحات",
@@ -1079,5 +1078,34 @@
"Books": "الكتب",
"Groups": "المجموعات",
"Back to TTS Location": "العودة إلى موقع القراءة الآلية",
"Metadata": "بيانات وصفية"
"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": "পৃষ্ঠায় যেতে ট্যাপ করুন",
@@ -1031,5 +1030,34 @@
"Books": "বই",
"Groups": "দল",
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান",
"Metadata": "মেটাডেটা"
"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": "ཤོག་གདོང་ལ་ཐོག་འགྲོ།",
@@ -1019,5 +1018,34 @@
"Books": "དཔེ་ཆ།",
"Groups": "ཚོགས་པ།",
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།",
"Metadata": "གནད་སྨིན་གོ་དོན།"
"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",
@@ -1031,5 +1030,34 @@
"Books": "Bücher",
"Groups": "Gruppen",
"Back to TTS Location": "Zurück zur TTS-Position",
"Metadata": "Metadaten"
"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": "Ταπ για σελιδοποίηση",
@@ -1031,5 +1030,34 @@
"Books": "Βιβλία",
"Groups": "Ομάδες",
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS",
"Metadata": "Μεταδεδομένα"
"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",
@@ -1043,5 +1042,34 @@
"Books": "Libros",
"Groups": "Grupos",
"Back to TTS Location": "Volver a la ubicación de TTS",
"Metadata": "Metadatos"
"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": "ضربه برای صفحه‌بندی",
@@ -1031,5 +1030,34 @@
"Books": "کتاب‌ها",
"Groups": "گروه‌ها",
"Back to TTS Location": "بازگشت به مکان TTS",
"Metadata": "فراداده"
"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",
@@ -1043,5 +1042,34 @@
"Books": "Livres",
"Groups": "Groupes",
"Back to TTS Location": "Retour à l'emplacement TTS",
"Metadata": "Métadonnées"
"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"
}
@@ -465,10 +465,10 @@
"Next Paragraph": "פסקה הבאה",
"Exit Paragraph Mode": "צא ממצב פסקה",
"{{time}} min left in chapter": "נותרו {{time}} דקות בפרק",
"{{number}} pages left in chapter": "נותרו {{number}} דפים בפרק",
"{{count}} pages left in chapter_one": "נותר דף אחד בפרק",
"{{count}} pages left in chapter_two": "נותרו {{count}} דפים בפרק",
"{{count}} pages left in chapter_other": "נותרו {{count}} דפים בפרק",
"{{number}} pages left in chapter": "<1>נותרו </1><0>{{number}}</0><1> דפים בפרק</1>",
"{{count}} pages left in chapter_one": "<1>נותר דף אחד בפרק</1>",
"{{count}} pages left in chapter_two": "<1>נותרו </1><0>{{count}}</0><1> דפים בפרק</1>",
"{{count}} pages left in chapter_other": "<1>נותרו </1><0>{{count}}</0><1> דפים בפרק</1>",
"On {{current}} of {{total}} page": "בדף {{current}} מתוך {{total}}",
"Selection": "בחירה",
"Book": "ספר",
@@ -878,7 +878,6 @@
"Device": "מכשיר",
"E-Ink Mode": "מצב E-Ink",
"Color E-Ink Mode": "מצב E-Ink צבעוני",
"Auto Screen Brightness": "בהירות מסך אוטומטית",
"Security": "אבטחה",
"Allow JavaScript": "אפשר JavaScript",
"Enable only if you trust the file.": "הפעל רק אם אתה בוטח בקובץ.",
@@ -1043,5 +1042,34 @@
"Reveal in Finder": "הצג ב-Finder",
"Reveal in File Explorer": "הצג בסייר הקבצים",
"Reveal in Folder": "הצג בתיקייה",
"Metadata": "מטא-נתונים"
"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": "फिट",
"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": "पृष्ठांकन के लिए टैप करें",
@@ -1031,5 +1030,34 @@
"Books": "पुस्तकें",
"Groups": "समूह",
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं",
"Metadata": "मेटाडाटा"
"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",
@@ -1019,5 +1018,34 @@
"Books": "Buku",
"Groups": "Grup",
"Back to TTS Location": "Kembali ke Lokasi TTS",
"Metadata": "Metadata"
"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",
@@ -1043,5 +1042,34 @@
"Books": "Libri",
"Groups": "Gruppi",
"Back to TTS Location": "Torna alla posizione TTS",
"Metadata": "Metadati"
"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": "タップしてページを切り替える",
@@ -1019,5 +1018,34 @@
"Books": "書籍",
"Groups": "グループ",
"Back to TTS Location": "TTSの位置に戻る",
"Metadata": "メタデータ"
"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": "탭하여 페이지 매김",
@@ -1019,5 +1018,34 @@
"Books": "도서",
"Groups": "그룹",
"Back to TTS Location": "TTS 위치로 돌아가기",
"Metadata": "메타데이터"
"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.",
@@ -1019,5 +1018,34 @@
"Books": "Buku",
"Groups": "Kumpulan",
"Back to TTS Location": "Kembali ke Lokasi TTS",
"Metadata": "Metadata"
"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}} paginas 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> paginas in hoofdstuk</1>",
"Show Remaining Pages": "Toon resterende paginas",
"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}} paginas in hoofdstuk",
"{{number}} pages left in chapter": "<1>Nog </1><0>{{number}}</0><1> paginas 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",
@@ -1031,5 +1030,34 @@
"Books": "Boeken",
"Groups": "Groepen",
"Back to TTS Location": "Terug naar TTS-locatie",
"Metadata": "Metadata"
"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ć",
@@ -1055,5 +1054,34 @@
"Books": "Książki",
"Groups": "Grupy",
"Back to TTS Location": "Powrót do lokalizacji TTS",
"Metadata": "Metadane"
"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",
@@ -1043,5 +1042,34 @@
"Books": "Livros",
"Groups": "Grupos",
"Back to TTS Location": "Voltar para a localização do TTS",
"Metadata": "Metadados"
"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": "Тапните, чтобы разбить на страницы",
@@ -1055,5 +1054,34 @@
"Books": "Книги",
"Groups": "Группы",
"Back to TTS Location": "Вернуться к местоположению TTS",
"Metadata": "Метаданные"
"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": "පිටුගත කිරීමට ටැප් කරන්න",
@@ -1031,5 +1030,34 @@
"Books": "පොත්",
"Groups": "කණ්ඩායම්",
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න",
"Metadata": "මෙටාඩේටා"
"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",
@@ -1031,5 +1030,34 @@
"Books": "Böcker",
"Groups": "Grupper",
"Back to TTS Location": "Tillbaka till TTS-plats",
"Metadata": "Metadata"
"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": "பக்கம் மாற்ற தொடுங்கள்",
@@ -1031,5 +1030,34 @@
"Books": "புத்தகங்கள்",
"Groups": "குழுக்கள்",
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு",
"Metadata": "மெட்டாடேட்டா"
"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": "แตะเพื่อแบ่งหน้า",
@@ -1019,5 +1018,34 @@
"Books": "หนังสือ",
"Groups": "กลุ่ม",
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS",
"Metadata": "เมทาดาตา"
"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",
@@ -1031,5 +1030,34 @@
"Books": "Kitaplar",
"Groups": "Gruplar",
"Back to TTS Location": "TTS Konumuna Geri Dön",
"Metadata": "Meta veriler"
"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": "Натисніть, щоб розбити на сторінки",
@@ -1055,5 +1054,34 @@
"Books": "Книги",
"Groups": "Групи",
"Back to TTS Location": "Повернутися до розташування TTS",
"Metadata": "Метадані"
"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",
@@ -1019,5 +1018,34 @@
"Books": "Sách",
"Groups": "Nhóm",
"Back to TTS Location": "Quay lại vị trí TTS",
"Metadata": "Siêu dữ liệu"
"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": "点击翻页",
@@ -1019,5 +1018,34 @@
"Books": "书籍",
"Groups": "分组",
"Back to TTS Location": "回到朗读位置",
"Metadata": "元数据"
"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": "點擊翻頁",
@@ -1019,5 +1018,34 @@
"Books": "書籍",
"Groups": "分組",
"Back to TTS Location": "回到朗讀位置",
"Metadata": "元數據"
"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": "註解頁碼"
}
+14
View File
@@ -1,5 +1,19 @@
{
"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": [
@@ -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")
+1 -1
View File
@@ -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);
});
});
});
@@ -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>&nbsp;</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);
});
});
+6 -3
View File
@@ -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) => {
+58 -13
View File
@@ -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}
/>
@@ -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);
}
@@ -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}
@@ -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;
@@ -1,5 +1,6 @@
import clsx from 'clsx';
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';
@@ -75,6 +76,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
})
: '';
const showPagesLeft = total - 1 > current;
const [progressInfoMode, setProgressInfoMode] = useState(viewSettings.progressInfoMode);
const cycleProgressInfoModes = () => {
@@ -171,9 +174,24 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
{(progressInfoMode === 'all' || progressInfoMode === 'remaining') && (
<>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeftStr}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pagesLeftStr}</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}
</>
)}
@@ -181,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>
@@ -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,
@@ -251,15 +263,15 @@ 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(
@@ -272,6 +284,17 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
[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
@@ -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
@@ -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>
);
};
@@ -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;
@@ -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;
@@ -161,7 +161,6 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
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>('');
@@ -173,9 +172,6 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
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) {
@@ -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,
@@ -373,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) {
@@ -418,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;
@@ -582,6 +610,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
cfi,
text: selection.text,
note: '',
page: progress.page,
createdAt: Date.now(),
updatedAt: Date.now(),
};
@@ -612,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',
@@ -620,6 +651,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
color,
text: selection.text,
note: '',
page: progress.page,
createdAt: Date.now(),
updatedAt: Date.now(),
};
@@ -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')}
@@ -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>{`
@@ -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,6 +1,7 @@
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';
@@ -39,6 +40,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
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);
@@ -130,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
@@ -212,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={
{
@@ -223,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 px-4'>
<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>
@@ -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;
}
@@ -29,8 +29,8 @@ const VELOCITY_THRESHOLD = 0.5;
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;
@@ -251,7 +251,9 @@ const SideBar = ({}) => {
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>{`
@@ -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;
@@ -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);
@@ -261,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,
@@ -270,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 };
};
@@ -16,6 +16,7 @@ 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 {
@@ -49,7 +50,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const ttsControllerRef = useRef<TTSController | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
const ttsOnRef = useRef(false);
const {
mediaSessionRef,
@@ -190,8 +190,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const { location } = progress || {};
if (!location || !ttsLocation) return;
const ttsInCurrentLocation = isCfiInLocation(ttsLocation, location);
if (ttsInCurrentLocation) {
if (isCfiInLocation(ttsLocation, location)) {
setShowBackToCurrentTTSLocation(false);
const range = view?.tts?.getLastRange() as Range | null;
if (range) {
@@ -226,6 +225,11 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
};
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;
@@ -344,7 +348,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
}
await deinitMediaSession();
setTTSEnabled(bookKey, false);
ttsOnRef.current = false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
@@ -354,13 +357,12 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
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);
const { location } = progress || {};
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
@@ -373,7 +375,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
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);
@@ -392,6 +393,9 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
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;
@@ -643,6 +647,9 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
showBackToCurrentTTSLocation,
timeoutOption,
timeoutTimestamp,
chapterRemainingSec: ttsTime.chapterRemainingSec,
bookRemainingSec: ttsTime.bookRemainingSec,
finishAtTimestamp: ttsTime.finishAtTimestamp,
handleTogglePlay,
handleBackward,
handleForward,
@@ -267,3 +267,180 @@ export const handleTouchMove = (bookKey: string, event: TouchEvent) => {
export const handleTouchEnd = (bookKey: string, event: TouchEvent) => {
handleTouchEv(bookKey, event, 'iframe-touchend');
};
export const addLongPressListeners = (bookKey: string, doc: Document) => {
const longPressDuration = 500;
const moveThreshold = 10; // pixels - movement threshold to detect dragging/selection
const pressTimers = new Map<Element, ReturnType<typeof setTimeout>>();
const pressStartPositions = new Map<Element, { x: number; y: number }>();
const handleLongPress = (event: Event, target: HTMLElement) => {
event.preventDefault?.();
// Check if there's an active text selection - if so, don't trigger long-press
const selection = doc.getSelection();
if (selection && selection.toString().length > 0) {
return;
}
if (target.localName === 'img') {
const imgTarget = target as HTMLImageElement;
window.postMessage(
{
type: 'iframe-long-press',
bookKey,
elementType: 'image',
src: imgTarget.src,
},
'*',
);
} else if (target.localName === 'table' || target.closest('table')) {
const tableTarget = (
target.localName === 'table' ? target : target.closest('table')
) as HTMLTableElement;
window.postMessage(
{
type: 'iframe-long-press',
bookKey,
elementType: 'table',
html: tableTarget.outerHTML,
},
'*',
);
}
};
const startPress = (event: Event) => {
const target = event.target as HTMLElement;
const isImage = target.localName === 'img';
const isTableOrInTable = target.localName === 'table' || target.closest('table');
if (!isImage && !isTableOrInTable) return;
const elementToTrack = isImage
? target
: ((target.localName === 'table' ? target : target.closest('table')) as HTMLElement);
// Store initial position for movement detection
if ('clientX' in event && 'clientY' in event) {
const mouseEvent = event as MouseEvent;
pressStartPositions.set(elementToTrack, { x: mouseEvent.clientX, y: mouseEvent.clientY });
} else if ('touches' in event) {
const touchEvent = event as TouchEvent;
const touch = touchEvent.touches[0];
if (touch) {
pressStartPositions.set(elementToTrack, { x: touch.clientX, y: touch.clientY });
}
}
clearTimeout(pressTimers.get(elementToTrack));
const timer = setTimeout(() => handleLongPress(event, elementToTrack), longPressDuration);
pressTimers.set(elementToTrack, timer);
};
const handleMove = (event: Event) => {
const target = event.target as HTMLElement;
const isImage = target.localName === 'img';
const isTableOrInTable = target.localName === 'table' || target.closest('table');
if (!isImage && !isTableOrInTable) return;
const elementToTrack = isImage
? target
: ((target.localName === 'table' ? target : target.closest('table')) as HTMLElement);
// Check if mouse/touch moved beyond threshold - if so, user is probably selecting text or dragging
const startPos = pressStartPositions.get(elementToTrack);
if (startPos) {
let currentX = 0;
let currentY = 0;
if ('clientX' in event && 'clientY' in event) {
const mouseEvent = event as MouseEvent;
currentX = mouseEvent.clientX;
currentY = mouseEvent.clientY;
} else if ('touches' in event) {
const touchEvent = event as TouchEvent;
const touch = touchEvent.touches[0];
if (touch) {
currentX = touch.clientX;
currentY = touch.clientY;
}
}
const distance = Math.sqrt(
Math.pow(currentX - startPos.x, 2) + Math.pow(currentY - startPos.y, 2),
);
// If moved beyond threshold, cancel the long-press
if (distance > moveThreshold) {
clearTimeout(pressTimers.get(elementToTrack));
pressTimers.delete(elementToTrack);
pressStartPositions.delete(elementToTrack);
}
}
};
const cancelPress = (event: Event) => {
const target = event.target as HTMLElement;
const isImage = target.localName === 'img';
const isTableOrInTable = target.localName === 'table' || target.closest('table');
if (!isImage && !isTableOrInTable) return;
const elementToTrack = isImage
? target
: ((target.localName === 'table' ? target : target.closest('table')) as HTMLElement);
clearTimeout(pressTimers.get(elementToTrack));
pressTimers.delete(elementToTrack);
pressStartPositions.delete(elementToTrack);
};
const processElements = () => {
const images = doc.querySelectorAll('img');
const tables = doc.querySelectorAll('table');
images.forEach((img) => {
if (!img.hasAttribute('data-long-press-added')) {
img.setAttribute('data-long-press-added', 'true');
img.addEventListener('mousedown', startPress);
img.addEventListener('mousemove', handleMove);
img.addEventListener('mouseup', cancelPress);
img.addEventListener('mouseleave', cancelPress);
img.addEventListener('touchstart', startPress, { passive: true });
img.addEventListener('touchmove', handleMove, { passive: true });
img.addEventListener('touchend', cancelPress);
}
});
tables.forEach((table) => {
if (!table.hasAttribute('data-long-press-added')) {
table.setAttribute('data-long-press-added', 'true');
table.addEventListener('mousedown', startPress);
table.addEventListener('mousemove', handleMove);
table.addEventListener('mouseup', cancelPress);
table.addEventListener('mouseleave', cancelPress);
table.addEventListener('touchstart', startPress, { passive: true });
table.addEventListener('touchmove', handleMove, { passive: true });
table.addEventListener('touchend', cancelPress);
}
});
};
processElements();
const observer = new MutationObserver((mutations) => {
const hasNewElements = mutations.some((m) => m.type === 'childList' && m.addedNodes.length > 0);
if (hasNewElements) {
processElements();
}
});
observer.observe(doc.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
pressTimers.forEach((timer) => clearTimeout(timer));
};
};
+1 -1
View File
@@ -226,7 +226,7 @@ const Dialog: React.FC<DialogProps> = ({
>
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
</div>
<div className='dialog-header sticky top-1 z-10 flex items-center justify-between px-2 sm:px-4'>
<div className='dialog-header sticky top-1 z-10 flex items-center justify-between px-2 sm:pe-3 sm:ps-2'>
{header ? (
header
) : (
+2 -2
View File
@@ -28,7 +28,7 @@ const Slider: React.FC<SliderProps> = ({
max = 100,
step = 1,
initialValue = 50,
heightPx = 40,
heightPx = 44,
minLabel = '',
maxLabel = '',
minIcon,
@@ -134,7 +134,7 @@ const Slider: React.FC<SliderProps> = ({
max={100}
step={step}
value={percentage}
className='absolute inset-0 h-full w-full cursor-pointer opacity-0'
className='slider-input absolute inset-0 h-full min-h-12 w-full cursor-pointer opacity-0'
onChange={handleChange}
aria-label={label}
aria-valuemin={min}
@@ -180,7 +180,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
{formatPublisher(metadata?.publisher || '') || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<div className='overflow-hidden pe-1 text-end sm:text-start'>
<span className='font-bold'>{_('Published')}</span>
<p className='text-neutral-content text-sm'>
{formatDate(metadata?.published, true) || _('Unknown')}
@@ -190,7 +190,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<span className='font-bold'>{_('Updated')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.updatedAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<div className='overflow-hidden pe-1 text-end sm:text-start'>
<span className='font-bold'>{_('Added')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.createdAt) || ''}</p>
</div>
@@ -200,7 +200,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
{formatLanguage(metadata?.language) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<div className='overflow-hidden pe-1 text-end sm:text-start'>
<span className='font-bold'>{_('Subjects')}</span>
<p className='text-neutral-content line-clamp-3 text-sm'>
{formatAuthors(metadata?.subject || '') || _('Unknown')}
@@ -210,13 +210,13 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<span className='font-bold'>{_('Format')}</span>
<p className='text-neutral-content text-sm'>{book.format || _('Unknown')}</p>
</div>
<div className='overflow-hidden'>
<div className='overflow-hidden pe-1 text-end sm:text-start'>
<span className='font-bold'>{_('File Size')}</span>
<p className='text-neutral-content text-sm'>
{formatBytes(fileSize) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<div className='col-span-2 overflow-hidden sm:col-span-1'>
<span className='font-bold'>{_('Identifier')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{metadata?.identifier || _('Unknown')}
@@ -245,8 +245,8 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
</button>
{!settings.metadataSeriesCollapsed && (
<div className='px-4 py-1'>
<div className='grid grid-cols-3 gap-4'>
<div className='col-span-2 overflow-hidden'>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-3'>
<div className='overflow-hidden sm:col-span-2'>
<span className='font-bold'>{_('Series')}</span>
<p className='text-neutral-content text-sm'>{metadata?.series || _('Unknown')}</p>
</div>
@@ -415,7 +415,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
)}
{appService?.isMobileApp && (
<div className='config-item'>
<span className=''>{_('Auto Screen Brightness')}</span>
<span className=''>{_('System Screen Brightness')}</span>
<input
type='checkbox'
className='toggle'
@@ -5,12 +5,12 @@ import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useCustomFontStore } from '@/store/customFontStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { SettingsPanelType } from './SettingsDialog';
import Menu from '@/components/Menu';
import MenuItem from '@/components/MenuItem';
interface DialogMenuProps {
bookKey: string;
activePanel: SettingsPanelType;
setIsDropdownOpen?: (open: boolean) => void;
onReset: () => void;
@@ -18,6 +18,7 @@ interface DialogMenuProps {
}
const DialogMenu: React.FC<DialogMenuProps> = ({
bookKey,
activePanel,
setIsDropdownOpen,
onReset,
@@ -25,7 +26,6 @@ const DialogMenu: React.FC<DialogMenuProps> = ({
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const iconSize = useResponsiveSize(16);
const { setFontPanelView, isSettingsGlobal, setSettingsGlobal } = useSettingsStore();
const { getAllFonts, removeFont, saveCustomFonts } = useCustomFontStore();
@@ -59,8 +59,9 @@ const DialogMenu: React.FC<DialogMenuProps> = ({
<MenuItem
label={_('Global Settings')}
tooltip={isSettingsGlobal ? _('Apply to All Books') : _('Apply to This Book')}
disabled={!bookKey}
buttonClass='lg:tooltip'
Icon={isSettingsGlobal ? <MdCheck size={iconSize} className='text-base-content' /> : null}
Icon={isSettingsGlobal ? MdCheck : null}
onClick={handleToggleGlobal}
/>
<MenuItem label={resetLabel || _('Reset Settings')} onClick={handleResetToDefaults} />
@@ -256,6 +256,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
toggleButton={<PiDotsThreeVerticalBold />}
>
<DialogMenu
bookKey={bookKey}
activePanel={activePanel}
onReset={handleResetCurrentPanel}
resetLabel={
@@ -44,7 +44,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
setToken(access_token);
setUser(user);
} else {
console.log('Clearing session');
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
+2 -2
View File
@@ -174,7 +174,7 @@ export function useGamepad(config: GamepadConfig = {}) {
const onDisconnect = (e: GamepadEvent) => {
console.log('Gamepad disconnected:', e.gamepad.id);
const gamepads = navigator.getGamepads?.() || [];
const gamepads = Array.from(navigator.getGamepads?.() || []);
if (!gamepads.some((g) => g?.connected)) {
stopPolling();
}
@@ -183,7 +183,7 @@ export function useGamepad(config: GamepadConfig = {}) {
window.addEventListener('gamepadconnected', onConnect);
window.addEventListener('gamepaddisconnected', onDisconnect);
const gamepads = navigator.getGamepads?.() || [];
const gamepads = Array.from(navigator.getGamepads?.() || []);
if (gamepads.some((g) => g?.connected)) {
startPolling();
}
+3 -1
View File
@@ -140,7 +140,9 @@ export const useLongPress = (
if (onContextMenu) {
e.preventDefault();
e.stopPropagation();
onContextMenu();
setTimeout(() => {
onContextMenu();
}, 100);
}
reset();
},
@@ -3,12 +3,11 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { Insets } from '@/types/misc';
import { getSafeAreaInsets } from '@/utils/bridge';
import { getOSPlatform } from '@/utils/misc';
export const useSafeAreaInsets = () => {
const { appService } = useEnv();
const currentInsets = useRef({ top: 0, right: 0, bottom: 0, left: 0 });
const retryCount = useRef(0);
const maxRetries = 3;
const { updateSafeAreaInsets } = useThemeStore();
@@ -36,9 +35,12 @@ export const useSafeAreaInsets = () => {
const rootStyles = getComputedStyle(document.documentElement);
const hasCustomProperties = rootStyles.getPropertyValue('--safe-area-inset-top');
const isWebView139 = /Chrome\/139/.test(navigator.userAgent);
// safe-area-inset-* values in css are always 0px in some versions of webview 139
// due to https://issues.chromium.org/issues/40699457
if (appService.isAndroidApp && isWebView139) {
if (appService.isIOSApp && getOSPlatform() === 'macos') {
// for iPadOS use zero insets
updateInsets({ top: 0, right: 0, bottom: 0, left: 0 });
} else if ((appService.isAndroidApp && isWebView139) || appService.isIOSApp) {
// safe-area-inset-* values in css are always 0px in some versions of webview 139
// due to https://issues.chromium.org/issues/40699457
getSafeAreaInsets().then((response) => {
if (response.error) {
console.error('Error getting safe area insets from native bridge:', response.error);
@@ -64,19 +66,6 @@ export const useSafeAreaInsets = () => {
left: Math.round(left),
};
const allZero = top === 0 && right === 0 && bottom === 0 && left === 0;
if (appService.isIOSApp && allZero && retryCount.current < maxRetries) {
retryCount.current++;
setTimeout(() => {
onUpdateInsets();
}, 50 * retryCount.current);
return;
}
if (!allZero) {
retryCount.current = 0;
}
updateInsets(insets);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -95,7 +84,6 @@ export const useSafeAreaInsets = () => {
// Listen for visibility changes (app returning from background)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
retryCount.current = 0;
onUpdateInsets();
}
};
@@ -103,7 +91,6 @@ export const useSafeAreaInsets = () => {
// Listen for window focus (additional safeguard for iOS)
const handleFocus = () => {
retryCount.current = 0;
onUpdateInsets();
};
window.addEventListener('focus', handleFocus);
+24 -3
View File
@@ -1,5 +1,6 @@
import { BookFormat } from '@/types/book';
import { Collection, Contributor, Identifier, LanguageMap } from '@/utils/book';
import { configureZip } from '@/utils/zip';
import * as epubcfi from 'foliate-js/epubcfi.js';
export const CFI = epubcfi;
@@ -148,10 +149,9 @@ export class DocumentLoader {
return null;
};
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } =
await import('@zip.js/zip.js');
await configureZip();
const { ZipReader, BlobReader, TextWriter, BlobWriter } = await import('@zip.js/zip.js');
type Entry = import('@zip.js/zip.js').Entry;
configure({ useWebWorkers: false, useCompressionStream: false });
const reader = new ZipReader(new BlobReader(this.file));
const entries = await reader.getEntries();
const map = new Map(entries.map((entry) => [entry.filename, entry]));
@@ -283,3 +283,24 @@ export const getMimeTypeFromFileExt = (ext: string): string => {
}
return 'application/octet-stream';
};
export const convertBlobUrlToDataUrl = async (blobUrl: string): Promise<string> => {
try {
const response = await fetch(blobUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch blob from "${blobUrl}": ${response.status} ${response.statusText}`,
);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error('Failed to convert blob to data URL:', error);
throw error;
}
};
@@ -20,6 +20,7 @@ import {
LibraryGroupByType,
LibrarySortByType,
ReadSettings,
ReadwiseSettings,
SystemSettings,
} from '@/types/settings';
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota';
@@ -64,6 +65,14 @@ export const DEFAULT_KOSYNC_SETTINGS = {
enabled: false,
} as KOSyncSettings;
export const READWISE_API_BASE_URL = 'https://readwise.io/api/v2';
export const DEFAULT_READWISE_SETTINGS = {
enabled: false,
accessToken: '',
lastSyncedAt: 0,
} as ReadwiseSettings;
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
keepLogin: false,
autoUpload: true,
@@ -93,6 +102,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
metadataDescriptionCollapsed: false,
kosync: DEFAULT_KOSYNC_SETTINGS,
readwise: DEFAULT_READWISE_SETTINGS,
aiSettings: DEFAULT_AI_SETTINGS,
lastSyncedAtBooks: 0,
@@ -297,6 +307,7 @@ export const DEFAULT_NOTE_EXPORT_CONFIG: NoteExportConfig = {
includeChapterTitles: true,
includeQuotes: true,
includeNotes: true,
includePageNumber: true,
includeTimestamp: false,
includeChapterSeparator: false,
noteSeparator: '\n\n',
@@ -816,6 +827,7 @@ export const TRANSLATOR_LANGS: Record<string, string> = {
da: 'Dansk',
cs: 'Čeština',
hu: 'Magyar',
km: 'ខ្មែរ',
ro: 'Română',
bg: 'Български',
hr: 'Hrvatski',
@@ -60,6 +60,14 @@ declare global {
const OS_TYPE = osType();
const safeDecodePath = (input: string) => {
try {
return decodeURI(input);
} catch {
return input;
}
};
// Helper function to create a path resolver based on custom root directory and portable mode
// 0. If no custom root dir and not portable mode, use default Tauri BaseDirectory
// 1. If custom root dir is set, use it as base dir (baseDir = 0)
@@ -193,12 +201,13 @@ export const nativeFileSystem: FileSystem = {
return this.getURL(path);
},
async openFile(path: string, base: BaseDir, name?: string) {
const { fp, baseDir } = this.resolvePath(path, base);
let fname = name || getFilename(fp);
const normalizedPath = OS_TYPE === 'ios' ? safeDecodePath(path) : path;
const { fp, baseDir } = this.resolvePath(normalizedPath, base);
let fname = safeDecodePath(name || getFilename(fp));
if (isValidURL(path)) {
return await new RemoteFile(path, fname).open();
} else if (isContentURI(path) || (isFileURI(path) && OS_TYPE === 'ios')) {
fname = await basename(path);
fname = safeDecodePath(await basename(path));
if (path.includes('com.android.externalstorage')) {
// If the URI is from shared internal storage (like /storage/emulated/0),
// we can access it directly using the path — no need to copy.
@@ -219,9 +228,10 @@ export const nativeFileSystem: FileSystem = {
} else if (isFileURI(path)) {
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
} else {
if (OS_TYPE === 'android') {
if (OS_TYPE === 'android' || OS_TYPE === 'ios') {
// NOTE: RemoteFile is not usable on Android due to a known issue of range request in Android WebView.
// see https://issues.chromium.org/issues/40739128
// On iOS, importing picker Inbox files should also use NativeFile to avoid fetch/HEAD issues.
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
} else {
// NOTE: RemoteFile currently performs about 2× faster than NativeFile
@@ -514,7 +524,8 @@ export class NativeAppService extends BaseAppService {
multiple: true,
filters: [{ name, extensions }],
});
return Array.isArray(selected) ? selected : selected ? [selected] : [];
const files = Array.isArray(selected) ? selected : selected ? [selected] : [];
return OS_TYPE === 'ios' ? files.map((f) => safeDecodePath(f)) : files;
}
async saveFile(
@@ -0,0 +1,93 @@
import { Book, BookNote, HighlightColor } from '@/types/book';
import { ReadwiseSettings } from '@/types/settings';
import { READWISE_API_BASE_URL } from '@/services/constants';
const READEST_TO_READWISE_COLOR: Record<HighlightColor, string> = {
red: 'pink',
yellow: 'yellow',
green: 'green',
blue: 'blue',
violet: 'purple',
};
export class ReadwiseClient {
private config: ReadwiseSettings;
constructor(config: ReadwiseSettings) {
this.config = config;
}
private async request(
endpoint: string,
options: { method?: 'GET' | 'POST'; body?: string } = {},
): Promise<Response> {
const { method = 'GET', body } = options;
return fetch(`${READWISE_API_BASE_URL}${endpoint}`, {
method,
headers: {
Authorization: `Token ${this.config.accessToken}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body,
});
}
async validateToken(): Promise<{ valid: boolean; isNetworkError?: boolean }> {
try {
const res = await this.request('/auth/');
return { valid: res.status === 204 };
} catch {
return { valid: false, isNetworkError: true };
}
}
async pushHighlights(
notes: BookNote[],
book: Book,
): Promise<{ success: boolean; message?: string; isNetworkError?: boolean }> {
const syncable = notes.filter(
(n) => (n.type === 'annotation' || n.type === 'excerpt') && !n.deletedAt && n.text,
);
if (syncable.length === 0) return { success: true };
const isPublicUrl = (url?: string | null) =>
!!url && /^https?:\/\/(?!localhost|127\.|asset\.localhost)/.test(url);
const highlights = syncable.map((note) => ({
text: note.text!,
title: book.title,
author: book.author,
...(isPublicUrl(book.coverImageUrl) ? { image_url: book.coverImageUrl } : {}),
source_type: 'readest',
category: 'books',
note: note.note || undefined,
location: note.page,
location_type: 'page',
highlighted_at: new Date(note.createdAt).toISOString(),
highlight_url: `readest://annotation/${book.hash}/${note.id}`,
color: note.color ? (READEST_TO_READWISE_COLOR[note.color] ?? 'yellow') : 'yellow',
}));
try {
const res = await this.request('/highlights/', {
method: 'POST',
body: JSON.stringify({ highlights }),
});
if (!res.ok) {
const errText = await res.text().catch(() => '');
console.error('Readwise API error:', res.status, errText);
let message = `HTTP ${res.status}`;
try {
const err = JSON.parse(errText);
message = err.detail || err.message || JSON.stringify(err) || message;
} catch {
if (errText) message = errText;
}
return { success: false, message };
}
return { success: true };
} catch (e) {
return { success: false, message: (e as Error).message, isNetworkError: true };
}
}
}
@@ -0,0 +1 @@
export { ReadwiseClient } from './ReadwiseClient';
@@ -2,9 +2,8 @@ import { getAPIBaseUrl } from '@/services/environment';
import { stubTranslation as _ } from '@/utils/misc';
import { ErrorCodes, TranslationProvider } from '../types';
import { UserPlan } from '@/types/quota';
import { getSubscriptionPlan } from '@/utils/access';
import { getSubscriptionPlan, getTranslationQuota } from '@/utils/access';
import { normalizeToShortLang } from '@/utils/lang';
import { DEFAULT_DAILY_TRANSLATION_QUOTA } from '@/services/constants';
import { saveDailyUsage } from '../utils';
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
@@ -44,7 +43,7 @@ export const deeplProvider: TranslationProvider = {
use_cache: useCache,
});
const quota = DEFAULT_DAILY_TRANSLATION_QUOTA[userPlan];
const quota = getTranslationQuota(userPlan);
try {
const response = await fetch(DEEPL_API_ENDPOINT, { method: 'POST', headers, body });
@@ -9,6 +9,7 @@ import { NativeTTSClient } from './NativeTTSClient';
import { EdgeTTSClient } from './EdgeTTSClient';
import { TTSUtils } from './TTSUtils';
import { TTSClient } from './TTSClient';
import { isValidLang } from '@/utils/lang';
type TTSState =
| 'stopped'
@@ -155,6 +156,12 @@ export class TTSController extends EventTarget {
doc = currentSection.doc;
} else {
doc = await section.createDocument();
const html = doc.querySelector('html');
const lang = html?.getAttribute('lang') || html?.getAttribute('xml:lang') || '';
if (html && !isValidLang(lang) && this.ttsLang) {
html.setAttribute('lang', this.ttsLang);
html.setAttribute('xml:lang', this.ttsLang);
}
}
if (this.view.tts && this.view.tts.doc === doc) {
@@ -206,7 +213,16 @@ export class TTSController extends EventTarget {
}
async #handleNavigationWithSSML(ssml: string | undefined, isPlaying: boolean) {
if (isPlaying) this.#speak(ssml);
if (isPlaying) {
this.#speak(ssml);
} else {
if (ssml) {
const { marks } = parseSSMLMarks(ssml);
if (marks.length > 0) {
this.dispatchSpeakMark(marks[0]);
}
}
}
}
async #handleNavigationWithoutSSML(initSection: () => Promise<boolean>, isPlaying: boolean) {
@@ -524,6 +540,7 @@ export class TTSController extends EventTarget {
await this.stop();
this.#clearHighlighter();
this.#ttsSectionIndex = -1;
this.view.tts = null;
if (this.ttsWebClient.initialized) {
await this.ttsWebClient.shutdown();
}
+3 -2
View File
@@ -298,8 +298,8 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const viewState = state.viewStates[key];
if (!viewState || !bookData) return state;
const pagePressInfo = bookData.isFixedLayout ? section : pageinfo;
const progress: [number, number] = [pagePressInfo.current + 1, pagePressInfo.total];
const pageInfo = bookData.isFixedLayout ? section : pageinfo;
const progress: [number, number] = [pageInfo.current + 1, pageInfo.total];
// calculate progress percentage
const progressPercentage = Math.round((progress[0] / progress[1]) * 100);
@@ -365,6 +365,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
pageinfo,
timeinfo,
range,
page: pageInfo.current + 1, // 1-based page number
},
},
},
+108 -1
View File
@@ -457,6 +457,11 @@ foliate-fxl {
inset: -8px;
}
input[type='range'].slider-input {
-webkit-appearance: none;
appearance: none;
}
@keyframes spin-counterclockwise {
from {
transform: rotate(0deg);
@@ -467,6 +472,10 @@ foliate-fxl {
}
}
.btn-ghost.btn-active {
background-color: theme('colors.base-300');
}
.animate-reverse-spin {
animation: spin-counterclockwise 1s linear;
}
@@ -547,7 +556,8 @@ foliate-fxl {
color: theme('colors.base-content') !important;
}
[data-eink='true'] .btn-primary {
[data-eink='true'] .btn-primary,
[data-eink='true'] .btn-outline {
background-color: theme('colors.base-content') !important;
border: 1px solid theme('colors.base-content') !important;
color: theme('colors.base-100') !important;
@@ -630,3 +640,100 @@ foliate-fxl {
animation: settings-highlight-pulse 0.5s ease-in-out 2;
border-radius: 8px;
}
/* View Transitions for library navigation */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-nav-direction='forward'] ::view-transition-old(root) {
animation-name: slide-fade-out-left;
}
html[data-nav-direction='forward'] ::view-transition-new(root) {
animation-name: slide-fade-in-right;
}
html[data-nav-direction='back'] ::view-transition-old(root) {
animation-name: slide-fade-out-right;
}
html[data-nav-direction='back'] ::view-transition-new(root) {
animation-name: slide-fade-in-left;
}
/* Forward animations */
@keyframes slide-fade-out-left {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-30px);
}
}
@keyframes slide-fade-in-right {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Back animations */
@keyframes slide-fade-out-right {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(30px);
}
}
@keyframes slide-fade-in-left {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Simpler fade for reduced motion preference */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.15s;
}
::view-transition-old(root) {
animation-name: simple-fade-out !important;
}
::view-transition-new(root) {
animation-name: simple-fade-in !important;
}
@keyframes simple-fade-out {
to {
opacity: 0;
}
}
@keyframes simple-fade-in {
from {
opacity: 0;
}
}
}
+3
View File
@@ -85,6 +85,7 @@ export interface BookNote {
id: string;
type: BookNoteType;
cfi: string;
page?: number;
text?: string;
style?: HighlightStyle;
color?: HighlightColor;
@@ -242,6 +243,7 @@ export interface NoteExportConfig {
includeChapterTitles: boolean;
includeQuotes: boolean;
includeNotes: boolean;
includePageNumber: boolean;
includeTimestamp: boolean;
includeChapterSeparator: boolean;
noteSeparator: string;
@@ -306,6 +308,7 @@ export interface BookProgress {
pageinfo: PageInfo;
timeinfo: TimeInfo;
range: Range;
page: number;
}
export interface BookSearchConfig {
+1
View File
@@ -50,6 +50,7 @@ export interface OPDSPublication {
identifier?: string;
subject?: OPDSSubject[];
rights?: string;
content?: OPDSContent;
[SYMBOL.CONTENT]?: OPDSContent;
};
links: OPDSLink[];
+1
View File
@@ -41,6 +41,7 @@ export interface DBBookNote {
id: string;
type: string;
cfi: string;
page?: number;
text?: string;
style?: string;
color?: string;
+7
View File
@@ -64,6 +64,12 @@ export interface KOSyncSettings {
strategy: KOSyncStrategy;
}
export interface ReadwiseSettings {
enabled: boolean;
accessToken: string;
lastSyncedAt: number;
}
export interface SystemSettings {
version: number;
localBooksDir: string;
@@ -101,6 +107,7 @@ export interface SystemSettings {
metadataDescriptionCollapsed: boolean;
kosync: KOSyncSettings;
readwise: ReadwiseSettings;
lastSyncedAtBooks: number;
lastSyncedAtConfigs: number;
+8
View File
@@ -22,6 +22,12 @@ export interface FoliateView extends HTMLElement {
goLeft: () => void;
goRight: () => void;
getCFI: (index: number, range: Range) => string;
getCFIProgress: (cfi: string) => Promise<{
fraction: number;
section: { current: number; total: number };
location: { current: number; next: number; total: number };
time: { section: number; total: number };
} | null>;
resolveCFI: (cfi: string) => { index: number; anchor: RangeAnchor };
resolveNavigation: (cfiOrHrefOrIndex: string | number) => { index: number; anchor?: RangeAnchor };
addAnnotation: (
@@ -61,6 +67,8 @@ export interface FoliateView extends HTMLElement {
end: number;
page: number;
pages: number;
atStart: boolean;
atEnd: boolean;
containerPosition: number;
sideProp: 'width' | 'height';
setAttribute: (name: string, value: string | number) => void;
+9 -4
View File
@@ -47,11 +47,18 @@ export const getStoragePlanData = (token: string) => {
};
};
export const getTranslationQuota = (plan: UserPlan): number => {
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'] || '0');
return (
fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free']
);
};
export const getTranslationPlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan: UserPlan = data['plan'] || 'free';
const usage = getDailyUsage() || 0;
const quota = DEFAULT_DAILY_TRANSLATION_QUOTA[plan];
const quota = getTranslationQuota(plan);
return {
plan,
@@ -63,9 +70,7 @@ export const getTranslationPlanData = (token: string) => {
export const getDailyTranslationPlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'] || '0');
const quota =
fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free'];
const quota = getTranslationQuota(plan);
return {
plan,
+22 -7
View File
@@ -217,15 +217,29 @@ export class NativeFile extends File implements ClosableFile {
override stream(): ReadableStream<Uint8Array<ArrayBuffer>> {
const CHUNK_SIZE = 1024 * 1024;
let offset = 0;
let streamHandle: FileHandle | null = null;
let streamClosed = false;
const ensureHandle = async () => {
if (streamHandle) return streamHandle;
streamHandle = await open(this.#fp, this.#baseDir ? { baseDir: this.#baseDir } : undefined);
streamClosed = false;
return streamHandle;
};
const closeHandle = async () => {
if (!streamHandle || streamClosed) return;
await streamHandle.close();
streamClosed = true;
streamHandle = null;
};
return new ReadableStream<Uint8Array<ArrayBuffer>>({
pull: async (controller) => {
if (!this.#handle) {
controller.error(new Error('File handle is not open'));
return;
}
const handle = await ensureHandle();
if (offset >= this.size) {
await closeHandle();
controller.close();
return;
}
@@ -233,10 +247,11 @@ export class NativeFile extends File implements ClosableFile {
const end = Math.min(offset + CHUNK_SIZE, this.size);
const buffer = new Uint8Array(end - offset);
await this.#handle.seek(offset, SeekMode.Start);
const bytesRead = await this.#handle.read(buffer);
await handle.seek(offset, SeekMode.Start);
const bytesRead = await handle.read(buffer);
if (bytesRead === null || bytesRead === 0) {
await closeHandle();
controller.close();
return;
}
@@ -246,7 +261,7 @@ export class NativeFile extends File implements ClosableFile {
},
cancel: async () => {
await this.#handle?.close();
await closeHandle();
},
});
}

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