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/.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/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index f93ac622..e0c6d5fe 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); @@ -993,8 +742,10 @@ var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; - var tag = node.parentElement && node.parentElement.tagName && node.parentElement.tagName.toLowerCase(); + var parent = node.parentElement; + var tag = parent && parent.tagName && parent.tagName.toLowerCase(); if (tag === 'script' || tag === 'style') return NodeFilter.FILTER_REJECT; + if (parent && parent.closest && parent.closest('.readany-translation')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); @@ -1059,8 +810,10 @@ var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; - var tag = node.parentElement && node.parentElement.tagName && node.parentElement.tagName.toLowerCase(); + var parent = node.parentElement; + var tag = parent && parent.tagName && parent.tagName.toLowerCase(); if (tag === 'script' || tag === 'style') return NodeFilter.FILTER_REJECT; + if (parent && parent.closest && parent.closest('.readany-translation')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); @@ -1204,6 +957,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 +1120,154 @@ 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; }', + '.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; }', + '}' + ].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..dd918baf 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; } } @@ -733,8 +742,10 @@ var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; - var tag = node.parentElement && node.parentElement.tagName && node.parentElement.tagName.toLowerCase(); + var parent = node.parentElement; + var tag = parent && parent.tagName && parent.tagName.toLowerCase(); if (tag === 'script' || tag === 'style') return NodeFilter.FILTER_REJECT; + if (parent && parent.closest && parent.closest('.readany-translation')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); @@ -799,8 +810,10 @@ var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; - var tag = node.parentElement && node.parentElement.tagName && node.parentElement.tagName.toLowerCase(); + var parent = node.parentElement; + var tag = parent && parent.tagName && parent.tagName.toLowerCase(); if (tag === 'script' || tag === 'style') return NodeFilter.FILTER_REJECT; + if (parent && parent.closest && parent.closest('.readany-translation')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); @@ -944,6 +957,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 +1120,154 @@ 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; }', + '.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; }', + '}' + ].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..0701d85b --- /dev/null +++ b/packages/app-expo/src/components/reader/ChapterTranslationSheet.tsx @@ -0,0 +1,464 @@ +/** + * 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 setTranslationLang = useSettingsStore((ss) => ss.setTranslationLang); + 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]} + + + + + { + setTranslationLang(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} + + { + setTranslationLang(selectedLang); + 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..b93eb909 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,23 @@ 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, + ready: webViewReady && !loading, + 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 +410,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 +563,49 @@ 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 { 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() { + 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}'); + els[i].setAttribute('data-solo', '${solo}'); + } + 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 +1139,22 @@ export function ReaderScreen({ route, navigation }: Props) { setShowTOC(true)}> + setShowChapterTranslation(true)} + > + + { @@ -1689,6 +1784,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 && ( 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/app/src/components/reader/ChapterTranslationBar.tsx b/packages/app/src/components/reader/ChapterTranslationBar.tsx new file mode 100644 index 00000000..8c321e37 --- /dev/null +++ b/packages/app/src/components/reader/ChapterTranslationBar.tsx @@ -0,0 +1,227 @@ +/** + * ChapterTranslationMenu — dropdown menu attached to the toolbar Languages button. + * + * States: idle → language selector + translate button + * extracting / translating → progress + cancel + * complete → toggle original / translation visibility + clear + * error → message + retry + clear + */ + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ChapterTranslationState } from "@readany/core/hooks"; +import { useSettingsStore } from "@/stores/settings-store"; +import type { TranslationTargetLang } from "@readany/core/types/translation"; +import { TRANSLATOR_LANGS } from "@readany/core/types/translation"; +import { Check, Eye, EyeOff, Languages, Loader2, Trash2, X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface ChapterTranslationMenuProps { + state: ChapterTranslationState; + onStart: (targetLang?: string) => 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 setTranslationLang = useSettingsStore((s) => s.setTranslationLang); + const [selectedLang, setSelectedLang] = useState(defaultLang); + + const isActive = state.status !== "idle"; + + return ( + + + + + + + {/* ── idle: language picker + translate ── */} + {state.status === "idle" && ( + <> +
+ +
+ { + setTranslationLang(selectedLang); + 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} +
+ + { + setTranslationLang(selectedLang); + 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..b4ba7ef7 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 { @@ -325,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; }, }); @@ -389,6 +400,111 @@ 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; } + .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; } + } + `; + 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 */}
+