diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 0627268c..104f2957 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -948,6 +948,26 @@ }); } + function postNoteTooltipHit(hit) { + const doc = hit?.range?.startContainer?.ownerDocument; + if (!hit || !hit.range || !doc) return false; + armNoteTapGuard(doc, 900); + const rect = hit.range.getBoundingClientRect(); + const iframe = doc.defaultView?.frameElement; + const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 }; + postToRN('note-tooltip', { + cfi: hit.cfi, + note: hit.note, + position: { + x: iframeRect.left + rect.left + rect.width / 2, + y: iframeRect.top + rect.top - 8, + selectionTop: iframeRect.top + rect.top, + selectionBottom: iframeRect.top + rect.bottom, + }, + }); + return true; + } + // Note tooltip registry: per-doc WeakMap of cfi -> { range, note } const docNoteRegistries = new WeakMap(); @@ -1178,24 +1198,10 @@ timer = null; const hit = isPointInNoteRange(doc, startX, startY); if (!hit) return; - armNoteTapGuard(doc, 900); // Prevent subsequent click / show-annotation e.preventDefault && e.preventDefault(); - - const rect = hit.range.getBoundingClientRect(); - const iframe = doc.defaultView && doc.defaultView.frameElement; - const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 }; - postToRN('note-tooltip', { - cfi: hit.cfi, - note: hit.note, - position: { - x: iframeRect.left + rect.left + rect.width / 2, - y: iframeRect.top + rect.top - 8, - selectionTop: iframeRect.top + rect.top, - selectionBottom: iframeRect.top + rect.bottom, - }, - }); + postNoteTooltipHit(hit); }, LONG_PRESS_MS); }, { passive: false }); @@ -2257,7 +2263,20 @@ if (!state || state.moved || e.changedTouches.length !== 1) return; if (Date.now() - state.startedAt > MAX_TAP_MS) return; const touch = e.changedTouches[0]; - const hitAnnotation = state.annotationRangeHit || isPointInAnnotationRange(doc, touch.clientX, touch.clientY); + const hitNote = + (state.annotationRangeHit && state.annotationRangeHit.note + ? state.annotationRangeHit + : null) || + isPointInNoteRange(doc, touch.clientX, touch.clientY); + if (hitNote) { + e.preventDefault(); + e.stopPropagation(); + postNoteTooltipHit(hitNote); + return; + } + + const hitAnnotation = + state.annotationRangeHit || isPointInAnnotationRange(doc, touch.clientX, touch.clientY); if (hitAnnotation) { e.preventDefault(); e.stopPropagation(); @@ -2649,10 +2668,10 @@ var isVisible = false; if (isPaginated && pSize > 0) { - // In paginated mode, first page starts at scroll offset = pSize - // So visible range in iframe coords is [start - size, end - size] - var visibleLeft = pStart - pSize; - var visibleRight = pStart; + var visibleLeft = pStart; + var visibleRight = typeof renderer.end === 'number' && renderer.end > pStart + ? renderer.end + : pStart + pSize; isVisible = rect.right > visibleLeft && rect.left < visibleRight && rect.width > 0; } else { // Scrolled mode: use iframe viewport dimensions @@ -2723,12 +2742,10 @@ var isVisible = false; if (isPaginated && pSize > 0) { - // In paginated mode, the iframe is expanded to the full content width. - // The outer container scrolls to show the current "page". - // First page starts at scroll offset = pSize (page 0 is padding). - // So visible range in iframe coords is [start - size, end - size]. - var visibleLeft = pStart - pSize; - var visibleRight = pStart; + var visibleLeft = pStart; + var visibleRight = typeof renderer.end === 'number' && renderer.end > pStart + ? renderer.end + : pStart + pSize; isVisible = rect.right > visibleLeft && rect.left < visibleRight && rect.width > 0; } else { // Scrolled mode: use iframe viewport dimensions @@ -2884,16 +2901,16 @@ const candidates = getPaginatedVisibleRangeCandidates(renderer); if (candidates.length <= 1) return candidates[0] || null; - const legacyRange = candidates.find((range) => range.source === 'legacy-offset') || null; - if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) return legacyRange; - const rendererRange = candidates.find((range) => range.source === 'renderer') || null; if (rendererRange && scorePaginatedVisibleRange(doc, rendererRange) > 0) return rendererRange; const fallbackRange = candidates.find((range) => range.source === 'size-fallback') || null; if (fallbackRange && scorePaginatedVisibleRange(doc, fallbackRange) > 0) return fallbackRange; - return legacyRange || rendererRange || fallbackRange || candidates[0] || null; + const legacyRange = candidates.find((range) => range.source === 'legacy-offset') || null; + if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) return legacyRange; + + return rendererRange || fallbackRange || legacyRange || candidates[0] || null; } let ttsVisibleRangeByDoc = new WeakMap(); @@ -3011,6 +3028,16 @@ return isHostRectVisible(mapIframeRectToHost(rect, doc)); } + function isRectStartVisibleInReader(rect, renderer, win, doc) { + if (!rect || rect.width <= 0 || rect.height <= 0) return false; + const isPaginated = !renderer.scrolled; + if (isPaginated && renderer.size > 0) { + const visibleRange = getVisibleRangeForTTSDoc(doc, renderer); + return visibleRange ? rect.left >= visibleRange.left && rect.left < visibleRange.right : false; + } + return isHostRectVisible(mapIframeRectToHost(rect, doc)); + } + function isRangeVisibleInReader(range, renderer, win) { if (!range || !renderer || !win) return false; try { @@ -3083,6 +3110,33 @@ return { node: last.node, offset: last.node.nodeValue.length }; } + function getVisibleTextStartOffset(textNode, renderer, win, doc, length) { + const textLength = typeof length === 'number' ? length : (textNode && textNode.nodeValue || '').length; + if (!textNode || textLength <= 0) return null; + + try { + const fullRange = doc.createRange(); + fullRange.selectNodeContents(textNode); + const firstRect = Array.from(fullRange.getClientRects ? fullRange.getClientRects() : []) + .find((rect) => rect.width > 0 && rect.height > 0); + if (firstRect && isRectStartVisibleInReader(firstRect, renderer, win, doc)) return 0; + } catch (e) {} + + for (let offset = 0; offset < textLength; offset++) { + if (!String(textNode.nodeValue || '')[offset]?.trim()) continue; + try { + const range = doc.createRange(); + range.setStart(textNode, offset); + range.setEnd(textNode, Math.min(offset + 1, textLength)); + const rects = Array.from(range.getClientRects ? range.getClientRects() : []); + if (rects.some((rect) => isRectStartVisibleInReader(rect, renderer, win, doc))) { + return offset; + } + } catch (e) {} + } + return null; + } + function fallbackSentenceSegments(text) { const normalized = normalizeTTSText(text); if (!normalized) return []; @@ -3099,6 +3153,9 @@ 'rp', 'sup', '.readany-translation', + '.foliate-note-tooltip', + '#foliate-note-shared-tooltip', + '[data-readany-tts-skip]', '[role="doc-noteref"]', '[role="doc-footnote"]', '[epub\\:type~="noteref"]', @@ -3531,15 +3588,21 @@ if (!textNodes.length) continue; var absoluteText = ''; + let visibleTextStart = null; const positionedNodes = textNodes.map((item) => { const start = absoluteText.length; absoluteText += item.text; + if (visibleTextStart === null) { + const offset = getVisibleTextStartOffset(item.node, renderer, win, doc, item.text.length); + if (offset !== null) visibleTextStart = start + offset; + } return { node: item.node, start, end: absoluteText.length, }; }); + if (visibleTextStart === null) continue; const rawSegments = segmenter ? Array.from(segmenter.segment(absoluteText)).map((item) => ({ @@ -3563,6 +3626,8 @@ for (var si = 0; si < normalizedSegments.length; si++) { let start = normalizedSegments[si].start; let end = normalizedSegments[si].end; + if (end <= visibleTextStart) continue; + start = Math.max(start, visibleTextStart); while (start < end && /\s/u.test(absoluteText[start])) start++; while (end > start && /\s/u.test(absoluteText[end - 1])) end--; @@ -3622,11 +3687,13 @@ // Use range-based alignment (preferred: uses boundary comparison, not CFI string equality) // Fall back to CFI string alignment for older tts.js builds without tts.from() const firstVisibleCfi = segments[0]?.cfi || null; - const resolvedAlignRange = resolveRangeForCfi(alignCfi); + const lastLocationCfi = view && view.lastLocation && view.lastLocation.cfi || null; + const liveAlignCfi = alignCfi || lastLocationCfi || null; + const resolvedAlignRange = resolveRangeForCfi(liveAlignCfi); const alignedSegments = segments.length ? collectTTSSegmentsFromEngine( - alignCfi ? 500 : segments.length, - alignCfi || firstVisibleCfi, + liveAlignCfi ? 500 : segments.length, + liveAlignCfi || firstVisibleCfi, resolvedAlignRange?.range || firstVisibleRange ) : []; @@ -3649,7 +3716,7 @@ } else if (filtered.length > 0) { returnSource = 'direct-partial-filtered-fallback'; returnedSegments = segments; - } else if (alignCfi) { + } else if (liveAlignCfi) { const alignedStart = alignedSegments[0] || null; const alignedStartIdentity = alignedStart ? getTTSSegmentIdentity(alignedStart.cfi, alignedStart.text) @@ -3674,9 +3741,12 @@ } window.__lastVisibleTTSDiagnostics = { - alignCfi: alignCfi || null, + inputAlignCfi: alignCfi || null, + liveAlignCfi: liveAlignCfi, + lastLocationCfi: lastLocationCfi, firstVisibleCfi: firstVisibleCfi, firstVisibleText: segments[0] && segments[0].text || null, + firstReturnedText: returnedSegments[0] && returnedSegments[0].text || null, directCount: segments.length, alignedCount: alignedSegments.length, filteredAlignedCount: filteredAlignedCount, @@ -3692,13 +3762,17 @@ return returnedSegments; } catch (e) { console.log('[visibleTTSSegments] extraction error:', e); - const fallbackSegments = alignCfi - ? collectTTSSegmentsFromEngine(500, alignCfi, resolveRangeForCfi(alignCfi)?.range || null) + const lastLocationCfi = view && view.lastLocation && view.lastLocation.cfi || null; + const liveAlignCfi = alignCfi || lastLocationCfi || null; + const fallbackSegments = liveAlignCfi + ? collectTTSSegmentsFromEngine(500, liveAlignCfi, resolveRangeForCfi(liveAlignCfi)?.range || null) : []; window.__lastVisibleTTSDiagnostics = { - alignCfi: alignCfi || null, + inputAlignCfi: alignCfi || null, + liveAlignCfi: liveAlignCfi, + lastLocationCfi: lastLocationCfi, extractionError: String(e), - returnSource: alignCfi ? 'engine-align-fallback' : 'no-visible-segments', + returnSource: liveAlignCfi ? 'engine-align-fallback' : 'no-visible-segments', directCount: 0, alignedCount: fallbackSegments.length, filteredAlignedCount: 0, diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index ba1ec433..a535cbfc 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -948,6 +948,26 @@ }); } + function postNoteTooltipHit(hit) { + const doc = hit?.range?.startContainer?.ownerDocument; + if (!hit || !hit.range || !doc) return false; + armNoteTapGuard(doc, 900); + const rect = hit.range.getBoundingClientRect(); + const iframe = doc.defaultView?.frameElement; + const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 }; + postToRN('note-tooltip', { + cfi: hit.cfi, + note: hit.note, + position: { + x: iframeRect.left + rect.left + rect.width / 2, + y: iframeRect.top + rect.top - 8, + selectionTop: iframeRect.top + rect.top, + selectionBottom: iframeRect.top + rect.bottom, + }, + }); + return true; + } + // Note tooltip registry: per-doc WeakMap of cfi -> { range, note } const docNoteRegistries = new WeakMap(); @@ -1178,24 +1198,10 @@ timer = null; const hit = isPointInNoteRange(doc, startX, startY); if (!hit) return; - armNoteTapGuard(doc, 900); // Prevent subsequent click / show-annotation e.preventDefault && e.preventDefault(); - - const rect = hit.range.getBoundingClientRect(); - const iframe = doc.defaultView && doc.defaultView.frameElement; - const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 }; - postToRN('note-tooltip', { - cfi: hit.cfi, - note: hit.note, - position: { - x: iframeRect.left + rect.left + rect.width / 2, - y: iframeRect.top + rect.top - 8, - selectionTop: iframeRect.top + rect.top, - selectionBottom: iframeRect.top + rect.bottom, - }, - }); + postNoteTooltipHit(hit); }, LONG_PRESS_MS); }, { passive: false }); @@ -2257,7 +2263,20 @@ if (!state || state.moved || e.changedTouches.length !== 1) return; if (Date.now() - state.startedAt > MAX_TAP_MS) return; const touch = e.changedTouches[0]; - const hitAnnotation = state.annotationRangeHit || isPointInAnnotationRange(doc, touch.clientX, touch.clientY); + const hitNote = + (state.annotationRangeHit && state.annotationRangeHit.note + ? state.annotationRangeHit + : null) || + isPointInNoteRange(doc, touch.clientX, touch.clientY); + if (hitNote) { + e.preventDefault(); + e.stopPropagation(); + postNoteTooltipHit(hitNote); + return; + } + + const hitAnnotation = + state.annotationRangeHit || isPointInAnnotationRange(doc, touch.clientX, touch.clientY); if (hitAnnotation) { e.preventDefault(); e.stopPropagation(); @@ -2649,10 +2668,10 @@ var isVisible = false; if (isPaginated && pSize > 0) { - // In paginated mode, first page starts at scroll offset = pSize - // So visible range in iframe coords is [start - size, end - size] - var visibleLeft = pStart - pSize; - var visibleRight = pStart; + var visibleLeft = pStart; + var visibleRight = typeof renderer.end === 'number' && renderer.end > pStart + ? renderer.end + : pStart + pSize; isVisible = rect.right > visibleLeft && rect.left < visibleRight && rect.width > 0; } else { // Scrolled mode: use iframe viewport dimensions @@ -2723,12 +2742,10 @@ var isVisible = false; if (isPaginated && pSize > 0) { - // In paginated mode, the iframe is expanded to the full content width. - // The outer container scrolls to show the current "page". - // First page starts at scroll offset = pSize (page 0 is padding). - // So visible range in iframe coords is [start - size, end - size]. - var visibleLeft = pStart - pSize; - var visibleRight = pStart; + var visibleLeft = pStart; + var visibleRight = typeof renderer.end === 'number' && renderer.end > pStart + ? renderer.end + : pStart + pSize; isVisible = rect.right > visibleLeft && rect.left < visibleRight && rect.width > 0; } else { // Scrolled mode: use iframe viewport dimensions @@ -2884,16 +2901,16 @@ const candidates = getPaginatedVisibleRangeCandidates(renderer); if (candidates.length <= 1) return candidates[0] || null; - const legacyRange = candidates.find((range) => range.source === 'legacy-offset') || null; - if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) return legacyRange; - const rendererRange = candidates.find((range) => range.source === 'renderer') || null; if (rendererRange && scorePaginatedVisibleRange(doc, rendererRange) > 0) return rendererRange; const fallbackRange = candidates.find((range) => range.source === 'size-fallback') || null; if (fallbackRange && scorePaginatedVisibleRange(doc, fallbackRange) > 0) return fallbackRange; - return legacyRange || rendererRange || fallbackRange || candidates[0] || null; + const legacyRange = candidates.find((range) => range.source === 'legacy-offset') || null; + if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) return legacyRange; + + return rendererRange || fallbackRange || legacyRange || candidates[0] || null; } let ttsVisibleRangeByDoc = new WeakMap(); @@ -3011,6 +3028,16 @@ return isHostRectVisible(mapIframeRectToHost(rect, doc)); } + function isRectStartVisibleInReader(rect, renderer, win, doc) { + if (!rect || rect.width <= 0 || rect.height <= 0) return false; + const isPaginated = !renderer.scrolled; + if (isPaginated && renderer.size > 0) { + const visibleRange = getVisibleRangeForTTSDoc(doc, renderer); + return visibleRange ? rect.left >= visibleRange.left && rect.left < visibleRange.right : false; + } + return isHostRectVisible(mapIframeRectToHost(rect, doc)); + } + function isRangeVisibleInReader(range, renderer, win) { if (!range || !renderer || !win) return false; try { @@ -3083,6 +3110,33 @@ return { node: last.node, offset: last.node.nodeValue.length }; } + function getVisibleTextStartOffset(textNode, renderer, win, doc, length) { + const textLength = typeof length === 'number' ? length : (textNode && textNode.nodeValue || '').length; + if (!textNode || textLength <= 0) return null; + + try { + const fullRange = doc.createRange(); + fullRange.selectNodeContents(textNode); + const firstRect = Array.from(fullRange.getClientRects ? fullRange.getClientRects() : []) + .find((rect) => rect.width > 0 && rect.height > 0); + if (firstRect && isRectStartVisibleInReader(firstRect, renderer, win, doc)) return 0; + } catch (e) {} + + for (let offset = 0; offset < textLength; offset++) { + if (!String(textNode.nodeValue || '')[offset]?.trim()) continue; + try { + const range = doc.createRange(); + range.setStart(textNode, offset); + range.setEnd(textNode, Math.min(offset + 1, textLength)); + const rects = Array.from(range.getClientRects ? range.getClientRects() : []); + if (rects.some((rect) => isRectStartVisibleInReader(rect, renderer, win, doc))) { + return offset; + } + } catch (e) {} + } + return null; + } + function fallbackSentenceSegments(text) { const normalized = normalizeTTSText(text); if (!normalized) return []; @@ -3099,6 +3153,9 @@ 'rp', 'sup', '.readany-translation', + '.foliate-note-tooltip', + '#foliate-note-shared-tooltip', + '[data-readany-tts-skip]', '[role="doc-noteref"]', '[role="doc-footnote"]', '[epub\\:type~="noteref"]', @@ -3531,15 +3588,21 @@ if (!textNodes.length) continue; var absoluteText = ''; + let visibleTextStart = null; const positionedNodes = textNodes.map((item) => { const start = absoluteText.length; absoluteText += item.text; + if (visibleTextStart === null) { + const offset = getVisibleTextStartOffset(item.node, renderer, win, doc, item.text.length); + if (offset !== null) visibleTextStart = start + offset; + } return { node: item.node, start, end: absoluteText.length, }; }); + if (visibleTextStart === null) continue; const rawSegments = segmenter ? Array.from(segmenter.segment(absoluteText)).map((item) => ({ @@ -3563,6 +3626,8 @@ for (var si = 0; si < normalizedSegments.length; si++) { let start = normalizedSegments[si].start; let end = normalizedSegments[si].end; + if (end <= visibleTextStart) continue; + start = Math.max(start, visibleTextStart); while (start < end && /\s/u.test(absoluteText[start])) start++; while (end > start && /\s/u.test(absoluteText[end - 1])) end--; @@ -3622,11 +3687,13 @@ // Use range-based alignment (preferred: uses boundary comparison, not CFI string equality) // Fall back to CFI string alignment for older tts.js builds without tts.from() const firstVisibleCfi = segments[0]?.cfi || null; - const resolvedAlignRange = resolveRangeForCfi(alignCfi); + const lastLocationCfi = view && view.lastLocation && view.lastLocation.cfi || null; + const liveAlignCfi = alignCfi || lastLocationCfi || null; + const resolvedAlignRange = resolveRangeForCfi(liveAlignCfi); const alignedSegments = segments.length ? collectTTSSegmentsFromEngine( - alignCfi ? 500 : segments.length, - alignCfi || firstVisibleCfi, + liveAlignCfi ? 500 : segments.length, + liveAlignCfi || firstVisibleCfi, resolvedAlignRange?.range || firstVisibleRange ) : []; @@ -3649,7 +3716,7 @@ } else if (filtered.length > 0) { returnSource = 'direct-partial-filtered-fallback'; returnedSegments = segments; - } else if (alignCfi) { + } else if (liveAlignCfi) { const alignedStart = alignedSegments[0] || null; const alignedStartIdentity = alignedStart ? getTTSSegmentIdentity(alignedStart.cfi, alignedStart.text) @@ -3674,9 +3741,12 @@ } window.__lastVisibleTTSDiagnostics = { - alignCfi: alignCfi || null, + inputAlignCfi: alignCfi || null, + liveAlignCfi: liveAlignCfi, + lastLocationCfi: lastLocationCfi, firstVisibleCfi: firstVisibleCfi, firstVisibleText: segments[0] && segments[0].text || null, + firstReturnedText: returnedSegments[0] && returnedSegments[0].text || null, directCount: segments.length, alignedCount: alignedSegments.length, filteredAlignedCount: filteredAlignedCount, @@ -3692,13 +3762,17 @@ return returnedSegments; } catch (e) { console.log('[visibleTTSSegments] extraction error:', e); - const fallbackSegments = alignCfi - ? collectTTSSegmentsFromEngine(500, alignCfi, resolveRangeForCfi(alignCfi)?.range || null) + const lastLocationCfi = view && view.lastLocation && view.lastLocation.cfi || null; + const liveAlignCfi = alignCfi || lastLocationCfi || null; + const fallbackSegments = liveAlignCfi + ? collectTTSSegmentsFromEngine(500, liveAlignCfi, resolveRangeForCfi(liveAlignCfi)?.range || null) : []; window.__lastVisibleTTSDiagnostics = { - alignCfi: alignCfi || null, + inputAlignCfi: alignCfi || null, + liveAlignCfi: liveAlignCfi, + lastLocationCfi: lastLocationCfi, extractionError: String(e), - returnSource: alignCfi ? 'engine-align-fallback' : 'no-visible-segments', + returnSource: liveAlignCfi ? 'engine-align-fallback' : 'no-visible-segments', directCount: 0, alignedCount: fallbackSegments.length, filteredAlignedCount: 0, diff --git a/packages/app-expo/src/screens/reader/useReaderTTS.ts b/packages/app-expo/src/screens/reader/useReaderTTS.ts index 372a7cc4..b682131f 100644 --- a/packages/app-expo/src/screens/reader/useReaderTTS.ts +++ b/packages/app-expo/src/screens/reader/useReaderTTS.ts @@ -222,7 +222,7 @@ export function useReaderTTS({ const pendingTTSContinueCallbackRef = useRef<(() => void) | null>(null); const pendingTTSContinueSafetyTimerRef = useRef | null>(null); const startPageTTSFromCfiRef = useRef< - ((targetCfi: string, targetText?: string) => Promise) | null + ((targetCfi: string, targetText?: string, options?: { navigate?: boolean }) => Promise) | null >(null); const ttsHandlingPageEndRef = useRef(false); const ttsLastStopHandledSignatureRef = useRef(null); @@ -409,9 +409,21 @@ export function useReaderTTS({ const resolvedTTSSegmentCfi = useMemo(() => { if (ttsCurrentBookId !== bookId) return null; + const indexCfi = + localTTSChunkIndex >= 0 && localTTSChunkIndex < ttsSegments.length + ? ttsSegments[localTTSChunkIndex]?.cfi || null + : null; + if (indexCfi) return indexCfi; if (currentTTSSegment?.cfi) return currentTTSSegment.cfi; return ttsCurrentLocationCfi || null; - }, [bookId, currentTTSSegment?.cfi, ttsCurrentBookId, ttsCurrentLocationCfi]); + }, [ + bookId, + currentTTSSegment?.cfi, + localTTSChunkIndex, + ttsCurrentBookId, + ttsCurrentLocationCfi, + ttsSegments, + ]); const ttsSourceLabel = ttsSourceKind === "selection" ? "来自选中文本" : "从当前页开始"; @@ -1225,7 +1237,7 @@ export function useReaderTTS({ // ─── startPageTTSFromCfi ────────────────────────────────────────────────── const startPageTTSFromCfi = useCallback( - async (targetCfi: string, targetText?: string) => { + async (targetCfi: string, targetText?: string, options?: { navigate?: boolean }) => { if (!targetCfi || !bridgeRef.current) return; const normalizedTargetText = (targetText || "").trim(); pendingTTSContinueCallbackRef.current = null; @@ -1233,8 +1245,11 @@ export function useReaderTTS({ clearTimeout(pendingTTSContinueSafetyTimerRef.current); pendingTTSContinueSafetyTimerRef.current = null; } - bridgeRef.current.goToCFI(targetCfi); - await new Promise((resolve) => setTimeout(resolve, 320)); + const shouldNavigate = options?.navigate !== false; + if (shouldNavigate) { + bridgeRef.current.goToCFI(targetCfi); + await new Promise((resolve) => setTimeout(resolve, 320)); + } const normalizedSegments = await getNormalizedVisibleTTSSegments(targetCfi); const context = await getCachedTTSSegmentContext( targetCfi, @@ -1253,7 +1268,9 @@ export function useReaderTTS({ const seedSegment = normalizedTargetText.length > 0 ? [{ text: normalizedTargetText, cfi: targetCfi }] : []; const visibleSegments = - visibleIndexByCfi >= 0 + !shouldNavigate + ? normalizedSegments + : visibleIndexByCfi >= 0 ? normalizedSegments.slice(visibleIndexByCfi) : dedupeTTSSegments([ ...seedSegment, @@ -1344,11 +1361,7 @@ export function useReaderTTS({ // ─── startPageTTS ───────────────────────────────────────────────────────── const startPageTTS = useCallback( async (continuous = ttsContinuousEnabled) => { - // Start from the currently visible page, not from currentCfi. During fast - // page/chapter transitions currentCfi can still point at the previous - // relocation or a mid-page sentence, which makes TTS skip the real first - // visible sentence. - const normalizedSegments = await getNormalizedVisibleTTSSegments(null); + const normalizedSegments = await getNormalizedVisibleTTSSegments(currentCfi || null); const pageAnchorCfi = normalizedSegments[0]?.cfi || currentCfi || null; if (__DEV__) { console.log("[ReaderScreen][TTS][queue-start] visible-page", { @@ -1476,6 +1489,7 @@ export function useReaderTTS({ primeTTSLyricContext, setShowControls, setShowTTS, + startPageTTSFromCfi, syncTTSChunkOffset, ttsContinuousEnabled, ttsCoverUri, @@ -2199,7 +2213,7 @@ export function useReaderTTS({ lastFollowedTTSCfiRef.current = null; return; } - if (showTTS || ttsSourceKind !== "page") { + if (ttsSourceKind !== "page") { lastFollowedTTSCfiRef.current = null; return; } @@ -2214,7 +2228,6 @@ export function useReaderTTS({ }, [ bridgeRef, resolvedTTSSegmentCfi, - showTTS, ttsCurrentBookId, ttsPlayState, ttsSourceKind, diff --git a/packages/app/src/components/reader/FoliateViewer.tsx b/packages/app/src/components/reader/FoliateViewer.tsx index 1179c838..d0b7b190 100644 --- a/packages/app/src/components/reader/FoliateViewer.tsx +++ b/packages/app/src/components/reader/FoliateViewer.tsx @@ -226,6 +226,38 @@ function acceptTTSNode(node: Node) { return shouldSkipTTSNode(parent) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } +function getVisibleTextStartOffset( + textNode: Text, + isRectVisible: (rect: DOMRect) => boolean, + length = textNode.nodeValue?.length ?? 0, +) { + if (length <= 0) return null; + + try { + const fullRange = textNode.ownerDocument.createRange(); + fullRange.selectNodeContents(textNode); + const firstRect = Array.from(fullRange.getClientRects()).find( + (rect) => rect.width > 0 && rect.height > 0, + ); + if (firstRect && isRectVisible(firstRect)) return 0; + } catch { + // Fall through to per-character probing. + } + + for (let offset = 0; offset < length; offset += 1) { + if (!textNode.nodeValue?.[offset]?.trim()) continue; + try { + const range = textNode.ownerDocument.createRange(); + range.setStart(textNode, offset); + range.setEnd(textNode, Math.min(offset + 1, length)); + if (Array.from(range.getClientRects()).some(isRectVisible)) return offset; + } catch { + // Ignore characters that cannot be measured. + } + } + return null; +} + function getTTSSegmentIdentity(cfi?: string | null, text?: string | null) { return `${cfi || ""}::${normalizeTTSSegmentText(text)}`; } @@ -249,6 +281,10 @@ function getRendererContents(view: FoliateView | null): RendererContent[] { return (view?.renderer?.getContents?.() ?? []) as RendererContent[]; } +function getViewLastLocationCfi(view: FoliateView | null) { + return (view?.lastLocation as { cfi?: string } | null | undefined)?.cfi || null; +} + function getPaginatedVisibleRangeCandidates(renderer: { start?: unknown; end?: unknown; @@ -280,6 +316,10 @@ function rectIntersectsPaginatedRange(rect: DOMRect, range: PaginatedVisibleRang return rect.right > range.left && rect.left < range.right; } +function rectContainsPaginatedStart(rect: DOMRect, range: PaginatedVisibleRange) { + return rect.left >= range.left && rect.left < range.right; +} + function scorePaginatedVisibleRange(doc: Document, range: PaginatedVisibleRange) { if (!doc.body) return 0; const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { @@ -318,11 +358,6 @@ function pickPaginatedVisibleRange( const candidates = getPaginatedVisibleRangeCandidates(renderer); if (candidates.length <= 1) return candidates[0] ?? null; - const legacyRange = candidates.find((range) => range.source === "legacy-offset") ?? null; - if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) { - return legacyRange; - } - const rendererRange = candidates.find((range) => range.source === "renderer") ?? null; if (rendererRange && scorePaginatedVisibleRange(doc, rendererRange) > 0) { return rendererRange; @@ -333,7 +368,12 @@ function pickPaginatedVisibleRange( return fallbackRange; } - return legacyRange ?? rendererRange ?? fallbackRange ?? candidates[0]; + const legacyRange = candidates.find((range) => range.source === "legacy-offset") ?? null; + if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) { + return legacyRange; + } + + return rendererRange ?? fallbackRange ?? legacyRange ?? candidates[0]; } function getIframeClickMetrics(doc: Document, container: HTMLElement | null, clientX: number) { @@ -620,6 +660,7 @@ export interface FoliateViewerHandle { }) => AsyncGenerator | null; clearSearch: () => void; getView: () => FoliateView | null; + getCurrentCfi: () => string | null; /** Get visible text on the current page for TTS */ getVisibleText: () => string; getVisibleTTSSegments: (alignCfi?: string | null) => Promise; @@ -1007,6 +1048,16 @@ export const FoliateViewer = forwardRef return isHostRectVisible(mapIframeRectToHost(rect, doc)); }; + const isRectStartVisibleInReader = (rect: DOMRect, doc?: Document | null) => { + if (!rect || rect.width <= 0 || rect.height <= 0) return false; + const isPaginated = !renderer.scrolled; + if (isPaginated) { + const visibleRange = doc ? getVisibleRangeForDoc(doc) : null; + return visibleRange ? rectContainsPaginatedStart(rect, visibleRange) : false; + } + return isHostRectVisible(mapIframeRectToHost(rect, doc)); + }; + // Require the START of the sentence range to be visible on the current page, // preventing sentences that began on the previous page from appearing as the // first TTS segment. @@ -1095,6 +1146,7 @@ export const FoliateViewer = forwardRef const positionedNodes: Array<{ node: Text; start: number; end: number }> = []; let absoluteText = ""; + let visibleTextStart: number | null = null; for ( let textNode = walker.nextNode() as Text | null; textNode; @@ -1104,8 +1156,18 @@ export const FoliateViewer = forwardRef const start = absoluteText.length; absoluteText += text; positionedNodes.push({ node: textNode, start, end: absoluteText.length }); + if (visibleTextStart === null) { + const offset = getVisibleTextStartOffset( + textNode, + (rect) => isRectStartVisibleInReader(rect, doc), + text.length, + ); + if (offset !== null) visibleTextStart = start + offset; + } + } + if (!absoluteText.trim() || positionedNodes.length === 0 || visibleTextStart === null) { + continue; } - if (!absoluteText.trim() || positionedNodes.length === 0) continue; const rawSegments = segmenter ? Array.from(segmenter.segment(absoluteText)).map( @@ -1144,6 +1206,8 @@ export const FoliateViewer = forwardRef : [{ start: 0, end: absoluteText.length }]) { let start = rawSegment.start; let end = rawSegment.end; + if (end <= visibleTextStart) continue; + start = Math.max(start, visibleTextStart); while (start < end && /\s/u.test(absoluteText[start] ?? "")) start++; while (end > start && /\s/u.test(absoluteText[end - 1] ?? "")) end--; if (end - start < 2) continue; @@ -1183,9 +1247,12 @@ export const FoliateViewer = forwardRef ) => Array<{ text?: string; cfi?: string }>; }; + const lastLocationCfi = getViewLastLocationCfi(view); + const liveAlignCfi = alignCfi || lastLocationCfi || null; + if (segments.length > 0 && tts) { try { - const alignTargetCfi = alignCfi || segments[0]?.cfi; + const alignTargetCfi = liveAlignCfi || segments[0]?.cfi; if (!alignTargetCfi) return segments; if (typeof tts.alignCfi === "function") { tts.alignCfi(alignTargetCfi); @@ -1202,7 +1269,7 @@ export const FoliateViewer = forwardRef ? tts.collectDetails( Math.max( 0, - Math.max(segments.length, alignCfi ? 12 : segments.length) - + Math.max(segments.length, liveAlignCfi ? 12 : segments.length) - (currentDetail ? 1 : 0), ), { @@ -1245,7 +1312,7 @@ export const FoliateViewer = forwardRef } else if (filtered.length > 0) { returnedSegments = segments; returnSource = "direct-partial-filtered-fallback"; - } else if (alignCfi) { + } else if (liveAlignCfi) { const alignedStart = alignedSegments[0] || null; const alignedStartIdentity = alignedStart ? getTTSSegmentIdentity(alignedStart.cfi, alignedStart.text) @@ -1269,7 +1336,9 @@ export const FoliateViewer = forwardRef } } console.log("[FoliateViewer][TTS] visibleTTSSegments", { - alignCfi: alignCfi || null, + inputAlignCfi: alignCfi || null, + liveAlignCfi, + lastLocationCfi, contentsCount: contents.length, scannedContentsCount: scanContents.length, directCount: segments.length, @@ -1277,6 +1346,7 @@ export const FoliateViewer = forwardRef returnedCount: returnedSegments.length, returnSource, firstVisibleText: segments[0]?.text || null, + firstReturnedText: returnedSegments[0]?.text || null, }); return returnedSegments; } @@ -1286,7 +1356,9 @@ export const FoliateViewer = forwardRef } console.log("[FoliateViewer][TTS] visibleTTSSegments", { - alignCfi: alignCfi || null, + inputAlignCfi: alignCfi || null, + liveAlignCfi, + lastLocationCfi, contentsCount: contents.length, scannedContentsCount: scanContents.length, directCount: segments.length, @@ -1294,6 +1366,7 @@ export const FoliateViewer = forwardRef returnedCount: segments.length, returnSource: "direct", firstVisibleText: segments[0]?.text || null, + firstReturnedText: segments[0]?.text || null, }); return segments; }, @@ -1470,6 +1543,9 @@ export const FoliateViewer = forwardRef viewRef.current?.clearSearch(); }, getView: () => viewRef.current, + getCurrentCfi: () => { + return getViewLastLocationCfi(viewRef.current); + }, getVisibleText: () => { try { const renderer = viewRef.current?.renderer; @@ -1486,11 +1562,11 @@ export const FoliateViewer = forwardRef const pStart = renderer.start; // abs(scrollLeft) if (isPaginated && pSize > 0) { - // In paginated mode, first page starts at scroll offset = pSize - // (page 0 is padding). So visible range in iframe coords is - // [start - size, end - size]. - const visibleLeft = pStart - pSize; - const visibleRight = pStart; // end - size = (start + size) - size = start + const visibleLeft = pStart; + const visibleRight = + typeof renderer.end === "number" && renderer.end > pStart + ? renderer.end + : pStart + pSize; const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { acceptNode: acceptTTSNode, @@ -1922,8 +1998,11 @@ export const FoliateViewer = forwardRef const pStart = renderer.start; if (isPaginated && pSize > 0) { - const visibleLeft = pStart - pSize; - const visibleRight = pStart; + const visibleLeft = pStart; + const visibleRight = + typeof renderer.end === "number" && renderer.end > pStart + ? renderer.end + : pStart + pSize; const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { acceptNode: acceptTTSNode, }); @@ -2132,8 +2211,14 @@ export const FoliateViewer = forwardRef // show-annotation fires synchronously before the setTimeout(10ms) in pointerup annotationClickedRef.current = true; - // Always show the same annotation popover for existing highlights, including - // highlights with notes, so actions like copy remain available. + if (cfisWithNotes.has(value)) { + const doc = + range.startContainer.nodeType === Node.DOCUMENT_NODE + ? (range.startContainer as Document) + : range.startContainer.ownerDocument; + if (doc && showNoteTooltip(doc, value)) return; + } + onShowAnnotationRef.current?.(value, range, index); }, []); @@ -3382,6 +3467,7 @@ const NOTE_TOOLTIP_STYLES = ` // Per-doc registry: cfi -> { range, note } const docNoteRegistries = new WeakMap>(); +const docNoteTooltipShowers = new WeakMap boolean>(); // Global set to track CFIs that have notes (for showAnnotationHandler) const cfisWithNotes = new Set(); @@ -3417,6 +3503,7 @@ function ensureNoteTooltipSystem(doc: Document) { const tooltip = doc.createElement("div"); tooltip.className = "foliate-note-tooltip"; tooltip.id = "foliate-note-shared-tooltip"; + tooltip.setAttribute("data-readany-tts-skip", "true"); const content = doc.createElement("div"); content.className = "note-content"; tooltip.appendChild(content); @@ -3468,6 +3555,14 @@ function ensureNoteTooltipSystem(doc: Document) { tooltip.style.top = `${top}px`; }; + docNoteTooltipShowers.set(doc, (cfi: string) => { + const entry = docNoteRegistries.get(doc)?.get(cfi); + if (!entry) return false; + activeCfi = cfi; + showTooltip(entry.note, entry.range); + return true; + }); + const hideTooltip = () => { hideTimer = setTimeout(() => { tooltip.classList.remove("visible"); @@ -3532,3 +3627,7 @@ function removeNoteTooltip(doc: Document, cfi: string) { const registry = docNoteRegistries.get(doc); if (registry) registry.delete(cfi); } + +function showNoteTooltip(doc: Document, cfi: string) { + return docNoteTooltipShowers.get(doc)?.(cfi) ?? false; +} diff --git a/packages/app/src/components/reader/ReaderView.tsx b/packages/app/src/components/reader/ReaderView.tsx index 0605de7d..07dbc108 100644 --- a/packages/app/src/components/reader/ReaderView.tsx +++ b/packages/app/src/components/reader/ReaderView.tsx @@ -801,7 +801,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const pendingTTSContinueCallbackRef = useRef<(() => void) | null>(null); const pendingTTSContinueSafetyTimerRef = useRef | null>(null); const startPageTTSFromCfiRef = useRef< - ((targetCfi: string, targetText?: string) => Promise) | null + ((targetCfi: string, targetText?: string, options?: { navigate?: boolean }) => Promise) | null >(null); const previousReaderBookIdRef = useRef(null); @@ -2035,8 +2035,9 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const startPageTTS = useCallback( async (continuous = ttsContinuousEnabled) => { + const currentReaderCfi = foliateRef.current?.getCurrentCfi() || readerTab?.currentCfi || null; const segments = - (await foliateRef.current?.getVisibleTTSSegments())?.map((segment) => ({ + (await foliateRef.current?.getVisibleTTSSegments(currentReaderCfi))?.map((segment) => ({ text: segment.text.trim(), cfi: segment.cfi, })) ?? []; @@ -2092,15 +2093,18 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { ); const startPageTTSFromCfi = useCallback( - async (targetCfi: string, targetText?: string) => { + async (targetCfi: string, targetText?: string, options?: { navigate?: boolean }) => { if (!targetCfi) return; pendingTTSContinueCallbackRef.current = null; if (pendingTTSContinueSafetyTimerRef.current) { clearTimeout(pendingTTSContinueSafetyTimerRef.current); pendingTTSContinueSafetyTimerRef.current = null; } - goToCFISafely(targetCfi); - await new Promise((resolve) => setTimeout(resolve, 280)); + const shouldNavigate = options?.navigate !== false; + if (shouldNavigate) { + goToCFISafely(targetCfi); + await new Promise((resolve) => setTimeout(resolve, 280)); + } const segments = (await foliateRef.current?.getVisibleTTSSegments(targetCfi))?.map((segment) => ({ text: segment.text.trim(), @@ -2168,9 +2172,10 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // queue isn't just a single sentence. const isSelectionSession = ttsSourceKind === "selection"; - // If playing, always just re-show (don't interrupt playback) - // If stopped/paused with active session and same chapter, also just re-show - if (hasActiveSession && (isPlaying || !chapterChanged) && !isSelectionSession) { + // If playing, just re-show without interrupting playback. Otherwise refresh + // from the current reader position so a stale queue from a previous page is + // not reused. + if (hasActiveSession && isPlaying && !chapterChanged && !isSelectionSession) { setShowTTS(true); return; } diff --git a/packages/core/src/tts/text-utils.test.ts b/packages/core/src/tts/text-utils.test.ts index 8ef1a490..ae7f3941 100644 --- a/packages/core/src/tts/text-utils.test.ts +++ b/packages/core/src/tts/text-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { cleanText, isTTSFootnoteMarker } from "./text-utils"; +import { cleanText, isTTSFootnoteMarker, shouldSkipTTSNode } from "./text-utils"; describe("TTS text utils", () => { it("removes numeric footnote markers from narration text", () => { @@ -20,4 +20,15 @@ describe("TTS text utils", () => { expect(isTTSFootnoteMarker("(四)")).toBe(true); expect(isTTSFootnoteMarker("正文[十二]")).toBe(false); }); + + it("skips ReadAny note tooltip text", () => { + const tooltipChild = { + closest: (selector: string) => + selector.includes(".foliate-note-tooltip") && selector.includes("[data-readany-tts-skip]") + ? {} + : null, + } as unknown as Element; + + expect(shouldSkipTTSNode(tooltipChild)).toBe(true); + }); }); diff --git a/packages/core/src/tts/text-utils.ts b/packages/core/src/tts/text-utils.ts index bee5804d..91ba5221 100644 --- a/packages/core/src/tts/text-utils.ts +++ b/packages/core/src/tts/text-utils.ts @@ -15,6 +15,9 @@ const TTS_SKIPPED_ELEMENT_SELECTOR = [ "rp", "sup", ".readany-translation", + ".foliate-note-tooltip", + "#foliate-note-shared-tooltip", + "[data-readany-tts-skip]", '[role="doc-noteref"]', '[role="doc-footnote"]', '[epub\\:type~="noteref"]', @@ -25,8 +28,8 @@ const TTS_SKIPPED_ELEMENT_SELECTOR = [ 'a[href^="#footnote"]', 'a[href*="footnote"]', 'a[href*="note"]', - 'a.noteref', - 'a.footnote', + "a.noteref", + "a.footnote", ".noteref", ".footnote", ".footnote-ref", @@ -48,10 +51,7 @@ export function shouldSkipTTSNode(element: Element | null | undefined): boolean /** Clean text for TTS: remove footnote references and extra whitespace. */ export function cleanText(text: string): string { - return text - .replace(FOOTNOTE_MARKER_PATTERN, "") - .replace(/\s+/g, " ") - .trim(); + return text.replace(FOOTNOTE_MARKER_PATTERN, "").replace(/\s+/g, " ").trim(); } /** Count characters (CJK = 2 units, others = 1) */ diff --git a/packages/core/src/utils/api.test.ts b/packages/core/src/utils/api.test.ts index 45bf5223..c3a60043 100644 --- a/packages/core/src/utils/api.test.ts +++ b/packages/core/src/utils/api.test.ts @@ -191,17 +191,19 @@ describe("AI API URL helpers", () => { }); it("reports the normalized request URL when endpoint tests fail", async () => { + const expectedError: Partial = { + name: "EmbeddingEndpointTestError", + url: "https://api.siliconflow.cn/v1/embeddings", + status: 404, + }; + await expect( testEmbeddingEndpoint({ url: "https://api.siliconflow.cn/v1", modelId: "Qwen/Qwen3-Embedding-4B", fetcher: async () => new Response("not found", { status: 404, statusText: "Not Found" }), }), - ).rejects.toMatchObject>({ - name: "EmbeddingEndpointTestError", - url: "https://api.siliconflow.cn/v1/embeddings", - status: 404, - }); + ).rejects.toMatchObject(expectedError); }); }); });