Lesen in Seiten auch im Browser und auf dem Desktop, mit Fußnoten unten auf der Seite und Doppelseite
Some checks failed
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 11m2s
Test / e2e (push) Failing after 1m29s

63-pages.js (DioraPages): die Seitenansicht des E-Reader-Modus der Android-Apps, jetzt für alle –
Seitenumbrüche vor angeschnittenen Zeilen, ein Kapitel auf einmal, Fußnoten am Seitenfuß wie im
gedruckten Buch, Blättern per Tippen/Klick am Rand, Wischen, Pfeil-/Bild-/Leertaste und Mausrad.
Neu: Doppelseite (zwei Seiten nebeneinander ab 900 px Breite). Einstellung „Scrollen | Seiten“
(+ „Fußnoten unten“, „Doppelseite“) im Schrift-&-Layout-Panel, je Gerät.

Fußnoten: Erkennung und Auflösung aus diora-android native-ui.js übernommen (40-epub.js,
78-footnotes.js) – die Notiz wird im richtigen Kapitel gesucht (IDs wiederholen sich je Datei),
auch das Popover zeigt jetzt genau die Notiz. Vorbereitete Bücher werden einmal neu aufbereitet.

Behoben gegenüber der App-Fassung: ein Verweis in der ersten Zeile einer Seite (hochgestellt)
zählte zur Vorseite, und ein Verweis, der samt Notiz nicht mehr passte, blieb ohne Notiz stehen –
jetzt wandert er mit ihr auf die nächste Seite. Schnelles Blättern löst „Weit gescrollt“ nicht mehr aus.

sw CACHE v157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-26 19:41:27 +01:00
parent 308eed150a
commit 6bb774adb9
8 changed files with 1258 additions and 41 deletions

View file

@ -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; }

View file

@ -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 <sup> — in either direction
// (<sup><a>…</a></sup> or <a><sup>…</sup></a>). 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 (<a class="footnote" href="#ftn.fn1">, 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: <p><a id="F258"></a><a href="…#N258">1</a> Note…</p>).
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';
}

View file

@ -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;

