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