Reader: Lesefortschritt nach Textanteil statt Pixeln (SW v46)
Der Fortschritt (Speichern/Sync, Prozentanzeige im Reader-Kopf, Sprung über das Prozentfeld, "≈ x % im Buch" bei Markierungen) wird jetzt aus dem Positions-Anker als Anteil des Buchtexts in Zeichen berechnet statt aus scrollTop/scrollHeight. Das ist unabhängig vom Layout (Schriftgröße, Breite, welche Kapitel gerendert sind) und damit auf allen Clients gleich — die Android-App rendert im E-Reader-Modus nur ein Kapitel und kennt die Pixelhöhe des Buchs nicht. Pixel zählten außerdem Ränder, Überschriften und Bilder mit. Anker und Server-Logik bleiben unverändert; PDFs weiter nach Seiten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9d097f5fba
commit
09055b9874
2 changed files with 78 additions and 7 deletions
|
|
@ -2966,6 +2966,59 @@ function getPositionAnchor(contentEl) {
|
|||
return `${bestIndex}:${innerFraction.toFixed(6)}`;
|
||||
}
|
||||
|
||||
// Reading progress as a share of the book's text (characters), from a
|
||||
// position anchor. Independent of layout — font size, screen width, which
|
||||
// chapters are rendered (the Android app's e-reader mode lays out one chapter
|
||||
// at a time) — so every client shows the same percentage for the same place.
|
||||
// The pixel share (scrollTop / scrollHeight) used before differed between
|
||||
// layouts and counted margins, headings and images as reading.
|
||||
let _charIndex = null; // {content, starts[], lens[], total} for the open book
|
||||
|
||||
function _bookCharIndex(contentEl) {
|
||||
if (_charIndex && _charIndex.content === contentEl) return _charIndex;
|
||||
const offsets = new Map();
|
||||
let pos = 0;
|
||||
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
offsets.set(node, pos);
|
||||
pos += node.nodeValue.length;
|
||||
}
|
||||
const blocks = contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR);
|
||||
const starts = new Array(blocks.length);
|
||||
const lens = new Array(blocks.length);
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const first = document.createTreeWalker(blocks[i], NodeFilter.SHOW_TEXT).nextNode();
|
||||
starts[i] = first ? offsets.get(first) : (i ? starts[i - 1] + lens[i - 1] : 0);
|
||||
lens[i] = blocks[i].textContent.length;
|
||||
}
|
||||
_charIndex = {content: contentEl, starts, lens, total: pos || 1};
|
||||
return _charIndex;
|
||||
}
|
||||
|
||||
// Share of the text before the anchored place (0..1), or null.
|
||||
function _fractionFromAnchor(contentEl, anchor) {
|
||||
const [block, inner] = _anchorParts(anchor);
|
||||
if (block < 0) return null;
|
||||
const idx = _bookCharIndex(contentEl);
|
||||
if (block >= idx.starts.length) return null;
|
||||
return Math.max(0, Math.min(1, (idx.starts[block] + inner * idx.lens[block]) / idx.total));
|
||||
}
|
||||
|
||||
// Anchor of the place where `fraction` of the text has been read.
|
||||
function _anchorForFraction(contentEl, fraction) {
|
||||
const idx = _bookCharIndex(contentEl);
|
||||
if (!idx.starts.length) return '';
|
||||
const target = Math.max(0, Math.min(1, fraction)) * idx.total;
|
||||
let lo = 0, hi = idx.starts.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (idx.starts[mid] <= target) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
const inner = idx.lens[lo] ? Math.max(0, Math.min(1, (target - idx.starts[lo]) / idx.lens[lo])) : 0;
|
||||
return `${lo}:${inner.toFixed(6)}`;
|
||||
}
|
||||
|
||||
function restoreFromAnchor(contentEl, anchor) {
|
||||
if (!anchor) return false;
|
||||
const parts = anchor.split(':');
|
||||
|
|
@ -4464,6 +4517,7 @@ async function openBook(bookId) {
|
|||
|
||||
try {
|
||||
loadReaderSettings(bookId);
|
||||
_charIndex = null;
|
||||
const key = await getOrCreateEncKey();
|
||||
let data_ct, data_iv;
|
||||
const cached = await _getCachedBook(bookId);
|
||||
|
|
@ -4591,7 +4645,7 @@ async function openBook(bookId) {
|
|||
} else {
|
||||
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
|
||||
this.value = _formatBookPercent(pct / 100);
|
||||
contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight;
|
||||
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -4660,10 +4714,8 @@ async function openBook(bookId) {
|
|||
}
|
||||
}
|
||||
progressInput.value = currentPage;
|
||||
} else {
|
||||
const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
||||
progressInput.value = _formatBookPercent(f);
|
||||
}
|
||||
// EPUB: shown from the position anchor, see the anchor tracking below.
|
||||
});
|
||||
|
||||
// Auto-save progress every 10s and on scroll (debounced 2s)
|
||||
|
|
@ -4684,6 +4736,10 @@ async function openBook(bookId) {
|
|||
clearTimeout(_anchorTrackDebounce);
|
||||
_anchorTrackDebounce = setTimeout(() => {
|
||||
_currentPositionAnchor = getPositionAnchor(contentEl);
|
||||
const f = _fractionFromAnchor(contentEl, _currentPositionAnchor);
|
||||
if (progressInput && f !== null && document.activeElement !== progressInput) {
|
||||
progressInput.value = _formatBookPercent(f);
|
||||
}
|
||||
}, 150);
|
||||
}, {passive: true});
|
||||
}
|
||||
|
|
@ -4787,13 +4843,16 @@ async function saveReaderProgress(force = false) {
|
|||
} else {
|
||||
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
||||
}
|
||||
fraction = Math.min(1.0, Math.max(0.0, fraction));
|
||||
|
||||
let anchor = '';
|
||||
if (!currentPdfDoc) {
|
||||
anchor = getPositionAnchor(contentEl);
|
||||
_currentPositionAnchor = anchor;
|
||||
// EPUB: share of the text, the same on every device and layout.
|
||||
const textFraction = _fractionFromAnchor(contentEl, anchor);
|
||||
if (textFraction !== null) fraction = textFraction;
|
||||
}
|
||||
fraction = Math.min(1.0, Math.max(0.0, fraction));
|
||||
|
||||
_updateCachedBookProgress(currentBookId, fraction, anchor, force);
|
||||
|
||||
|
|
@ -6556,7 +6615,19 @@ function _tocTitleForChapterSrc(chapterSrc) {
|
|||
// relocate a highlight/note later.
|
||||
function _percentFromRange(range) {
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl || !range || !contentEl.scrollHeight) return null;
|
||||
if (!contentEl || !range) return null;
|
||||
// Share of the text before the range, like the reading progress.
|
||||
const block = _blockAncestor(range.startContainer);
|
||||
if (block && contentEl.contains(block)) {
|
||||
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
|
||||
const i = blocks.indexOf(block);
|
||||
if (i >= 0) {
|
||||
const idx = _bookCharIndex(contentEl);
|
||||
const at = idx.starts[i] + _charOffsetInBlock(block, range.startContainer, range.startOffset);
|
||||
return Math.max(0, Math.min(100, (at / idx.total) * 100));
|
||||
}
|
||||
}
|
||||
if (!contentEl.scrollHeight) return null;
|
||||
const contentRect = contentEl.getBoundingClientRect();
|
||||
const rangeRect = range.getBoundingClientRect();
|
||||
if (!rangeRect.width && !rangeRect.height) return null;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v45';
|
||||
const CACHE = 'diora-v46';
|
||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue