diora-web/static/js/app/63-pages.js
mrwnslz e8b525a3de
Some checks failed
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Successful in 11m2s
Test / e2e (push) Failing after 1m36s
Nachrichten und Blogs im Buch-Reader, Doppelklick für Vollbild, Hörbücher wie die Bücher-Bibliothek
65-article.js (DioraArticle): Artikel öffnen im selben Reader wie Bücher – Schrift, Farben,
Seiten/Doppelseite mit Fußnoten am Seitenfuß, Suche, Vorlesen, Vollbild. Lesezeichen,
Markierungen und Notizspalte bleiben Büchern vorbehalten. Merken, Projekt und Original
stehen in der Kopfzeile (Apps: im Lesemenü). feeds.js gibt sein eigenes Overlay auf;
Fortschritt wie bisher (bis zum unteren Bildschirmrand gelesen, ab 90 % gelesen).
Einstellungen der Artikel gelten für alle Artikel zusammen.

62-reader.js: Doppelklick (Maus) auf den Text schaltet Vollbild an/aus, die Leisten gehen mit.

audiobooks.js: die Hörbücher sind gebaut wie die Bücher – Kopf mit Suche, Sortierung und +,
Chips Angefangen/Ungehört/Gehört und Ordner, „Weiterhören“ wie „Weiterlesen“, Kacheln aus
denselben Bausteinen (53-library.js gibt seine Styles auch an #audiobook-shelf), Cover quadratisch.

63-pages.js: bei nur einem Kapitel zeigt die Fußzeile nur die Seiten.

sw CACHE v158

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:15:00 +01:00

1030 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// ---------------------------------------------------------------------------
// Reading in pages ("Seiten" in the reader settings) — in the browser, the
// desktop app and the Android apps, whose e-reader mode always reads in pages
// (diora-android native-eink.js turns it on with DioraPages.force).
//
// Pages without rewriting the reader: the book stays one long vertical
// document (so position anchors, highlights, TOC, search and read-aloud keep
// working and stay the same on every device), but free scrolling is off and
// the pager moves exactly one page at a time. A page ends before the first
// line that would be cut off (masked), a new chapter starts a new page, and
// the notes of the references on a page stand at its foot, like in a printed
// book. Only the current chapter is laid out; the others stay in the DOM
// (display:none), so block indices, search, notes and anchors see the whole
// book as before. Chrome-61-safe (tolino).
// ---------------------------------------------------------------------------
(function () {
const TOP_GAP = 14; // blank band above the first line of a page
const NOTES_GAP = 10; // space between the text and the notes' rule
const KEY_ON = 'diora_pages';
const KEY_NOTES = 'diora_eink_pagenotes'; // named after the e-reader mode, where pages began
const KEY_SPREAD = 'diora_pages_spread';
const get = (k, d) => { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch (e) { return d; } };
const set = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
let wanted = get(KEY_ON, '0') === '1'; // the reader's choice on this device
let forced = false; // e-reader mode (Android): always pages
let on = wanted;
let pageNotes = get(KEY_NOTES, '1') === '1';
const turnHooks = [];
const content = () => document.getElementById('reader-content');
const readerOpen = () => {
const o = document.getElementById('reader-overlay');
return !!o && o.style.display !== 'none';
};
const isPdf = () => typeof currentPdfDoc !== 'undefined' && !!currentPdfDoc;
const paging = () => on && readerOpen() && !isPdf();
// ---- page geometry ------------------------------------------------------------
// Rendered line boxes of the text at (x, y). Only text nodes count: an
// element's own box (a whole paragraph) is not a line and must never be
// taken for one, or a long paragraph would push the page end up to its top.
const BLOCKS = 'p, li, dd, dt, blockquote, h1, h2, h3, h4, h5, h6, figcaption, pre, td, div';
function linesAt(c, x, y) {
const el = bookElementAt(c, x, y);
if (!el || el === c) return [];
const block = el.closest(BLOCKS);
if (!block || block === c || block.hasAttribute('data-epub-src') || !c.contains(block)) return [];
const rects = [];
const range = document.createRange();
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, null, false);
let node, n = 0;
while ((node = walker.nextNode()) && n++ < 2000) {
if (!node.nodeValue.trim()) continue;
range.selectNodeContents(node);
const list = range.getClientRects();
for (let i = 0; i < list.length; i++) if (list[i].height > 0) rects.push(list[i]);
}
return rects;
}
// The book's element at (x, y) — also under a toast or a popover lying over the page.
function bookElementAt(c, x, y) {
if (document.elementsFromPoint) {
const list = document.elementsFromPoint(x, y);
for (let i = 0; i < list.length; i++) if (c.contains(list[i])) return list[i];
return null;
}
const el = document.elementFromPoint(x, y);
return el && c.contains(el) ? el : null;
}
// Where to look for lines: across the text column (on a wide screen the
// reader's edges are empty margin).
function probeXs(c) {
const col = columnOf(c);
return [col.left + 24, col.left + col.width / 2, col.left + col.width - 24];
}
// Viewport y (px) where the current page must end: before the next chapter
// and before any text line or image that would only be partly visible.
function pageLimit(c, maxY) {
const box = c.getBoundingClientRect();
const top = box.top;
let limit = maxY !== undefined ? maxY : box.top + c.clientHeight;
// A new chapter starts a new page — but only the chapter *after* the one
// this page is in (the first chapter itself may start a bit down the page,
// below the reader's top padding and its heading's margin).
const chapters = chapterList(c);
for (let i = 0; i < chapters.length; i++) {
if (chapters[i].getBoundingClientRect().bottom <= top + TOP_GAP) continue;
const nextCh = chapters[i + 1];
if (nextCh) {
const t = nextCh.getBoundingClientRect().top;
if (t > top + TOP_GAP + 40 && t < limit) limit = t;
}
break;
}
const probeY = limit - 1;
for (const x of probeXs(c)) {
const el = bookElementAt(c, x, probeY);
if (el && c.contains(el) && /^(IMG|svg|SVG|image)$/.test(el.tagName)) {
const r = el.getBoundingClientRect();
if (r.top > top + TOP_GAP && r.height < c.clientHeight * 0.8) limit = Math.min(limit, r.top);
continue;
}
for (const r of linesAt(c, x, probeY)) {
if (r.top < limit && r.bottom > limit + 0.5 && r.top > top + TOP_GAP) limit = Math.min(limit, r.top);
}
}
return limit;
}
// Make the first line of the page start right at TOP_GAP. `showCut`: if a
// line is cut at the top, scroll up to show it whole (used after jumps, so
// the jump target stays visible); otherwise start at the next line.
function alignTop(c, showCut) {
const box = c.getBoundingClientRect();
const edge = box.top + TOP_GAP;
for (const x of probeXs(c)) {
for (const r of linesAt(c, x, edge + 1)) {
if (r.top < edge - 0.5 && r.bottom > edge) {
ownScroll(c, showCut ? c.scrollTop - (edge - r.top) : c.scrollTop + (r.bottom - edge));
return;
}
}
}
}
// ---- masks: hide the cut line at the bottom and whatever sits in the top gap ----
// The colour behind the text: the reader's own, or the first opaque one around it.
function backgroundOf(el) {
for (; el && el.nodeType === 1; el = el.parentElement) {
const bg = getComputedStyle(el).backgroundColor;
if (bg && bg !== 'transparent' && !/^rgba\(.*,\s*0\)$/.test(bg)) return bg;
}
return '#fff';
}
// What lies over one page: a mask for the top gap, one from the cut line
// down, and the notes. The book's page has one, the right page of a spread another.
function makeCover() {
const k = {top: document.createElement('div'), bottom: document.createElement('div'), notes: makeNotesBox(), refs: []};
k.top.className = k.bottom.className = 'nui-page-mask';
document.body.appendChild(k.top);
document.body.appendChild(k.bottom);
return k;
}
let cover = null; // over #reader-content
function mainCover() {
if (!cover) {
cover = makeCover();
cover.notes.id = 'nui-page-notes';
}
return cover;
}
let rightCover = null; // over the right page of a spread
function hideCover(k) {
if (!k) return;
k.top.style.display = k.bottom.style.display = k.notes.style.display = 'none';
}
function hideMasks() {
hideCover(cover);
hideCover(rightCover);
}
// Lay `page` ({limit, notesH}, its notes already filled in k.notes) over the
// pane `el` (its viewport box `box`); `blank`: nothing of the text shows.
function placeCover(k, el, box, bg, page, gap, blank) {
const bottom = box.top + el.clientHeight;
const place = (m, t, h) => {
m.style.cssText = `display:${h > 0 ? 'block' : 'none'};left:${box.left}px;width:${el.clientWidth}px;top:${t}px;height:${h}px;background:${bg}`;
};
if (blank) {
place(k.top, box.top, el.clientHeight + 5);
k.bottom.style.display = k.notes.style.display = 'none';
return;
}
place(k.top, box.top, gap ? TOP_GAP : 0);
// Down to the bottom, across the whole width: the notes lie on it, only as wide as the text.
place(k.bottom, page.limit - 2, bottom - page.limit + 2 + 5);
if (page.notesH) {
const col = columnOf(el);
k.notes.style.left = col.left + 'px';
k.notes.style.width = col.width + 'px';
k.notes.style.top = (bottom - page.notesH) + 'px';
k.notes.style.height = (page.notesH + 4) + 'px';
k.notes.style.background = bg;
k.notes.style.color = getComputedStyle(el).color;
k.notes.style.visibility = 'visible';
} else {
k.notes.style.display = 'none';
}
}
function updateMasks() {
const c = content();
if (!paging() || !c) { hideMasks(); showSpread(false); renderFooter(); return; }
if (showSpread(spreadFits())) relayout(0); // the page got narrower or wider: line it up again
mainCover();
const box = c.getBoundingClientRect();
const bg = backgroundOf(c);
const page = layoutPage(c, cover);
placeCover(cover, c, box, bg, page, c.scrollTop > 0, false);
if (spreadShown) renderRightPage(c, box, bg, page);
else hideCover(rightCover);
renderFooter();
}
// ---- footnotes at the foot of the page, like a printed book -------------------
// The notes of the references on this page go below its text; the text area
// shrinks to make room. A reference that no longer fits moves to the next
// page and takes its note along (settled in a few passes). The notes are
// found like everywhere else (78-footnotes.js: noteForLink).
function makeNotesBox() {
const box = document.createElement('div');
box.className = 'nui-page-notes';
box.style.display = 'none';
// A tap on a note opens it whole (long ones are cut off here) and turns no page.
box.addEventListener('click', e => {
e.stopPropagation();
const item = e.target.closest('[data-note-idx]');
const k = [cover, rightCover].find(x => x && x.notes === box);
const link = item && k && k.refs[+item.dataset.noteIdx];
if (link) openNote(link, k === rightCover ? item : null);
});
document.body.appendChild(box);
return box;
}
// A note opened from a page. `near`: show it there — the reference itself is
// not on screen (the right page of a spread is a copy).
function openNote(link, near) {
if (window.DioraUI && DioraUI.openNote) { DioraUI.openNote(link); return; }
showFootnotePopover(link);
const p = currentFootnotePopover;
if (!p || !near) return;
const r = near.getBoundingClientRect();
const pr = p.getBoundingClientRect();
let top = r.top - pr.height - 8;
if (top < 8) top = r.bottom + 8;
p.style.top = Math.max(8, Math.min(window.innerHeight - pr.height - 8, top)) + 'px';
p.style.left = Math.max(8, Math.min(window.innerWidth - pr.width - 8, r.left)) + 'px';
}
function refsOnPage(c, top, limit) {
const out = [];
const seen = new Set();
const refs = ((on && currentChapter(c)) || c).querySelectorAll('[data-footnote-ref]');
for (let i = 0; i < refs.length; i++) {
const r = refs[i].getBoundingClientRect();
// On the page's first line a raised number (<sup>) reaches above the
// page's top edge: its bottom tells (it belongs to the page before only
// when its whole line was there).
if (r.height === 0 || r.bottom <= top + 1) continue;
if (r.top > limit + 4000) break; // document order: far below, done
if (r.bottom > limit) continue;
const key = refs[i].dataset.footnoteHref || refs[i].dataset.footnoteRef;
if (seen.has(key)) continue;
seen.add(key);
out.push(refs[i]);
}
return out;
}
// The text column (the chapter, at most --reader-max-width wide on a wide
// screen) of a pane: the notes line up with the text above them.
function columnOf(el) {
const ch = el.querySelector(':scope > .nui-ch-current') || el.querySelector(':scope > [data-epub-src]');
const r = ch && ch.getBoundingClientRect();
if (r && r.width) return {left: r.left, width: r.width};
return {left: el.getBoundingClientRect().left, width: el.clientWidth};
}
// Put the notes for `refs` into k.notes; returns the height they need.
function fillNotes(c, refs, k) {
const box = k.notes;
const cs = getComputedStyle(c);
box.style.cssText = `display:block;visibility:hidden;left:0;top:0;height:auto;width:${columnOf(c).width}px;` +
`font-family:${cs.fontFamily};font-size:${(parseFloat(cs.fontSize) * 0.8).toFixed(1)}px`;
box.innerHTML = '<div class="nui-pn-rule"></div>';
k.refs = refs;
refs.forEach((ref, i) => {
const note = noteForLink(ref);
const item = document.createElement('div');
item.className = 'nui-pn-item';
item.dataset.noteIdx = i;
const label = document.createElement('span');
label.className = 'nui-pn-label';
label.textContent = (ref.textContent || '').trim().replace(/^[\[(]\s*|\s*[\])]$/g, '');
item.appendChild(label);
if (note) item.appendChild(note);
else item.appendChild(document.createTextNode('(Fußnote nicht gefunden)'));
box.appendChild(item);
});
return box.scrollHeight;
}
// Where the page shown in #reader-content ends ({limit, notesH}), its notes
// filled into k (the book page's cover unless said otherwise).
function layoutPage(c, k) {
let limit = pageLimit(c);
if (!pageNotes) return {limit, notesH: 0};
if (!k) k = mainCover();
const box = c.getBoundingClientRect();
const bottom = box.top + c.clientHeight;
const maxH = Math.round(c.clientHeight * 0.45);
let refs = refsOnPage(c, box.top + TOP_GAP, limit);
for (let pass = 0; pass < 6 && refs.length; pass++) {
const needed = fillNotes(c, refs, k);
const h = Math.min(maxH, needed);
k.notes.classList.toggle('nui-pn-cut', needed > maxH);
const textLimit = pageLimit(c, Math.min(limit, bottom - h - NOTES_GAP));
const fitting = refs.filter(r => r.getBoundingClientRect().bottom <= textLimit);
if (fitting.length === refs.length) return {limit: textLimit, notesH: h};
// A reference whose line the notes push off goes to the next page and
// takes its note along: this page ends above that line.
// (Measured in the middle of the reference: its line is cut there.)
const dropped = refs.filter(r => fitting.indexOf(r) < 0)[0].getBoundingClientRect();
limit = pageLimit(c, Math.min(limit, (dropped.top + dropped.bottom) / 2));
refs = refsOnPage(c, box.top + TOP_GAP, limit);
}
k.notes.style.display = 'none';
k.refs = [];
return {limit, notesH: 0};
}
// ---- two pages side by side, like an open book ---------------------------------------
// On a wide screen ("Doppelseite"): the book's page is the left one — position,
// marking, notes, read-aloud all happen there — and the right page shows the
// page after it: a copy of the chapter beside it, scrolled to where the next
// page begins, masked and with its own notes. Laid out by the same rules: the
// book is scrolled there for a moment and measured. Turning moves two pages.
const SPREAD_MIN = 900; // px of reader width below which one page stays
let spreadWanted = get(KEY_SPREAD, '0') === '1';
let spreadShown = false;
let pane = null; // #reader-spread-page
let paneChapter = null; // the chapter its copy is of
let paneDirty = true; // the book changed (marks, search hits, read-aloud) since
function spreadFits() {
const row = document.getElementById('reader-body-row');
return spreadWanted && paging() && !!row && row.clientWidth >= SPREAD_MIN;
}
// Show or hide the right page; true if that changed the page's width.
function showSpread(show) {
const overlay = document.getElementById('reader-overlay');
if (show === spreadShown || !overlay) return false;
if (show && !pane) {
pane = document.createElement('div');
pane.id = 'reader-spread-page';
pane.className = 'reader-content';
pane.setAttribute('aria-hidden', 'true');
pane.addEventListener('click', onPaneClick, true);
pane.addEventListener('wheel', onWheel, {passive: false});
pane.addEventListener('touchstart', onTouchStart, {passive: true});
pane.addEventListener('touchend', onTouchEnd, {passive: true});
pane.addEventListener('pointerdown', e => { pointer = e.pointerType || 'mouse'; }, true);
const c = content();
c.parentNode.insertBefore(pane, c.nextSibling);
}
spreadShown = show;
overlay.classList.toggle('reader-spread', show);
if (!show) hideCover(rightCover);
history = [];
return true;
}
function syncPane(c) {
const ch = currentChapter(c);
if (paneDirty || paneChapter !== ch) {
pane.textContent = '';
if (ch) {
const copy = ch.cloneNode(true);
// No second element with the book's ids (getElementById must find the book's).
copy.removeAttribute('id');
copy.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
pane.appendChild(copy);
}
paneChapter = ch;
paneDirty = false;
}
// Font, size, line height, width: what the reader settings put on the book's page.
pane.style.cssText = c.style.cssText;
const cs = getComputedStyle(c);
pane.style.padding = cs.paddingTop + ' ' + cs.paddingRight + ' ' + cs.paddingBottom + ' ' + cs.paddingLeft;
}
// The page after the one shown, on the right — `left` is the shown page's layout.
function renderRightPage(c, box, bg, left) {
if (!rightCover) rightCover = makeCover();
syncPane(c);
const pbox = pane.getBoundingClientRect();
const ch = currentChapter(c);
// The chapter ends on the left page: the right one stays empty (a new chapter starts a new spread).
if (!ch || ch.getBoundingClientRect().bottom <= left.limit + 1) {
placeCover(rightCover, pane, pbox, bg, null, false, true);
pane.dataset.top = '';
return;
}
const top0 = c.scrollTop;
let step = left.limit - box.top - TOP_GAP;
if (step < 40) step = c.clientHeight - TOP_GAP;
ownScroll(c, top0 + step);
const rightTop = c.scrollTop;
const right = layoutPage(c, rightCover);
// The right page's layout in the book's coordinates: the panes share their top.
c.scrollTop = top0;
expectedTop = c.scrollTop;
pane.scrollTop = rightTop;
pane.dataset.top = String(rightTop);
placeCover(rightCover, pane, pbox, bg, right, true, false);
// The notes above were filled before the book scrolled back: still right.
}
// A tap on the right page: a reference opens its note (the book's own
// reference, found by its place among the chapter's); else as on the left.
function onPaneClick(e) {
const ref = e.target.closest('[data-footnote-ref]');
const c = content();
if (ref && c && currentChapter(c)) {
e.preventDefault();
e.stopPropagation();
const copies = pane.querySelectorAll('[data-footnote-ref]');
const originals = currentChapter(c).querySelectorAll('[data-footnote-ref]');
const i = Array.prototype.indexOf.call(copies, ref);
if (originals[i]) openNote(originals[i], ref);
return;
}
onClick(e);
}
// ---- chapter window ----------------------------------------------------------------
// Only the current chapter is laid out (#reader-content.nui-window, app.css):
// layout (font changes, paging) shrinks from the whole book to one chapter.
const chapterList = c => c.querySelectorAll(':scope > [data-epub-src]');
const currentChapter = c => c.querySelector(':scope > .nui-ch-current');
function chapterOf(node) {
const el = node && node.nodeType !== 1 ? node.parentElement : node;
return el && el.closest ? el.closest('#reader-content > [data-epub-src]') : null;
}
function siblingChapter(ch, dir) {
let sib = dir > 0 ? ch.nextElementSibling : ch.previousElementSibling;
while (sib && !sib.hasAttribute('data-epub-src')) sib = dir > 0 ? sib.nextElementSibling : sib.previousElementSibling;
return sib;
}
// Make `ch` the visible chapter. where: 'top' scrolls to its start,
// otherwise the caller positions (a jump that follows).
function showChapter(ch, where) {
const c = content();
if (!c || !ch) return false;
const cur = currentChapter(c);
if (cur === ch) return false;
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
if (cur) cur.classList.remove('nui-ch-current');
ch.classList.add('nui-ch-current');
history = [];
fractionCache = null;
if (where === 'top') ownScroll(c, 0);
return true;
}
// Before anything scrolls to an element: bring its chapter in.
function reveal(node) {
if (!on) return false;
const ch = chapterOf(node);
return ch ? showChapter(ch, null) : false;
}
function ensureChapter(c) {
if (on && c && !isPdf() && !currentChapter(c)) {
const first = chapterList(c)[0];
if (first) showChapter(first, 'top');
}
}
// Page tops of the (visible) chapter, walked like reading forward — so going
// back into a chapter lands exactly on its last page and back from there.
function chapterPageTops(c) {
const tops = [];
ownScroll(c, 0);
for (let i = 0; i < 2000; i++) {
tops.push(c.scrollTop);
if (atEnd(c)) break;
const box = c.getBoundingClientRect();
let step = layoutPage(c).limit - box.top - TOP_GAP;
if (step < 40) step = c.clientHeight - TOP_GAP;
ownScroll(c, c.scrollTop + step);
if (c.scrollTop <= tops[tops.length - 1]) break;
}
return tops;
}
// Share of the book's text read (44-books.js): the scroll position only knows
// the visible chapter.
let fractionCache = null;
function globalFraction(c) {
if (!on || isPdf() || !c) return null;
const cur = currentChapter(c);
const key = c.scrollTop + '|' + c.clientHeight + '|' + (cur ? cur.id : '');
if (fractionCache && fractionCache.key === key) return fractionCache.value;
const value = _fractionFromAnchor(c, getPositionAnchor(c));
fractionCache = {key, value};
return value;
}
function jumpToFraction(fraction) {
const c = content();
if (!c) return;
_suppressScrollJumpDetect();
restoreFromAnchor(c, _anchorForFraction(c, fraction));
}
// ---- turning pages ------------------------------------------------------------
let expectedTop = null; // scrollTop our own last move produced
let history = []; // page tops while reading forward, for exact "back"
function ownScroll(c, top) {
const max = c.scrollHeight - c.clientHeight;
top = Math.max(0, Math.min(max, Math.round(top)));
expectedTop = top;
// Pages turned quickly add up to a long way: still no accidental fling.
if (top !== c.scrollTop) _suppressScrollJumpDetect();
c.scrollTop = top;
}
function atEnd(c) {
const ch = on && currentChapter(c);
if (ch) return ch.getBoundingClientRect().bottom <= layoutPage(c).limit + 1;
return c.scrollTop >= c.scrollHeight - c.clientHeight - 1;
}
// Turning a page is reading (docs/progress-sync.md), however it came.
function turned() {
if (window.__dioraReaderInteraction) window.__dioraReaderInteraction();
}
// One page forward: 'page', 'chapter' (the next one begins) or 'end' (of the book).
function stepForward(c) {
if (atEnd(c)) {
const nextCh = on && currentChapter(c) && siblingChapter(currentChapter(c), 1);
if (!nextCh) return 'end';
showChapter(nextCh, 'top');
return 'chapter';
}
const box = c.getBoundingClientRect();
let step = layoutPage(c).limit - box.top - TOP_GAP;
if (step < 40) step = c.clientHeight - TOP_GAP; // e.g. an image taller than the page
history.push(c.scrollTop);
if (history.length > 200) history.shift();
ownScroll(c, c.scrollTop + step);
return 'page';
}
function next() {
const c = content();
if (!c) return;
turned();
const r = stepForward(c);
if (r === 'end') { toast('Ende des Buchs'); return; }
// A spread: the right page becomes the left one's predecessor — one more.
if (r === 'page' && spreadShown) stepForward(c);
afterTurn();
}
function prev() {
const c = content();
if (!c) return;
turned();
if (c.scrollTop <= 0) {
const prevCh = on && currentChapter(c) && siblingChapter(currentChapter(c), -1);
if (!prevCh) return;
showChapter(prevCh, 'top');
const tops = chapterPageTops(c);
// The chapter's last page — in a spread, the left page of its last spread.
let i = tops.length - 1;
if (spreadShown) i -= i % 2;
const last = tops[i];
history = tops.slice(0, i);
ownScroll(c, last);
afterTurn();
return;
}
let back = history.pop();
if (spreadShown && back !== undefined) {
const before = history.pop();
if (before !== undefined) back = before;
}
if (back !== undefined && back < c.scrollTop) {
ownScroll(c, back);
} else {
history = [];
ownScroll(c, c.scrollTop - (spreadShown ? 2 : 1) * (c.clientHeight - TOP_GAP));
if (c.scrollTop > 0) alignTop(c, false);
}
afterTurn();
}
function afterTurn() {
requestAnimationFrame(() => {
updateMasks();
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
});
turnHooks.forEach(fn => { try { fn(); } catch (e) {} });
}
function toast(text) {
const t = document.createElement('div');
t.className = 'reader-toast';
t.textContent = text;
document.body.appendChild(t);
setTimeout(() => t.remove(), 1600);
}
// Line the page up again after the layout changed under it (bars, font, size).
function relayout(delay) {
if (!paging()) { updateMasks(); return; }
setTimeout(() => {
const c = content();
if (!c || !paging()) return;
if (c.scrollTop > 0) alignTop(c, true);
updateMasks();
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
}, delay || 0);
}
// Something else moved the text (TOC, search, bookmark, restore on open,
// font change): line it up as a page again and forget the back history.
let realignTimer = null;
function onScroll() {
if (!paging()) return;
const c = content();
if (expectedTop !== null && Math.abs(c.scrollTop - expectedTop) < 2) return;
clearTimeout(realignTimer);
realignTimer = setTimeout(() => {
expectedTop = null;
history = [];
if (c.scrollTop > 0) alignTop(c, true);
updateMasks();
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
}, 120);
}
// ---- input: taps and clicks, swipes, keys, the mouse wheel -----------------------
let touch = null;
let pointer = 'touch'; // what the last press came from: a mouse turns only at the edges
function interactive(target) {
return target.closest('a, button, input, select, label, mark.epub-highlight, [data-footnote-ref], .footnote-popover');
}
function onClick(e) {
if (!paging() || interactive(e.target)) return;
if (typeof markerModeActive !== 'undefined' && markerModeActive) return;
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
const c = content();
const box = c.getBoundingClientRect();
const right = spreadShown ? pane : c;
const end = right.getBoundingClientRect().right;
if (pointer === 'mouse') {
// A mouse: only beside the text, so clicking into it (to select a word)
// leaves the page where it is.
if (e.clientX < columnOf(c).left) prev();
else if (e.clientX > columnOf(right).left + columnOf(right).width) next();
else return; // on the text: the reader toggles its bars
} else {
// Fingers: a third on each side (of both pages in a spread).
const x = (e.clientX - box.left) / (end - box.left);
if (x < 0.3) prev();
else if (x > 0.7) next();
else return; // middle: the reader toggles its bars
}
e.preventDefault();
e.stopPropagation();
}
function onTouchStart(e) {
if (!paging() || e.touches.length !== 1) { touch = null; return; }
touch = {x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now()};
}
function onTouchEnd(e) {
if (!touch || !paging()) return;
const t = e.changedTouches[0];
const dx = t.clientX - touch.x, dy = t.clientY - touch.y;
const quick = Date.now() - touch.t < 600;
touch = null;
const sel = window.getSelection();
if (!quick || (sel && !sel.isCollapsed)) return;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) { dx < 0 ? next() : prev(); }
else if (Math.abs(dy) > 60 && Math.abs(dy) > Math.abs(dx) * 1.5) { dy < 0 ? next() : prev(); }
}
// Keys that scroll in the scrolling reader turn pages here (j/k/f/b/d/u are
// its vim keys, 26-sidebar.js).
const KEYS = {ArrowRight: 1, ArrowDown: 1, PageDown: 1, ' ': 1, j: 1, f: 1, d: 1,
ArrowLeft: -1, ArrowUp: -1, PageUp: -1, k: -1, b: -1, u: -1};
function onKey(e) {
if (!paging() || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
let dir = KEYS[e.key];
if (!dir) return;
const t = e.target;
if (t && t.closest && t.closest('input, textarea, select, [contenteditable="true"], .note-bottom-sheet')) return;
// Space on a button presses it.
if (e.key === ' ' && t && t.closest && t.closest('button, a')) return;
// The search bar's arrows go from match to match.
if (typeof readerSearchOpen !== 'undefined' && readerSearchOpen && /^Arrow(Up|Down)$/.test(e.key)) return;
if (document.getElementById('nui-note')) return; // the apps' note sheet is open
if (e.key === ' ' && e.shiftKey) dir = -1;
e.preventDefault();
e.stopPropagation();
dir > 0 ? next() : prev();
}
// One turn per wheel gesture: a trackpad's flick (with its long tail) or a
// wheel notch; a wheel spun on turns on every fourth of a second.
let wheelSum = 0, wheelTurnAt = 0, wheelQuiet = 0, wheelLocked = false;
function onWheel(e) {
if (!paging() || e.ctrlKey) return;
e.preventDefault();
const dy = e.deltaMode === 1 ? e.deltaY * 30 : e.deltaY;
clearTimeout(wheelQuiet);
wheelQuiet = setTimeout(() => { wheelLocked = false; wheelSum = 0; }, 180);
if (wheelLocked && !(Math.abs(dy) >= 50 && Date.now() - wheelTurnAt > 250)) return;
wheelSum += dy;
if (Math.abs(wheelSum) < 40) return;
wheelLocked = true;
wheelTurnAt = Date.now();
const dir = wheelSum > 0 ? 1 : -1;
wheelSum = 0;
dir > 0 ? next() : prev();
}
// ---- footer: chapter, page and share of the book ------------------------------------
function footerText(c) {
if (!paging() || !c) return null;
const pageH = Math.max(1, c.clientHeight - TOP_GAP);
// Pages vary in height (notes at the foot), so page numbers within the
// chapter are an estimate — rounded, so a short page doesn't repeat one.
const cur = currentChapter(c);
const textEnd = cur ? cur.offsetTop + cur.offsetHeight : c.scrollHeight;
const current = Math.round(c.scrollTop / pageH) + 1;
// A spread with text on the right page too: both its numbers.
const two = spreadShown && pane && pane.dataset.top;
const total = Math.max(current + (two ? 1 : 0), Math.ceil(Math.max(0, textEnd - TOP_GAP) / pageH));
const chapters = chapterList(c);
const idx = Array.prototype.indexOf.call(chapters, cur);
const f = globalFraction(c);
return {
// One chapter (an article, a single-file book): just its pages.
left: (chapters.length > 1 ? 'Kap. ' + (idx + 1) + '/' + chapters.length + ' · ' : '') +
'S. ' + current + (two ? '–' + (current + 1) : '') + '/' + total,
right: f === null ? '' : _formatBookPercent(f) + ' %',
};
}
// The browser has no footer of its own (the apps' native-ui.js has one and
// asks footerText): a line under the page while reading in pages.
let footer = null;
function renderFooter() {
const c = content();
const own = !document.getElementById('nui-reader-footer');
const text = own && c ? footerText(c) : null;
if (!text) { if (footer) footer.style.display = 'none'; return; }
if (!footer) {
const overlay = document.getElementById('reader-overlay');
if (!overlay) return;
footer = document.createElement('div');
footer.id = 'reader-page-footer';
footer.innerHTML = '<span class="rpf-left"></span><span class="rpf-right"></span>';
overlay.appendChild(footer);
}
footer.style.display = '';
const l = footer.querySelector('.rpf-left');
const r = footer.querySelector('.rpf-right');
// Rewriting unchanged text still repaints (a flicker on e-ink).
if (l.textContent !== text.left) l.textContent = text.left;
if (r.textContent !== text.right) r.textContent = text.right;
}
// ---- switching -------------------------------------------------------------------
function apply() {
const c = content();
const bookOpen = !!c && readerOpen() && !isPdf();
// The place, taken while the old layout is still there.
const anchor = bookOpen ? getPositionAnchor(c) : '';
if (c) c.classList.toggle('nui-window', on && !isPdf());
history = [];
fractionCache = null;
if (bookOpen) {
_suppressScrollJumpDetect();
if (anchor) restoreFromAnchor(c, anchor);
ensureChapter(c);
}
if (!on) { hideMasks(); showSpread(false); }
if (bookOpen && on) relayout(50);
renderFooter();
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
}
function update() {
const was = on;
on = wanted || forced;
if (was !== on) apply();
}
function setOn(value) {
wanted = !!value;
set(KEY_ON, wanted ? '1' : '0');
update();
}
function force(value) {
forced = !!value;
update();
}
// A book or an article (65-article.js) is in the reader now.
function opened() {
const c = content();
if (!c) return;
c.classList.toggle('nui-window', on && !isPdf());
history = [];
fractionCache = null;
paneDirty = true;
ensureChapter(c); // a new book has no saved position to reveal
if (paging()) relayout(250);
}
function setSpread(value) {
spreadWanted = !!value;
set(KEY_SPREAD, spreadWanted ? '1' : '0');
updateMasks();
relayout(50);
}
function togglePageNotes() {
pageNotes = !pageNotes;
set(KEY_NOTES, pageNotes ? '1' : '0');
updateMasks();
}
// ---- hooks into the reader ----------------------------------------------------------
// Whatever scrolls to an element (search, read-aloud, notes) brings its
// chapter in first, and jumps instead of gliding.
function patchScrolling() {
['scrollBy', 'scrollTo', 'scrollIntoView'].forEach(name => {
const orig = Element.prototype[name];
if (!orig) return;
Element.prototype[name] = function () {
const args = Array.prototype.slice.call(arguments);
if (on) {
if (name === 'scrollIntoView') reveal(this);
if (paging() && args.length === 1 && args[0] && typeof args[0] === 'object') {
args[0] = Object.assign({}, args[0], {behavior: 'auto'});
}
}
return orig.apply(this, args);
};
});
}
function wrap(name, make) {
const orig = window[name];
if (typeof orig === 'function') window[name] = make(orig);
}
function wrapReader() {
wrap('openBook', orig => async function () {
const c = content();
if (c) c.classList.toggle('nui-window', on);
fractionCache = null;
paneDirty = true;
const r = await orig.apply(this, arguments);
opened();
return r;
});
wrap('restoreFromAnchor', orig => function (contentEl, anchor) {
let first = false;
if (on && anchor && contentEl) {
const parts = _anchorParts(anchor);
const list = contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR);
const block = parts[0] >= 0 && parts[0] < list.length ? list[parts[0]] : null;
if (block) {
reveal(block);
// The chapter's very beginning: its first page, with the margin above
// the heading — not the heading pushed up to the edge.
const ch = chapterOf(block);
first = !!ch && parts[1] < 0.01 && ch.querySelector(EPUB_BLOCK_SELECTOR) === block;
}
}
const r = orig.apply(this, arguments);
if (first && r !== false && contentEl === content()) contentEl.scrollTop = 0;
return r;
});
wrap('jumpToTocEntry', orig => function (href) {
const c = content();
if (on && c && typeof href === 'string' && href.indexOf('#pdf-page-') !== 0) {
const i = href.indexOf('#');
const frag = i >= 0 ? href.slice(i + 1) : '';
const file = i >= 0 ? href.slice(0, i) : href;
let t = frag ? c.querySelector('#' + CSS.escape(frag)) : null;
if (!t && file) t = Array.prototype.find.call(chapterList(c), el => el.getAttribute('data-epub-src') === file);
if (t) reveal(t);
}
return orig.apply(this, arguments);
});
wrap('jumpToBookmark', orig => function (id) {
const c = content();
const bm = on && typeof currentBookmarks !== 'undefined' && currentBookmarks.find(b => b.id === id);
if (c && bm && bm.anchor && bm.anchor.indexOf('pdf-page-') !== 0) {
const ch = Array.prototype.find.call(chapterList(c), el => el.getAttribute('data-epub-src') === bm.anchor);
if (ch) reveal(ch);
}
return orig.apply(this, arguments);
});
wrap('jumpToAnnotation', orig => function (id) {
const h = on && typeof currentHighlights !== 'undefined' && currentHighlights.find(x => x.id === id);
if (h && typeof resolveAnchorRange === 'function') {
try { const r = resolveAnchorRange(h); if (r) reveal(r.startContainer); } catch (e) {}
}
return orig.apply(this, arguments);
});
wrap('_ttsHighlightSentence', orig => function (block) {
const c = content();
if (on && c) {
const ch = chapterOf(block);
if (ch && ch !== currentChapter(c)) showChapter(ch, 'top');
}
return orig.apply(this, arguments);
});
// Read-aloud follows the text page by page instead of scrolling.
wrap('_ttsScrollIntoView', orig => function (el) {
if (!paging()) return orig.apply(this, arguments);
const c = content();
let guard = 50;
let moved = false;
// Page by page (also in a spread: the voice's page goes left, the next one beside it).
while (guard-- > 0 && el.getBoundingClientRect().bottom > layoutPage(c).limit && !atEnd(c)) moved = stepForward(c) === 'page' || moved;
if (moved) afterTurn();
if (el.getBoundingClientRect().top < c.getBoundingClientRect().top) {
ownScroll(c, c.scrollTop - (c.getBoundingClientRect().top + TOP_GAP - el.getBoundingClientRect().top));
history = [];
afterTurn();
}
});
wrap('applyReaderSettings', orig => function () {
const r = orig.apply(this, arguments);
if (paging()) relayout(60);
return r;
});
wrap('closeReader', orig => function () {
hideMasks();
if (footer) footer.style.display = 'none';
return orig.apply(this, arguments);
});
}
window.DioraPages = {
isOn: () => on,
isForced: () => forced,
paging,
setOn,
force,
pageNotes: () => pageNotes,
togglePageNotes,
spread: () => spreadWanted,
spreadShown: () => spreadShown,
setSpread,
next,
prev,
reveal,
currentChapter: () => (content() ? currentChapter(content()) : null),
globalFraction,
jumpToFraction,
footerText,
relayout,
opened,
onTurn: fn => turnHooks.push(fn),
};
patchScrolling();
document.addEventListener('DOMContentLoaded', () => {
wrapReader();
const c = content();
if (!c) return;
c.classList.toggle('nui-window', on);
c.addEventListener('pointerdown', e => { pointer = e.pointerType || 'mouse'; }, true);
c.addEventListener('click', onClick, true);
c.addEventListener('scroll', onScroll, {passive: true});
c.addEventListener('touchstart', onTouchStart, {passive: true});
c.addEventListener('touchend', onTouchEnd, {passive: true});
c.addEventListener('wheel', onWheel, {passive: false});
window.addEventListener('keydown', onKey, true);
// The right page of a spread is a copy: made again after the book changed
// (a marking, search hits, the read-aloud mark).
new MutationObserver(() => { paneDirty = true; })
.observe(c, {childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'data-color']});
// Bars shown or hidden, panels opened, the window resized: the page's
// height changed. Look again once the layout (and its transition) settled.
const settle = () => {
if (!paging()) return;
requestAnimationFrame(updateMasks);
setTimeout(updateMasks, 80);
relayout(400);
};
new MutationObserver(settle).observe(document.body, {attributes: true, attributeFilter: ['class']});
if (window.ResizeObserver) new ResizeObserver(settle).observe(c); // not in old WebViews
window.addEventListener('resize', () => relayout(150));
});
})();