1018
static/js/app/63-pages.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -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 `
<button class="btn btn-sm ${pages ? '' : 'active'}" data-rs-mode="scroll">Scrollen</button>
<button class="btn btn-sm ${pages ? 'active' : ''}" data-rs-mode="pages">Seiten</button>
<button class="btn btn-sm ${DioraPages.pageNotes() ? 'active' : ''}" id="rs-page-notes" style="${pages ? '' : 'display:none'}"
title="Die Fußnoten stehen unten auf der Seite, auf der auf sie verwiesen wird">Fußnoten unten</button>
<button class="btn btn-sm ${DioraPages.spread() ? 'active' : ''}" id="rs-page-spread" style="${pages ? '' : 'display:none'}"
title="Zwei Seiten nebeneinander wie ein aufgeschlagenes Buch — ab einer Breite von 900 Pixeln">Doppelseite</button>`;
}
function toggleSettingsPanel() {
const overlay = $('reader-overlay');
const contentEl = $('reader-content');
@ -148,6 +163,7 @@ function toggleSettingsPanel() {
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button>
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button>
${_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');

View file

@ -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 <p>/<aside>/<li>, which can be a whole chapter. The same
// lookup serves the popover here, the notes at the foot of a page
// (63-pages.js) and the apps' note sheet (diora-android native-ui.js).
const _NOTE_INLINE = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL|CODE)$/;
function noteHrefOf(link) {
return link.dataset.footnoteHref || ('#' + (link.dataset.footnoteRef || ''));
}
function _noteChapterFor(fromEl, href) {
const chapter = fromEl.closest('[data-epub-src]');
const file = href.split('#')[0];
if (!file || !chapter) return chapter;
const src = chapter.dataset.epubSrc || '';
const dir = src.includes('/') ? src.slice(0, src.lastIndexOf('/') + 1) : '';
const target = resolveEpubPath(dir, file);
return document.querySelector(`#reader-content > [data-epub-src="${CSS.escape(target)}"]`) || chapter;
}
// The element `href` (as written in the book, relative to fromEl's chapter) points at.
function resolveNoteTarget(fromEl, href) {
const id = decodeURIComponent(href.slice(href.indexOf('#') + 1));
const scope = _noteChapterFor(fromEl, href);
const content = document.getElementById('reader-content');
let el = id && scope ? scope.querySelector(`[id="${CSS.escape(id)}"]`) : null;
if (!el && id && content) el = content.querySelector(`[id="${CSS.escape(id)}"]`);
if (!el && !id) el = scope; // link to a whole chapter file
return el;
}
// Converters (zeno.org / e-artnow, some Calibre output) park an empty
// <a id="F537"></a> right *before* the note's paragraph instead of inside it —
// or at the start of it (then the paragraph is the note, not what follows).
function _noteAfterEmptyAnchor(target) {
if ((target.textContent || '').trim() || target.querySelector('img')) return null;
const own = target.closest('p, li, dd, dt, td, blockquote');
const ownText = own && !own.hasAttribute('data-epub-src') ? (own.textContent || '').trim() : '';
if (ownText && !/^[\[(]?\d{1,4}[\])]?\.?$/.test(ownText)) return own;
if (ownText) {
// Only the number: the note (a poem, a long quote) runs on in the next
// paragraphs, up to the next note's anchor.
const box = document.createElement('div');
box.appendChild(own.cloneNode(true));
let len = 0;
for (let next = own.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
if (next.matches('h1, h2, h3, h4, h5, h6') || next.querySelector('[id]')) break;
if (!(next.textContent || '').trim()) continue;
box.appendChild(next.cloneNode(true));
len += next.textContent.length;
}
return box;
}
for (let el = target; el && !el.hasAttribute('data-epub-src'); el = el.parentElement) {
let next = el.nextElementSibling;
while (next && !(next.textContent || '').trim()) next = next.nextElementSibling;
if (next) return next.matches('h1, h2, h3, h4, h5, h6') ? null : next;
}
return null;
}
function _noteBox(target) {
const following = _noteAfterEmptyAnchor(target);
if (following) return following;
let box = target.closest(_NOTE_BOX_SELECTOR); // 40-epub.js
if (box && (box.hasAttribute('data-epub-src') || box.id === 'reader-content')) box = null;
if (!box) box = _NOTE_INLINE.test(target.tagName) ? (target.closest('p, li, dd, div, td, blockquote') || target) : target;
// Never a whole chapter or section: fall back to the paragraph around the target.
if (box.hasAttribute('data-epub-src') || (box.textContent || '').length > 6000) {
box = target.closest('p, li, dd') || target;
}
return box;
}
// A copy of the note at `target`, ready to show outside the book.
function noteContentFor(target, label) {
const clone = _noteBox(target).cloneNode(true);
clone.querySelectorAll('[role="doc-backlink"], [epub\\:type~="backlink"]').forEach(e => e.remove());
// A page of the printed edition that starts inside the note.
clone.querySelectorAll('.epub-pagenum, [role="doc-pagebreak"], [epub\\:type~="pagebreak"]').forEach(e => e.remove());
// Links back to the text ("↩", or the note's own number at its start).
clone.querySelectorAll('a').forEach(a => {
const t = (a.textContent || '').trim();
if (/^[↩↑⤴^]+$/.test(t)) a.remove();
});
// The note's own number at its very start ("3 Endnote…", "[4] …"): whatever
// shows the note labels it already.
const first = Array.prototype.find.call(clone.querySelectorAll('a, sup, span, b, strong'),
e => (e.textContent || '').trim());
const firstText = first ? (first.textContent || '').trim() : '';
const bare = t => t.replace(/[\[\]().\s]/g, '');
if (first && firstText && (clone.textContent || '').trim().startsWith(firstText) &&
(bare(firstText) === bare(label || '') || /^[\[(]?(\d{1,4}|[*†‡§]+)[\])]?\.?$/.test(firstText))) {
first.remove();
// …and the space that followed it.
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null, false);
const t = walker.nextNode();
if (t) t.nodeValue = t.nodeValue.replace(/^\s+/, '');
}
// No duplicate ids in the page (getElementById must keep finding the book's).
clone.removeAttribute('id');
clone.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
clone.querySelectorAll('mark.tts-current, mark.reader-search-match').forEach(m => m.replaceWith(...m.childNodes));
return clone;
}
// The note of a reference link, or null.
function noteForLink(link) {
const target = resolveNoteTarget(link, noteHrefOf(link));
return target ? noteContentFor(target, (link.textContent || '').trim()) : null;
}
let currentFootnotePopover = null;
let _footnotePopoverTimer = null;
function showFootnotePopover(link) {
dismissFootnotePopover();
const targetEl = document.getElementById(link.dataset.footnoteRef);
if (!targetEl) return;
// The note's paragraph, also when the id sits on an inline element in it
// (<p><span><a id="F400" …>143</a></span> Note…</p>).
const inline = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL)$/.test(targetEl.tagName);
const container = targetEl.closest('.footnote') ||
(inline && targetEl.closest('p, li, dd, blockquote')) || targetEl.parentElement;
if (!container) return;
const clone = container.cloneNode(true);
clone.querySelector('sup')?.remove();
clone.querySelectorAll('.epub-pagenum').forEach(e => e.remove());
// The note's own number at its start, a link back to the text ("1 Note…").
const first = Array.from(clone.querySelectorAll('a')).find(a => a.textContent.trim());
const num = first ? first.textContent.trim() : '';
if (/^[\[(]?\d{1,4}[\])]?\.?$/.test(num) && clone.textContent.trim().startsWith(num)) first.remove();
// Only the number: the note (a poem, a long quote) runs on in the next
// paragraphs, up to the next note's anchor.
if (!clone.textContent.trim()) {
let len = 0;
for (let next = container.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
if (/^H[1-6]$/.test(next.tagName) || next.querySelector('[id]')) break;
if (!next.textContent.trim()) continue;
clone.appendChild(next.cloneNode(true));
len += next.textContent.length;
}
}
const note = noteForLink(link);
if (!note) return;
const popover = document.createElement('div');
popover.className = 'footnote-popover';
@ -43,7 +133,7 @@ function showFootnotePopover(link) {
['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => {
if (_overlay?.classList.contains(cls)) popover.classList.add(cls);
});
popover.innerHTML = clone.innerHTML;
popover.appendChild(note);
const closeBtn = document.createElement('button');
closeBtn.className = 'footnote-popover-close';
@ -66,7 +156,7 @@ function showFootnotePopover(link) {
const pr = popover.getBoundingClientRect();
if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px';
if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px';
if (pr.bottom > window.innerHeight - 8) popover.style.top = Math.max(8, rect.top - pr.height - 8) + 'px';
}
function dismissFootnotePopover() {

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v156';
const CACHE = 'diora-v157';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',
@ -31,6 +31,7 @@ const SHELL = [
'/static/js/app/58-pdf.js',
'/static/js/app/60-tts.js',
'/static/js/app/62-reader.js',
'/static/js/app/63-pages.js',
'/static/js/app/64-reader-settings.js',
'/static/js/app/66-pdf-paginated.js',
'/static/js/app/68-bookmarks.js',

View file

@ -386,6 +386,7 @@
<script src="/static/js/app/58-pdf.js"></script>
<script src="/static/js/app/60-tts.js"></script>
<script src="/static/js/app/62-reader.js"></script>
<script src="/static/js/app/63-pages.js"></script>
<script src="/static/js/app/64-reader-settings.js"></script>
<script src="/static/js/app/66-pdf-paginated.js"></script>
<script src="/static/js/app/68-bookmarks.js"></script>