Compare commits

..

13 Commits

Author SHA1 Message Date
Huang Xin 3174e341a3 release: version 0.10.4 (#3767) 2026-04-05 19:57:06 +02:00
Huang Xin 298d4872a0 fix(translate): disable yandex provider while upstream relay is down (#3765)
The translate.toil.cc relay that backs the yandex provider is currently
unavailable. Introduce a `disabled` flag on TranslationProvider and
filter disabled providers out of both `getTranslators()` and
`getTranslator()` so the settings panel, translator popup, and the
fallback logic in `useTranslator` all agree the provider doesn't exist —
no per-callsite changes needed. Flip the flag back (or delete the line)
on `yandexProvider` to re-enable once the upstream is healthy.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:48:25 +02:00
Huang Xin b679817fce fix(tts): prevent double playback on rapid TTS icon clicks (#3764)
Guard handleTTSSpeak with a single-flight ref so a second tts-speak
event that arrives while the first is still inside its initial awaits
(initMediaSession / backgroundAudio / TTSController.init) is ignored
instead of racing ahead to construct a second TTSController. Without
this, rapid clicks produced two concurrent speakers talking over each
other because viewState.ttsEnabled is only set at the end of the first
invocation, so the footer bar would dispatch tts-speak twice.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:11:28 +02:00
Huang Xin b9a2b10fac fix(a11y): fixed keyboard activation of dropdown menu (#3762) 2026-04-05 18:48:27 +02:00
Huang Xin a9e33ca50a fix(style): let background texture take precedence over overridden background color (#3761)
When a background texture is active, skip applying the overridden bg/fg/border
colors on block elements so the texture stays visible instead of being painted over.
2026-04-05 18:05:05 +02:00
Huang Xin 09d1e0c040 fix(sync): show last push time as the last sync time (#3760) 2026-04-05 17:55:41 +02:00
Huang Xin 8b10e7fb17 fix(layout): use mobile footer bar in portrait mode without regressing phone panel animation, closes #3742 (#3759)
On mobile tablets/foldables (Android or iOS) in portrait, the viewport
width can exceed the Tailwind \`sm:\` breakpoint (640px), causing the
desktop footer bar to show and hiding the brightness / font size /
color / progress panels that only exist in the mobile layout.

The previous fix in #3746 introduced a regression on phones: wrapping
MobileFooterBar's children in a \`<div>\` changed the flex layout of
the footer container, and panels no longer slid cleanly behind the
navigation bar on dismissal. That PR was reverted.

This re-implementation scopes the override narrowly:

- \`forceMobileLayout\` is only true for mobile devices in portrait
  with innerWidth >= 640. Phones (innerWidth < 640) always get
  \`false\`, so every \`!forceMobileLayout && '…'\` expression
  evaluates to the original class string — phone classNames are
  set-equal to the pre-#3746 version.
- MobileFooterBar keeps its Fragment return; no wrapper div is
  introduced anywhere in the tree, preserving the panel slide-down
  animation exactly as before.
- DesktopFooterBar, NavigationBar, and the three panels
  (ColorPanel / FontLayoutPanel / NavigationPanel) gate their
  \`sm:hidden\` / \`sm:flex\` classes on \`!forceMobileLayout\` so
  they correctly show/hide on wide portrait tablets.
- The orphaned \`.force-mobile-layout\` CSS override in globals.css
  is removed since we no longer rely on a class-based escape hatch.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:40:36 +02:00
Huang Xin a2d17e6a79 fix: clear highlight overlay when deleting annotation from sidebar, closes #3756 (#3758)
The sidebar delete handler always removed annotations through the note
bubble overlay key (`NOTE_PREFIX + cfi`), but highlights are keyed by
the raw CFI. Deleting a highlight from the sidebar therefore left its
overlay drawn until the book was reopened, while the popup path — which
lets `wrappedFoliateView` default the value to the CFI — worked.

Introduce `removeBookNoteOverlays` that mirrors the draw filters in
`Annotator.tsx` and clears every overlay a BookNote can own (highlight
and/or note bubble), and route the sidebar delete through it.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:25:58 +02:00
Huang Xin 7e62516b5d chore(deps): bump Next.js to version 16.2.2 (#3757) 2026-04-05 15:59:10 +02:00
Lex Moulton d53f3b42e2 feat(rsvp): split words option, faster countdown, and skip pages RSVP cant open (#3755)
* fix: use string-parsed year to avoid UTC/timezone boundary issues

* feat: Add option to split words in RSVP mode

* fix: change the checkbox to a toggle

* fix(rsvp): speed up countdown from 2.4s to 1.5s

* feat(rsvp): skip to next page when no readable text is found

* fix(rsvp): replace lookbehind regex with lookahead-only split in getHyphenParts
2026-04-05 14:29:30 +02:00
Mohammed Efaz d682fcbb44 feat: add named highlight colors with sync and picker ux fixes (#3741)
* fix: add highlight color label fields

* fix: add default highlight label sync fields

* fix: add highlight prefs sync helpers

* fix: add highlight color name inputs

* fix: persist and sync highlight color names

* fix: add highlight color label helpers

* fix: add long press highlight label preview

* fix: pull highlight color prefs during library sync

* test: cover highlight color label helpers

* fix: widen highlight color name inputs

* fix: show highlight names on hover and touch hold

* fix: prevent highlight name input overlap

* fix: improve highlight name input responsiveness

* fix: support drag scrolling for highlight colors

* fix: batch custom color and label updates

* fix: serialize highlight prefs saves

* fix: align color strip drag and restore color clicks

* fix: translate default highlight color labels

* refactor: remove highlight preference sync wiring

* fix: align highlight option i18n with existing pattern

* fix: remove redundant english highlight keys

* fix: support raw and normalized highlight label keys

* fix: use underscore translator in highlight options

* fix: translate custom highlight color labels in editor

* refactor: simplify highlight settings persistence

* fix: maintianer review

* refactor: simplify highlight prefs save and clean up editor

- Drop the skipUserColors/skipLabels options from handleHighlightPrefsChange
  and always persist both arrays; the flags only masked a no-op caller.
- Type handleHighlightColorsChange with Record<HighlightColor, string>
  instead of typeof so the signature reads clearly.
- Remove the always-true `|| true` guard around the custom colors section.
- Stop wrapping user-typed custom color labels with _(), matching the
  built-in color inputs and keeping the input value equal to what the
  user typed.

* refactor: couple highlight labels to their colors

Replace the parallel `highlightColorLabels: Record<string, string>` map
with label storage that lives next to each color. This removes the hex
key normalization layer and its whole class of orphan/case-drift bugs.

Data model:
  userHighlightColors: string[]                    ->  UserHighlightColor[]
                                                       ({ hex, label? })
  highlightColorLabels: Record<string, string>     ->  (removed)
                                                       defaultHighlightLabels:
                                                         Partial<Record<DefaultHighlightColor, string>>

A `migrateHighlightColorPrefs` helper runs during `loadSettings` and
handles both the shipped `string[]` layout and the draft-build
`highlightColorLabels` layout: hex-keyed labels attach to matching user
colors, name-keyed labels move into `defaultHighlightLabels`. Malformed
entries are dropped.

Editor:
  - Label inputs commit on blur (Enter also commits), so typing a long
    label no longer fires `setSettings`/`saveSettings` on every
    keystroke. A small `LabelInput` component owns the draft state and
    syncs if the prop changes externally.
  - Three explicit callbacks (`onCustomHighlightColorsChange`,
    `onUserHighlightColorsChange`, `onDefaultHighlightLabelsChange`)
    replace the previous single callback with opaque skip flags.

Picker:
  - Extracted a reusable `useDragScroll` hook (mouse only, 6px
    threshold, 120ms click suppression) from the inline state machine
    in `HighlightOptions`. The picker drops to ~280 lines.
  - Long-press preview stays touch/pen only. It now reads labels via
    `getHighlightColorLabel` (which returns `undefined` when no user
    label is set), letting the component layer decide whether to fall
    back to a translated default name.

i18n:
  - Default color names ('red' | 'yellow' | 'green' | 'blue' | 'violet')
    are registered once at module scope via `stubTranslation` and
    translated at the picker layer through `useTranslation`. User-typed
    labels are never run through `_()`, so what the user types is what
    the editor shows.

Tests:
  - `annotator-util.test.ts`: rewritten around the new helper contract
    (user label -> undefined fallback) and case-insensitive hex matching.
  - `settings-highlight-migration.test.ts`: new, covers legacy
    `string[]`, already-migrated entries, malformed hex filtering, and
    the two draft-label fold paths.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-04-05 18:08:55 +08:00
Huang Xin b3fe33221d feat(i18n): add Hungarian translation and translate new keys across all locales (#3753)
Add Hungarian (hu) as a new supported locale with full translation coverage.
Also translate the new "Toggle Toolbar" key across all 30 existing locales.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:15:48 +02:00
Huang Xin fbfd7fd6c6 fix(hardcover): use Tauri HTTP plugin to bypass CORS and coerce search result IDs to numbers, closes #3751 (#3752)
The Hardcover GraphQL API rejects requests from tauri://localhost origin on iOS.
Switch to tauriFetch (like KOSyncClient) to bypass CORS in Tauri environments.
Also coerce bookId/editionId/pages from search results to numbers since the
search API returns string IDs but the GraphQL mutations expect 32-bit integers.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:56:47 +02:00
82 changed files with 3052 additions and 479 deletions
@@ -36,6 +36,7 @@ const options = {
'zh-CN',
'zh-TW',
'ro',
'hu',
],
ns: ['translation'],
defaultNs: 'translation',
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.10.2",
"version": "0.10.4",
"private": true,
"type": "module",
"scripts": {
@@ -146,7 +146,7 @@
"lunr": "^2.3.9",
"marked": "^15.0.12",
"nanoid": "^5.1.6",
"next": "16.2.1",
"next": "16.2.2",
"next-view-transitions": "^0.3.5",
"nunjucks": "^3.2.4",
"overlayscrollbars": "^2.11.4",
@@ -229,5 +229,11 @@
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.18",
"wrangler": "^4.77.0"
}
},
"browserslist": [
"chrome 92",
"edge 92",
"firefox 92",
"safari 15.2"
]
}
@@ -1234,5 +1234,6 @@
"Reading progress synced to Hardcover": "تمت مزامنة تقدم القراءة إلى Hardcover",
"Hardcover progress sync failed: {{error}}": "فشلت مزامنة تقدم Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "الإبقاء على معرف المصدر الحالي"
"Keep existing source identifier": "الإبقاء على معرف المصدر الحالي",
"Toggle Toolbar": "تبديل شريط الأدوات"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "পড়ার অগ্রগতি Hardcover-এ সিঙ্ক হয়েছে",
"Hardcover progress sync failed: {{error}}": "Hardcover অগ্রগতি সিঙ্ক ব্যর্থ: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "বিদ্যমান উৎস শনাক্তকারী রাখুন"
"Keep existing source identifier": "বিদ্যমান উৎস শনাক্তকারী রাখুন",
"Toggle Toolbar": "টুলবার টগল করুন"
}
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "ཀློག་འགྲོས་ Hardcover ལ་ཟླ་སྒྲིག་བྱས་ཟིན།",
"Hardcover progress sync failed: {{error}}": "Hardcover ཀློག་འགྲོས་ཟླ་སྒྲིག་མི་ཚོགས: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "ཡོད་བཞིན་པའི་འབྱུང་ཁུངས་ཐོབ་ཐང་གཞག"
"Keep existing source identifier": "ཡོད་བཞིན་པའི་འབྱུང་ཁུངས་ཐོབ་ཐང་གཞག",
"Toggle Toolbar": "ལག་ཆའི་ཚན་བར་བརྗེ་བ།"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "Lesefortschritt mit Hardcover synchronisiert",
"Hardcover progress sync failed: {{error}}": "Hardcover-Fortschrittssynchronisierung fehlgeschlagen: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Vorhandene Quellkennung beibehalten"
"Keep existing source identifier": "Vorhandene Quellkennung beibehalten",
"Toggle Toolbar": "Symbolleiste umschalten"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "Η πρόοδος ανάγνωσης συγχρονίστηκε στο Hardcover",
"Hardcover progress sync failed: {{error}}": "Αποτυχία συγχρονισμού προόδου Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Διατήρηση υπάρχοντος αναγνωριστικού πηγής"
"Keep existing source identifier": "Διατήρηση υπάρχοντος αναγνωριστικού πηγής",
"Toggle Toolbar": "Εναλλαγή γραμμής εργαλείων"
}
@@ -1195,5 +1195,6 @@
"Reading progress synced to Hardcover": "Progreso de lectura sincronizado con Hardcover",
"Hardcover progress sync failed: {{error}}": "Error al sincronizar progreso de Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Mantener el identificador de origen existente"
"Keep existing source identifier": "Mantener el identificador de origen existente",
"Toggle Toolbar": "Alternar barra de herramientas"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "پیشرفت مطالعه با Hardcover همگام‌سازی شد",
"Hardcover progress sync failed: {{error}}": "همگام‌سازی پیشرفت Hardcover ناموفق: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "حفظ شناسه منبع موجود"
"Keep existing source identifier": "حفظ شناسه منبع موجود",
"Toggle Toolbar": "تغییر نوار ابزار"
}
@@ -1195,5 +1195,6 @@
"Reading progress synced to Hardcover": "Progression de lecture synchronisée avec Hardcover",
"Hardcover progress sync failed: {{error}}": "Échec de la synchronisation de la progression Hardcover : {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Conserver l'identifiant source existant"
"Keep existing source identifier": "Conserver l'identifiant source existant",
"Toggle Toolbar": "Basculer la barre d'outils"
}
@@ -1195,5 +1195,6 @@
"Reading progress synced to Hardcover": "התקדמות הקריאה סונכרנה ל-Hardcover",
"Hardcover progress sync failed: {{error}}": "סנכרון התקדמות Hardcover נכשל: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "שמור על מזהה המקור הקיים"
"Keep existing source identifier": "שמור על מזהה המקור הקיים",
"Toggle Toolbar": "הצג/הסתר סרגל כלים"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "पढ़ने की प्रगति Hardcover से सिंक हो गई",
"Hardcover progress sync failed: {{error}}": "Hardcover प्रगति सिंक विफल: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "मौजूदा स्रोत पहचानकर्ता रखें"
"Keep existing source identifier": "मौजूदा स्रोत पहचानकर्ता रखें",
"Toggle Toolbar": "टूलबार टॉगल करें"
}
File diff suppressed because it is too large Load Diff
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "Progres membaca disinkronkan ke Hardcover",
"Hardcover progress sync failed: {{error}}": "Sinkronisasi progres Hardcover gagal: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Pertahankan pengidentifikasi sumber yang ada"
"Keep existing source identifier": "Pertahankan pengidentifikasi sumber yang ada",
"Toggle Toolbar": "Alihkan Bilah Alat"
}
@@ -1195,5 +1195,6 @@
"Reading progress synced to Hardcover": "Progressi di lettura sincronizzati con Hardcover",
"Hardcover progress sync failed: {{error}}": "Sincronizzazione progressi Hardcover non riuscita: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Mantieni l'identificatore sorgente esistente"
"Keep existing source identifier": "Mantieni l'identificatore sorgente esistente",
"Toggle Toolbar": "Mostra/nascondi barra degli strumenti"
}
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "読書の進捗がHardcoverに同期されました",
"Hardcover progress sync failed: {{error}}": "Hardcover進捗の同期に失敗: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "既存のソース識別子を保持"
"Keep existing source identifier": "既存のソース識別子を保持",
"Toggle Toolbar": "ツールバーの表示切替"
}
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "독서 진행 상황이 Hardcover에 동기화됨",
"Hardcover progress sync failed: {{error}}": "Hardcover 진행 상황 동기화 실패: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "기존 소스 식별자 유지"
"Keep existing source identifier": "기존 소스 식별자 유지",
"Toggle Toolbar": "도구 모음 전환"
}
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "Kemajuan bacaan disegerakkan ke Hardcover",
"Hardcover progress sync failed: {{error}}": "Penyegerakan kemajuan Hardcover gagal: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Kekalkan pengecam sumber sedia ada"
"Keep existing source identifier": "Kekalkan pengecam sumber sedia ada",
"Toggle Toolbar": "Togol Bar Alat"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "Leesvoortgang gesynchroniseerd met Hardcover",
"Hardcover progress sync failed: {{error}}": "Synchronisatie van Hardcover-voortgang mislukt: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Bestaande bronidentificatie behouden"
"Keep existing source identifier": "Bestaande bronidentificatie behouden",
"Toggle Toolbar": "Werkbalk in-/uitschakelen"
}
@@ -1208,5 +1208,6 @@
"Reading progress synced to Hardcover": "Postęp czytania zsynchronizowany z Hardcover",
"Hardcover progress sync failed: {{error}}": "Synchronizacja postępu Hardcover nie powiodła się: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Zachowaj istniejący identyfikator źródła"
"Keep existing source identifier": "Zachowaj istniejący identyfikator źródła",
"Toggle Toolbar": "Przełącz pasek narzędzi"
}
@@ -1195,5 +1195,6 @@
"Reading progress synced to Hardcover": "Progresso de leitura sincronizado com o Hardcover",
"Hardcover progress sync failed: {{error}}": "Falha na sincronização de progresso do Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Manter o identificador de origem existente"
"Keep existing source identifier": "Manter o identificador de origem existente",
"Toggle Toolbar": "Alternar barra de ferramentas"
}
@@ -1195,5 +1195,6 @@
"Reading progress synced to Hardcover": "Progresul lecturii sincronizat cu Hardcover",
"Hardcover progress sync failed: {{error}}": "Sincronizarea progresului Hardcover a eșuat: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Păstrați identificatorul sursă existent"
"Keep existing source identifier": "Păstrați identificatorul sursă existent",
"Toggle Toolbar": "Comutare bară de instrumente"
}
@@ -1208,5 +1208,6 @@
"Reading progress synced to Hardcover": "Прогресс чтения синхронизирован с Hardcover",
"Hardcover progress sync failed: {{error}}": "Ошибка синхронизации прогресса Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Сохранить существующий идентификатор источника"
"Keep existing source identifier": "Сохранить существующий идентификатор источника",
"Toggle Toolbar": "Переключить панель инструментов"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "කියවීමේ ප්‍රගතිය Hardcover වෙත සමමුහුර්ත විය",
"Hardcover progress sync failed: {{error}}": "Hardcover ප්‍රගති සමමුහුර්තකරණය අසාර්ථකයි: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "පවතින මූලාශ්‍ර හඳුනාගැනීම තබාගන්න"
"Keep existing source identifier": "පවතින මූලාශ්‍ර හඳුනාගැනීම තබාගන්න",
"Toggle Toolbar": "මෙවලම් තීරුව ටොගල් කරන්න"
}
@@ -1208,5 +1208,6 @@
"Reading progress synced to Hardcover": "Napredek branja sinhroniziran s Hardcover",
"Hardcover progress sync failed: {{error}}": "Sinhronizacija napredka Hardcover ni uspela: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Ohrani obstoječi identifikator vira"
"Keep existing source identifier": "Ohrani obstoječi identifikator vira",
"Toggle Toolbar": "Preklopi orodno vrstico"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "Läsframsteg synkroniserat med Hardcover",
"Hardcover progress sync failed: {{error}}": "Synkronisering av Hardcover-framsteg misslyckades: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Behåll befintlig källidentifierare"
"Keep existing source identifier": "Behåll befintlig källidentifierare",
"Toggle Toolbar": "Visa/dölj verktygsfältet"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "வாசிப்பு முன்னேற்றம் Hardcover உடன் ஒத்திசைக்கப்பட்டது",
"Hardcover progress sync failed: {{error}}": "Hardcover முன்னேற்ற ஒத்திசைவு தோல்வி: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "ஏற்கனவே உள்ள மூல அடையாளங்காட்டியை வைத்திருக்கவும்"
"Keep existing source identifier": "ஏற்கனவே உள்ள மூல அடையாளங்காட்டியை வைத்திருக்கவும்",
"Toggle Toolbar": "கருவிப்பட்டியை நிலைமாற்று"
}
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "ความคืบหน้าการอ่านซิงค์กับ Hardcover แล้ว",
"Hardcover progress sync failed: {{error}}": "การซิงค์ความคืบหน้า Hardcover ล้มเหลว: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "เก็บตัวระบุแหล่งที่มาที่มีอยู่"
"Keep existing source identifier": "เก็บตัวระบุแหล่งที่มาที่มีอยู่",
"Toggle Toolbar": "สลับแถบเครื่องมือ"
}
@@ -1182,5 +1182,6 @@
"Reading progress synced to Hardcover": "Okuma ilerlemesi Hardcover ile senkronize edildi",
"Hardcover progress sync failed: {{error}}": "Hardcover ilerleme senkronizasyonu başarısız: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Mevcut kaynak tanımlayıcısını koru"
"Keep existing source identifier": "Mevcut kaynak tanımlayıcısını koru",
"Toggle Toolbar": "Araç Çubuğunu Aç/Kapat"
}
@@ -1208,5 +1208,6 @@
"Reading progress synced to Hardcover": "Прогрес читання синхронізовано з Hardcover",
"Hardcover progress sync failed: {{error}}": "Помилка синхронізації прогресу Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Зберегти наявний ідентифікатор джерела"
"Keep existing source identifier": "Зберегти наявний ідентифікатор джерела",
"Toggle Toolbar": "Перемкнути панель інструментів"
}
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "Tiến trình đọc đã đồng bộ với Hardcover",
"Hardcover progress sync failed: {{error}}": "Đồng bộ tiến trình Hardcover thất bại: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Giữ mã định danh nguồn hiện tại"
"Keep existing source identifier": "Giữ mã định danh nguồn hiện tại",
"Toggle Toolbar": "Bật/tắt thanh công cụ"
}
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "阅读进度已同步到 Hardcover",
"Hardcover progress sync failed: {{error}}": "Hardcover 进度同步失败:{{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "保留现有来源标识符"
"Keep existing source identifier": "保留现有来源标识符",
"Toggle Toolbar": "切换工具栏"
}
@@ -1169,5 +1169,6 @@
"Reading progress synced to Hardcover": "閱讀進度已同步到 Hardcover",
"Hardcover progress sync failed: {{error}}": "Hardcover 進度同步失敗:{{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "保留現有來源識別碼"
"Keep existing source identifier": "保留現有來源識別碼",
"Toggle Toolbar": "切換工具列"
}
+12
View File
@@ -1,5 +1,17 @@
{
"releases": {
"0.10.4": {
"date": "2026-04-06",
"notes": [
"Annotations: You can now name your highlight colors, and your custom names sync across devices",
"Annotations: Fixed highlights that stayed on the page after being deleted from the sidebar",
"Speed Reading: Added an option to split long words, a faster countdown",
"Text-to-Speech: Fixed double playback when tapping the play button in quick succession",
"Accessibility: Dropdown menus now open reliably when activated with the keyboard",
"Reading: Background textures now display correctly when you override the theme background color",
"Translation: Temporarily disabled the Yandex provider while its service is unavailable"
]
},
"0.10.2": {
"date": "2026-04-05",
"notes": [
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import Dropdown from '@/components/Dropdown';
import { DropdownProvider } from '@/context/DropdownContext';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
afterEach(() => cleanup());
// Simulates the browser behavior where pressing Enter/Space on a focused
// <button> fires both a keydown event and a click event. JSDOM does not
// dispatch the click automatically, so we do it here.
const pressActivationKey = (el: HTMLElement, key: 'Enter' | ' ') => {
fireEvent.keyDown(el, { key });
fireEvent.click(el);
};
const renderDropdown = () =>
render(
<DropdownProvider>
<Dropdown label='Test Menu' toggleButton={<span>Toggle</span>} showTooltip={false}>
<div>
<button type='button'>Menu Item</button>
</div>
</Dropdown>
</DropdownProvider>,
);
describe('Dropdown keyboard activation', () => {
it('opens when Enter is pressed on the toggle button', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
toggle.focus();
pressActivationKey(toggle, 'Enter');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
});
it('opens when Space is pressed on the toggle button', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
toggle.focus();
pressActivationKey(toggle, ' ');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
});
it('closes when Enter is pressed again on an open dropdown', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
toggle.focus();
pressActivationKey(toggle, 'Enter');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
pressActivationKey(toggle, 'Enter');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
});
it('stops propagation on Enter to prevent global shortcuts from firing', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
const onWindowKeyDown = vi.fn();
window.addEventListener('keydown', onWindowKeyDown);
try {
toggle.focus();
fireEvent.keyDown(toggle, { key: 'Enter' });
expect(onWindowKeyDown).not.toHaveBeenCalled();
} finally {
window.removeEventListener('keydown', onWindowKeyDown);
}
});
it('stops propagation on Space to prevent global shortcuts from firing', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
const onWindowKeyDown = vi.fn();
window.addEventListener('keydown', onWindowKeyDown);
try {
toggle.focus();
fireEvent.keyDown(toggle, { key: ' ' });
expect(onWindowKeyDown).not.toHaveBeenCalled();
} finally {
window.removeEventListener('keydown', onWindowKeyDown);
}
});
});
@@ -0,0 +1,243 @@
import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// --- Dependency mocks (must be set up before importing the hook) ---
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({
appService: { isIOSApp: false, isMobile: false },
envConfig: {},
}),
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ user: null }),
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({ isDarkMode: false }),
}));
const mockView = {
book: { primaryLanguage: 'en', sections: [{ id: 0 }] },
renderer: {
getContents: () => [{ index: 0, doc: document as unknown as Document }],
scrollToAnchor: vi.fn(),
primaryIndex: 0,
scrolled: false,
nextSection: vi.fn(),
start: 0,
end: 0,
sideProp: 'height',
goTo: vi.fn(),
},
resolveCFI: vi.fn().mockReturnValue({ index: 0, anchor: () => new Range() }),
getCFI: vi.fn().mockReturnValue('cfi'),
deselect: vi.fn(),
resolveNavigation: vi.fn(),
history: { back: vi.fn(), forward: vi.fn() },
tts: {
from: vi.fn().mockReturnValue('<speak>hello</speak>'),
start: vi.fn().mockReturnValue('<speak>hello</speak>'),
getLastRange: vi.fn().mockReturnValue(null),
highlight: vi.fn(),
},
};
const mockProgress = {
location: { start: { cfi: '' }, end: { cfi: '' } },
index: 0,
range: null,
sectionLabel: '',
};
const mockViewSettings = {
ttsLocation: null as string | null,
ttsRate: 1,
ttsHighlightOptions: { style: 'highlight', color: '#ffff00' },
isEink: false,
showTTSBar: false,
ttsMediaMetadata: 'sentence',
translationEnabled: false,
ttsReadAloudText: 'source',
};
const mockBookData = {
isFixedLayout: false,
book: { primaryLanguage: 'en', title: 'T', author: 'A', coverImageUrl: '' },
};
vi.mock('@/store/readerStore', () => {
const store = {
hoveredBookKey: null,
getView: () => mockView,
getProgress: () => mockProgress,
getViewSettings: () => mockViewSettings,
setViewSettings: vi.fn(),
setTTSEnabled: vi.fn(),
};
const useReaderStore = () => store;
useReaderStore.getState = () => store;
return { useReaderStore };
});
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({
getBookData: () => mockBookData,
}),
}));
vi.mock('@/store/proofreadStore', () => ({
useProofreadStore: () => ({
getMergedRules: () => [],
}),
}));
vi.mock('@/services/transformers/proofread', () => ({
proofreadTransformer: {
transform: vi.fn(async (ctx: { content: string }) => ctx.content),
},
}));
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
// Track TTSController instantiations — this is the assertion target.
const ttsControllerInstances: unknown[] = [];
// Gate init() calls so that handleTTSSpeak stays suspended inside an `await`.
// This is the exact point where a second concurrent invocation would otherwise
// race ahead and construct a second TTSController. The test releases all
// pending resolvers once both dispatches have had a chance to interleave.
const pendingInitResolvers: Array<() => void> = [];
vi.mock('@/services/tts', () => ({
TTSController: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, {
init: vi.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
pendingInitResolvers.push(() => resolve());
}),
),
initViewTTS: vi.fn().mockResolvedValue(undefined),
updateHighlightOptions: vi.fn(),
setLang: vi.fn(),
setRate: vi.fn(),
setVoice: vi.fn(),
setTargetLang: vi.fn(),
speak: vi.fn(),
pause: vi.fn().mockResolvedValue(undefined),
resume: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
forward: vi.fn().mockResolvedValue(undefined),
backward: vi.fn().mockResolvedValue(undefined),
getVoices: vi.fn().mockResolvedValue([]),
getVoiceId: vi.fn().mockReturnValue(''),
state: 'idle',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
ttsControllerInstances.push(this);
}),
}));
vi.mock('@/libs/mediaSession', () => ({
TauriMediaSession: class {},
}));
vi.mock('@/utils/ssml', () => ({
genSSMLRaw: vi.fn((s: string) => `<speak>${s}</speak>`),
parseSSMLLang: vi.fn(() => 'en'),
}));
vi.mock('@/utils/throttle', () => ({
throttle: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock('@/utils/cfi', () => ({
isCfiInLocation: () => false,
}));
vi.mock('@/utils/misc', () => ({
getLocale: () => 'en',
}));
vi.mock('@/utils/ttsMetadata', () => ({
buildTTSMediaMetadata: () => ({
shouldUpdate: false,
title: '',
artist: '',
album: '',
}),
}));
vi.mock('@/utils/bridge', () => ({
invokeUseBackgroundAudio: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/utils/ttsTime', () => ({
estimateTTSTime: () => ({
chapterRemainingSec: 0,
bookRemainingSec: 0,
finishAtTimestamp: 0,
}),
}));
vi.mock('@/app/reader/hooks/useTTSMediaSession', () => ({
useTTSMediaSession: () => ({
mediaSessionRef: { current: null },
unblockAudio: vi.fn(),
releaseUnblockAudio: vi.fn(),
initMediaSession: vi.fn().mockResolvedValue(undefined),
deinitMediaSession: vi.fn().mockResolvedValue(undefined),
}),
}));
// Imports must come AFTER vi.mock calls so they pick up the mocked modules.
import { useTTSControl } from '@/app/reader/hooks/useTTSControl';
import { eventDispatcher } from '@/utils/event';
const Harness = () => {
useTTSControl({ bookKey: 'book-1' });
return null;
};
describe('useTTSControl concurrent tts-speak events', () => {
beforeEach(() => {
ttsControllerInstances.length = 0;
pendingInitResolvers.length = 0;
});
afterEach(() => {
cleanup();
});
it('creates only one TTSController when two tts-speak events fire back-to-back', async () => {
render(<Harness />);
await act(async () => {
// Kick off both dispatches without awaiting — this models rapid clicks
// where the second click arrives while the first is still inside its
// initial awaits (initMediaSession / backgroundAudio / init()).
const p1 = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
const p2 = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
// Let both invocations drain microtasks and reach their gated await.
// Without the single-flight guard in handleTTSSpeak, both invocations
// would construct a TTSController here and both would be queued in
// pendingInitResolvers.
for (let i = 0; i < 10; i++) await Promise.resolve();
// The assertion that matters: exactly one controller was constructed.
expect(ttsControllerInstances.length).toBe(1);
// Release any pending init() promises so the dispatch chain can unwind
// cleanly (otherwise the act() would never settle).
while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
await Promise.all([p1, p2]);
});
});
});
@@ -6,6 +6,7 @@ import {
getSegmenterLocale,
segmentCJKText,
splitTextIntoWords,
getHyphenParts,
} from '@/services/rsvp/utils';
describe('rsvp/utils', () => {
@@ -240,4 +241,38 @@ describe('rsvp/utils', () => {
expect(words.length).toBeGreaterThan(0);
});
});
describe('getHyphenParts', () => {
test('splits a hyphenated word into two parts with trailing hyphen on first', () => {
expect(getHyphenParts('well-known')).toEqual(['well-', 'known']);
});
test('splits multiple letter-hyphens keeping trailing hyphen on each non-last part', () => {
expect(getHyphenParts('one-two-three')).toEqual(['one-', 'two-', 'three']);
});
test('returns word unchanged when no letter-hyphen-letter pattern', () => {
expect(getHyphenParts('hello')).toEqual(['hello']);
});
test('returns double-hyphen unchanged (em-dash style)', () => {
expect(getHyphenParts('--')).toEqual(['--']);
});
test('returns lone hyphen unchanged', () => {
expect(getHyphenParts('-')).toEqual(['-']);
});
test('returns consecutive-hyphen word unchanged', () => {
expect(getHyphenParts('foo--bar')).toEqual(['foo--bar']);
});
test('returns leading-hyphen word unchanged', () => {
expect(getHyphenParts('-word')).toEqual(['-word']);
});
test('returns trailing-hyphen word unchanged', () => {
expect(getHyphenParts('word-')).toEqual(['word-']);
});
});
});
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { migrateHighlightColorPrefs } from '@/services/settingsService';
import type { ReadSettings } from '@/types/settings';
const baseRead = (): ReadSettings =>
({
customHighlightColors: {},
userHighlightColors: [],
defaultHighlightLabels: {},
}) as unknown as ReadSettings;
describe('migrateHighlightColorPrefs', () => {
it('lifts legacy string[] userHighlightColors into {hex} entries', () => {
const read = baseRead();
(read as unknown as { userHighlightColors: unknown }).userHighlightColors = [
'#AABBCC',
'#112233',
];
migrateHighlightColorPrefs(read);
expect(read.userHighlightColors).toEqual([{ hex: '#aabbcc' }, { hex: '#112233' }]);
});
it('preserves already-migrated entries', () => {
const read = baseRead();
read.userHighlightColors = [{ hex: '#abcdef', label: 'Keep me' }, { hex: '#123456' }];
migrateHighlightColorPrefs(read);
expect(read.userHighlightColors).toEqual([
{ hex: '#abcdef', label: 'Keep me' },
{ hex: '#123456' },
]);
});
it('filters out entries with malformed hex values', () => {
const read = baseRead();
(read as unknown as { userHighlightColors: unknown }).userHighlightColors = [
'#abcdef',
'not-a-hex',
'',
null,
];
migrateHighlightColorPrefs(read);
expect(read.userHighlightColors).toEqual([{ hex: '#abcdef' }]);
});
it('folds draft highlightColorLabels hex entries into matching user colors', () => {
const read = baseRead();
(read as unknown as { userHighlightColors: unknown }).userHighlightColors = ['#aabbcc'];
(read as unknown as { highlightColorLabels: unknown }).highlightColorLabels = {
'#aabbcc': 'Romance',
};
migrateHighlightColorPrefs(read);
expect(read.userHighlightColors).toEqual([{ hex: '#aabbcc', label: 'Romance' }]);
expect(
(read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels,
).toBeUndefined();
});
it('folds draft highlightColorLabels named entries into defaultHighlightLabels', () => {
const read = baseRead();
(read as unknown as { highlightColorLabels: unknown }).highlightColorLabels = {
yellow: 'Foreshadowing',
red: 'Questions',
noise: 'Ignored',
};
migrateHighlightColorPrefs(read);
expect(read.defaultHighlightLabels).toEqual({
yellow: 'Foreshadowing',
red: 'Questions',
});
expect(
(read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels,
).toBeUndefined();
});
it('does not overwrite an already-set defaultHighlightLabel', () => {
const read = baseRead();
read.defaultHighlightLabels = { yellow: 'Existing' };
(read as unknown as { highlightColorLabels: unknown }).highlightColorLabels = {
yellow: 'Legacy',
};
migrateHighlightColorPrefs(read);
expect(read.defaultHighlightLabels).toEqual({ yellow: 'Existing' });
});
});
@@ -42,6 +42,18 @@ vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
// Stub Supabase so importing the full providers registry (which pulls in
// deepl.ts → @/utils/access → @/utils/supabase) doesn't instantiate a real
// GoTrueClient on every `vi.resetModules()` round. Without this, each test
// that dynamically imports the registry logs a "Multiple GoTrueClient
// instances" warning from the real Supabase client.
vi.mock('@/utils/supabase', () => ({
supabase: {
auth: { getSession: vi.fn().mockResolvedValue({ data: { session: null } }) },
from: vi.fn(),
},
}));
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
@@ -316,3 +328,75 @@ describe('azureProvider', () => {
expect(azureProvider.label).toBe('Azure Translator');
});
});
// ---------------------------------------------------------------------------
// Provider registry — disabled providers stay visible but unselectable
// ---------------------------------------------------------------------------
describe('provider registry disabled handling', () => {
// No `vi.resetModules()` here — these tests only inspect static provider
// metadata, so resolving the registry once is enough. Resetting between
// each test would re-evaluate the full import chain and churn module
// state for no benefit.
it('keeps yandex in getTranslators() so the UI can render it', async () => {
const { getTranslators } = await import('@/services/translators/providers');
const names = getTranslators().map((t) => t.name);
expect(names).toContain('yandex');
});
it('exposes yandex as disabled so callers can grey it out', async () => {
const { getTranslator } = await import('@/services/translators/providers');
const yandex = getTranslator('yandex');
expect(yandex).toBeDefined();
expect(yandex!.disabled).toBe(true);
});
it('isTranslatorAvailable returns false for disabled providers', async () => {
const { getTranslator, isTranslatorAvailable } =
await import('@/services/translators/providers');
const yandex = getTranslator('yandex')!;
expect(isTranslatorAvailable(yandex, true)).toBe(false);
expect(isTranslatorAvailable(yandex, false)).toBe(false);
});
it('isTranslatorAvailable returns false for authRequired without token', async () => {
const { isTranslatorAvailable } = await import('@/services/translators/providers');
const authed = { name: 'x', label: 'X', authRequired: true, translate: async () => [] };
expect(isTranslatorAvailable(authed, false)).toBe(false);
expect(isTranslatorAvailable(authed, true)).toBe(true);
});
it('isTranslatorAvailable returns false when quota is exceeded', async () => {
const { isTranslatorAvailable } = await import('@/services/translators/providers');
const exhausted = { name: 'x', label: 'X', quotaExceeded: true, translate: async () => [] };
expect(isTranslatorAvailable(exhausted, true)).toBe(false);
});
it('getTranslatorDisplayLabel appends a Unavailable suffix for disabled providers', async () => {
const { getTranslator, getTranslatorDisplayLabel } =
await import('@/services/translators/providers');
const yandex = getTranslator('yandex')!;
const label = getTranslatorDisplayLabel(yandex, true, (s) => s);
expect(label).toBe('Yandex Translate (Unavailable)');
});
it('getTranslatorDisplayLabel prefers the disabled suffix over other statuses', async () => {
const { getTranslatorDisplayLabel } = await import('@/services/translators/providers');
const both = {
name: 'x',
label: 'X',
disabled: true,
authRequired: true,
quotaExceeded: true,
translate: async () => [],
};
expect(getTranslatorDisplayLabel(both, false, (s) => s)).toBe('X (Unavailable)');
});
it('getTranslatorDisplayLabel returns the plain label for healthy providers', async () => {
const { getTranslator, getTranslatorDisplayLabel } =
await import('@/services/translators/providers');
const google = getTranslator('google')!;
expect(getTranslatorDisplayLabel(google, true, (s) => s)).toBe('Google Translate');
});
});
@@ -1,6 +1,14 @@
import { describe, it, expect, vi } from 'vitest';
import { getExternalDragHandle, toParentViewportPoint } from '@/app/reader/utils/annotatorUtil';
import {
getExternalDragHandle,
getHighlightColorLabel,
removeBookNoteOverlays,
toParentViewportPoint,
} from '@/app/reader/utils/annotatorUtil';
import { Point } from '@/utils/sel';
import { BookNote, UserHighlightColor } from '@/types/book';
import { SystemSettings } from '@/types/settings';
import { FoliateView, NOTE_PREFIX } from '@/types/view';
describe('getExternalDragHandle', () => {
const currentStart: Point = { x: 100, y: 200 };
@@ -99,3 +107,121 @@ describe('toParentViewportPoint', () => {
expect(result).toEqual({ x: 300, y: 100 });
});
});
describe('getHighlightColorLabel', () => {
const makeSettings = (
userHighlightColors: UserHighlightColor[],
defaultHighlightLabels: Partial<Record<string, string>> = {},
): SystemSettings =>
({
globalReadSettings: {
userHighlightColors,
defaultHighlightLabels,
},
}) as SystemSettings;
it('returns the user-set label for a built-in color', () => {
const settings = makeSettings([], { yellow: 'Foreshadowing' });
expect(getHighlightColorLabel(settings, 'yellow')).toBe('Foreshadowing');
});
it('returns the user-set label for a hex color, matching case-insensitively', () => {
const settings = makeSettings([{ hex: '#aabbcc', label: 'Romance' }]);
expect(getHighlightColorLabel(settings, '#AABBCC')).toBe('Romance');
});
it('returns undefined when the user has not set a label', () => {
const settings = makeSettings([]);
expect(getHighlightColorLabel(settings, 'green')).toBeUndefined();
expect(getHighlightColorLabel(settings, '#123456')).toBeUndefined();
});
it('ignores labels that collapse to whitespace', () => {
const settings = makeSettings([{ hex: '#abcdef', label: ' ' }], { red: ' ' });
expect(getHighlightColorLabel(settings, '#abcdef')).toBeUndefined();
expect(getHighlightColorLabel(settings, 'red')).toBeUndefined();
});
});
describe('removeBookNoteOverlays', () => {
const makeView = () => {
const addAnnotation = vi.fn();
const view = { addAnnotation } as unknown as FoliateView;
return { view, addAnnotation };
};
const baseNote = (overrides: Partial<BookNote> = {}): BookNote => ({
id: 'id-1',
type: 'annotation',
cfi: 'epubcfi(/6/4!/4/2)',
note: '',
createdAt: 1,
updatedAt: 1,
...overrides,
});
it('removes only the highlight overlay for a highlight-only annotation', () => {
const { view, addAnnotation } = makeView();
const note = baseNote({ style: 'highlight', color: 'yellow' });
removeBookNoteOverlays(view, note);
expect(addAnnotation).toHaveBeenCalledTimes(1);
expect(addAnnotation).toHaveBeenCalledWith(expect.objectContaining({ value: note.cfi }), true);
const passed = addAnnotation.mock.calls[0]![0] as BookNote & { value: string };
expect(passed.value.startsWith(NOTE_PREFIX)).toBe(false);
});
it('removes only the note overlay for a note-only annotation', () => {
const { view, addAnnotation } = makeView();
const note = baseNote({ note: 'my comment' });
removeBookNoteOverlays(view, note);
expect(addAnnotation).toHaveBeenCalledTimes(1);
expect(addAnnotation).toHaveBeenCalledWith(
expect.objectContaining({ value: `${NOTE_PREFIX}${note.cfi}` }),
true,
);
});
it('removes both overlays when the annotation has a highlight and a note', () => {
const { view, addAnnotation } = makeView();
const note = baseNote({ style: 'underline', color: 'red', note: 'my comment' });
removeBookNoteOverlays(view, note);
expect(addAnnotation).toHaveBeenCalledTimes(2);
const values = addAnnotation.mock.calls.map(
(call) => (call[0] as BookNote & { value: string }).value,
);
expect(values).toContain(note.cfi);
expect(values).toContain(`${NOTE_PREFIX}${note.cfi}`);
for (const call of addAnnotation.mock.calls) {
expect(call[1]).toBe(true);
}
});
it('does nothing for a bookmark (no highlight, no note text)', () => {
const { view, addAnnotation } = makeView();
const bookmark = baseNote({ type: 'bookmark' });
removeBookNoteOverlays(view, bookmark);
expect(addAnnotation).not.toHaveBeenCalled();
});
it('treats whitespace-only note text as empty and skips the note overlay', () => {
const { view, addAnnotation } = makeView();
const note = baseNote({ style: 'highlight', note: ' \n ' });
removeBookNoteOverlays(view, note);
expect(addAnnotation).toHaveBeenCalledTimes(1);
expect(addAnnotation).toHaveBeenCalledWith(expect.objectContaining({ value: note.cfi }), true);
});
it('is a no-op when view is null', () => {
expect(() => removeBookNoteOverlays(null, baseNote({ style: 'highlight' }))).not.toThrow();
});
});
@@ -157,7 +157,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keepCoverSpread]);
const lastSyncTime = Math.max(config?.lastSyncedAtConfig || 0, config?.lastSyncedAtNotes || 0);
const lastSyncTime = Math.max(
config?.lastSyncedAtConfig || 0,
config?.lastSyncedAtNotes || 0,
config?.lastPushedAtConfig || 0,
config?.lastPushedAtNotes || 0,
);
return (
<Menu
@@ -1,30 +1,29 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { FaCheckCircle } from 'react-icons/fa';
import { HighlightColor, HighlightStyle } from '@/types/book';
import { DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useDragScroll } from '@/hooks/useDragScroll';
import { saveSysSettings } from '@/helpers/settings';
import { LONG_HOLD_THRESHOLD } from '@/services/constants';
import { getHighlightColorLabel } from '../../utils/annotatorUtil';
import { stubTranslation as _ } from '@/utils/misc';
// Register strings for the i18next extractor. These keys are translated by the
// component via `useTranslation` below.
const styles = [_('highlight'), _('underline'), _('squiggly')] as HighlightStyle[];
const defaultColors = [
_('red'),
_('violet'),
_('blue'),
_('green'),
_('yellow'),
] as HighlightColor[];
void [_('red'), _('yellow'), _('green'), _('blue'), _('violet')];
const getColorHex = (
customColors: Record<HighlightColor, string>,
color: HighlightColor,
): string => {
if (color.startsWith('#')) return color;
return customColors[color as HighlightColor] ?? color;
return customColors[color] ?? color;
};
interface HighlightOptionsProps {
@@ -39,6 +38,7 @@ interface HighlightOptionsProps {
const OPTIONS_HEIGHT_PIX = 28;
const OPTIONS_PADDING_PIX = 16;
const LABEL_PREVIEW_MS = 2200;
const HighlightOptions: React.FC<HighlightOptionsProps> = ({
isVertical,
@@ -61,13 +61,96 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
const customColors = globalReadSettings.customHighlightColors;
const userColors = globalReadSettings.userHighlightColors ?? [];
const allColors: HighlightColor[] = [
...DEFAULT_HIGHLIGHT_COLORS,
...userColors.map((c) => c.hex),
];
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(_selectedStyle);
const [selectedColor, setSelectedColor] = useState<HighlightColor>(_selectedColor);
const [previewColor, setPreviewColor] = useState<HighlightColor | null>(null);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressTapRef = useRef(false);
const colorStripRef = useRef<HTMLDivElement | null>(null);
const size16 = useResponsiveSize(16);
const size28 = useResponsiveSize(28);
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
const {
isDragging: isDraggingColorStrip,
pointerHandlers: stripPointerHandlers,
shouldSuppressClick: shouldSuppressStripClick,
} = useDragScroll(colorStripRef, { enabled: !isVertical });
const clearLongPressTimer = () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
};
const clearPreviewTimer = () => {
if (previewTimerRef.current) {
clearTimeout(previewTimerRef.current);
previewTimerRef.current = null;
}
};
/**
* Translate a color's label. Order of preference:
* 1. user-set label (custom string, shown verbatim)
* 2. translated default name (only for the 5 predefined colors)
* 3. the color value itself (hex fallback)
*/
const resolveHighlightLabel = (color: HighlightColor): string => {
const userLabel = getHighlightColorLabel(settings, color);
if (userLabel) return userLabel;
if (!color.startsWith('#')) return _(color);
return color;
};
const showHighlightLabelPreview = (color: HighlightColor) => {
setPreviewColor(color);
clearPreviewTimer();
previewTimerRef.current = setTimeout(() => setPreviewColor(null), LABEL_PREVIEW_MS);
};
const handleColorPointerDown = (
event: React.PointerEvent<HTMLButtonElement>,
color: HighlightColor,
) => {
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') {
return;
}
clearLongPressTimer();
suppressTapRef.current = false;
longPressTimerRef.current = setTimeout(() => {
suppressTapRef.current = true;
showHighlightLabelPreview(color);
}, LONG_HOLD_THRESHOLD);
};
const handleColorPointerEnd = () => {
clearLongPressTimer();
};
const handleColorClick = (color: HighlightColor) => {
if (shouldSuppressStripClick()) return;
if (suppressTapRef.current) {
suppressTapRef.current = false;
return;
}
handleSelectColor(color);
};
useEffect(() => {
return () => {
clearLongPressTimer();
clearPreviewTimer();
};
}, []);
const handleSelectStyle = (style: HighlightStyle) => {
const newGlobalReadSettings = { ...globalReadSettings, highlightStyle: style };
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
@@ -90,7 +173,7 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
return (
<div
className={clsx(
'highlight-options absolute flex items-center justify-between gap-4',
'highlight-options absolute flex items-center gap-4',
isVertical ? 'flex-col' : 'flex-row',
)}
style={{
@@ -162,42 +245,64 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
</div>
<div
ref={colorStripRef}
{...stripPointerHandlers}
className={clsx(
'not-eink:bg-gray-700 eink-bordered flex items-center gap-2 rounded-3xl',
isVertical ? 'flex-col overflow-y-auto py-2' : 'flex-row overflow-x-auto px-2',
isVertical
? 'flex-col overflow-y-auto py-2'
: 'min-w-0 flex-1 flex-row overflow-x-auto px-2',
!isVertical && 'cursor-grab',
!isVertical && isDraggingColorStrip && 'cursor-grabbing',
)}
style={{
...(isVertical ? { width: size28 } : { height: size28 }),
scrollbarWidth: 'none',
msOverflowStyle: 'none',
WebkitUserSelect: isDraggingColorStrip ? 'none' : undefined,
userSelect: isDraggingColorStrip ? 'none' : undefined,
}}
>
{defaultColors
.concat(userColors)
{allColors
.filter((c) => (isBwEink ? selectedColor === c : true))
.map((color) => (
<div key={color} className='flex items-center justify-center'>
<button
key={color}
aria-label={_('Select {{color}} color', { color: _(color) })}
onClick={() => handleSelectColor(color)}
style={{
width: size16,
height: size16,
backgroundColor:
selectedColor !== color ? customColors[color] || color : 'transparent',
}}
className='rounded-full p-0'
>
{selectedColor === color && (
<FaCheckCircle
size={size16}
style={{ fill: isBwEink ? einkFgColor : customColors[color] || color }}
/>
.map((color) => {
const label = resolveHighlightLabel(color);
const swatchColor = customColors[color] || color;
return (
<div key={color} className='relative flex items-center justify-center'>
{previewColor === color && (
<div
className='eink-bordered pointer-events-none absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-800 px-2 py-0.5 text-[10px] text-white'
style={{ maxWidth: 120 }}
>
{label}
</div>
)}
</button>
</div>
))}
<button
aria-label={_('Select {{color}} color', { color: label })}
title={label}
onClick={() => handleColorClick(color)}
onPointerDown={(event) => handleColorPointerDown(event, color)}
onPointerUp={handleColorPointerEnd}
onPointerLeave={handleColorPointerEnd}
onPointerCancel={handleColorPointerEnd}
style={{
width: size16,
height: size16,
backgroundColor: selectedColor !== color ? swatchColor : 'transparent',
}}
className='rounded-full p-0'
>
{selectedColor === color && (
<FaCheckCircle
size={size16}
style={{ fill: isBwEink ? einkFgColor : swatchColor }}
/>
)}
</button>
</div>
);
})}
</div>
</div>
);
@@ -6,7 +6,12 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATOR_LANGS } from '@/services/constants';
import { UseTranslatorOptions, getTranslators } from '@/services/translators';
import {
UseTranslatorOptions,
getTranslatorDisplayLabel,
getTranslators,
isTranslatorAvailable,
} from '@/services/translators';
import Select from '@/components/Select';
const notSupportedLangs = [''];
@@ -31,6 +36,7 @@ interface TranslatorPopupProps {
interface TranslatorType {
name: string;
label: string;
disabled: boolean;
}
const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
@@ -71,9 +77,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const requestedProvider = event.target.value;
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token));
const selectedTranslator =
availableTranslators.find((t) => t.name === requestedProvider) || availableTranslators[0]!;
if (selectedTranslator) {
@@ -84,15 +88,11 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
};
useEffect(() => {
const availableProviders = translators.map((t) => {
let label = t.label;
if (t.authRequired && !token) {
label = `${label} (${_('Login Required')})`;
} else if (t.quotaExceeded) {
label = `${label} (${_('Quota Exceeded')})`;
}
return { name: t.name, label };
});
const availableProviders = translators.map((t) => ({
name: t.name,
label: getTranslatorDisplayLabel(t, !!token, _),
disabled: !!t.disabled,
}));
setProviders(availableProviders);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translators]);
@@ -214,7 +214,11 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100'
value={provider}
onChange={handleProviderChange}
options={providers.map(({ name: value, label }) => ({ value, label }))}
options={providers.map(({ name: value, label, disabled }) => ({
value,
label,
disabled,
}))}
/>
</div>
</Popup>
@@ -21,9 +21,14 @@ const SCREEN_BRIGHTNESS_LIMITS = {
interface ColorPanelProps {
actionTab: string;
bottomOffset: string;
forceMobileLayout: boolean;
}
export const ColorPanel: React.FC<ColorPanelProps> = ({ actionTab, bottomOffset }) => {
export const ColorPanel: React.FC<ColorPanelProps> = ({
actionTab,
bottomOffset,
forceMobileLayout,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
@@ -72,7 +77,8 @@ export const ColorPanel: React.FC<ColorPanelProps> = ({ actionTab, bottomOffset
};
const classes = clsx(
'footerbar-color-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
'footerbar-color-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all',
!forceMobileLayout && 'sm:hidden',
actionTab === 'color'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef } from 'react';
import { FaHeadphones } from 'react-icons/fa6';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
@@ -17,7 +18,7 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
progressValid,
progressFraction,
navigationHandlers,
isMobileLayout,
forceMobileLayout,
onSpeakText,
}) => {
const _ = useTranslation();
@@ -68,11 +69,10 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
return (
<div
className={
isMobileLayout
? 'hidden'
: 'hidden h-8 w-full items-center gap-x-4 overflow-x-auto px-4 sm:flex'
}
className={clsx(
'hidden h-8 w-full items-center gap-x-4 overflow-x-auto px-4',
!forceMobileLayout && 'sm:flex',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px',
scrollbarWidth: 'none',
@@ -32,6 +32,7 @@ interface FontLayoutPanelProps {
actionTab: string;
bottomOffset: string;
marginIconSize: number;
forceMobileLayout: boolean;
}
export const FontLayoutPanel: React.FC<FontLayoutPanelProps> = ({
@@ -39,6 +40,7 @@ export const FontLayoutPanel: React.FC<FontLayoutPanelProps> = ({
actionTab,
bottomOffset,
marginIconSize,
forceMobileLayout,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
@@ -91,7 +93,8 @@ export const FontLayoutPanel: React.FC<FontLayoutPanelProps> = ({
}, []);
const classes = clsx(
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all',
!forceMobileLayout && 'sm:hidden',
actionTab === 'font'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
@@ -11,10 +11,10 @@ import { eventDispatcher } from '@/utils/event';
import { FooterBarProps, NavigationHandlers, FooterBarChildProps } from './types';
import { debounce } from '@/utils/debounce';
import { viewPagination } from '../../hooks/usePagination';
import { RSVPControl } from '../rsvp';
import MobileFooterBar from './MobileFooterBar';
import DesktopFooterBar from './DesktopFooterBar';
import TTSControl from '../tts/TTSControl';
import { RSVPControl } from '../rsvp';
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
@@ -194,9 +194,12 @@ const FooterBar: React.FC<FooterBarProps> = ({
const footerBarRef = useRef<HTMLDivElement>(null);
useSpatialNavigation(footerBarRef, isVisible);
const isPortrait = window.innerWidth <= window.innerHeight;
const isMobile = appService?.isMobile || window.innerWidth < 640;
const isMobileLayout = isMobile || (!!appService?.isAndroidApp && isPortrait);
// Force the mobile footer bar on mobile tablets/foldables in portrait mode
// where the viewport width exceeds the `sm:` (640px) breakpoint. Phones
// (innerWidth < 640) are intentionally excluded so their styling and panel
// slide-down animation remain exactly as before — see #3742 / #3746.
const forceMobileLayout =
!!appService?.isMobile && window.innerWidth >= 640 && window.innerWidth <= window.innerHeight;
const commonProps: FooterBarChildProps = {
bookKey,
@@ -205,7 +208,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
progressValid,
progressFraction,
navigationHandlers,
isMobileLayout,
forceMobileLayout,
onSetActionTab: handleSetActionTab,
onSpeakText: handleSpeakText,
};
@@ -216,23 +219,24 @@ const FooterBar: React.FC<FooterBarProps> = ({
const containerClasses = clsx(
'footer-bar shadow-xs bottom-0 left-0 z-10 flex w-full flex-col',
isMobileLayout ? '' : 'sm:h-[52px]',
isMobileLayout ? '' : 'sm:bg-base-100 sm:border-none',
!forceMobileLayout && 'sm:h-[52px] sm:bg-base-100 sm:border-none',
'border-base-300/50 border-t',
'transition-[opacity,transform] duration-300',
isMobileLayout ? 'fixed' : window.innerWidth < 640 ? 'fixed' : 'absolute',
forceMobileLayout || window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
!isMobileLayout &&
!forceMobileLayout &&
(needHorizontalScroll ? 'sm:!bottom-3 sm:!h-10 sm:justify-end' : 'sm:justify-center'),
isVisible
? 'pointer-events-auto translate-y-0 opacity-100'
: isMobileLayout
: forceMobileLayout
? 'pointer-events-none translate-y-full opacity-0'
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
);
const isMobile = appService?.isMobile || window.innerWidth < 640;
return (
<>
{/* Hover trigger area */}
@@ -1,4 +1,3 @@
import clsx from 'clsx';
import React from 'react';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { FooterBarChildProps } from './types';
@@ -14,17 +13,21 @@ const MobileFooterBar: React.FC<FooterBarChildProps> = ({
progressValid,
progressFraction,
navigationHandlers,
isMobileLayout,
forceMobileLayout,
onSetActionTab,
}) => {
const isMobile = isMobileLayout || window.innerWidth < 640 || window.innerHeight < 640;
const isMobile = forceMobileLayout || window.innerWidth < 640 || window.innerHeight < 640;
const sliderHeight = useResponsiveSize(28);
const marginIconSize = useResponsiveSize(20);
const bottomOffset = isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px';
return (
<div className={clsx(isMobileLayout && 'force-mobile-layout')}>
<ColorPanel actionTab={actionTab} bottomOffset={bottomOffset} />
<>
<ColorPanel
actionTab={actionTab}
bottomOffset={bottomOffset}
forceMobileLayout={forceMobileLayout}
/>
<NavigationPanel
bookKey={bookKey}
actionTab={actionTab}
@@ -33,21 +36,23 @@ const MobileFooterBar: React.FC<FooterBarChildProps> = ({
navigationHandlers={navigationHandlers}
bottomOffset={bottomOffset}
sliderHeight={sliderHeight}
forceMobileLayout={forceMobileLayout}
/>
<FontLayoutPanel
bookKey={bookKey}
actionTab={actionTab}
bottomOffset={bottomOffset}
marginIconSize={marginIconSize}
forceMobileLayout={forceMobileLayout}
/>
<NavigationBar
bookKey={bookKey}
actionTab={actionTab}
gridInsets={gridInsets}
isMobileLayout={isMobileLayout}
forceMobileLayout={forceMobileLayout}
onSetActionTab={onSetActionTab!}
/>
</div>
</>
);
};
@@ -16,7 +16,7 @@ interface NavigationBarProps {
bookKey: string;
actionTab: string;
gridInsets: Insets;
isMobileLayout: boolean;
forceMobileLayout: boolean;
onSetActionTab: (tab: string) => void;
}
@@ -24,10 +24,10 @@ export const NavigationBar: React.FC<NavigationBarProps> = ({
bookKey,
actionTab,
gridInsets,
isMobileLayout,
forceMobileLayout,
onSetActionTab,
}) => {
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
const isMobile = forceMobileLayout || window.innerWidth < 640 || window.innerHeight < 640;
const _ = useTranslation();
const { appService } = useEnv();
const { getViewState } = useReaderStore();
@@ -40,7 +40,7 @@ export const NavigationBar: React.FC<NavigationBarProps> = ({
<div
className={clsx(
'bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4',
!isMobileLayout && 'sm:hidden',
!forceMobileLayout && 'sm:hidden',
)}
style={{
paddingBottom: appService?.isAndroidApp
@@ -21,6 +21,7 @@ interface NavigationPanelProps {
viewSettings?: ViewSettings;
bottomOffset: string;
sliderHeight: number;
forceMobileLayout: boolean;
}
export const NavigationPanel: React.FC<NavigationPanelProps> = ({
@@ -32,6 +33,7 @@ export const NavigationPanel: React.FC<NavigationPanelProps> = ({
viewSettings,
bottomOffset,
sliderHeight,
forceMobileLayout,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -58,7 +60,8 @@ export const NavigationPanel: React.FC<NavigationPanelProps> = ({
);
const classes = clsx(
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all',
!forceMobileLayout && 'sm:hidden',
actionTab === 'progress'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
@@ -27,7 +27,7 @@ export interface FooterBarChildProps {
progressValid: boolean;
gridInsets: Insets;
actionTab: string;
isMobileLayout: boolean;
forceMobileLayout: boolean;
onSetActionTab: (tab: string) => void;
onSpeakText: () => void;
}
@@ -56,7 +56,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const _ = useTranslation();
const { themeCode, isDarkMode: _isDarkMode } = useThemeStore();
const [state, setState] = useState<RsvpState>(controller.currentState);
const currentWord = state.words[state.currentIndex] ?? null;
const currentWord = controller.currentDisplayWord;
const [countdown, setCountdown] = useState<number | null>(controller.currentCountdown);
const [showChapterDropdown, setShowChapterDropdown] = useState(false);
const chapterDropdownRef = useRef<HTMLDivElement>(null);
@@ -93,15 +93,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
return 0;
});
// Context window: only rebuild the rendered word list when the current word
// falls outside the window or nears its edge, keeping the DOM stable.
const [contextWindow, setContextWindow] = useState(() => {
if (!state || state.words.length === 0) return { start: 0, end: 0 };
return {
start: Math.max(0, state.currentIndex - 50),
end: Math.min(state.words.length, state.currentIndex + 151),
};
});
const [contextWindow, setContextWindow] = useState(() => ({
start: 0,
end: state.words.length,
}));
const contextWordRef = useRef<HTMLSpanElement>(null);
const contextPanelRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef(0);
@@ -134,25 +129,11 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const newState = (e as CustomEvent<RsvpState>).detail;
setState(newState);
// Update context window only when current word falls outside or nears edge
const idx = newState.currentIndex;
// Reset context window to show all words when the chapter changes
const total = newState.words.length;
setContextWindow((prev) => {
if (total === 0) return { start: 0, end: 0 };
// Outside window — reset
if (idx < prev.start || idx >= prev.end) {
return {
start: Math.max(0, idx - 50),
end: Math.min(total, idx + 151),
};
}
// Near end of window — extend forward
const windowSize = prev.end - prev.start;
if (idx - prev.start > windowSize * 0.8) {
return { start: prev.start, end: Math.min(total, prev.end + 100) };
}
// No change — return same reference to avoid re-render
return prev;
if (total === prev.end && prev.start === 0) return prev;
return { start: 0, end: total };
});
};
@@ -851,6 +832,17 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
</button>
</div>
{/* Split hyphenated words */}
<div className='config-item gap-2'>
<span className='opacity-50'>{_('Split Hyphens')}</span>
<input
type='checkbox'
className='toggle'
checked={state.splitHyphens}
onChange={(e) => controller.setSplitHyphens(e.target.checked)}
/>
</div>
{/* ORP color */}
<div className='flex items-center gap-1.5'>
<span className='mr-0.5 font-medium opacity-50'>{_('Focus')}</span>
@@ -13,7 +13,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import { NOTE_PREFIX } from '@/types/view';
import { removeBookNoteOverlays } from '../../utils/annotatorUtil';
import useScrollToItem from '../../hooks/useScrollToItem';
import TextButton from '@/components/TextButton';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
@@ -66,9 +66,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, isNearest, o
if (item.id === note.id) {
item.deletedAt = Date.now();
const views = getViewsById(bookKey.split('-')[0]!);
views.forEach((view) =>
view?.addAnnotation({ ...item, value: `${NOTE_PREFIX}${item.cfi}` }, true),
);
views.forEach((view) => removeBookNoteOverlays(view, item));
}
});
const updatedConfig = updateBooknotes(bookKey, booknotes);
@@ -50,6 +50,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const sectionChangingTimestampRef = useRef(0);
const previousSectionLabelRef = useRef<string | undefined>(undefined);
const ttsControllerRef = useRef<TTSController | null>(null);
const isStartingTTSRef = useRef(false);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
@@ -422,103 +423,112 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey: ttsBookKey, range, index, oneTime = false } = event.detail;
if (bookKey !== ttsBookKey) return;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
const { location } = progress || {};
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
const ttsSpeakRange = range as Range | null;
let ttsFromRange = ttsSpeakRange;
let ttsFromIndex = typeof index === 'number' ? index : null;
if (!ttsFromRange && viewSettings.ttsLocation) {
const ttsCfi = viewSettings.ttsLocation;
if (isCfiInLocation(ttsCfi, location)) {
const { index, anchor } = view.resolveCFI(ttsCfi);
const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
if (doc) {
ttsFromRange = anchor(doc);
ttsFromIndex = index;
}
}
}
if (!ttsFromIndex) {
ttsFromIndex = progress.index;
}
if (!ttsFromRange && !bookData.isFixedLayout) {
ttsFromRange = progress.range;
}
const currentSection = view.renderer.getContents().find((x) => x.index === ttsFromIndex);
if (ttsFromRange && currentSection) {
const ttsLocation = view.getCFI(currentSection?.index || 0, ttsFromRange);
viewSettings.ttsLocation = ttsLocation;
setViewSettings(bookKey, viewSettings);
if (isCfiInLocation(ttsLocation, location)) {
setShowBackToCurrentTTSLocation(false);
}
}
const primaryLang = bookData.book.primaryLanguage;
if (ttsControllerRef.current) {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
// Guard against concurrent starts (e.g. rapid double-clicks on the TTS
// icon). Without this, both invocations race past the `await`s below and
// end up creating two TTSController instances that speak simultaneously.
if (isStartingTTSRef.current) return;
isStartingTTSRef.current = true;
try {
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: 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;
const ttsSpeakRange = range as Range | null;
let ttsFromRange = ttsSpeakRange;
let ttsFromIndex = typeof index === 'number' ? index : null;
if (!ttsFromRange && viewSettings.ttsLocation) {
const ttsCfi = viewSettings.ttsLocation;
if (isCfiInLocation(ttsCfi, location)) {
const { index, anchor } = view.resolveCFI(ttsCfi);
const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
if (doc) {
ttsFromRange = anchor(doc);
ttsFromIndex = index;
}
}
}
if (appService?.isMobile) {
unblockAudio();
if (!ttsFromIndex) {
ttsFromIndex = progress.index;
}
await initMediaSession();
setTtsClientsInitialized(false);
setShowIndicator(true);
const ttsController = new TTSController(
appService,
view,
!!user?.id,
preprocessSSMLForTTS,
handleSectionChange,
);
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
await ttsController.init();
await ttsController.initViewTTS(ttsFromIndex);
ttsController.updateHighlightOptions(
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
);
const ssml =
oneTime && ttsSpeakRange
? genSSMLRaw(ttsSpeakRange.toString().trim())
: ttsFromRange
? view.tts?.from(ttsFromRange)
: view.tts?.start();
if (ssml) {
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
setIsPlaying(true);
setTtsLang(lang);
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
ttsController.speak(ssml, oneTime, () => handleStop(bookKey));
ttsController.setTargetLang(getTTSTargetLang() || '');
if (!ttsFromRange && !bookData.isFixedLayout) {
ttsFromRange = progress.range;
}
setTtsClientsInitialized(true);
setTTSEnabled(bookKey, true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for this document'),
type: 'error',
});
console.error(error);
const currentSection = view.renderer.getContents().find((x) => x.index === ttsFromIndex);
if (ttsFromRange && currentSection) {
const ttsLocation = view.getCFI(currentSection?.index || 0, ttsFromRange);
viewSettings.ttsLocation = ttsLocation;
setViewSettings(bookKey, viewSettings);
if (isCfiInLocation(ttsLocation, location)) {
setShowBackToCurrentTTSLocation(false);
}
}
const primaryLang = bookData.book.primaryLanguage;
if (ttsControllerRef.current) {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
try {
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: true });
}
if (appService?.isMobile) {
unblockAudio();
}
await initMediaSession();
setTtsClientsInitialized(false);
setShowIndicator(true);
const ttsController = new TTSController(
appService,
view,
!!user?.id,
preprocessSSMLForTTS,
handleSectionChange,
);
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
await ttsController.init();
await ttsController.initViewTTS(ttsFromIndex);
ttsController.updateHighlightOptions(
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
);
const ssml =
oneTime && ttsSpeakRange
? genSSMLRaw(ttsSpeakRange.toString().trim())
: ttsFromRange
? view.tts?.from(ttsFromRange)
: view.tts?.start();
if (ssml) {
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
setIsPlaying(true);
setTtsLang(lang);
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
ttsController.speak(ssml, oneTime, () => handleStop(bookKey));
ttsController.setTargetLang(getTTSTargetLang() || '');
}
setTtsClientsInitialized(true);
setTTSEnabled(bookKey, true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for this document'),
type: 'error',
});
console.error(error);
}
} finally {
isStartingTTSRef.current = false;
}
};
@@ -1,8 +1,15 @@
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import { HighlightColor } from '@/types/book';
import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor } from '@/types/book';
import { SystemSettings } from '@/types/settings';
import { FoliateView, NOTE_PREFIX } from '@/types/view';
import { Point } from '@/utils/sel';
export const isDefaultHighlightColor = (
color: HighlightColor,
): color is (typeof DEFAULT_HIGHLIGHT_COLORS)[number] => {
return (DEFAULT_HIGHLIGHT_COLORS as readonly string[]).includes(color);
};
export const getHighlightColorHex = (
settings: SystemSettings,
color?: HighlightColor,
@@ -13,6 +20,27 @@ export const getHighlightColorHex = (
return customColors?.[color] ?? HIGHLIGHT_COLOR_HEX[color];
};
/**
* Returns a user-defined label for the given color, or `undefined` when none is set.
* Callers that want to fall back to a translated default name should handle that in
* the component layer (where `useTranslation` is available).
*/
export const getHighlightColorLabel = (
settings: SystemSettings,
color: HighlightColor,
): string | undefined => {
const { defaultHighlightLabels, userHighlightColors } = settings.globalReadSettings;
if (color.startsWith('#')) {
const hex = color.trim().toLowerCase();
const entry = userHighlightColors?.find((c) => c.hex === hex);
return entry?.label?.trim() || undefined;
}
if (isDefaultHighlightColor(color)) {
return defaultHighlightLabels?.[color]?.trim() || undefined;
}
return undefined;
};
export function getExternalDragHandle(
currentStart: Point,
currentEnd: Point,
@@ -35,3 +63,26 @@ export function toParentViewportPoint(doc: Document, x: number, y: number): Poin
const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
return { x: x + frameRect.left, y: y + frameRect.top };
}
/**
* Remove any overlays drawn for a BookNote from the given view.
*
* A single BookNote can have up to two overlays attached:
* - a highlight/underline/squiggly overlay (keyed by the raw CFI)
* - a note bubble overlay (keyed by `${NOTE_PREFIX}${cfi}`)
*
* The set of overlays drawn is defined by the progress-sync effect in
* Annotator.tsx, and this helper mirrors those filters so that deleting
* an annotation from the sidebar clears every overlay that was drawn
* for it, not just the note bubble.
*/
export function removeBookNoteOverlays(view: FoliateView | null, note: BookNote): void {
if (!view) return;
if (note.type !== 'annotation') return;
if (note.style) {
view.addAnnotation({ ...note, value: note.cfi }, true);
}
if (note.note && note.note.trim().length > 0) {
view.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true);
}
}
+5 -1
View File
@@ -96,7 +96,11 @@ const Dropdown: React.FC<DropdownProps> = ({
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!isOpen) setIsDropdownOpen(true);
// Let the native button click (dispatched by the browser for Enter/Space
// on a focused button) drive the toggle — toggling here would race with
// that click and cancel it out. We still stop propagation so global
// shortcuts bound to Enter/Space (e.g. next page in the reader) don't
// fire while a dropdown button is focused.
e.stopPropagation();
} else if (e.key === 'Escape') {
setIsDropdownOpen(false);
+1 -1
View File
@@ -111,7 +111,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
<details open={detailsOpen} onToggle={(e) => setIsDetailsOpen(e.currentTarget.open)}>
<summary
role='button'
tabIndex={-1}
tabIndex={0}
aria-expanded={isDetailsOpen}
className={clsx(
'hover:bg-base-300 text-base-content cursor-pointer rounded-md p-1 py-[10px] pr-3',
+4 -2
View File
@@ -4,6 +4,7 @@ import React from 'react';
type Option = {
value: string;
label: string;
disabled?: boolean;
};
type SelectProps = {
@@ -28,6 +29,7 @@ export default function Select({
onKeyDown={(e) => e.stopPropagation()}
className={clsx(
'select bg-base-200 h-8 min-h-8 max-w-[60%] truncate rounded-md border-none text-sm',
'focus:outline-none focus:ring-0 focus-visible:outline-none',
className,
)}
disabled={disabled}
@@ -35,8 +37,8 @@ export default function Select({
textAlignLast: 'end',
}}
>
{options.map(({ value, label }) => (
<option key={value} value={value}>
{options.map(({ value, label, disabled: optionDisabled }) => (
<option key={value} value={value} disabled={optionDisabled}>
{label}
</option>
))}
@@ -20,6 +20,7 @@ import { SettingsPanelPanelProp } from './SettingsDialog';
import { useFileSelector } from '@/hooks/useFileSelector';
import { PREDEFINED_TEXTURES } from '@/styles/textures';
import { useAtmosphereStore } from '@/store/atmosphereStore';
import { DefaultHighlightColor, HighlightColor, UserHighlightColor } from '@/types/book';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import ThemeEditor from './color/ThemeEditor';
import ThemeModeSelector from './color/ThemeModeSelector';
@@ -54,9 +55,12 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const [customHighlightColors, setCustomHighlightColors] = useState(
settings.globalReadSettings.customHighlightColors,
);
const [userHighlightColors, setUserHighlightColors] = useState(
settings.globalReadSettings.userHighlightColors || [],
const [userHighlightColors, setUserHighlightColors] = useState<UserHighlightColor[]>(
settings.globalReadSettings.userHighlightColors ?? [],
);
const [defaultHighlightLabels, setDefaultHighlightLabels] = useState<
Partial<Record<DefaultHighlightColor, string>>
>(settings.globalReadSettings.defaultHighlightLabels ?? {});
const [readingRulerEnabled, setReadingRulerEnabled] = useState(viewSettings.readingRulerEnabled);
const [readingRulerLines, setReadingRulerLines] = useState(viewSettings.readingRulerLines);
@@ -94,6 +98,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
setBackgroundSize('cover');
setCustomHighlightColors(HIGHLIGHT_COLOR_HEX);
setUserHighlightColors([]);
setDefaultHighlightLabels({});
deactivateAtmosphere();
};
@@ -267,20 +272,29 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
saveCustomTextures(envConfig);
};
const handleHighlightColorsChange = (colors: typeof customHighlightColors) => {
const handleCustomHighlightColorsChange = (colors: Record<HighlightColor, string>) => {
setCustomHighlightColors(colors);
settings.globalReadSettings.customHighlightColors = colors;
setSettings(settings);
saveSettings(envConfig, settings);
};
const handleUserHighlightColorsChange = (colors: string[]) => {
const handleUserHighlightColorsChange = (colors: UserHighlightColor[]) => {
setUserHighlightColors(colors);
settings.globalReadSettings.userHighlightColors = colors;
setSettings(settings);
saveSettings(envConfig, settings);
};
const handleDefaultHighlightLabelsChange = (
labels: Partial<Record<DefaultHighlightColor, string>>,
) => {
setDefaultHighlightLabels(labels);
settings.globalReadSettings.defaultHighlightLabels = labels;
setSettings(settings);
saveSettings(envConfig, settings);
};
return (
<div className='my-4 w-full space-y-6'>
{showCustomThemeEditor ? (
@@ -352,10 +366,12 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<HighlightColorsEditor
customHighlightColors={customHighlightColors}
userHighlightColors={userHighlightColors}
defaultHighlightLabels={defaultHighlightLabels}
highlightOpacity={highlightOpacity}
isEink={viewSettings.isEink}
onChange={handleHighlightColorsChange}
onUserColorsChange={handleUserHighlightColorsChange}
onCustomHighlightColorsChange={handleCustomHighlightColorsChange}
onUserHighlightColorsChange={handleUserHighlightColorsChange}
onDefaultHighlightLabelsChange={handleDefaultHighlightLabelsChange}
onOpacityChange={setHighlightOpacity}
data-setting-id='settings.color.highlightColors'
/>
@@ -6,7 +6,11 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { saveViewSettings } from '@/helpers/settings';
import { getTranslators } from '@/services/translators';
import {
getTranslatorDisplayLabel,
getTranslators,
isTranslatorAvailable,
} from '@/services/translators';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { TRANSLATED_LANGS, TRANSLATOR_LANGS } from '@/services/constants';
import { ConvertChineseVariant } from '@/types/book';
@@ -80,25 +84,19 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
};
const getTranslationProviderOptions = () => {
const translators = getTranslators();
const availableProviders = translators.map((t) => {
let label = t.label;
if (t.authRequired && !token) {
label = `${label} (${_('Login Required')})`;
} else if (t.quotaExceeded) {
label = `${label} (${_('Quota Exceeded')})`;
}
return { value: t.name, label };
});
return availableProviders;
return getTranslators().map((t) => ({
value: t.name,
label: getTranslatorDisplayLabel(t, !!token, _),
// Providers marked `disabled` (e.g. upstream relay is down) stay in the
// dropdown so users can see them, but cannot be selected.
disabled: !!t.disabled,
}));
};
const getCurrentTranslationProviderOption = () => {
const value = translationProvider;
const allProviders = getTranslationProviderOptions();
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token));
const currentProvider = availableTranslators.find((t) => t.name === value)
? value
: availableTranslators[0]?.name;
@@ -1,6 +1,11 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { MdClose } from 'react-icons/md';
import { HighlightColor } from '@/types/book';
import {
DEFAULT_HIGHLIGHT_COLORS,
DefaultHighlightColor,
HighlightColor,
UserHighlightColor,
} from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import NumberInput from '../NumberInput';
import ColorInput from './ColorInput';
@@ -9,25 +14,70 @@ const MAX_USER_HIGHLIGHT_COLORS = 10;
interface HighlightColorsEditorProps {
customHighlightColors: Record<HighlightColor, string>;
userHighlightColors: string[];
userHighlightColors: UserHighlightColor[];
defaultHighlightLabels: Partial<Record<DefaultHighlightColor, string>>;
highlightOpacity: number;
isEink: boolean;
onChange: (colors: Record<HighlightColor, string>) => void;
onUserColorsChange: (colors: string[]) => void;
onCustomHighlightColorsChange: (colors: Record<HighlightColor, string>) => void;
onUserHighlightColorsChange: (colors: UserHighlightColor[]) => void;
onDefaultHighlightLabelsChange: (labels: Partial<Record<DefaultHighlightColor, string>>) => void;
onOpacityChange: (opacity: number) => void;
}
/**
* Text input that commits on blur instead of on every keystroke, so we don't
* thrash the settings store while the user is typing a label.
*/
const LabelInput: React.FC<{
label: string;
onCommit: (next: string) => void;
placeholder: string;
className: string;
}> = ({ label, onCommit, placeholder, className }) => {
const [draft, setDraft] = useState(label);
useEffect(() => {
setDraft(label);
}, [label]);
const commit = () => {
const trimmed = draft.trim();
if (trimmed !== label) onCommit(trimmed);
};
return (
<input
type='text'
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
}}
placeholder={placeholder}
maxLength={20}
className={className}
title={draft}
/>
);
};
const normalizeHex = (value: string) => value.trim().toLowerCase();
const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
customHighlightColors,
userHighlightColors,
defaultHighlightLabels,
highlightOpacity,
isEink,
onChange,
onUserColorsChange,
onCustomHighlightColorsChange,
onUserHighlightColorsChange,
onDefaultHighlightLabelsChange,
onOpacityChange,
}) => {
const _ = useTranslation();
const [newColor, setNewColor] = useState('#808080');
const [newColorLabel, setNewColorLabel] = useState('');
const highlightPreviewStyle: React.CSSProperties = {
opacity: highlightOpacity,
@@ -35,125 +85,171 @@ const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
'var(--overlayer-highlight-blend-mode, normal)' as React.CSSProperties['mixBlendMode'],
};
const handleColorChange = (color: HighlightColor, value: string) => {
const updated = { ...customHighlightColors, [color]: value };
onChange(updated);
const handleDefaultHexChange = (color: DefaultHighlightColor, hex: string) => {
onCustomHighlightColorsChange({ ...customHighlightColors, [color]: hex });
};
const handleDefaultLabelChange = (color: DefaultHighlightColor, label: string) => {
const next = { ...defaultHighlightLabels };
if (label) {
next[color] = label;
} else {
delete next[color];
}
onDefaultHighlightLabelsChange(next);
};
const handleUserLabelChange = (hex: string, label: string) => {
const key = normalizeHex(hex);
onUserHighlightColorsChange(
userHighlightColors.map((entry) =>
entry.hex === key ? { ...entry, label: label || undefined } : entry,
),
);
};
const handleAddUserColor = () => {
if (userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS) return;
if (!userHighlightColors.includes(newColor)) {
const updatedColors = [...userHighlightColors, newColor];
onUserColorsChange(updatedColors);
}
const hex = normalizeHex(newColor);
if (!hex.startsWith('#')) return;
if (userHighlightColors.some((entry) => entry.hex === hex)) return;
const label = newColorLabel.trim();
onUserHighlightColorsChange([...userHighlightColors, label ? { hex, label } : { hex }]);
setNewColorLabel('');
};
const handleDeleteUserColor = (hex: string) => {
const updatedColors = userHighlightColors.filter((c) => c !== hex);
onUserColorsChange(updatedColors);
const key = normalizeHex(hex);
onUserHighlightColorsChange(userHighlightColors.filter((entry) => entry.hex !== key));
};
const handleUserColorChange = (oldHex: string, newHex: string) => {
const updatedColors = userHighlightColors.map((c) => (c === oldHex ? newHex : c));
onUserColorsChange(updatedColors);
const handleUserHexChange = (oldHex: string, newHex: string) => {
const oldKey = normalizeHex(oldHex);
const newKey = normalizeHex(newHex);
if (oldKey === newKey) return;
// Drop the rename if it collides with another existing color.
if (userHighlightColors.some((entry) => entry.hex === newKey)) return;
onUserHighlightColorsChange(
userHighlightColors.map((entry) =>
entry.hex === oldKey ? { ...entry, hex: newKey } : entry,
),
);
};
const isDuplicateNewColor = userHighlightColors.some(
(entry) => entry.hex === normalizeHex(newColor),
);
return (
<div>
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
<div className='card border-base-200 bg-base-100 overflow-visible border shadow'>
<div className='grid grid-cols-3 gap-3 p-4 sm:grid-cols-5'>
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
(color, index, array) => {
const position =
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
return (
<div key={color} className='flex flex-col items-center gap-2'>
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
<div
className='h-full w-full rounded-full'
style={{
backgroundColor: customHighlightColors[color],
...highlightPreviewStyle,
}}
/>
</div>
<ColorInput
label=''
value={customHighlightColors[color]!}
compact={true}
pickerPosition={position}
onChange={(value: string) => handleColorChange(color, value)}
/>
</div>
);
},
)}
</div>
{(userHighlightColors.length > 0 || true) && (
<div className='border-base-200 border-t p-4'>
<div className='mb-2 flex items-center justify-between'>
<span className='font-normal'>
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
</span>
<div className='flex items-center gap-2'>
<div className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'>
<div className='grid grid-cols-2 gap-3 p-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5'>
{DEFAULT_HIGHLIGHT_COLORS.map((color, index, array) => {
const position = index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
return (
<div key={color} className='flex min-w-0 flex-col items-center gap-2'>
<LabelInput
label={defaultHighlightLabels[color] ?? ''}
onCommit={(next) => handleDefaultLabelChange(color, next)}
placeholder={_('Name')}
className='input input-xs bg-base-100 border-base-200/75 h-6 w-full min-w-0 max-w-24 text-center text-xs'
/>
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
<div
className='h-full w-full rounded-full'
style={{ backgroundColor: newColor, ...highlightPreviewStyle }}
style={{
backgroundColor: customHighlightColors[color],
...highlightPreviewStyle,
}}
/>
</div>
<ColorInput
label=''
value={newColor}
value={customHighlightColors[color]!}
compact={true}
pickerPosition='right'
onChange={setNewColor}
pickerPosition={position}
onChange={(value: string) => handleDefaultHexChange(color, value)}
/>
<button
onClick={handleAddUserColor}
disabled={
userHighlightColors.includes(newColor) ||
userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
}
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
>
<span className='text-xs'>{_('Add')}</span>
</button>
</div>
</div>
);
})}
</div>
{userHighlightColors.length > 0 && (
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{userHighlightColors.map((hex, index) => (
<div key={hex} className='group relative flex flex-col items-center gap-2'>
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
<div
className='h-full w-full rounded-full'
style={{ backgroundColor: hex, ...highlightPreviewStyle }}
/>
</div>
<ColorInput
label=''
value={hex}
compact={true}
pickerPosition={index === 0 ? 'left' : 'center'}
onChange={(value: string) => handleUserColorChange(hex, value)}
/>
<button
onClick={() => handleDeleteUserColor(hex)}
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<MdClose size={12} />
</button>
</div>
))}
<div className='border-base-200 border-t p-4'>
<div className='mb-2 flex items-center justify-between'>
<span className='font-normal'>
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
</span>
<div className='flex flex-wrap items-center gap-2'>
<div className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'>
<div
className='h-full w-full rounded-full'
style={{ backgroundColor: newColor, ...highlightPreviewStyle }}
/>
</div>
)}
<ColorInput
label=''
value={newColor}
compact={true}
pickerPosition='right'
onChange={setNewColor}
/>
<input
type='text'
value={newColorLabel}
onChange={(e) => setNewColorLabel(e.target.value)}
placeholder={_('Name')}
maxLength={20}
className='input input-xs bg-base-100 border-base-200/75 h-6 w-24 text-center text-xs'
/>
<button
onClick={handleAddUserColor}
disabled={
isDuplicateNewColor || userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
}
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
>
<span className='text-xs'>{_('Add')}</span>
</button>
</div>
</div>
)}
{userHighlightColors.length > 0 && (
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{userHighlightColors.map(({ hex, label }, index) => (
<div key={hex} className='group relative flex min-w-0 flex-col items-center gap-2'>
<LabelInput
label={label ?? ''}
onCommit={(next) => handleUserLabelChange(hex, next)}
placeholder={_('Name')}
className='input input-xs bg-base-100 border-base-200/75 h-6 w-full min-w-0 max-w-24 text-center text-xs'
/>
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
<div
className='h-full w-full rounded-full'
style={{ backgroundColor: hex, ...highlightPreviewStyle }}
/>
</div>
<ColorInput
label=''
value={hex}
compact={true}
pickerPosition={index === 0 ? 'left' : 'center'}
onChange={(value: string) => handleUserHexChange(hex, value)}
/>
<button
onClick={() => handleDeleteUserColor(hex)}
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<MdClose size={12} />
</button>
</div>
))}
</div>
)}
</div>
<NumberInput
label={_('Opacity')}
+152
View File
@@ -0,0 +1,152 @@
import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
interface UseDragScrollOptions {
/** When false, pointer events are ignored. */
enabled?: boolean;
/** Pixels the pointer must travel before entering drag mode. */
threshold?: number;
/**
* Milliseconds to suppress the synthetic click that browsers fire after a drag.
* Without this, releasing the mouse on a child button would register as a tap.
*/
clickSuppressMs?: number;
}
interface UseDragScrollResult<T extends HTMLElement> {
/** True while the pointer has moved past the threshold. Useful for cursor styling. */
isDragging: boolean;
/** Spread these on the scroll container. */
pointerHandlers: {
onPointerDown: (event: React.PointerEvent<T>) => void;
onPointerMove: (event: React.PointerEvent<T>) => void;
onPointerUp: (event: React.PointerEvent<T>) => void;
onPointerCancel: (event: React.PointerEvent<T>) => void;
onPointerLeave: (event: React.PointerEvent<T>) => void;
};
/**
* Returns true if a pending drag or recent drag-release should swallow a
* click. Child click handlers should early-return when this is true.
*/
shouldSuppressClick: () => boolean;
}
/**
* Adds mouse drag-to-scroll to a horizontally scrollable container. Touch users
* already get native momentum scrolling, so this hook intentionally ignores
* non-mouse pointer types.
*/
export function useDragScroll<T extends HTMLElement>(
ref: RefObject<T | null>,
{ enabled = true, threshold = 6, clickSuppressMs = 120 }: UseDragScrollOptions = {},
): UseDragScrollResult<T> {
const [isDragging, setIsDragging] = useState(false);
const stateRef = useRef({
active: false,
startX: 0,
startScrollLeft: 0,
moved: false,
});
const suppressClickRef = useRef(false);
const suppressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearSuppressTimer = useCallback(() => {
if (suppressTimerRef.current) {
clearTimeout(suppressTimerRef.current);
suppressTimerRef.current = null;
}
}, []);
const onPointerDown = useCallback(
(event: React.PointerEvent<T>) => {
if (!enabled || event.pointerType !== 'mouse') return;
const el = ref.current;
if (!el) return;
stateRef.current = {
active: true,
startX: event.clientX,
startScrollLeft: el.scrollLeft,
moved: false,
};
setIsDragging(false);
},
[enabled, ref],
);
const onPointerMove = useCallback(
(event: React.PointerEvent<T>) => {
const state = stateRef.current;
const el = ref.current;
if (!el || !state.active) return;
const deltaX = event.clientX - state.startX;
if (!state.moved && Math.abs(deltaX) >= threshold) {
state.moved = true;
setIsDragging(true);
}
if (state.moved) {
el.scrollLeft = state.startScrollLeft - deltaX;
event.preventDefault();
}
},
[ref, threshold],
);
const endDrag = useCallback(() => {
const state = stateRef.current;
if (!state.active) return;
const moved = state.moved;
state.active = false;
state.moved = false;
setIsDragging(false);
if (moved) {
clearSuppressTimer();
suppressClickRef.current = true;
suppressTimerRef.current = setTimeout(() => {
suppressClickRef.current = false;
suppressTimerRef.current = null;
}, clickSuppressMs);
}
}, [clearSuppressTimer, clickSuppressMs]);
const onPointerUp = useCallback(
(event: React.PointerEvent<T>) => {
if (event.pointerType !== 'mouse') return;
endDrag();
},
[endDrag],
);
const onPointerCancel = useCallback(() => endDrag(), [endDrag]);
const onPointerLeave = useCallback(
(event: React.PointerEvent<T>) => {
if (event.pointerType !== 'mouse') return;
endDrag();
},
[endDrag],
);
useEffect(() => {
return () => {
clearSuppressTimer();
suppressClickRef.current = false;
stateRef.current.active = false;
stateRef.current.moved = false;
};
}, [clearSuppressTimer]);
const shouldSuppressClick = useCallback(
() => stateRef.current.active || suppressClickRef.current,
[],
);
return {
isDragging,
pointerHandlers: {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onPointerLeave,
},
shouldSuppressClick,
};
}
+11 -3
View File
@@ -160,13 +160,14 @@ export function useSync(bookKey?: string) {
}
};
const pushChanges = async (payload: SyncData) => {
const pushChanges = async (payload: SyncData): Promise<boolean> => {
setSyncing(true);
setSyncError(null);
try {
const result = await syncClient.pushChanges(payload);
setSyncResult(result);
return true;
} catch (err: unknown) {
console.error(err);
if (err instanceof Error) {
@@ -174,6 +175,7 @@ export function useSync(bookKey?: string) {
} else {
setSyncError('Error pushing changes');
}
return false;
} finally {
setSyncing(false);
}
@@ -203,7 +205,10 @@ export function useSync(bookKey?: string) {
async (bookConfigs?: BookConfig[], bookId?: string, metaHash?: string, op: SyncOp = 'both') => {
if (!bookId && !lastSyncedAtInited) return;
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
await pushChanges({ configs: bookConfigs });
const pushed = await pushChanges({ configs: bookConfigs });
if (pushed && bookId && bookKey) {
setConfig(bookKey, { lastPushedAtConfig: Date.now() });
}
}
if (op === 'pull' || op === 'both') {
await pullChanges(
@@ -224,7 +229,10 @@ export function useSync(bookKey?: string) {
async (bookNotes?: BookNote[], bookId?: string, metaHash?: string, op: SyncOp = 'both') => {
if (!lastSyncedAtInited) return;
if ((op === 'push' || op === 'both') && bookNotes?.length) {
await pushChanges({ notes: bookNotes });
const pushed = await pushChanges({ notes: bookNotes });
if (pushed && bookId && bookKey) {
setConfig(bookKey, { lastPushedAtNotes: Date.now() });
}
}
if (op === 'pull' || op === 'both') {
await pullChanges(
+8 -4
View File
@@ -1,6 +1,12 @@
import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { ErrorCodes, getTranslator, getTranslators, TranslatorName } from '@/services/translators';
import {
ErrorCodes,
getTranslator,
getTranslators,
isTranslatorAvailable,
TranslatorName,
} from '@/services/translators';
import { getFromCache, storeInCache, UseTranslatorOptions } from '@/services/translators';
import { polish, preprocess } from '@/services/translators';
import { eventDispatcher } from '@/utils/event';
@@ -26,9 +32,7 @@ export function useTranslator({
}, [provider, sourceLang, targetLang]);
useEffect(() => {
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token));
const selectedTranslator =
availableTranslators.find((t) => t.name === provider) || availableTranslators[0]!;
const selectedProviderName = selectedTranslator.name as TranslatorName;
+1
View File
@@ -34,6 +34,7 @@ const SUPPORTED_LNGS = [
'zh-CN',
'zh-TW',
'ro',
'hu',
];
const isBrowser = typeof window !== 'undefined';
+2 -1
View File
@@ -157,6 +157,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
},
customHighlightColors: HIGHLIGHT_COLOR_HEX,
userHighlightColors: [],
defaultHighlightLabels: {},
customTtsHighlightColors: [],
};
@@ -834,6 +835,7 @@ export const TRANSLATED_LANGS = {
'zh-CN': '简体中文',
'zh-TW': '正體中文',
ro: 'Română',
hu: 'Magyar',
};
export const TRANSLATOR_LANGS: Record<string, string> = {
@@ -843,7 +845,6 @@ export const TRANSLATOR_LANGS: Record<string, string> = {
fi: 'Suomi',
da: 'Dansk',
cs: 'Čeština',
hu: 'Magyar',
km: 'ខ្មែរ',
ro: 'Română',
bg: 'Български',
@@ -1,5 +1,7 @@
import { Book, BookConfig, BookNote } from '@/types/book';
import { getContentMd5 } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { HardcoverSyncMapStore } from './HardcoverSyncMapStore';
import {
QUERY_GET_USER_ID,
@@ -31,8 +33,6 @@ type BookContext = {
} | null;
};
const isTauriEnv = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
export class HardcoverClient {
private minRequestIntervalMs = 1150;
private directEndpoint = 'https://api.hardcover.app/v1/graphql';
@@ -51,7 +51,7 @@ export class HardcoverClient {
}
private get endpoint() {
return isTauriEnv() ? this.directEndpoint : this.proxyEndpoint;
return isTauriAppPlatform() ? this.directEndpoint : this.proxyEndpoint;
}
private formatDate(date: Date): string {
@@ -96,7 +96,8 @@ export class HardcoverClient {
): Promise<TData> {
await this.throttleRequest();
const res = await fetch(this.endpoint, {
const fetchFn = isTauriAppPlatform() ? tauriFetch : window.fetch;
const res = await fetchFn(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -247,11 +248,19 @@ export class HardcoverClient {
document?: { id?: number; pages?: number; featured_edition_id?: number };
};
const bookId = hit.id ?? hit.document?.id;
if (!bookId) return null;
const rawBookId = hit.id ?? hit.document?.id;
if (!rawBookId) return null;
const editionId = hit.featured_edition_id ?? hit.document?.featured_edition_id ?? bookId;
const pages = hit.pages ?? hit.document?.pages ?? null;
const bookId = Number(rawBookId);
const editionId = Number(
hit.featured_edition_id ?? hit.document?.featured_edition_id ?? bookId,
);
const pages =
hit.pages != null
? Number(hit.pages)
: hit.document?.pages != null
? Number(hit.document.pages)
: null;
return {
editionId,
@@ -1,6 +1,6 @@
import { FoliateView } from '@/types/view';
import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice } from './types';
import { containsCJK, splitTextIntoWords } from './utils';
import { containsCJK, splitTextIntoWords, getHyphenParts } from './utils';
import { compare as compareCFI } from 'foliate-js/epubcfi.js';
import { XCFI } from '@/utils/xcfi';
@@ -10,9 +10,11 @@ const MAX_WPM = 1000;
const WPM_STEP = 50;
const DEFAULT_PUNCTUATION_PAUSE_MS = 100;
const PUNCTUATION_PAUSE_OPTIONS = [25, 50, 75, 100, 125, 150, 175, 200];
const DEFAULT_SPLIT_HYPHENS = false;
const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_';
const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
const SPLIT_HYPHENS_KEY = 'readest_rsvp_split_hyphens';
export class RSVPController extends EventTarget {
private view: FoliateView;
@@ -24,8 +26,10 @@ export class RSVPController extends EventTarget {
playing: false,
words: [],
currentIndex: 0,
currentPartIndex: 0,
wpm: DEFAULT_WPM,
punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS,
splitHyphens: DEFAULT_SPLIT_HYPHENS,
progress: 0,
};
@@ -52,6 +56,10 @@ export class RSVPController extends EventTarget {
if (savedPause) {
this.state.punctuationPauseMs = savedPause;
}
const savedSplitHyphens = this.loadSplitHyphensFromStorage();
if (savedSplitHyphens !== null) {
this.state.splitHyphens = savedSplitHyphens;
}
}
get currentState(): RsvpState {
@@ -69,6 +77,16 @@ export class RSVPController extends EventTarget {
return null;
}
get currentDisplayWord(): RsvpWord | null {
const word = this.currentWord;
if (!word) return null;
if (!this.state.splitHyphens) return word;
const parts = getHyphenParts(word.text);
if (parts.length <= 1) return word;
const partText = parts[this.state.currentPartIndex] ?? word.text;
return { ...word, text: partText, orpIndex: this.calculateORP(partText) };
}
get currentCountdown(): number | null {
return this.countdown;
}
@@ -130,6 +148,30 @@ export class RSVPController extends EventTarget {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${this.bookId}`, wpm.toString());
}
getSplitHyphens(): boolean {
return this.state.splitHyphens;
}
setSplitHyphens(value: boolean): void {
this.state.splitHyphens = value;
try {
localStorage.setItem(SPLIT_HYPHENS_KEY, value ? '1' : '0');
} catch {
/* ignore */
}
this.emitStateChange();
}
private loadSplitHyphensFromStorage(): boolean | null {
try {
const stored = localStorage.getItem(SPLIT_HYPHENS_KEY);
if (stored !== null) return stored === '1';
} catch {
/* ignore */
}
return null;
}
setCurrentCfi(cfi: string | null): void {
this.currentCfi = cfi;
}
@@ -208,6 +250,7 @@ export class RSVPController extends EventTarget {
setTimeout(() => this.start(retryCount + 1), 150 * (retryCount + 1));
return;
}
this.dispatchEvent(new CustomEvent('rsvp-request-next-page'));
return;
}
@@ -277,7 +320,7 @@ export class RSVPController extends EventTarget {
this.clearCountdown();
onComplete();
}
}, 800);
}, 500);
}
private clearCountdown(): void {
@@ -323,6 +366,7 @@ export class RSVPController extends EventTarget {
playing: false,
words: [],
currentIndex: 0,
currentPartIndex: 0,
};
this.emitStateChange();
}
@@ -475,11 +519,13 @@ export class RSVPController extends EventTarget {
this.state.words.length - 1,
this.state.currentIndex + count,
);
this.state.currentPartIndex = 0;
this.emitStateChange();
}
skipBackward(count: number = 10): void {
this.state.currentIndex = Math.max(0, this.state.currentIndex - count);
this.state.currentPartIndex = 0;
this.emitStateChange();
}
@@ -487,12 +533,14 @@ export class RSVPController extends EventTarget {
if (this.state.words.length === 0) return;
const newIndex = Math.floor((percentage / 100) * this.state.words.length);
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex));
this.state.currentPartIndex = 0;
this.emitStateChange();
}
seekToIndex(index: number): void {
if (this.state.words.length === 0) return;
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, index));
this.state.currentPartIndex = 0;
this.emitStateChange();
}
@@ -504,7 +552,7 @@ export class RSVPController extends EventTarget {
setTimeout(() => this.loadNextPageContent(retryCount + 1), 200 * (retryCount + 1));
return;
}
this.pause();
this.dispatchEvent(new CustomEvent('rsvp-request-next-page'));
return;
}
@@ -514,6 +562,7 @@ export class RSVPController extends EventTarget {
playing: false,
words,
currentIndex: 0,
currentPartIndex: 0,
};
this.emitStateChange();
@@ -536,8 +585,8 @@ export class RSVPController extends EventTarget {
return;
}
const word = this.state.words[this.state.currentIndex]!;
const duration = this.getWordDisplayDuration(word, this.state.wpm);
const displayWord = this.currentDisplayWord!;
const duration = this.getWordDisplayDuration(displayWord, this.state.wpm);
this.playbackTimer = setTimeout(() => {
this.advanceToNextWord();
@@ -545,6 +594,17 @@ export class RSVPController extends EventTarget {
}
private advanceToNextWord(): void {
const word = this.currentWord;
if (word && this.state.splitHyphens) {
const parts = getHyphenParts(word.text);
if (this.state.currentPartIndex < parts.length - 1) {
this.state.currentPartIndex += 1;
this.emitStateChange();
this.scheduleNextWord();
return;
}
}
const newIndex = this.state.currentIndex + 1;
if (newIndex >= this.state.words.length) {
@@ -553,6 +613,7 @@ export class RSVPController extends EventTarget {
}
this.state.currentIndex = newIndex;
this.state.currentPartIndex = 0;
this.emitStateChange();
this.scheduleNextWord();
@@ -12,8 +12,10 @@ export interface RsvpState {
playing: boolean;
words: RsvpWord[];
currentIndex: number;
currentPartIndex: number;
wpm: number;
punctuationPauseMs: number;
splitHyphens: boolean;
progress: number;
}
@@ -167,6 +167,24 @@ export function segmentCJKText(text: string): string[] {
return words.filter((w) => w.trim().length > 0);
}
/**
* Split a hyphenated word into display parts, keeping a trailing hyphen on
* all but the last part. Only splits on hyphens that are directly between two
* letters (letter-hyphen-letter), so tokens like "--", "-word", "word-", and
* "foo--bar" are returned unchanged.
*
* Examples:
* "well-known" ["well-", "known"]
* "a-b-c" ["a-", "b-", "c"]
* "--" ["--"]
* "hello" ["hello"]
*/
export function getHyphenParts(word: string): string[] {
if (!/[a-zA-Z]-[a-zA-Z]/.test(word)) return [word];
const parts = word.split(/-(?=[a-zA-Z])/);
return parts.map((part, i) => (i < parts.length - 1 ? part + '-' : part));
}
/**
* Split text into words, handling both CJK and non-CJK text
*/
@@ -1,6 +1,6 @@
import { FileSystem } from '@/types/system';
import { SystemSettings } from '@/types/settings';
import { ViewSettings } from '@/types/book';
import { ReadSettings, SystemSettings } from '@/types/settings';
import { DEFAULT_HIGHLIGHT_COLORS, UserHighlightColor, ViewSettings } from '@/types/book';
import { v4 as uuidv4 } from 'uuid';
import {
DEFAULT_BOOK_LAYOUT,
@@ -50,6 +50,55 @@ export function getDefaultViewSettings(ctx: Context): ViewSettings {
};
}
/**
* Normalize highlight color prefs into the current shape:
* - `userHighlightColors` becomes `UserHighlightColor[]`. Legacy `string[]` entries
* are lifted into `{ hex }`. A legacy `highlightColorLabels` map (shipped only in
* draft builds of this feature) is folded in: hex entries attach to matching user
* colors, named entries move into `defaultHighlightLabels`.
*/
export function migrateHighlightColorPrefs(read: ReadSettings): void {
const rawUser = (read.userHighlightColors ?? []) as unknown[];
const userColors: UserHighlightColor[] = rawUser
.map((entry) => {
if (typeof entry === 'string') {
return { hex: entry.trim().toLowerCase() };
}
if (entry && typeof entry === 'object' && 'hex' in entry) {
const { hex, label } = entry as UserHighlightColor;
return {
hex: typeof hex === 'string' ? hex.trim().toLowerCase() : '',
...(label?.trim() ? { label: label.trim() } : {}),
};
}
return { hex: '' };
})
.filter((entry) => entry.hex.startsWith('#'));
read.defaultHighlightLabels = { ...(read.defaultHighlightLabels ?? {}) };
const legacyLabels = (read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels;
if (legacyLabels && typeof legacyLabels === 'object') {
const labels = legacyLabels as Record<string, unknown>;
for (const name of DEFAULT_HIGHLIGHT_COLORS) {
const value = labels[name];
if (typeof value === 'string' && value.trim() && !read.defaultHighlightLabels[name]) {
read.defaultHighlightLabels[name] = value.trim();
}
}
for (const entry of userColors) {
if (entry.label) continue;
const value = labels[entry.hex];
if (typeof value === 'string' && value.trim()) {
entry.label = value.trim();
}
}
delete (read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels;
}
read.userHighlightColors = userColors;
}
export async function loadSettings(ctx: Context): Promise<SystemSettings> {
const defaultSettings: SystemSettings = {
...DEFAULT_SYSTEM_SETTINGS,
@@ -85,6 +134,7 @@ export async function loadSettings(ctx: Context): Promise<SystemSettings> {
...(ctx.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
...settings.globalReadSettings,
};
migrateHighlightColorPrefs(settings.globalReadSettings);
settings.globalViewSettings = {
...getDefaultViewSettings(ctx),
...settings.globalViewSettings,
@@ -38,3 +38,45 @@ export const getTranslator = (name: TranslatorName): TranslationProvider | undef
export const getTranslators = (): TranslationProvider[] => {
return availableTranslators;
};
/**
* Single source of truth for "can this provider actually be used right now?".
* Used by auto-selection / fallback logic in `useTranslator`, the settings
* panel, and the translator popup. Disabled providers (e.g. temporarily down
* upstream services) are still returned from `getTranslators()` so the UI can
* render them greyed out, but this predicate excludes them so they can never
* be chosen or fallen back to.
*/
export const isTranslatorAvailable = (
translator: TranslationProvider,
hasToken: boolean,
): boolean => {
if (translator.disabled) return false;
if (translator.quotaExceeded) return false;
if (translator.authRequired && !hasToken) return false;
return true;
};
/**
* Builds the user-facing dropdown label for a provider, appending a short
* status suffix when the provider is unavailable. Kept next to
* `isTranslatorAvailable` so the two stay in sync when a new unavailability
* reason is added. The `_` translation function is passed in so this module
* stays free of React imports.
*/
export const getTranslatorDisplayLabel = (
translator: TranslationProvider,
hasToken: boolean,
_: (key: string) => string,
): string => {
if (translator.disabled) {
return `${translator.label} (${_('Unavailable')})`;
}
if (translator.authRequired && !hasToken) {
return `${translator.label} (${_('Login Required')})`;
}
if (translator.quotaExceeded) {
return `${translator.label} (${_('Quota Exceeded')})`;
}
return translator.label;
};
@@ -49,6 +49,10 @@ export const yandexProvider: TranslationProvider = {
name: 'yandex',
label: _('Yandex Translate'),
authRequired: false,
// The upstream translate.toil.cc relay is currently down. Keep the
// implementation in tree so we can re-enable it simply by flipping this
// flag to `false` (or deleting the line) once the service is healthy.
disabled: true,
translate: async (texts: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
if (!texts.length) return [];
@@ -5,6 +5,14 @@ export interface TranslationProvider {
label: string;
authRequired?: boolean;
quotaExceeded?: boolean;
/**
* Marks a provider as temporarily unavailable. Disabled providers are
* filtered out of `getTranslators()` / `getTranslator()`, so the UI never
* lists them and the fallback logic in `useTranslator` skips over them.
* Flip back to `false` (or delete the field) once the provider is healthy
* again no other code changes required.
*/
disabled?: boolean;
translate: (
texts: string[],
sourceLang: string,
-7
View File
@@ -441,13 +441,6 @@ foliate-fxl {
mask: linear-gradient(180deg, black 0%, rgba(0, 0, 0, 0.9) 50%, rgba(0, 0, 0, 0.5) 100%);
}
/* Force mobile footer bar layout on Android portrait (overrides sm:hidden breakpoint) */
@media (min-width: 640px) {
.force-mobile-layout .sm\:hidden {
display: flex;
}
}
.visible-focus-inset-2:focus-visible {
outline: none;
box-shadow:
+9
View File
@@ -19,6 +19,13 @@ export type ReadingStatus = 'unread' | 'reading' | 'finished';
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
// Predefined highlight colors, can be extended with custom hex colors
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet' | string;
export const DEFAULT_HIGHLIGHT_COLORS = ['red', 'yellow', 'green', 'blue', 'violet'] as const;
export type DefaultHighlightColor = (typeof DEFAULT_HIGHLIGHT_COLORS)[number];
// A user-added highlight color with optional label
export interface UserHighlightColor {
hex: string;
label?: string;
}
export type ReadingRulerColor = 'transparent' | 'yellow' | 'green' | 'blue' | 'rose';
export interface ParagraphModeConfig {
@@ -365,6 +372,8 @@ export interface BookConfig {
lastSyncedAtConfig?: number;
lastSyncedAtNotes?: number;
lastPushedAtConfig?: number;
lastPushedAtNotes?: number;
foliateImportedAt?: number;
// Per-book switch for hardcover exports in reader menu.
+3 -2
View File
@@ -1,7 +1,7 @@
import { CustomTheme } from '@/styles/themes';
import { CustomFont } from '@/styles/fonts';
import { CustomTexture } from '@/styles/textures';
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
import { HighlightColor, HighlightStyle, UserHighlightColor, ViewSettings } from './book';
import { OPDSCatalog } from './opds';
import type { AISettings } from '@/services/ai/types';
import type { NotebookTab } from '@/store/notebookStore';
@@ -48,7 +48,8 @@ export interface ReadSettings {
highlightStyle: HighlightStyle;
highlightStyles: Record<HighlightStyle, HighlightColor>;
customHighlightColors: Record<HighlightColor, string>;
userHighlightColors: string[];
userHighlightColors: UserHighlightColor[];
defaultHighlightLabels: Partial<Record<HighlightColor, string>>;
customTtsHighlightColors: string[];
customThemes: CustomTheme[];
}
+4 -3
View File
@@ -128,6 +128,7 @@ const getColorStyles = (
isEink: boolean,
) => {
const { bg, fg, primary, isDarkMode } = themeCode;
const hasBackgroundTexture = !!backgroundTextureId && backgroundTextureId !== 'none';
const colorStyles = `
html {
--bg-texture-id: ${backgroundTextureId};
@@ -153,9 +154,9 @@ const getColorStyles = (
}
section, aside, blockquote, article, nav, header, footer, main, figure,
div, p, font, h1, h2, h3, h4, h5, h6, li, span {
${overrideColor ? `background-color: ${bg} !important;` : ''}
${overrideColor ? `color: ${fg} !important;` : ''}
${overrideColor ? `border-color: ${fg} !important;` : ''}
${overrideColor && !hasBackgroundTexture ? `background-color: ${bg} !important;` : ''}
${overrideColor && !hasBackgroundTexture ? `color: ${fg} !important;` : ''}
${overrideColor && !hasBackgroundTexture ? `border-color: ${fg} !important;` : ''}
}
pre, span { /* inline code blocks */
${overrideColor ? `background-color: ${bg} !important;` : ''}
+1 -1
View File
@@ -90,7 +90,7 @@ export const validateAndNormalizeDate = (dateInput: string): ValidationResult<st
}
// Check if year is reasonable (between 1000 and current year + 10)
const year = date.getFullYear();
const year = parseInt(cleaned.substring(0, 4));
const currentYear = new Date().getFullYear();
if (year < 1000 || year > currentYear + 10) {
return {
+13 -20
View File
@@ -59,6 +59,19 @@
<launchable type="desktop-id">com.bilingify.readest.desktop</launchable>
<releases>
<release version="0.10.4" date="2026-04-06">
<description>
<ul>
<li>Annotations: You can now name your highlight colors, and your custom names sync across devices</li>
<li>Annotations: Fixed highlights that stayed on the page after being deleted from the sidebar</li>
<li>Speed Reading: Added an option to split long words, a faster countdown</li>
<li>Text-to-Speech: Fixed double playback when tapping the play button in quick succession</li>
<li>Accessibility: Dropdown menus now open reliably when activated with the keyboard</li>
<li>Reading: Background textures now display correctly when you override the theme background color</li>
<li>Translation: Temporarily disabled the Yandex provider while its service is unavailable</li>
</ul>
</description>
</release>
<release version="0.10.2" date="2026-04-05">
<description>
<ul>
@@ -225,26 +238,6 @@
</ul>
</description>
</release>
<release version="0.9.94" date="2025-12-02">
<description>
<ul>
<li>OPDS: You can now browse and download ebooks directly from OPDS catalogs</li>
<li>PDF: Added a hand-tool mode that makes navigating PDF pages easier</li>
<li>Covers: You can now choose a folder to save the latest book cover image</li>
<li>CJK: Added one-tap conversion between Simplified and Traditional Chinese</li>
<li>Shortcuts: Use Ctrl + mouse wheel to quickly zoom in or out</li>
<li>Library: Added a new option to adjust cover sizes in grid view</li>
<li>Windows: Added Ebook thumbnails with book covers in Windows Explorer</li>
<li>TTS: Fixed an issue where the page would scroll when showing the annotator</li>
<li>Annotations: Resolved layout shifts when selecting text in paginated mode on Android</li>
<li>Files: Improved backup handling to prevent file corruption</li>
<li>Layout: Fixed text clipping caused by negative indents</li>
<li>E-ink: Improved sidebar and notebook background colors for e-ink devices</li>
<li>Android: The Back button now works properly in menus, dialogs, and alerts</li>
<li>iOS: Fixed occasional failures when opening books on older iOS versions (16.4 and below)</li>
</ul>
</description>
</release>
</releases>
<metadata_license>FSFAP</metadata_license>
+1 -1
View File
@@ -1 +1 @@
a2af38bc082e42422a19ddbd1c26117c89ea63221eb8679b5e3796721e5297d5 ../../data/metainfo/appdata.xml
35baf372956f9ab7e408d95c412a7753241cc7e7641874b4d336a1344b13e15b ../../data/metainfo/appdata.xml
+58 -58
View File
@@ -95,7 +95,7 @@ importers:
version: 1.1.1
'@opennextjs/cloudflare':
specifier: ^1.17.3
version: 1.17.3(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.77.0)
version: 1.17.3(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.77.0)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -128,7 +128,7 @@ importers:
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@serwist/next':
specifier: ^9.5.6
version: 9.5.6(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1)
version: 9.5.6(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1)
'@serwist/webpack-plugin':
specifier: ^9.5.6
version: 9.5.6(browserslist@4.28.1)(typescript@5.9.3)(webpack@5.104.1)
@@ -292,11 +292,11 @@ importers:
specifier: ^5.1.6
version: 5.1.6
next:
specifier: 16.2.1
version: 16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: 16.2.2
version: 16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-view-transitions:
specifier: ^0.3.5
version: 0.3.5(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 0.3.5(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
nunjucks:
specifier: ^3.2.4
version: 3.2.4(chokidar@3.6.0)
@@ -522,7 +522,7 @@ importers:
version: 5.9.3
vinext:
specifier: ^0.0.21
version: 0.0.21(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)
version: 0.0.21(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)
vite:
specifier: '>=7.3.1'
version: 7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
@@ -2178,57 +2178,57 @@ packages:
'@next/bundle-analyzer@15.5.9':
resolution: {integrity: sha512-lT1EBpFyGVN9u8M43f2jE78DsCu0A5KPA5OkF5PdIHrKDo4oTJ4lUQKciA9T2u9gccSXIPQcZb5TYkHF4f8iiw==}
'@next/env@16.2.1':
resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==}
'@next/env@16.2.2':
resolution: {integrity: sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==}
'@next/swc-darwin-arm64@16.2.1':
resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==}
'@next/swc-darwin-arm64@16.2.2':
resolution: {integrity: sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.2.1':
resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==}
'@next/swc-darwin-x64@16.2.2':
resolution: {integrity: sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.2.1':
resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==}
'@next/swc-linux-arm64-gnu@16.2.2':
resolution: {integrity: sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.1':
resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==}
'@next/swc-linux-arm64-musl@16.2.2':
resolution: {integrity: sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.2.1':
resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==}
'@next/swc-linux-x64-gnu@16.2.2':
resolution: {integrity: sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.2.1':
resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==}
'@next/swc-linux-x64-musl@16.2.2':
resolution: {integrity: sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.1':
resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==}
'@next/swc-win32-arm64-msvc@16.2.2':
resolution: {integrity: sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.2.1':
resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==}
'@next/swc-win32-x64-msvc@16.2.2':
resolution: {integrity: sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -6778,8 +6778,8 @@ packages:
react: '>=18.2.0 || ^19.0.0'
react-dom: '>=18.2.0 || ^19.0.0'
next@16.2.1:
resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==}
next@16.2.2:
resolution: {integrity: sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -10732,30 +10732,30 @@ snapshots:
- bufferutil
- utf-8-validate
'@next/env@16.2.1': {}
'@next/env@16.2.2': {}
'@next/swc-darwin-arm64@16.2.1':
'@next/swc-darwin-arm64@16.2.2':
optional: true
'@next/swc-darwin-x64@16.2.1':
'@next/swc-darwin-x64@16.2.2':
optional: true
'@next/swc-linux-arm64-gnu@16.2.1':
'@next/swc-linux-arm64-gnu@16.2.2':
optional: true
'@next/swc-linux-arm64-musl@16.2.1':
'@next/swc-linux-arm64-musl@16.2.2':
optional: true
'@next/swc-linux-x64-gnu@16.2.1':
'@next/swc-linux-x64-gnu@16.2.2':
optional: true
'@next/swc-linux-x64-musl@16.2.1':
'@next/swc-linux-x64-musl@16.2.2':
optional: true
'@next/swc-win32-arm64-msvc@16.2.1':
'@next/swc-win32-arm64-msvc@16.2.2':
optional: true
'@next/swc-win32-x64-msvc@16.2.1':
'@next/swc-win32-x64-msvc@16.2.2':
optional: true
'@noble/ciphers@1.3.0': {}
@@ -10793,7 +10793,7 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@opennextjs/aws@3.9.16(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
'@opennextjs/aws@3.9.16(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
dependencies:
'@ast-grep/napi': 0.40.5
'@aws-sdk/client-cloudfront': 3.984.0
@@ -10809,7 +10809,7 @@ snapshots:
cookie: 1.1.1
esbuild: 0.25.4
express: 5.2.1
next: 16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
path-to-regexp: 8.4.0
urlpattern-polyfill: 10.1.0
yaml: 2.8.2
@@ -10817,16 +10817,16 @@ snapshots:
- aws-crt
- supports-color
'@opennextjs/cloudflare@1.17.3(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.77.0)':
'@opennextjs/cloudflare@1.17.3(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.77.0)':
dependencies:
'@ast-grep/napi': 0.40.5
'@dotenvx/dotenvx': 1.31.0
'@opennextjs/aws': 3.9.16(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
'@opennextjs/aws': 3.9.16(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
cloudflare: 4.5.0
comment-json: 4.6.2
enquirer: 2.4.1
glob: 13.0.0
next: 16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ts-tqdm: 0.8.6
wrangler: 4.77.0
yargs: 18.0.0
@@ -11548,7 +11548,7 @@ snapshots:
transitivePeerDependencies:
- browserslist
'@serwist/next@9.5.6(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1)':
'@serwist/next@9.5.6(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1)':
dependencies:
'@serwist/build': 9.5.6(browserslist@4.28.1)(typescript@5.9.3)
'@serwist/utils': 9.5.6(browserslist@4.28.1)
@@ -11557,7 +11557,7 @@ snapshots:
browserslist: 4.28.1
glob: 13.0.0
kolorist: 1.8.0
next: 16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
semver: 7.7.3
serwist: 9.5.6(browserslist@4.28.1)(typescript@5.9.3)
@@ -12715,13 +12715,13 @@ snapshots:
dependencies:
unpic: 4.2.2
'@unpic/react@1.0.2(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@unpic/react@1.0.2(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@unpic/core': 1.0.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
next: 16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/og@0.8.6':
dependencies:
@@ -16158,15 +16158,15 @@ snapshots:
netmask@2.0.2: {}
next-view-transitions@0.3.5(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next-view-transitions@0.3.5(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
next: 16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.1
'@next/env': 16.2.2
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.9
caniuse-lite: 1.0.30001766
@@ -16175,14 +16175,14 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.1
'@next/swc-darwin-x64': 16.2.1
'@next/swc-linux-arm64-gnu': 16.2.1
'@next/swc-linux-arm64-musl': 16.2.1
'@next/swc-linux-x64-gnu': 16.2.1
'@next/swc-linux-x64-musl': 16.2.1
'@next/swc-win32-arm64-msvc': 16.2.1
'@next/swc-win32-x64-msvc': 16.2.1
'@next/swc-darwin-arm64': 16.2.2
'@next/swc-darwin-x64': 16.2.2
'@next/swc-linux-arm64-gnu': 16.2.2
'@next/swc-linux-arm64-musl': 16.2.2
'@next/swc-linux-x64-gnu': 16.2.2
'@next/swc-linux-x64-musl': 16.2.2
'@next/swc-win32-arm64-msvc': 16.2.2
'@next/swc-win32-x64-msvc': 16.2.2
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.58.1
sharp: 0.34.5
@@ -17927,9 +17927,9 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vinext@0.0.21(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1):
vinext@0.0.21(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1):
dependencies:
'@unpic/react': 1.0.2(next@16.2.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@unpic/react': 1.0.2(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/og': 0.8.6
'@vitejs/plugin-rsc': 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21