feat: support full book translation to bilingual books, closes #398 (#1240)

This commit is contained in:
Huang Xin
2025-05-26 18:23:33 +08:00
committed by GitHub
parent 033e161455
commit 699a01c78f
38 changed files with 576 additions and 64 deletions
@@ -306,7 +306,6 @@
"File Size:": "حجم الملف:",
"Auto Detect": "الكشف التلقائي",
"Touch": "اللمس",
"Scrolling Overlap (px)": "تداخل التمرير (بكسل)",
"Next Section": "القسم التالي",
"Previous Section": "القسم السابق",
"Next Page": "الصفحة التالية",
@@ -327,5 +326,12 @@
"Azure Translator": "مترجم أزور",
"Invert Image In Dark Mode": "عكس الصورة في الوضع المظلم",
"Help improve Readest": "ساعد في تحسين ريديست",
"Sharing anonymized statistics": "مشاركة إحصائيات مجهولة الهوية"
"Sharing anonymized statistics": "مشاركة إحصائيات مجهولة الهوية",
"Interface Language": "لغة الواجهة",
"Translation": "الترجمة",
"Enable Translation": "تمكين الترجمة",
"Translation Service": "خدمة الترجمة",
"Translate To": "ترجمة إلى",
"Disable Translation": "تعطيل الترجمة",
"Overlap Margin": "هامش التداخل"
}
@@ -306,7 +306,6 @@
"File Size:": "Dateigröße:",
"Auto Detect": "Automatisch erkennen",
"Touch": "Berührung",
"Scrolling Overlap (px)": "Scroll-Überlappung (px)",
"Next Section": "Nächster Abschnitt",
"Previous Section": "Vorheriger Abschnitt",
"Next Page": "Nächste Seite",
@@ -323,5 +322,12 @@
"Azure Translator": "Azure Übersetzer",
"Invert Image In Dark Mode": "Invertiere Bild im Dunkeln",
"Help improve Readest": "Readest verbessern helfen",
"Sharing anonymized statistics": "Anonymisierte Statistiken teilen"
"Sharing anonymized statistics": "Anonymisierte Statistiken teilen",
"Interface Language": "Benutzeroberflächensprache",
"Translation": "Übersetzung",
"Enable Translation": "Übersetzung aktivieren",
"Translation Service": "Übersetzungsdienst",
"Translate To": "Übersetzen nach",
"Disable Translation": "Übersetzung deaktivieren",
"Overlap Margin": "Überlappungsrand"
}
@@ -306,7 +306,6 @@
"File Size:": "Μέγεθος αρχείου:",
"Auto Detect": "Αυτόματη ανίχνευση",
"Touch": "Αφή",
"Scrolling Overlap (px)": "Επικάλυψη κύλισης (px)",
"Next Section": "Επόμενη ενότητα",
"Previous Section": "Προηγούμενη ενότητα",
"Next Page": "Επόμενη σελίδα",
@@ -323,5 +322,12 @@
"Azure Translator": "Μεταφραστής Azure",
"Invert Image In Dark Mode": "Αντιστροφή σε σκούρο",
"Help improve Readest": "Βοηθήστε στη βελτίωση του Readest",
"Sharing anonymized statistics": "Μοιράζομαι ανώνυμες στατιστικές"
"Sharing anonymized statistics": "Μοιράζομαι ανώνυμες στατιστικές",
"Interface Language": "Γλώσσα διεπαφής",
"Translation": "Μετάφραση",
"Enable Translation": "Ενεργοποίηση μετάφρασης",
"Translation Service": "Υπηρεσία μετάφρασης",
"Translate To": "Μετάφραση σε",
"Disable Translation": "Απενεργοποίηση μετάφρασης",
"Overlap Margin": "Περιθώριο επικάλυψης"
}
@@ -306,7 +306,6 @@
"File Size:": "Peso del archivo:",
"Auto Detect": "Detección automática",
"Touch": "Toque",
"Scrolling Overlap (px)": "Superposición de desplazamiento (px)",
"Next Section": "Siguiente sección",
"Previous Section": "Sección anterior",
"Next Page": "Página siguiente",
@@ -324,5 +323,12 @@
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Invertir imagen en oscuro",
"Help improve Readest": "Ayuda a mejorar Readest",
"Sharing anonymized statistics": "Compartiendo estadísticas anónimas"
"Sharing anonymized statistics": "Compartiendo estadísticas anónimas",
"Interface Language": "Idioma de la interfaz",
"Translation": "Traducción",
"Enable Translation": "Habilitar traducción",
"Translation Service": "Servicio de traducción",
"Translate To": "Traducir a",
"Disable Translation": "Deshabilitar traducción",
"Overlap Margin": "Margen de superposición"
}
@@ -306,7 +306,6 @@
"File Size:": "Taille du fichier :",
"Auto Detect": "Détection automatique",
"Touch": "Touch",
"Scrolling Overlap (px)": "Chevauchement de défilement (px)",
"Next Section": "Section suivante",
"Previous Section": "Section précédente",
"Next Page": "Page suivante",
@@ -324,5 +323,12 @@
"Azure Translator": "Traducteur Azure",
"Invert Image In Dark Mode": "Inverser l'image en mode sombre",
"Help improve Readest": "Contribuer à améliorer Readest",
"Sharing anonymized statistics": "Partage de statistiques anonymisées"
"Sharing anonymized statistics": "Partage de statistiques anonymisées",
"Interface Language": "Langue de l'interface",
"Translation": "Traduction",
"Enable Translation": "Activer la traduction",
"Translation Service": "Service de traduction",
"Translate To": "Traduire vers",
"Disable Translation": " désactiver la traduction",
"Overlap Margin": "Marges de chevauchement"
}
@@ -306,7 +306,6 @@
"File Size:": "फ़ाइल आकार:",
"Auto Detect": "स्वचालित पहचान",
"Touch": "स्पर्श",
"Scrolling Overlap (px)": "स्क्रॉलिंग ओवरलैप (px)",
"Next Section": "अगला अनुभाग",
"Previous Section": "पिछला अनुभाग",
"Next Page": "अगला पृष्ठ",
@@ -323,5 +322,12 @@
"Azure Translator": "एज़्योर अनुवादक",
"Invert Image In Dark Mode": "अंधेरे में छवि को उलटें",
"Help improve Readest": "Readest में सुधार करने में मदद करें",
"Sharing anonymized statistics": "सांख्यिकी साझा करना"
"Sharing anonymized statistics": "सांख्यिकी साझा करना",
"Interface Language": "इंटरफ़ेस भाषा",
"Translation": "अनुवाद",
"Enable Translation": "अनुवाद सक्षम करें",
"Translation Service": "अनुवाद सेवा",
"Translate To": "अनुवाद करें",
"Disable Translation": "अनुवाद अक्षम करें",
"Overlap Margin": "ओवरलैप मार्जिन"
}
@@ -306,7 +306,6 @@
"File Size:": "Ukuran File:",
"Auto Detect": "Deteksi Otomatis",
"Touch": "Sentuh",
"Scrolling Overlap (px)": "Overlapping Gulir (px)",
"Next Section": "Bagian Selanjutnya",
"Previous Section": "Bagian Sebelumnya",
"Next Page": "Halaman Berikutnya",
@@ -322,5 +321,12 @@
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Inversi Gambar di Gelap",
"Help improve Readest": "Bantu tingkatkan Readest",
"Sharing anonymized statistics": "Berbagi statistik anonim"
"Sharing anonymized statistics": "Berbagi statistik anonim",
"Interface Language": "Bahasa Antarmuka",
"Translation": "Terjemahan",
"Enable Translation": "Aktifkan Terjemahan",
"Translation Service": "Layanan Terjemahan",
"Translate To": "Terjemahkan Ke",
"Disable Translation": "Nonaktifkan Terjemahan",
"Overlap Margin": "Margin Tumpang Tindih"
}
@@ -306,7 +306,6 @@
"File Size:": "Dimensione file:",
"Auto Detect": "Rilevamento automatico",
"Touch": "Touch",
"Scrolling Overlap (px)": "Overlap scorrimento (px)",
"Next Section": "Sezione successiva",
"Previous Section": "Sezione precedente",
"Next Page": "Pagina successiva",
@@ -324,5 +323,12 @@
"Azure Translator": "Traduttore Azure",
"Invert Image In Dark Mode": "Inverti immagine in scuro",
"Help improve Readest": "Aiuta a migliorare Readest",
"Sharing anonymized statistics": "Condivisione di statistiche anonime"
"Sharing anonymized statistics": "Condivisione di statistiche anonime",
"Interface Language": "Lingua dell'interfaccia",
"Translation": "Traduzione",
"Enable Translation": "Abilita traduzione",
"Translation Service": "Servizio di traduzione",
"Translate To": "Traduci in",
"Disable Translation": "Disabilita traduzione",
"Overlap Margin": "Margine di sovrapposizione"
}
@@ -306,7 +306,6 @@
"File Size:": "ファイルサイズ:",
"Auto Detect": "自動検出",
"Touch": "タッチ",
"Scrolling Overlap (px)": "スクロール重複(px",
"Next Section": "次のセクション",
"Previous Section": "前のセクション",
"Next Page": "次のページ",
@@ -322,5 +321,12 @@
"Azure Translator": "Azure翻訳",
"Invert Image In Dark Mode": "ダークモードで画像を反転",
"Help improve Readest": "Readestを改善する手助け",
"Sharing anonymized statistics": "匿名化された統計情報を共有"
"Sharing anonymized statistics": "匿名化された統計情報を共有",
"Interface Language": "インターフェースの言語",
"Translation": "翻訳",
"Enable Translation": "翻訳を有効にする",
"Translation Service": "翻訳サービス",
"Translate To": "翻訳先",
"Disable Translation": "翻訳を無効にする",
"Overlap Margin": "オーバーラップマージン"
}
@@ -306,7 +306,6 @@
"File Size:": "파일 크기:",
"Auto Detect": "자동 감지",
"Touch": "터치",
"Scrolling Overlap (px)": "스크롤 겹침 (px)",
"Next Section": "다음 섹션",
"Previous Section": "이전 섹션",
"Next Page": "다음 페이지",
@@ -322,5 +321,12 @@
"Azure Translator": "Azure 번역기",
"Invert Image In Dark Mode": "어두운 곳에서 이미지 반전",
"Help improve Readest": "Readest 개선에 도움을 주세요",
"Sharing anonymized statistics": "익명화된 통계 공유"
"Sharing anonymized statistics": "익명화된 통계 공유",
"Interface Language": "인터페이스 언어",
"Translation": "번역",
"Enable Translation": "번역 활성화",
"Translation Service": "번역 서비스",
"Translate To": "번역 대상 언어",
"Disable Translation": "번역 비활성화",
"Overlap Margin": "겹침 여백"
}
@@ -306,7 +306,6 @@
"Dont have an account? Sign up": "Nog geen account? Registreer u",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "De hele wereld is een toneel,\nEn alle mannen en vrouwen slechts spelers;\nZij hebben hun uitgangen en hun entrees,\nEn één man speelt in zijn tijd vele rollen,\nZijn daden zijnde zeven leeftijden.\n\n— William Shakespeare",
"Touch": "Raak",
"Scrolling Overlap (px)": "Scroll-overlap (px)",
"Next Section": "Volgende sectie",
"Previous Section": "Vorige sectie",
"Next Page": "Volgende pagina",
@@ -323,5 +322,12 @@
"Azure Translator": "Azure Vertaler",
"Invert Image In Dark Mode": "Inverteer afbeelding in het donker",
"Help improve Readest": "Help Readest verbeteren",
"Sharing anonymized statistics": "Anonieme statistieken delen"
"Sharing anonymized statistics": "Anonieme statistieken delen",
"Interface Language": "Interface-taal",
"Translation": "Vertaling",
"Enable Translation": "Vertaling inschakelen",
"Translation Service": "Vertaalservice",
"Translate To": "Vertalen naar",
"Disable Translation": "Vertaling uitschakelen",
"Overlap Margin": "Overlap marge"
}
@@ -306,7 +306,6 @@
"File Size:": "Rozmiar pliku:",
"Auto Detect": "Automatyczne wykrywanie",
"Touch": "Dotyk",
"Scrolling Overlap (px)": "Nakładanie przewijania (px)",
"Next Section": "Następna sekcja",
"Previous Section": "Poprzednia sekcja",
"Next Page": "Następna strona",
@@ -325,5 +324,12 @@
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Odwróć obraz w ciemności",
"Help improve Readest": "Pomóż ulepszyć Readest",
"Sharing anonymized statistics": "Udostępnianie zanonimizowanych statystyk"
"Sharing anonymized statistics": "Udostępnianie zanonimizowanych statystyk",
"Interface Language": "Język interfejsu",
"Translation": "Tłumaczenie",
"Enable Translation": "Włącz tłumaczenie",
"Translation Service": "Usługa tłumaczenia",
"Translate To": "Przetłumacz na",
"Disable Translation": "Wyłącz tłumaczenie",
"Overlap Margin": "Margines nakładania"
}
@@ -306,7 +306,6 @@
"File Size:": "Tamanho do arquivo:",
"Auto Detect": "Detectar Automaticamente",
"Touch": "Toque",
"Scrolling Overlap (px)": "Sobreposição de Rolagem (px)",
"Next Section": "Próxima seção",
"Previous Section": "Seção anterior",
"Next Page": "Próxima página",
@@ -324,5 +323,12 @@
"Azure Translator": "Tradutor Azure",
"Invert Image In Dark Mode": "Inverter Imagem no Escuro",
"Help improve Readest": "Ajude a melhorar o Readest",
"Sharing anonymized statistics": "Compartilhando estatísticas anonimizadas"
"Sharing anonymized statistics": "Compartilhando estatísticas anonimizadas",
"Interface Language": "Idioma da interface",
"Translation": "Tradução",
"Enable Translation": "Ativar tradução",
"Translation Service": "Serviço de tradução",
"Translate To": "Traduzir para",
"Disable Translation": "Desativar tradução",
"Overlap Margin": "Margem de Sobreposição"
}
@@ -306,7 +306,6 @@
"File Size:": "Размер файла:",
"Auto Detect": "Автоопределение",
"Touch": "Сенсорный экран",
"Scrolling Overlap (px)": "Перекрытие прокрутки (px)",
"Next Section": "Следующий раздел",
"Previous Section": "Предыдущий раздел",
"Next Page": "Следующая страница",
@@ -325,5 +324,12 @@
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Инвертировать в тёмной теме",
"Help improve Readest": "Помогите улучшить Readest",
"Sharing anonymized statistics": "Отправка анонимной статистики"
"Sharing anonymized statistics": "Отправка анонимной статистики",
"Interface Language": "Язык интерфейса",
"Translation": "Перевод",
"Enable Translation": "Включить перевод",
"Translation Service": "Сервис перевода",
"Translate To": "Перевести на",
"Disable Translation": "Отключить перевод",
"Overlap Margin": "Поле перекрытия"
}
@@ -306,7 +306,6 @@
"File Size:": "Dosya Boyutu:",
"Auto Detect": "Algıla",
"Touch": "Dokunmatik",
"Scrolling Overlap (px)": "Kaydırma Üst Üste Binen (px)",
"Next Section": "Sonraki Bölüm",
"Previous Section": "Önceki Bölüm",
"Next Page": "Sonraki Sayfa",
@@ -323,5 +322,12 @@
"Azure Translator": "Azure Çevirmen",
"Invert Image In Dark Mode": "Karartılmış Görüntüyü Ters Çevir",
"Help improve Readest": "Readesti geliştirin",
"Sharing anonymized statistics": "Anonim verileri paylaş"
"Sharing anonymized statistics": "Anonim verileri paylaş",
"Interface Language": "Arayüz Dili",
"Translation": "Çeviri",
"Enable Translation": "Çeviriyi Etkinleştir",
"Translation Service": "Çeviri Hizmeti",
"Translate To": "Şuna Çevir",
"Disable Translation": "Çeviriyi Devre Dışı Bırak",
"Overlap Margin": "Çakışma Kenar Boşluğu"
}
@@ -306,7 +306,6 @@
"File Size:": "Розмір файлу:",
"Auto Detect": "Авто виявлення",
"Touch": "Дотик",
"Scrolling Overlap (px)": "Прокрутка перекриття (px)",
"Next Section": "Наступний розділ",
"Previous Section": "Попередній розділ",
"Next Page": "Наступна сторінка",
@@ -325,5 +324,12 @@
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Інвертувати зображення в темряві",
"Help improve Readest": "Допоможіть покращити Readest",
"Sharing anonymized statistics": "Поділ анонімною статистикою"
"Sharing anonymized statistics": "Поділ анонімною статистикою",
"Interface Language": "Мова інтерфейсу",
"Translation": "Переклад",
"Enable Translation": "Увімкнути переклад",
"Translation Service": "Служба перекладу",
"Translate To": "Перекласти на",
"Disable Translation": "Вимкнути переклад",
"Overlap Margin": "Відступ перекриття"
}
@@ -306,7 +306,6 @@
"File Size:": "Kích thước tệp:",
"Auto Detect": "Phát hiện tự động",
"Touch": "Chạm",
"Scrolling Overlap (px)": "Chồng chéo cuộn (px)",
"Next Section": "Phần tiếp theo",
"Previous Section": "Phần trước",
"Next Page": "Trang tiếp theo",
@@ -322,5 +321,12 @@
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "Đảo ảnh chế độ tối",
"Help improve Readest": "Giúp cải thiện Readest",
"Sharing anonymized statistics": "Chia sẻ thống kê ẩn danh"
"Sharing anonymized statistics": "Chia sẻ thống kê ẩn danh",
"Interface Language": "Ngôn ngữ giao diện",
"Translation": "Dịch",
"Enable Translation": "Bật dịch",
"Translation Service": "Dịch vụ dịch thuật",
"Translate To": "Dịch sang",
"Disable Translation": "Tắt dịch",
"Overlap Margin": "Độ chồng lề"
}
@@ -306,7 +306,6 @@
"File Size:": "文件大小",
"Auto Detect": "自动检测",
"Touch": "触控",
"Scrolling Overlap (px)": "滚动重叠(px",
"Next Section": "下一章",
"Previous Section": "上一章",
"Next Page": "下一页",
@@ -322,5 +321,12 @@
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "深色主题下图片反色",
"Help improve Readest": "帮助改进 Readest",
"Sharing anonymized statistics": "分享匿名统计数据"
"Sharing anonymized statistics": "分享匿名统计数据",
"Interface Language": "界面语言",
"Translation": "翻译",
"Enable Translation": "启用翻译",
"Translation Service": "翻译服务",
"Translate To": "翻译到",
"Disable Translation": "禁用翻译",
"Overlap Margin": "重叠边距"
}
@@ -306,7 +306,6 @@
"File Size:": "檔案大小",
"Auto Detect": "自動檢測",
"Touch": "觸控",
"Scrolling Overlap (px)": "滾動重疊(px",
"Next Section": "下一章",
"Previous Section": "上一章",
"Next Page": "下一頁",
@@ -322,5 +321,12 @@
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "深色主題下圖片反相",
"Help improve Readest": "幫助改進 Readest",
"Sharing anonymized statistics": "分享匿名統計數據"
"Sharing anonymized statistics": "分享匿名統計數據",
"Interface Language": "介面語言",
"Translation": "翻譯",
"Enable Translation": "啟用翻譯",
"Translation Service": "翻譯服務",
"Translate To": "翻譯至",
"Disable Translation": "停用翻譯",
"Overlap Margin": "重疊邊距"
}
@@ -28,6 +28,7 @@ import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -52,6 +53,7 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -14,6 +14,7 @@ import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler';
import NotebookToggler from './NotebookToggler';
import SettingsToggler from './SettingsToggler';
import TranslationToggler from './TranslationToggler';
import ViewMenu from './ViewMenu';
interface HeaderBarProps {
@@ -129,6 +130,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4'>
<SettingsToggler />
<TranslationToggler bookKey={bookKey} />
<NotebookToggler bookKey={bookKey} />
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
@@ -0,0 +1,38 @@
import React, { useEffect, useState } from 'react';
import { RiTranslateAi } from 'react-icons/ri';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import Button from '@/components/Button';
import { useEnv } from '@/context/EnvContext';
const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getViewSettings, setViewSettings, setHoveredBookKey } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
useEffect(() => {
if (translationEnabled === viewSettings.translationEnabled) return;
setHoveredBookKey('');
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
viewSettings.translationEnabled = translationEnabled;
setViewSettings(bookKey, { ...viewSettings });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translationEnabled]);
return (
<Button
icon={
<RiTranslateAi className={translationEnabled ? 'text-blue-500' : 'text-base-content'} />
}
onClick={() => setTranslationEnabled(!translationEnabled)}
tooltip={translationEnabled ? _('Disable Translation') : _('Enable Translation')}
tooltipDirection='bottom'
></Button>
);
};
export default TranslationToggler;
@@ -6,7 +6,8 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATED_LANGS } from '@/services/constants';
import { TranslatorName } from '@/services/translators';
import { UseTranslatorOptions } from '@/services/translators';
import { localeToLang } from '@/utils/lang';
import Select from '@/components/Select';
const notSupportedLangs = ['hi', 'vi'];
@@ -16,12 +17,7 @@ const generateTranslatorLangs = () => {
const result: Record<string, string> = {};
for (const [code, name] of Object.entries(langs)) {
if (notSupportedLangs.includes(code)) continue;
let newCode = code.toUpperCase();
if (newCode === 'ZH-CN') {
newCode = 'ZH-HANS';
} else if (newCode === 'ZH-TW') {
newCode = 'ZH-HANT';
}
const newCode = localeToLang(code).toUpperCase();
result[newCode] = name;
}
return result;
@@ -53,11 +49,9 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
const { token } = useAuth();
const { settings, setSettings } = useSettingsStore();
const [providers, setProviders] = useState<TranslatorType[]>([]);
const [provider, setProvider] = useState<TranslatorName>(
settings.globalReadSettings.translationProvider as TranslatorName,
);
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
const [provider, setProvider] = useState(settings.globalReadSettings.translationProvider);
const [translation, setTranslation] = useState<string | null>(null);
const [detectedSourceLang, setDetectedSourceLang] = useState<
keyof typeof TRANSLATOR_LANGS | null
@@ -69,7 +63,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
provider,
sourceLang,
targetLang,
});
} as UseTranslatorOptions);
const handleSourceLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceLang(event.target.value);
@@ -86,7 +80,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
if (selectedTranslator) {
settings.globalReadSettings.translationProvider = selectedTranslator.name;
setSettings(settings);
setProvider(selectedTranslator.name as TranslatorName);
setProvider(selectedTranslator.name);
}
};
@@ -92,7 +92,7 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
</div>
<NumberInput
label={_('Scrolling Overlap (px)')}
label={_('Overlap Margin')}
value={scrollingOverlap}
onChange={setScrollingOverlap}
disabled={!viewSettings.scrolled}
@@ -8,17 +8,30 @@ interface DropDownProps {
selected: { option: string; label: string };
options: { option: string; label: string }[];
onSelect: (option: string) => void;
disabled?: boolean;
className?: string;
listClassName?: string;
}
const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
const DropDown: React.FC<DropDownProps> = ({
selected,
options,
onSelect,
className,
listClassName,
disabled = false,
}) => {
const iconSize16 = useResponsiveSize(16);
const defaultIconSize = useDefaultIconSize();
return (
<div className='dropdown dropdown-bottom'>
<div className={clsx('dropdown dropdown-bottom', className)}>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
className={clsx(
'btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case',
disabled && 'btn-disabled',
)}
onClick={(e) => e.currentTarget.focus()}
>
<span>{selected.label}</span>
@@ -29,6 +42,7 @@ const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] shadow',
'menu-vertical right-[-32px] mt-2 inline max-h-80 w-44 overflow-y-scroll sm:right-0',
listClassName,
)}
>
{options.map(({ option, label }) => (
@@ -10,6 +10,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { lockScreenOrientation } from '@/utils/bridge';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { getTranslators } from '@/services/translators';
import { TRANSLATED_LANGS } from '@/services/constants';
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
@@ -26,8 +27,11 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
const [error, setError] = useState<string | null>(null);
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
const [translationProvider, setTranslationProvider] = useState(viewSettings.translationProvider!);
const [translateTargetLang, setTranslateTargetLang] = useState(viewSettings.translateTargetLang!);
const [error, setError] = useState<string | null>(null);
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -108,7 +112,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
};
const getUILangOptions = () => {
const getLangOptions = () => {
const langs = TRANSLATED_LANGS as Record<string, string>;
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
options.sort((a, b) => a.label.localeCompare(b.label));
@@ -121,6 +125,40 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
i18n.changeLanguage(option ? option : navigator.language);
};
const getTranslationProviderOptions = () => {
const translators = getTranslators();
const availableProviders = translators.map((t) => {
return { option: t.name, label: t.label };
});
return availableProviders;
};
const getCurrentTranslationProviderOption = () => {
const option = translationProvider;
const availableProviders = getTranslationProviderOptions();
return availableProviders.find((p) => p.option === option) || availableProviders[0]!;
};
const handleSelectTranslationProvider = (option: string) => {
setTranslationProvider(option);
saveViewSettings(envConfig, bookKey, 'translationProvider', option, false, false);
viewSettings.translationProvider = option;
setViewSettings(bookKey, { ...viewSettings });
};
const getCurrentTargetLangOption = () => {
const option = translateTargetLang;
const availableOptions = getLangOptions();
return availableOptions.find((o) => o.option === option) || availableOptions[0]!;
};
const handleSelectTargetLang = (option: string) => {
setTranslateTargetLang(option);
saveViewSettings(envConfig, bookKey, 'translateTargetLang', option, false, false);
viewSettings.translateTargetLang = option;
setViewSettings(bookKey, { ...viewSettings });
};
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
if (animated) {
@@ -139,6 +177,14 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenOrientation]);
useEffect(() => {
if (translationEnabled === viewSettings.translationEnabled) return;
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
viewSettings.translationEnabled = translationEnabled;
setViewSettings(bookKey, { ...viewSettings });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translationEnabled]);
return (
<div
className={clsx(
@@ -151,10 +197,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Language')}</span>
<span className=''>{_('Interface Language')}</span>
<DropDown
options={getLangOptions()}
selected={getCurrentUILangOption()}
options={getUILangOptions()}
onSelect={handleSelectUILang}
/>
</div>
@@ -162,6 +208,46 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Translation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span className=''>{_('Enable Translation')}</span>
<input
type='checkbox'
className='toggle'
checked={translationEnabled}
onChange={() => setTranslationEnabled(!translationEnabled)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Translation Service')}</span>
<DropDown
selected={getCurrentTranslationProviderOption()}
options={getTranslationProviderOptions()}
onSelect={handleSelectTranslationProvider}
disabled={!translationEnabled}
className='dropdown-top'
/>
</div>
<div className='config-item'>
<span className=''>{_('Translate To')}</span>
<DropDown
options={getLangOptions()}
selected={getCurrentTargetLangOption()}
onSelect={handleSelectTargetLang}
disabled={!translationEnabled}
className='dropdown-top'
listClassName='!max-h-60'
/>
</div>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
@@ -141,8 +141,8 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
<div
ref={tabsRef}
className={clsx(
'dialog-tabs flex h-10 w-full items-center',
showTabLabels ? 'gap-4 sm:gap-2' : 'gap-4',
'dialog-tabs ms-2 flex h-10 w-full items-center',
showTabLabels ? 'gap-2' : 'gap-4',
)}
>
{tabConfig.map(({ tab, icon: Icon, label }) => (
@@ -0,0 +1,171 @@
import { useEffect, useRef, useState } from 'react';
import { FoliateView } from '@/types/view';
import { UseTranslatorOptions } from '@/services/translators';
import { useReaderStore } from '@/store/readerStore';
import { useTranslator } from '@/hooks/useTranslator';
import { walkTextNodes } from '@/utils/walk';
import { localeToLang } from '@/utils/lang';
import { getLocale } from '@/utils/misc';
export function useTextTranslation(bookKey: string, view: FoliateView | null) {
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const enabled = useRef(viewSettings.translationEnabled);
const [provider, setProvider] = useState(viewSettings.translationProvider);
const [targetLang, setTargetLang] = useState(viewSettings.translateTargetLang);
const { translate } = useTranslator({
provider,
targetLang: localeToLang(targetLang || getLocale()),
} as UseTranslatorOptions);
const translateRef = useRef(translate);
const observerRef = useRef<IntersectionObserver | null>(null);
const translatedElements = useRef<HTMLElement[]>([]);
const allTextNodes = useRef<HTMLElement[]>([]);
const toggleTranslationVisibility = (visible: boolean) => {
translatedElements.current.forEach((element) => {
const translationTargets = element.querySelectorAll('.translation-target');
translationTargets.forEach((target) => {
if (visible) {
target.classList.remove('hidden');
} else {
target.classList.add('hidden');
}
});
});
};
useEffect(() => {
translateRef.current = translate;
}, [translate]);
const updateTranslation = () => {
translatedElements.current.forEach((element) => {
const translationTargets = element.querySelectorAll('.translation-target');
translationTargets.forEach((target) => target.remove());
});
translatedElements.current = [];
if (viewSettings.translationEnabled && view) {
recreateTranslationObserver();
}
};
const createTranslationObserver = () => {
return new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
translateElement(entry.target as HTMLElement);
}
},
{
rootMargin: '2560px',
threshold: 0.01,
},
);
};
const recreateTranslationObserver = () => {
const observer = createTranslationObserver();
observerRef.current?.disconnect();
observerRef.current = observer;
allTextNodes.current.forEach((el) => observer.observe(el));
};
const translateElement = async (el: HTMLElement) => {
if (!enabled.current) return;
const text = el.textContent?.trim();
if (!text) return;
if (el.querySelector('.translation-target')) {
return;
}
try {
const translated = await translateRef.current([text]);
const translatedText = translated[0];
if (!translatedText || text === translatedText) return;
const wrapper = document.createElement('font');
wrapper.className = `translation-target ${!enabled.current ? 'hidden' : ''}`;
wrapper.setAttribute('translation-element-mark', '1');
wrapper.setAttribute('lang', targetLang || getLocale());
wrapper.appendChild(document.createElement('br'));
const blockWrapper = document.createElement('font');
blockWrapper.className = 'translation-target translation-block-wrapper';
const inner = document.createElement('font');
inner.className = 'translation-target target-inner target-inner-theme-none';
inner.textContent = translatedText;
blockWrapper.appendChild(inner);
wrapper.appendChild(blockWrapper);
el.appendChild(wrapper);
translatedElements.current.push(el);
} catch (err) {
console.warn('Translation failed:', err);
}
};
useEffect(() => {
if (!viewSettings) return;
const enabledChanged = enabled.current !== viewSettings.translationEnabled;
const providerChanged = provider !== viewSettings.translationProvider;
const targetLangChanged = targetLang !== viewSettings.translateTargetLang;
if (enabledChanged) {
enabled.current = viewSettings.translationEnabled;
}
if (providerChanged) {
setProvider(viewSettings.translationProvider);
}
if (targetLangChanged) {
setTargetLang(viewSettings.translateTargetLang);
}
if (enabledChanged) {
toggleTranslationVisibility(viewSettings.translationEnabled);
if (enabled.current) {
recreateTranslationObserver();
}
} else if (providerChanged || targetLangChanged) {
updateTranslation();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey, viewSettings, provider, targetLang]);
useEffect(() => {
if (!view) return;
const onLoad = () => {
const observer = createTranslationObserver();
observerRef.current = observer;
const nodes = walkTextNodes(view);
allTextNodes.current = nodes;
nodes.forEach((el) => observer.observe(el));
};
view.addEventListener('load', onLoad);
return () => {
view.removeEventListener('load', onLoad);
observerRef.current?.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
useEffect(() => {
return () => {
observerRef.current?.disconnect();
translatedElements.current = [];
};
}, [view]);
}
@@ -127,6 +127,7 @@ export function useTranslator({
throw err instanceof Error ? err : new Error(String(err));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[provider, sourceLang, targetLang, translator, token],
);
+4 -1
View File
@@ -34,8 +34,9 @@ import {
DEFAULT_CJK_VIEW_SETTINGS,
DEFAULT_MOBILE_READSETTINGS,
DEFAULT_SCREEN_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
} from './constants';
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { getOSPlatform, getTargetLang, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
import { ClosableFile } from '@/utils/file';
@@ -97,6 +98,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_SCREEN_CONFIG,
...{ ...DEFAULT_TRANSLATOR_CONFIG, translateTargetLang: getTargetLang() },
...settings.globalViewSettings,
};
} catch {
@@ -117,6 +119,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_SCREEN_CONFIG,
...{ ...DEFAULT_TRANSLATOR_CONFIG, translateTargetLang: getTargetLang() },
},
} as SystemSettings;
@@ -5,6 +5,7 @@ import {
BookStyle,
HighlightColor,
ScreenConfig,
TranslatorConfig,
TTSConfig,
ViewConfig,
ViewSettings,
@@ -147,6 +148,12 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsVoice: '',
};
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
translationEnabled: false,
translationProvider: 'deepl',
translateTargetLang: '',
};
export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
screenOrientation: 'auto',
};
@@ -24,8 +24,8 @@ export const deeplProvider: TranslationProvider = {
const body = JSON.stringify({
text: text,
source_lang: sourceLang,
target_lang: targetLang,
source_lang: sourceLang.toUpperCase(),
target_lang: targetLang.toUpperCase(),
use_cache: useCache,
});
@@ -81,6 +81,7 @@ export class TTSController extends EventTarget {
#preprocessSSML(ssml?: string) {
if (!ssml) return;
ssml = ssml
.replace(/<emphasis[^>]*>([^<]+)<\/emphasis>/g, '$1')
.replace(/[–—]/g, ',')
.replace('<break/>', ' ')
.replace(/\.{3,}/g, ' ')
+7
View File
@@ -127,6 +127,12 @@ export interface TTSConfig {
ttsVoice: string;
}
export interface TranslatorConfig {
translationEnabled: boolean;
translationProvider: string;
translateTargetLang: string;
}
export interface ScreenConfig {
screenOrientation: 'auto' | 'portrait' | 'landscape';
}
@@ -137,6 +143,7 @@ export interface ViewSettings
BookFont,
ViewConfig,
TTSConfig,
TranslatorConfig,
ScreenConfig {}
export interface BookProgress {
+13
View File
@@ -26,3 +26,16 @@ export const langToDefaultLocale = (langCode: string): string => {
return mapping[langCode] || langCode;
};
export const localeToLang = (locale: string): string => {
const mapping: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant',
'zh-HK': 'zh-Hant',
};
if (locale.startsWith('zh')) {
return mapping[locale] || 'zh-Hans';
}
return mapping[locale] || locale.split('-')[0]!.toLowerCase();
};
+8
View File
@@ -40,6 +40,14 @@ export const getUserLang = () => {
return locale.split('-')[0] || 'en';
};
export const getTargetLang = () => {
const locale = getLocale();
if (locale.startsWith('zh')) {
return locale === 'zh-Hant' || locale === 'zh-HK' || locale === 'zh-TW' ? 'zh-Hant' : 'zh-Hans';
}
return locale.split('-')[0] || 'en';
};
export const isCJKEnv = () => {
const browserLanguage = navigator.language || '';
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
+12 -1
View File
@@ -353,6 +353,16 @@ export const getFootnoteStyles = () => `
}
`;
const getTranslationStyles = () => `
.translation-block-wrapper {
display: inline-block !important;
margin: 0.5em 0 !important;
}
.translation-target.hidden {
display: none !important;
}
`;
export interface ThemeCode {
bg: string;
fg: string;
@@ -432,8 +442,9 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
viewSettings.overrideFont!,
themeCode,
);
const translationStyles = getTranslationStyles();
const userStylesheet = viewSettings.userStylesheet!;
return `${layoutStyles}\n${fontStyles}\n${userStylesheet}`;
return `${layoutStyles}\n${fontStyles}\n${translationStyles}\n${userStylesheet}`;
};
export const mountAdditionalFonts = (document: Document) => {
+40
View File
@@ -0,0 +1,40 @@
export const walkTextNodes = (root: HTMLElement): HTMLElement[] => {
const elements: HTMLElement[] = [];
const walk = (node: HTMLElement | Document | ShadowRoot, depth = 0) => {
if (depth > 15) return;
if (node instanceof HTMLElement && node.shadowRoot) {
walk(node.shadowRoot, depth + 1);
}
const children = 'children' in node ? (Array.from(node.children) as HTMLElement[]) : [];
for (const child of children) {
if (child.tagName === 'STYLE' || child.tagName === 'LINK') {
continue;
}
if (child.shadowRoot) {
walk(child.shadowRoot, depth + 1);
}
if (child.tagName === 'IFRAME') {
const iframe = child as HTMLIFrameElement;
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDoc && iframeDoc.body) {
walk(iframeDoc.body, depth + 1);
}
}
const hasDirectText =
child.childNodes &&
Array.from(child.childNodes).some(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
);
if (child.children.length === 0 && child.textContent?.trim()) {
elements.push(child);
} else if (hasDirectText) {
elements.push(child);
} else if (child.children.length > 0) {
walk(child, depth + 1);
}
}
};
walk(root);
return elements;
};