Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 113 additions & 39 deletions packages/app-expo/assets/reader/reader.html
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 [];
Expand All @@ -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"]',
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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--;
Expand Down Expand Up @@ -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
)
: [];
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading