From 159aa6f3400902bb513fe4d3fc8ddbf4732f6435 Mon Sep 17 00:00:00 2001 From: marwin Date: Fri, 29 May 2026 21:04:47 +0200 Subject: [PATCH] Books: footnote hover popover for EPUB reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/css/app.css | 4 +++ static/js/app.js | 89 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/static/css/app.css b/static/css/app.css index ae33a79..d4c3d86 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1724,6 +1724,10 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h) .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; } +/* 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 */ .reader-no-bold * { font-weight: normal !important; } .reader-content img { max-width: 100%; height: auto; display: block; } diff --git a/static/js/app.js b/static/js/app.js index 581cda1..2165f54 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2619,6 +2619,10 @@ function sanitizeEpubHtml(html) { if (href.startsWith('http://') || href.startsWith('https://')) { el.setAttribute('target', '_blank'); 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 { el.removeAttribute('href'); el.style.cursor = 'default'; @@ -3219,8 +3223,13 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { let _immBarsVisible = true; function _immHandleTap(e) { - // Ignore taps on interactive elements (buttons, links, inputs, settings panel) - if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header')) return; + // 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, .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; document.body.classList.toggle('reader-immersive', !_immBarsVisible); // 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) // --------------------------------------------------------------------------- @@ -4852,4 +4912,29 @@ function openRadioSidebar() { if (IS_AUTHENTICATED) { 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); + } + }); + } })();