feat: add fuzzy search for searching across all settings (#3085)

* feat: enable linking to settings items

* feat: integrate command palette with global state and styles

* feat: add command pallete ui and fuzzy search

* feat: add command palette with global state and styles

* feat: add command registry and search dependencies

* fix: open command palette with shortcuts in reader page

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
Mohammed Efaz
2026-01-26 18:32:04 +01:00
committed by GitHub
parent 64a1ea531a
commit a52d9e3a2b
48 changed files with 2096 additions and 124 deletions
+1
View File
@@ -109,6 +109,7 @@
"dompurify": "^3.3.0",
"foliate-js": "workspace:*",
"franc-min": "^6.2.0",
"fzf": "^0.5.2",
"google-auth-library": "^10.5.0",
"googleapis": "^164.1.0",
"highlight.js": "^11.11.1",
@@ -982,5 +982,21 @@
"Reading Ruler": "مسطرة القراءة",
"Enable Reading Ruler": "تفعيل مسطرة القراءة",
"Lines to Highlight": "الأسطر المراد تمييزها",
"Ruler Color": "لون المسطرة"
"Ruler Color": "لون المسطرة",
"Command Palette": "لوحة الأوامر",
"Search settings and actions...": "البحث في الإعدادات والإجراءات...",
"No results found for": "لم يتم العثور على نتائج لـ",
"Type to search settings and actions": "اكتب للبحث في الإعدادات والإجراءات",
"Recent": "الأخير",
"navigate": "التنقل",
"select": "اختيار",
"close": "إغلاق",
"Search Settings": "البحث في الإعدادات",
"Page Margins": "هوامش الصفحة",
"AI Provider": "مزود الذكاء الاصطناعي",
"Ollama URL": "رابط Ollama",
"Ollama Model": "نموذج Ollama",
"AI Gateway Model": "نموذج بوابة الذكاء الاصطناعي",
"Actions": "الإجراءات",
"Navigation": "التنقل"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "রিডিং রুলার",
"Enable Reading Ruler": "রিডিং রুলার সক্ষম করুন",
"Lines to Highlight": "হাইলাইট করার জন্য লাইন",
"Ruler Color": "রুলারের রঙ"
"Ruler Color": "রুলারের রঙ",
"Command Palette": "কমান্ড প্যালেট",
"Search settings and actions...": "সেটিংস এবং অ্যাকশন খুঁজুন...",
"No results found for": "এর জন্য কোনো ফলাফল পাওয়া যায়নি",
"Type to search settings and actions": "সেটিংস এবং অ্যাকশন খুঁজতে টাইপ করুন",
"Recent": "সাম্প্রতিক",
"navigate": "নেভিগেট",
"select": "নির্বাচন করুন",
"close": "বন্ধ করুন",
"Search Settings": "সেটিংস খুঁজুন",
"Page Margins": "পৃষ্ঠার মার্জিন",
"AI Provider": "AI প্রদানকারী",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama মডেল",
"AI Gateway Model": "AI গেটওয়ে মডেল",
"Actions": "অ্যাকশন",
"Navigation": "নেভিগেশন"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "ལྟ་ཀློག་ཐིག་ཤིང་།",
"Enable Reading Ruler": "ལྟ་ཀློག་ཐིག་ཤིང་སྤྱོད་པ།",
"Lines to Highlight": "མངོན་གསལ་དུ་གཏོང་དགོས་པའི་ཐིག་ཕྲེང་།",
"Ruler Color": "ཐིག་ཤིང་ගི་ཚོན་མདོག"
"Ruler Color": "ཐིག་ཤིང་ගི་ཚོན་མདོག",
"Command Palette": "བཀོད་འདོམས་པང་ལེབ།",
"Search settings and actions...": "སྒྲིག་བཀོད་དང་བྱ་འགུལ་འཚོལ་བ།...",
"No results found for": "འབྲས་བུ་མ་རྙེད་པ།",
"Type to search settings and actions": "ཡིག་གཟུགས་ནང་འཇུག་བྱས་ཏེ་སྒྲིག་བཀོད་དང་བྱ་འགུལ་འཚོལ་བ།",
"Recent": "ཉེ་ཆར།",
"navigate": "འགྲུལ་བཞུད།",
"select": "འདེམས་པ།",
"close": "ཁ་རྒྱག་པ།",
"Search Settings": "སྒྲིག་བཀོད་འཚོལ་བ།",
"Page Margins": "ཤོག་ངོས་མཐའ་འགྲམ།",
"AI Provider": "ནུས་པའི་རིག་རྩལ་མཁོ་སྤྲོད་པ།",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama དཔེ་གཞི།",
"AI Gateway Model": "AI Gateway དཔེ་གཞི།",
"Actions": "བྱ་འགུལ།",
"Navigation": "འགྲུལ་བཞུད།"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "Lese-Lineal",
"Enable Reading Ruler": "Lese-Lineal aktivieren",
"Lines to Highlight": "Hervorzuhebende Zeilen",
"Ruler Color": "Linealfarbe"
"Ruler Color": "Linealfarbe",
"Command Palette": "Befehlspalette",
"Search settings and actions...": "Einstellungen und Aktionen suchen...",
"No results found for": "Keine Ergebnisse gefunden für",
"Type to search settings and actions": "Tippen, um Einstellungen und Aktionen zu suchen",
"Recent": "Zuletzt verwendet",
"navigate": "navigieren",
"select": "auswählen",
"close": "schließen",
"Search Settings": "Einstellungen suchen",
"Page Margins": "Seitenränder",
"AI Provider": "KI-Anbieter",
"Ollama URL": "Ollama-URL",
"Ollama Model": "Ollama-Modell",
"AI Gateway Model": "AI Gateway-Modell",
"Actions": "Aktionen",
"Navigation": "Navigation"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "Χάρακας ανάγνωσης",
"Enable Reading Ruler": "Ενεργοποίηση χάρακα ανάγνωσης",
"Lines to Highlight": "Γραμμές για επισήμανση",
"Ruler Color": "Χρώμα χάρακα"
"Ruler Color": "Χρώμα χάρακα",
"Command Palette": "Παλέτα εντολών",
"Search settings and actions...": "Αναζήτηση ρυθμίσεων και ενεργειών...",
"No results found for": "Δεν βρέθηκαν αποτελέσματα για",
"Type to search settings and actions": "Πληκτρολογήστε για αναζήτηση ρυθμίσεων και ενεργειών",
"Recent": "Πρόσφατα",
"navigate": "πλοήγηση",
"select": "επιλογή",
"close": "κλείσιμο",
"Search Settings": "Αναζήτηση ρυθμίσεων",
"Page Margins": "Περιθώρια σελίδας",
"AI Provider": "Πάροχος AI",
"Ollama URL": "URL Ollama",
"Ollama Model": "Μοντέλο Ollama",
"AI Gateway Model": "Μοντέλο AI Gateway",
"Actions": "Ενέργειες",
"Navigation": "Πλοήγηση"
}
@@ -952,5 +952,21 @@
"Reading Ruler": "Regla de lectura",
"Enable Reading Ruler": "Habilitar regla de lectura",
"Lines to Highlight": "Líneas a resaltar",
"Ruler Color": "Color de la regla"
"Ruler Color": "Color de la regla",
"Command Palette": "Paleta de comandos",
"Search settings and actions...": "Buscar ajustes y acciones...",
"No results found for": "No se encontraron resultados para",
"Type to search settings and actions": "Escribe para buscar ajustes y acciones",
"Recent": "Reciente",
"navigate": "navegar",
"select": "seleccionar",
"close": "cerrar",
"Search Settings": "Buscar ajustes",
"Page Margins": "Márgenes de página",
"AI Provider": "Proveedor de IA",
"Ollama URL": "URL de Ollama",
"Ollama Model": "Modelo de Ollama",
"AI Gateway Model": "Modelo de AI Gateway",
"Actions": "Acciones",
"Navigation": "Navegación"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "خط‌کش مطالعه",
"Enable Reading Ruler": "فعال‌سازی خط‌کش مطالعه",
"Lines to Highlight": "خطوط برای تمایز",
"Ruler Color": "رنگ خط‌کش"
"Ruler Color": "رنگ خط‌کش",
"Command Palette": "پالت فرمان",
"Search settings and actions...": "جستجوی تنظیمات و عملیات...",
"No results found for": "نتیجه‌ای برای این مورد یافت نشد",
"Type to search settings and actions": "برای جستجوی تنظیمات و عملیات تایپ کنید",
"Recent": "اخیر",
"navigate": "ناوبری",
"select": "انتخاب",
"close": "بستن",
"Search Settings": "جستجوی تنظیمات",
"Page Margins": "حاشیه‌های صفحه",
"AI Provider": "ارائه دهنده هوش مصنوعی",
"Ollama URL": "آدرس Ollama",
"Ollama Model": "مدل Ollama",
"AI Gateway Model": "مدل AI Gateway",
"Actions": "عملیات",
"Navigation": "ناوبری"
}
@@ -952,5 +952,21 @@
"Reading Ruler": "Règle de lecture",
"Enable Reading Ruler": "Activer la règle de lecture",
"Lines to Highlight": "Lignes à mettre en évidence",
"Ruler Color": "Couleur de la règle"
"Ruler Color": "Couleur de la règle",
"Command Palette": "Palette de commandes",
"Search settings and actions...": "Rechercher des paramètres et des actions...",
"No results found for": "Aucun résultat trouvé pour",
"Type to search settings and actions": "Saisissez pour rechercher des paramètres et des actions",
"Recent": "Récent",
"navigate": "naviguer",
"select": "sélectionner",
"close": "fermer",
"Search Settings": "Rechercher dans les paramètres",
"Page Margins": "Marges de page",
"AI Provider": "Fournisseur d'IA",
"Ollama URL": "URL Ollama",
"Ollama Model": "Modèle Ollama",
"AI Gateway Model": "Modèle AI Gateway",
"Actions": "Actions",
"Navigation": "Navigation"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "पठन रूलर",
"Enable Reading Ruler": "पठन रूलर सक्षम करें",
"Lines to Highlight": "हाइलाइट करने के लिए पंक्तियाँ",
"Ruler Color": "रूलर का रंग"
"Ruler Color": "रूलर का रंग",
"Command Palette": "कमांड पैलेट",
"Search settings and actions...": "सेटिंग और क्रियाएं खोजें...",
"No results found for": "के लिए कोई परिणाम नहीं मिला",
"Type to search settings and actions": "सेटिंग और क्रियाएं खोजने के लिए टाइप करें",
"Recent": "हालिया",
"navigate": "नेविगेट",
"select": "चुनें",
"close": "बंद करें",
"Search Settings": "सेटिंग खोजें",
"Page Margins": "पेज मार्जिन",
"AI Provider": "AI प्रदाता",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama मॉडल",
"AI Gateway Model": "AI गेटवे मॉडल",
"Actions": "क्रियाएं",
"Navigation": "नेविगेशन"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "Penggaris Membaca",
"Enable Reading Ruler": "Aktifkan Penggaris Membaca",
"Lines to Highlight": "Baris untuk Disorot",
"Ruler Color": "Warna Penggaris"
"Ruler Color": "Warna Penggaris",
"Command Palette": "Palet Perintah",
"Search settings and actions...": "Cari pengaturan dan tindakan...",
"No results found for": "Tidak ada hasil ditemukan untuk",
"Type to search settings and actions": "Ketik untuk mencari pengaturan dan tindakan",
"Recent": "Terbaru",
"navigate": "navigasi",
"select": "pilih",
"close": "tutup",
"Search Settings": "Cari Pengaturan",
"Page Margins": "Margin Halaman",
"AI Provider": "Penyedia AI",
"Ollama URL": "URL Ollama",
"Ollama Model": "Model Ollama",
"AI Gateway Model": "Model AI Gateway",
"Actions": "Tindakan",
"Navigation": "Navigasi"
}
@@ -952,5 +952,21 @@
"Reading Ruler": "Righello di lettura",
"Enable Reading Ruler": "Abilita righello di lettura",
"Lines to Highlight": "Righe da evidenziare",
"Ruler Color": "Colore del righello"
"Ruler Color": "Colore del righello",
"Command Palette": "Tavolozza dei comandi",
"Search settings and actions...": "Cerca impostazioni e azioni...",
"No results found for": "Nessun risultato trovato per",
"Type to search settings and actions": "Digita per cercare impostazioni e azioni",
"Recent": "Recenti",
"navigate": "naviga",
"select": "seleziona",
"close": "chiudi",
"Search Settings": "Cerca impostazioni",
"Page Margins": "Margini della pagina",
"AI Provider": "Fornitore AI",
"Ollama URL": "URL Ollama",
"Ollama Model": "Modello Ollama",
"AI Gateway Model": "Modello AI Gateway",
"Actions": "Azioni",
"Navigation": "Navigazione"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "リーディングルーラー",
"Enable Reading Ruler": "リーディングルーラーを有効にする",
"Lines to Highlight": "ハイライトする行数",
"Ruler Color": "ルーラーの色"
"Ruler Color": "ルーラーの色",
"Command Palette": "コマンドパレット",
"Search settings and actions...": "設定とアクションを検索...",
"No results found for": "の結果が見つかりませんでした",
"Type to search settings and actions": "入力して設定とアクションを検索",
"Recent": "最近使った項目",
"navigate": "ナビゲート",
"select": "選択",
"close": "閉じる",
"Search Settings": "設定を検索",
"Page Margins": "ページ余白",
"AI Provider": "AIプロバイダー",
"Ollama URL": "OllamaのURL",
"Ollama Model": "Ollamaモデル",
"AI Gateway Model": "AI Gatewayモデル",
"Actions": "アクション",
"Navigation": "ナビゲーション"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "읽기 자",
"Enable Reading Ruler": "읽기 자 활성화",
"Lines to Highlight": "강조할 줄 수",
"Ruler Color": "자 색상"
"Ruler Color": "자 색상",
"Command Palette": "명령 팔레트",
"Search settings and actions...": "설정 및 작업 검색...",
"No results found for": "에 대한 결과를 찾을 수 없음",
"Type to search settings and actions": "입력하여 설정 및 작업 검색",
"Recent": "최근",
"navigate": "탐색",
"select": "선택",
"close": "닫기",
"Search Settings": "설정 검색",
"Page Margins": "페이지 여백",
"AI Provider": "AI 제공처",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama 모델",
"AI Gateway Model": "AI 게이트웨이 모델",
"Actions": "작업",
"Navigation": "탐색"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "Pembaris Membaca",
"Enable Reading Ruler": "Dayakan Pembaris Membaca",
"Lines to Highlight": "Baris untuk Diserlahkan",
"Ruler Color": "Warna Pembaris"
"Ruler Color": "Warna Pembaris",
"Command Palette": "Palet Perintah",
"Search settings and actions...": "Cari tetapan dan tindakan...",
"No results found for": "Tiada hasil dijumpai untuk",
"Type to search settings and actions": "Taip untuk mencari tetapan dan tindakan",
"Recent": "Baru-baru ini",
"navigate": "navigasi",
"select": "pilih",
"close": "tutup",
"Search Settings": "Cari Tetapan",
"Page Margins": "Margin Halaman",
"AI Provider": "Penyedia AI",
"Ollama URL": "URL Ollama",
"Ollama Model": "Model Ollama",
"AI Gateway Model": "Model AI Gateway",
"Actions": "Tindakan",
"Navigation": "Navigasi"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "Leesliniaal",
"Enable Reading Ruler": "Leesliniaal inschakelen",
"Lines to Highlight": "Regels om te markeren",
"Ruler Color": "Kleur van liniaal"
"Ruler Color": "Kleur van liniaal",
"Command Palette": "Opdrachtenpalet",
"Search settings and actions...": "Zoek instellingen en acties...",
"No results found for": "Geen resultaten gevonden voor",
"Type to search settings and actions": "Typ om instellingen en acties te zoeken",
"Recent": "Recent",
"navigate": "navigeren",
"select": "selecteren",
"close": "sluiten",
"Search Settings": "Instellingen zoeken",
"Page Margins": "Paginamarges",
"AI Provider": "AI-provider",
"Ollama URL": "Ollama-URL",
"Ollama Model": "Ollama-model",
"AI Gateway Model": "AI Gateway-model",
"Actions": "Acties",
"Navigation": "Navigatie"
}
@@ -962,5 +962,21 @@
"Reading Ruler": "Linijka do czytania",
"Enable Reading Ruler": "Włącz linijkę do czytania",
"Lines to Highlight": "Linie do wyróżnienia",
"Ruler Color": "Kolor linijki"
"Ruler Color": "Kolor linijki",
"Command Palette": "Paleta poleceń",
"Search settings and actions...": "Szukaj ustawień i akcji...",
"No results found for": "Brak wyników dla",
"Type to search settings and actions": "Wpisz, aby szukać ustawień i akcji",
"Recent": "Ostatnie",
"navigate": "nawiguj",
"select": "wybierz",
"close": "zamknij",
"Search Settings": "Szukaj ustawień",
"Page Margins": "Marginesy strony",
"AI Provider": "Dostawca AI",
"Ollama URL": "URL Ollama",
"Ollama Model": "Model Ollama",
"AI Gateway Model": "Model AI Gateway",
"Actions": "Akcje",
"Navigation": "Nawigacja"
}
@@ -952,5 +952,21 @@
"Reading Ruler": "Régua de leitura",
"Enable Reading Ruler": "Ativar régua de leitura",
"Lines to Highlight": "Linhas para destacar",
"Ruler Color": "Cor da régua"
"Ruler Color": "Cor da régua",
"Command Palette": "Paleta de Comandos",
"Search settings and actions...": "Pesquisar definições e ações...",
"No results found for": "Nenhum resultado encontrado para",
"Type to search settings and actions": "Digite para pesquisar definições e ações",
"Recent": "Recente",
"navigate": "navegar",
"select": "selecionar",
"close": "fechar",
"Search Settings": "Pesquisar Definições",
"Page Margins": "Margens da Página",
"AI Provider": "Fornecedor de IA",
"Ollama URL": "URL do Ollama",
"Ollama Model": "Modelo do Ollama",
"AI Gateway Model": "Modelo de AI Gateway",
"Actions": "Ações",
"Navigation": "Navegação"
}
@@ -962,5 +962,21 @@
"Reading Ruler": "Линейка для чтения",
"Enable Reading Ruler": "Включить линейку для чтения",
"Lines to Highlight": "Строк для выделения",
"Ruler Color": "Цвет линейки"
"Ruler Color": "Цвет линейки",
"Command Palette": "Палитра команд",
"Search settings and actions...": "Поиск настроек и действий...",
"No results found for": "Результатов не найдено для",
"Type to search settings and actions": "Введите для поиска настроек и действий",
"Recent": "Недавние",
"navigate": "навигация",
"select": "выбрать",
"close": "закрыть",
"Search Settings": "Поиск настроек",
"Page Margins": "Поля страницы",
"AI Provider": "Провайдер ИИ",
"Ollama URL": "URL Ollama",
"Ollama Model": "Модель Ollama",
"AI Gateway Model": "Модель AI Gateway",
"Actions": "Действия",
"Navigation": "Навигация"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "කියවීමේ රූල",
"Enable Reading Ruler": "කියවීමේ රූල සක්‍රීය කරන්න",
"Lines to Highlight": "මතුකර පෙන්විය යුතු පේළි",
"Ruler Color": "රූලේ වර්ණය"
"Ruler Color": "රූලේ වර්ණය",
"Command Palette": "විධාන පුවරුව",
"Search settings and actions...": "සැකසුම් සහ ක්‍රියා සොයන්න...",
"No results found for": "සඳහා ප්‍රතිඵල හමු නොවීය",
"Type to search settings and actions": "සැකසුම් සහ ක්‍රියා සෙවීමට ටයිප් කරන්න",
"Recent": "මෑත කාලීන",
"navigate": "සංචාලනය",
"select": "තෝරන්න",
"close": "වසා දමන්න",
"Search Settings": "සැකසුම් සොයන්න",
"Page Margins": "පිටු මායිම්",
"AI Provider": "AI සපයන්නා",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama ආකෘතිය",
"AI Gateway Model": "AI ਗੇਟਵੇ ਆਕෘතිය",
"Actions": "ක්‍රියා",
"Navigation": "සංචාලනය"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "Läs-linjal",
"Enable Reading Ruler": "Aktivera läs-linjal",
"Lines to Highlight": "Rader att markera",
"Ruler Color": "Linjalens färg"
"Ruler Color": "Linjalens färg",
"Command Palette": "Kommandopalett",
"Search settings and actions...": "Sök inställningar och åtgärder...",
"No results found for": "Inga resultat hittades för",
"Type to search settings and actions": "Skriv för att söka inställningar och åtgärder",
"Recent": "Senaste",
"navigate": "navigera",
"select": "välj",
"close": "stäng",
"Search Settings": "Sök inställningar",
"Page Margins": "Sidmarginaler",
"AI Provider": "AI-leverantör",
"Ollama URL": "Ollama-URL",
"Ollama Model": "Ollama-modell",
"AI Gateway Model": "AI Gateway-modell",
"Actions": "Åtgärder",
"Navigation": "Navigering"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "வாசிப்பு அளவுகோல்",
"Enable Reading Ruler": "வாசிப்பு அளவுகோலை இயக்கு",
"Lines to Highlight": "சிறப்பித்துக் காட்ட வேண்டிய வரிகள்",
"Ruler Color": "அளவுகோல் வண்ணம்"
"Ruler Color": "அளவுகோல் வண்ணம்",
"Command Palette": "கட்டளை தட்டு",
"Search settings and actions...": "அமைப்புகள் மற்றும் செயல்களைத் தேடுங்கள்...",
"No results found for": "இதற்கு முடிவுகள் எதுவும் கிடைக்கவில்லை",
"Type to search settings and actions": "அமைப்புகள் மற்றும் செயல்களைத் தேட தட்டச்சு செய்க",
"Recent": "சமீபத்திய",
"navigate": "வழிசெலுத்து",
"select": "தேர்ந்தெடு",
"close": "மூடு",
"Search Settings": "அமைப்புகளைத் தேடு",
"Page Margins": "பக்க விளிம்புகள்",
"AI Provider": "AI வழங்குநர்",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama மாதிரி",
"AI Gateway Model": "AI கேட்வே மாதிரி",
"Actions": "செயல்கள்",
"Navigation": "வழிசெலுத்தல்"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "ไม้บรรทัดการอ่าน",
"Enable Reading Ruler": "เปิดใช้งานไม้บรรทัดการอ่าน",
"Lines to Highlight": "บรรทัดที่ต้องการเน้น",
"Ruler Color": "สีไม้บรรทัด"
"Ruler Color": "สีไม้บรรทัด",
"Command Palette": "พาเลทคำสั่ง",
"Search settings and actions...": "ค้นหาการตั้งค่าและคำสั่ง...",
"No results found for": "ไม่พบผลลัพธ์สำหรับ",
"Type to search settings and actions": "พิมพ์เพื่อค้นหาการตั้งค่าและคำสั่ง",
"Recent": "ล่าสุด",
"navigate": "นำทาง",
"select": "เลือก",
"close": "ปิด",
"Search Settings": "ค้นหาการตั้งค่า",
"Page Margins": "ระยะขอบหน้า",
"AI Provider": "ผู้ให้บริการ AI",
"Ollama URL": "Ollama URL",
"Ollama Model": "โมเดล Ollama",
"AI Gateway Model": "โมเดล AI Gateway",
"Actions": "การกระทำ",
"Navigation": "การนำทาง"
}
@@ -942,5 +942,21 @@
"Reading Ruler": "Okuma Cetveli",
"Enable Reading Ruler": "Okuma Cetvelini Etkinleştir",
"Lines to Highlight": "Vurgulanacak Satırlar",
"Ruler Color": "Cetvel Rengi"
"Ruler Color": "Cetvel Rengi",
"Command Palette": "Komut Paleti",
"Search settings and actions...": "Ayarları ve eylemleri ara...",
"No results found for": "Şunun için sonuç bulunamadı",
"Type to search settings and actions": "Ayarları ve eylemleri aramak için yazın",
"Recent": "Son kullanılanlar",
"navigate": "gezin",
"select": "seç",
"close": "kapat",
"Search Settings": "Ayarları Ara",
"Page Margins": "Sayfa Kenar Boşlukları",
"AI Provider": "Yapay Zeka Sağlayıcısı",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama Modeli",
"AI Gateway Model": "AI Gateway Modeli",
"Actions": "Eylemler",
"Navigation": "Gezين"
}
@@ -962,5 +962,21 @@
"Reading Ruler": "Лінійка для читання",
"Enable Reading Ruler": "Увімкнути лінійку для читання",
"Lines to Highlight": "Рядки для виділення",
"Ruler Color": "Коліර් лінійки"
"Ruler Color": "Коліර් лінійки",
"Command Palette": "Палітра команд",
"Search settings and actions...": "Пошук налаштувань та дій...",
"No results found for": "Результатів не знайдено для",
"Type to search settings and actions": "Введіть для пошуку налаштувань та дій",
"Recent": "Нещодавні",
"navigate": "навігація",
"select": "вибрати",
"close": "закрити",
"Search Settings": "Пошук налаштувань",
"Page Margins": "Поля сторінки",
"AI Provider": "Провайдер ШІ",
"Ollama URL": "URL Ollama",
"Ollama Model": "Модель Ollama",
"AI Gateway Model": "Модель AI Gateway",
"Actions": "Дії",
"Navigation": "Навігація"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "Thước đọc",
"Enable Reading Ruler": "Bật thước đọc",
"Lines to Highlight": "Số dòng cần làm nổi bật",
"Ruler Color": "Màu thước"
"Ruler Color": "Màu thước",
"Command Palette": "Bảng lệnh",
"Search settings and actions...": "Tìm kiếm cài đặt và hành động...",
"No results found for": "Không tìm thấy kết quả cho",
"Type to search settings and actions": "Nhập để tìm kiếm cài đặt và hành động",
"Recent": "Gần đây",
"navigate": "điều hướng",
"select": "chọn",
"close": "đóng",
"Search Settings": "Tìm kiếm cài đặt",
"Page Margins": "Lề trang",
"AI Provider": "Nhà cung cấp AI",
"Ollama URL": "URL Ollama",
"Ollama Model": "Mô hình Ollama",
"AI Gateway Model": "Mô hình AI Gateway",
"Actions": "Hành động",
"Navigation": "Điều hướng"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "阅读尺",
"Enable Reading Ruler": "启用阅读尺",
"Lines to Highlight": "突出显示行数",
"Ruler Color": "尺子颜色"
"Ruler Color": "尺子颜色",
"Command Palette": "命令面板",
"Search settings and actions...": "搜索设置和操作...",
"No results found for": "未找到结果:",
"Type to search settings and actions": "输入以搜索设置和操作",
"Recent": "最近",
"navigate": "导航",
"select": "选择",
"close": "关闭",
"Search Settings": "搜索设置",
"Page Margins": "页边距",
"AI Provider": "AI 服务商",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama 模型",
"AI Gateway Model": "AI Gateway 模型",
"Actions": "操作",
"Navigation": "导航"
}
@@ -932,5 +932,21 @@
"Reading Ruler": "閱讀尺",
"Enable Reading Ruler": "啟用閱讀尺",
"Lines to Highlight": "突出顯示行數",
"Ruler Color": "尺子顏色"
"Ruler Color": "尺子顏色",
"Command Palette": "命令面板",
"Search settings and actions...": "搜尋設定和操作...",
"No results found for": "未找到結果:",
"Type to search settings and actions": "輸入以搜尋設定和操作",
"Recent": "最近",
"navigate": "導覽",
"select": "選擇",
"close": "關閉",
"Search Settings": "搜尋設定",
"Page Margins": "頁邊距",
"AI Provider": "AI 服務商",
"Ollama URL": "Ollama URL",
"Ollama Model": "Ollama 模型",
"AI Gateway Model": "AI Gateway 模型",
"Actions": "操作",
"Navigation": "導覽"
}
@@ -5,11 +5,12 @@ import { isTauriAppPlatform } from '@/services/environment';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { getStyles } from '@/utils/style';
import { useCommandPalette } from '@/components/command-palette';
import { tauriHandleClose, tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { viewPagination } from './usePagination';
import { getStyles } from '@/utils/style';
import useShortcuts from '@/hooks/useShortcuts';
import useBooksManager from './useBooksManager';
@@ -25,6 +26,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const { getBookData } = useBookDataStore();
const { toggleNotebook } = useNotebookStore();
const { getNextBookKey } = useBooksManager();
const { open: openCommandPalette } = useCommandPalette();
const viewSettings = getViewSettings(sideBarBookKey ?? '');
const fontSize = viewSettings?.defaultFontSize ?? 16;
const lineHeight = viewSettings?.lineHeight ?? 1.6;
@@ -254,6 +256,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onZoomIn: zoomIn,
onZoomOut: zoomOut,
onResetZoom: resetZoom,
onOpenCommandPalette: openCommandPalette,
},
[sideBarBookKey, bookKeys],
);
@@ -16,6 +16,7 @@ import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getLocale } from '@/utils/misc';
import { getDirFromUILanguage } from '@/utils/rtl';
import { CommandPaletteProvider, CommandPalette } from '@/components/command-palette';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { envConfig, appService } = useEnv();
@@ -67,7 +68,12 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
<CSPostHogProvider>
<AuthProvider>
<IconContext.Provider value={{ size: `${iconSize}px` }}>
<SyncProvider>{children}</SyncProvider>
<SyncProvider>
<CommandPaletteProvider>
{children}
<CommandPalette />
</CommandPaletteProvider>
</SyncProvider>
</IconContext.Provider>
</AuthProvider>
</CSPostHogProvider>
@@ -0,0 +1,315 @@
'use client';
import clsx from 'clsx';
import React, { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import { FiSearch } from 'react-icons/fi';
import { MdClose } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useCommandPalette } from './CommandPaletteProvider';
import { CommandSearchResult, getCategoryLabel, CommandCategory } from '@/services/commandRegistry';
import HighlightChars from './HighlightChars';
const CommandPalette: React.FC = () => {
const _ = useTranslation();
const { isOpen, close, query, setQuery, results, groupedResults, recentItems, executeCommand } =
useCommandPalette();
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
// flatten results for keyboard navigation
const flattenedResults = useMemo(() => {
if (query.trim()) {
return results;
}
// show recent items when no query
return recentItems.map((item) => ({
item,
score: 0,
positions: new Set<number>(),
highlightIndices: new Set<number>(),
}));
}, [query, results, recentItems]);
// reset selection when results change - legitimate derived state reset
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- resetting UI state on data change is intended
setSelectedIndex(0);
}, [flattenedResults.length]);
// focus input and reset selection on open
useEffect(() => {
if (isOpen) {
previousActiveElementRef.current = document.activeElement as HTMLElement;
// eslint-disable-next-line react-hooks/set-state-in-effect -- resetting UI state on modal open is intended
setSelectedIndex(0);
setTimeout(() => {
inputRef.current?.focus();
}, 50);
} else {
if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
previousActiveElementRef.current = null;
}
}
}, [isOpen]);
// scroll selected item into view
useEffect(() => {
if (!listRef.current) return;
const selectedItem = listRef.current.querySelector('[data-selected="true"]');
if (selectedItem) {
selectedItem.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, flattenedResults.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
{
const selected = flattenedResults[selectedIndex];
if (selected) {
executeCommand(selected.item);
}
}
break;
case 'Escape':
e.preventDefault();
close();
break;
case 'Tab':
// trap focus within the palette
e.preventDefault();
break;
}
},
[flattenedResults, selectedIndex, executeCommand, close],
);
const handleItemClick = useCallback(
(result: CommandSearchResult) => {
executeCommand(result.item);
},
[executeCommand],
);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
close();
}
},
[close],
);
if (!isOpen) return null;
const showEmptyState = query.trim() && flattenedResults.length === 0;
const showRecentState = !query.trim() && recentItems.length > 0;
const showNoRecentState = !query.trim() && recentItems.length === 0;
// group results by category for display
const orderedCategories: CommandCategory[] = ['settings', 'actions', 'navigation'];
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className='fixed inset-0 z-[100] flex items-start justify-center bg-black/50 pt-[15vh]'
onClick={handleBackdropClick}
>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div
className='bg-base-100 w-full max-w-lg overflow-hidden rounded-xl shadow-2xl'
role='dialog'
aria-modal='true'
aria-label={_('Command Palette')}
onKeyDown={handleKeyDown}
>
{/* search input */}
<div className='border-base-300 flex items-center border-b px-4'>
<FiSearch className='text-base-content/50 mr-3 h-5 w-5 shrink-0' />
<input
ref={inputRef}
type='text'
className='placeholder:text-base-content/50 h-12 w-full bg-transparent text-base outline-none'
placeholder={_('Search settings and actions...')}
value={query}
onChange={(e) => setQuery(e.target.value)}
spellCheck={false}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
/>
{query && (
<button
className='btn btn-ghost btn-circle btn-sm ml-2'
onClick={() => setQuery('')}
aria-label={_('Clear search')}
>
<MdClose className='h-4 w-4' />
</button>
)}
</div>
{/* results list */}
<div ref={listRef} className='max-h-80 overflow-y-auto p-2' role='listbox'>
{showEmptyState && (
<div className='text-base-content/60 px-4 py-8 text-center text-sm'>
{_('No results found for')} &quot;{query}&quot;
</div>
)}
{showNoRecentState && (
<div className='text-base-content/60 px-4 py-8 text-center text-sm'>
{_('Type to search settings and actions')}
</div>
)}
{showRecentState && (
<div className='mb-1'>
<div className='text-base-content/50 px-3 py-1.5 text-xs font-medium uppercase'>
{_('Recent')}
</div>
{flattenedResults.map((result, index) => (
<CommandResultItem
key={result.item.id}
result={result}
isSelected={index === selectedIndex}
onClick={() => handleItemClick(result)}
onMouseEnter={() => setSelectedIndex(index)}
_={_}
/>
))}
</div>
)}
{query.trim() && flattenedResults.length > 0 && (
<>
{orderedCategories.map((category) => {
const categoryResults = groupedResults[category];
if (categoryResults.length === 0) return null;
const startIndex = flattenedResults.findIndex((r) => r.item.category === category);
return (
<div key={category} className='mb-1'>
<div className='text-base-content/50 px-3 py-1.5 text-xs font-medium uppercase'>
{getCategoryLabel(_, category)}
</div>
{categoryResults.map((result, catIndex) => {
const globalIndex = startIndex + catIndex;
return (
<CommandResultItem
key={result.item.id}
result={result}
isSelected={globalIndex === selectedIndex}
onClick={() => handleItemClick(result)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
_={_}
/>
);
})}
</div>
);
})}
</>
)}
</div>
{/* footer with keyboard hints */}
<div className='border-base-300 text-base-content/50 flex items-center justify-between border-t px-4 py-2 text-xs'>
<div className='flex items-center gap-3'>
<span>
<kbd className='kbd kbd-xs'></kbd> {_('navigate')}
</span>
<span>
<kbd className='kbd kbd-xs'></kbd> {_('select')}
</span>
<span>
<kbd className='kbd kbd-xs'>esc</kbd> {_('close')}
</span>
</div>
</div>
</div>
</div>
);
};
interface CommandResultItemProps {
result: CommandSearchResult;
isSelected: boolean;
onClick: () => void;
onMouseEnter: () => void;
_: (key: string) => string;
}
const CommandResultItem: React.FC<CommandResultItemProps> = ({
result,
isSelected,
onClick,
onMouseEnter,
_,
}) => {
const { item, highlightIndices, matchContext } = result;
const Icon = item.icon;
return (
<button
className={clsx(
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors',
isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-base-200',
)}
onClick={onClick}
onMouseEnter={onMouseEnter}
role='option'
aria-selected={isSelected}
data-selected={isSelected}
>
{Icon && (
<div className='text-base-content/60 flex h-8 w-8 shrink-0 items-center justify-center'>
<Icon className='h-5 w-5' />
</div>
)}
{!Icon && <div className='w-8 shrink-0' />}
<div className='min-w-0 flex-1'>
<div className='truncate text-sm'>
<HighlightChars str={_(item.labelKey)} indices={highlightIndices} />
</div>
{(item.section || matchContext) && (
<div className='text-base-content/50 truncate text-xs'>
{item.panel && <span>{_(item.panelLabel ?? item.panel)}</span>}
{item.panel && item.section && <span> </span>}
{item.section && <span>{_(item.section)}</span>}
{matchContext &&
matchContext !== item.section &&
matchContext !== item.panelLabel &&
matchContext !== item.panel && <span className='ml-2 italic'>({matchContext})</span>}
</div>
)}
</div>
{item.shortcut && item.shortcut.length > 0 && (
<div className='text-base-content/40 ml-auto shrink-0 text-xs'>
{item.shortcut.map((key, i) => (
<kbd key={i} className='kbd kbd-xs ml-1'>
{key}
</kbd>
))}
</div>
)}
</button>
);
};
export default CommandPalette;
@@ -0,0 +1,239 @@
'use client';
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useThemeStore } from '@/store/themeStore';
import { useEnv } from '@/context/EnvContext';
import { isTauriAppPlatform } from '@/services/environment';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { saveSysSettings } from '@/helpers/settings';
import { SettingsPanelType } from '@/components/settings/SettingsDialog';
import {
CommandItem,
buildCommandRegistry,
searchCommands,
CommandSearchResult,
groupResultsByCategory,
trackCommandUsage,
getRecentCommands,
CommandCategory,
} from '@/services/commandRegistry';
interface CommandPaletteContextValue {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
query: string;
setQuery: (query: string) => void;
results: CommandSearchResult[];
groupedResults: Record<CommandCategory, CommandSearchResult[]>;
recentItems: CommandItem[];
executeCommand: (item: CommandItem) => void;
commandItems: CommandItem[];
}
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null);
export const useCommandPalette = (): CommandPaletteContextValue => {
const context = useContext(CommandPaletteContext);
if (!context) {
throw new Error('useCommandPalette must be used within CommandPaletteProvider');
}
return context;
};
interface CommandPaletteProviderProps {
children: React.ReactNode;
}
export const CommandPaletteProvider: React.FC<CommandPaletteProviderProps> = ({ children }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { themeMode, setThemeMode } = useThemeStore();
const { settings, setSettingsDialogOpen, setActiveSettingsItemId } = useSettingsStore();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const isDesktop = isTauriAppPlatform() && !appService?.isMobile;
// action handlers
const toggleTheme = useCallback(() => {
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
setThemeMode(nextMode);
}, [themeMode, setThemeMode]);
const toggleFullscreen = useCallback(() => {
tauriHandleToggleFullScreen();
}, []);
const toggleAlwaysOnTop = useCallback(() => {
const newValue = !settings.alwaysOnTop;
saveSysSettings(envConfig, 'alwaysOnTop', newValue);
tauriHandleSetAlwaysOnTop(newValue);
}, [envConfig, settings.alwaysOnTop]);
const toggleScreenWakeLock = useCallback(() => {
const newValue = !settings.screenWakeLock;
saveSysSettings(envConfig, 'screenWakeLock', newValue);
}, [envConfig, settings.screenWakeLock]);
const toggleAutoUpload = useCallback(() => {
const newValue = !settings.autoUpload;
saveSysSettings(envConfig, 'autoUpload', newValue);
}, [envConfig, settings.autoUpload]);
const reloadPage = useCallback(() => {
window.location.reload();
}, []);
const toggleOpenLastBooks = useCallback(() => {
const newValue = !settings.openLastBooks;
saveSysSettings(envConfig, 'openLastBooks', newValue);
}, [envConfig, settings.openLastBooks]);
const showAbout = useCallback(() => {
setAboutDialogVisible(true);
}, []);
const toggleTelemetry = useCallback(() => {
const newValue = !settings.telemetryEnabled;
saveSysSettings(envConfig, 'telemetryEnabled', newValue);
}, [envConfig, settings.telemetryEnabled]);
const openSettingsPanel = useCallback(
(_panel: SettingsPanelType, itemId?: string) => {
// panel is encoded in itemId (e.g., 'settings.font.defaultFontSize')
// SettingsDialog will parse this to determine which panel to show
if (itemId) {
setActiveSettingsItemId(itemId);
}
setSettingsDialogOpen(true);
},
[setSettingsDialogOpen, setActiveSettingsItemId],
);
// build command registry
const commandItems = useMemo(
() =>
buildCommandRegistry({
_,
openSettingsPanel,
toggleTheme,
toggleFullscreen,
toggleAlwaysOnTop,
toggleScreenWakeLock,
toggleAutoUpload,
reloadPage,
toggleOpenLastBooks,
showAbout,
toggleTelemetry,
isDesktop,
}),
[
_,
openSettingsPanel,
toggleTheme,
toggleFullscreen,
toggleAlwaysOnTop,
toggleScreenWakeLock,
toggleAutoUpload,
reloadPage,
toggleOpenLastBooks,
showAbout,
toggleTelemetry,
isDesktop,
],
);
// search results
const results = useMemo(() => searchCommands(query, commandItems), [query, commandItems]);
const groupedResults = useMemo(() => groupResultsByCategory(results), [results]);
// recent items
const recentItems = useMemo(() => getRecentCommands(commandItems, 5), [commandItems]);
// palette controls
const open = useCallback(() => {
setIsOpen(true);
setQuery('');
}, []);
const close = useCallback(() => {
setIsOpen(false);
setQuery('');
}, []);
const toggle = useCallback(() => {
if (isOpen) {
close();
} else {
open();
}
}, [isOpen, open, close]);
// execute command
const executeCommand = useCallback(
(item: CommandItem) => {
trackCommandUsage(item.id);
close();
// slight delay to allow modal to close before action
requestAnimationFrame(() => {
item.action();
});
},
[close],
);
// keyboard shortcut handler (Ctrl/Cmd+Shift+P)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCmdOrCtrl = e.metaKey || e.ctrlKey;
if (isCmdOrCtrl && e.shiftKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
e.stopPropagation();
setSettingsDialogOpen(false);
toggle();
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [toggle, setSettingsDialogOpen]);
const value = useMemo(
() => ({
isOpen,
open,
close,
toggle,
query,
setQuery,
results,
groupedResults,
recentItems,
executeCommand,
commandItems,
}),
[
isOpen,
open,
close,
toggle,
query,
setQuery,
results,
groupedResults,
recentItems,
executeCommand,
commandItems,
],
);
return <CommandPaletteContext.Provider value={value}>{children}</CommandPaletteContext.Provider>;
};
export default CommandPaletteProvider;
@@ -0,0 +1,35 @@
import React from 'react';
interface HighlightCharsProps {
str: string;
indices: Set<number>;
highlightClassName?: string;
}
const HighlightChars: React.FC<HighlightCharsProps> = ({
str,
indices,
highlightClassName = 'bg-primary/30 text-primary rounded-sm',
}) => {
if (indices.size === 0) {
return <>{str}</>;
}
const chars = str.normalize().split('');
return (
<>
{chars.map((char, i) =>
indices.has(i) ? (
<mark key={i} className={highlightClassName}>
{char}
</mark>
) : (
<span key={i}>{char}</span>
),
)}
</>
);
};
export default HighlightChars;
@@ -0,0 +1,3 @@
export { default as CommandPalette } from './CommandPalette';
export { CommandPaletteProvider, useCommandPalette } from './CommandPaletteProvider';
export { default as HighlightChars } from './HighlightChars';
@@ -304,9 +304,16 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
) : (
<>
<ThemeModeSelector themeMode={themeMode} onThemeModeChange={setThemeMode} />
<ThemeModeSelector
themeMode={themeMode}
onThemeModeChange={setThemeMode}
data-setting-id='settings.color.themeMode'
/>
<div className='flex items-center justify-between'>
<div
data-setting-id='settings.color.invertImageInDarkMode'
className='flex items-center justify-between'
>
<h2 className='font-medium'>{_('Invert Image In Dark Mode')}</h2>
<input
type='checkbox'
@@ -317,7 +324,10 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
</div>
<div className='flex items-center justify-between'>
<div
data-setting-id='settings.color.overrideBookColor'
className='flex items-center justify-between'
>
<h2 className='font-medium'>{_('Override Book Color')}</h2>
<input
type='checkbox'
@@ -334,6 +344,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
onThemeColorChange={setThemeColor}
onEditTheme={handleEditTheme}
onCreateTheme={() => setShowCustomThemeEditor(true)}
data-setting-id='settings.color.themeColor'
/>
<BackgroundTextureSelector
@@ -347,6 +358,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
onSizeChange={setBackgroundSize}
onImportImage={handleImportImage}
onDeleteTexture={handleDeleteCustomTexture}
data-setting-id='settings.color.backgroundTexture'
/>
<HighlightColorsEditor
@@ -354,6 +366,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
userHighlightColors={userHighlightColors}
onChange={handleHighlightColorsChange}
onUserColorsChange={handleUserHighlightColorsChange}
data-setting-id='settings.color.highlightColors'
/>
<TTSHighlightStyleEditor
@@ -363,6 +376,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
onStyleChange={handleTTSStyleChange}
onColorChange={handleTTSColorChange}
onCustomColorsChange={handleCustomTtsColorsChange}
data-setting-id='settings.color.ttsHighlightStyle'
/>
<ReadingRulerSettings
@@ -374,6 +388,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
onLinesChange={setReadingRulerLines}
onOpacityChange={setReadingRulerOpacity}
onColorChange={setReadingRulerColor}
data-setting-id='settings.color.readingRuler'
/>
<CodeHighlightingSettings
@@ -381,6 +396,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
codeLanguage={codeLanguage}
onToggle={setcodeHighlighting}
onLanguageChange={setCodeLanguage}
data-setting-id='settings.color.codeHighlighting'
/>
</>
)}
@@ -205,7 +205,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.control.scrolledMode'>
<h2 className='mb-2 font-medium'>{_('Scroll')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -219,7 +219,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.control.continuousScroll'>
<span className=''>{_('Continuous Scroll')}</span>
<input
type='checkbox'
@@ -237,12 +237,13 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
min={0}
max={200}
step={10}
data-setting-id='settings.control.overlapPixels'
/>
</div>
</div>
</div>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.control.clickToPaginate'>
<h2 className='mb-2 font-medium'>{_('Pagination')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
@@ -257,7 +258,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
onChange={() => setIsDisableClick(!isDisableClick)}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.control.clickBothSides'>
<span className=''>
{appService?.isMobileApp ? _('Tap Both Sides') : _('Click Both Sides')}
</span>
@@ -269,7 +270,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
onChange={() => setFullscreenClickArea(!fullscreenClickArea)}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.control.swapClickSides'>
<span className=''>
{appService?.isMobileApp ? _('Swap Tap Sides') : _('Swap Click Sides')}
</span>
@@ -281,7 +282,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
onChange={() => setSwapClickArea(!swapClickArea)}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.control.disableDoubleClick'>
<span className=''>
{appService?.isMobileApp ? _('Disable Double Tap') : _('Disable Double Click')}
</span>
@@ -307,7 +308,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
</div>
</div>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.control.enableQuickActions'>
<h2 className='mb-2 font-medium'>{_('Annotation Tools')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -320,7 +321,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
onChange={() => setEnableAnnotationQuickActions(!enableAnnotationQuickActions)}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.control.quickAction'>
<span className=''>{_('Quick Action')}</span>
<Select
value={annotationQuickAction || ''}
@@ -329,7 +330,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
disabled={!enableAnnotationQuickActions}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.control.copyToNotebook'>
<span className=''>{_('Copy to Notebook')}</span>
<input
type='checkbox'
@@ -342,7 +343,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
</div>
</div>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.control.pagingAnimation'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -360,7 +361,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
</div>
{(appService?.isMobileApp || appService?.appPlatform === 'web') && (
<div className='w-full'>
<div className='w-full' data-setting-id='settings.control.einkMode'>
<h2 className='mb-2 font-medium'>{_('Device')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -376,7 +377,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
</div>
)}
{(appService?.isAndroidApp || appService?.appPlatform === 'web') && (
<div className='config-item'>
<div className='config-item' data-setting-id='settings.control.colorEinkMode'>
<span className=''>{_('Color E-Ink Mode')}</span>
<input
type='checkbox'
@@ -403,7 +404,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
</div>
)}
<div className='w-full'>
<div className='w-full' data-setting-id='settings.control.allowJavascript'>
<h2 className='mb-2 font-medium'>{_('Security')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -51,6 +51,7 @@ interface FontFaceProps {
moreOptions?: string[];
selected: string;
onSelect: (option: string) => void;
'data-setting-id'?: string;
}
const handleFontFaceFont = (option: string, family: string) => {
@@ -69,10 +70,11 @@ const FontFace = ({
moreOptions,
selected,
onSelect,
'data-setting-id': settingId,
}: FontFaceProps) => {
const _ = useTranslation();
return (
<div className={clsx('config-item', className)}>
<div className={clsx('config-item', className)} data-setting-id={settingId}>
<span className='line-clamp-2 min-w-10'>{label}</span>
<FontDropdown
family={family}
@@ -284,7 +286,10 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<div
data-setting-id='settings.font.overrideBookFont'
className='flex items-center justify-between'
>
<h2 className='font-medium'>{_('Override Book Font')}</h2>
<input
type='checkbox'
@@ -304,6 +309,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
onChange={setDefaultFontSize}
min={minFontSize}
max={120}
data-setting-id='settings.font.defaultFontSize'
/>
<NumberInput
label={_('Minimum Font Size')}
@@ -311,12 +317,13 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
onChange={setMinFontSize}
min={1}
max={120}
data-setting-id='settings.font.minimumFontSize'
/>
</div>
</div>
</div>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.font.fontWeight'>
<h2 className='mb-2 font-medium'>{_('Font Weight')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -336,7 +343,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<h2 className='mb-2 font-medium'>{_('Font Family')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.font.defaultFont'>
<span className=''>{_('Default Font')}</span>
<FontDropdown
options={fontFamilyOptions}
@@ -354,6 +361,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
options={CJKFonts}
selected={defaultCJKFont}
onSelect={setDefaultCJKFont}
data-setting-id='settings.font.cjkFont'
/>
)}
</div>
@@ -385,6 +393,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
moreOptions={sysFonts}
selected={serifFont}
onSelect={setSerifFont}
data-setting-id='settings.font.serifFont'
/>
<FontFace
family='sans-serif'
@@ -397,6 +406,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
moreOptions={sysFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
data-setting-id='settings.font.sansSerifFont'
/>
<FontFace
className='config-item-bottom'
@@ -406,6 +416,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
moreOptions={sysFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
data-setting-id='settings.font.monospaceFont'
/>
</div>
</div>
@@ -242,7 +242,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
return (
<div className={clsx('my-4 w-full space-y-6')}>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.language.interfaceLanguage'>
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -258,7 +258,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
</div>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.language.translationEnabled'>
<h2 className='mb-2 font-medium'>{_('Translation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
@@ -283,7 +283,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.language.ttsTextTranslation'>
<span className=''>{_('TTS Text')}</span>
<Select
value={ttsReadAloudText}
@@ -292,7 +292,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.language.translationProvider'>
<span className=''>{_('Translation Service')}</span>
<Select
value={getCurrentTranslationProviderOption().value}
@@ -301,7 +301,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.language.targetLanguage'>
<span className=''>{_('Translate To')}</span>
<Select
value={getCurrentTargetLangOption().value}
@@ -314,7 +314,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
{(isCJKEnv() || view?.language.isCJK) && (
<div className='w-full'>
<div className='w-full' data-setting-id='settings.language.quotationMarks'>
<h2 className='mb-2 font-medium'>{_('Punctuation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
@@ -336,7 +336,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
)}
{(isCJKEnv() || view?.language.isCJK) && (
<div className='w-full'>
<div className='w-full' data-setting-id='settings.language.chineseConversion'>
<h2 className='mb-2 font-medium'>{_('Convert Simplified and Traditional Chinese')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
@@ -389,7 +389,10 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<div
data-setting-id='settings.layout.overrideBookLayout'
className='flex items-center justify-between'
>
<h2 className='font-medium'>{_('Override Book Layout')}</h2>
<input
type='checkbox'
@@ -399,7 +402,10 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
/>
</div>
{mightBeRTLBook && (
<div className='flex items-center justify-between'>
<div
data-setting-id='settings.layout.writingMode'
className='flex items-center justify-between'
>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<div className='flex gap-4'>
<button
@@ -438,7 +444,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
)}
{viewSettings.vertical && (
<div className='w-full'>
<div className='w-full' data-setting-id='settings.layout.borderFrame'>
<h2 className='mb-2 font-medium'>{_('Border Frame')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -482,6 +488,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
min={0}
max={4}
step={0.1}
data-setting-id='settings.layout.paragraphMargin'
/>
<NumberInput
label={_('Line Spacing')}
@@ -490,6 +497,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
min={1.0}
max={3.0}
step={0.1}
data-setting-id='settings.layout.lineSpacing'
/>
{langCode !== 'zh' && (
<NumberInput
@@ -499,6 +507,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
min={-4}
max={8}
step={0.5}
data-setting-id='settings.layout.wordSpacing'
/>
)}
<NumberInput
@@ -508,6 +517,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
min={-2}
max={4}
step={0.5}
data-setting-id='settings.layout.letterSpacing'
/>
<NumberInput
label={_('Text Indent')}
@@ -516,8 +526,9 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
min={-2}
max={4}
step={1}
data-setting-id='settings.layout.paragraphIndent'
/>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.layout.fullJustification'>
<span className=''>{_('Full Justification')}</span>
<input
type='checkbox'
@@ -526,7 +537,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
onChange={() => setFullJustification(!fullJustification)}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.layout.hyphenation'>
<span className=''>{_('Hyphenation')}</span>
<input
type='checkbox'
@@ -539,7 +550,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
</div>
</div>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.layout.pageMargins'>
<h2 className='mb-2 font-medium'>{_('Page')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -589,6 +600,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
onChange={setGapPercent}
min={0}
max={30}
data-setting-id='settings.layout.pageGap'
/>
<NumberInput
label={_('Maximum Number of Columns')}
@@ -596,6 +608,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
onChange={setMaxColumnCount}
min={1}
max={4}
data-setting-id='settings.layout.maxColumnCount'
/>
<NumberInput
label={viewSettings.vertical ? _('Maximum Column Height') : _('Maximum Column Width')}
@@ -605,6 +618,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
min={200}
max={9999}
step={50}
data-setting-id='settings.layout.maxInlineSize'
/>
<NumberInput
label={viewSettings.vertical ? _('Maximum Column Width') : _('Maximum Column Height')}
@@ -614,6 +628,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
min={400}
max={9999}
step={50}
data-setting-id='settings.layout.maxBlockSize'
/>
<div className='config-item'>
<span className=''>{_('Apply also in Scrolled Mode')}</span>
@@ -628,7 +643,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
</div>
</div>
<div className='w-full'>
<div className='w-full' data-setting-id='settings.layout.showHeader'>
<h2 className='mb-2 font-medium'>{_('Header & Footer')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
@@ -641,7 +656,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
onChange={() => setShowHeader(!showHeader)}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.layout.showFooter'>
<span className=''>{_('Show Footer')}</span>
<input
type='checkbox'
@@ -694,7 +709,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
onChange={() => setShowProgressInfo(!showProgressInfo)}
/>
</div>
<div className='config-item'>
<div className='config-item' data-setting-id='settings.layout.progressDisplay'>
<span className=''>{_('Reading Progress Style')}</span>
<Select
value={progressStyle}
@@ -151,8 +151,9 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
error: string | null,
saved: boolean,
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
settingId?: string,
) => (
<div className='w-full'>
<div className='w-full' data-setting-id={settingId}>
<h2 className='mb-2 font-medium' aria-label={_(title)}>
{_(title)}
</h2>
@@ -208,6 +209,7 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
contentError,
draftContentStylesheetSaved,
contentTextareaRef,
'settings.custom.contentCss',
)}
{renderCSSEditor(
@@ -218,6 +220,7 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
uiError,
draftUIStylesheetSaved,
uiTextareaRef,
'settings.custom.readerUiCss',
)}
</div>
);
@@ -14,6 +14,7 @@ interface NumberInputProps {
step?: number;
disabled?: boolean;
onChange: (value: number) => void;
'data-setting-id'?: string;
}
const NumberInput: React.FC<NumberInputProps> = ({
@@ -27,6 +28,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
max,
step,
disabled,
'data-setting-id': settingId,
}) => {
const _ = useTranslation();
const [localValue, setLocalValue] = useState(value);
@@ -72,7 +74,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
};
return (
<div className={clsx('config-item', className)}>
<div className={clsx('config-item', className)} data-setting-id={settingId}>
<span className='text-base-content line-clamp-2'>{label}</span>
{iconSize && <span style={{ minWidth: `${iconSize}px` }} />}
<div className='text-base-content flex items-center gap-2'>
@@ -11,7 +11,9 @@ import { PiDotsThreeVerticalBold, PiRobot } from 'react-icons/pi';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
import { MdArrowBackIosNew, MdArrowForwardIos, MdClose } from 'react-icons/md';
import { FiSearch } from 'react-icons/fi';
import { getDirFromUILanguage } from '@/utils/rtl';
import { getCommandPaletteShortcut } from '@/services/environment';
import FontPanel from './FontPanel';
import LayoutPanel from './LayoutPanel';
import ColorPanel from './ColorPanel';
@@ -22,6 +24,7 @@ import ControlPanel from './ControlPanel';
import LangPanel from './LangPanel';
import MiscPanel from './MiscPanel';
import AIPanel from './AIPanel';
import { useCommandPalette } from '@/components/command-palette';
export type SettingsPanelType =
| 'Font'
@@ -49,8 +52,16 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const closeIconSize = useResponsiveSize(16);
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const tabsRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
const { setFontPanelView, setSettingsDialogOpen } = useSettingsStore();
const { setFontPanelView, setSettingsDialogOpen, activeSettingsItemId, setActiveSettingsItemId } =
useSettingsStore();
const { open: openCommandPalette } = useCommandPalette();
const handleOpenCommandPalette = () => {
openCommandPalette();
setSettingsDialogOpen(false);
};
const tabConfig = [
{
@@ -105,6 +116,16 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
localStorage.setItem('lastConfigPanel', tab);
};
// sync localStorage and fontPanelView when activePanel changes
const activePanelRef = useRef(activePanel);
useEffect(() => {
if (activePanelRef.current !== activePanel) {
activePanelRef.current = activePanel;
setFontPanelView('main-fonts');
localStorage.setItem('lastConfigPanel', activePanel);
}
}, [activePanel, setFontPanelView]);
const [resetFunctions, setResetFunctions] = useState<
Record<SettingsPanelType, (() => void) | null>
>({
@@ -132,6 +153,46 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSettingsDialogOpen(false);
};
// handle activeSettingsItemId: switch to correct panel and scroll to item
useEffect(() => {
if (!activeSettingsItemId) return;
// parse panel from item id (format: settings.panel.itemName)
const parts = activeSettingsItemId.split('.');
if (parts.length >= 2) {
const panelMap: Record<string, SettingsPanelType> = {
font: 'Font',
layout: 'Layout',
color: 'Color',
control: 'Control',
language: 'Language',
ai: 'AI',
custom: 'Custom',
};
const panelKey = parts[1]?.toLowerCase();
const targetPanel = panelMap[panelKey || ''];
if (targetPanel && targetPanel !== activePanel) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- panel switch based on external navigation is intended
setActivePanel(targetPanel);
}
}
// scroll to item after panel renders
const timeoutId = setTimeout(() => {
const element = panelRef.current?.querySelector(
`[data-setting-id="${activeSettingsItemId}"]`,
);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('settings-highlight');
setTimeout(() => element.classList.remove('settings-highlight'), 2000);
}
setActiveSettingsItemId(null);
}, 100);
return () => clearTimeout(timeoutId);
}, [activeSettingsItemId, activePanel, setActiveSettingsItemId]);
useEffect(() => {
setFontPanelView('main-fonts');
@@ -241,6 +302,14 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
))}
</div>
<div className='flex h-full items-center justify-end gap-x-2'>
<button
onClick={handleOpenCommandPalette}
aria-label={_('Search Settings')}
title={`${_('Search Settings')} (${getCommandPaletteShortcut()})`}
className='btn btn-ghost hidden h-8 min-h-8 w-8 items-center justify-center p-0 sm:flex'
>
<FiSearch />
</button>
<Dropdown
label={_('Settings Menu')}
className='dropdown-bottom dropdown-end'
@@ -271,7 +340,11 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
}
>
<div role='group' aria-label={`${_(currentPanel?.label || '')} - ${_('Settings')}`}>
<div
ref={panelRef}
role='group'
aria-label={`${_(currentPanel?.label || '')} - ${_('Settings')}`}
>
{activePanel === 'Font' && (
<FontPanel
bookKey={bookKey}
@@ -18,6 +18,7 @@ const DEFAULT_SHORTCUTS = {
onReadAloudSelection: ['ctrl+r', 'cmd+r'],
onProofreadSelection: ['ctrl+p', 'cmd+p'],
onOpenFontLayoutSettings: ['shift+f', 'ctrl+,', 'cmd+,'],
onOpenCommandPalette: ['ctrl+Shift+p', 'cmd+Shift+p'],
onOpenBooks: ['ctrl+o'],
onReloadPage: ['shift+r'],
onToggleFullscreen: ['F11'],
@@ -0,0 +1,837 @@
import { Fzf, FzfResultItem, byLengthAsc } from 'fzf';
import { SettingsPanelType } from '@/components/settings/SettingsDialog';
import { RiFontSize, RiDashboardLine, RiTranslate } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
import { PiRobot, PiSun, PiMoon } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { MdRefresh } from 'react-icons/md';
import { IconType } from 'react-icons';
import { stubTranslation as _ } from '@/utils/misc';
export type CommandCategory = 'settings' | 'actions' | 'navigation';
export interface CommandItem {
id: string;
labelKey: string;
localizedLabel: string;
keywords: string[];
category: CommandCategory;
panel?: SettingsPanelType;
panelLabel?: string;
section?: string;
icon?: IconType;
shortcut?: string[];
action: () => void;
isAvailable?: () => boolean;
}
export interface CommandSearchResult {
item: CommandItem;
score: number;
positions: Set<number>;
highlightIndices: Set<number>;
matchContext?: string;
}
type TranslationFunc = (key: string) => string;
// selector for fzf - combines all searchable text
const getSearchableText = (item: CommandItem): string => {
return [
item.localizedLabel,
item.labelKey,
item.panel ?? '',
item.panelLabel ?? '',
item.section ?? '',
...item.keywords,
]
.filter(Boolean)
.join(' ');
};
// map fzf positions to display label positions
const mapPositionsToLabel = (entry: FzfResultItem<CommandItem>): Set<number> => {
const searchText = getSearchableText(entry.item);
const label = entry.item.localizedLabel;
const labelStart = searchText.indexOf(label);
if (labelStart === -1) return new Set();
const labelEnd = labelStart + label.length;
const mapped = new Set<number>();
for (const pos of entry.positions) {
if (pos >= labelStart && pos < labelEnd) {
mapped.add(pos - labelStart);
}
}
return mapped;
};
// find matched context from keywords/section/panel for secondary display
const findMatchContext = (entry: FzfResultItem<CommandItem>): string | undefined => {
const searchText = getSearchableText(entry.item);
const label = entry.item.localizedLabel;
const labelStart = searchText.indexOf(label);
const labelEnd = labelStart + label.length;
// check if any match is outside the label
for (const pos of entry.positions) {
if (pos < labelStart || pos >= labelEnd) {
// match is in keywords/section/panel area
const parts = [
entry.item.panelLabel ?? entry.item.panel,
entry.item.section,
...entry.item.keywords,
].filter(Boolean);
for (const part of parts) {
if (part && searchText.includes(part)) {
const partStart = searchText.indexOf(part, labelEnd);
if (partStart !== -1) {
for (const p of entry.positions) {
if (p >= partStart && p < partStart + part.length) {
return part;
}
}
}
}
}
}
}
return undefined;
};
export const searchCommands = (query: string, items: CommandItem[]): CommandSearchResult[] => {
if (!query.trim()) return [];
const availableItems = items.filter((item) => !item.isAvailable || item.isAvailable());
const fzf = new Fzf(availableItems, {
selector: getSearchableText,
tiebreakers: [byLengthAsc],
casing: 'smart-case',
normalize: true,
limit: 50,
});
const results = fzf.find(query);
return results.map((entry) => ({
item: entry.item,
score: entry.score,
positions: entry.positions,
highlightIndices: mapPositionsToLabel(entry),
matchContext: findMatchContext(entry),
}));
};
// group results by category
export const groupResultsByCategory = (
results: CommandSearchResult[],
): Record<CommandCategory, CommandSearchResult[]> => {
const grouped: Record<CommandCategory, CommandSearchResult[]> = {
settings: [],
actions: [],
navigation: [],
};
for (const result of results) {
grouped[result.item.category].push(result);
}
return grouped;
};
// settings panel icon map
const panelIcons: Record<SettingsPanelType, IconType> = {
Font: RiFontSize,
Layout: RiDashboardLine,
Color: VscSymbolColor,
Control: LiaHandPointerSolid,
Language: RiTranslate,
AI: PiRobot,
Custom: IoAccessibilityOutline,
};
// font panel items
const fontPanelItems = [
{
id: 'settings.font.overrideBookFont',
labelKey: _('Override Book Font'),
keywords: ['font', 'override', 'book', 'custom'],
section: 'Font',
},
{
id: 'settings.font.defaultFontSize',
labelKey: _('Default Font Size'),
keywords: ['font', 'size', 'default', 'px', 'pixels', 'text'],
section: 'Font Size',
},
{
id: 'settings.font.minimumFontSize',
labelKey: _('Minimum Font Size'),
keywords: ['font', 'size', 'minimum', 'min', 'small'],
section: 'Font Size',
},
{
id: 'settings.font.fontWeight',
labelKey: _('Font Weight'),
keywords: ['font', 'weight', 'bold', 'light', 'thickness'],
section: 'Font Weight',
},
{
id: 'settings.font.defaultFont',
labelKey: _('Default Font'),
keywords: ['font', 'family', 'serif', 'sans', 'default'],
section: 'Font Family',
},
{
id: 'settings.font.cjkFont',
labelKey: _('CJK Font'),
keywords: ['font', 'cjk', 'chinese', 'japanese', 'korean', 'asian'],
section: 'Font Family',
},
{
id: 'settings.font.serifFont',
labelKey: _('Serif Font'),
keywords: ['font', 'serif', 'family', 'typeface'],
section: 'Font Face',
},
{
id: 'settings.font.sansSerifFont',
labelKey: _('Sans-Serif Font'),
keywords: ['font', 'sans', 'serif', 'family', 'typeface'],
section: 'Font Face',
},
{
id: 'settings.font.monospaceFont',
labelKey: _('Monospace Font'),
keywords: ['font', 'monospace', 'mono', 'code', 'fixed', 'width'],
section: 'Font Face',
},
];
// layout panel items
const layoutPanelItems = [
{
id: 'settings.layout.overrideBookLayout',
labelKey: _('Override Book Layout'),
keywords: ['layout', 'override', 'book', 'custom'],
section: 'Layout',
},
{
id: 'settings.layout.writingMode',
labelKey: _('Writing Mode'),
keywords: ['writing', 'mode', 'vertical', 'horizontal', 'direction', 'rtl', 'ltr'],
section: 'Layout',
},
{
id: 'settings.layout.borderFrame',
labelKey: _('Border Frame'),
keywords: ['border', 'frame', 'vertical', 'mode'],
section: 'Layout',
},
{
id: 'settings.layout.paragraphMargin',
labelKey: _('Paragraph Margin'),
keywords: ['paragraph', 'margin', 'spacing', 'gap'],
section: 'Paragraph',
},
{
id: 'settings.layout.lineSpacing',
labelKey: _('Line Spacing'),
keywords: ['line', 'spacing', 'height', 'leading'],
section: 'Paragraph',
},
{
id: 'settings.layout.wordSpacing',
labelKey: _('Word Spacing'),
keywords: ['word', 'spacing', 'gap'],
section: 'Paragraph',
},
{
id: 'settings.layout.letterSpacing',
labelKey: _('Letter Spacing'),
keywords: ['letter', 'spacing', 'tracking', 'character'],
section: 'Paragraph',
},
{
id: 'settings.layout.paragraphIndent',
labelKey: _('Text Indent'),
keywords: ['paragraph', 'indent', 'first', 'line'],
section: 'Paragraph',
},
{
id: 'settings.layout.fullJustification',
labelKey: _('Full Justification'),
keywords: ['justify', 'justification', 'alignment', 'text', 'full'],
section: 'Paragraph',
},
{
id: 'settings.layout.hyphenation',
labelKey: _('Hyphenation'),
keywords: ['hyphen', 'hyphenation', 'break', 'word'],
section: 'Paragraph',
},
{
id: 'settings.layout.pageMargins',
labelKey: _('Page Margins'),
keywords: ['page', 'margin', 'edge', 'border'],
section: 'Page',
},
{
id: 'settings.layout.pageGap',
labelKey: _('Column Gap (%)'),
keywords: ['page', 'gap', 'spacing', 'gutter'],
section: 'Page',
},
{
id: 'settings.layout.maxColumnCount',
labelKey: _('Maximum Number of Columns'),
keywords: ['column', 'columns', 'max', 'count', 'multi'],
section: 'Page',
},
{
id: 'settings.layout.maxInlineSize',
labelKey: _('Maximum Column Width'),
keywords: ['width', 'max', 'inline', 'size', 'column'],
section: 'Page',
},
{
id: 'settings.layout.maxBlockSize',
labelKey: _('Maximum Column Height'),
keywords: ['height', 'max', 'block', 'size'],
section: 'Page',
},
{
id: 'settings.layout.showHeader',
labelKey: _('Show Header'),
keywords: ['header', 'show', 'top', 'bar', 'title'],
section: 'Header & Footer',
},
{
id: 'settings.layout.showFooter',
labelKey: _('Show Footer'),
keywords: ['footer', 'show', 'bottom', 'bar', 'page', 'number'],
section: 'Header & Footer',
},
{
id: 'settings.layout.progressDisplay',
labelKey: _('Reading Progress Style'),
keywords: ['progress', 'display', 'page', 'number', 'percentage'],
section: 'Header & Footer',
},
];
// color panel items
const colorPanelItems = [
{
id: 'settings.color.themeMode',
labelKey: _('Theme Mode'),
keywords: ['theme', 'mode', 'dark', 'light', 'auto', 'system'],
section: 'Theme',
},
{
id: 'settings.color.invertImageInDarkMode',
labelKey: _('Invert Image In Dark Mode'),
keywords: ['invert', 'image', 'dark', 'mode', 'photo'],
section: 'Theme',
},
{
id: 'settings.color.overrideBookColor',
labelKey: _('Override Book Color'),
keywords: ['override', 'book', 'color', 'custom'],
section: 'Theme',
},
{
id: 'settings.color.themeColor',
labelKey: _('Theme Color'),
keywords: ['theme', 'color', 'palette', 'accent'],
section: 'Theme',
},
{
id: 'settings.color.backgroundTexture',
labelKey: _('Background Image'),
keywords: ['background', 'texture', 'image', 'paper', 'pattern'],
section: 'Theme',
},
{
id: 'settings.color.highlightColors',
labelKey: _('Highlight Colors'),
keywords: ['highlight', 'color', 'annotation', 'marker'],
section: 'Highlight',
},
{
id: 'settings.color.ttsHighlightStyle',
labelKey: _('TTS Highlighting'),
keywords: ['tts', 'highlight', 'style', 'speech', 'read', 'aloud'],
section: 'Highlight',
},
{
id: 'settings.color.readingRuler',
labelKey: _('Reading Ruler'),
keywords: ['reading', 'ruler', 'line', 'guide', 'focus'],
section: 'Reading',
},
{
id: 'settings.color.codeHighlighting',
labelKey: _('Code Highlighting'),
keywords: ['code', 'highlighting', 'syntax', 'programming'],
section: 'Code',
},
];
// control panel items
const controlPanelItems = [
{
id: 'settings.control.scrolledMode',
labelKey: _('Scrolled Mode'),
keywords: ['scroll', 'scrolled', 'mode', 'paginate', 'continuous'],
section: 'Scroll',
},
{
id: 'settings.control.continuousScroll',
labelKey: _('Continuous Scroll'),
keywords: ['continuous', 'scroll', 'endless', 'infinite'],
section: 'Scroll',
},
{
id: 'settings.control.overlapPixels',
labelKey: _('Overlap Pixels'),
keywords: ['overlap', 'pixels', 'scroll', 'offset'],
section: 'Scroll',
},
{
id: 'settings.control.clickToPaginate',
labelKey: _('Click to Paginate'),
keywords: ['click', 'tap', 'paginate', 'page', 'turn'],
section: 'Pagination',
},
{
id: 'settings.control.clickBothSides',
labelKey: _('Click Both Sides'),
keywords: ['click', 'tap', 'both', 'sides', 'fullscreen'],
section: 'Pagination',
},
{
id: 'settings.control.swapClickSides',
labelKey: _('Swap Click Sides'),
keywords: ['swap', 'click', 'tap', 'sides', 'reverse'],
section: 'Pagination',
},
{
id: 'settings.control.disableDoubleClick',
labelKey: _('Disable Double Click'),
keywords: ['disable', 'double', 'click', 'tap'],
section: 'Pagination',
},
{
id: 'settings.control.enableQuickActions',
labelKey: _('Enable Quick Actions'),
keywords: ['quick', 'actions', 'annotation', 'enable'],
section: 'Annotation Tools',
},
{
id: 'settings.control.quickAction',
labelKey: _('Quick Action'),
keywords: ['quick', 'action', 'annotation', 'highlight', 'copy'],
section: 'Annotation Tools',
},
{
id: 'settings.control.copyToNotebook',
labelKey: _('Copy to Notebook'),
keywords: ['copy', 'notebook', 'annotation', 'excerpt'],
section: 'Annotation Tools',
},
{
id: 'settings.control.pagingAnimation',
labelKey: _('Paging Animation'),
keywords: ['paging', 'animation', 'transition', 'effect'],
section: 'Animation',
},
{
id: 'settings.control.einkMode',
labelKey: _('E-Ink Mode'),
keywords: ['eink', 'e-ink', 'kindle', 'e-reader', 'epaper'],
section: 'Device',
},
{
id: 'settings.control.colorEinkMode',
labelKey: _('Color E-Ink Mode'),
keywords: ['color', 'eink', 'e-ink', 'kaleido'],
section: 'Device',
},
{
id: 'settings.control.allowJavascript',
labelKey: _('Allow JavaScript'),
keywords: ['javascript', 'js', 'script', 'security', 'allow'],
section: 'Security',
},
];
// language panel items
const languagePanelItems = [
{
id: 'settings.language.interfaceLanguage',
labelKey: _('Interface Language'),
keywords: ['interface', 'language', 'locale', 'ui', 'translation'],
section: 'Language',
},
{
id: 'settings.language.translationEnabled',
labelKey: _('Enable Translation'),
keywords: ['translation', 'translate', 'enable', 'language'],
section: 'Translation',
},
{
id: 'settings.language.translationProvider',
labelKey: _('Translation Service'),
keywords: ['translation', 'provider', 'google', 'deepl', 'service'],
section: 'Translation',
},
{
id: 'settings.language.targetLanguage',
labelKey: _('Translate To'),
keywords: ['target', 'language', 'translation', 'destination'],
section: 'Translation',
},
{
id: 'settings.language.ttsTextTranslation',
labelKey: _('TTS Text'),
keywords: ['tts', 'text', 'translation', 'speech', 'read'],
section: 'Translation',
},
{
id: 'settings.language.quotationMarks',
labelKey: _('Replace Quotation Marks'),
keywords: ['quotation', 'marks', 'quotes', 'punctuation', 'cjk'],
section: 'Punctuation',
},
{
id: 'settings.language.chineseConversion',
labelKey: _('Convert Simplified and Traditional Chinese'),
keywords: ['chinese', 'conversion', 'simplified', 'traditional', 'cjk'],
section: 'Chinese',
},
];
// ai panel items
const aiPanelItems = [
{
id: 'settings.ai.enableAssistant',
labelKey: _('Enable AI Assistant'),
keywords: ['ai', 'assistant', 'enable', 'chatbot', 'llm'],
section: 'AI',
},
{
id: 'settings.ai.provider',
labelKey: _('AI Provider'),
keywords: ['ai', 'provider', 'ollama', 'gateway', 'service'],
section: 'AI',
},
{
id: 'settings.ai.ollamaUrl',
labelKey: _('Ollama URL'),
keywords: ['ollama', 'url', 'server', 'endpoint', 'api'],
section: 'Ollama',
},
{
id: 'settings.ai.ollamaModel',
labelKey: _('Ollama Model'),
keywords: ['ollama', 'model', 'llama', 'mistral', 'gemma'],
section: 'Ollama',
},
{
id: 'settings.ai.gatewayApiKey',
labelKey: _('API Key'),
keywords: ['api', 'key', 'gateway', 'token', 'secret'],
section: 'AI Gateway',
},
{
id: 'settings.ai.gatewayModel',
labelKey: _('AI Gateway Model'),
keywords: ['gateway', 'model', 'openai', 'gpt', 'claude'],
section: 'AI Gateway',
},
];
// custom panel items
const customPanelItems = [
{
id: 'settings.custom.contentCss',
labelKey: _('Custom Content CSS'),
keywords: ['custom', 'css', 'content', 'style', 'book'],
section: 'Custom CSS',
},
{
id: 'settings.custom.readerUiCss',
labelKey: _('Custom Reader UI CSS'),
keywords: ['custom', 'css', 'reader', 'ui', 'interface'],
section: 'Custom CSS',
},
];
const actionItems = [
{
id: 'action.toggleTheme',
labelKey: _('Theme Mode'),
keywords: ['theme', 'dark', 'light', 'auto', 'mode', 'toggle'],
},
{
id: 'action.fullscreen',
labelKey: _('Fullscreen'),
keywords: ['fullscreen', 'full', 'screen', 'maximize', 'window'],
},
{
id: 'action.alwaysOnTop',
labelKey: _('Always on Top'),
keywords: ['always', 'top', 'pin', 'window', 'float'],
},
{
id: 'action.screenWakeLock',
labelKey: _('Keep Screen Awake'),
keywords: ['screen', 'wake', 'lock', 'awake', 'sleep', 'display'],
},
{
id: 'action.autoUpload',
labelKey: _('Auto Upload Books to Cloud'),
keywords: ['auto', 'upload', 'cloud', 'sync', 'backup'],
},
{
id: 'action.reload',
labelKey: _('Reload Page'),
keywords: ['reload', 'refresh', 'page'],
},
{
id: 'action.openLastBooks',
labelKey: _('Open Last Book on Start'),
keywords: ['open', 'last', 'book', 'start', 'resume'],
},
{
id: 'action.about',
labelKey: _('About Readest'),
keywords: ['about', 'readest', 'version', 'info'],
},
{
id: 'action.telemetry',
labelKey: _('Help improve Readest'),
keywords: ['telemetry', 'analytics', 'improve', 'statistics'],
},
];
export interface CommandRegistryOptions {
_: TranslationFunc;
openSettingsPanel: (panel: SettingsPanelType, itemId?: string) => void;
toggleTheme: () => void;
toggleFullscreen: () => void;
toggleAlwaysOnTop: () => void;
toggleScreenWakeLock: () => void;
toggleAutoUpload: () => void;
reloadPage: () => void;
toggleOpenLastBooks: () => void;
showAbout: () => void;
toggleTelemetry: () => void;
isDesktop: boolean;
// TODO: add reader-specific actions when reader is open (tts, bookmark, etc.)
}
export const buildCommandRegistry = (options: CommandRegistryOptions): CommandItem[] => {
const { _, openSettingsPanel, isDesktop } = options;
const items: CommandItem[] = [];
// helper to create settings item
const createSettingsItem = (
def: { id: string; labelKey: string; keywords: string[]; section?: string },
panel: SettingsPanelType,
panelLabel?: string,
): CommandItem => ({
id: def.id,
labelKey: def.labelKey,
localizedLabel: _(def.labelKey),
keywords: def.keywords,
category: 'settings',
panel,
panelLabel: _(panelLabel ?? panel),
section: def.section,
icon: panelIcons[panel],
action: () => openSettingsPanel(panel, def.id),
});
// add font panel items
for (const def of fontPanelItems) {
items.push(createSettingsItem(def, 'Font'));
}
// add layout panel items
for (const def of layoutPanelItems) {
items.push(createSettingsItem(def, 'Layout'));
}
// add color panel items
for (const def of colorPanelItems) {
items.push(createSettingsItem(def, 'Color'));
}
// add control panel items
for (const def of controlPanelItems) {
items.push(createSettingsItem(def, 'Control', 'Behavior'));
}
// add language panel items
for (const def of languagePanelItems) {
items.push(createSettingsItem(def, 'Language'));
}
// add ai panel items (only in dev, as of now atleast)
if (process.env.NODE_ENV !== 'production') {
for (const def of aiPanelItems) {
items.push(createSettingsItem(def, 'AI'));
}
}
// add custom panel items
for (const def of customPanelItems) {
items.push(createSettingsItem(def, 'Custom'));
}
// add action items
const getThemeIcon = (): IconType => {
const themeMode =
typeof localStorage !== 'undefined' ? localStorage.getItem('themeMode') : 'auto';
return themeMode === 'dark' ? PiMoon : themeMode === 'light' ? PiSun : TbSunMoon;
};
const createActionItem = (def: {
id: string;
action: () => void;
icon?: IconType;
isAvailable?: () => boolean;
}): CommandItem => {
const item = actionItems.find((item) => item.id === def.id);
if (!item) throw new Error(`Action item definition not found for id: ${def.id}`);
return {
id: def.id,
labelKey: item.labelKey,
localizedLabel: _(item.labelKey),
keywords: item.keywords,
icon: def.icon ?? getThemeIcon(),
category: 'actions',
action: def.action,
isAvailable: def.isAvailable,
};
};
items.push(
createActionItem({
id: 'action.toggleTheme',
action: options.toggleTheme,
}),
);
items.push(
createActionItem({
id: 'action.fullscreen',
action: options.toggleFullscreen,
isAvailable: () => isDesktop,
}),
);
items.push(
createActionItem({
id: 'action.alwaysOnTop',
action: options.toggleAlwaysOnTop,
isAvailable: () => isDesktop,
}),
);
items.push(
createActionItem({
id: 'action.screenWakeLock',
action: options.toggleScreenWakeLock,
}),
);
items.push(
createActionItem({
id: 'action.autoUpload',
action: options.toggleAutoUpload,
}),
);
items.push(
createActionItem({
id: 'action.reload',
icon: MdRefresh,
action: options.reloadPage,
}),
);
items.push(
createActionItem({
id: 'action.openLastBooks',
action: options.toggleOpenLastBooks,
isAvailable: () => isDesktop,
}),
);
items.push(
createActionItem({
id: 'action.about',
action: options.showAbout,
}),
);
items.push(
createActionItem({
id: 'action.telemetry',
action: options.toggleTelemetry,
}),
);
return items;
};
// category labels for display
export const getCategoryLabel = (_: TranslationFunc, category: CommandCategory): string => {
switch (category) {
case 'settings':
return _('Settings');
case 'actions':
return _('Actions');
case 'navigation':
return _('Navigation');
default:
return category;
}
};
// get recent commands from localStorage
export const getRecentCommands = (items: CommandItem[], limit = 5): CommandItem[] => {
if (typeof localStorage === 'undefined') return [];
try {
const recentIds = JSON.parse(localStorage.getItem('recentCommands') || '[]') as string[];
return recentIds
.slice(0, limit)
.map((id) => items.find((item) => item.id === id))
.filter((item): item is CommandItem => item !== undefined);
} catch {
return [];
}
};
// track command usage for recent list
export const trackCommandUsage = (commandId: string): void => {
if (typeof localStorage === 'undefined') return;
try {
const recentIds = JSON.parse(localStorage.getItem('recentCommands') || '[]') as string[];
const updated = [commandId, ...recentIds.filter((id) => id !== commandId)].slice(0, 10);
localStorage.setItem('recentCommands', JSON.stringify(updated));
} catch {
// ignore errors
}
};
@@ -15,6 +15,11 @@ export const getBaseUrl = () => process.env['NEXT_PUBLIC_API_BASE_URL'] ?? READE
export const getNodeBaseUrl = () =>
process.env['NEXT_PUBLIC_NODE_BASE_URL'] ?? READEST_NODE_BASE_URL;
export const isMacPlatform = () =>
typeof window !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const getCommandPaletteShortcut = () => (isMacPlatform() ? '⌘⇧P' : 'Ctrl+Shift+P');
const isWebDevMode = () => process.env['NODE_ENV'] === 'development' && isWebAppPlatform();
// Dev API only in development mode and web platform
@@ -12,12 +12,14 @@ interface SettingsState {
isSettingsDialogOpen: boolean;
isSettingsGlobal: boolean;
fontPanelView: FontPanelView;
activeSettingsItemId: string | null;
setSettings: (settings: SystemSettings) => void;
saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void;
setSettingsDialogBookKey: (bookKey: string) => void;
setSettingsDialogOpen: (open: boolean) => void;
setSettingsGlobal: (global: boolean) => void;
setFontPanelView: (view: FontPanelView) => void;
setActiveSettingsItemId: (id: string | null) => void;
applyUILanguage: (uiLanguage?: string) => void;
}
@@ -28,6 +30,7 @@ export const useSettingsStore = create<SettingsState>((set) => ({
isSettingsDialogOpen: false,
isSettingsGlobal: true,
fontPanelView: 'main-fonts',
activeSettingsItemId: null,
setSettings: (settings) => set({ settings }),
saveSettings: async (envConfig: EnvConfigType, settings: SystemSettings) => {
const appService = await envConfig.getAppService();
@@ -37,6 +40,7 @@ export const useSettingsStore = create<SettingsState>((set) => ({
setSettingsDialogOpen: (open) => set({ isSettingsDialogOpen: open }),
setSettingsGlobal: (global) => set({ isSettingsGlobal: global }),
setFontPanelView: (view) => set({ fontPanelView: view }),
setActiveSettingsItemId: (id) => set({ activeSettingsItemId: id }),
applyUILanguage: (uiLanguage?: string) => {
const locale = uiLanguage ? uiLanguage : navigator.language;
+17
View File
@@ -622,3 +622,20 @@ foliate-fxl {
.no-transitions * {
transition: none !important;
}
/* settings highlight animation for command palette */
@keyframes settings-highlight-pulse {
0%,
100% {
background-color: transparent;
}
50% {
background-color: theme('colors.primary/0.15');
}
}
.settings-highlight {
animation: settings-highlight-pulse 0.5s ease-in-out 2;
border-radius: 8px;
}
+8 -55
View File
@@ -218,6 +218,9 @@ importers:
franc-min:
specifier: ^6.2.0
version: 6.2.0
fzf:
specifier: ^0.5.2
version: 0.5.2
google-auth-library:
specifier: ^10.5.0
version: 10.5.0
@@ -622,28 +625,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@ast-grep/napi-linux-arm64-musl@0.40.0':
resolution: {integrity: sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@ast-grep/napi-linux-x64-gnu@0.40.0':
resolution: {integrity: sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@ast-grep/napi-linux-x64-musl@0.40.0':
resolution: {integrity: sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@ast-grep/napi-win32-arm64-msvc@0.40.0':
resolution: {integrity: sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w==}
@@ -1779,105 +1778,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -1964,35 +1947,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.88':
resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.88':
resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.88':
resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.88':
resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==}
@@ -2039,28 +2017,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.0.10':
resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.0.10':
resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.0.10':
resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.0.10':
resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==}
@@ -2750,79 +2724,66 @@ packages:
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.56.0':
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.56.0':
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.56.0':
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.56.0':
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.56.0':
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.56.0':
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.56.0':
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.56.0':
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.56.0':
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.56.0':
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
@@ -3376,35 +3337,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.9.6':
resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==}
@@ -3825,49 +3781,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -5288,6 +5236,9 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fzf@0.5.2:
resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==}
gaxios@7.1.3:
resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==}
engines: {node: '>=18'}
@@ -13903,6 +13854,8 @@ snapshots:
functions-have-names@1.2.3: {}
fzf@0.5.2: {}
gaxios@7.1.3:
dependencies:
extend: 3.0.2