'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 = `Weit gescrollt`; 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); }