forked from akai/readest
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 957c6b9f32 | |||
| 9c5a1147fa | |||
| 60ad6706ff | |||
| d9199d1182 | |||
| 219edb9faf | |||
| 30a0227ef3 | |||
| 29b5a7cb88 | |||
| abb727870e | |||
| af84a8b110 | |||
| 0dea5d3094 | |||
| 527b95521e | |||
| 396b23ddb4 | |||
| ee0d1549bd | |||
| 6da6b85530 | |||
| cde055dcb7 |
@@ -38,3 +38,5 @@ next-env.d.ts
|
||||
|
||||
# Rust build
|
||||
target
|
||||
|
||||
fastlane/report.xml
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.64",
|
||||
"version": "0.9.65",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
|
||||
@@ -465,5 +465,7 @@
|
||||
"Google Books": "كتب جوجل",
|
||||
"Open Library": "المكتبة المفتوحة",
|
||||
"Fiction, Science, History": "خيال، علوم، تاريخ",
|
||||
"Select Cover Image": "اختر صورة الغلاف"
|
||||
"Select Cover Image": "اختر صورة الغلاف",
|
||||
"Open Book in New Window": "فتح الكتاب في نافذة جديدة",
|
||||
"Voices for {{lang}}": "الأصوات لـ {{lang}}"
|
||||
}
|
||||
|
||||
@@ -449,5 +449,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte",
|
||||
"Select Cover Image": "Coverbild auswählen"
|
||||
"Select Cover Image": "Coverbild auswählen",
|
||||
"Open Book in New Window": "Buch in neuem Fenster öffnen",
|
||||
"Voices for {{lang}}": "Stimmen für {{lang}}"
|
||||
}
|
||||
|
||||
@@ -449,5 +449,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία",
|
||||
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου"
|
||||
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου",
|
||||
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο",
|
||||
"Voices for {{lang}}": "Φωνές για {{lang}}"
|
||||
}
|
||||
|
||||
@@ -453,5 +453,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Ficción, Ciencia, Historia",
|
||||
"Select Cover Image": "Seleccionar imagen de portada"
|
||||
"Select Cover Image": "Seleccionar imagen de portada",
|
||||
"Open Book in New Window": "Abre el libro en una nueva ventana",
|
||||
"Voices for {{lang}}": "Voces para {{lang}}"
|
||||
}
|
||||
|
||||
@@ -453,5 +453,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fiction, Science, Histoire",
|
||||
"Select Cover Image": "Sélectionner l'image de couverture"
|
||||
"Select Cover Image": "Sélectionner l'image de couverture",
|
||||
"Open Book in New Window": "Ouvrir dans une nouvelle fenêtre",
|
||||
"Voices for {{lang}}": "Voix pour {{lang}}"
|
||||
}
|
||||
|
||||
@@ -449,5 +449,7 @@
|
||||
"Google Books": "गूगल बुक्स",
|
||||
"Open Library": "ओपन लाइब्रेरी",
|
||||
"Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास",
|
||||
"Select Cover Image": "कवर इमेज चुनें"
|
||||
"Select Cover Image": "कवर इमेज चुनें",
|
||||
"Open Book in New Window": "नई विंडो में पुस्तक खोलें",
|
||||
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें"
|
||||
}
|
||||
|
||||
@@ -445,5 +445,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fiksi, Sains, Sejarah",
|
||||
"Select Cover Image": "Pilihan Gambar Sampul"
|
||||
"Select Cover Image": "Pilihan Gambar Sampul",
|
||||
"Open Book in New Window": "Buka Buku di Jendela Baru",
|
||||
"Voices for {{lang}}": "Suara untuk {{lang}}"
|
||||
}
|
||||
|
||||
@@ -453,5 +453,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Narrativa, Scienza, Storia",
|
||||
"Select Cover Image": "Seleziona immagine di copertina"
|
||||
"Select Cover Image": "Seleziona immagine di copertina",
|
||||
"Open Book in New Window": "Apri libro in una nuova finestra",
|
||||
"Voices for {{lang}}": "Voci per {{lang}}"
|
||||
}
|
||||
|
||||
@@ -445,5 +445,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "小説、科学、歴史",
|
||||
"Select Cover Image": "表紙画像を選択"
|
||||
"Select Cover Image": "表紙画像を選択",
|
||||
"Open Book in New Window": "新しいウィンドウで本を開く",
|
||||
"Voices for {{lang}}": "{{lang}}の音声"
|
||||
}
|
||||
|
||||
@@ -445,5 +445,7 @@
|
||||
"Google Books": "구글 북스",
|
||||
"Open Library": "오픈 라이브러리",
|
||||
"Fiction, Science, History": "소설, 과학, 역사",
|
||||
"Select Cover Image": "표지 이미지 선택"
|
||||
"Select Cover Image": "표지 이미지 선택",
|
||||
"Open Book in New Window": "새 창에서 책 열기",
|
||||
"Voices for {{lang}}": "{{lang}}의 음성"
|
||||
}
|
||||
|
||||
@@ -449,5 +449,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis",
|
||||
"Select Cover Image": "Selecteer omslagafbeelding"
|
||||
"Select Cover Image": "Selecteer omslagafbeelding",
|
||||
"Open Book in New Window": "Open boek in nieuw venster",
|
||||
"Voices for {{lang}}": "Stemmen voor {{lang}}"
|
||||
}
|
||||
|
||||
@@ -457,5 +457,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Beletrystyka, Nauka, Historia",
|
||||
"Select Cover Image": "Zaznacz obraz okładki"
|
||||
"Select Cover Image": "Zaznacz obraz okładki",
|
||||
"Open Book in New Window": "Otwórz książkę w nowym oknie",
|
||||
"Voices for {{lang}}": "{{lang}} Głosy"
|
||||
}
|
||||
|
||||
@@ -453,5 +453,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Ficção, Ciência, História",
|
||||
"Select Cover Image": "Selecionar imagem da capa"
|
||||
"Select Cover Image": "Selecionar imagem da capa",
|
||||
"Open Book in New Window": "Abrir livro em nova janela",
|
||||
"Voices for {{lang}}": "Vozes para {{lang}}"
|
||||
}
|
||||
|
||||
@@ -457,5 +457,7 @@
|
||||
"Google Books": "Google Книги",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Художественная литература, Наука, История",
|
||||
"Select Cover Image": "Выбрать изображение обложки"
|
||||
"Select Cover Image": "Выбрать изображение обложки",
|
||||
"Open Book in New Window": "Открыть книгу в новом окне",
|
||||
"Voices for {{lang}}": "Голоса для {{lang}}"
|
||||
}
|
||||
|
||||
@@ -445,5 +445,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์",
|
||||
"Select Cover Image": "เลือกภาพปก"
|
||||
"Select Cover Image": "เลือกภาพปก",
|
||||
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่",
|
||||
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}"
|
||||
}
|
||||
|
||||
@@ -449,5 +449,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Kurgu, Bilim, Tarih",
|
||||
"Select Cover Image": "Kapak Resmi Seç"
|
||||
"Select Cover Image": "Kapak Resmi Seç",
|
||||
"Open Book in New Window": "Kitabı Yeni Pencerede Aç",
|
||||
"Voices for {{lang}}": "{{lang}} için Sesler"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"(detected)": "(виявлено)",
|
||||
"About Readest": "Про Readest",
|
||||
"Add your notes here...": "Додайте свої нотатки тут...",
|
||||
"Add your notes here...": "Додайте свої нотатки сюди...",
|
||||
"Animation": "Анімація",
|
||||
"Annotate": "Анотація",
|
||||
"Apply": "Застосувати",
|
||||
@@ -395,30 +395,30 @@
|
||||
"Enable": "Увімкнути",
|
||||
"Upgrade to Readest Premium": "Оновити до Readest Premium",
|
||||
"Show Source Text": "Показати вихідний текст",
|
||||
"Cross-Platform Sync": "Міжплатформова Синхронізація",
|
||||
"Cross-Platform Sync": "Міжплатформова синхронізація",
|
||||
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "Безперешкодно синхронізуйте свою бібліотеку, прогрес, виділення та нотатки на всіх своїх пристроях. Більше ніколи не втратите свої книги.",
|
||||
"Customizable Reading": "Користуацьке читання",
|
||||
"Customizable Reading": "Користувацьке читання",
|
||||
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "Персоналізуйте кожну деталь за допомогою налаштовуваних шрифтів, макетів, тем та розширених налаштувань відображення для ідеального читання.",
|
||||
"AI Read Aloud": "Озвучування ШІ",
|
||||
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "Насолоджуйтесь читанням без використання рук з природними звучаннями голосів, які оживляють ваші книги.",
|
||||
"AI Translations": "Переклади ШІ",
|
||||
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Перекладайте будь-який текст миттєво за допомогою Google, Azure або DeepL. Розумійте зміст будь-якою мовою.",
|
||||
"Connect with fellow readers and get help fast in our friendly community channels.": "Спілкуйтеся з іншими читачами та отримуйте швидку допомогу в наших дружніх каналах спільноти.",
|
||||
"Unlimited AI Read Aloud Hours": "Необмежені Години Озвучування ШІ",
|
||||
"Unlimited AI Read Aloud Hours": "Необмежені години озвучування ШІ",
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Слухайте без обмежень—перетворюйте стільки тексту, скільки хочете, в захоплююче аудіо.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Розблокуйте покращені можливості перекладу з більшим щоденним використанням та розширеними функціями.",
|
||||
"DeepL Pro Access": "Доступ до DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 ГБ Хмарного Сховища",
|
||||
"2 GB Cloud Sync Storage": "2 ГБ Хмарного сховища",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Насолоджуйтеся швидшими відповідями та персональною допомогою, коли вам потрібна підтримка.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Досягнуто денну квоту перекладів. Оновіть свій план, щоб продовжити використовувати переклади ШІ.",
|
||||
"Includes All Plus Plan Benefits": "Включає Всі Переваги Plus Плану",
|
||||
"Early Feature Access": "Ранній Доступ до Функцій",
|
||||
"Includes All Plus Plan Benefits": "Включає усі переваги Plus плану",
|
||||
"Early Feature Access": "Ранній Доступ до функцій",
|
||||
"Be the first to explore new features, updates, and innovations before anyone else.": "Досліджуйте нові функції, оновлення та інновації першими.",
|
||||
"Advanced AI Tools": "Розширені ШІ Інструменти",
|
||||
"Advanced AI Tools": "Розширені ШІ інструменти",
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Використовуйте потужні ШІ інструменти для розумного читання, перекладу та пошуку контенту.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Перекладайте до 100 000 символів щодня з найточнішим доступним рушієм перекладу.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Перекладайте до 500 000 символів щодня з найточнішим доступним рушієм перекладу.",
|
||||
"10 GB Cloud Sync Storage": "10 ГБ Хмарного Сховища",
|
||||
"10 GB Cloud Sync Storage": "10 ГБ Хмарного сховища",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 2 ГБ хмарного сховища.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 10 ГБ хмарного сховища.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Видалено хмарну резервну копію книги: {{title}}",
|
||||
@@ -429,22 +429,22 @@
|
||||
"Subtitle": "Підзаголовок",
|
||||
"Enter book subtitle": "Введіть підзаголовок книги",
|
||||
"Enter author name": "Введіть ім'я автора",
|
||||
"Series": "Серія",
|
||||
"Enter series name": "Введіть назву серії",
|
||||
"Series Index": "Номер у серії",
|
||||
"Enter series index": "Введіть номер у серії",
|
||||
"Total in Series": "Всього в серії",
|
||||
"Enter total books in series": "Введіть загальну кількість книг у серії",
|
||||
"Enter publisher": "Введіть видавництво",
|
||||
"Publication Date": "Дата публікації",
|
||||
"Series": "Цикл",
|
||||
"Enter series name": "Введіть назву циклу",
|
||||
"Series Index": "Номер циклу",
|
||||
"Enter series index": "Введіть номер циклу",
|
||||
"Total in Series": "Всього у циклі",
|
||||
"Enter total books in series": "Введіть загальну кількість книг у циклі",
|
||||
"Enter publisher": "Введіть видавництвця",
|
||||
"Publication Date": "Дата видання",
|
||||
"Identifier": "Ідентифікатор",
|
||||
"Enter book description": "Введіть опис книги",
|
||||
"Change cover image": "Змінити зображення обкладинки",
|
||||
"Replace": "Замінити",
|
||||
"Unlock cover": "Розблокувати обкладинку",
|
||||
"Lock cover": "Заблокувати обкладинку",
|
||||
"Auto-Retrieve Metadata": "Автоматично отримати метадані",
|
||||
"Auto-Retrieve": "Автоматичне отримання",
|
||||
"Auto-Retrieve Metadata": "Автоматично підтягнути метадані",
|
||||
"Auto-Retrieve": "Автоматичне підтягування",
|
||||
"Unlock all fields": "Розблокувати всі поля",
|
||||
"Unlock All": "Розблокувати все",
|
||||
"Lock all fields": "Заблокувати всі поля",
|
||||
@@ -454,8 +454,10 @@
|
||||
"Locked": "Заблоковано",
|
||||
"Select Metadata Source": "Оберіть джерело метаданих",
|
||||
"Keep manual input": "Зберегти ручне введення",
|
||||
"Google Books": "Google Books",
|
||||
"Google Books": "Google Книги",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Художня література, Наука, Історія",
|
||||
"Select Cover Image": "Оберіть зображення обкладинки"
|
||||
"Fiction, Science, History": "Художня література, Наукова фантастика, Історія",
|
||||
"Select Cover Image": "Оберіть зображення обкладинки",
|
||||
"Open Book in New Window": "Відкрити книгу в новому вікні",
|
||||
"Voices for {{lang}}": "Голоси для {{lang}}"
|
||||
}
|
||||
|
||||
@@ -445,5 +445,7 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Tiểu thuyết, Khoa học, Lịch sử",
|
||||
"Select Cover Image": "Chọn ảnh bìa"
|
||||
"Select Cover Image": "Chọn ảnh bìa",
|
||||
"Open Book in New Window": "Open Sách trong Cửa sổ Mới",
|
||||
"Voices for {{lang}}": "Giọng nói cho {{lang}}"
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@
|
||||
"Quota Exceeded": "额度不足",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻译字符数",
|
||||
"Translation Characters": "翻译字数",
|
||||
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种声音",
|
||||
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种语音",
|
||||
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "出了点问题。请不要担心,我们的团队已经收到通知并正在修复中。",
|
||||
"Error Details:": "错误详情:",
|
||||
"Try Again": "重试",
|
||||
@@ -445,5 +445,7 @@
|
||||
"Google Books": "谷歌图书",
|
||||
"Open Library": "开放图书馆",
|
||||
"Fiction, Science, History": "小说,科学,历史",
|
||||
"Select Cover Image": "选择封面图片"
|
||||
"Select Cover Image": "选择封面图片",
|
||||
"Open Book in New Window": "在新窗口中打开书籍",
|
||||
"Voices for {{lang}}": "{{lang}} 的语音"
|
||||
}
|
||||
|
||||
@@ -445,5 +445,7 @@
|
||||
"Google Books": "Google 圖書",
|
||||
"Open Library": "開放圖書館",
|
||||
"Fiction, Science, History": "小說,科學,歷史",
|
||||
"Select Cover Image": "選擇封面圖片"
|
||||
"Select Cover Image": "選擇封面圖片",
|
||||
"Open Book in New Window": "在新視窗中打開書籍",
|
||||
"Voices for {{lang}}": "{{lang}} 的語音"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.65": {
|
||||
"date": "2025-07-19",
|
||||
"notes": [
|
||||
"Reader: Added support for opening multiple reader windows on desktop",
|
||||
"Layout: Added visible window borders on Linux",
|
||||
"TTS: Improved handling of invalid book language",
|
||||
"TTS: Skipped reading footnote anchor links",
|
||||
"Translation: Fixed an issue where table of contents entries were not translated",
|
||||
"Sync: Edited book metadata is now included in sync across devices",
|
||||
"iOS: Disabled context menu when long-pressing on book covers"
|
||||
]
|
||||
},
|
||||
"0.9.64": {
|
||||
"date": "2025-07-16",
|
||||
"notes": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main", "updater"],
|
||||
"windows": ["main", "updater", "reader-*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"fs:default",
|
||||
@@ -91,6 +91,7 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-destroy",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:path:allow-resolve-directory",
|
||||
"log:default",
|
||||
|
||||
@@ -144,7 +144,8 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
.expect("NS window should exist on state to handle resize")
|
||||
as id;
|
||||
|
||||
if state.window.label() == "main" {
|
||||
if state.window.label() == "main" || state.window.label().starts_with("reader")
|
||||
{
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
@@ -278,7 +279,8 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
.expect("Failed to emit event");
|
||||
|
||||
let id = state.window.ns_window().expect("Failed to emit event") as id;
|
||||
if state.window.label() == "main" {
|
||||
if state.window.label() == "main" || state.window.label().starts_with("reader")
|
||||
{
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
|
||||
@@ -334,8 +334,10 @@ export default function AuthPage() {
|
||||
return isTauriAppPlatform() ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
|
||||
'bg-base-100 border-base-200 border',
|
||||
'bg-base-100 fixed inset-0 flex select-none flex-col items-center overflow-hidden overflow-y-auto',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
|
||||
@@ -169,8 +169,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
};
|
||||
|
||||
const openSelectedBooks = () => {
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
navigateToReader(router, selectedBooks);
|
||||
handleSetSelectMode(false);
|
||||
if (appService?.hasWindow && settings.openBookInNewWindow) {
|
||||
showReaderWindow(appService, selectedBooks);
|
||||
} else {
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
navigateToReader(router, selectedBooks);
|
||||
}
|
||||
};
|
||||
|
||||
const openBookDetails = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -117,8 +117,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const makeBookAvailable = async (book: Book) => {
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
if (await appService?.isBookAvailable(book)) {
|
||||
if (!book.downloadedAt) {
|
||||
if (!book.downloadedAt || !book.coverDownloadedAt) {
|
||||
book.downloadedAt = Date.now();
|
||||
book.coverDownloadedAt = Date.now();
|
||||
updateBook(envConfig, book);
|
||||
}
|
||||
return true;
|
||||
@@ -142,7 +143,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
toggleSelection(book.hash);
|
||||
} else {
|
||||
if (!(await makeBookAvailable(book))) return;
|
||||
navigateToReader(router, [book.hash]);
|
||||
if (appService?.hasWindow && settings.openBookInNewWindow) {
|
||||
showReaderWindow(appService, [book.hash]);
|
||||
} else {
|
||||
navigateToReader(router, [book.hash]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +83,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleOpenInNewWindow = () => {
|
||||
settings.openBookInNewWindow = !settings.openBookInNewWindow;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleAlwaysOnTop = () => {
|
||||
settings.alwaysOnTop = !settings.alwaysOnTop;
|
||||
setSettings(settings);
|
||||
@@ -219,6 +226,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
/>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
{appService?.hasWindow && (
|
||||
<MenuItem
|
||||
label={_('Open Book in New Window')}
|
||||
Icon={settings.openBookInNewWindow ? MdCheck : undefined}
|
||||
onClick={toggleOpenInNewWindow}
|
||||
/>
|
||||
)}
|
||||
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
||||
{appService?.hasWindow && (
|
||||
<MenuItem
|
||||
|
||||
@@ -70,7 +70,7 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
const processOldBook = async (oldBook: Book) => {
|
||||
const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash);
|
||||
if (matchingBook) {
|
||||
if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.downloadedAt) {
|
||||
if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.coverDownloadedAt) {
|
||||
await appService?.downloadBook(oldBook, true);
|
||||
}
|
||||
const mergedBook =
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
|
||||
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
@@ -194,6 +194,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshLibrary = useCallback(async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
const library = await appService.loadLibraryBooks();
|
||||
setSettings(settings);
|
||||
setLibrary(library);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envConfig, appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appService?.hasWindow) {
|
||||
const currentWebview = getCurrentWebview();
|
||||
const unlisten = currentWebview.listen('close-reader-window', async () => {
|
||||
handleRefreshLibrary();
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [appService, handleRefreshLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
const libraryPage = document.querySelector('.library-page');
|
||||
if (!appService?.isMobile) {
|
||||
@@ -294,7 +316,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
navigateToReader(router, bookIds);
|
||||
}
|
||||
}
|
||||
}, [pendingNavigationBookIds, router]);
|
||||
}, [pendingNavigationBookIds, appService, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
@@ -647,6 +669,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
className={clsx(
|
||||
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
const { getProgress, getViewState, getViewSettings } = useReaderStore();
|
||||
const { setGridInsets, hoveredBookKey } = useReaderStore();
|
||||
const { isSideBarVisible, sideBarBookKey } = useSidebarStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const screenInsets = useSafeAreaInsets();
|
||||
@@ -70,7 +70,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('relative grid h-full flex-grow')}
|
||||
className={clsx('books-grid bg-base-100 relative grid h-full flex-grow')}
|
||||
style={{
|
||||
gridTemplateColumns: gridTemplate.columns,
|
||||
gridTemplateRows: gridTemplate.rows,
|
||||
@@ -106,7 +106,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
key={bookKey}
|
||||
className={clsx(
|
||||
'relative h-full w-full overflow-hidden',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
{isBookmarked && !hoveredBookKey && <Ribbon width={`${horizontalGapPercent}%`} />}
|
||||
|
||||
@@ -62,19 +62,18 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
setTrafficLightVisibility(isSideBarVisible);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSideBarVisible]);
|
||||
}, [appService, isSideBarVisible]);
|
||||
|
||||
const isVisible = hoveredBookKey === bookKey || isDropdownOpen;
|
||||
|
||||
@@ -86,13 +85,14 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx('absolute top-0 z-10 hidden h-11 w-full sm:flex')}
|
||||
className={clsx('absolute top-0 z-10 h-11 w-full')}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-100 absolute left-0 right-0 top-0 z-10',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right',
|
||||
isVisible ? 'visible' : 'hidden',
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -111,8 +111,9 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
settings.globalReadSettings && (
|
||||
<div
|
||||
className={clsx(
|
||||
`reader-page bg-base-100 text-base-content select-none`,
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
|
||||
`reader-page text-base-content select-none overflow-hidden`,
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
<Suspense>
|
||||
|
||||
@@ -5,14 +5,16 @@ import * as React from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { Book } from '@/types/book';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { parseOpenWithFiles } from '@/helpers/openWith';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
@@ -83,7 +85,10 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
saveSettings(envConfig, settings);
|
||||
}
|
||||
|
||||
if (isTauriAppPlatform()) tauriHandleOnCloseWindow(handleCloseBooks);
|
||||
let unlistenOnCloseWindow: Promise<UnlistenFn>;
|
||||
if (isTauriAppPlatform()) {
|
||||
unlistenOnCloseWindow = tauriHandleOnCloseWindow(handleCloseBooks);
|
||||
}
|
||||
window.addEventListener('beforeunload', handleCloseBooks);
|
||||
eventDispatcher.on('beforereload', handleCloseBooks);
|
||||
eventDispatcher.on('quit-app', handleCloseBooks);
|
||||
@@ -91,6 +96,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
window.removeEventListener('beforeunload', handleCloseBooks);
|
||||
eventDispatcher.off('beforereload', handleCloseBooks);
|
||||
eventDispatcher.off('quit-app', handleCloseBooks);
|
||||
unlistenOnCloseWindow?.then((fn) => fn());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKeys]);
|
||||
@@ -128,7 +134,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
|
||||
await saveSettings(envConfig, settings);
|
||||
}, 500);
|
||||
}, 200);
|
||||
|
||||
const handleCloseBooksToLibrary = () => {
|
||||
handleCloseBooks();
|
||||
@@ -143,11 +149,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
dismissBook(bookKey);
|
||||
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
|
||||
const openWithFiles = (await parseOpenWithFiles()) || [];
|
||||
if (openWithFiles.length > 0 && !appService?.isMobile) {
|
||||
tauriHandleClose();
|
||||
} else {
|
||||
saveSettingsAndGoToLibrary();
|
||||
if (appService?.hasWindow) {
|
||||
if (openWithFiles.length > 0) {
|
||||
return await tauriHandleClose();
|
||||
}
|
||||
const currentWindow = getCurrentWindow();
|
||||
if (currentWindow.label.startsWith('reader')) {
|
||||
return await currentWindow.close();
|
||||
}
|
||||
}
|
||||
saveSettingsAndGoToLibrary();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -166,7 +177,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
|
||||
<div className={clsx('reader-content flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
|
||||
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
|
||||
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
|
||||
<Notebook />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
|
||||
const BookCard = ({ book }: { book: Book }) => {
|
||||
const { coverImageUrl, title, author } = book;
|
||||
const { coverImageUrl, title, author, metadata } = book;
|
||||
const _ = useTranslation();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
@@ -22,7 +22,7 @@ const BookCard = ({ book }: { book: Book }) => {
|
||||
return (
|
||||
<div className='flex h-20 w-full items-center'>
|
||||
<Image
|
||||
src={coverImageUrl!}
|
||||
src={metadata?.coverImageUrl || coverImageUrl!}
|
||||
alt={_('Book Cover')}
|
||||
width={56}
|
||||
height={80}
|
||||
|
||||
@@ -27,7 +27,7 @@ const SidebarHeader: React.FC<{
|
||||
<div
|
||||
className={clsx(
|
||||
'sidebar-header flex h-11 items-center justify-between pe-2',
|
||||
isTrafficLightVisible ? 'pl-20' : 'ps-1.5',
|
||||
isTrafficLightVisible ? 'ps-1.5 sm:ps-20' : 'ps-1.5',
|
||||
)}
|
||||
dir='ltr'
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { isCJKStr } from '@/utils/lang';
|
||||
import { createRejecttFilter } from '@/utils/node';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SearchOptions from './SearchOptions';
|
||||
|
||||
@@ -117,19 +118,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
return searchTerm.length >= minLength;
|
||||
};
|
||||
|
||||
const createAcceptNode = ({ withRT = true } = {}) => {
|
||||
return (node: Node): number => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const name = (node as Element).tagName.toLowerCase();
|
||||
if (name === 'script' || name === 'style' || (!withRT && name === 'rt')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
};
|
||||
};
|
||||
|
||||
const handleSearch = useCallback(
|
||||
async (term: string) => {
|
||||
console.log('searching for:', term);
|
||||
@@ -139,7 +127,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
...searchConfig,
|
||||
index,
|
||||
query: term,
|
||||
acceptNode: createAcceptNode({ withRT: !primaryLang.startsWith('ja') }),
|
||||
acceptNode: createRejecttFilter({
|
||||
tags: primaryLang.startsWith('ja') ? ['rt'] : [],
|
||||
}),
|
||||
});
|
||||
const results: BookSearchResult[] = [];
|
||||
let lastProgressLogTime = 0;
|
||||
|
||||
@@ -81,7 +81,12 @@ const TOCView: React.FC<{
|
||||
}
|
||||
}, [initialize]);
|
||||
|
||||
useTextTranslation(bookKey, containerRef.current, false, 'translation-target-toc');
|
||||
useTextTranslation(
|
||||
bookKey,
|
||||
containerRef.current || staticListRef.current,
|
||||
false,
|
||||
'translation-target-toc',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MdBookmarkBorder as BookmarkIcon } from 'react-icons/md';
|
||||
import { IoIosList as TOCIcon } from 'react-icons/io';
|
||||
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
const TabNavigation: React.FC<{
|
||||
@@ -11,12 +12,16 @@ const TabNavigation: React.FC<{
|
||||
onTabChange: (tab: string) => void;
|
||||
}> = ({ activeTab, onTabChange }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
|
||||
const tabs = ['toc', 'annotations', 'bookmarks'];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t')}
|
||||
className={clsx(
|
||||
'bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
)}
|
||||
dir='ltr'
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -212,11 +212,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
|
||||
await ttsController.initViewTTS();
|
||||
const ssml = view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
let lang = parseSSMLLang(ssml) || 'en';
|
||||
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
|
||||
if (lang === 'en' && primaryLang && primaryLang !== 'en') {
|
||||
lang = primaryLang.split('-')[0]!;
|
||||
}
|
||||
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
setIsPlaying(true);
|
||||
setTtsLang(lang);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getLanguageName } from '@/utils/lang';
|
||||
|
||||
type TTSPanelProps = {
|
||||
bookKey: string;
|
||||
@@ -178,7 +179,19 @@ const TTSPanel = ({
|
||||
useEffect(() => {
|
||||
const fetchVoices = async () => {
|
||||
const voiceGroups = await onGetVoices(ttsLang);
|
||||
setVoiceGroups(voiceGroups);
|
||||
const voicesCount = voiceGroups.reduce((acc, group) => acc + group.voices.length, 0);
|
||||
if (!voiceGroups || voicesCount === 0) {
|
||||
console.warn('No voices found for TTSPanel');
|
||||
setVoiceGroups([
|
||||
{
|
||||
id: 'no-voices',
|
||||
name: _('Voices for {{lang}}', { lang: getLanguageName(ttsLang) }),
|
||||
voices: [],
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setVoiceGroups(voiceGroups);
|
||||
}
|
||||
};
|
||||
fetchVoices();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -289,48 +302,44 @@ const TTSPanel = ({
|
||||
>
|
||||
{voiceGroups.map((voiceGroup, index) => {
|
||||
return (
|
||||
voiceGroup.voices.length > 0 && (
|
||||
<div key={voiceGroup.id} className=''>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<span
|
||||
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
|
||||
></span>
|
||||
<span className='text-sm text-gray-400 sm:text-xs'>
|
||||
{_('{{engine}}: {{count}} voices', {
|
||||
engine: _(voiceGroup.name),
|
||||
count: voiceGroup.voices.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{voiceGroup.voices.map((voice, voiceIndex) => (
|
||||
<li
|
||||
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span
|
||||
style={{
|
||||
width: `${defaultIconSize}px`,
|
||||
height: `${defaultIconSize}px`,
|
||||
}}
|
||||
>
|
||||
{selectedVoice === voice.id && (
|
||||
<MdCheck className='text-base-content' />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-base sm:text-sm',
|
||||
voice.disabled && 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
{_(voice.name)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<div key={voiceGroup.id} className=''>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<span
|
||||
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
|
||||
></span>
|
||||
<span className='text-sm text-gray-400 sm:text-xs'>
|
||||
{_('{{engine}}: {{count}} voices', {
|
||||
engine: _(voiceGroup.name),
|
||||
count: voiceGroup.voices.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
{voiceGroup.voices.map((voice, voiceIndex) => (
|
||||
<li
|
||||
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span
|
||||
style={{
|
||||
width: `${defaultIconSize}px`,
|
||||
height: `${defaultIconSize}px`,
|
||||
}}
|
||||
>
|
||||
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-base sm:text-sm',
|
||||
voice.disabled && 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
{_(voice.name)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
@@ -69,7 +69,7 @@ const ProfilePage = () => {
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
const { quotas, userPlan } = useQuotaStats();
|
||||
const { quotas, userPlan = 'free' } = useQuotaStats();
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (showEmbeddedCheckout) {
|
||||
@@ -219,62 +219,66 @@ const ProfilePage = () => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
|
||||
'bg-base-100 border-base-200 border',
|
||||
'bg-base-100 fixed inset-0 select-none overflow-hidden',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
)}
|
||||
>
|
||||
<ProfileHeader onGoBack={handleGoBack} />
|
||||
<div className='w-full min-w-60 max-w-4xl py-10'>
|
||||
{loading && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
{showEmbeddedCheckout ? (
|
||||
<div className='bg-base-100 rounded-lg p-4'>
|
||||
<Checkout
|
||||
clientSecret={checkoutState.clientSecret}
|
||||
sessionId={checkoutState.sessionId}
|
||||
planName={checkoutState.planName}
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='sm:bg-base-200 overflow-hidden rounded-lg sm:p-6 sm:shadow-md'>
|
||||
<div className='flex flex-col gap-y-8'>
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<UserInfo
|
||||
avatarUrl={avatarUrl}
|
||||
userFullName={userFullName}
|
||||
userEmail={userEmail}
|
||||
planDetails={planDetails}
|
||||
/>
|
||||
<div className='flex h-full w-full flex-col items-center overflow-y-auto'>
|
||||
<ProfileHeader onGoBack={handleGoBack} />
|
||||
<div className='w-full min-w-60 max-w-4xl py-10'>
|
||||
{loading && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
{showEmbeddedCheckout ? (
|
||||
<div className='bg-base-100 rounded-lg p-4'>
|
||||
<Checkout
|
||||
clientSecret={checkoutState.clientSecret}
|
||||
sessionId={checkoutState.sessionId}
|
||||
planName={checkoutState.planName}
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='sm:bg-base-200 overflow-hidden rounded-lg sm:p-6 sm:shadow-md'>
|
||||
<div className='flex flex-col gap-y-8'>
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<UserInfo
|
||||
avatarUrl={avatarUrl}
|
||||
userFullName={userFullName}
|
||||
userEmail={userEmail}
|
||||
planDetails={planDetails}
|
||||
/>
|
||||
|
||||
<UsageStats quotas={quotas} />
|
||||
</div>
|
||||
<UsageStats quotas={quotas} />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-y-8 sm:px-6'>
|
||||
<PlansComparison
|
||||
availablePlans={availablePlans}
|
||||
userPlan={userPlan}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-8 sm:px-6'>
|
||||
<PlansComparison
|
||||
availablePlans={availablePlans}
|
||||
userPlan={userPlan}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<AccountActions
|
||||
userPlan={userPlan}
|
||||
onLogout={handleLogout}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onManageSubscription={handleManageSubscription}
|
||||
/>
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<AccountActions
|
||||
userPlan={userPlan}
|
||||
onLogout={handleLogout}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onManageSubscription={handleManageSubscription}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<Toast />
|
||||
</div>
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Book } from '@/types/book';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
@@ -21,26 +22,43 @@ const BookCover: React.FC<BookCoverProps> = ({
|
||||
imageClassName,
|
||||
isPreview,
|
||||
}) => {
|
||||
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.target as HTMLImageElement;
|
||||
img.style.display = 'none';
|
||||
const coverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const mainContainer = img.closest('.book-cover-container');
|
||||
const fallbackDiv = mainContainer?.querySelector('.fallback-cover');
|
||||
|
||||
if (fallbackDiv) {
|
||||
fallbackDiv.classList.remove('invisible');
|
||||
const toggleImageVisibility = (showImage: boolean) => {
|
||||
if (coverRef.current) {
|
||||
const coverImage = coverRef.current.querySelector('.cover-image');
|
||||
const fallbackCover = coverRef.current.querySelector('.fallback-cover');
|
||||
if (coverImage) {
|
||||
coverImage.classList.toggle('invisible', !showImage);
|
||||
}
|
||||
if (fallbackCover) {
|
||||
fallbackCover.classList.toggle('invisible', showImage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
toggleImageVisibility(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
toggleImageVisibility(true);
|
||||
}, [book.metadata?.coverImageUrl, book.coverImageUrl]);
|
||||
|
||||
return (
|
||||
<div className={clsx('book-cover-container relative flex h-full w-full', className)}>
|
||||
<div
|
||||
ref={coverRef}
|
||||
className={clsx(
|
||||
'book-cover-container no-context-menu relative flex h-full w-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{coverFit === 'crop' ? (
|
||||
<Image
|
||||
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
fill={true}
|
||||
className={clsx('crop-cover-img object-cover', imageClassName)}
|
||||
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
@@ -57,7 +75,7 @@ const BookCover: React.FC<BookCoverProps> = ({
|
||||
height={0}
|
||||
sizes='100vw'
|
||||
className={clsx(
|
||||
'fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
|
||||
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
|
||||
imageClassName,
|
||||
)}
|
||||
onError={handleImageError}
|
||||
|
||||
@@ -160,7 +160,11 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
dir={isRtl ? 'rtl' : undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)}
|
||||
className={clsx(
|
||||
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
bgClassName,
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -60,7 +60,7 @@ export const useMetadataEdit = (metadata: BookMetadata | null) => {
|
||||
const newMeta = { ...editedMeta } as { [key: string]: unknown };
|
||||
switch (field) {
|
||||
case 'subject':
|
||||
newMeta['subject'] = value ? value.split(/,|,|、/).map((s) => s.trim()) : [];
|
||||
newMeta['subject'] = value ? value.split(/,|;|,|、/).map((s) => s.trim()) : [];
|
||||
break;
|
||||
default:
|
||||
newMeta[field] = value;
|
||||
|
||||
@@ -13,7 +13,10 @@ interface CliArgument {
|
||||
}
|
||||
|
||||
const parseWindowOpenWithFiles = () => {
|
||||
return window.OPEN_WITH_FILES;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const filesParams = params.get('files') || '';
|
||||
const files = filesParams ? filesParams.split(',').map(decodeURIComponent) : [];
|
||||
return files.length > 0 ? files : window.OPEN_WITH_FILES;
|
||||
};
|
||||
|
||||
const parseCLIOpenWithFiles = async () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { navigateToLibrary, showLibraryWindow } from '@/utils/nav';
|
||||
|
||||
interface SingleInstancePayload {
|
||||
args: string[];
|
||||
@@ -13,6 +15,7 @@ interface SingleInstancePayload {
|
||||
|
||||
export function useOpenWithBooks() {
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { setCheckOpenWithBooks } = useLibraryStore();
|
||||
const listenedOpenWithBooks = useRef(false);
|
||||
|
||||
@@ -23,14 +26,19 @@ export function useOpenWithBooks() {
|
||||
filePath = decodeURI(filePath.replace('file://', ''));
|
||||
}
|
||||
if (!/^(https?:|data:|blob:)/i.test(filePath)) {
|
||||
window.OPEN_WITH_FILES = [filePath];
|
||||
setCheckOpenWithBooks(true);
|
||||
navigateToLibrary(router, `reload=${Date.now()}`);
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
if (appService?.hasWindow && settings.openBookInNewWindow) {
|
||||
showLibraryWindow(appService, [filePath]);
|
||||
} else {
|
||||
window.OPEN_WITH_FILES = [filePath];
|
||||
setCheckOpenWithBooks(true);
|
||||
navigateToLibrary(router, `reload=${Date.now()}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
if (!isTauriAppPlatform() || !appService) return;
|
||||
if (listenedOpenWithBooks.current) return;
|
||||
listenedOpenWithBooks.current = true;
|
||||
|
||||
@@ -54,5 +62,5 @@ export function useOpenWithBooks() {
|
||||
unlistenOpenUrl.then((f) => f());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [appService]);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const useQuotaStats = () => {
|
||||
const _ = useTranslation();
|
||||
const { token, user } = useAuth();
|
||||
const [quotas, setQuotas] = useState<QuotaType[]>([]);
|
||||
const [userPlan, setUserPlan] = useState<UserPlan>('free');
|
||||
const [userPlan, setUserPlan] = useState<UserPlan | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
getPrimaryLanguage,
|
||||
} from '@/utils/book';
|
||||
import { partialMD5 } from '@/utils/md5';
|
||||
import { BookDoc, DocumentLoader } from '@/libs/document';
|
||||
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
|
||||
import {
|
||||
DEFAULT_BOOK_LAYOUT,
|
||||
DEFAULT_BOOK_STYLE,
|
||||
@@ -205,6 +205,7 @@ export abstract class BaseAppService implements AppService {
|
||||
hash,
|
||||
format,
|
||||
title: formatTitle(loadedBook.metadata.title),
|
||||
sourceTitle: formatTitle(loadedBook.metadata.title),
|
||||
author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language),
|
||||
primaryLanguage: getPrimaryLanguage(loadedBook.metadata.language),
|
||||
createdAt: existingBook ? existingBook.createdAt : Date.now(),
|
||||
@@ -215,8 +216,9 @@ export abstract class BaseAppService implements AppService {
|
||||
};
|
||||
// update book metadata when reimporting the same book
|
||||
if (existingBook) {
|
||||
existingBook.title = book.title;
|
||||
existingBook.author = book.author;
|
||||
existingBook.title = existingBook.title ?? book.title;
|
||||
existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle;
|
||||
existingBook.author = existingBook.author ?? book.author;
|
||||
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
|
||||
}
|
||||
|
||||
@@ -298,6 +300,7 @@ export abstract class BaseAppService implements AppService {
|
||||
if (includingLocal) {
|
||||
book.deletedAt = Date.now();
|
||||
book.downloadedAt = null;
|
||||
book.coverDownloadedAt = null;
|
||||
}
|
||||
if (includingUploaded) {
|
||||
book.uploadedAt = null;
|
||||
@@ -357,6 +360,7 @@ export abstract class BaseAppService implements AppService {
|
||||
book.updatedAt = Date.now();
|
||||
book.uploadedAt = Date.now();
|
||||
book.downloadedAt = Date.now();
|
||||
book.coverDownloadedAt = Date.now();
|
||||
} else {
|
||||
throw new Error('Book file not uploaded');
|
||||
}
|
||||
@@ -379,6 +383,7 @@ export abstract class BaseAppService implements AppService {
|
||||
|
||||
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
|
||||
let bookDownloaded = false;
|
||||
let bookCoverDownloaded = false;
|
||||
const completedFiles = { count: 0 };
|
||||
let toDownloadFpCount = 0;
|
||||
const needDownCover = !(await this.fs.exists(getCoverFilename(book), 'Books'));
|
||||
@@ -402,6 +407,7 @@ export abstract class BaseAppService implements AppService {
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
|
||||
await this.downloadCloudFile(lfp, cfp, handleProgress);
|
||||
completedFiles.count++;
|
||||
bookCoverDownloaded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// don't throw error here since some books may not have cover images at all
|
||||
@@ -420,6 +426,9 @@ export abstract class BaseAppService implements AppService {
|
||||
if (bookDownloaded || (!onlyCover && !needDownBook)) {
|
||||
book.downloadedAt = Date.now();
|
||||
}
|
||||
if ((bookCoverDownloaded || !needDownCover) && !book.coverDownloadedAt) {
|
||||
book.coverDownloadedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
async isBookAvailable(book: Book): Promise<boolean> {
|
||||
@@ -460,7 +469,19 @@ export abstract class BaseAppService implements AppService {
|
||||
} else if (book.url) {
|
||||
file = await this.fs.openFile(book.url, 'None');
|
||||
} else {
|
||||
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
|
||||
// 0.9.64 has a bug that book.title might be modified but the filename is not updated
|
||||
const bookDir = getDir(book);
|
||||
const files = await this.fs.readDir(getDir(book), 'Books');
|
||||
if (files.length > 0) {
|
||||
const bookFile = files.find((f) => f.path.endsWith(`.${EXTS[book.format]}`));
|
||||
if (bookFile) {
|
||||
file = await this.fs.openFile(`${bookDir}/${bookFile.path}`, 'Books');
|
||||
} else {
|
||||
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
|
||||
}
|
||||
} else {
|
||||
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
|
||||
}
|
||||
}
|
||||
return { book, file, config: await this.loadBookConfig(book, settings) };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
keepLogin: false,
|
||||
autoUpload: true,
|
||||
alwaysOnTop: false,
|
||||
openBookInNewWindow: false,
|
||||
alwaysShowStatusBar: false,
|
||||
autoCheckUpdates: true,
|
||||
screenWakeLock: false,
|
||||
|
||||
@@ -220,7 +220,7 @@ export class NativeAppService extends BaseAppService {
|
||||
override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
|
||||
override hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
|
||||
override hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
|
||||
override hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android') && !!window.IS_ROUNDED;
|
||||
override hasRoundedWindow = OS_TYPE === 'linux';
|
||||
override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
|
||||
override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
|
||||
override hasUpdater =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getUserLocale } from '@/utils/misc';
|
||||
import { TTSClient, TTSMessageEvent } from './TTSClient';
|
||||
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
|
||||
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
|
||||
import { parseSSMLMarks } from '@/utils/ssml';
|
||||
import { TTSController } from './TTSController';
|
||||
import { TTSUtils } from './TTSUtils';
|
||||
import { TTSGranularity, TTSVoice, TTSVoicesGroup } from './types';
|
||||
@@ -60,19 +60,14 @@ export class EdgeTTSClient implements TTSClient {
|
||||
};
|
||||
|
||||
async *speak(ssml: string, signal: AbortSignal, preload = false) {
|
||||
const { marks } = parseSSMLMarks(ssml);
|
||||
let defaultLang = parseSSMLLang(ssml) || 'en';
|
||||
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
|
||||
defaultLang = this.#primaryLang;
|
||||
}
|
||||
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
|
||||
|
||||
if (preload) {
|
||||
// preload the first 2 marks immediately and the rest in the background
|
||||
const maxImmediate = 2;
|
||||
for (let i = 0; i < Math.min(maxImmediate, marks.length); i++) {
|
||||
const mark = marks[i]!;
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
const { language: voiceLang } = mark;
|
||||
const voiceId = await this.getVoiceIdFromLang(voiceLang);
|
||||
this.#currentVoiceId = voiceId;
|
||||
await this.#edgeTTS
|
||||
@@ -86,8 +81,7 @@ export class EdgeTTSClient implements TTSClient {
|
||||
for (let i = maxImmediate; i < marks.length; i++) {
|
||||
const mark = marks[i]!;
|
||||
try {
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
const { language: voiceLang } = mark;
|
||||
const voiceId = await this.getVoiceIdFromLang(voiceLang);
|
||||
await this.#edgeTTS.createAudio(this.getPayload(voiceLang, mark.text, voiceId));
|
||||
} catch (err) {
|
||||
@@ -116,8 +110,7 @@ export class EdgeTTSClient implements TTSClient {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
const { language: voiceLang } = mark;
|
||||
const voiceId = await this.getVoiceIdFromLang(voiceLang);
|
||||
this.#speakingLang = voiceLang;
|
||||
const blob = await this.#edgeTTS.createAudio(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { addPluginListener, PluginListener } from '@tauri-apps/api/core';
|
||||
import { getUserLocale } from '@/utils/misc';
|
||||
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
|
||||
import { parseSSMLMarks } from '@/utils/ssml';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { TTSClient, TTSMessageEvent } from './TTSClient';
|
||||
import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types';
|
||||
@@ -100,13 +100,12 @@ export class NativeTTSClient implements TTSClient {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
async *speakMark(mark: TTSMark, preload: boolean, defaultLang: string, signal: AbortSignal) {
|
||||
async *speakMark(mark: TTSMark, preload: boolean, signal: AbortSignal) {
|
||||
if (preload) {
|
||||
yield { code: 'end', message: 'Dummy preload finished' } as TTSMessageEvent;
|
||||
return;
|
||||
}
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
const { language: voiceLang } = mark;
|
||||
const voiceId = await this.getVoiceIdFromLang(voiceLang);
|
||||
this.#currentVoiceId = voiceId;
|
||||
this.#speakingLang = voiceLang;
|
||||
@@ -179,14 +178,10 @@ export class NativeTTSClient implements TTSClient {
|
||||
}
|
||||
|
||||
async *speak(ssml: string, signal: AbortSignal, preload: boolean = false) {
|
||||
const { marks } = parseSSMLMarks(ssml);
|
||||
let defaultLang = parseSSMLLang(ssml) || 'en';
|
||||
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
|
||||
defaultLang = this.#primaryLang;
|
||||
}
|
||||
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
|
||||
|
||||
for (const mark of marks) {
|
||||
for await (const ev of this.speakMark(mark, preload, defaultLang, signal)) {
|
||||
for await (const ev of this.speakMark(mark, preload, signal)) {
|
||||
if (signal.aborted) {
|
||||
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AppService } from '@/types/system';
|
||||
import { parseSSMLMarks } from '@/utils/ssml';
|
||||
import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
import { TTSGranularity, TTSHighlightOptions, TTSMark, TTSVoice } from './types';
|
||||
import { createRejecttFilter } from '@/utils/node';
|
||||
import { WebSpeechClient } from './WebSpeechClient';
|
||||
import { NativeTTSClient } from './NativeTTSClient';
|
||||
import { EdgeTTSClient } from './EdgeTTSClient';
|
||||
@@ -106,7 +107,14 @@ export class TTSController extends EventTarget {
|
||||
granularity = supportedGranularities[0]!;
|
||||
}
|
||||
const highlightOptions: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
|
||||
await this.view.initTTS(granularity, this.#getHighlighter(highlightOptions));
|
||||
await this.view.initTTS(
|
||||
granularity,
|
||||
createRejecttFilter({
|
||||
tags: ['rt', 'sup'],
|
||||
contents: [{ tag: 'a', content: /^\d+$/ }],
|
||||
}),
|
||||
this.#getHighlighter(highlightOptions),
|
||||
);
|
||||
}
|
||||
|
||||
async preloadSSML(ssml: string | undefined) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getUserLocale } from '@/utils/misc';
|
||||
import { TTSClient, TTSMessageEvent } from './TTSClient';
|
||||
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
|
||||
import { parseSSMLMarks } from '@/utils/ssml';
|
||||
import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types';
|
||||
import { WEB_SPEECH_BLACKLISTED_VOICES } from './TTSData';
|
||||
import { TTSController } from './TTSController';
|
||||
@@ -18,7 +18,7 @@ interface TTSBoundaryEvent {
|
||||
|
||||
async function* speakWithMarks(
|
||||
ssml: string,
|
||||
defaultLang: string,
|
||||
primaryLang: string,
|
||||
getRate: () => number,
|
||||
getPitch: () => number,
|
||||
getVoice: (lang: string) => Promise<SpeechSynthesisVoice | null>,
|
||||
@@ -26,14 +26,11 @@ async function* speakWithMarks(
|
||||
setSpeakingLang: (lang: string) => void,
|
||||
dispatchSpeakMark: (mark: TTSMark) => void,
|
||||
) {
|
||||
const { marks } = parseSSMLMarks(ssml);
|
||||
|
||||
const { marks } = parseSSMLMarks(ssml, primaryLang);
|
||||
const synth = window.speechSynthesis;
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance();
|
||||
for (const mark of marks) {
|
||||
const { language } = mark;
|
||||
const voiceLang = language || defaultLang;
|
||||
const { language: voiceLang } = mark;
|
||||
dispatchSpeakMark(mark);
|
||||
utterance.text = mark.text;
|
||||
utterance.rate = getRate();
|
||||
@@ -149,14 +146,9 @@ export class WebSpeechClient implements TTSClient {
|
||||
// no need to preload for web speech
|
||||
if (preload) return;
|
||||
|
||||
let defaultLang = parseSSMLLang(ssml) || 'en';
|
||||
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
|
||||
defaultLang = this.#primaryLang;
|
||||
}
|
||||
|
||||
for await (const ev of speakWithMarks(
|
||||
ssml,
|
||||
defaultLang,
|
||||
this.#primaryLang,
|
||||
() => this.#rate,
|
||||
() => this.#pitch,
|
||||
this.getWebSpeechVoiceFromLang,
|
||||
|
||||
@@ -23,5 +23,5 @@ export type TTSMark = {
|
||||
offset: number;
|
||||
name: string;
|
||||
text: string;
|
||||
language?: string;
|
||||
language: string;
|
||||
};
|
||||
|
||||
@@ -151,8 +151,10 @@ const indexedDBFileSystem: FileSystem = {
|
||||
async removeDir() {
|
||||
// Directories are virtual in IndexedDB; no-op
|
||||
},
|
||||
async readDir(path: string) {
|
||||
async readDir(path: string, base: BaseDir) {
|
||||
const { fp } = resolvePath(path, base);
|
||||
const db = await openIndexedDB();
|
||||
|
||||
return new Promise<{ path: string; isDir: boolean }[]>((resolve, reject) => {
|
||||
const transaction = db.transaction('files', 'readonly');
|
||||
const store = transaction.objectStore('files');
|
||||
@@ -162,8 +164,8 @@ const indexedDBFileSystem: FileSystem = {
|
||||
const files = request.result as { path: string }[];
|
||||
resolve(
|
||||
files
|
||||
.filter((file) => file.path.startsWith(path))
|
||||
.map((file) => ({ path: file.path, isDir: false })),
|
||||
.filter((file) => file.path.startsWith(fp))
|
||||
.map((file) => ({ path: file.path.slice(fp.length + 1), isDir: false })),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { updateToc } from '@/utils/toc';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useBookDataStore } from './bookDataStore';
|
||||
import { useLibraryStore } from './libraryStore';
|
||||
import { getPrimaryLanguage } from '@/utils/book';
|
||||
import { formatTitle, getBaseFilename, getPrimaryLanguage } from '@/utils/book';
|
||||
|
||||
interface ViewState {
|
||||
/* Unique key for each book view */
|
||||
@@ -138,8 +138,12 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const { book: bookDoc } = await new DocumentLoader(file).open();
|
||||
updateToc(bookDoc, config.viewSettings?.sortedTOC ?? false);
|
||||
// Set the book's language for formerly imported books, newly imported books have this field set
|
||||
book.primaryLanguage =
|
||||
book.primaryLanguage ?? getPrimaryLanguage(bookDoc.metadata.language);
|
||||
if (!bookDoc.metadata.title) {
|
||||
bookDoc.metadata.title = getBaseFilename(file.name);
|
||||
}
|
||||
book.sourceTitle = formatTitle(bookDoc.metadata.title);
|
||||
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
|
||||
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
|
||||
book.metadata = book.metadata ?? bookDoc.metadata;
|
||||
useBookDataStore.setState((state) => ({
|
||||
booksData: {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
border-radius: 10px;
|
||||
scrollbar-gutter: auto !important;
|
||||
overscroll-behavior: none;
|
||||
background-color: transparent;
|
||||
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--safe-area-inset-right: env(safe-area-inset-right);
|
||||
@@ -97,6 +98,25 @@ foliate-view {
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
.window-border {
|
||||
border-radius: 10px;
|
||||
z-index: 999;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.window-border::before {
|
||||
content: '';
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 1px solid theme('colors.base-300');
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
position: relative;
|
||||
@apply bg-base-100;
|
||||
@@ -304,3 +324,12 @@ foliate-view {
|
||||
.drop-zone.drag-over .drop-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.no-context-menu {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export interface Book {
|
||||
filePath?: string;
|
||||
hash: string;
|
||||
format: BookFormat;
|
||||
title: string;
|
||||
title: string; // editable title from metadata
|
||||
sourceTitle?: string; // parsed when the book is imported and used to locate the file
|
||||
author: string;
|
||||
group?: string; // deprecated in favor of groupId and groupName
|
||||
groupId?: string;
|
||||
@@ -26,6 +27,7 @@ export interface Book {
|
||||
|
||||
uploadedAt?: number | null;
|
||||
downloadedAt?: number | null;
|
||||
coverDownloadedAt?: number | null;
|
||||
|
||||
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||
progress?: [number, number]; // Add progress field: [current, total], 1-based page number
|
||||
|
||||
@@ -3,12 +3,14 @@ export interface DBBook {
|
||||
book_hash: string;
|
||||
format: string;
|
||||
title: string;
|
||||
source_title?: string;
|
||||
author: string;
|
||||
group_id?: string;
|
||||
group_name?: string;
|
||||
tags?: string[];
|
||||
progress?: [number, number];
|
||||
|
||||
metadata?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface SystemSettings {
|
||||
keepLogin: boolean;
|
||||
autoUpload: boolean;
|
||||
alwaysOnTop: boolean;
|
||||
openBookInNewWindow: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
screenWakeLock: boolean;
|
||||
alwaysShowStatusBar: boolean;
|
||||
|
||||
@@ -20,7 +20,11 @@ export interface FoliateView extends HTMLElement {
|
||||
clearSearch: () => void;
|
||||
select: (target: string | number | { fraction: number }) => void;
|
||||
deselect: () => void;
|
||||
initTTS: (granularity?: TTSGranularity, highlight?: (range: Range) => void) => Promise<void>;
|
||||
initTTS: (
|
||||
granularity?: TTSGranularity,
|
||||
nodeFilter?: (node: Node) => number,
|
||||
highlight?: (range: Range) => void,
|
||||
) => Promise<void>;
|
||||
book: BookDoc;
|
||||
tts: TTS | null;
|
||||
language: {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const getLibraryFilename = () => {
|
||||
export const getRemoteBookFilename = (book: Book) => {
|
||||
// S3 storage: https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/object-keys.html
|
||||
if (getStorageType() === 'r2') {
|
||||
return `${book.hash}/${makeSafeFilename(book.title)}.${EXTS[book.format]}`;
|
||||
return `${book.hash}/${makeSafeFilename(book.sourceTitle || book.title)}.${EXTS[book.format]}`;
|
||||
} else if (getStorageType() === 's3') {
|
||||
return `${book.hash}/${book.hash}.${EXTS[book.format]}`;
|
||||
} else {
|
||||
@@ -22,7 +22,7 @@ export const getRemoteBookFilename = (book: Book) => {
|
||||
}
|
||||
};
|
||||
export const getLocalBookFilename = (book: Book) => {
|
||||
return `${book.hash}/${makeSafeFilename(book.title)}.${EXTS[book.format]}`;
|
||||
return `${book.hash}/${makeSafeFilename(book.sourceTitle || book.title)}.${EXTS[book.format]}`;
|
||||
};
|
||||
export const getCoverFilename = (book: Book) => {
|
||||
return `${book.hash}/cover.png`;
|
||||
|
||||
@@ -68,7 +68,32 @@ export const isSameLang = (lang1?: string | null, lang2?: string | null): boolea
|
||||
return normalizedLang1 === normalizedLang2;
|
||||
};
|
||||
|
||||
export const isValidLang = (lang?: string) => {
|
||||
if (!lang) return false;
|
||||
const code = lang.split('-')[0]!.toLowerCase();
|
||||
return iso6392.some((l) => l.iso6391 === code || l.iso6392B === code);
|
||||
};
|
||||
|
||||
export const code6392to6391 = (code: string): string => {
|
||||
const lang = iso6392.find((l) => l.iso6392B === code);
|
||||
return lang?.iso6391 || '';
|
||||
};
|
||||
|
||||
export const getLanguageName = (code: string): string => {
|
||||
const lang = normalizedLangCode(code);
|
||||
const language = iso6392.find((l) => l.iso6391 === lang || l.iso6392B === lang);
|
||||
return language ? language.name : lang;
|
||||
};
|
||||
|
||||
export const inferLangFromScript = (text: string, lang: string): string => {
|
||||
if (!lang || lang === 'en') {
|
||||
if (/[\p{Script=Hangul}]/u.test(text)) {
|
||||
return 'ko';
|
||||
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
|
||||
return 'ja';
|
||||
} else if (/[\p{Script=Han}]/u.test(text)) {
|
||||
return 'zh';
|
||||
}
|
||||
}
|
||||
return lang;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
import { useRouter, redirect } from 'next/navigation';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { isPWA, isWebAppPlatform } from '@/services/environment';
|
||||
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
||||
import { AppService } from '@/types/system';
|
||||
|
||||
let readerWindowsCount = 0;
|
||||
const createReaderWindow = (appService: AppService, url: string) => {
|
||||
const win = new WebviewWindow(`reader-${readerWindowsCount}`, {
|
||||
url,
|
||||
width: 800,
|
||||
height: 600,
|
||||
center: true,
|
||||
resizable: true,
|
||||
title: appService.isMacOSApp ? '' : 'Readest',
|
||||
decorations: appService.isMacOSApp ? true : false,
|
||||
transparent: appService.isMacOSApp ? false : true,
|
||||
shadow: appService.isMacOSApp ? undefined : true,
|
||||
titleBarStyle: appService.isMacOSApp ? 'overlay' : undefined,
|
||||
});
|
||||
win.once('tauri://created', () => {
|
||||
console.log('new window created');
|
||||
readerWindowsCount += 1;
|
||||
});
|
||||
win.once('tauri://error', (e) => {
|
||||
console.error('error creating window', e);
|
||||
});
|
||||
win.once('tauri://destroyed', () => {
|
||||
readerWindowsCount -= 1;
|
||||
});
|
||||
};
|
||||
|
||||
export const showReaderWindow = (appService: AppService, bookIds: string[]) => {
|
||||
const ids = bookIds.join(BOOK_IDS_SEPARATOR);
|
||||
const params = new URLSearchParams('');
|
||||
params.set('ids', ids);
|
||||
const url = `/reader?${params.toString()}`;
|
||||
createReaderWindow(appService, url);
|
||||
};
|
||||
|
||||
export const showLibraryWindow = (appService: AppService, filenames: string[]) => {
|
||||
const files = filenames.map((file) => `${encodeURIComponent(file)}`).join(',');
|
||||
const url = `/library?files=${files}`;
|
||||
createReaderWindow(appService, url);
|
||||
};
|
||||
|
||||
export const navigateToReader = (
|
||||
router: ReturnType<typeof useRouter>,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export const createRejecttFilter = ({
|
||||
tags = [],
|
||||
classes = [],
|
||||
attributes = [],
|
||||
contents = [],
|
||||
}: {
|
||||
tags?: string[];
|
||||
classes?: string[];
|
||||
attributes?: string[];
|
||||
contents?: { tag: string; content: RegExp }[];
|
||||
}) => {
|
||||
return (node: Node): number => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const name = (node as Element).tagName.toLowerCase();
|
||||
if (name === 'script' || name === 'style') {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (tags.includes(name)) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (classes.some((cls) => (node as Element).classList.contains(cls))) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (attributes.some((attr) => (node as Element).hasAttribute(attr))) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (
|
||||
contents.some(({ tag, content }) => {
|
||||
return name === tag && content.test((node as Element).textContent || '');
|
||||
})
|
||||
) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
};
|
||||
};
|
||||
@@ -183,13 +183,13 @@ export const getPopupPosition = (
|
||||
return { point: popupPoint, dir: position.dir } as Position;
|
||||
};
|
||||
|
||||
export const getTextFromRange = (range: Range, rejects: string[] = []): string => {
|
||||
export const getTextFromRange = (range: Range, rejectTags: string[] = []): string => {
|
||||
const clonedRange = range.cloneRange();
|
||||
const fragment = clonedRange.cloneContents();
|
||||
const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const parent = node.parentElement;
|
||||
if (rejects.includes(parent?.tagName.toLowerCase() || '')) {
|
||||
if (rejectTags.includes(parent?.tagName.toLowerCase() || '')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TTSMark } from '@/services/tts/types';
|
||||
import { inferLangFromScript, isSameLang, isValidLang } from './lang';
|
||||
|
||||
const cleanTextContent = (text: string) =>
|
||||
text.replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ').trimStart();
|
||||
@@ -16,9 +17,28 @@ export const genSSML = (lang: string, text: string, voice: string, rate: number)
|
||||
`;
|
||||
};
|
||||
|
||||
export const parseSSMLMarks = (ssml: string) => {
|
||||
const speakMatch = ssml.match(/<speak[^>]*xml:lang="([^"]+)"[^>]*>/i);
|
||||
const defaultLang = speakMatch?.[1] ?? 'en';
|
||||
export const parseSSMLLang = (ssml: string, primaryLang?: string): string => {
|
||||
let lang = 'en';
|
||||
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
|
||||
if (match && match[1]) {
|
||||
const parts = match[1].split('-');
|
||||
lang =
|
||||
parts.length > 1
|
||||
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
|
||||
: parts[0]!.toLowerCase();
|
||||
|
||||
if (!isValidLang(lang)) {
|
||||
lang = 'en';
|
||||
}
|
||||
}
|
||||
if (lang === 'en' && primaryLang && !isSameLang(lang, primaryLang)) {
|
||||
lang = primaryLang.split('-')[0]!.toLowerCase();
|
||||
}
|
||||
return inferLangFromScript(ssml, lang);
|
||||
};
|
||||
|
||||
export const parseSSMLMarks = (ssml: string, primaryLang?: string) => {
|
||||
const defaultLang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
ssml = ssml.replace(/<speak[^>]*>/i, '').replace(/<\/speak>/i, '');
|
||||
|
||||
let plainText = '';
|
||||
@@ -93,29 +113,3 @@ export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const inferLangFromScript = (text: string, lang: string | null): string | null => {
|
||||
if (!lang || lang.startsWith('en')) {
|
||||
if (/[\p{Script=Hangul}]/u.test(text)) {
|
||||
return 'ko';
|
||||
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
|
||||
return 'ja';
|
||||
} else if (/[\p{Script=Han}]/u.test(text)) {
|
||||
return 'zh';
|
||||
}
|
||||
}
|
||||
return lang;
|
||||
};
|
||||
|
||||
export const parseSSMLLang = (ssml: string): string | null => {
|
||||
let lang = null;
|
||||
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
|
||||
if (match && match[1]) {
|
||||
const parts = match[1].split('-');
|
||||
lang =
|
||||
parts.length > 1
|
||||
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
|
||||
: parts[0]!.toLowerCase();
|
||||
}
|
||||
return inferLangFromScript(ssml, lang);
|
||||
};
|
||||
|
||||
@@ -213,8 +213,15 @@ const getLayoutStyles = (
|
||||
}
|
||||
/* enlarge the clickable area of links */
|
||||
a {
|
||||
padding: 10px;
|
||||
margin: -10px;
|
||||
position: relative !important;
|
||||
}
|
||||
a::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
right: -10px;
|
||||
bottom: -10px;
|
||||
}
|
||||
p, blockquote, dd, div:not(:has(*:not(b, a, em, i, strong, u, span))) {
|
||||
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
|
||||
@@ -237,7 +244,8 @@ const getLayoutStyles = (
|
||||
text-indent: initial !important;
|
||||
}
|
||||
blockquote[align="center"], div[align="center"],
|
||||
p[align="center"], dd[align="center"] {
|
||||
p[align="center"], dd[align="center"],
|
||||
li p, ol p, ul p {
|
||||
text-indent: initial !important;
|
||||
}
|
||||
p {
|
||||
|
||||
@@ -41,11 +41,13 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
hash,
|
||||
format,
|
||||
title,
|
||||
sourceTitle,
|
||||
author,
|
||||
groupId,
|
||||
groupName,
|
||||
tags,
|
||||
progress,
|
||||
metadata,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
@@ -62,6 +64,8 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
group_name: groupName,
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
source_title: sourceTitle,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
||||
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
||||
@@ -79,6 +83,8 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
group_name,
|
||||
tags,
|
||||
progress,
|
||||
source_title,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
@@ -94,6 +100,8 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
groupName: group_name,
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
sourceTitle: source_title,
|
||||
metadata: metadata ? JSON.parse(metadata) : null,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at).getTime() : null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { TauriEvent } from '@tauri-apps/api/event';
|
||||
import { emitTo, TauriEvent } from '@tauri-apps/api/event';
|
||||
import { exit } from '@tauri-apps/plugin-process';
|
||||
import { eventDispatcher } from './event';
|
||||
|
||||
@@ -33,8 +33,12 @@ export const tauriHandleOnCloseWindow = async (callback: () => void) => {
|
||||
const currentWindow = getCurrentWindow();
|
||||
return currentWindow.listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => {
|
||||
await callback();
|
||||
console.log('exit app');
|
||||
await exit(0);
|
||||
if (currentWindow.label.startsWith('reader')) {
|
||||
await emitTo('main', 'close-reader-window', { label: currentWindow.label });
|
||||
setTimeout(() => currentWindow.destroy(), 300);
|
||||
} else if (currentWindow.label === 'main') {
|
||||
await currentWindow.destroy();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"repository": "readest/readest",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"tauri": "pnpm --filter @readest/readest-app tauri"
|
||||
"tauri": "pnpm --filter @readest/readest-app tauri",
|
||||
"dev-web": "pnpm --filter @readest/readest-app dev-web"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: ff36292b7a...97b906075f
Reference in New Issue
Block a user