From 4fed3bc5f9a2e5f3c78cbf6edb5b6fc769d4f14a Mon Sep 17 00:00:00 2001 From: bealqiu Date: Thu, 26 Mar 2026 16:45:33 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=20TXT=20=E5=AF=BC=E5=85=A5=E9=9D=9E=20UTF-8=20?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E6=96=87=E4=BB=B6=E4=B9=B1=E7=A0=81=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个 bug 共同导致移动端导入 GBK/GB18030 编码的 TXT 文件显示乱码: 1. text-encoding polyfill 未生效:Hermes 原生 TextDecoder 已存在于 globalThis,导致 text-encoding 跳过安装自己的全编码版本,非 UTF-8 解码全部失败。修复:require 前临时隐藏原生 TextDecoder/TextEncoder, 强制 polyfill 安装,之后恢复原生版本。 2. UTF-8 验证在多字节字符边界误判:ensureUtf8Bytes 用 64KB subarray 做 fatal 模式验证,但截断点可能落在多字节 UTF-8 字符中间,导致 合法 UTF-8 文件被误判为非 UTF-8,再被错误地用 GB18030 解码。 修复:对 sample 边界做 UTF-8 continuation byte 回退对齐。 3. Shift-JIS 检测误判 GB18030:原逻辑只要找到一个符合 Shift-JIS 字节范围的字节对就判定为 Shift-JIS,但 GB18030 的 0xE0-0xFC 开头 双字节与 Shift-JIS 大量重叠。修复:改为统计型判断,分别计数 GBK 典型对和 Shift-JIS 独有对,只有后者明显多于前者才判定为 Shift-JIS。 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 +- packages/app-expo/src/stores/library-store.ts | 85 +++++++++++++------ packages/core/src/utils/txt-to-epub.ts | 36 +++++--- 3 files changed, 86 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 23e781d0..5509b160 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ tauri-signing-key.key.pub npm-debug.log* pnpm-debug.log* -readese/* \ No newline at end of file +readese/* + +# Local WebDAV test server +webdav-server/ \ No newline at end of file diff --git a/packages/app-expo/src/stores/library-store.ts b/packages/app-expo/src/stores/library-store.ts index b3f35021..d5931d7c 100644 --- a/packages/app-expo/src/stores/library-store.ts +++ b/packages/app-expo/src/stores/library-store.ts @@ -11,11 +11,30 @@ import { create } from "zustand"; import { debouncedSave, loadFromFS } from "./persist"; // Hermes (React Native) only supports UTF-8 in TextDecoder. -// Use text-encoding polyfill for GBK/GB18030/Shift-JIS etc. +// text-encoding polyfill detects the native TextDecoder and skips installing +// its own full-encoding version. Workaround: temporarily hide the native +// TextDecoder so the polyfill installs unconditionally, then restore native. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const _nativeTD = globalThis.TextDecoder; +const _nativeTE = globalThis.TextEncoder; +// @ts-expect-error — temporarily remove native TextDecoder/TextEncoder +globalThis.TextDecoder = undefined; +// @ts-expect-error +globalThis.TextEncoder = undefined; // eslint-disable-next-line @typescript-eslint/no-var-requires const { TextDecoder: PolyfillTextDecoder } = require("text-encoding") as { TextDecoder: typeof TextDecoder; }; +// Restore native TextDecoder/TextEncoder for rest of the app +globalThis.TextDecoder = _nativeTD; +globalThis.TextEncoder = _nativeTE; + +// Verify polyfill can decode non-UTF-8 at module load time +try { + new PolyfillTextDecoder("gb18030"); +} catch (e) { + console.error("[text-encoding] Polyfill BROKEN: gb18030 not supported!", e); +} export type LibraryViewMode = "grid" | "list"; @@ -76,7 +95,7 @@ async function saveCoverToAppData(bookId: string, coverBlob: Blob): Promise 0 && sampleEnd < bytes.length && (bytes[sampleEnd]! & 0xC0) === 0x80) { + sampleEnd--; + } try { - new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, sampleSize)); - // Also check a mid-section for large files - if (bytes.length > sampleSize * 2) { - const midStart = Math.floor(bytes.length / 2); - const midEnd = Math.min(midStart + 8192, bytes.length); + new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, sampleEnd)); + if (bytes.length > sampleEnd * 2) { + let midStart = Math.floor(bytes.length / 2); + // Align mid-sample start to a UTF-8 character boundary + while (midStart < bytes.length && (bytes[midStart]! & 0xC0) === 0x80) { + midStart++; + } + let midEnd = Math.min(midStart + 8192, bytes.length); + while (midEnd > midStart && midEnd < bytes.length && (bytes[midEnd]! & 0xC0) === 0x80) { + midEnd--; + } new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(midStart, midEnd)); } + console.log(`[ensureUtf8Bytes] passed UTF-8 validation`); return bytes; // Valid UTF-8 } catch { // Not valid UTF-8 — detect which encoding it is } - // Check if high bytes suggest GBK/GB18030 or Shift-JIS - const sample = bytes.subarray(0, Math.min(1024, bytes.length)); + // Disambiguate GBK/GB18030 vs Shift-JIS by counting distinctive byte patterns. + // GBK double-byte: lead 0xA1-0xFE, trail 0xA1-0xFE (dominant in Chinese text) + // Shift-JIS distinctive: lead 0x81-0x9F (below GBK lead range) + const sample = bytes.subarray(0, Math.min(4096, bytes.length)); let highBytes = 0; for (let i = 0; i < sample.length; i++) { if (sample[i]! >= 0x80) highBytes++; } const highRatio = sample.length > 0 ? highBytes / sample.length : 0; - // Check for Shift-JIS patterns - let isShiftJIS = false; - if (highRatio > 0.1) { - for (let i = 0; i < sample.length - 1; i++) { - const b1 = sample[i]!; - const b2 = sample[i + 1]!; - if ( - ((b1 >= 0x81 && b1 <= 0x9f) || (b1 >= 0xe0 && b1 <= 0xfc)) && - ((b2 >= 0x40 && b2 <= 0x7e) || (b2 >= 0x80 && b2 <= 0xfc)) - ) { - isShiftJIS = true; - break; - } + let gbkPairs = 0; + let sjisDistinctPairs = 0; + for (let i = 0; i < sample.length - 1; i++) { + const b1 = sample[i]!; + const b2 = sample[i + 1]!; + if (b1 >= 0xA1 && b1 <= 0xFE && b2 >= 0xA1 && b2 <= 0xFE) { + gbkPairs++; + i++; + } else if (b1 >= 0x81 && b1 <= 0x9F && ((b2 >= 0x40 && b2 <= 0x7E) || (b2 >= 0x80 && b2 <= 0xFC))) { + sjisDistinctPairs++; + i++; } } + const isShiftJIS = sjisDistinctPairs > 0 && sjisDistinctPairs > gbkPairs; const encoding = isShiftJIS ? "shift_jis" : highRatio > 0.1 ? "gb18030" : "gbk"; console.log(`[ensureUtf8Bytes] Detected non-UTF-8 encoding: ${encoding}, converting to UTF-8`); @@ -314,7 +345,7 @@ export const useLibraryStore = create((set, get) => ({ const rawBytes = await platform.readFile(filePath); // Hermes only supports UTF-8 in TextDecoder. Convert GBK/GB18030 - // etc. to UTF-8 using iconv-lite before passing to the converter. + // etc. to UTF-8 using text-encoding polyfill before passing to converter. const bytes = ensureUtf8Bytes(rawBytes); // React Native Blob/File constructor doesn't support ArrayBuffer/Uint8Array. diff --git a/packages/core/src/utils/txt-to-epub.ts b/packages/core/src/utils/txt-to-epub.ts index ada8bc29..de22b4f3 100644 --- a/packages/core/src/utils/txt-to-epub.ts +++ b/packages/core/src/utils/txt-to-epub.ts @@ -637,7 +637,7 @@ export class TxtToEpubConverter { if (headerBytes[0] === 0xef && headerBytes[1] === 0xbb && headerBytes[2] === 0xbf) return "utf-8"; - const sample = new Uint8Array(buffer.slice(0, Math.min(1024, buffer.byteLength))); + const sample = new Uint8Array(buffer.slice(0, Math.min(4096, buffer.byteLength))); let highByteCount = 0; for (let i = 0; i < sample.length; i++) { if (sample[i]! >= 0x80) highByteCount++; @@ -647,16 +647,23 @@ export class TxtToEpubConverter { if (highByteRatio > 0.3) return "gbk"; if (highByteRatio > 0.1) { + // Disambiguate Shift-JIS vs GBK/GB18030 by counting distinctive patterns. + // GBK double-byte: lead 0xA1-0xFE, trail 0xA1-0xFE + // Shift-JIS distinctive: lead 0x81-0x9F (below GBK lead range) + let gbkPairs = 0; + let sjisDistinctPairs = 0; for (let i = 0; i < sample.length - 1; i++) { const b1 = sample[i]!; const b2 = sample[i + 1]!; - if ( - ((b1 >= 0x81 && b1 <= 0x9f) || (b1 >= 0xe0 && b1 <= 0xfc)) && - ((b2 >= 0x40 && b2 <= 0x7e) || (b2 >= 0x80 && b2 <= 0xfc)) - ) { - return "shift-jis"; + if (b1 >= 0xA1 && b1 <= 0xFE && b2 >= 0xA1 && b2 <= 0xFE) { + gbkPairs++; + i++; + } else if (b1 >= 0x81 && b1 <= 0x9F && ((b2 >= 0x40 && b2 <= 0x7E) || (b2 >= 0x80 && b2 <= 0xFC))) { + sjisDistinctPairs++; + i++; } } + if (sjisDistinctPairs > 0 && sjisDistinctPairs > gbkPairs) return "shift-jis"; return "gb18030"; } @@ -704,7 +711,7 @@ export class TxtToEpubConverter { if (headSample[0] === 0xef && headSample[1] === 0xbb && headSample[2] === 0xbf) return "utf-8"; - const sample = headSample.slice(0, Math.min(1024, headSample.length)); + const sample = headSample.slice(0, Math.min(4096, headSample.length)); let highByteCount = 0; for (let i = 0; i < sample.length; i++) { if (sample[i]! >= 0x80) highByteCount++; @@ -714,16 +721,21 @@ export class TxtToEpubConverter { if (highByteRatio > 0.3) return "gbk"; if (highByteRatio > 0.1) { + // Disambiguate Shift-JIS vs GBK/GB18030 by counting distinctive patterns. + let gbkPairs = 0; + let sjisDistinctPairs = 0; for (let i = 0; i < sample.length - 1; i++) { const b1 = sample[i]!; const b2 = sample[i + 1]!; - if ( - ((b1 >= 0x81 && b1 <= 0x9f) || (b1 >= 0xe0 && b1 <= 0xfc)) && - ((b2 >= 0x40 && b2 <= 0x7e) || (b2 >= 0x80 && b2 <= 0xfc)) - ) { - return "shift-jis"; + if (b1 >= 0xA1 && b1 <= 0xFE && b2 >= 0xA1 && b2 <= 0xFE) { + gbkPairs++; + i++; + } else if (b1 >= 0x81 && b1 <= 0x9F && ((b2 >= 0x40 && b2 <= 0x7E) || (b2 >= 0x80 && b2 <= 0xFC))) { + sjisDistinctPairs++; + i++; } } + if (sjisDistinctPairs > 0 && sjisDistinctPairs > gbkPairs) return "shift-jis"; return "gb18030"; } From e35fe0819693c52069015d331be7c295bffcc1e4 Mon Sep 17 00:00:00 2001 From: bealqiu Date: Fri, 27 Mar 2026 14:18:19 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E6=95=B4=E7=AB=A0=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=8A=9F=E8=83=BD=E6=94=B9=E8=BF=9B=20=E2=80=94=20?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=98=BE=E9=9A=90=E6=8E=A7=E5=88=B6=20+=20UI?= =?UTF-8?q?=20=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原文/译文独立显隐:翻译完成后可分别隐藏原文或译文,支持纯译文/纯原文阅读 - 桌面端:翻译控制改为工具栏下拉菜单(DropdownMenu),不再占用独立栏位 - 移动端:翻译控制改为底部弹出 ActionSheet,替代直接触发模式 - 修复移动端翻译失败(No text to translate):WebView bridge 函数暴露到 window 对象 - 修复移动端翻译不渐进展示:injectChapterTranslations 同样修复作用域问题 - 三个 WebView handler 均添加 iframe fallback 兼容路径 Co-Authored-By: Claude Opus 4.6 --- packages/app-expo/assets/reader/reader.html | 425 +++++++--------- .../assets/reader/reader.template.html | 165 +++++++ .../reader/ChapterTranslationSheet.tsx | 459 ++++++++++++++++++ .../app-expo/src/hooks/use-reader-bridge.ts | 86 ++++ .../app-expo/src/screens/ReaderScreen.tsx | 103 ++++ .../reader/ChapterTranslationBar.tsx | 220 +++++++++ .../src/components/reader/FoliateViewer.tsx | 105 ++++ .../src/components/reader/ReaderToolbar.tsx | 24 +- .../app/src/components/reader/ReaderView.tsx | 50 +- packages/core/src/hooks/index.ts | 5 + .../core/src/hooks/useChapterTranslation.ts | 157 ++++++ packages/core/src/i18n/locales/en.json | 14 +- packages/core/src/i18n/locales/zh.json | 14 +- .../core/src/translation/chapter-cache.ts | 45 ++ .../src/translation/chapter-translator.ts | 184 +++++++ packages/core/src/translation/providers.ts | 94 ++++ packages/core/src/translation/translator.ts | 10 + pnpm-lock.yaml | 2 +- 18 files changed, 1897 insertions(+), 265 deletions(-) create mode 100644 packages/app-expo/src/components/reader/ChapterTranslationSheet.tsx create mode 100644 packages/app/src/components/reader/ChapterTranslationBar.tsx create mode 100644 packages/core/src/hooks/useChapterTranslation.ts create mode 100644 packages/core/src/translation/chapter-cache.ts create mode 100644 packages/core/src/translation/chapter-translator.ts diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index f93ac622..90f0bafd 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -112,7 +112,6 @@ let searchGenerator = null; let searchResults = []; let searchIndex = 0; - const loadedDocuments = new Set(); // Store user annotations for re-rendering on page change const userAnnotations = new Map(); @@ -206,218 +205,10 @@ }, { passive: true }); } - // ─── Pull down gesture for bookmark toggle ─── - // State shared across all documents (main + iframes) - let pullDownState = { - startY: 0, - startX: 0, - isPulling: false, - pullDelta: 0, - scrollTop: 0, - }; - - function getReaderContainer() { - return document.querySelector('#reader-container'); - } - - function applyPull(delta) { - pullDownState.pullDelta = Math.min(delta * 0.6, 150); - const container = getReaderContainer(); - if (container) { - container.style.transform = `translateY(${pullDownState.pullDelta}px)`; - container.style.transition = 'none'; - } - } - - function resetPull() { - const container = getReaderContainer(); - if (container) { - container.style.transition = 'transform 0.35s cubic-bezier(0.2, 0, 0.2, 1)'; - container.style.transform = ''; - } - pullDownState.pullDelta = 0; - } - - // Check if we're in an iframe - function isInIframe() { - try { - return window.self !== window.top; - } catch (e) { - return true; - } - } - - function attachPullDownListener(doc) { - if (doc.__readany_pullDownAttached) return; - doc.__readany_pullDownAttached = true; - - const win = doc.defaultView || window; - const inIframe = isInIframe(); - - // Local state for iframe (since pullDownState is in parent) - let localStartY = 0; - let localStartX = 0; - let localScrollTop = 0; - - const handleTouchStart = function(e) { - if (e.touches.length !== 1) return; - const startY = e.touches[0].clientY; - const startX = e.touches[0].clientX; - const docEl = doc.documentElement || doc.body; - const scrollTop = docEl.scrollTop || win.scrollY || 0; - - if (inIframe) { - localStartY = startY; - localStartX = startX; - localScrollTop = scrollTop; - postToRN('pullDownTouchStart', { startY, startX, scrollTop }); - } else { - pullDownState.startY = startY; - pullDownState.startX = startX; - pullDownState.isPulling = false; - pullDownState.scrollTop = scrollTop; - } - }; - - const handleTouchMove = function(e) { - if (inIframe) { - const y = e.touches[0].clientY; - const x = e.touches[0].clientX; - const dy = y - localStartY; - const dx = x - localStartX; - - // If pulling down at top, prevent swipe and notify parent - if (dy > 15 && dy > Math.abs(dx) * 2 && localScrollTop <= 5) { - e.preventDefault(); - e.stopPropagation(); - } - postToRN('pullDownTouchMove', { y, x }); - } else { - if (!pullDownState.startY || e.touches.length !== 1) return; - const y = e.touches[0].clientY; - const dy = y - pullDownState.startY; - const dx = e.touches[0].clientX - pullDownState.startX; - - if (dy > 15 && dy > Math.abs(dx) * 2 && pullDownState.scrollTop <= 5) { - // Prevent swipe gesture when pulling down at top - e.preventDefault(); - e.stopPropagation(); - pullDownState.isPulling = true; - applyPull(dy); - } - } - }; - - const handleTouchEnd = function(e) { - if (inIframe) { - postToRN('pullDownTouchEnd', {}); - localStartY = 0; - localStartX = 0; - localScrollTop = 0; - } else { - if (pullDownState.isPulling) { - e.preventDefault(); - const wasTrigger = pullDownState.pullDelta > 50; - resetPull(); - if (wasTrigger) { - postToRN('toggleBookmark', {}); - } - } - pullDownState.isPulling = false; - pullDownState.startY = 0; - pullDownState.startX = 0; - pullDownState.scrollTop = 0; - } - }; - - const handleTouchCancel = function() { - if (inIframe) { - postToRN('pullDownTouchCancel', {}); - localStartY = 0; - localStartX = 0; - localScrollTop = 0; - } else { - if (pullDownState.isPulling) { - resetPull(); - } - pullDownState.isPulling = false; - pullDownState.startY = 0; - pullDownState.startX = 0; - pullDownState.scrollTop = 0; - } - }; - - // Use passive: false to allow preventDefault - doc.addEventListener('touchstart', handleTouchStart, { capture: true, passive: true }); - doc.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false }); - doc.addEventListener('touchend', handleTouchEnd, { capture: true, passive: false }); - doc.addEventListener('touchcancel', handleTouchCancel, { capture: true, passive: true }); - - win.addEventListener('touchstart', handleTouchStart, { capture: true, passive: true }); - win.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false }); - win.addEventListener('touchend', handleTouchEnd, { capture: true, passive: false }); - win.addEventListener('touchcancel', handleTouchCancel, { capture: true, passive: true }); - } - - // Handle pull-down messages from iframes (in main document only) - function handlePullDownMessage(msg) { - if (isInIframe()) return; // Only handle in main document - - switch (msg.type) { - case 'pullDownTouchStart': - pullDownState.startY = msg.startY; - pullDownState.startX = msg.startX; - pullDownState.isPulling = false; - pullDownState.scrollTop = msg.scrollTop; - break; - case 'pullDownTouchMove': - if (!pullDownState.startY) return; - const dy = msg.y - pullDownState.startY; - const dx = msg.x - pullDownState.startX; - if (dy > 15 && dy > Math.abs(dx) * 2 && pullDownState.scrollTop <= 5) { - pullDownState.isPulling = true; - applyPull(dy); - } - break; - case 'pullDownTouchEnd': - if (pullDownState.isPulling) { - const wasTrigger = pullDownState.pullDelta > 50; - resetPull(); - if (wasTrigger) { - postToRN('toggleBookmark', {}); - } - } - pullDownState.isPulling = false; - pullDownState.startY = 0; - pullDownState.startX = 0; - pullDownState.scrollTop = 0; - break; - case 'pullDownTouchCancel': - if (pullDownState.isPulling) { - resetPull(); - } - pullDownState.isPulling = false; - pullDownState.startY = 0; - pullDownState.startX = 0; - pullDownState.scrollTop = 0; - break; - } - } - - // Also attach to main document body - (function initMainPullDown() { - attachPullDownListener(document); - })(); - // ─── Message handler from React Native ─── window.addEventListener('message', async (e) => { try { const msg = JSON.parse(e.data); - // Handle pull-down messages from iframes - if (msg.type && msg.type.startsWith('pullDown')) { - handlePullDownMessage(msg); - return; - } await handleCommand(msg); } catch (err) { console.error('[WebView] Error in message handler:', err); @@ -428,11 +219,6 @@ document.addEventListener('message', async (e) => { try { const msg = JSON.parse(e.data); - // Handle pull-down messages from iframes - if (msg.type && msg.type.startsWith('pullDown')) { - handlePullDownMessage(msg); - return; - } await handleCommand(msg); } catch (err) { console.error('[WebView] Error in iOS message handler:', err); @@ -498,6 +284,15 @@ case 'extractChapters': await handleExtractChapters(); break; + case 'getChapterParagraphs': + handleGetChapterParagraphs(); + break; + case 'injectChapterTranslations': + handleInjectChapterTranslations(msg.results || []); + break; + case 'removeChapterTranslations': + handleRemoveChapterTranslations(); + break; } } @@ -505,9 +300,6 @@ async function openBook(msg) { const container = document.getElementById('reader-container'); const loading = document.getElementById('loading'); - - // Clear loaded documents set when opening a new book - loadedDocuments.clear(); try { let file; @@ -537,12 +329,10 @@ el.addEventListener('load', (e) => { const { doc, index } = e.detail; loading.classList.add('hidden'); - loadedDocuments.add(doc); applyDocStyles(doc); attachTapListener(doc); attachSelectionListener(doc); attachNoteLongPress(doc); - attachPullDownListener(doc); // Re-add all user annotations for this page // foliate-view will only render those that belong to current section for (const [cfi, annotation] of userAnnotations) { @@ -771,32 +561,6 @@ } } - function updateAllDocumentsTheme() { - const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#1c1c1e'; - const textColor = getComputedStyle(document.documentElement).getPropertyValue('--fg').trim() || '#e8e8ed'; - const mutedColor = getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || '#7c7c82'; - - loadedDocuments.forEach(doc => { - // Update existing style if it exists, or create new one - let style = doc.querySelector('style[data-theme-colors]'); - if (!style) { - style = doc.createElement('style'); - style.setAttribute('data-theme-colors', 'true'); - doc.head.appendChild(style); - } - style.textContent = ` - * { -webkit-touch-callout: none !important; -webkit-user-select: text !important; user-select: text !important; } - ::selection { background: rgba(250, 204, 21, 0.4) !important; } - html, body { - background-color: ${bgColor} !important; - color: ${textColor} !important; - } - a { color: var(--primary, #3b82f6) !important; } - .text-muted, [class*="muted"], [class*="secondary"] { color: ${mutedColor} !important; } - `; - }); - } - function setThemeColors(colors) { document.documentElement.style.setProperty('--bg', colors.background || '#1c1c1e'); document.documentElement.style.setProperty('--fg', colors.foreground || '#e8e8ed'); @@ -808,8 +572,6 @@ if (view && view.setSearchIndicator) { view.setSearchIndicator('outline', { color: colors.primary || '#3b82f6' }); } - // Update all loaded documents with new theme colors - updateAllDocumentsTheme(); } // ─── Document styles ─── @@ -818,23 +580,10 @@ img.style.maxWidth = '100%'; img.style.height = 'auto'; } - - // Get theme colors from parent document - const parentDoc = window.top?.document || document; const style = doc.createElement('style'); - const bgColor = getComputedStyle(parentDoc.documentElement).getPropertyValue('--bg').trim() || '#1c1c1e'; - const textColor = getComputedStyle(parentDoc.documentElement).getPropertyValue('--fg').trim() || '#e8e8ed'; - const mutedColor = getComputedStyle(parentDoc.documentElement).getPropertyValue('--muted').trim() || '#7c7c82'; - style.textContent = ` * { -webkit-touch-callout: none !important; -webkit-user-select: text !important; user-select: text !important; } ::selection { background: rgba(250, 204, 21, 0.4) !important; } - html, body { - background-color: ${bgColor} !important; - color: ${textColor} !important; - } - a { color: var(--primary, #3b82f6) !important; } - .text-muted, [class*="muted"], [class*="secondary"] { color: ${mutedColor} !important; } `; doc.head.appendChild(style); doc.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); return false; }, true); @@ -1204,6 +953,15 @@ var result = doGetVisibleText(); return JSON.stringify(result); }; + window.doGetChapterParagraphs = function () { + handleGetChapterParagraphs(); + }; + window.doInjectChapterTranslations = function (results) { + handleInjectChapterTranslations(results); + }; + window.doRemoveChapterTranslations = function () { + handleRemoveChapterTranslations(); + }; // ─── Polyfills ─── Object.groupBy ??= function (iterable, fn) { @@ -1358,6 +1116,153 @@ return segments; } + // ─── Chapter Translation Handlers ─── + + function handleGetChapterParagraphs() { + try { + // First try: view.renderer.getContents() + var doc = null; + var renderer = view && view.renderer; + if (renderer && renderer.getContents) { + var contents = renderer.getContents(); + if (contents && contents.length && contents[0] && contents[0].doc) { + doc = contents[0].doc; + } + } + + // Fallback: scan iframes + if (!doc) { + var iframes = document.querySelectorAll('iframe'); + for (var fi = 0; fi < iframes.length; fi++) { + try { + var iframeDoc = iframes[fi].contentDocument || (iframes[fi].contentWindow && iframes[fi].contentWindow.document); + if (iframeDoc && iframeDoc.body && iframeDoc.body.innerText && iframeDoc.body.innerText.trim().length > 0) { + doc = iframeDoc; + break; + } + } catch (e) { /* cross-origin, skip */ } + } + } + + if (!doc) { + postToRN('chapterParagraphs', { paragraphs: [], error: 'no document found (view=' + !!view + ', renderer=' + !!renderer + ', iframes=' + document.querySelectorAll('iframe').length + ')' }); + return; + } + + var blockSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote, dd, dt, figcaption, pre, td, th"; + var blocks = doc.querySelectorAll(blockSelector); + var paragraphs = []; + for (var i = 0; i < blocks.length; i++) { + var el = blocks[i]; + var text = (el.innerText || el.textContent || '').trim(); + if (text.length < 2) continue; + var id = 'para_' + i; + el.setAttribute('data-translate-id', id); + paragraphs.push({ id: id, text: text, tagName: el.tagName.toLowerCase() }); + } + + postToRN('chapterParagraphs', { paragraphs: paragraphs }); + } catch (err) { + postToRN('chapterParagraphs', { paragraphs: [], error: String(err) }); + } + } + + function handleInjectChapterTranslations(results) { + try { + var doc = null; + var renderer = view && view.renderer; + if (renderer && renderer.getContents) { + var contents = renderer.getContents(); + if (contents && contents.length && contents[0] && contents[0].doc) { + doc = contents[0].doc; + } + } + if (!doc) { + var iframes = document.querySelectorAll('iframe'); + for (var fi = 0; fi < iframes.length; fi++) { + try { + var iframeDoc = iframes[fi].contentDocument || (iframes[fi].contentWindow && iframes[fi].contentWindow.document); + if (iframeDoc && iframeDoc.body) { doc = iframeDoc; break; } + } catch (e) {} + } + } + if (!doc) return; + + // Inject CSS once + if (!doc.getElementById('readany-chapter-translation-style')) { + var style = doc.createElement('style'); + style.id = 'readany-chapter-translation-style'; + style.textContent = [ + '.readany-translation {', + ' color: #6b7280;', + ' font-size: 0.9em;', + ' line-height: 1.5;', + ' margin-top: 4px;', + ' margin-bottom: 8px;', + ' padding-left: 8px;', + ' border-left: 2px solid #d1d5db;', + ' opacity: 0.85;', + '}', + '.readany-translation[data-hidden="true"] { display: none; }', + '[data-translate-id][data-original-hidden="true"] { display: none; }', + '@media (prefers-color-scheme: dark) {', + ' .readany-translation { color: #9ca3af; border-left-color: #4b5563; }', + '}' + ].join('\n'); + doc.head.appendChild(style); + } + + for (var i = 0; i < results.length; i++) { + var r = results[i]; + if (!r.translatedText) continue; + var el = doc.querySelector('[data-translate-id="' + r.paragraphId + '"]'); + if (!el) continue; + // Skip if already injected + if (el.nextElementSibling && el.nextElementSibling.classList.contains('readany-translation')) continue; + + var div = doc.createElement('div'); + div.className = 'readany-translation'; + div.setAttribute('data-para-id', r.paragraphId); + div.textContent = r.translatedText; + el.parentNode.insertBefore(div, el.nextSibling); + } + } catch (err) { + console.error('[WebView] Error injecting translations:', err); + } + } + + function handleRemoveChapterTranslations() { + try { + var doc = null; + var renderer = view && view.renderer; + if (renderer && renderer.getContents) { + var contents = renderer.getContents(); + if (contents && contents.length && contents[0] && contents[0].doc) { + doc = contents[0].doc; + } + } + if (!doc) { + var iframes = document.querySelectorAll('iframe'); + for (var fi = 0; fi < iframes.length; fi++) { + try { + var iframeDoc = iframes[fi].contentDocument || (iframes[fi].contentWindow && iframes[fi].contentWindow.document); + if (iframeDoc && iframeDoc.body) { doc = iframeDoc; break; } + } catch (e) {} + } + } + if (!doc) return; + + var elements = doc.querySelectorAll('.readany-translation'); + for (var i = 0; i < elements.length; i++) { + elements[i].remove(); + } + var style = doc.getElementById('readany-chapter-translation-style'); + if (style) style.remove(); + } catch (err) { + console.error('[WebView] Error removing translations:', err); + } + } + function getTextNodes(element) { const walker = element.ownerDocument.createTreeWalker( element, diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 70a4580e..e1d0a616 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -284,6 +284,15 @@ case 'extractChapters': await handleExtractChapters(); break; + case 'getChapterParagraphs': + handleGetChapterParagraphs(); + break; + case 'injectChapterTranslations': + handleInjectChapterTranslations(msg.results || []); + break; + case 'removeChapterTranslations': + handleRemoveChapterTranslations(); + break; } } @@ -944,6 +953,15 @@ var result = doGetVisibleText(); return JSON.stringify(result); }; + window.doGetChapterParagraphs = function () { + handleGetChapterParagraphs(); + }; + window.doInjectChapterTranslations = function (results) { + handleInjectChapterTranslations(results); + }; + window.doRemoveChapterTranslations = function () { + handleRemoveChapterTranslations(); + }; // ─── Polyfills ─── Object.groupBy ??= function (iterable, fn) { @@ -1098,6 +1116,153 @@ return segments; } + // ─── Chapter Translation Handlers ─── + + function handleGetChapterParagraphs() { + try { + // First try: view.renderer.getContents() + var doc = null; + var renderer = view && view.renderer; + if (renderer && renderer.getContents) { + var contents = renderer.getContents(); + if (contents && contents.length && contents[0] && contents[0].doc) { + doc = contents[0].doc; + } + } + + // Fallback: scan iframes + if (!doc) { + var iframes = document.querySelectorAll('iframe'); + for (var fi = 0; fi < iframes.length; fi++) { + try { + var iframeDoc = iframes[fi].contentDocument || (iframes[fi].contentWindow && iframes[fi].contentWindow.document); + if (iframeDoc && iframeDoc.body && iframeDoc.body.innerText && iframeDoc.body.innerText.trim().length > 0) { + doc = iframeDoc; + break; + } + } catch (e) { /* cross-origin, skip */ } + } + } + + if (!doc) { + postToRN('chapterParagraphs', { paragraphs: [], error: 'no document found (view=' + !!view + ', renderer=' + !!renderer + ', iframes=' + document.querySelectorAll('iframe').length + ')' }); + return; + } + + var blockSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote, dd, dt, figcaption, pre, td, th"; + var blocks = doc.querySelectorAll(blockSelector); + var paragraphs = []; + for (var i = 0; i < blocks.length; i++) { + var el = blocks[i]; + var text = (el.innerText || el.textContent || '').trim(); + if (text.length < 2) continue; + var id = 'para_' + i; + el.setAttribute('data-translate-id', id); + paragraphs.push({ id: id, text: text, tagName: el.tagName.toLowerCase() }); + } + + postToRN('chapterParagraphs', { paragraphs: paragraphs }); + } catch (err) { + postToRN('chapterParagraphs', { paragraphs: [], error: String(err) }); + } + } + + function handleInjectChapterTranslations(results) { + try { + var doc = null; + var renderer = view && view.renderer; + if (renderer && renderer.getContents) { + var contents = renderer.getContents(); + if (contents && contents.length && contents[0] && contents[0].doc) { + doc = contents[0].doc; + } + } + if (!doc) { + var iframes = document.querySelectorAll('iframe'); + for (var fi = 0; fi < iframes.length; fi++) { + try { + var iframeDoc = iframes[fi].contentDocument || (iframes[fi].contentWindow && iframes[fi].contentWindow.document); + if (iframeDoc && iframeDoc.body) { doc = iframeDoc; break; } + } catch (e) {} + } + } + if (!doc) return; + + // Inject CSS once + if (!doc.getElementById('readany-chapter-translation-style')) { + var style = doc.createElement('style'); + style.id = 'readany-chapter-translation-style'; + style.textContent = [ + '.readany-translation {', + ' color: #6b7280;', + ' font-size: 0.9em;', + ' line-height: 1.5;', + ' margin-top: 4px;', + ' margin-bottom: 8px;', + ' padding-left: 8px;', + ' border-left: 2px solid #d1d5db;', + ' opacity: 0.85;', + '}', + '.readany-translation[data-hidden="true"] { display: none; }', + '[data-translate-id][data-original-hidden="true"] { display: none; }', + '@media (prefers-color-scheme: dark) {', + ' .readany-translation { color: #9ca3af; border-left-color: #4b5563; }', + '}' + ].join('\n'); + doc.head.appendChild(style); + } + + for (var i = 0; i < results.length; i++) { + var r = results[i]; + if (!r.translatedText) continue; + var el = doc.querySelector('[data-translate-id="' + r.paragraphId + '"]'); + if (!el) continue; + // Skip if already injected + if (el.nextElementSibling && el.nextElementSibling.classList.contains('readany-translation')) continue; + + var div = doc.createElement('div'); + div.className = 'readany-translation'; + div.setAttribute('data-para-id', r.paragraphId); + div.textContent = r.translatedText; + el.parentNode.insertBefore(div, el.nextSibling); + } + } catch (err) { + console.error('[WebView] Error injecting translations:', err); + } + } + + function handleRemoveChapterTranslations() { + try { + var doc = null; + var renderer = view && view.renderer; + if (renderer && renderer.getContents) { + var contents = renderer.getContents(); + if (contents && contents.length && contents[0] && contents[0].doc) { + doc = contents[0].doc; + } + } + if (!doc) { + var iframes = document.querySelectorAll('iframe'); + for (var fi = 0; fi < iframes.length; fi++) { + try { + var iframeDoc = iframes[fi].contentDocument || (iframes[fi].contentWindow && iframes[fi].contentWindow.document); + if (iframeDoc && iframeDoc.body) { doc = iframeDoc; break; } + } catch (e) {} + } + } + if (!doc) return; + + var elements = doc.querySelectorAll('.readany-translation'); + for (var i = 0; i < elements.length; i++) { + elements[i].remove(); + } + var style = doc.getElementById('readany-chapter-translation-style'); + if (style) style.remove(); + } catch (err) { + console.error('[WebView] Error removing translations:', err); + } + } + function getTextNodes(element) { const walker = element.ownerDocument.createTreeWalker( element, diff --git a/packages/app-expo/src/components/reader/ChapterTranslationSheet.tsx b/packages/app-expo/src/components/reader/ChapterTranslationSheet.tsx new file mode 100644 index 00000000..58236301 --- /dev/null +++ b/packages/app-expo/src/components/reader/ChapterTranslationSheet.tsx @@ -0,0 +1,459 @@ +/** + * ChapterTranslationSheet — bottom action-sheet on mobile for whole-chapter translation. + * + * Triggered from the Languages toolbar button. Shows as a modal bottom sheet: + * idle → language selector + translate button + * extracting / translating → progress + cancel + * complete → toggle original / translation visibility + clear + * error → message + retry + clear + */ + +import { CheckIcon, EyeIcon, EyeOffIcon, LanguagesIcon, Trash2Icon, XIcon } from "@/components/ui/Icon"; +import type { ChapterTranslationState } from "@readany/core/hooks"; +import { useSettingsStore } from "@/stores"; +import type { TranslationTargetLang } from "@readany/core/types/translation"; +import { TRANSLATOR_LANGS } from "@readany/core/types/translation"; +import { type ThemeColors, fontSize, useColors } from "@/styles/theme"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from "react-native"; + +interface ChapterTranslationSheetProps { + visible: boolean; + onClose: () => void; + state: ChapterTranslationState; + onStart: (targetLang?: string) => void; + onCancel: () => void; + onToggleOriginalVisible: () => void; + onToggleTranslationVisible: () => void; + onReset: () => void; +} + +export function ChapterTranslationSheet({ + visible, + onClose, + state, + onStart, + onCancel, + onToggleOriginalVisible, + onToggleTranslationVisible, + onReset, +}: ChapterTranslationSheetProps) { + const { t } = useTranslation(); + const colors = useColors(); + const s = makeStyles(colors); + const defaultLang = useSettingsStore((ss) => ss.translationConfig.targetLang); + const [selectedLang, setSelectedLang] = useState(defaultLang); + const [showLangPicker, setShowLangPicker] = useState(false); + + const renderContent = () => { + // ── idle: language picker + translate ── + if (state.status === "idle") { + return ( + <> + + + {t("translation.translateChapter")} + + + setShowLangPicker(true)}> + {t("translation.targetLanguage")} + + {TRANSLATOR_LANGS[selectedLang]} + + + + + { + onStart(selectedLang); + }} + > + {t("translation.translateChapter")} + + + {/* Language picker modal */} + setShowLangPicker(false)} + > + setShowLangPicker(false)}> + + + {t("translation.selectLanguage", { defaultValue: "Select Language" })} + + code} + renderItem={({ item: [code, name] }) => ( + { + setSelectedLang(code as TranslationTargetLang); + setShowLangPicker(false); + }} + > + + {name} + + + )} + /> + + + + + ); + } + + // ── extracting ── + if (state.status === "extracting") { + return ( + + + {t("common.loading")} + + ); + } + + // ── translating: progress + cancel ── + if (state.status === "translating") { + const { translatedCount, totalParagraphs } = state.progress; + const pct = totalParagraphs > 0 ? Math.round((translatedCount / totalParagraphs) * 100) : 0; + + return ( + <> + + + + {t("translation.translatingProgress", { + count: translatedCount, + total: totalParagraphs, + })} + + + + + + + {t("translation.cancelTranslation")} + + + ); + } + + // ── complete: toggle original / translation + clear ── + if (state.status === "complete") { + return ( + <> + + + + {t("translation.chapterTranslated")} + + + + + + + {t("translation.original")} + + {state.originalVisible ? ( + + ) : ( + + )} + + + + + {t("translation.translationLabel")} + + {state.translationVisible ? ( + + ) : ( + + )} + + + + { + onReset(); + onClose(); + }} + > + + + {t("common.remove")} + + + + ); + } + + // ── error: message + retry + clear ── + if (state.status === "error") { + return ( + <> + + {state.message} + + onStart(selectedLang)}> + {t("common.retry")} + + { + onReset(); + onClose(); + }} + > + + + {t("common.remove")} + + + + ); + } + + return null; + }; + + return ( + + + e.stopPropagation()}> + {/* Drag handle */} + + + {renderContent()} + + {/* Close row — only for idle/complete, others have explicit actions */} + {(state.status === "idle" || state.status === "complete") && ( + + {t("common.close")} + + )} + + + + ); +} + +function makeStyles(colors: ThemeColors) { + return StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.35)", + justifyContent: "flex-end", + }, + sheet: { + backgroundColor: colors.background, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 20, + paddingBottom: 34, // safe area + paddingTop: 8, + gap: 12, + }, + handleBar: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.border, + alignSelf: "center", + marginBottom: 4, + }, + sheetTitle: { + fontSize: fontSize.base, + fontWeight: "600", + color: colors.foreground, + textAlign: "center", + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + }, + buttonRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + }, + + // Language selector + langSelector: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 14, + paddingVertical: 12, + borderRadius: 10, + backgroundColor: colors.muted, + }, + langSelectorLabel: { + fontSize: fontSize.sm, + color: colors.mutedForeground, + }, + langSelectorValue: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + langSelectorText: { + fontSize: fontSize.sm, + fontWeight: "500", + color: colors.foreground, + }, + langChevron: { + fontSize: 12, + color: colors.mutedForeground, + }, + + // Buttons + primaryButton: { + paddingVertical: 12, + borderRadius: 10, + backgroundColor: colors.primary, + alignItems: "center", + }, + primaryButtonText: { + fontSize: fontSize.sm, + fontWeight: "600", + color: "#fff", + }, + destructiveButton: { + paddingVertical: 10, + borderRadius: 10, + backgroundColor: colors.muted, + alignItems: "center", + }, + destructiveButtonText: { + fontSize: fontSize.sm, + color: colors.destructive || "#ef4444", + }, + + // Status + statusRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + paddingVertical: 4, + }, + statusText: { + fontSize: fontSize.sm, + color: colors.mutedForeground, + }, + progressBg: { + height: 6, + borderRadius: 3, + backgroundColor: colors.muted, + overflow: "hidden", + }, + progressFill: { + height: "100%", + backgroundColor: colors.primary, + borderRadius: 3, + }, + + // Toggle buttons for complete state + toggleRow: { + flexDirection: "row", + gap: 10, + }, + toggleButton: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 14, + paddingVertical: 12, + borderRadius: 10, + backgroundColor: colors.muted, + borderWidth: 1.5, + borderColor: colors.primary, + }, + toggleButtonOff: { + borderColor: colors.border, + opacity: 0.6, + }, + toggleLabel: { + fontSize: fontSize.sm, + fontWeight: "500", + color: colors.foreground, + }, + toggleLabelOff: { + color: colors.mutedForeground, + }, + + // Close + closeRow: { + alignItems: "center", + paddingVertical: 8, + }, + closeText: { + fontSize: fontSize.sm, + color: colors.mutedForeground, + }, + + // Language picker modal (nested) + langModalOverlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.4)", + justifyContent: "center", + alignItems: "center", + }, + langModalContent: { + width: 260, + maxHeight: 400, + backgroundColor: colors.background, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 4, + }, + langModalTitle: { + fontSize: fontSize.sm, + fontWeight: "600", + color: colors.foreground, + textAlign: "center", + marginBottom: 8, + }, + langOption: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 8, + }, + langOptionText: { + fontSize: fontSize.sm, + color: colors.foreground, + }, + }); +} diff --git a/packages/app-expo/src/hooks/use-reader-bridge.ts b/packages/app-expo/src/hooks/use-reader-bridge.ts index eb8a34c4..2b262ec9 100644 --- a/packages/app-expo/src/hooks/use-reader-bridge.ts +++ b/packages/app-expo/src/hooks/use-reader-bridge.ts @@ -58,6 +58,12 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { const callbacksRef = useRef(callbacks); callbacksRef.current = callbacks; const pendingVisibleTextResolveRef = useRef<((text: string) => void) | null>(null); + const pendingChapterParagraphsResolveRef = useRef< + | (( + paragraphs: Array<{ id: string; text: string; tagName: string }>, + ) => void) + | null + >(null); // ─── Send commands to WebView ─── @@ -230,6 +236,65 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { [inject], ); + const getChapterParagraphs = useCallback(() => { + return new Promise>((resolve) => { + pendingChapterParagraphsResolveRef.current = resolve; + + webViewRef.current?.injectJavaScript(` + (function() { + try { + if (window.doGetChapterParagraphs) { + window.doGetChapterParagraphs(); + } else { + window.ReactNativeWebView.postMessage(JSON.stringify({type:'chapterParagraphs',paragraphs:[],error:'doGetChapterParagraphs not defined'})); + } + } catch(e) { + window.ReactNativeWebView.postMessage(JSON.stringify({type:'chapterParagraphs',paragraphs:[],error:String(e)})); + } + })(); + true; + `); + + // Timeout fallback + setTimeout(() => { + if (pendingChapterParagraphsResolveRef.current === resolve) { + pendingChapterParagraphsResolveRef.current = null; + resolve([]); + } + }, 5000); + }); + }, []); + + const injectChapterTranslations = useCallback( + (results: Array<{ paragraphId: string; originalText: string; translatedText: string }>) => { + const payload = JSON.stringify(results); + webViewRef.current?.injectJavaScript(` + (function() { + try { + if (window.doInjectChapterTranslations) { + window.doInjectChapterTranslations(${payload}); + } + } catch(e) { console.error('[WebView] injectChapterTranslations error:', e); } + })(); + true; + `); + }, + [], + ); + + const removeChapterTranslations = useCallback(() => { + webViewRef.current?.injectJavaScript(` + (function() { + try { + if (window.doRemoveChapterTranslations) { + window.doRemoveChapterTranslations(); + } + } catch(e) { console.error('[WebView] removeChapterTranslations error:', e); } + })(); + true; + `); + }, []); + // ─── Handle messages from WebView ─── const handleMessage = useCallback((event: { nativeEvent: { data: string } }) => { @@ -312,6 +377,21 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { pendingVisibleTextResolveRef.current = null; } break; + case "chapterParagraphs": + console.log("[ChapterTranslation] Received chapterParagraphs:", JSON.stringify({ + count: msg.paragraphs?.length || 0, + error: msg.error || "none", + })); + if (pendingChapterParagraphsResolveRef.current) { + if (msg.error) { + console.warn("[ChapterTranslation] WebView error:", msg.error); + } + pendingChapterParagraphsResolveRef.current(msg.paragraphs || []); + pendingChapterParagraphsResolveRef.current = null; + } else { + console.warn("[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)"); + } + break; default: break; } @@ -343,6 +423,9 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { requestPageSnippet, getVisibleText, flashHighlight, + getChapterParagraphs, + injectChapterTranslations, + removeChapterTranslations, }), [ handleMessage, @@ -363,6 +446,9 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { setNavigationLocked, requestPageSnippet, getVisibleText, + getChapterParagraphs, + injectChapterTranslations, + removeChapterTranslations, ], ); } diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index 6db932c4..a8eba2e9 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -3,6 +3,8 @@ import { BookmarkRibbon } from "@/components/reader/BookmarkRibbon"; import { SelectionPopover } from "@/components/reader/SelectionPopover"; import { TTSControls } from "@/components/reader/TTSControls"; import { TranslationPanel } from "@/components/reader/TranslationPanel"; +import { ChapterTranslationSheet } from "@/components/reader/ChapterTranslationSheet"; +import { useChapterTranslation } from "@readany/core/hooks"; import { BookmarkFilledIcon, BookmarkIcon, @@ -11,6 +13,7 @@ import { ChevronLeftIcon, ChevronRightIcon, EditIcon, + LanguagesIcon, MessageSquareIcon, NotebookPenIcon, SearchIcon, @@ -226,6 +229,7 @@ export function ReaderScreen({ route, navigation }: Props) { const [showTranslation, setShowTranslation] = useState(false); const [translationText, setTranslationText] = useState(""); const [showTTS, setShowTTS] = useState(false); + const [showChapterTranslation, setShowChapterTranslation] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchResultCount, setSearchResultCount] = useState(0); const [searchIndex, setSearchIndex] = useState(0); @@ -266,6 +270,14 @@ export function ReaderScreen({ route, navigation }: Props) { getVisibleText: () => Promise; } | null>(null); + // Chapter translation state + const [currentSectionIndex, setCurrentSectionIndex] = useState(0); + const chapterTranslationBridgeRef = useRef<{ + getChapterParagraphs: () => Promise>; + injectChapterTranslations: (results: Array<{ paragraphId: string; originalText: string; translatedText: string }>) => void; + removeChapterTranslations: () => void; + } | null>(null); + const readSettings = useSettingsStore((s) => s.readSettings); const updateReadSettings = useSettingsStore((s) => s.updateReadSettings); const settingFontSize = readSettings.fontSize; @@ -301,6 +313,22 @@ export function ReaderScreen({ route, navigation }: Props) { const book = useMemo(() => books.find((b) => b.id === bookId), [books, bookId]); + // Chapter translation hook + const chapterTranslation = useChapterTranslation({ + bookId, + sectionIndex: currentSectionIndex, + getParagraphs: async () => { + if (!chapterTranslationBridgeRef.current) return []; + return chapterTranslationBridgeRef.current.getChapterParagraphs(); + }, + injectTranslations: (results) => { + chapterTranslationBridgeRef.current?.injectChapterTranslations(results); + }, + removeTranslations: () => { + chapterTranslationBridgeRef.current?.removeChapterTranslations(); + }, + }); + // Bookmark state const existingBookmark = useMemo( () => bookmarks.find((b) => b.bookId === bookId && b.cfi === currentCfi), @@ -381,6 +409,13 @@ export function ReaderScreen({ route, navigation }: Props) { }); }, onRelocate: (detail: RelocateEvent) => { + // Track section changes for chapter translation reset + const newSection = detail.section?.current ?? 0; + if (newSection !== currentSectionIndex) { + setCurrentSectionIndex(newSection); + chapterTranslation.reset(); + } + if (detail.fraction != null) setProgress(detail.fraction); if (detail.location) { setCurrentPage(detail.location.current); @@ -527,6 +562,46 @@ export function ReaderScreen({ route, navigation }: Props) { }); bridgeRef.current = bridge; + chapterTranslationBridgeRef.current = bridge; + + // Sync chapter translation visibility with WebView DOM + useEffect(() => { + if (chapterTranslation.state.status !== "complete") return; + const translationHidden = !chapterTranslation.state.translationVisible; + const originalHidden = !chapterTranslation.state.originalVisible; + // Inject JS to toggle data-hidden on translation elements and data-original-hidden on originals + bridge.webViewRef.current?.injectJavaScript(` + (function() { + try { + var doc = null; + var renderer = typeof view !== 'undefined' && view && view.renderer; + if (renderer && renderer.getContents) { + var contents = renderer.getContents(); + if (contents && contents[0] && contents[0].doc) doc = contents[0].doc; + } + if (!doc) { + var iframes = document.querySelectorAll('iframe'); + for (var fi = 0; fi < iframes.length; fi++) { + try { + var iframeDoc = iframes[fi].contentDocument || (iframes[fi].contentWindow && iframes[fi].contentWindow.document); + if (iframeDoc && iframeDoc.body) { doc = iframeDoc; break; } + } catch (e) {} + } + } + if (!doc) return; + var els = doc.querySelectorAll('.readany-translation'); + for (var i = 0; i < els.length; i++) { + els[i].setAttribute('data-hidden', '${translationHidden}'); + } + var origEls = doc.querySelectorAll('[data-translate-id]'); + for (var j = 0; j < origEls.length; j++) { + origEls[j].setAttribute('data-original-hidden', '${originalHidden}'); + } + } catch(e) {} + })(); + true; + `); + }, [chapterTranslation.state, bridge.webViewRef]); // Load book useEffect(() => { @@ -1060,6 +1135,22 @@ export function ReaderScreen({ route, navigation }: Props) { setShowTOC(true)}> + setShowChapterTranslation(true)} + > + + { @@ -1689,6 +1780,18 @@ export function ReaderScreen({ route, navigation }: Props) { /> )} + {/* ─── Chapter Translation Sheet ─── */} + setShowChapterTranslation(false)} + state={chapterTranslation.state} + onStart={chapterTranslation.startTranslation} + onCancel={chapterTranslation.cancelTranslation} + onToggleOriginalVisible={chapterTranslation.toggleOriginalVisible} + onToggleTranslationVisible={chapterTranslation.toggleTranslationVisible} + onReset={chapterTranslation.reset} + /> + {/* ─── TTS Controls ─── */} {showTTS && ( void; + onCancel: () => void; + onToggleOriginalVisible: () => void; + onToggleTranslationVisible: () => void; + onReset: () => void; +} + +export function ChapterTranslationMenu({ + state, + onStart, + onCancel, + onToggleOriginalVisible, + onToggleTranslationVisible, + onReset, +}: ChapterTranslationMenuProps) { + const { t } = useTranslation(); + const defaultLang = useSettingsStore((s) => s.translationConfig.targetLang); + const [selectedLang, setSelectedLang] = useState(defaultLang); + + const isActive = state.status !== "idle"; + + return ( + + + + + + + {/* ── idle: language picker + translate ── */} + {state.status === "idle" && ( + <> +
+ +
+ onStart(selectedLang)} + > + + {t("translation.translateChapter")} + + + )} + + {/* ── extracting ── */} + {state.status === "extracting" && ( +
+ + {t("common.loading")} +
+ )} + + {/* ── translating: progress + cancel ── */} + {state.status === "translating" && (() => { + const { translatedCount, totalParagraphs } = state.progress; + const pct = totalParagraphs > 0 ? Math.round((translatedCount / totalParagraphs) * 100) : 0; + return ( + <> +
+
+ + + {t("translation.translatingProgress", { + count: translatedCount, + total: totalParagraphs, + })} + +
+
+
+
+
+ + + + {t("translation.cancelTranslation")} + + + ); + })()} + + {/* ── complete: toggle original / translation + clear ── */} + {state.status === "complete" && ( + <> +
+ + + {t("translation.chapterTranslated")} + +
+ + { + e.preventDefault(); + onToggleOriginalVisible(); + }} + > + {state.originalVisible ? ( + + ) : ( + + )} + {t("translation.original")} + {state.originalVisible && } + + { + e.preventDefault(); + onToggleTranslationVisible(); + }} + > + {state.translationVisible ? ( + + ) : ( + + )} + {t("translation.translationLabel")} + {state.translationVisible && } + + + + + {t("common.remove")} + + + )} + + {/* ── error: message + retry + clear ── */} + {state.status === "error" && ( + <> +
+ {state.message} +
+ + onStart(selectedLang)} + > + + {t("common.retry")} + + + + {t("common.remove")} + + + )} + + + ); +} diff --git a/packages/app/src/components/reader/FoliateViewer.tsx b/packages/app/src/components/reader/FoliateViewer.tsx index d7db6e51..408a647e 100644 --- a/packages/app/src/components/reader/FoliateViewer.tsx +++ b/packages/app/src/components/reader/FoliateViewer.tsx @@ -8,6 +8,10 @@ import type { BookDoc, BookFormat } from "@/lib/reader/document-loader"; import { getDirection, isFixedLayoutFormat } from "@/lib/reader/document-loader"; import { getFontTheme } from "@/lib/reader/font-themes"; import { registerIframeEventHandlers } from "@/lib/reader/iframe-event-handlers"; +import type { + ChapterParagraph, + ChapterTranslationResult, +} from "@readany/core/translation/chapter-translator"; import type { ViewSettings } from "@readany/core/types"; import { Overlayer } from "foliate-js/overlayer.js"; import { marked } from "marked"; @@ -139,6 +143,12 @@ export interface FoliateViewerHandle { getView: () => FoliateView | null; /** Get visible text on the current page for TTS */ getVisibleText: () => string; + /** Extract all paragraphs from current section for chapter translation */ + getChapterParagraphs: () => ChapterParagraph[]; + /** Inject translated paragraphs below each original paragraph */ + injectChapterTranslations: (results: ChapterTranslationResult[]) => void; + /** Remove all injected chapter translation elements */ + removeChapterTranslations: () => void; } interface FoliateViewerProps { @@ -389,6 +399,101 @@ export const FoliateViewer = forwardRef return ""; } }, + getChapterParagraphs: () => { + try { + const renderer = viewRef.current?.renderer; + const contents = renderer?.getContents?.(); + if (!contents?.[0]?.doc) return []; + const doc = contents[0].doc as Document; + + const blockSelector = + "p, h1, h2, h3, h4, h5, h6, li, blockquote, dd, dt, figcaption, pre, td, th"; + const blocks = doc.querySelectorAll(blockSelector); + const paragraphs: ChapterParagraph[] = []; + + blocks.forEach((el, i) => { + const text = (el as HTMLElement).innerText?.trim() || el.textContent?.trim() || ""; + if (text.length < 2) return; + const id = `para_${i}`; + (el as HTMLElement).setAttribute("data-translate-id", id); + paragraphs.push({ + id, + text, + tagName: el.tagName.toLowerCase(), + }); + }); + + return paragraphs; + } catch { + return []; + } + }, + injectChapterTranslations: (results: ChapterTranslationResult[]) => { + try { + const renderer = viewRef.current?.renderer; + const contents = renderer?.getContents?.(); + if (!contents?.[0]?.doc) return; + const doc = contents[0].doc as Document; + + // Inject translation CSS once + if (!doc.getElementById("readany-chapter-translation-style")) { + const style = doc.createElement("style"); + style.id = "readany-chapter-translation-style"; + style.textContent = ` + .readany-translation { + color: #6b7280; + font-size: 0.9em; + line-height: 1.5; + margin-top: 4px; + margin-bottom: 8px; + padding-left: 8px; + border-left: 2px solid #d1d5db; + opacity: 0.85; + } + .readany-translation[data-hidden="true"] { display: none; } + [data-translate-id][data-original-hidden="true"] { display: none; } + @media (prefers-color-scheme: dark) { + .readany-translation { color: #9ca3af; border-left-color: #4b5563; } + } + `; + doc.head.appendChild(style); + } + + for (const result of results) { + if (!result.translatedText) continue; + const el = doc.querySelector( + `[data-translate-id="${result.paragraphId}"]`, + ) as HTMLElement | null; + if (!el) continue; + // Skip if already injected + if (el.nextElementSibling?.classList?.contains("readany-translation")) continue; + + const div = doc.createElement("div"); + div.className = "readany-translation"; + div.setAttribute("data-para-id", result.paragraphId); + div.textContent = result.translatedText; + el.parentNode?.insertBefore(div, el.nextSibling); + } + } catch (err) { + console.error("[injectChapterTranslations] Error:", err); + } + }, + removeChapterTranslations: () => { + try { + const renderer = viewRef.current?.renderer; + const contents = renderer?.getContents?.(); + if (!contents?.[0]?.doc) return; + const doc = contents[0].doc as Document; + + const elements = doc.querySelectorAll(".readany-translation"); + elements.forEach((el) => el.remove()); + + const style = doc.getElementById("readany-chapter-translation-style"); + style?.remove(); + } catch (err) { + console.error("[removeChapterTranslations] Error:", err); + } + }, }), [viewReady], ); diff --git a/packages/app/src/components/reader/ReaderToolbar.tsx b/packages/app/src/components/reader/ReaderToolbar.tsx index 7017d84d..e0193680 100644 --- a/packages/app/src/components/reader/ReaderToolbar.tsx +++ b/packages/app/src/components/reader/ReaderToolbar.tsx @@ -5,6 +5,7 @@ import { useAppStore } from "@/stores/app-store"; import { useNotebookStore } from "@/stores/notebook-store"; import { useReaderStore } from "@/stores/reader-store"; import { generateId } from "@readany/core/utils"; +import type { ChapterTranslationState } from "@readany/core/hooks"; import { ArrowLeft, Bookmark, @@ -18,6 +19,7 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { ChapterTranslationMenu } from "./ChapterTranslationBar"; import type { TOCItem } from "./FoliateViewer"; interface ReaderToolbarProps { @@ -32,6 +34,12 @@ interface ReaderToolbarProps { onToggleSettings?: () => void; onToggleChat?: () => void; onToggleTTS?: () => void; + chapterTranslationState: ChapterTranslationState; + onChapterTranslationStart: (targetLang?: string) => void; + onChapterTranslationCancel: () => void; + onToggleOriginalVisible: () => void; + onToggleTranslationVisible: () => void; + onChapterTranslationReset: () => void; isChatOpen?: boolean; isTTSActive?: boolean; isFixedLayout?: boolean; @@ -52,6 +60,12 @@ export function ReaderToolbar({ onToggleSettings, onToggleChat, onToggleTTS, + chapterTranslationState, + onChapterTranslationStart, + onChapterTranslationCancel, + onToggleOriginalVisible, + onToggleTranslationVisible, + onChapterTranslationReset, isChatOpen, isTTSActive, isFixedLayout = false, @@ -179,8 +193,16 @@ export function ReaderToolbar({
- {/* Right: TTS + search + AI chat + settings */} + {/* Right: translate + TTS + search + AI chat + settings */}
+
onStart(selectedLang)} + onSelect={() => { + setTranslationLang(selectedLang); + onStart(selectedLang); + }} > {t("translation.translateChapter")} @@ -200,7 +204,10 @@ export function ChapterTranslationMenu({ onStart(selectedLang)} + onSelect={() => { + setTranslationLang(selectedLang); + onStart(selectedLang); + }} > {t("common.retry")} diff --git a/packages/app/src/components/reader/FoliateViewer.tsx b/packages/app/src/components/reader/FoliateViewer.tsx index 408a647e..39f58a9f 100644 --- a/packages/app/src/components/reader/FoliateViewer.tsx +++ b/packages/app/src/components/reader/FoliateViewer.tsx @@ -335,6 +335,7 @@ export const FoliateViewer = forwardRef const parent = (node as Text).parentElement; const tag = parent?.tagName?.toLowerCase(); if (tag === "script" || tag === "style") return NodeFilter.FILTER_REJECT; + if (parent?.closest?.(".readany-translation")) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }); @@ -445,6 +446,8 @@ export const FoliateViewer = forwardRef font-size: 0.9em; line-height: 1.5; margin-top: 4px; + user-select: none; + -webkit-user-select: none; margin-bottom: 8px; padding-left: 8px; border-left: 2px solid #d1d5db; From b7b5524333614f9c6bf505b1ceaee608fc4399b1 Mon Sep 17 00:00:00 2001 From: bealqiu Date: Fri, 27 Mar 2026 14:45:22 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=B7=B2=E7=BC=93=E5=AD=98=E7=BF=BB=E8=AF=91=20+=20?= =?UTF-8?q?=E8=AF=91=E6=96=87=E7=8B=AC=E6=98=BE=E6=97=B6=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E7=BB=A7=E6=89=BF=E5=8E=9F=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 打开已翻译章节时自动检测缓存并恢复翻译(桌面端+移动端) - hook 新增 ready 参数,确保 DOM 就绪后再恢复 - 隐藏原文只显示译文时,译文样式继承原文(去除灰色、缩进、边框) - CSS 新增 data-solo 属性控制译文独显样式 Co-Authored-By: Claude Opus 4.6 --- packages/app-expo/assets/reader/reader.html | 1 + .../assets/reader/reader.template.html | 1 + .../app-expo/src/screens/ReaderScreen.tsx | 8 ++++-- .../src/components/reader/FoliateViewer.tsx | 10 +++++++ .../app/src/components/reader/ReaderView.tsx | 16 ++++++----- .../core/src/hooks/useChapterTranslation.ts | 27 ++++++++++++++++--- 6 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 87a42bf6..403d35e7 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -1210,6 +1210,7 @@ ' opacity: 0.85;', '}', '.readany-translation[data-hidden="true"] { display: none; }', + '.readany-translation[data-solo="true"] { color: inherit; font-size: inherit; line-height: inherit; margin-top: 0; margin-bottom: 0; padding-left: 0; border-left: none; opacity: 1; }', '[data-translate-id][data-original-hidden="true"] { display: none; }', '@media (prefers-color-scheme: dark) {', ' .readany-translation { color: #9ca3af; border-left-color: #4b5563; }', diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index c78b36f8..72aea36e 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -1210,6 +1210,7 @@ ' opacity: 0.85;', '}', '.readany-translation[data-hidden="true"] { display: none; }', + '.readany-translation[data-solo="true"] { color: inherit; font-size: inherit; line-height: inherit; margin-top: 0; margin-bottom: 0; padding-left: 0; border-left: none; opacity: 1; }', '[data-translate-id][data-original-hidden="true"] { display: none; }', '@media (prefers-color-scheme: dark) {', ' .readany-translation { color: #9ca3af; border-left-color: #4b5563; }', diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index a8eba2e9..b93eb909 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -317,6 +317,7 @@ export function ReaderScreen({ route, navigation }: Props) { const chapterTranslation = useChapterTranslation({ bookId, sectionIndex: currentSectionIndex, + ready: webViewReady && !loading, getParagraphs: async () => { if (!chapterTranslationBridgeRef.current) return []; return chapterTranslationBridgeRef.current.getChapterParagraphs(); @@ -567,8 +568,10 @@ export function ReaderScreen({ route, navigation }: Props) { // Sync chapter translation visibility with WebView DOM useEffect(() => { if (chapterTranslation.state.status !== "complete") return; - const translationHidden = !chapterTranslation.state.translationVisible; - const originalHidden = !chapterTranslation.state.originalVisible; + const { originalVisible, translationVisible } = chapterTranslation.state; + const translationHidden = !translationVisible; + const originalHidden = !originalVisible; + const solo = !originalVisible && translationVisible; // Inject JS to toggle data-hidden on translation elements and data-original-hidden on originals bridge.webViewRef.current?.injectJavaScript(` (function() { @@ -592,6 +595,7 @@ export function ReaderScreen({ route, navigation }: Props) { var els = doc.querySelectorAll('.readany-translation'); for (var i = 0; i < els.length; i++) { els[i].setAttribute('data-hidden', '${translationHidden}'); + els[i].setAttribute('data-solo', '${solo}'); } var origEls = doc.querySelectorAll('[data-translate-id]'); for (var j = 0; j < origEls.length; j++) { diff --git a/packages/app/src/components/reader/FoliateViewer.tsx b/packages/app/src/components/reader/FoliateViewer.tsx index 39f58a9f..42bd8cd4 100644 --- a/packages/app/src/components/reader/FoliateViewer.tsx +++ b/packages/app/src/components/reader/FoliateViewer.tsx @@ -454,6 +454,16 @@ export const FoliateViewer = forwardRef opacity: 0.85; } .readany-translation[data-hidden="true"] { display: none; } + .readany-translation[data-solo="true"] { + color: inherit; + font-size: inherit; + line-height: inherit; + margin-top: 0; + margin-bottom: 0; + padding-left: 0; + border-left: none; + opacity: 1; + } [data-translate-id][data-original-hidden="true"] { display: none; } @media (prefers-color-scheme: dark) { .readany-translation { color: #9ca3af; border-left-color: #4b5563; } diff --git a/packages/app/src/components/reader/ReaderView.tsx b/packages/app/src/components/reader/ReaderView.tsx index e7e1f0ab..aa4a0157 100644 --- a/packages/app/src/components/reader/ReaderView.tsx +++ b/packages/app/src/components/reader/ReaderView.tsx @@ -247,10 +247,14 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // Current section index for chapter translation const [currentSectionIndex, setCurrentSectionIndex] = useState(0); + // Track when foliate is ready to receive annotations + const [foliateReady, setFoliateReady] = useState(false); + // Chapter translation hook const chapterTranslation = useChapterTranslation({ bookId, sectionIndex: currentSectionIndex, + ready: foliateReady, getParagraphs: () => foliateRef.current?.getChapterParagraphs() ?? [], injectTranslations: (results) => foliateRef.current?.injectChapterTranslations(results), removeTranslations: () => foliateRef.current?.removeChapterTranslations(), @@ -259,9 +263,6 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // Track which highlights have been rendered (id -> {cfi, note}) to detect changes const renderedHighlightsRef = useRef>(new Map()); - // Track when foliate is ready to receive annotations - const [foliateReady, setFoliateReady] = useState(false); - // Reset rendered highlights tracking when book changes useEffect(() => { renderedHighlightsRef.current.clear(); @@ -582,17 +583,18 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const contents = renderer?.getContents?.(); if (!contents?.[0]?.doc) return; const doc = contents[0].doc as Document; + const { originalVisible, translationVisible } = chapterTranslation.state; // Translation elements const translationEls = doc.querySelectorAll(".readany-translation"); - const translationHidden = !chapterTranslation.state.translationVisible; translationEls.forEach((el) => { - (el as HTMLElement).setAttribute("data-hidden", String(translationHidden)); + (el as HTMLElement).setAttribute("data-hidden", String(!translationVisible)); + // When original is hidden, show translation in original style + (el as HTMLElement).setAttribute("data-solo", String(!originalVisible && translationVisible)); }); // Original text elements const originalEls = doc.querySelectorAll("[data-translate-id]"); - const originalHidden = !chapterTranslation.state.originalVisible; originalEls.forEach((el) => { - (el as HTMLElement).setAttribute("data-original-hidden", String(originalHidden)); + (el as HTMLElement).setAttribute("data-original-hidden", String(!originalVisible)); }); } catch { // Ignore diff --git a/packages/core/src/hooks/useChapterTranslation.ts b/packages/core/src/hooks/useChapterTranslation.ts index d2ae156c..3427efe6 100644 --- a/packages/core/src/hooks/useChapterTranslation.ts +++ b/packages/core/src/hooks/useChapterTranslation.ts @@ -7,9 +7,9 @@ * Supports progressive injection, cancellation, and visibility toggle. */ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useSettingsStore } from "../stores/settings-store"; -import { markChapterFullyCached } from "../translation/chapter-cache"; +import { isChapterFullyCached, markChapterFullyCached } from "../translation/chapter-cache"; import type { ChapterParagraph, ChapterTranslationProgress, @@ -31,6 +31,8 @@ export type ChapterTranslationState = export interface UseChapterTranslationOptions { bookId: string; sectionIndex: number; + /** Whether the reader is ready (DOM loaded) — auto-restore waits for this */ + ready?: boolean; /** Extract paragraphs from the current section DOM */ getParagraphs: () => Promise | ChapterParagraph[]; /** Inject translated paragraphs into the DOM */ @@ -40,10 +42,11 @@ export interface UseChapterTranslationOptions { } export function useChapterTranslation(options: UseChapterTranslationOptions) { - const { bookId, sectionIndex, getParagraphs, injectTranslations, removeTranslations } = options; + const { bookId, sectionIndex, ready = true, getParagraphs, injectTranslations, removeTranslations } = options; const [state, setState] = useState({ status: "idle" }); const abortRef = useRef(null); + const autoRestoreAttemptedRef = useRef(""); const translationConfig = useSettingsStore((s) => s.translationConfig); const aiConfig = useSettingsStore((s) => s.aiConfig); @@ -149,9 +152,27 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { const reset = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; + autoRestoreAttemptedRef.current = ""; removeTranslations(); setState({ status: "idle" }); }, [removeTranslations]); + // ---- Auto-restore cached translations on section load ----------------------- + useEffect(() => { + const key = `${bookId}_${sectionIndex}_${translationConfig.targetLang}`; + // Only attempt once per section+lang combo, and only when idle+ready + if (!ready || state.status !== "idle" || autoRestoreAttemptedRef.current === key) return; + autoRestoreAttemptedRef.current = key; + + let cancelled = false; + isChapterFullyCached(bookId, sectionIndex, translationConfig.targetLang).then((cached) => { + if (cached && !cancelled) { + startTranslation(); + } + }).catch(() => {}); + + return () => { cancelled = true; }; + }, [ready, bookId, sectionIndex, translationConfig.targetLang, state.status, startTranslation]); + return { state, startTranslation, cancelTranslation, toggleOriginalVisible, toggleTranslationVisible, reset }; } From 6f50c9a29e2f75e8c115eb6c9bc4e2ce14b87d7c Mon Sep 17 00:00:00 2001 From: bealqiu Date: Fri, 27 Mar 2026 15:13:52 +0800 Subject: [PATCH 5/8] =?UTF-8?q?debug:=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=81=A2=E5=A4=8D=E7=BF=BB=E8=AF=91=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E6=97=A5=E5=BF=97=20+=20=E4=BF=AE=E5=A4=8D=20translationReady?= =?UTF-8?q?=20=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 13 ++++++++++++- .../app/src/components/reader/ReaderView.tsx | 8 +++++++- .../core/src/hooks/useChapterTranslation.ts | 17 +++++++++++------ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 73b41f0e..0b767e50 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -34,7 +34,18 @@ "WebFetch(domain:stackoverflow.com)", "WebFetch(domain:mozilla.github.io)", "WebFetch(domain:jsdev.space)", - "WebFetch(domain:pdf-lib.js.org)" + "WebFetch(domain:pdf-lib.js.org)", + "Read(//Users/bealqiu/Downloads/**)", + "Read(//Users/bealqiu/Desktop/**)", + "Bash(iconv:*)", + "Bash(/tmp/txt_import_analysis.md:*)", + "Read(//tmp/**)", + "Bash(npm install:*)", + "Bash(grep \"\"packageManager\"\":*)", + "Bash(grep \"\"main\"\":*)", + "Bash(/tmp/encoding_detection_flow.txt:*)", + "Bash(gh pr:*)", + "Bash(gh api:*)" ] } } diff --git a/packages/app/src/components/reader/ReaderView.tsx b/packages/app/src/components/reader/ReaderView.tsx index aa4a0157..28f3863e 100644 --- a/packages/app/src/components/reader/ReaderView.tsx +++ b/packages/app/src/components/reader/ReaderView.tsx @@ -249,12 +249,14 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // Track when foliate is ready to receive annotations const [foliateReady, setFoliateReady] = useState(false); + // Separate delayed ready for chapter translation (avoids DOM conflict with CFI navigation) + const [translationReady, setTranslationReady] = useState(false); // Chapter translation hook const chapterTranslation = useChapterTranslation({ bookId, sectionIndex: currentSectionIndex, - ready: foliateReady, + ready: translationReady, getParagraphs: () => foliateRef.current?.getChapterParagraphs() ?? [], injectTranslations: (results) => foliateRef.current?.injectChapterTranslations(results), removeTranslations: () => foliateRef.current?.removeChapterTranslations(), @@ -267,6 +269,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { useEffect(() => { renderedHighlightsRef.current.clear(); setFoliateReady(false); + setTranslationReady(false); }, [bookId]); // Ref to track if we've already handled the initialCfi for this mount @@ -527,6 +530,8 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { setIsLoading(false); // Mark foliate as ready to receive annotations setFoliateReady(true); + // Delay translation ready to avoid DOM conflict with CFI navigation + setTimeout(() => setTranslationReady(true), 500); }, []); // Handle section load (chapter change) - re-render all highlights @@ -537,6 +542,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { (sectionIndex: number) => { // Reset chapter translation on section change setCurrentSectionIndex(sectionIndex); + setTranslationReady(false); chapterTranslation.reset(); // Delay slightly to ensure foliate view is ready diff --git a/packages/core/src/hooks/useChapterTranslation.ts b/packages/core/src/hooks/useChapterTranslation.ts index 3427efe6..1fbb0253 100644 --- a/packages/core/src/hooks/useChapterTranslation.ts +++ b/packages/core/src/hooks/useChapterTranslation.ts @@ -160,18 +160,23 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { // ---- Auto-restore cached translations on section load ----------------------- useEffect(() => { const key = `${bookId}_${sectionIndex}_${translationConfig.targetLang}`; + console.log("[ChapterTranslation] auto-restore check:", { ready, status: state.status, key, attempted: autoRestoreAttemptedRef.current }); // Only attempt once per section+lang combo, and only when idle+ready if (!ready || state.status !== "idle" || autoRestoreAttemptedRef.current === key) return; autoRestoreAttemptedRef.current = key; let cancelled = false; - isChapterFullyCached(bookId, sectionIndex, translationConfig.targetLang).then((cached) => { - if (cached && !cancelled) { - startTranslation(); - } - }).catch(() => {}); + // Small delay to ensure DOM is fully stable after navigation + const timer = setTimeout(() => { + isChapterFullyCached(bookId, sectionIndex, translationConfig.targetLang).then((cached) => { + console.log("[ChapterTranslation] cache check result:", { cached, cancelled, key }); + if (cached && !cancelled) { + startTranslation(); + } + }).catch(() => {}); + }, 300); - return () => { cancelled = true; }; + return () => { cancelled = true; clearTimeout(timer); }; }, [ready, bookId, sectionIndex, translationConfig.targetLang, state.status, startTranslation]); return { state, startTranslation, cancelTranslation, toggleOriginalVisible, toggleTranslationVisible, reset }; From b3e91013e21763b433e4d60e885b47849901b3ed Mon Sep 17 00:00:00 2001 From: bealqiu Date: Fri, 27 Mar 2026 16:05:18 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=BF=BB=E8=AF=91=E7=9A=84=20useEffect=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startTranslation 因内联函数每次 render 重建,导致 useEffect cleanup 取消 timer,翻译永远不触发。改用 ref 存最新 startTranslation 引用, 从 useEffect 依赖中移除。 Co-Authored-By: Claude Opus 4.6 --- packages/core/src/hooks/useChapterTranslation.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/hooks/useChapterTranslation.ts b/packages/core/src/hooks/useChapterTranslation.ts index 1fbb0253..6f7f72e5 100644 --- a/packages/core/src/hooks/useChapterTranslation.ts +++ b/packages/core/src/hooks/useChapterTranslation.ts @@ -47,6 +47,7 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { const [state, setState] = useState({ status: "idle" }); const abortRef = useRef(null); const autoRestoreAttemptedRef = useRef(""); + const startTranslationRef = useRef<() => void>(() => {}); const translationConfig = useSettingsStore((s) => s.translationConfig); const aiConfig = useSettingsStore((s) => s.aiConfig); @@ -126,6 +127,9 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { [translationConfig, aiConfig, bookId, sectionIndex, getParagraphs, injectTranslations], ); + // Keep ref in sync so auto-restore effect doesn't depend on startTranslation identity + startTranslationRef.current = startTranslation; + // ---- Cancel --------------------------------------------------------------- const cancelTranslation = useCallback(() => { abortRef.current?.abort(); @@ -160,7 +164,6 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { // ---- Auto-restore cached translations on section load ----------------------- useEffect(() => { const key = `${bookId}_${sectionIndex}_${translationConfig.targetLang}`; - console.log("[ChapterTranslation] auto-restore check:", { ready, status: state.status, key, attempted: autoRestoreAttemptedRef.current }); // Only attempt once per section+lang combo, and only when idle+ready if (!ready || state.status !== "idle" || autoRestoreAttemptedRef.current === key) return; autoRestoreAttemptedRef.current = key; @@ -169,15 +172,14 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { // Small delay to ensure DOM is fully stable after navigation const timer = setTimeout(() => { isChapterFullyCached(bookId, sectionIndex, translationConfig.targetLang).then((cached) => { - console.log("[ChapterTranslation] cache check result:", { cached, cancelled, key }); if (cached && !cancelled) { - startTranslation(); + startTranslationRef.current(); } }).catch(() => {}); }, 300); return () => { cancelled = true; clearTimeout(timer); }; - }, [ready, bookId, sectionIndex, translationConfig.targetLang, state.status, startTranslation]); + }, [ready, bookId, sectionIndex, translationConfig.targetLang, state.status]); return { state, startTranslation, cancelTranslation, toggleOriginalVisible, toggleTranslationVisible, reset }; } From 1518f4cd79ae914723bc8d7dc4246c3196ff59c0 Mon Sep 17 00:00:00 2001 From: bealqiu Date: Fri, 27 Mar 2026 16:35:55 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=E8=87=AA=E5=8A=A8=E6=81=A2=E5=A4=8D=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E4=B9=A6=E7=B1=8D=E5=8A=A0=E8=BD=BD=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=20+=20=E8=AF=91=E6=96=87=E7=8B=AC=E6=98=BE=E6=AE=B5?= =?UTF-8?q?=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - translationReady 改为在 relocate 事件后设置,确保 CFI 导航完成后再注入翻译 - 之前在 handleLoaded + 500ms timer 设置,但 CFI 解析在 load 事件后仍在进行 - 译文独显模式 (data-solo) 添加 margin-bottom: 0.8em 段落间距 Co-Authored-By: Claude Opus 4.6 --- packages/app-expo/assets/reader/reader.html | 2 +- packages/app-expo/assets/reader/reader.template.html | 2 +- packages/app/src/components/reader/FoliateViewer.tsx | 2 +- packages/app/src/components/reader/ReaderView.tsx | 7 ++++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 403d35e7..404150a5 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -1210,7 +1210,7 @@ ' opacity: 0.85;', '}', '.readany-translation[data-hidden="true"] { display: none; }', - '.readany-translation[data-solo="true"] { color: inherit; font-size: inherit; line-height: inherit; margin-top: 0; margin-bottom: 0; padding-left: 0; border-left: none; opacity: 1; }', + '.readany-translation[data-solo="true"] { color: inherit; font-size: inherit; line-height: inherit; margin-top: 0; margin-bottom: 0.8em; padding-left: 0; border-left: none; opacity: 1; }', '[data-translate-id][data-original-hidden="true"] { display: none; }', '@media (prefers-color-scheme: dark) {', ' .readany-translation { color: #9ca3af; border-left-color: #4b5563; }', diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 72aea36e..a454ff48 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -1210,7 +1210,7 @@ ' opacity: 0.85;', '}', '.readany-translation[data-hidden="true"] { display: none; }', - '.readany-translation[data-solo="true"] { color: inherit; font-size: inherit; line-height: inherit; margin-top: 0; margin-bottom: 0; padding-left: 0; border-left: none; opacity: 1; }', + '.readany-translation[data-solo="true"] { color: inherit; font-size: inherit; line-height: inherit; margin-top: 0; margin-bottom: 0.8em; padding-left: 0; border-left: none; opacity: 1; }', '[data-translate-id][data-original-hidden="true"] { display: none; }', '@media (prefers-color-scheme: dark) {', ' .readany-translation { color: #9ca3af; border-left-color: #4b5563; }', diff --git a/packages/app/src/components/reader/FoliateViewer.tsx b/packages/app/src/components/reader/FoliateViewer.tsx index 42bd8cd4..a2b7fa4e 100644 --- a/packages/app/src/components/reader/FoliateViewer.tsx +++ b/packages/app/src/components/reader/FoliateViewer.tsx @@ -459,7 +459,7 @@ export const FoliateViewer = forwardRef font-size: inherit; line-height: inherit; margin-top: 0; - margin-bottom: 0; + margin-bottom: 0.8em; padding-left: 0; border-left: none; opacity: 1; diff --git a/packages/app/src/components/reader/ReaderView.tsx b/packages/app/src/components/reader/ReaderView.tsx index 28f3863e..6e761e40 100644 --- a/packages/app/src/components/reader/ReaderView.tsx +++ b/packages/app/src/components/reader/ReaderView.tsx @@ -518,8 +518,11 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // Throttled save to DB throttledSaveProgress(bookId, progress, cfi); + + // Mark translation ready after first successful relocate (CFI navigation done) + if (!translationReady) setTranslationReady(true); }, - [tabId, bookId, bookFormat, setProgress, setChapter, throttledSaveProgress], + [tabId, bookId, bookFormat, setProgress, setChapter, throttledSaveProgress, translationReady], ); const handleTocReady = useCallback((toc: TOCItem[]) => { @@ -530,8 +533,6 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { setIsLoading(false); // Mark foliate as ready to receive annotations setFoliateReady(true); - // Delay translation ready to avoid DOM conflict with CFI navigation - setTimeout(() => setTranslationReady(true), 500); }, []); // Handle section load (chapter change) - re-render all highlights From 2da08b5e201f2537fa20ad068a7d81d1ee9de4a9 Mon Sep 17 00:00:00 2001 From: bealqiu Date: Fri, 27 Mar 2026 16:53:27 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E8=AF=91=E6=96=87?= =?UTF-8?q?=20user-select:none=EF=BC=8C=E6=81=A2=E5=A4=8D=E9=80=89?= =?UTF-8?q?=E4=B8=AD=E5=A4=8D=E5=88=B6=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 译文不应禁止选中——只是不支持高亮笔记(因为 CFI 不覆盖注入的 DOM)。 Co-Authored-By: Claude Opus 4.6 --- packages/app-expo/assets/reader/reader.html | 2 -- packages/app-expo/assets/reader/reader.template.html | 2 -- packages/app/src/components/reader/FoliateViewer.tsx | 2 -- 3 files changed, 6 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 404150a5..e0c6d5fe 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -1203,8 +1203,6 @@ ' line-height: 1.5;', ' margin-top: 4px;', ' margin-bottom: 8px;', - ' user-select: none;', - ' -webkit-user-select: none;', ' padding-left: 8px;', ' border-left: 2px solid #d1d5db;', ' opacity: 0.85;', diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index a454ff48..dd918baf 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -1203,8 +1203,6 @@ ' line-height: 1.5;', ' margin-top: 4px;', ' margin-bottom: 8px;', - ' user-select: none;', - ' -webkit-user-select: none;', ' padding-left: 8px;', ' border-left: 2px solid #d1d5db;', ' opacity: 0.85;', diff --git a/packages/app/src/components/reader/FoliateViewer.tsx b/packages/app/src/components/reader/FoliateViewer.tsx index a2b7fa4e..b4ba7ef7 100644 --- a/packages/app/src/components/reader/FoliateViewer.tsx +++ b/packages/app/src/components/reader/FoliateViewer.tsx @@ -446,8 +446,6 @@ export const FoliateViewer = forwardRef font-size: 0.9em; line-height: 1.5; margin-top: 4px; - user-select: none; - -webkit-user-select: none; margin-bottom: 8px; padding-left: 8px; border-left: 2px solid #d1d5db;