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)}`;
|
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) {
|
function restoreFromAnchor(contentEl, anchor) {
|
||||||
if (!anchor) return false;
|
if (!anchor) return false;
|
||||||
const parts = anchor.split(':');
|
const parts = anchor.split(':');
|
||||||
|
|
@ -4464,6 +4517,7 @@ async function openBook(bookId) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loadReaderSettings(bookId);
|
loadReaderSettings(bookId);
|
||||||
|
_charIndex = null;
|
||||||
const key = await getOrCreateEncKey();
|
const key = await getOrCreateEncKey();
|
||||||
let data_ct, data_iv;
|
let data_ct, data_iv;
|
||||||
const cached = await _getCachedBook(bookId);
|
const cached = await _getCachedBook(bookId);
|
||||||
|
|
@ -4591,7 +4645,7 @@ async function openBook(bookId) {
|
||||||
} else {
|
} else {
|
||||||
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
|
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
|
||||||
this.value = _formatBookPercent(pct / 100);
|
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;
|
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)
|
// Auto-save progress every 10s and on scroll (debounced 2s)
|
||||||
|
|
@ -4684,6 +4736,10 @@ async function openBook(bookId) {
|
||||||
clearTimeout(_anchorTrackDebounce);
|
clearTimeout(_anchorTrackDebounce);
|
||||||
_anchorTrackDebounce = setTimeout(() => {
|
_anchorTrackDebounce = setTimeout(() => {
|
||||||
_currentPositionAnchor = getPositionAnchor(contentEl);
|
_currentPositionAnchor = getPositionAnchor(contentEl);
|
||||||
|
const f = _fractionFromAnchor(contentEl, _currentPositionAnchor);
|
||||||
|
if (progressInput && f !== null && document.activeElement !== progressInput) {
|
||||||
|
progressInput.value = _formatBookPercent(f);
|
||||||
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
}, {passive: true});
|
}, {passive: true});
|
||||||
}
|
}
|
||||||
|
|
@ -4787,13 +4843,16 @@ async function saveReaderProgress(force = false) {
|
||||||
} else {
|
} else {
|
||||||
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
||||||
}
|
}
|
||||||
fraction = Math.min(1.0, Math.max(0.0, fraction));
|
|
||||||
|
|
||||||
let anchor = '';
|
let anchor = '';
|
||||||
if (!currentPdfDoc) {
|
if (!currentPdfDoc) {
|
||||||
anchor = getPositionAnchor(contentEl);
|
anchor = getPositionAnchor(contentEl);
|
||||||
_currentPositionAnchor = anchor;
|
_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);
|
_updateCachedBookProgress(currentBookId, fraction, anchor, force);
|
||||||
|
|
||||||
|
|
@ -6556,7 +6615,19 @@ function _tocTitleForChapterSrc(chapterSrc) {
|
||||||
// relocate a highlight/note later.
|
// relocate a highlight/note later.
|
||||||
function _percentFromRange(range) {
|
function _percentFromRange(range) {
|
||||||
const contentEl = $('reader-content');
|
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 contentRect = contentEl.getBoundingClientRect();
|
||||||
const rangeRect = range.getBoundingClientRect();
|
const rangeRect = range.getBoundingClientRect();
|
||||||
if (!rangeRect.width && !rangeRect.height) return null;
|
if (!rangeRect.width && !rangeRect.height) return null;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* 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 PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue