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