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
@@ -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 }) => (