Leseposition: Binärsuche über die Blöcke statt alle vom Buchanfang an messen
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 8m18s

getPositionAnchor maß jeden Block vom Buchanfang bis zur Leseposition und suchte
die Blöcke bei jedem Aufruf neu – im E-Reader-Modus bei jedem Umblättern (die
Prozentanzeige), beim Speichern des Fortschritts sowieso. In Das Kapital ab
Kapitel 15: 0,8 s bei 6-fach gedrosselter CPU; auf dem tolino filtert compat61
das :has() dazu in JavaScript.
- Blockliste samt Kapitel je Block einmal je geöffnetem Buch (verworfen bei
  jeder Änderung am Inhalt, MutationObserver)
- Binärsuche nur über die angezeigten Kapitel; gleiche Regel wie vorher
  (letzter Block mit Höhe, der an/über der Oberkante beginnt, bei gleicher Höhe
  der erste; sonst der erste darunter)
- Geprüft an 480 Stellen (Kapital, Hyperpolitik; E-Reader und Scrollen): gleiche
  Anker wie vorher; 53/281 ms → 4 ms; Umblättern 21–48 ms statt bis 0,9 s
- sw v98

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-08 19:01:40 +02:00
parent 3f2fcec3b8
commit 53ca3b0002
2 changed files with 76 additions and 14 deletions

View file

@ -2120,26 +2120,88 @@ function _checkScrollJump(contentEl) {
const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))';
function getPositionAnchor(contentEl) {
// The open book's text blocks (EPUB_BLOCK_SELECTOR), with the chapter each one
// belongs to — found once and kept until the book's content changes (a
// highlight wrapping text can turn a leaf div into a non-leaf one). Selecting
// them is the expensive part on an e-reader: Chrome 61 has no :has(), so
// compat61.js filters every div in JavaScript.
let _blockCache = null; // {content, blocks, chapterOf: Int32Array, chapters: Element[]}
function _epubBlocks(contentEl) {
if (_blockCache && _blockCache.content === contentEl) return _blockCache;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
const chapters = Array.from(contentEl.querySelectorAll('[data-epub-src]'));
const chapterOf = new Int32Array(blocks.length);
for (let i = 0, ch = -1; i < blocks.length; i++) {
while (ch + 1 < chapters.length && chapters[ch + 1].compareDocumentPosition(blocks[i]) & Node.DOCUMENT_POSITION_FOLLOWING) ch++;
chapterOf[i] = ch;
}
const observer = new MutationObserver(() => {
if (_blockCache && _blockCache.content === contentEl) _blockCache = null;
observer.disconnect();
});
observer.observe(contentEl, {childList: true, subtree: true});
_blockCache = {content: contentEl, blocks, chapterOf, chapters};
return _blockCache;
}
// The anchor of the reading position: "block:inner" — the last block (with a
// height) that starts at or above the top of the view, the first of several
// starting at the same height; else the first block below. Blocks are in
// document order and so top to bottom: a binary search over the chapters on
// screen (the e-reader mode shows one), not every block from the book's start.
function getPositionAnchor(contentEl) {
const {blocks, chapterOf, chapters} = _epubBlocks(contentEl);
if (!blocks.length) return '';
const containerTop = contentEl.getBoundingClientRect().top;
const containerBottom = containerTop + contentEl.clientHeight;
let bestIndex = 0;
let bestDelta = Infinity;
// Index ranges of the blocks in chapters that are laid out (all, when reading by scrolling).
const shown = new Set();
chapters.forEach((ch, i) => { if (ch.getClientRects().length) shown.add(i); });
const order = [];
for (let i = 0; i < blocks.length; i++) {
const rect = blocks[i].getBoundingClientRect();
if (rect.height < 1) continue;
if (rect.top > containerBottom) break;
const delta = rect.top - containerTop;
if (delta <= 0 && Math.abs(delta) < Math.abs(bestDelta)) {
bestIndex = i;
bestDelta = delta;
} else if (delta > 0 && bestDelta === Infinity) {
bestIndex = i;
bestDelta = delta;
if (chapterOf[i] < 0 || shown.has(chapterOf[i])) {
let j = i;
while (j + 1 < blocks.length && chapterOf[j + 1] === chapterOf[i]) j++;
order.push([i, j]);
i = j;
}
}
const count = order.reduce((n, r) => n + r[1] - r[0] + 1, 0);
const at = k => { // k-th block on screen -> block index
for (const [a, b] of order) { if (k <= b - a) return a + k; k -= b - a + 1; }
return -1;
};
const rectOf = k => blocks[at(k)].getBoundingClientRect();
// A block without height says nothing about the position: use the next one with a height.
const measured = k => {
for (let n = k; n < count; n++) { const r = rectOf(n); if (r.height >= 1) return {k: n, top: r.top}; }
return null;
};
// Binary search for the last block with a height whose top <= containerTop.
let lo = 0, hi = count - 1, best = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const m = measured(mid);
if (m && m.top <= containerTop) { best = m.k; lo = m.k + 1; }
else hi = mid - 1;
}
let bestIndex;
if (best >= 0) {
// Several blocks starting at the same height (a blockquote and its first paragraph): the first.
const top = rectOf(best).top;
let k = best;
for (let n = best - 1; n >= 0; n--) {
const r = rectOf(n);
if (r.height < 1) continue;
if (r.top !== top) break;
k = n;
}
bestIndex = at(k);
} else {
const first = measured(0);
bestIndex = first && first.top <= containerBottom ? at(first.k) : 0;
}
const rect = blocks[bestIndex].getBoundingClientRect();
const innerFraction = Math.max(0, Math.min(1, (containerTop - rect.top) / (rect.height || 1)));
return `${bestIndex}:${innerFraction.toFixed(6)}`;

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v97';
const CACHE = 'diora-v98';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',