Books: footnote hover popover for EPUB reader
All checks were successful
Build and push Docker image / build (push) Successful in 20s
Test / test (push) Successful in 16s

Hovering over a footnote reference shows the footnote content in a
floating popover; moving the mouse away dismisses it. On mobile, a
tap toggles the popover with an ✕ close button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-05-29 21:04:47 +02:00
parent fe91000e7c
commit 159aa6f340
2 changed files with 91 additions and 2 deletions

View file

@ -1724,6 +1724,10 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
.hl-color-btn:hover { border-color:#fff; } .hl-color-btn:hover { border-color:#fff; }
.hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; } .hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; }
/* Footnote popover */
.footnote-popover { position:fixed; z-index:500; max-width:min(400px,90vw); max-height:220px; overflow-y:auto; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:10px 28px 10px 12px; box-shadow:0 4px 20px rgba(0,0,0,.6); font-size:0.88em; line-height:1.55; pointer-events:auto; }
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:var(--fg-muted,#888); cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
/* Highlight marks */ /* Highlight marks */
.reader-no-bold * { font-weight: normal !important; } .reader-no-bold * { font-weight: normal !important; }
.reader-content img { max-width: 100%; height: auto; display: block; } .reader-content img { max-width: 100%; height: auto; display: block; }

View file

@ -2619,6 +2619,10 @@ function sanitizeEpubHtml(html) {
if (href.startsWith('http://') || href.startsWith('https://')) { if (href.startsWith('http://') || href.startsWith('https://')) {
el.setAttribute('target', '_blank'); el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer'); el.setAttribute('rel', 'noopener noreferrer');
} else if (el.classList.contains('footnote') && href.startsWith('#')) {
el.dataset.footnoteRef = href.slice(1);
el.removeAttribute('href');
el.style.cursor = 'pointer';
} else { } else {
el.removeAttribute('href'); el.removeAttribute('href');
el.style.cursor = 'default'; el.style.cursor = 'default';
@ -3219,8 +3223,13 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
let _immBarsVisible = true; let _immBarsVisible = true;
function _immHandleTap(e) { function _immHandleTap(e) {
// Ignore taps on interactive elements (buttons, links, inputs, settings panel) // Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover)
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header')) return; if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover')) return;
// If a footnote popover is open, dismiss it instead of toggling bars
if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) {
dismissFootnotePopover();
return;
}
_immBarsVisible = !_immBarsVisible; _immBarsVisible = !_immBarsVisible;
document.body.classList.toggle('reader-immersive', !_immBarsVisible); document.body.classList.toggle('reader-immersive', !_immBarsVisible);
// Close settings panel when bars disappear // Close settings panel when bars disappear
@ -4726,6 +4735,57 @@ function dismissHighlightPopover() {
} }
} }
// ---------------------------------------------------------------------------
// Footnote popover
// ---------------------------------------------------------------------------
let currentFootnotePopover = null;
let _footnotePopoverTimer = null;
function showFootnotePopover(link) {
dismissFootnotePopover();
const targetEl = document.getElementById(link.dataset.footnoteRef);
if (!targetEl) return;
const container = targetEl.closest('.footnote') || targetEl.parentElement;
if (!container) return;
const clone = container.cloneNode(true);
clone.querySelector('sup')?.remove();
const popover = document.createElement('div');
popover.className = 'footnote-popover';
popover.innerHTML = clone.innerHTML;
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 = (rect.top - pr.height - 8) + 'px';
}
function dismissFootnotePopover() {
clearTimeout(_footnotePopoverTimer);
currentFootnotePopover?.remove();
currentFootnotePopover = null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Radio sidebar (compact player) // Radio sidebar (compact player)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -4852,4 +4912,29 @@ function openRadioSidebar() {
if (IS_AUTHENTICATED) { if (IS_AUTHENTICATED) {
setInterval(refreshAllFeeds, 60 * 60 * 1000); setInterval(refreshAllFeeds, 60 * 60 * 1000);
} }
// Footnote hover (desktop) + tap (mobile) via event delegation
const _readerContentEl = $('reader-content');
if (_readerContentEl) {
_readerContentEl.addEventListener('mouseover', e => {
const link = e.target.closest('[data-footnote-ref]');
if (!link) return;
clearTimeout(_footnotePopoverTimer);
_footnotePopoverTimer = setTimeout(() => showFootnotePopover(link), 120);
});
_readerContentEl.addEventListener('mouseout', e => {
if (!e.target.closest('[data-footnote-ref]')) return;
_footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250);
});
_readerContentEl.addEventListener('click', e => {
const link = e.target.closest('[data-footnote-ref]');
if (!link) return;
e.preventDefault();
if (currentFootnotePopover?._refLink === link) {
dismissFootnotePopover();
} else {
showFootnotePopover(link);
}
});
}
})(); })();