forked from akai/readest
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66d2fdf999 | |||
| d8e0ceeff1 | |||
| f3ad97b989 | |||
| 1bb49ab023 | |||
| f9a0b39586 | |||
| c533da498d | |||
| b50bc0b854 | |||
| 96c465931c | |||
| 2bd54ac236 | |||
| 6ad549d13c | |||
| 67249370c9 | |||
| fe3ab011ca | |||
| 6cb4278b98 | |||
| 51468862a2 | |||
| b3a44d066f | |||
| 68d4538d40 | |||
| 80105af839 | |||
| 3904c1e8e7 | |||
| 99be6c58e2 | |||
| 664cc772d0 | |||
| 21208adbcf | |||
| 70eb59e2d6 | |||
| 3e7b57282e | |||
| 534582b125 | |||
| 4465e6986e | |||
| a535f6419e | |||
| 65da9c1d47 | |||
| 5f71fd9e47 | |||
| dcf75e07d1 | |||
| 79ba9b3818 | |||
| 128b238bcb | |||
| e26f7e6a2c | |||
| 4c5ff59bcf | |||
| 99b2a34bd2 | |||
| 40f3268ef3 | |||
| 4dac0850c5 | |||
| 118538ba35 | |||
| 4a92cacd84 | |||
| ce53cd2b47 | |||
| b99c1bc19a | |||
| eec2c39f19 | |||
| c6ae85484e | |||
| 7a9f46e93c | |||
| b9f6578127 | |||
| e75a3d254e | |||
| ea15906acf | |||
| 15d2784725 | |||
| ae3bb9da9a | |||
| 239b32fcc2 |
@@ -1,4 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
docker/.env
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
pnpm -C apps/readest-app lint
|
||||
pnpm -C apps/readest-app test
|
||||
+32
-12
@@ -1,20 +1,40 @@
|
||||
FROM node:22-slim
|
||||
|
||||
ENV PNPM_HOME="/root/.local/share/pnpm"
|
||||
ENV PATH="${PATH}:${PNPM_HOME}"
|
||||
|
||||
RUN npm install --global pnpm
|
||||
|
||||
COPY . /app
|
||||
|
||||
FROM docker.io/node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.29.3 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/readest-app/package.json ./apps/readest-app/
|
||||
COPY patches/ ./patches/
|
||||
COPY packages/ ./packages/
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
FROM base AS dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @readest/readest-app setup-vendors
|
||||
|
||||
FROM dependencies AS development-stage
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["pnpm", "dev-web", "-H", "0.0.0.0"]
|
||||
|
||||
FROM base AS build
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_APP_PLATFORM
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_OBJECT_STORAGE_TYPE
|
||||
ARG NEXT_PUBLIC_STORAGE_FIXED_QUOTA
|
||||
ARG NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA
|
||||
COPY --from=dependencies /app/node_modules /app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/node_modules /app/apps/readest-app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-app/public/vendor
|
||||
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
RUN pnpm build-web
|
||||
|
||||
ENTRYPOINT ["pnpm", "start-web"]
|
||||
FROM build as production-stage
|
||||
ENTRYPOINT ["pnpm", "start-web", "-H", "0.0.0.0"]
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -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}} pagina’s in hoofdstuk",
|
||||
"{{count}} pages left in chapter_one": "<1>Nog </1><0>{{count}}</0><1> pagina in hoofdstuk</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Nog </1><0>{{count}}</0><1> pagina’s in hoofdstuk</1>",
|
||||
"Show Remaining Pages": "Toon resterende pagina’s",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +599,10 @@
|
||||
"Size": "Grootte",
|
||||
"Cover": "Omslag",
|
||||
"Contain": "Bevatten",
|
||||
"{{number}} pages left in chapter": "Nog {{number}} pagina’s in hoofdstuk",
|
||||
"{{number}} pages left in chapter": "<1>Nog </1><0>{{number}}</0><1> pagina’s in hoofdstuk</1>",
|
||||
"Device": "Apparaat",
|
||||
"E-Ink Mode": "E-Ink Modus",
|
||||
"Highlight Colors": "Markeerkleuren",
|
||||
"Auto Screen Brightness": "Automatische Schermhelderheid",
|
||||
"Pagination": "Paginering",
|
||||
"Disable Double Tap": "Schakel Dubbel Tikken Uit",
|
||||
"Tap to Paginate": "Tik om te Pagineren",
|
||||
@@ -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": "註解頁碼"
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+22
@@ -889,6 +889,28 @@ class NativeBridgePlugin: Plugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func get_safe_area_insets(_ invoke: Invoke) {
|
||||
DispatchQueue.main.async {
|
||||
if let window = UIApplication.shared.windows.first {
|
||||
let insets = window.safeAreaInsets
|
||||
invoke.resolve([
|
||||
"top": insets.top,
|
||||
"left": insets.left,
|
||||
"bottom": insets.bottom,
|
||||
"right": insets.right
|
||||
])
|
||||
} else {
|
||||
invoke.resolve([
|
||||
"error": "No window found",
|
||||
"top": 0,
|
||||
"left": 0,
|
||||
"bottom": 0,
|
||||
"right": 0
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_native_bridge")
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' data: blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
|
||||
},
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock('@/services/environment', async (importOriginal) => {
|
||||
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { DEFAULT_SYSTEM_SETTINGS } from '@/services/constants';
|
||||
|
||||
function renderWithProviders(ui: React.ReactNode) {
|
||||
return render(
|
||||
@@ -81,12 +82,7 @@ describe('ProofreadRulesManager', () => {
|
||||
beforeEach(() => {
|
||||
// Reset stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SYSTEM_SETTINGS,
|
||||
});
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
|
||||
useSidebarStore.setState({ sideBarBookKey: null });
|
||||
@@ -101,6 +97,7 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange: populate stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [
|
||||
{
|
||||
@@ -127,7 +124,6 @@ describe('ProofreadRulesManager', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -164,8 +160,8 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange: populate stores with a selection rule persisted in book config
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -284,10 +280,10 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [libraryRule],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -364,8 +360,8 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -418,8 +414,8 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
@@ -453,8 +449,8 @@ describe('ProofreadRulesManager', () => {
|
||||
it('shows empty state messages when no rules exist', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -509,10 +505,10 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [libraryRule],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { FoliateView } from '@/types/view';
|
||||
import { wrappedFoliateView } from '@/types/view';
|
||||
|
||||
// jsdom's Blob doesn't implement arrayBuffer(), polyfill it via FileReader
|
||||
if (typeof Blob.prototype.arrayBuffer !== 'function') {
|
||||
Blob.prototype.arrayBuffer = function () {
|
||||
return new Promise((res, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => res(reader.result as ArrayBuffer);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(this);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
|
||||
if (!customElements.get('foliate-paginator')) {
|
||||
customElements.define(
|
||||
'foliate-paginator',
|
||||
class extends HTMLElement {
|
||||
override setAttribute() {}
|
||||
override addEventListener() {}
|
||||
open() {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Mock the paginator module import so View doesn't try to load the real one
|
||||
vi.mock('foliate-js/paginator.js', () => ({}));
|
||||
|
||||
let book: BookDoc;
|
||||
let view: FoliateView;
|
||||
let totalSections: number;
|
||||
|
||||
const loadEPUB = async () => {
|
||||
const epubPath = resolve(__dirname, '../fixtures/sample-alice.epub');
|
||||
const buffer = readFileSync(epubPath);
|
||||
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const { book } = await loader.open();
|
||||
return book;
|
||||
};
|
||||
|
||||
describe('getCFIProgress with real EPUB', () => {
|
||||
beforeAll(async () => {
|
||||
book = await loadEPUB();
|
||||
totalSections = book.sections!.length;
|
||||
|
||||
// Import View and create the element, wrapping it like the app does
|
||||
await import('foliate-js/view.js');
|
||||
const rawView = document.createElement('foliate-view') as FoliateView;
|
||||
view = wrappedFoliateView(rawView);
|
||||
await view.open(book);
|
||||
}, 30000);
|
||||
|
||||
it('should load the EPUB with sections', () => {
|
||||
expect(book.sections!.length).toBeGreaterThan(0);
|
||||
expect(view.book).toBe(book);
|
||||
});
|
||||
|
||||
it('should return a valid progress object with all fields', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
expect(firstTextNode).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode!, 0);
|
||||
range.setEnd(firstTextNode!, 0);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// fraction is the overall book progress
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.fraction).toBeLessThanOrEqual(1);
|
||||
// section info
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
expect(result!.section.total).toBe(totalSections);
|
||||
// location info
|
||||
expect(result!.location.current).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.location.next).toBeGreaterThanOrEqual(result!.location.current);
|
||||
expect(result!.location.total).toBeGreaterThan(0);
|
||||
// time info
|
||||
expect(result!.time.section).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.time.total).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return the smallest fraction for a CFI at the start of a section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
expect(firstTextNode).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode!, 0);
|
||||
range.setEnd(firstTextNode!, 0);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
// At the start of a section, fraction should equal the section's start fraction
|
||||
// (i.e. only prior sections' sizes contribute)
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return a larger fraction for a CFI at the end of a section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
// Get start CFI
|
||||
const walkerStart = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walkerStart.nextNode()!;
|
||||
const startRange = doc.createRange();
|
||||
startRange.setStart(firstTextNode, 0);
|
||||
startRange.setEnd(firstTextNode, 0);
|
||||
const startCfi = view.getCFI(sectionIndex, startRange);
|
||||
const startResult = await view.getCFIProgress(startCfi);
|
||||
|
||||
// Get end CFI
|
||||
const walkerEnd = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
let lastTextNode: Node | null = null;
|
||||
for (let node = walkerEnd.nextNode(); node; node = walkerEnd.nextNode()) {
|
||||
lastTextNode = node;
|
||||
}
|
||||
expect(lastTextNode).not.toBeNull();
|
||||
const endRange = doc.createRange();
|
||||
endRange.setStart(lastTextNode!, lastTextNode!.textContent!.length);
|
||||
endRange.setEnd(lastTextNode!, lastTextNode!.textContent!.length);
|
||||
const endCfi = view.getCFI(sectionIndex, endRange);
|
||||
const endResult = await view.getCFIProgress(endCfi);
|
||||
|
||||
expect(startResult).not.toBeNull();
|
||||
expect(endResult).not.toBeNull();
|
||||
expect(endResult!.section.current).toBe(sectionIndex);
|
||||
expect(endResult!.fraction).toBeGreaterThan(startResult!.fraction);
|
||||
});
|
||||
|
||||
it('should return a fraction between start and end for a midpoint CFI', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Node[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
expect(textNodes.length).toBeGreaterThan(0);
|
||||
|
||||
const midNodeIndex = Math.floor(textNodes.length / 2);
|
||||
const midNode = textNodes[midNodeIndex]!;
|
||||
const midOffset = Math.floor((midNode.textContent?.length ?? 0) / 2);
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(midNode, midOffset);
|
||||
range.setEnd(midNode, midOffset);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
expect(result!.fraction).toBeGreaterThan(0);
|
||||
expect(result!.fraction).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('should produce consistent results for the same CFI', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 500);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const node = walker.nextNode();
|
||||
expect(node).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
const offset = Math.min(3, node!.textContent!.length);
|
||||
range.setStart(node!, offset);
|
||||
range.setEnd(node!, offset);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
|
||||
const result1 = await view.getCFIProgress(cfi);
|
||||
const result2 = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
expect(result1!.fraction).toBe(result2!.fraction);
|
||||
expect(result1!.section.current).toBe(result2!.section.current);
|
||||
expect(result1!.location.current).toBe(result2!.location.current);
|
||||
});
|
||||
|
||||
it('should return increasing fractions for successive CFIs in the same section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Node[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
const positions = [0, Math.floor(textNodes.length / 4), Math.floor((textNodes.length * 3) / 4)];
|
||||
const fractions: number[] = [];
|
||||
|
||||
for (const pos of positions) {
|
||||
const node = textNodes[pos]!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(node, 0);
|
||||
range.setEnd(node, 0);
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
fractions.push(result!.fraction);
|
||||
}
|
||||
|
||||
for (let i = 1; i < fractions.length; i++) {
|
||||
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return increasing fractions across different sections', async () => {
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no' && s.size > 0);
|
||||
|
||||
expect(linearSections.length).toBeGreaterThan(1);
|
||||
|
||||
const fractions: number[] = [];
|
||||
|
||||
for (const { s, i } of linearSections.slice(0, 3)) {
|
||||
const doc = await s.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
if (!firstTextNode) continue;
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode, 0);
|
||||
range.setEnd(firstTextNode, 0);
|
||||
|
||||
const cfi = view.getCFI(i, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(i);
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.fraction).toBeLessThanOrEqual(1);
|
||||
fractions.push(result!.fraction);
|
||||
}
|
||||
|
||||
// Fractions across sections should be increasing
|
||||
for (let i = 1; i < fractions.length; i++) {
|
||||
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for an invalid CFI', async () => {
|
||||
const result = await view.getCFIProgress('epubcfi(/99/999!/0)');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return progress for real CFIs', async () => {
|
||||
const expectedData = [
|
||||
{ cfi: 'epubcfi(/99/999!/0)', fraction: null },
|
||||
{ cfi: 'epubcfi(/6/4!/4/2/6,/1:0,/1:13)', fraction: 0.009, page: 1 },
|
||||
{ cfi: 'epubcfi(/6/8!/4/2/2[chapter_458]/4/18,/1:0,/1:681)', fraction: 0.045, page: 5 },
|
||||
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/2,/3:1,/3:18)', fraction: 0.098, page: 11 },
|
||||
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/4/70,/1:0,/1:261)', fraction: 0.161, page: 19 },
|
||||
{ cfi: 'epubcfi(/6/12!/4/2/2[chapter_462]/4/22,/1:0,/1:156)', fraction: 0.179, page: 21 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/6,/1:0,/1:65)', fraction: 0.411, page: 46 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/94,/1:1,/1:30)', fraction: 0.446, page: 51 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/134,/1:0,/1:58)', fraction: 0.473, page: 54 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/170,/1:0,/1:124)', fraction: 0.491, page: 55 },
|
||||
{ cfi: 'epubcfi(/6/26!/4/2/2[chapter_476]/4/120,/1:0,/1:117)', fraction: 0.804, page: 91 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4,/82/1:0,/86/1:26)', fraction: 0.955, page: 107 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:1,/1:118)', fraction: 0.955, page: 108 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:119,/1:358)', fraction: 0.955, page: 108 },
|
||||
];
|
||||
|
||||
for (const { cfi, fraction, page } of expectedData) {
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
if (fraction === null) {
|
||||
expect(result).toBeNull();
|
||||
} else {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(fraction, 1);
|
||||
expect(result!.location.current).toBe(page! - 1);
|
||||
expect(Math.abs(result!.fraction - fraction)).toBeLessThan(0.006);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PageProgress } from 'foliate-js/progress.js';
|
||||
|
||||
const createHTML = (html: string): Document => {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(`<!DOCTYPE html><html><body>${html}</body></html>`, 'text/html');
|
||||
};
|
||||
|
||||
const makeBook = (sections: { html: string }[]) => ({
|
||||
sections: sections.map(({ html }) => ({
|
||||
createDocument: () => Promise.resolve(createHTML(html)),
|
||||
})),
|
||||
});
|
||||
|
||||
describe('PageProgress', () => {
|
||||
describe('getProgress', () => {
|
||||
it('should return fraction 0 when anchor targets the start of a section', async () => {
|
||||
const book = makeBook([{ html: '<p>Hello world</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-start');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return fraction 1 when anchor targets the end of a section', async () => {
|
||||
const book = makeBook([{ html: '<p>Hello world</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, text.textContent!.length);
|
||||
range.setEnd(text, text.textContent!.length);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-end');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(1);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct fraction for a midpoint in a section', async () => {
|
||||
const book = makeBook([{ html: '<p>abcdefghij</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 5);
|
||||
range.setEnd(text, 5);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-mid');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0.5);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple text nodes across elements', async () => {
|
||||
const book = makeBook([{ html: '<p>aaaa</p><p>bbbb</p><p>cccc</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
// target start of second <p>, which is at offset 4 of 12 total chars
|
||||
const text = doc.body.querySelectorAll('p')[1]!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-multi');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(4 / 12);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle anchor returning an Element instead of a Range', async () => {
|
||||
const book = makeBook([{ html: '<p>first</p><p id="target">second</p><p>third</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => doc.getElementById('target')!,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-element');
|
||||
expect(result).not.toBeNull();
|
||||
// "first" is 5 chars, "second" starts at offset 5 of 16 total
|
||||
expect(result!.fraction).toBeCloseTo(5 / 16);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct index for non-zero section', async () => {
|
||||
const book = makeBook([
|
||||
{ html: '<p>chapter one</p>' },
|
||||
{ html: '<p>chapter two content</p>' },
|
||||
]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 1,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-section1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
expect(result!.index).toBe(1);
|
||||
});
|
||||
|
||||
it('should return null when resolveNavigation returns no index', async () => {
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = (): { index: undefined; anchor: undefined } => ({
|
||||
index: undefined,
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('bad-cfi');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when resolveNavigation returns no anchor', async () => {
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = (): { index: number; anchor: undefined } => ({
|
||||
index: 0,
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('no-anchor');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when section has no createDocument', async () => {
|
||||
const book = { sections: [{}] };
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: () => null,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('no-doc');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return fraction 0 for an empty document', async () => {
|
||||
const book = makeBook([{ html: '' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const range = doc.createRange();
|
||||
range.selectNodeContents(doc.body);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-empty');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
});
|
||||
|
||||
it('should return null and log error when resolveNavigation throws', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = () => {
|
||||
throw new Error('bad cfi');
|
||||
};
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('invalid');
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle nested elements correctly', async () => {
|
||||
const book = makeBook([{ html: '<div><span>ab</span><em>cd</em></div><p>ef</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
// target the <em> text node "cd" at offset 0, which is at char 2 of 6
|
||||
const text = doc.body.querySelector('em')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 2);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-nested');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(2 / 6);
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ParagraphIterator } from '@/utils/paragraph';
|
||||
|
||||
const createDoc = (body: string): Document =>
|
||||
new DOMParser().parseFromString(`<html><body>${body}</body></html>`, 'text/html');
|
||||
|
||||
describe('ParagraphIterator', () => {
|
||||
it('skips whitespace-only paragraphs', async () => {
|
||||
const doc = createDoc(`
|
||||
<p>First</p>
|
||||
<p> </p>
|
||||
<p> </p>
|
||||
<p>\u200b</p>
|
||||
<p><span>\u00a0</span></p>
|
||||
<p><br /></p>
|
||||
<p>Second</p>
|
||||
`);
|
||||
|
||||
const iterator = await ParagraphIterator.createAsync(doc, 1000);
|
||||
|
||||
expect(iterator.length).toBe(2);
|
||||
expect(iterator.first()?.toString()).toContain('First');
|
||||
expect(iterator.next()?.toString()).toContain('Second');
|
||||
});
|
||||
|
||||
it('keeps paragraphs with non-text content', async () => {
|
||||
const doc = createDoc(`
|
||||
<p>Intro</p>
|
||||
<p><img src="cover.png" alt="" /></p>
|
||||
<p>Outro</p>
|
||||
`);
|
||||
|
||||
const iterator = await ParagraphIterator.createAsync(doc, 1000);
|
||||
|
||||
expect(iterator.length).toBe(3);
|
||||
|
||||
iterator.first();
|
||||
expect(iterator.current()?.toString()).toContain('Intro');
|
||||
|
||||
const imageRange = iterator.next();
|
||||
const fragment = imageRange?.cloneContents();
|
||||
expect(fragment?.querySelector('img')).not.toBeNull();
|
||||
|
||||
expect(iterator.next()?.toString()).toContain('Outro');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { estimateTTSTime } from '@/utils/ttsTime';
|
||||
import { BookProgress } from '@/types/book';
|
||||
|
||||
const createProgress = (values: {
|
||||
sectionMin: number;
|
||||
totalMin: number;
|
||||
pageCurrent: number;
|
||||
pageTotal: number;
|
||||
}) => {
|
||||
return {
|
||||
timeinfo: {
|
||||
section: values.sectionMin,
|
||||
total: values.totalMin,
|
||||
},
|
||||
pageinfo: {
|
||||
current: values.pageCurrent,
|
||||
total: values.pageTotal,
|
||||
},
|
||||
} as BookProgress;
|
||||
};
|
||||
|
||||
describe('estimateTTSTime', () => {
|
||||
it('estimates chapter and book remaining time at 1x', () => {
|
||||
const progress = createProgress({
|
||||
sectionMin: 10,
|
||||
totalMin: 60,
|
||||
pageCurrent: 49,
|
||||
pageTotal: 100,
|
||||
});
|
||||
|
||||
const result = estimateTTSTime(progress, 1, 1_700_000_000_000);
|
||||
|
||||
expect(result.chapterRemainingSec).toBe(600);
|
||||
expect(result.bookRemainingSec).toBe(3600);
|
||||
expect(result.finishAtTimestamp).toBe(1_700_003_600_000);
|
||||
});
|
||||
|
||||
it('scales estimates by playback rate', () => {
|
||||
const progress = createProgress({
|
||||
sectionMin: 12,
|
||||
totalMin: 90,
|
||||
pageCurrent: 44,
|
||||
pageTotal: 90,
|
||||
});
|
||||
|
||||
const result = estimateTTSTime(progress, 1.5, 1000);
|
||||
|
||||
expect(result.chapterRemainingSec).toBe(480);
|
||||
expect(result.bookRemainingSec).toBe(3600);
|
||||
expect(result.finishAtTimestamp).toBe(3_601_000);
|
||||
});
|
||||
|
||||
it('falls back safely for missing progress', () => {
|
||||
const result = estimateTTSTime(null, 2, 1000);
|
||||
|
||||
expect(result.chapterRemainingSec).toBeNull();
|
||||
expect(result.bookRemainingSec).toBeNull();
|
||||
expect(result.finishAtTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('uses book remaining to compute finish time', () => {
|
||||
const progress = createProgress({
|
||||
sectionMin: 1,
|
||||
totalMin: 2,
|
||||
pageCurrent: 99,
|
||||
pageTotal: 100,
|
||||
});
|
||||
|
||||
const result = estimateTTSTime(progress, 1, 1000);
|
||||
|
||||
expect(result.bookRemainingSec).toBe(120);
|
||||
expect(result.finishAtTimestamp).toBe(121000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TxtToEpubConverter } from '@/utils/txt';
|
||||
|
||||
type TestChapter = {
|
||||
title: string;
|
||||
content: string;
|
||||
isVolume: boolean;
|
||||
};
|
||||
|
||||
type TestMetadata = {
|
||||
bookTitle: string;
|
||||
author: string;
|
||||
language: string;
|
||||
identifier: string;
|
||||
};
|
||||
|
||||
type TxtConverterPrivateAPI = {
|
||||
detectEncoding(buffer: ArrayBuffer): string | undefined;
|
||||
createEpub(chapters: TestChapter[], metadata: TestMetadata): Promise<Blob>;
|
||||
};
|
||||
|
||||
type TxtConverterFlowPrivateAPI = TxtConverterPrivateAPI & {
|
||||
convert(options: { file: File; author?: string; language?: string }): Promise<{
|
||||
chapterCount: number;
|
||||
}>;
|
||||
extractChapters(
|
||||
txtContent: string,
|
||||
metadata: TestMetadata,
|
||||
option: { linesBetweenSegments: number; fallbackParagraphsPerChapter: number },
|
||||
): TestChapter[];
|
||||
probeChapterCount(
|
||||
txtContent: string,
|
||||
metadata: TestMetadata,
|
||||
option: { linesBetweenSegments: number; fallbackParagraphsPerChapter: number },
|
||||
): number;
|
||||
iterateSegmentsFromTextChunks(
|
||||
chunks: Iterable<string>,
|
||||
linesBetweenSegments: number,
|
||||
): Generator<string>;
|
||||
detectEncodingFromFile(file: File): Promise<string | undefined>;
|
||||
extractChaptersFromFileBySegments(
|
||||
file: File,
|
||||
encoding: string,
|
||||
metadata: TestMetadata,
|
||||
option: { linesBetweenSegments: number; fallbackParagraphsPerChapter: number },
|
||||
): Promise<TestChapter[]>;
|
||||
probeChapterCountFromFileBySegments(
|
||||
file: File,
|
||||
encoding: string,
|
||||
metadata: TestMetadata,
|
||||
option: { linesBetweenSegments: number; fallbackParagraphsPerChapter: number },
|
||||
): Promise<number>;
|
||||
};
|
||||
|
||||
const getBufferSize = (input?: BufferSource): number => {
|
||||
if (!input) return 0;
|
||||
return input instanceof ArrayBuffer ? input.byteLength : input.byteLength;
|
||||
};
|
||||
|
||||
describe('TxtToEpubConverter', () => {
|
||||
it('convert should choose 8 -> 7 when probe detects multiple chapters', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const calls: number[] = [];
|
||||
|
||||
converter.detectEncoding = () => 'utf-8';
|
||||
converter.createEpub = async () => new Blob();
|
||||
converter.extractChapters = (_, __, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
if (option.linesBetweenSegments === 8) {
|
||||
return [{ title: 'Only', content: 'c', isVolume: false }];
|
||||
}
|
||||
if (option.linesBetweenSegments === 7) {
|
||||
return [
|
||||
{ title: 'A', content: 'a', isVolume: false },
|
||||
{ title: 'B', content: 'b', isVolume: false },
|
||||
];
|
||||
}
|
||||
return [{ title: 'Fallback', content: 'f', isVolume: false }];
|
||||
};
|
||||
converter.probeChapterCount = (_, __, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
return 2;
|
||||
};
|
||||
|
||||
const file = new File(['dummy content'], 'sample.txt');
|
||||
const result = await converter.convert({ file });
|
||||
|
||||
expect(calls).toEqual([8, 7, 7]);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('convert should choose 8 -> 6 when probe does not detect multiple chapters', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const calls: number[] = [];
|
||||
|
||||
converter.detectEncoding = () => 'utf-8';
|
||||
converter.createEpub = async () => new Blob();
|
||||
converter.extractChapters = (_, __, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
if (option.linesBetweenSegments === 8) {
|
||||
return [{ title: 'Only', content: 'c', isVolume: false }];
|
||||
}
|
||||
if (option.linesBetweenSegments === 6) {
|
||||
return [
|
||||
{ title: 'A', content: 'a', isVolume: false },
|
||||
{ title: 'B', content: 'b', isVolume: false },
|
||||
];
|
||||
}
|
||||
return [{ title: 'Single', content: 's', isVolume: false }];
|
||||
};
|
||||
converter.probeChapterCount = (_, __, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
return 1;
|
||||
};
|
||||
|
||||
const file = new File(['dummy content'], 'sample.txt');
|
||||
const result = await converter.convert({ file });
|
||||
|
||||
expect(calls).toEqual([8, 7, 6]);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('detectEncoding should probe UTF-8 with sampled buffers only', () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterPrivateAPI;
|
||||
const fullSize = 220 * 1024;
|
||||
const buffer = new TextEncoder().encode('a'.repeat(fullSize)).buffer;
|
||||
|
||||
const OriginalTextDecoder = globalThis.TextDecoder;
|
||||
const decodeSizes: number[] = [];
|
||||
|
||||
class RecordingTextDecoder extends OriginalTextDecoder {
|
||||
override decode(input?: BufferSource, options?: TextDecodeOptions): string {
|
||||
decodeSizes.push(getBufferSize(input));
|
||||
return super.decode(input, options);
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as { TextDecoder: typeof TextDecoder }).TextDecoder =
|
||||
RecordingTextDecoder as typeof TextDecoder;
|
||||
try {
|
||||
expect(converter.detectEncoding(buffer)).toBe('utf-8');
|
||||
} finally {
|
||||
(globalThis as { TextDecoder: typeof TextDecoder }).TextDecoder = OriginalTextDecoder;
|
||||
}
|
||||
|
||||
expect(Math.max(...decodeSizes)).toBeLessThanOrEqual(64 * 1024);
|
||||
expect(decodeSizes).toContain(8192);
|
||||
expect(decodeSizes).not.toContain(fullSize);
|
||||
});
|
||||
|
||||
it('createEpub should use metadata language for chapter lang attributes', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterPrivateAPI;
|
||||
const chapters: TestChapter[] = [
|
||||
{
|
||||
title: 'Chapter 1',
|
||||
content: '<h2>Chapter 1</h2><p>Hello world</p>',
|
||||
isVolume: false,
|
||||
},
|
||||
];
|
||||
const metadata: TestMetadata = {
|
||||
bookTitle: 'Sample Book',
|
||||
author: 'Sample Author',
|
||||
language: 'zh',
|
||||
identifier: 'sample-id',
|
||||
};
|
||||
|
||||
const blob = await converter.createEpub(chapters, metadata);
|
||||
const { ZipReader, BlobReader, TextWriter } = await import('@zip.js/zip.js');
|
||||
const reader = new ZipReader(new BlobReader(blob));
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
const chapterEntry = entries.find((entry) => entry.filename === 'OEBPS/chapter1.xhtml');
|
||||
expect(chapterEntry).toBeDefined();
|
||||
const chapterContent = await chapterEntry!.getData(new TextWriter());
|
||||
expect(chapterContent).toContain('lang="zh"');
|
||||
expect(chapterContent).toContain('xml:lang="zh"');
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('iterateSegmentsFromTextChunks should split by 8 newlines across chunk boundaries', () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const chunks = ['Segment A\n\n\n\n', '\n\n\n\nSegment B'];
|
||||
|
||||
const segments = Array.from(converter.iterateSegmentsFromTextChunks(chunks, 8));
|
||||
|
||||
expect(segments).toEqual(['Segment A', 'Segment B']);
|
||||
});
|
||||
|
||||
it('convert should use chunked path for large files without calling file.arrayBuffer', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const calls: number[] = [];
|
||||
let arrayBufferCalled = false;
|
||||
const backingBlob = new Blob(['Header line\n\n\n\n\n\n\n\nChapter content']);
|
||||
|
||||
const largeFile = {
|
||||
name: 'large.txt',
|
||||
size: 9 * 1024 * 1024,
|
||||
slice: (start?: number, end?: number) => backingBlob.slice(start, end),
|
||||
stream: () => backingBlob.stream(),
|
||||
arrayBuffer: async () => {
|
||||
arrayBufferCalled = true;
|
||||
throw new Error('large path should not call file.arrayBuffer');
|
||||
},
|
||||
} as unknown as File;
|
||||
|
||||
converter.detectEncodingFromFile = async () => 'utf-8';
|
||||
converter.createEpub = async () => new Blob();
|
||||
converter.extractChaptersFromFileBySegments = async (_, __, ___, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
if (option.linesBetweenSegments === 8) {
|
||||
return [{ title: 'Only', content: 'c', isVolume: false }];
|
||||
}
|
||||
if (option.linesBetweenSegments === 7) {
|
||||
return [
|
||||
{ title: 'A', content: 'a', isVolume: false },
|
||||
{ title: 'B', content: 'b', isVolume: false },
|
||||
];
|
||||
}
|
||||
return [{ title: 'Fallback', content: 'f', isVolume: false }];
|
||||
};
|
||||
converter.probeChapterCountFromFileBySegments = async (_, __, ___, option) => {
|
||||
calls.push(option.linesBetweenSegments);
|
||||
return 2;
|
||||
};
|
||||
|
||||
const result = await converter.convert({ file: largeFile });
|
||||
|
||||
expect(arrayBufferCalled).toBe(false);
|
||||
expect(calls).toEqual([8, 7, 7]);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('convert large file should execute real chunked extraction without file.arrayBuffer', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
let arrayBufferCalled = false;
|
||||
const backingBlob = new Blob(['Segment A\n\n\n\n\n\n\n\nSegment B']);
|
||||
|
||||
const largeFile = {
|
||||
name: 'large.txt',
|
||||
size: 9 * 1024 * 1024,
|
||||
slice: (start?: number, end?: number) => backingBlob.slice(start, end),
|
||||
stream: () => backingBlob.stream(),
|
||||
arrayBuffer: async () => {
|
||||
arrayBufferCalled = true;
|
||||
throw new Error('large path should not call file.arrayBuffer');
|
||||
},
|
||||
} as unknown as File;
|
||||
|
||||
converter.createEpub = async () => new Blob();
|
||||
|
||||
const result = await converter.convert({ file: largeFile });
|
||||
|
||||
expect(arrayBufferCalled).toBe(false);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('iterateSegmentsFromFile should cancel stream on early return', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI & {
|
||||
iterateSegmentsFromFile(
|
||||
file: File,
|
||||
encoding: string,
|
||||
linesBetweenSegments: number,
|
||||
): AsyncGenerator<string>;
|
||||
};
|
||||
const encoder = new TextEncoder();
|
||||
let cancelled = false;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('Segment A\n\n\n\n\n\n\n\nSegment B'));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
|
||||
const file = {
|
||||
stream: () => stream,
|
||||
} as unknown as File;
|
||||
|
||||
const iterator = converter.iterateSegmentsFromFile(file, 'utf-8', 8);
|
||||
const first = await iterator.next();
|
||||
expect(first.value).toBe('Segment A');
|
||||
await iterator.return(undefined);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { ViewTransitions } from 'next-view-transitions';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
@@ -72,9 +73,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta name='twitter:image' content={previewImage} />
|
||||
</head>
|
||||
<body>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
<ViewTransitions>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
</ViewTransitions>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ interface BookshelfProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handlePushLibrary: () => Promise<void>;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
}
|
||||
@@ -69,6 +70,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleLibraryNavigation,
|
||||
handlePushLibrary,
|
||||
booksTransferProgress,
|
||||
}) => {
|
||||
@@ -426,6 +428,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
|
||||
@@ -434,7 +437,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
))}
|
||||
{viewMode === 'grid' && currentBookshelfItems.length > 0 && (
|
||||
<div
|
||||
className={clsx('mx-0 my-2 sm:mx-4 sm:my-4')}
|
||||
className={clsx('bookshelf-import-item mx-0 my-2 sm:mx-4 sm:my-4')}
|
||||
style={
|
||||
coverFit === 'fit' && viewMode === 'grid'
|
||||
? {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useTransitionRouter } from 'next-view-transitions';
|
||||
import { navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -99,6 +99,7 @@ interface BookshelfItemProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
|
||||
}
|
||||
|
||||
@@ -116,11 +117,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleBookDownload,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useTransitionRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
@@ -179,15 +180,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
if (isSelectMode) {
|
||||
toggleSelection(group.id);
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('group', group.id);
|
||||
setTimeout(() => {
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}, 0);
|
||||
handleLibraryNavigation(group.id);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isSelectMode, searchParams],
|
||||
[isSelectMode, handleLibraryNavigation],
|
||||
);
|
||||
|
||||
const bookContextMenuHandler = async (book: Book) => {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
) : (
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface OPDSPublication {
|
||||
identifier?: string;
|
||||
subject?: OPDSSubject[];
|
||||
rights?: string;
|
||||
content?: OPDSContent;
|
||||
[SYMBOL.CONTENT]?: OPDSContent;
|
||||
};
|
||||
links: OPDSLink[];
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface DBBookNote {
|
||||
id: string;
|
||||
type: string;
|
||||
cfi: string;
|
||||
page?: number;
|
||||
text?: string;
|
||||
style?: string;
|
||||
color?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user