From 6bb774adb944656e13f69fb8af207d92c447bc18 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 26 Oct 2026 19:41:27 +0100 Subject: [PATCH] =?UTF-8?q?Lesen=20in=20Seiten=20auch=20im=20Browser=20und?= =?UTF-8?q?=20auf=20dem=20Desktop,=20mit=20Fu=C3=9Fnoten=20unten=20auf=20d?= =?UTF-8?q?er=20Seite=20und=20Doppelseite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/css/app.css | 40 ++ static/js/app/40-epub.js | 44 +- static/js/app/48-book-cache.js | 6 +- static/js/app/63-pages.js | 1018 +++++++++++++++++++++++++++ static/js/app/64-reader-settings.js | 37 + static/js/app/78-footnotes.js | 150 +++- static/js/sw.js | 3 +- templates/radio/player.html | 1 + 8 files changed, 1258 insertions(+), 41 deletions(-) create mode 100644 static/js/app/63-pages.js diff --git a/static/css/app.css b/static/css/app.css index 94c2eb8..4bd3dd9 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1866,6 +1866,46 @@ html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); } .footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; } .footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; } .footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; } +.footnote-popover > :first-child { margin-top:0; } +.footnote-popover > :last-child { margin-bottom:0; } + +/* ---- Reading in pages (63-pages.js) ---- + Only the current chapter is laid out; the others stay in the DOM (block + indices, search, notes, anchors). No free scrolling: the pager moves. */ +#reader-content.nui-window > [data-epub-src]:not(.nui-ch-current) { display:none; } +/* Room below the chapter, so its last page can show just the remaining text + (then the page's colour) instead of scrolling back over lines already read. */ +#reader-content.nui-window::after { content:""; display:block; height:100vh; } +#reader-content.nui-window { overflow-y:hidden; -ms-overflow-style:none; scrollbar-width:none; } +#reader-content.nui-window::-webkit-scrollbar { display:none; } +/* Masks: the line cut at the page's bottom and whatever sits in the top gap. */ +.nui-page-mask { position:fixed; z-index:250; pointer-events:none; display:none; } +/* Footnotes at the foot of the page, like a printed book. */ +.nui-page-notes { position:fixed; z-index:255; overflow:hidden; padding:0 0 4px; box-sizing:border-box; + line-height:1.4; font-weight:400; cursor:pointer; } +.nui-pn-rule { width:30%; border-top:1px solid currentColor; margin:2px 0 6px; } +.nui-pn-item { margin:0 0 4px; padding-left:1.6em; text-indent:-1.6em; } +.nui-pn-item p, .nui-pn-item div, .nui-pn-item aside, .nui-pn-item li { display:inline; margin:0; padding:0; text-indent:0; } +.nui-pn-item p + p::before { content:" "; } +.nui-pn-label { display:inline-block; min-width:1.6em; text-indent:0; font-weight:700; } +.nui-pn-item a { color:inherit; text-decoration:none; } +.nui-pn-item img { display:none; } +/* Cut off: say so, a tap shows the whole note. */ +.nui-page-notes.nui-pn-cut::after { content:"… mehr"; position:absolute; right:0; bottom:2px; padding-left:8px; + background:inherit; font-weight:700; } +/* Two pages side by side (a spread, 63-pages.js): the right one is a copy of + the chapter, scrolled to the page after the left one. */ +#reader-spread-page { display:none; } +#reader-overlay.reader-spread #reader-content, +#reader-overlay.reader-spread #reader-spread-page { flex:1 1 0; min-width:0; } +#reader-overlay.reader-spread #reader-spread-page { display:block; overflow:hidden; box-shadow:inset 1px 0 0 var(--border); + user-select:none; -webkit-user-select:none; cursor:default; } +#reader-spread-page::after { content:""; display:block; height:100vh; } +/* The browser's line under the page (the apps have their own footer). */ +#reader-page-footer { display:flex; justify-content:space-between; gap:12px; flex-shrink:0; padding:4px 16px 6px; + font-size:12px; color:var(--muted,#888); font-variant-numeric:tabular-nums; } +.reader-theme-sepia #reader-page-footer { background:#f5e6c8; color:#7a6650; } +.reader-theme-bright #reader-page-footer { background:#fff; color:#777; } /* Page numbers of the printed edition (epub:type="pagebreak") */ .reader-content .epub-pagenum { display:inline-block; font-size:0.62em; line-height:1.3; font-weight:400; font-style:normal; letter-spacing:0; text-transform:none; vertical-align:0.35em; padding:0 0.35em; margin-right:0.4em; border:1px solid currentColor; border-radius:3px; opacity:0.45; user-select:none; } diff --git a/static/js/app/40-epub.js b/static/js/app/40-epub.js index 6c2f4bf..dea34dc 100644 --- a/static/js/app/40-epub.js +++ b/static/js/app/40-epub.js @@ -228,14 +228,41 @@ function _injectImageBlobs(html, chapterDir, imageMap) { // Calibre/zeno.org conversions) use neither and just wrap a plain link to // another chapter file's fragment in a — in either direction // (… or …). Recognize all of these -// rather than only the single hardcoded class name. +// rather than only the single hardcoded class name. Also role="doc-noteref", +// more note classes, and a bare note number as the link's text ("3", "[12]", "*"). function _looksLikeFootnoteLink(el, href) { if (!href.includes('#')) return false; const cls = (el.getAttribute('class') || '').toLowerCase(); if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true; + if (/\b(fnref|noteref|footnote-?ref|endnote-?ref)\b/.test(cls)) return true; const epubType = (el.getAttribute('epub:type') || '').toLowerCase(); if (epubType.includes('noteref')) return true; - return !!(el.closest('sup') || el.querySelector('sup')); + if ((el.getAttribute('role') || '').toLowerCase() === 'doc-noteref') return true; + if (el.closest('sup') || el.querySelector('sup')) return true; + return /^\s*[\[(]?\s*(\d{1,4}|[ivxlc]{1,6}|[a-z]|[*†‡§¶]+)\s*[\])]?\s*$/i.test(el.textContent || ''); +} + +// The number at the start of a note that links back to the text ("3 Endnote…") +// is a way back, not a reference to another note. +const _NOTE_BOX_SELECTOR = '.footnote, .endnote, aside, [role="doc-footnote"], [role="doc-endnote"], ' + + '[epub\\:type~="footnote"], [epub\\:type~="endnote"], [epub\\:type~="rearnote"]'; +function _isNoteBacklink(el) { + const role = (el.getAttribute('role') || '').toLowerCase(); + const type = (el.getAttribute('epub:type') || '').toLowerCase(); + if (role === 'doc-backlink' || /backlink/.test(type)) return true; + // Inside a note's box. Not the link itself: a reference can carry the class + // "footnote" too (, Suhrkamp/DocBook). + if (el.parentElement && el.parentElement.closest(_NOTE_BOX_SELECTOR)) return true; + const block = el.closest('p, li, div, dd'); + if (!block) return false; + // A note: the block is a link target itself, or holds an empty anchor that is + // (zeno.org / e-artnow:

