From 09055b98742809b32ed7aeaeef951313f674ce0d Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 27 Sep 2026 19:57:10 +0200 Subject: [PATCH] Reader: Lesefortschritt nach Textanteil statt Pixeln (SW v46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/js/app.js | 83 ++++++++++++++++++++++++++++++++++++++++++++---- static/js/sw.js | 2 +- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 751d2b1..64a27d3 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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; diff --git a/static/js/sw.js b/static/js/sw.js index f99fe3a..24a7b9c 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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',