Reader: Lesefortschritt nach Textanteil statt Pixeln (SW v46)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 1m33s

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:
mrwnslz 2026-09-27 19:57:10 +02:00
parent 9d097f5fba
commit 09055b9874
2 changed files with 78 additions and 7 deletions

View file

@ -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;

View file

@ -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',