1 Note…

). + const anchored = block.id || el.id || Array.prototype.some.call(block.querySelectorAll('[id]'), + x => x !== el && !(x.textContent || '').trim()); + if (!anchored) return false; + const text = (block.textContent || '').trim(); + const firstLink = Array.prototype.find.call(block.querySelectorAll('a'), a => (a.textContent || '').trim()); + return firstLink === el && text.startsWith((el.textContent || '').trim()); } // Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML @@ -272,15 +299,18 @@ function sanitizeEpubHtml(html) { if (href.startsWith('http://') || href.startsWith('https://')) { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); - } else if (_looksLikeFootnoteLink(el, href)) { - // The whole book is one concatenated DOM, so a fragment id resolves - // via getElementById regardless of which chapter file it originally - // pointed into — strip any "../Text/other.html" path prefix and keep - // just the id after the last '#'. + } else if (!(href.includes('#') && _isNoteBacklink(el)) && _looksLikeFootnoteLink(el, href)) { + // data-footnote-ref (the id) marks the link as a reference; the href + // as written (data-footnote-href) finds the note in the chapter file it + // points into (78-footnotes.js) — ids like "fn1" repeat across files. el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1); + el.dataset.footnoteHref = href; el.removeAttribute('href'); el.style.cursor = 'pointer'; } else { + // A link within the book (a cross-reference, a note's way back): kept + // for the apps, which follow it (diora-android native-ui.js). + if (href && !/^[a-z]+:/i.test(href)) el.dataset.internalHref = href; el.removeAttribute('href'); el.style.cursor = 'default'; } diff --git a/static/js/app/48-book-cache.js b/static/js/app/48-book-cache.js index 5b0781d..9d0750c 100644 --- a/static/js/app/48-book-cache.js +++ b/static/js/app/48-book-cache.js @@ -41,9 +41,9 @@ let _parseCodeSig = null; function _parsePreparationSig() { if (_parseCodeSig) return _parseCodeSig; - // window.__dioraPrepareSig: what an app layer adds to the preparation (the - // footnote detection in diora-android's native-ui.js) — a change there too. - const src = [parseEpub, sanitizeEpubHtml, _injectImageBlobs, _resolveImageBlob, _parseEpubToc] + // window.__dioraPrepareSig: what an app layer adds to the preparation — a + // change there too. + const src = [parseEpub, sanitizeEpubHtml, _looksLikeFootnoteLink, _isNoteBacklink, _injectImageBlobs, _resolveImageBlob, _parseEpubToc] .map(f => String(f)).join('\n') + (window.__dioraPrepareSig || ''); let h = 5381; for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0; diff --git a/static/js/app/63-pages.js b/static/js/app/63-pages.js new file mode 100644 index 0000000..d5547e0 --- /dev/null +++ b/static/js/app/63-pages.js @@ -0,0 +1,1018 @@ +'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 { + 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 = ''; + 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)); + }); +})(); diff --git a/static/js/app/64-reader-settings.js b/static/js/app/64-reader-settings.js index fe94dae..128280c 100644 --- a/static/js/app/64-reader-settings.js +++ b/static/js/app/64-reader-settings.js @@ -113,6 +113,21 @@ function _readerWeight() { return Math.round(w / step) * step; } +// "Scrollen | Seiten" and, in pages, whether the notes stand at the page's foot +// and whether two pages lie side by side. +// The apps' e-reader mode always reads in pages: no choice there. +function _readerModeButtons() { + if (!window.DioraPages || DioraPages.isForced()) return ''; + const pages = DioraPages.isOn(); + return ` + + + + `; +} + function toggleSettingsPanel() { const overlay = $('reader-overlay'); const contentEl = $('reader-content'); @@ -148,6 +163,7 @@ function toggleSettingsPanel() { + ${_readerModeButtons()} `; } else { panel.innerHTML = ` @@ -267,6 +283,27 @@ function toggleSettingsPanel() { saveReaderSettings(); }); }); + + // Scrolling or pages (63-pages.js): per device, for every book. + const syncMode = () => { + const pages = DioraPages.isOn(); + panel.querySelectorAll('[data-rs-mode]').forEach(b => b.classList.toggle('active', (b.dataset.rsMode === 'pages') === pages)); + const notes = panel.querySelector('#rs-page-notes'); + if (notes) { + notes.style.display = pages ? '' : 'none'; + notes.classList.toggle('active', DioraPages.pageNotes()); + } + const spread = panel.querySelector('#rs-page-spread'); + if (spread) { + spread.style.display = pages ? '' : 'none'; + spread.classList.toggle('active', DioraPages.spread()); + } + }; + panel.querySelectorAll('[data-rs-mode]').forEach(btn => { + btn.addEventListener('click', () => { DioraPages.setOn(btn.dataset.rsMode === 'pages'); syncMode(); }); + }); + panel.querySelector('#rs-page-notes')?.addEventListener('click', () => { DioraPages.togglePageNotes(); syncMode(); }); + panel.querySelector('#rs-page-spread')?.addEventListener('click', () => { DioraPages.setSpread(!DioraPages.spread()); syncMode(); }); } else { const zoomRange = panel.querySelector('#rs-zoom'); diff --git a/static/js/app/78-footnotes.js b/static/js/app/78-footnotes.js index 727efc2..5a74925 100644 --- a/static/js/app/78-footnotes.js +++ b/static/js/app/78-footnotes.js @@ -1,41 +1,131 @@ 'use strict'; // --------------------------------------------------------------------------- -// Footnote popover +// Footnotes: finding a reference's note, and the popover that shows it // --------------------------------------------------------------------------- +// The whole book is one concatenated DOM, and ids like "fn1" repeat per +// chapter file: a note is looked up in the chapter its link points into (the +// link's original href, kept in data-footnote-href by sanitizeEpubHtml), and +// only exactly the note is shown — not the parent around an id that sits on +// the note's own

/