63-pages.js (DioraPages): die Seitenansicht des E-Reader-Modus der Android-Apps, jetzt für alle – Seitenumbrüche vor angeschnittenen Zeilen, ein Kapitel auf einmal, Fußnoten am Seitenfuß wie im gedruckten Buch, Blättern per Tippen/Klick am Rand, Wischen, Pfeil-/Bild-/Leertaste und Mausrad. Neu: Doppelseite (zwei Seiten nebeneinander ab 900 px Breite). Einstellung „Scrollen | Seiten“ (+ „Fußnoten unten“, „Doppelseite“) im Schrift-&-Layout-Panel, je Gerät. Fußnoten: Erkennung und Auflösung aus diora-android native-ui.js übernommen (40-epub.js, 78-footnotes.js) – die Notiz wird im richtigen Kapitel gesucht (IDs wiederholen sich je Datei), auch das Popover zeigt jetzt genau die Notiz. Vorbereitete Bücher werden einmal neu aufbereitet. Behoben gegenüber der App-Fassung: ein Verweis in der ersten Zeile einer Seite (hochgestellt) zählte zur Vorseite, und ein Verweis, der samt Notiz nicht mehr passte, blieb ohne Notiz stehen – jetzt wandert er mit ihr auf die nächste Seite. Schnelles Blättern löst „Weit gescrollt“ nicht mehr aus. sw CACHE v157 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1018 lines
40 KiB
JavaScript
1018 lines
40 KiB
JavaScript
'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 {
|
||
left: '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();
|
||
}
|
||
|
||
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);
|
||
history = [];
|
||
if (c && isPdf()) c.classList.remove('nui-window');
|
||
ensureChapter(content()); // a new book has no saved position to reveal
|
||
if (paging()) relayout(250);
|
||
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,
|
||
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));
|
||
});
|
||
})();
|