diora-web/static/js/app/78-footnotes.js
mrwnslz 6bb774adb9
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
Lesen in Seiten auch im Browser und auf dem Desktop, mit Fußnoten unten auf der Seite und Doppelseite
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>
2026-10-26 19:41:27 +01:00

166 lines
7.5 KiB
JavaScript

'use strict';
// ---------------------------------------------------------------------------
// 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 note = noteForLink(link);
if (!note) return;
const popover = document.createElement('div');
popover.className = 'footnote-popover';
const _overlay = document.getElementById('reader-overlay');
['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => {
if (_overlay?.classList.contains(cls)) popover.classList.add(cls);
});
popover.appendChild(note);
const closeBtn = document.createElement('button');
closeBtn.className = 'footnote-popover-close';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', dismissFootnotePopover);
popover.appendChild(closeBtn);
const rect = link.getBoundingClientRect();
popover.style.top = (rect.bottom + 8) + 'px';
popover.style.left = Math.max(8, rect.left - 8) + 'px';
popover.addEventListener('mouseenter', () => clearTimeout(_footnotePopoverTimer));
popover.addEventListener('mouseleave', () => {
_footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250);
});
document.body.appendChild(popover);
currentFootnotePopover = popover;
currentFootnotePopover._refLink = 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 = Math.max(8, rect.top - pr.height - 8) + 'px';
}
function dismissFootnotePopover() {
clearTimeout(_footnotePopoverTimer);
currentFootnotePopover?.remove();
currentFootnotePopover = null;
}