forked from akai/readest
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3174e341a3 | |||
| 298d4872a0 | |||
| b679817fce | |||
| b9a2b10fac | |||
| a9e33ca50a | |||
| 09d1e0c040 | |||
| 8b10e7fb17 | |||
| a2d17e6a79 | |||
| 7e62516b5d | |||
| d53f3b42e2 | |||
| d682fcbb44 | |||
| b3fe33221d | |||
| fbfd7fd6c6 |
@@ -36,6 +36,7 @@ const options = {
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'ro',
|
||||
'hu',
|
||||
],
|
||||
ns: ['translation'],
|
||||
defaultNs: 'translation',
|
||||
|
||||
@@ -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": "切換工具列"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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')}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,7 @@ const SUPPORTED_LNGS = [
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'ro',
|
||||
'hu',
|
||||
];
|
||||
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;` : ''}
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
a2af38bc082e42422a19ddbd1c26117c89ea63221eb8679b5e3796721e5297d5 ../../data/metainfo/appdata.xml
|
||||
35baf372956f9ab7e408d95c412a7753241cc7e7641874b4d336a1344b13e15b ../../data/metainfo/appdata.xml
|
||||
|
||||
Generated
+58
-58
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user