fix(translation): reduce initial layout shift in the translation view, closes #3078 (#3428)

* fix(css): allow overriding the padding of the root html

* fix(translation): reduce initial layout shift in the translation view, closes #3078
This commit is contained in:
Huang Xin
2026-03-01 16:12:02 +08:00
committed by GitHub
parent e949476d27
commit 0d2e5b7c76
32 changed files with 198 additions and 67 deletions
@@ -1107,5 +1107,6 @@
"System Screen Brightness": "سطوع شاشة النظام",
"Page:": "صفحة:",
"Page: {{number}}": "صفحة: {{number}}",
"Annotation page number": "رقم صفحة التعליق"
"Annotation page number": "رقم صفحة التعליق",
"Translating...": "جارٍ الترجمة..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "সিস্টেম স্ক্রিন উজ্জ্বলতা",
"Page:": "পৃষ্ঠা:",
"Page: {{number}}": "পৃষ্ঠা: {{number}}",
"Annotation page number": "টীকা পৃষ্ঠা নম্বর"
"Annotation page number": "টীকা পৃষ্ঠা নম্বর",
"Translating...": "অনুবাদ করা হচ্ছে..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "མ་ལག་ཤེལ་སྒོའི་གསལ་ཚད།",
"Page:": "ཤོག་ལྷེ།:",
"Page: {{number}}": "ཤོག་ལྷེ།: {{number}}",
"Annotation page number": "མཆན་འགྲེལ་ཤོག་ཨང་།"
"Annotation page number": "མཆན་འགྲེལ་ཤོག་ཨང་།",
"Translating...": "ཡིག་བསྒྱུར་བྱེད་བཞིན་པ།..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "System-Bildschirmhelligkeit",
"Page:": "Seite:",
"Page: {{number}}": "Seite: {{number}}",
"Annotation page number": "Seitenzahl der Anmerkung"
"Annotation page number": "Seitenzahl der Anmerkung",
"Translating...": "Übersetzen..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "Φωτεινότητα οθόνης συστήματος",
"Page:": "Σελίδα:",
"Page: {{number}}": "Σελίδα: {{number}}",
"Annotation page number": "Αριθμός σελίδας σχολίου"
"Annotation page number": "Αριθμός σελίδας σχολίου",
"Translating...": "Μετάφραση..."
}
@@ -1071,5 +1071,6 @@
"System Screen Brightness": "Brillo de pantalla del sistema",
"Page:": "Página:",
"Page: {{number}}": "Página: {{number}}",
"Annotation page number": "Número de página de la anotación"
"Annotation page number": "Número de página de la anotación",
"Translating...": "Traduciendo..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "روشنایی صفحه نمایش سیستم",
"Page:": "صفحه:",
"Page: {{number}}": "صفحه: {{number}}",
"Annotation page number": "شماره صفحه یادداشت"
"Annotation page number": "شماره صفحه یادداشت",
"Translating...": "در حال ترجمه..."
}
@@ -1071,5 +1071,6 @@
"System Screen Brightness": "Luminosité de l'écran système",
"Page:": "Page :",
"Page: {{number}}": "Page : {{number}}",
"Annotation page number": "Numéro de page de l'annotation"
"Annotation page number": "Numéro de page de l'annotation",
"Translating...": "Traduction en cours..."
}
@@ -1071,5 +1071,6 @@
"System Screen Brightness": "בהירות מסך המערכת",
"Page:": "עמוד:",
"Page: {{number}}": "עמוד: {{number}}",
"Annotation page number": "מספר עמוד הערה"
"Annotation page number": "מספר עמוד הערה",
"Translating...": "מתרגם..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "सिस्टम स्क्रीन चमक",
"Page:": "पृष्ठ:",
"Page: {{number}}": "पृष्ठ: {{number}}",
"Annotation page number": "व्याख्या पृष्ठ संख्या"
"Annotation page number": "व्याख्या पृष्ठ संख्या",
"Translating...": "अनुवाद हो रहा है..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "Kecerahan Layar Sistem",
"Page:": "Halaman:",
"Page: {{number}}": "Halaman: {{number}}",
"Annotation page number": "Nomor halaman anotasi"
"Annotation page number": "Nomor halaman anotasi",
"Translating...": "Menerjemahkan..."
}
@@ -1071,5 +1071,6 @@
"System Screen Brightness": "Luminosità dello schermo del sistema",
"Page:": "Pagina:",
"Page: {{number}}": "Pagina: {{number}}",
"Annotation page number": "Numero di pagina dell'annotazione"
"Annotation page number": "Numero di pagina dell'annotazione",
"Translating...": "Traduzione..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "システムの画面の明るさ",
"Page:": "ページ:",
"Page: {{number}}": "ページ: {{number}}",
"Annotation page number": "注釈のページ番号"
"Annotation page number": "注釈のページ番号",
"Translating...": "翻訳中..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "시스템 화면 밝기",
"Page:": "페이지:",
"Page: {{number}}": "페이지: {{number}}",
"Annotation page number": "주석 페이지 번호"
"Annotation page number": "주석 페이지 번호",
"Translating...": "번역 중..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "Kecerahan Skrin Sistem",
"Page:": "Halaman:",
"Page: {{number}}": "Halaman: {{number}}",
"Annotation page number": "Nombor halaman anotasi"
"Annotation page number": "Nombor halaman anotasi",
"Translating...": "Menterjemah..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "Systeemhelderheid van het scherm",
"Page:": "Pagina:",
"Page: {{number}}": "Pagina: {{number}}",
"Annotation page number": "Annotatiepaginanummer"
"Annotation page number": "Annotatiepaginanummer",
"Translating...": "Vertalen..."
}
@@ -1083,5 +1083,6 @@
"System Screen Brightness": "Jasność ekranu systemowego",
"Page:": "Strona:",
"Page: {{number}}": "Strona: {{number}}",
"Annotation page number": "Numer strony adnotacji"
"Annotation page number": "Numer strony adnotacji",
"Translating...": "Tłumaczenie..."
}
@@ -1071,5 +1071,6 @@
"System Screen Brightness": "Brilho da tela do sistema",
"Page:": "Página:",
"Page: {{number}}": "Página: {{number}}",
"Annotation page number": "Número de página da anotação"
"Annotation page number": "Número de página da anotação",
"Translating...": "Traduzindo..."
}
@@ -1083,5 +1083,6 @@
"System Screen Brightness": "Систেমная яркость экрана",
"Page:": "Страница:",
"Page: {{number}}": "Страница: {{number}}",
"Annotation page number": "Номер страницы аннотации"
"Annotation page number": "Номер страницы аннотации",
"Translating...": "Перевод..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "පද්ධති තිර දීප්තිය",
"Page:": "පිටුව:",
"Page: {{number}}": "පිටුව: {{number}}",
"Annotation page number": "විවරණ පිටු අංකය"
"Annotation page number": "විවරණ පිටු අංකය",
"Translating...": "පරිවර්තනය වෙමින් පවතී..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "Systemets skärmljusstyrka",
"Page:": "Sida:",
"Page: {{number}}": "Sida: {{number}}",
"Annotation page number": "Annoteringssidnummer"
"Annotation page number": "Annoteringssidnummer",
"Translating...": "Översätter..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "கணினித் திரை பிரகாசம்",
"Page:": "பக்கம்:",
"Page: {{number}}": "பக்கம்: {{number}}",
"Annotation page number": "சிறுகுறிப்புப் பக்க எண்"
"Annotation page number": "சிறுகுறிப்புப் பக்க எண்",
"Translating...": "மொழிபெயர்க்கிறது..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "ความสว่างหน้าจอระบบ",
"Page:": "หน้า:",
"Page: {{number}}": "หน้า: {{number}}",
"Annotation page number": "หมายเลขหน้าคำอธิบายประกอบ"
"Annotation page number": "หมายเลขหน้าคำอธิบายประกอบ",
"Translating...": "กำลังแปล..."
}
@@ -1059,5 +1059,6 @@
"System Screen Brightness": "Sistem Ekran Parlaklığı",
"Page:": "Sayfa:",
"Page: {{number}}": "Sayfa: {{number}}",
"Annotation page number": "Açıklama sayfa numarası"
"Annotation page number": "Açıklama sayfa numarası",
"Translating...": "Çevriliyor..."
}
@@ -1083,5 +1083,6 @@
"System Screen Brightness": "Системна яскравість екрана",
"Page:": "Сторінка:",
"Page: {{number}}": "Сторінка: {{number}}",
"Annotation page number": "Номер сторінки анотації"
"Annotation page number": "Номер сторінки анотації",
"Translating...": "Переклад..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "Độ sáng màn hình hệ thống",
"Page:": "Trang:",
"Page: {{number}}": "Trang: {{number}}",
"Annotation page number": "Số trang chú thích"
"Annotation page number": "Số trang chú thích",
"Translating...": "Đang dịch..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "系统屏幕亮度",
"Page:": "页面:",
"Page: {{number}}": "页面: {{number}}",
"Annotation page number": "批注页码"
"Annotation page number": "批注页码",
"Translating...": "翻译中..."
}
@@ -1047,5 +1047,6 @@
"System Screen Brightness": "系統螢幕亮度",
"Page:": "頁面:",
"Page: {{number}}": "頁面: {{number}}",
"Annotation page number": "註解頁碼"
"Annotation page number": "註解頁碼",
"Translating...": "翻譯中..."
}
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { convertBlobUrlToDataUrl, BookDoc, getDirection } from '@/libs/document';
import { BookConfig, PageInfo } from '@/types/book';
@@ -611,7 +612,10 @@ const FoliateViewer: React.FC<{
tabIndex={-1}
role='document'
aria-label={_('Book Content')}
className='foliate-viewer h-[100%] w-[100%] focus:outline-none'
className={clsx(
'foliate-viewer h-[100%] w-[100%] focus:outline-none',
viewState?.loading && 'bg-base-100',
)}
style={{
paddingTop: showViewMargins ? insets.top : 0,
paddingBottom: showViewMargins ? insets.bottom : 0,
@@ -620,7 +624,11 @@ const FoliateViewer: React.FC<{
{...touchHandlers}
/>
<ParagraphControl bookKey={bookKey} viewRef={viewRef} gridInsets={gridInsets} />
{!docLoaded.current && loading && <Spinner loading={true} />}
{((!docLoaded.current && loading) || viewState?.loading) && (
<div className='bg-base-100/85 absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center'>
<Spinner loading={true} />
</div>
)}
{syncState === 'conflict' && conflictDetails && (
<KOSyncConflictResolver
details={conflictDetails}
@@ -3,6 +3,8 @@ import { FoliateView } from '@/types/view';
import { UseTranslatorOptions } from '@/services/translators';
import { useReaderStore } from '@/store/readerStore';
import { useTranslator } from '@/hooks/useTranslator';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { walkTextNodes } from '@/utils/walk';
import { debounce } from '@/utils/debounce';
import { getLocale } from '@/utils/misc';
@@ -13,7 +15,8 @@ export function useTextTranslation(
widthLineBreak = false,
targetBlockClassName = 'translation-target-block',
) {
const { getViewSettings, getProgress } = useReaderStore();
const _ = useTranslation();
const { getViewSettings, getProgress, setIsLoading } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const progress = getProgress(bookKey);
@@ -31,6 +34,12 @@ export function useTextTranslation(
const observerRef = useRef<IntersectionObserver | null>(null);
const translatedElements = useRef<HTMLElement[]>([]);
const allTextNodes = useRef<HTMLElement[]>([]);
const translationQueue = useRef<HTMLElement[]>([]);
const activeTranslations = useRef(0);
const MAX_CONCURRENT_TRANSLATIONS = 5;
const pendingDOMUpdates = useRef<Array<() => void>>([]);
const batchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toggleTranslationVisibility = (visible: boolean) => {
translatedElements.current.forEach((element) => {
@@ -49,8 +58,21 @@ export function useTextTranslation(
translateRef.current = translate;
}, [translate]);
const hintInitialTranslating = () => {
setIsLoading(bookKey, true);
eventDispatcher.dispatch('hint', {
bookKey,
message: _('Translating...'),
});
hintTimerRef.current = setTimeout(() => {
hintTimerRef.current = null;
setIsLoading(bookKey, false);
}, 2000);
};
const observeTextNodes = () => {
if (!view || !enabled.current) return;
const observer = createTranslationObserver();
observerRef.current = observer;
const nodes = walkTextNodes(view, ['pre', 'code', 'math']);
@@ -64,6 +86,13 @@ export function useTextTranslation(
};
const updateTranslation = () => {
translationQueue.current = [];
activeTranslations.current = 0;
if (batchTimerRef.current) {
clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
pendingDOMUpdates.current = [];
translatedElements.current.forEach((element) => {
const translationTargets = element.querySelectorAll('.translation-target');
translationTargets.forEach((target) => target.remove());
@@ -76,48 +105,86 @@ export function useTextTranslation(
};
const createTranslationObserver = () => {
const visibleElements = new Set<HTMLElement>();
return new IntersectionObserver(
(entries) => {
let beforeIntersectedElement: HTMLElement | null = null;
let lastIntersectedElement: HTMLElement | null = null;
for (const entry of entries) {
if (!entry.isIntersecting) {
if (!lastIntersectedElement) {
beforeIntersectedElement = entry.target as HTMLElement;
}
continue;
if (entry.isIntersecting) {
visibleElements.add(entry.target as HTMLElement);
} else {
visibleElements.delete(entry.target as HTMLElement);
}
const currentElement = entry.target as HTMLElement;
translateElement(currentElement);
lastIntersectedElement = currentElement;
}
if (beforeIntersectedElement) {
translateElement(beforeIntersectedElement);
if (visibleElements.size === 0) return;
const nodes = allTextNodes.current;
if (nodes.length === 0) return;
let firstIdx = nodes.length;
let lastIdx = -1;
for (const el of visibleElements) {
const idx = nodes.indexOf(el);
if (idx !== -1) {
if (idx < firstIdx) firstIdx = idx;
if (idx > lastIdx) lastIdx = idx;
}
}
if (lastIntersectedElement) {
preTranslateNextElements(lastIntersectedElement, 2);
if (lastIdx === -1) return;
const startIdx = Math.max(0, firstIdx - 1);
const endIdx = Math.min(nodes.length - 1, lastIdx + 2);
for (let i = startIdx; i <= endIdx; i++) {
const node = nodes[i];
if (node) {
scheduleTranslation(node);
}
}
},
{
rootMargin: '1280px',
threshold: 0,
},
{ threshold: 0 },
);
};
const preTranslateNextElements = (currentElement: HTMLElement, count: number) => {
if (!allTextNodes.current || count <= 0) return;
const currentIndex = allTextNodes.current.indexOf(currentElement);
if (currentIndex === -1) {
return;
}
const scheduleTranslation = (el: HTMLElement) => {
if (!enabled.current) return;
if (el.classList.contains('translation-target')) return;
if (el.querySelector('.translation-target')) return;
if (translationQueue.current.indexOf(el) !== -1) return;
translationQueue.current.push(el);
drainTranslationQueue();
};
const nextElements = allTextNodes.current.slice(currentIndex + 1, currentIndex + 1 + count);
nextElements.forEach((element, index) => {
const drainTranslationQueue = () => {
while (
activeTranslations.current < MAX_CONCURRENT_TRANSLATIONS &&
translationQueue.current.length > 0
) {
const el = translationQueue.current.shift()!;
if (el.querySelector('.translation-target') || !enabled.current) continue;
activeTranslations.current++;
translateElement(el).finally(() => {
activeTranslations.current--;
drainTranslationQueue();
});
}
if (translationQueue.current.length === 0 && activeTranslations.current === 0) {
setTimeout(() => {
translateElement(element);
}, index * 500);
});
setIsLoading(bookKey, false);
}, 500);
}
};
const batchDOMUpdate = (update: () => void) => {
pendingDOMUpdates.current.push(update);
if (!batchTimerRef.current) {
batchTimerRef.current = setTimeout(() => {
batchTimerRef.current = null;
const updates = pendingDOMUpdates.current.splice(0);
updates.forEach((fn) => fn());
}, 50);
}
};
const recreateTranslationObserver = () => {
@@ -183,8 +250,6 @@ export function useTextTranslation(
}
};
updateSourceNodes(el);
try {
const translated = await translateRef.current([text]);
const translatedText = translated[0];
@@ -211,8 +276,12 @@ export function useTextTranslation(
if (el.querySelector('.translation-target')) {
return;
}
el.appendChild(wrapper);
translatedElements.current.push(el);
batchDOMUpdate(() => {
if (!enabled.current || el.querySelector('.translation-target')) return;
updateSourceNodes(el);
el.appendChild(wrapper);
translatedElements.current.push(el);
});
} catch (err) {
console.warn('Translation failed:', err);
}
@@ -260,11 +329,11 @@ export function useTextTranslation(
for (let i = beforeStart; i <= afterEnd; i++) {
const node = nodes[i];
if (node) {
translateElement(node);
scheduleTranslation(node);
}
}
}, 500),
[translateElement],
[scheduleTranslation],
);
useEffect(() => {
@@ -316,15 +385,29 @@ export function useTextTranslation(
if ('renderer' in view) {
view.addEventListener('load', observeTextNodes);
view.addEventListener('load', hintInitialTranslating);
} else {
observeTextNodes();
}
return () => {
if ('renderer' in view) {
view.removeEventListener('load', observeTextNodes);
view.removeEventListener('load', hintInitialTranslating);
}
observerRef.current?.disconnect();
translatedElements.current = [];
translationQueue.current = [];
activeTranslations.current = 0;
if (batchTimerRef.current) {
clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
if (hintTimerRef.current) {
clearTimeout(hintTimerRef.current);
hintTimerRef.current = null;
}
pendingDOMUpdates.current = [];
setIsLoading(bookKey, false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
+12
View File
@@ -51,6 +51,7 @@ interface ReaderStore {
setHoveredBookKey: (key: string | null) => void;
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
setTTSEnabled: (key: string, enabled: boolean) => void;
setIsLoading: (key: string, loading: boolean) => void;
setIsSyncing: (key: string, syncing: boolean) => void;
setProgress: (
key: string,
@@ -393,6 +394,17 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
})),
setIsLoading: (key: string, loading: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
loading,
},
},
})),
setIsSyncing: (key: string, syncing: boolean) =>
set((state) => ({
viewStates: {