From 53ca3b00024188476a3addd85d1e84c82da79768 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Thu, 8 Oct 2026 19:01:40 +0200 Subject: [PATCH] =?UTF-8?q?Leseposition:=20Bin=C3=A4rsuche=20=C3=BCber=20d?= =?UTF-8?q?ie=20Bl=C3=B6cke=20statt=20alle=20vom=20Buchanfang=20an=20messe?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/js/app.js | 88 +++++++++++++++++++++++++++++++++++++++++------- static/js/sw.js | 2 +- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index fe12f31..a094a04 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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)}`; diff --git a/static/js/sw.js b/static/js/sw.js index fba4890..853f5fb 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-v97'; +const CACHE = 'diora-v98'; const SHELL = [ '/static/css/app.css', '/static/js/app.js',