forked from akai/readest
@@ -885,5 +885,6 @@
|
||||
"Error": "خطأ",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "تعذر تحميل المقال. حاول البحث مباشرة على {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "تعذر تحميل الكلمة. حاول البحث مباشرة على {{link}}.",
|
||||
"Date Published": "تاريخ النشر"
|
||||
"Date Published": "تاريخ النشر",
|
||||
"Only for TTS:": "فقط لـ TTS:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "ত্রুটি",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "নিবন্ধ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "শব্দ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
|
||||
"Date Published": "প্রকাশনার তারিখ"
|
||||
"Date Published": "প্রকাশনার তারিখ",
|
||||
"Only for TTS:": "শুধুমাত্র TTS-এর জন্য:"
|
||||
}
|
||||
|
||||
@@ -835,5 +835,6 @@
|
||||
"Error": "ནོར་འཁྲུལ།",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "རྩོམ་ཡིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "ཚིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
|
||||
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།"
|
||||
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།",
|
||||
"Only for TTS:": "TTS ལ་ཁོ་ན།:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "Fehler",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Artikel kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Wort kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
|
||||
"Date Published": "Veröffentlichungsdatum"
|
||||
"Date Published": "Veröffentlichungsdatum",
|
||||
"Only for TTS:": "Nur für TTS:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "Σφάλμα",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης του άρθρου. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης της λέξης. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
|
||||
"Date Published": "Ημερομηνία δημοσίευσης"
|
||||
"Date Published": "Ημερομηνία δημοσίευσης",
|
||||
"Only for TTS:": "Μόνο για TTS:"
|
||||
}
|
||||
|
||||
@@ -855,5 +855,6 @@
|
||||
"Error": "Error",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "No se puede cargar el artículo. Intenta buscar directamente en {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "No se puede cargar la palabra. Intenta buscar directamente en {{link}}.",
|
||||
"Date Published": "Fecha de publicación"
|
||||
"Date Published": "Fecha de publicación",
|
||||
"Only for TTS:": "Solo para TTS:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "خطا",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "بارگذاری مقاله امکانپذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "بارگذاری کلمه امکانپذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
|
||||
"Date Published": "تاریخ انتشار"
|
||||
"Date Published": "تاریخ انتشار",
|
||||
"Only for TTS:": "فقط برای TTS:"
|
||||
}
|
||||
|
||||
@@ -855,5 +855,6 @@
|
||||
"Error": "Erreur",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Impossible de charger l'article. Essayez de rechercher directement sur {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Impossible de charger le mot. Essayez de rechercher directement sur {{link}}.",
|
||||
"Date Published": "Date de publication"
|
||||
"Date Published": "Date de publication",
|
||||
"Only for TTS:": "Uniquement pour TTS :"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "त्रुटि",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "लेख लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "शब्द लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
|
||||
"Date Published": "प्रकाशन तिथि"
|
||||
"Date Published": "प्रकाशन तिथि",
|
||||
"Only for TTS:": "केवल TTS के लिए:"
|
||||
}
|
||||
|
||||
@@ -835,5 +835,6 @@
|
||||
"Error": "Kesalahan",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuat artikel. Coba cari langsung di {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuat kata. Coba cari langsung di {{link}}.",
|
||||
"Date Published": "Tanggal terbit"
|
||||
"Date Published": "Tanggal terbit",
|
||||
"Only for TTS:": "Hanya untuk TTS:"
|
||||
}
|
||||
|
||||
@@ -855,5 +855,6 @@
|
||||
"Error": "Errore",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Impossibile caricare l'articolo. Prova a cercare direttamente su {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Impossibile caricare la parola. Prova a cercare direttamente su {{link}}.",
|
||||
"Date Published": "Data di pubblicazione"
|
||||
"Date Published": "Data di pubblicazione",
|
||||
"Only for TTS:": "Solo per TTS:"
|
||||
}
|
||||
|
||||
@@ -835,5 +835,6 @@
|
||||
"Error": "エラー",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "記事を読み込めません。{{link}}で直接検索してみてください。",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "単語を読み込めません。{{link}}で直接検索してみてください。",
|
||||
"Date Published": "出版日"
|
||||
"Date Published": "出版日",
|
||||
"Only for TTS:": "TTSのみ:"
|
||||
}
|
||||
|
||||
@@ -835,5 +835,6 @@
|
||||
"Error": "오류",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "문서를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "단어를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
|
||||
"Date Published": "출판일"
|
||||
"Date Published": "출판일",
|
||||
"Only for TTS:": "TTS 전용:"
|
||||
}
|
||||
|
||||
@@ -835,5 +835,6 @@
|
||||
"Error": "Ralat",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuatkan artikel. Cuba cari terus di {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuatkan perkataan. Cuba cari terus di {{link}}.",
|
||||
"Date Published": "Tarikh diterbitkan"
|
||||
"Date Published": "Tarikh diterbitkan",
|
||||
"Only for TTS:": "Hanya untuk TTS:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "Fout",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Kan het artikel niet laden. Probeer direct te zoeken op {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Kan het woord niet laden. Probeer direct te zoeken op {{link}}.",
|
||||
"Date Published": "Publicatiedatum"
|
||||
"Date Published": "Publicatiedatum",
|
||||
"Only for TTS:": "Alleen voor TTS:"
|
||||
}
|
||||
|
||||
@@ -865,5 +865,6 @@
|
||||
"Error": "Błąd",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Nie można załadować artykułu. Spróbuj wyszukać bezpośrednio na {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Nie można załadować słowa. Spróbuj wyszukać bezpośrednio na {{link}}.",
|
||||
"Date Published": "Data wydania"
|
||||
"Date Published": "Data wydania",
|
||||
"Only for TTS:": "Tylko dla TTS:"
|
||||
}
|
||||
|
||||
@@ -855,5 +855,6 @@
|
||||
"Error": "Erro",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Não foi possível carregar o artigo. Tente pesquisar diretamente em {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Não foi possível carregar a palavra. Tente pesquisar diretamente em {{link}}.",
|
||||
"Date Published": "Data de publicação"
|
||||
"Date Published": "Data de publicação",
|
||||
"Only for TTS:": "Apenas para TTS:"
|
||||
}
|
||||
|
||||
@@ -865,5 +865,6 @@
|
||||
"Error": "Ошибка",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Не удалось загрузить статью. Попробуйте искать напрямую на {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Не удалось загрузить слово. Попробуйте искать напрямую на {{link}}.",
|
||||
"Date Published": "Дата публикации"
|
||||
"Date Published": "Дата публикации",
|
||||
"Only for TTS:": "Только для TTS:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "දෝෂය",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "ලිපිය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "වචනය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
|
||||
"Date Published": "ප්රකාශන දිනය"
|
||||
"Date Published": "ප්රකාශන දිනය",
|
||||
"Only for TTS:": "TTS සඳහා පමණක්:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "Fel",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Kan inte ladda artikeln. Försök söka direkt på {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Kan inte ladda ordet. Försök söka direkt på {{link}}.",
|
||||
"Date Published": "Publiceringsdatum"
|
||||
"Date Published": "Publiceringsdatum",
|
||||
"Only for TTS:": "Endast för TTS:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "பிழை",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "கட்டுரையை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "சொல்லை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
|
||||
"Date Published": "வெளியீட்டு தேதி"
|
||||
"Date Published": "வெளியீட்டு தேதி",
|
||||
"Only for TTS:": "TTS க்கு மட்டும்:"
|
||||
}
|
||||
|
||||
@@ -835,5 +835,6 @@
|
||||
"Error": "ข้อผิดพลาด",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "ไม่สามารถโหลดบทความได้ ลองค้นหาโดยตรงที่ {{link}}",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "ไม่สามารถโหลดคำได้ ลองค้นหาโดยตรงที่ {{link}}",
|
||||
"Date Published": "วันที่เผยแพร่"
|
||||
"Date Published": "วันที่เผยแพร่",
|
||||
"Only for TTS:": "สำหรับ TTS เท่านั้น:"
|
||||
}
|
||||
|
||||
@@ -845,5 +845,6 @@
|
||||
"Error": "Hata",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Makale yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Kelime yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
|
||||
"Date Published": "Yayın tarihi"
|
||||
"Date Published": "Yayın tarihi",
|
||||
"Only for TTS:": "Sadece TTS için:"
|
||||
}
|
||||
|
||||
@@ -865,5 +865,6 @@
|
||||
"Error": "Помилка",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Не вдалося завантажити статтю. Спробуйте шукати безпосередньо на {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Не вдалося завантажити слово. Спробуйте шукати безпосередньо на {{link}}.",
|
||||
"Date Published": "Дата публікації"
|
||||
"Date Published": "Дата публікації",
|
||||
"Only for TTS:": "Тільки для TTS:"
|
||||
}
|
||||
|
||||
@@ -835,5 +835,6 @@
|
||||
"Error": "Lỗi",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "Không thể tải bài viết. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "Không thể tải từ. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
|
||||
"Date Published": "Ngày xuất bản"
|
||||
"Date Published": "Ngày xuất bản",
|
||||
"Only for TTS:": "Chỉ dành cho TTS:"
|
||||
}
|
||||
|
||||
@@ -832,6 +832,7 @@
|
||||
"Failed to export the book.": "书籍导出失败。",
|
||||
"Export Book": "导出书籍",
|
||||
"Whole word:": "全词匹配:",
|
||||
"Only for TTS:": "仅用于TTS:",
|
||||
"Error": "错误",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "无法加载文章。请直接在 {{link}} 上搜索。",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "无法加载词汇。请直接在 {{link}} 上搜索。",
|
||||
|
||||
@@ -835,5 +835,6 @@
|
||||
"Error": "錯誤",
|
||||
"Unable to load the article. Try searching directly on {{link}}.": "無法載入文章。請直接在 {{link}} 上搜尋。",
|
||||
"Unable to load the word. Try searching directly on {{link}}.": "無法載入詞彙。請直接在 {{link}} 上搜尋。",
|
||||
"Date Published": "出版日期"
|
||||
"Date Published": "出版日期",
|
||||
"Only for TTS:": "僅用於TTS:"
|
||||
}
|
||||
|
||||
@@ -402,11 +402,11 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
const csRuleElement = screen.getByText('case-sensitive').closest('li');
|
||||
expect(within(csRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
|
||||
expect(within(csRuleElement!).getByText(/Yes/)).toBeTruthy();
|
||||
expect(within(csRuleElement!).getAllByText(/Yes/)).toBeTruthy();
|
||||
|
||||
const ciRuleElement = screen.getByText('case-insensitive').closest('li');
|
||||
expect(within(ciRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
|
||||
expect(within(ciRuleElement!).getByText(/No/)).toBeTruthy();
|
||||
expect(within(ciRuleElement!).getAllByText(/No/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens when BookMenu item is clicked (integration)', async () => {
|
||||
|
||||
@@ -86,14 +86,14 @@ const RuleItem: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-3 p-3'>
|
||||
<div className='relative flex items-start justify-between gap-3 p-3'>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-1.5'>
|
||||
<div className='break-words text-base font-medium leading-snug'>{rule.pattern}</div>
|
||||
<div className='text-base-content/70 break-words text-sm'>
|
||||
<span className='text-base-content/80 mr-1.5 text-xs font-medium'>
|
||||
{_('Replace with:')}
|
||||
</span>
|
||||
<span className='text-base-content/90'>{rule.replacement}</span>
|
||||
<span className='text-base-content/90 text-xs'>{rule.replacement}</span>
|
||||
</div>
|
||||
<div className='text-base-content/60 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs'>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
@@ -116,9 +116,16 @@ const RuleItem: React.FC<{
|
||||
{rule.caseSensitive !== false ? _('Yes') : _('No')}
|
||||
</span>
|
||||
</span>
|
||||
<span className='text-base-content/30'>•</span>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<span className='text-base-content/50'>{_('Only for TTS:')}</span>
|
||||
<span className='text-base-content/70 font-medium'>
|
||||
{rule.onlyForTTS === true ? _('Yes') : _('No')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
<div className='absolute right-2 top-2 flex items-center gap-1'>
|
||||
<button
|
||||
className='btn btn-ghost btn-sm h-8 w-8 p-0'
|
||||
onClick={onEdit}
|
||||
@@ -187,7 +194,8 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
enabled: boolean;
|
||||
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
|
||||
onlyForTTS: boolean;
|
||||
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true, onlyForTTS: false });
|
||||
|
||||
const { singleRules, bookRules } = useReplacementRules(sideBarBookKey);
|
||||
|
||||
@@ -199,18 +207,26 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
el?.removeEventListener('setProofreadRulesVisibility', handleVisibility as EventListener);
|
||||
}, []);
|
||||
|
||||
const startEdit = (rule: ProofreadRule, scope: ProofreadScope) => {
|
||||
const startEdit = (rule: ProofreadRule) => {
|
||||
setEditing({
|
||||
id: rule.id,
|
||||
scope,
|
||||
scope: rule.scope,
|
||||
pattern: rule.pattern,
|
||||
replacement: rule.replacement,
|
||||
enabled: !!rule.enabled,
|
||||
onlyForTTS: !!rule.onlyForTTS,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
|
||||
setEditing({
|
||||
id: null,
|
||||
scope: null,
|
||||
pattern: '',
|
||||
replacement: '',
|
||||
enabled: true,
|
||||
onlyForTTS: false,
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
@@ -221,16 +237,22 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
pattern: editing.pattern,
|
||||
replacement: editing.replacement,
|
||||
enabled: editing.enabled,
|
||||
onlyForTTS: editing.onlyForTTS,
|
||||
});
|
||||
|
||||
cancelEdit();
|
||||
recreateViewer(envConfig, sideBarBookKey);
|
||||
|
||||
if (!editing.onlyForTTS) {
|
||||
recreateViewer(envConfig, sideBarBookKey);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (ruleId: string, scope: ProofreadScope) => {
|
||||
const deleteRule = async (rule: ProofreadRule) => {
|
||||
if (!sideBarBookKey) return;
|
||||
await removeRule(envConfig, sideBarBookKey, ruleId, scope);
|
||||
recreateViewer(envConfig, sideBarBookKey);
|
||||
await removeRule(envConfig, sideBarBookKey, rule.id, rule.scope);
|
||||
if (!rule.onlyForTTS) {
|
||||
recreateViewer(envConfig, sideBarBookKey);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRuleList = (
|
||||
@@ -260,10 +282,8 @@ export const ProofreadRulesManager: React.FC = () => {
|
||||
editing.scope === (scopeType === 'selection' ? 'selection' : rule.scope)
|
||||
}
|
||||
editingData={editing}
|
||||
onEdit={() => startEdit(rule, scopeType === 'selection' ? 'selection' : rule.scope)}
|
||||
onDelete={() =>
|
||||
deleteRule(rule.id, scopeType === 'selection' ? 'selection' : rule.scope)
|
||||
}
|
||||
onEdit={() => startEdit(rule)}
|
||||
onDelete={() => deleteRule(rule)}
|
||||
onSave={saveEdit}
|
||||
onCancel={cancelEdit}
|
||||
onEditChange={(_, value) => setEditing({ ...editing, replacement: value })}
|
||||
|
||||
@@ -43,6 +43,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
|
||||
const [caseSensitive, setCaseSensitive] = useState(true);
|
||||
const [wholeWord, setWholeWord] = useState(!isPunctuationOnly(selection?.text || ''));
|
||||
const [scope, setScope] = useState<ProofreadScope>('selection');
|
||||
const [onlyForTTS, setOnlyForTTS] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useAutoFocus<HTMLInputElement>({ ref: inputRef });
|
||||
@@ -89,6 +90,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
|
||||
enabled: true,
|
||||
caseSensitive,
|
||||
wholeWord: wholeWord,
|
||||
onlyForTTS: scope !== 'selection' ? onlyForTTS : undefined,
|
||||
};
|
||||
onConfirm?.(options);
|
||||
|
||||
@@ -96,7 +98,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
|
||||
|
||||
onDismiss();
|
||||
|
||||
if (scope !== 'selection') {
|
||||
if (scope !== 'selection' && !onlyForTTS) {
|
||||
if (getView(bookKey)) {
|
||||
recreateViewer(envConfig, bookKey);
|
||||
}
|
||||
@@ -162,7 +164,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-8 p-4'>
|
||||
<div className='flex flex-wrap items-center gap-4 p-4'>
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<span className='line-clamp-1 text-xs' title={_('Case sensitive:')}>
|
||||
{_('Case sensitive:')}
|
||||
@@ -196,6 +198,24 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
|
||||
onChange={(e) => setWholeWord(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className='flex cursor-pointer items-center gap-2'>
|
||||
<span className='line-clamp-1 text-xs' title={_('Only for TTS:')}>
|
||||
{_('Only for TTS:')}
|
||||
</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
disabled={scope === 'selection'}
|
||||
className='toggle toggle-sm bg-gray-500 checked:bg-black hover:bg-gray-500 hover:checked:bg-black'
|
||||
style={
|
||||
{
|
||||
'--tglbg': '#4B5563',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
checked={onlyForTTS}
|
||||
onChange={(e) => setOnlyForTTS(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className='flex flex-1 items-center justify-between gap-2 p-4'>
|
||||
<label htmlFor='scope-select' className='line-clamp-1 text-xs' title={_('Scope:')}>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useProofreadStore } from '@/store/proofreadStore';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { proofreadTransformer } from '@/services/transformers/proofread';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
|
||||
import { TTSController, SILENCE_DATA, TTSMark, TTSHighlightOptions } from '@/services/tts';
|
||||
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
|
||||
import { getPopupPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
@@ -38,11 +41,12 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const { safeAreaInsets, isDarkMode } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { setViewSettings, setTTSEnabled } = useReaderStore();
|
||||
const { getMergedRules } = useProofreadStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const [ttsLang, setTtsLang] = useState<string>('en');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -285,6 +289,61 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
|
||||
}, [getTTSTargetLang]);
|
||||
|
||||
const transformCtx: TransformContext = useMemo(
|
||||
() => ({
|
||||
bookKey,
|
||||
viewSettings: getViewSettings(bookKey)!,
|
||||
userLocale: getLocale(),
|
||||
content: '',
|
||||
transformers: [],
|
||||
reversePunctuationTransform: true,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const preprocessSSMLForTTS = useCallback(
|
||||
async (ssml: string) => {
|
||||
const rules = getMergedRules(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const ttsOnlyRules = rules.filter(
|
||||
(rule) =>
|
||||
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
|
||||
);
|
||||
if (ttsOnlyRules.length === 0) return ssml;
|
||||
|
||||
transformCtx['content'] = ssml;
|
||||
transformCtx['viewSettings'] = viewSettings;
|
||||
ssml = await proofreadTransformer.transform(transformCtx, {
|
||||
docType: 'text/xml',
|
||||
onlyForTTS: true,
|
||||
});
|
||||
return ssml;
|
||||
},
|
||||
[bookKey, getMergedRules, getViewSettings, transformCtx],
|
||||
);
|
||||
|
||||
const getTTSHighlightOptions = useCallback(
|
||||
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
|
||||
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
|
||||
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
|
||||
return {
|
||||
...ttsHighlightOptions,
|
||||
color,
|
||||
};
|
||||
},
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
|
||||
if (ttsControllerRef.current && ttsHighlightOptions) {
|
||||
ttsControllerRef.current.initViewTTS(
|
||||
getTTSHighlightOptions(ttsHighlightOptions, viewSettings.isEink),
|
||||
);
|
||||
}
|
||||
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
|
||||
|
||||
const handleTTSSpeak = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
|
||||
if (bookKey !== ttsBookKey) return;
|
||||
@@ -338,12 +397,14 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
if (!oneTime) {
|
||||
setShowIndicator(true);
|
||||
}
|
||||
const ttsController = new TTSController(appService, view, !!user?.id);
|
||||
const ttsController = new TTSController(appService, view, !!user?.id, preprocessSSMLForTTS);
|
||||
ttsControllerRef.current = ttsController;
|
||||
setTtsController(ttsController);
|
||||
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS(viewSettings.ttsHighlightOptions);
|
||||
await ttsController.initViewTTS(
|
||||
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
|
||||
);
|
||||
const ssml = view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
@@ -643,13 +704,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
};
|
||||
}, [hoveredBookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
|
||||
if (ttsControllerRef.current && ttsHighlightOptions) {
|
||||
ttsControllerRef.current.initViewTTS(ttsHighlightOptions);
|
||||
}
|
||||
}, [viewSettings?.ttsHighlightOptions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
|
||||
|
||||
@@ -205,7 +205,12 @@ function getTextNodes(doc: Document): Text[] {
|
||||
export const proofreadTransformer: Transformer = {
|
||||
name: 'proofread',
|
||||
|
||||
transform: async (ctx) => {
|
||||
transform: async (ctx, options) => {
|
||||
const { docType = 'text/html', onlyForTTS = false } =
|
||||
(options as {
|
||||
docType?: DOMParserSupportedType;
|
||||
onlyForTTS?: boolean;
|
||||
}) || {};
|
||||
const globalRules = useSettingsStore.getState().settings?.globalViewSettings?.proofreadRules;
|
||||
const bookRules = ctx.viewSettings.proofreadRules;
|
||||
const merged = [...(globalRules ?? []), ...(bookRules ?? [])].sort(
|
||||
@@ -215,6 +220,7 @@ export const proofreadTransformer: Transformer = {
|
||||
|
||||
const processed = merged
|
||||
.filter((r) => r.enabled && r.pattern.trim())
|
||||
.filter((r) => (onlyForTTS ? r.onlyForTTS : !r.onlyForTTS))
|
||||
.map((r) => ({
|
||||
...r,
|
||||
normalizedPattern: normalizePattern(r.pattern, r.isRegex, r.caseSensitive !== false),
|
||||
@@ -223,7 +229,7 @@ export const proofreadTransformer: Transformer = {
|
||||
if (!processed.length) return ctx.content;
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(ctx.content, 'text/html');
|
||||
const doc = parser.parseFromString(ctx.content, docType);
|
||||
const textNodes = getTextNodes(doc);
|
||||
|
||||
const byScope = {
|
||||
|
||||
@@ -15,5 +15,5 @@ export type TransformContext = {
|
||||
|
||||
export type Transformer = {
|
||||
name: string;
|
||||
transform: (ctx: TransformContext) => Promise<string>;
|
||||
transform: (ctx: TransformContext, options?: unknown) => Promise<string>;
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export class TTSController extends EventTarget {
|
||||
appService: AppService | null = null;
|
||||
view: FoliateView;
|
||||
isAuthenticated: boolean = false;
|
||||
preprocessCallback?: (ssml: string) => Promise<string>;
|
||||
#nossmlCnt: number = 0;
|
||||
#currentSpeakAbortController: AbortController | null = null;
|
||||
#currentSpeakPromise: Promise<void> | null = null;
|
||||
@@ -44,7 +45,12 @@ export class TTSController extends EventTarget {
|
||||
|
||||
options: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
|
||||
|
||||
constructor(appService: AppService | null, view: FoliateView, isAuthenticated: boolean = false) {
|
||||
constructor(
|
||||
appService: AppService | null,
|
||||
view: FoliateView,
|
||||
isAuthenticated: boolean = false,
|
||||
preprocessCallback?: (ssml: string) => Promise<string>,
|
||||
) {
|
||||
super();
|
||||
this.ttsWebClient = new WebSpeechClient(this);
|
||||
this.ttsEdgeClient = new EdgeTTSClient(this);
|
||||
@@ -56,6 +62,7 @@ export class TTSController extends EventTarget {
|
||||
this.appService = appService;
|
||||
this.view = view;
|
||||
this.isAuthenticated = isAuthenticated;
|
||||
this.preprocessCallback = preprocessCallback;
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -112,7 +119,7 @@ export class TTSController extends EventTarget {
|
||||
granularity,
|
||||
createRejectFilter({
|
||||
tags: ['rt'],
|
||||
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/}],
|
||||
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
|
||||
}),
|
||||
this.#getHighlighter(),
|
||||
);
|
||||
@@ -129,7 +136,7 @@ export class TTSController extends EventTarget {
|
||||
if (!tts) return;
|
||||
let preloaded = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ssml = this.#preprocessSSML(tts.next());
|
||||
const ssml = await this.#preprocessSSML(tts.next());
|
||||
this.preloadSSML(ssml, new AbortController().signal);
|
||||
if (ssml) preloaded++;
|
||||
}
|
||||
@@ -138,7 +145,7 @@ export class TTSController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
#preprocessSSML(ssml?: string) {
|
||||
async #preprocessSSML(ssml?: string) {
|
||||
if (!ssml) return;
|
||||
ssml = ssml
|
||||
.replace(/<emphasis[^>]*>([^<]+)<\/emphasis>/g, '$1')
|
||||
@@ -152,6 +159,11 @@ export class TTSController extends EventTarget {
|
||||
if (this.ttsTargetLang) {
|
||||
ssml = filterSSMLWithLang(ssml, this.ttsTargetLang);
|
||||
}
|
||||
|
||||
if (this.preprocessCallback) {
|
||||
ssml = await this.preprocessCallback(ssml);
|
||||
}
|
||||
|
||||
return ssml;
|
||||
}
|
||||
|
||||
@@ -169,7 +181,7 @@ export class TTSController extends EventTarget {
|
||||
resolve();
|
||||
});
|
||||
|
||||
ssml = this.#preprocessSSML(await ssml);
|
||||
ssml = await this.#preprocessSSML(await ssml);
|
||||
if (!ssml) {
|
||||
this.#nossmlCnt++;
|
||||
// FIXME: in case we are at the end of the book, need a better way to handle this
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface CreateProofreadRuleOptions {
|
||||
caseSensitive?: boolean;
|
||||
order?: number;
|
||||
wholeWord?: boolean;
|
||||
onlyForTTS?: boolean;
|
||||
}
|
||||
|
||||
interface ProofreadStoreState {
|
||||
@@ -58,6 +59,7 @@ function createProofreadRule(opts: CreateProofreadRuleOptions): ProofreadRule {
|
||||
caseSensitive: opts.caseSensitive ?? true,
|
||||
order: opts.order ?? 1000,
|
||||
wholeWord: opts.wholeWord ?? true,
|
||||
onlyForTTS: opts.onlyForTTS ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,7 @@ export interface ProofreadRule {
|
||||
order: number; // Lower numbers apply first
|
||||
wholeWord?: boolean; // Match whole words only (uses \b word boundaries)
|
||||
caseSensitive?: boolean; // Case-sensitive matching (default true)
|
||||
onlyForTTS?: boolean; // Only replace text for TTS, not in the book display (only for book/library scope)
|
||||
}
|
||||
|
||||
export interface ProofreadRulesConfig {
|
||||
|
||||
Reference in New Issue
Block a user