feat: add an option to show/hide original text in translation, also closes #1492 (#1511)

This commit is contained in:
Huang Xin
2025-07-02 13:27:05 +08:00
committed by GitHub
parent a254139200
commit 8eefba2bee
34 changed files with 203 additions and 53 deletions
@@ -13,7 +13,7 @@ import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { getStyles, transformStylesheet } from '@/utils/style';
import { applyTranslationStyles, getStyles, transformStylesheet } from '@/utils/style';
import { mountAdditionalFonts } from '@/utils/font';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
@@ -242,6 +242,7 @@ const FoliateViewer: React.FC<{
const height = viewHeight - insets.top - insets.bottom;
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
applyTranslationStyles(viewSettings);
const animated = viewSettings.animated!;
const maxColumnCount = viewSettings.maxColumnCount!;
@@ -16,6 +16,7 @@ import { parseOpenWithFiles } from '@/helpers/openWith';
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
import { isTauriAppPlatform } from '@/services/environment';
import { uniqueId } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { eventDispatcher } from '@/utils/event';
import { navigateToLibrary } from '@/utils/nav';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
@@ -84,9 +85,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (isTauriAppPlatform()) tauriHandleOnCloseWindow(handleCloseBooks);
window.addEventListener('beforeunload', handleCloseBooks);
eventDispatcher.on('beforereload', handleCloseBooks);
eventDispatcher.on('quit-app', handleCloseBooks);
return () => {
window.removeEventListener('beforeunload', handleCloseBooks);
eventDispatcher.off('beforereload', handleCloseBooks);
eventDispatcher.off('quit-app', handleCloseBooks);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -121,11 +124,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
navigateToLibrary(router);
};
const handleCloseBooks = async () => {
const handleCloseBooks = throttle(async () => {
const settings = useSettingsStore.getState().settings;
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
await saveSettings(envConfig, settings);
};
}, 500);
const handleCloseBooksToLibrary = () => {
handleCloseBooks();
@@ -249,7 +249,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
try {
Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation)));
} catch (e) {
console.error(e);
console.warn(e);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
@@ -4,12 +4,12 @@ import { useReaderStore } from '@/store/readerStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { useResetViewSettings } from '../../hooks/useResetSettings';
import { getStyles } from '@/utils/style';
import { saveAndReload } from '@/utils/reload';
import { getMaxInlineSize } from '@/utils/config';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { RELOAD_BEFREE_SAVED_TIMEOUT_MS } from '@/services/constants';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { useResetViewSettings } from '../../hooks/useResetSettings';
import NumberInput from './NumberInput';
const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
@@ -108,7 +108,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
useEffect(() => {
if (viewSettings.allowScript === allowScript) return;
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
setTimeout(() => window.location.reload(), RELOAD_BEFREE_SAVED_TIMEOUT_MS);
saveAndReload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowScript]);
@@ -10,6 +10,7 @@ import { getTranslators } from '@/services/translators';
import { TRANSLATED_LANGS } from '@/services/constants';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { useResetViewSettings } from '../../hooks/useResetSettings';
import { saveAndReload } from '@/utils/reload';
import { initDayjs } from '@/utils/time';
import Select from '@/components/Select';
@@ -24,6 +25,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
const [translationProvider, setTranslationProvider] = useState(viewSettings.translationProvider!);
const [translateTargetLang, setTranslateTargetLang] = useState(viewSettings.translateTargetLang!);
const [showTranslateSource, setShowTranslateSource] = useState(viewSettings.showTranslateSource!);
const resetToDefaults = useResetViewSettings();
@@ -127,9 +129,19 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
viewSettings.translationEnabled = translationEnabled;
setViewSettings(bookKey, { ...viewSettings });
if (!showTranslateSource && !translationEnabled) {
saveAndReload();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translationEnabled]);
useEffect(() => {
if (showTranslateSource === viewSettings.showTranslateSource) return;
saveViewSettings(envConfig, bookKey, 'showTranslateSource', showTranslateSource, false, false);
saveAndReload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showTranslateSource]);
return (
<div className={clsx('my-4 w-full space-y-6')}>
<div className='w-full'>
@@ -162,6 +174,17 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Source Text')}</span>
<input
type='checkbox'
className='toggle'
checked={showTranslateSource}
disabled={!translationEnabled}
onChange={() => setShowTranslateSource(!showTranslateSource)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Translation Service')}</span>
<Select
@@ -11,11 +11,12 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResetViewSettings } from '../../hooks/useResetSettings';
import { isCJKEnv } from '@/utils/misc';
import { getStyles } from '@/utils/style';
import { saveAndReload } from '@/utils/reload';
import { getMaxInlineSize } from '@/utils/config';
import { lockScreenOrientation } from '@/utils/bridge';
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
import { MIGHT_BE_RTL_LANGS, RELOAD_BEFREE_SAVED_TIMEOUT_MS } from '@/services/constants';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
import { MIGHT_BE_RTL_LANGS } from '@/services/constants';
import { SettingsPanelPanelProp } from './SettingsDialog';
import NumberInput from './NumberInput';
@@ -280,7 +281,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
(['horizontal-rl', 'vertical-rl'].includes(writingMode) ||
['horizontal-rl', 'vertical-rl'].includes(prevWritingMode))
) {
setTimeout(() => window.location.reload(), RELOAD_BEFREE_SAVED_TIMEOUT_MS);
saveAndReload();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writingMode]);
@@ -57,7 +57,7 @@ const TOCItemView = React.memo<{
);
return (
<span
<div
role='treeitem'
tabIndex={-1}
onClick={item.href ? handleClickItem : undefined}
@@ -87,7 +87,7 @@ const TOCItemView = React.memo<{
{createExpanderIcon(flatItem.isExpanded || false)}
</span>
)}
<span
<div
className='ms-2 truncate text-ellipsis'
style={{
maxWidth: 'calc(100% - 24px)',
@@ -96,13 +96,13 @@ const TOCItemView = React.memo<{
}}
>
{item.label}
</span>
</div>
{item.location && (
<span className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
<div className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
{item.location.current + 1}
</span>
</div>
)}
</span>
</div>
);
});
@@ -45,7 +45,7 @@ const TOCView: React.FC<{
const vitualListRef = useRef<VirtualList | null>(null);
const staticListRef = useRef<HTMLDivElement | null>(null);
useTextTranslation(bookKey, containerRef.current);
useTextTranslation(bookKey, containerRef.current, false, 'translation-target-toc');
useEffect(() => {
const updateHeight = () => {
@@ -7,7 +7,12 @@ import { walkTextNodes } from '@/utils/walk';
import { debounce } from '@/utils/debounce';
import { getLocale } from '@/utils/misc';
export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElement | null) {
export function useTextTranslation(
bookKey: string,
view: FoliateView | HTMLElement | null,
widthLineBreak = false,
targetBlockClassName = 'translation-target-block',
) {
const { getViewSettings, getViewState, getProgress } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
@@ -16,6 +21,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
const enabled = useRef(viewSettings?.translationEnabled);
const [provider, setProvider] = useState(viewSettings?.translationProvider);
const [targetLang, setTargetLang] = useState(viewSettings?.translateTargetLang);
const showTranslateSourceRef = useRef(viewSettings?.showTranslateSource);
const { translate } = useTranslator({
provider,
@@ -123,10 +129,59 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
const text = el.textContent?.replaceAll('\n', '').trim();
if (!text) return;
if (el.querySelector('.translation-target')) {
if (el.classList.contains('translation-target')) {
return;
}
const updateSourceNodes = (element: HTMLElement) => {
const hasDirectText = Array.from(element.childNodes).some(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim() !== '',
);
if (hasDirectText) {
element.classList.add('translation-source');
const textNodes = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim() !== '',
);
if (!element.hasAttribute('original-text-stored')) {
element.setAttribute(
'original-text-nodes',
JSON.stringify(textNodes.map((node) => node.textContent)),
);
element.setAttribute('original-text-stored', 'true');
}
}
const isSource = element.classList.contains('translation-source');
if (isSource) {
const textNodes = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
) as Text[];
if (showTranslateSourceRef.current) {
const originalTexts = JSON.parse(element.getAttribute('original-text-nodes') || '[]');
textNodes.forEach((textNode, index) => {
if (originalTexts[index] !== undefined) {
textNode.textContent = originalTexts[index];
}
});
} else {
textNodes.forEach((textNode) => {
textNode.textContent = '';
});
}
}
for (const child of Array.from(element.childNodes)) {
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const node = child as HTMLElement;
if (!node.classList.contains('translation-target')) {
updateSourceNodes(node);
}
}
};
updateSourceNodes(el);
try {
const translated = await translateRef.current([text]);
const translatedText = translated[0];
@@ -136,9 +191,12 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
wrapper.className = `translation-target ${!enabled.current ? 'hidden' : ''}`;
wrapper.setAttribute('translation-element-mark', '1');
wrapper.setAttribute('lang', targetLang || getLocale());
if (widthLineBreak) {
wrapper.appendChild(document.createElement('br'));
}
const blockWrapper = document.createElement('font');
blockWrapper.className = 'translation-target translation-block-wrapper';
blockWrapper.className = `translation-target ${targetBlockClassName}`;
const inner = document.createElement('font');
inner.className = 'translation-target target-inner target-inner-theme-none';
@@ -201,7 +259,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
}
}
}, 500),
[],
[translateElement],
);
useEffect(() => {
@@ -218,6 +276,8 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
const enabledChanged = enabled.current !== viewSettings.translationEnabled;
const providerChanged = provider !== viewSettings.translationProvider;
const targetLangChanged = targetLang !== viewSettings.translateTargetLang;
const showTranslateSourceChanged =
showTranslateSourceRef.current !== viewSettings.showTranslateSource;
if (enabledChanged) {
enabled.current = viewSettings.translationEnabled;
@@ -231,12 +291,16 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
setTargetLang(viewSettings.translateTargetLang);
}
if (showTranslateSourceChanged) {
showTranslateSourceRef.current = viewSettings.showTranslateSource;
}
if (enabledChanged) {
toggleTranslationVisibility(viewSettings.translationEnabled);
if (enabled.current) {
observeTextNodes();
}
} else if (providerChanged || targetLangChanged) {
} else if (providerChanged || targetLangChanged || showTranslateSourceChanged) {
updateTranslation();
}
// eslint-disable-next-line react-hooks/exhaustive-deps