diora-web/static/js/app/44-books.js

299 lines
13 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// ---------------------------------------------------------------------------
// Books
// ---------------------------------------------------------------------------
let currentBookId = null;
let currentBookToc = [];
let currentImageMap = {};
let readerScrollSaveTimer = null;
let _resizeObserver = null;
let _currentPositionAnchor = '';
let _currentBookCharCount = 0;
// Rough characters per printed book page, used to estimate a real page count for EPUBs.
const CHARS_PER_BOOK_PAGE = 1800;
const bookMetaCache = {}; // id → {title, author, type}
// Reading-position percentages (the progress readout, annotation "% im Buch"
// coordinates): a whole percent is fine for a normal book, but on a long one
// (~600-700 physical pages and up) it barely moves during a whole scrolling
// session, which reads as broken. Past that length, show one decimal —
// formatted with the browser locale's own decimal separator.
const LONG_BOOK_CHAR_THRESHOLD = 1000000;
function _formatBookPercent(fraction) {
const pct = Math.max(0, Math.min(100, fraction * 100));
const decimals = _currentBookCharCount > LONG_BOOK_CHAR_THRESHOLD ? 1 : 0;
return pct.toLocaleString(undefined, {minimumFractionDigits: decimals, maximumFractionDigits: decimals});
}
// Accepts both '45.3' and '45,3' (locale-typed input)
function _parseLocalePercent(str) {
const v = parseFloat(String(str).trim().replace(',', '.'));
return isNaN(v) ? 0 : v;
}
// ---------------------------------------------------------------------------
// Reader "scrolled too far" safety net — offers a toast to jump back after a
// sudden, large scroll (accidental fling, fat-fingered keybind, etc.). Only
// watches organic scrolling; explicit destination jumps (TOC, bookmarks, the
// progress input, search) call _suppressScrollJumpDetect() first so they
// don't trigger it.
// ---------------------------------------------------------------------------
let _scrollRestTop = null; // scrollTop last "settled" at, before the current burst
let _scrollRestDebounce = null;
let _scrollJumpSuppressed = false; // true while an explicit/programmatic jump is in flight
let _scrollJumpToastEl = null;
// Marks the current (and any immediately-following, possibly animated) scroll
// as deliberate, so _checkScrollJump ignores it. Suppression lifts itself
// once scrolling actually settles (see _armScrollJumpSettle) — not after a
// fixed delay — so it stays active for however long a `smooth` scroll
// animation to a far-away target actually takes, and calling this again
// (e.g. from repeated resize-triggered corrections while a book settles)
// simply pushes that settle point further out instead of racing it.
function _suppressScrollJumpDetect() {
_scrollJumpSuppressed = true;
_armScrollJumpSettle();
}
function _armScrollJumpSettle() {
clearTimeout(_scrollRestDebounce);
_scrollRestDebounce = setTimeout(() => {
const contentEl = $('reader-content');
if (contentEl) _scrollRestTop = contentEl.scrollTop;
_scrollJumpSuppressed = false;
}, 500);
}
function _dismissScrollJumpToast() {
if (_scrollJumpToastEl) {
_scrollJumpToastEl.remove();
_scrollJumpToastEl = null;
}
if (_readPos && _readPos.hold === 'jump') _readPos.hold = false;
}
function _showScrollJumpToast(backTop) {
_dismissScrollJumpToast();
const toast = document.createElement('div');
toast.className = 'reader-toast reader-toast-action';
toast.innerHTML = `<span>Weit gescrollt</span><button type="button" class="reader-toast-btn">Zurückspringen</button>`;
document.body.appendChild(toast);
_scrollJumpToastEl = toast;
// Until it's gone, the place before the jump stays the reading position.
if (_readPos && !_readPos.hold) _readPos.hold = 'jump';
toast.querySelector('.reader-toast-btn').addEventListener('click', () => {
const contentEl = $('reader-content');
if (contentEl) {
_suppressScrollJumpDetect();
contentEl.scrollTo({top: backTop, behavior: 'smooth'});
}
_dismissScrollJumpToast();
// Back where it was: that's no new reading.
_readerInteractedAt = 0;
});
setTimeout(() => {
if (_scrollJumpToastEl !== toast) return;
_dismissScrollJumpToast();
// Stayed there: the far place is where the reading goes on.
_noteReaderInteraction();
if (_readPos) _readPos.key = null;
}, 6000);
}
function _checkScrollJump(contentEl) {
const top = contentEl.scrollTop;
if (_scrollRestTop === null) _scrollRestTop = top; // first sample this session
if (!_scrollJumpSuppressed && !_scrollJumpToastEl) {
const screen = contentEl.clientHeight || 1;
if (Math.abs(top - _scrollRestTop) > screen * 3.5) {
_showScrollJumpToast(_scrollRestTop);
}
}
// Only adopt the new position as the "settled" baseline — and lift any
// suppression — once scrolling has paused for a bit. That's what lets a
// fast, still-in-progress fling (or a long smooth-scroll animation) be
// compared against/protected until it actually finishes, not mid-flight.
_armScrollJumpSettle();
}
const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))';
// 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;
// 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++) {
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)}`;
}
// 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(':');
if (parts.length !== 2) return false;
const idx = parseInt(parts[0], 10);
const innerFraction = parseFloat(parts[1]);
if (isNaN(idx) || isNaN(innerFraction)) return false;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
if (idx >= blocks.length) return false;
const el = blocks[idx];
contentEl.scrollTop = el.offsetTop + Math.round(innerFraction * el.offsetHeight);
return true;
}
// Split 'blockIndex:innerFraction' into [block, inner]; [-1, 0] if invalid/empty.
function _anchorParts(anchor) {
if (typeof anchor !== 'string') return [-1, 0];
const c = anchor.indexOf(':');
if (c < 0) return [-1, 0];
const b = parseInt(anchor.slice(0, c), 10);
const f = parseFloat(anchor.slice(c + 1));
return [Number.isFinite(b) ? b : -1, Number.isFinite(f) ? f : 0];
}
// >0 if position A is further into the book than B, <0 if behind, 0 if equal.
// Compares by anchor block index (precise); falls back to scroll_fraction only
// when an anchor is missing (e.g. PDFs), matching the server's _progress_is_further.
function _cmpProgress(anchorA, fracA, anchorB, fracB) {
const [ba, ia] = _anchorParts(anchorA);
const [bb, ib] = _anchorParts(anchorB);
if (ba >= 0 && bb >= 0) return ba !== bb ? ba - bb : ia - ib;
return (fracA || 0) - (fracB || 0);
}