diora-web/static/js/app/63-pages.js

1031 lines
41 KiB
JavaScript
Raw Normal View History

'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));
});
})();