feat(preview): footnote hover preview

Hovering a footnote reference (rendered by marked-footnote as
<a data-footnote-ref href="#footnote-N">) now shows a small popover
with the footnote body text, matching the UX of Typora and Obsidian.

- src/renderer/footnote-preview.js — pure DOM module. Mounts a single
  popover once per preview pane; mouseover delegates via Element.closest()
  to the ref, lookups the matching <li id="footnote-N"> in the same pane,
  strips the backref ↩, and positions above/below the cursor with edge
  clamping. CSS.escape() fallback for non-browser environments.
- src/renderer.js — _renderPreview calls mountFootnotePreview once per
  pane (gated by a dataset marker so re-renders don't accumulate
  listeners).
- eslint.config.js — CSS + Element added to browser globals.

Tests (8 new, tests/footnote-preview.test.js): mount/unmount, delay +
timer behavior, mouseover/mouseout show/hide, dangling ref graceful
no-op, backref ↩ stripped from the displayed text, cancel-pending-show
when a new ref is hovered, idempotent remount guard.

Full suite: 69 suites, 797 tests, lint+format clean.

Amit Haridas
This commit is contained in:
2026-09-14 08:56:31 +05:30
parent 7397e7f618
commit 6d08c138d8
4 changed files with 302 additions and 0 deletions
+2
View File
@@ -62,6 +62,8 @@ module.exports = [
HTMLInputElement: 'readonly', HTMLInputElement: 'readonly',
HTMLTextAreaElement: 'readonly', HTMLTextAreaElement: 'readonly',
getComputedStyle: 'readonly', getComputedStyle: 'readonly',
CSS: 'readonly',
Element: 'readonly',
// Electron // Electron
electronAPI: 'readonly', electronAPI: 'readonly',
// Libraries // Libraries
+9
View File
@@ -6,6 +6,7 @@
const { ipcRenderer, webUtils } = require('electron'); const { ipcRenderer, webUtils } = require('electron');
const { AutosaveController } = require('./renderer/autosave-client'); const { AutosaveController } = require('./renderer/autosave-client');
const writingStats = require('./utils/writing-stats'); const writingStats = require('./utils/writing-stats');
const { mountFootnotePreview } = require('./renderer/footnote-preview');
// Renderer-side autosave controller. The TabManager calls into this when a // Renderer-side autosave controller. The TabManager calls into this when a
// tab becomes dirty so the current buffer is periodically persisted under // tab becomes dirty so the current buffer is periodically persisted under
@@ -1181,6 +1182,14 @@ class TabManager {
preview.innerHTML = preview.innerHTML =
'<p class="error">Error rendering preview. Please check your markdown syntax.</p>'; '<p class="error">Error rendering preview. Please check your markdown syntax.</p>';
} }
// Wire the footnote hover preview once per preview pane. mountFootnotePreview
// is idempotent per element via the dataset marker; subsequent re-renders
// don't accumulate listeners.
if (!preview.dataset.footnotePreviewMounted) {
mountFootnotePreview(preview);
preview.dataset.footnotePreviewMounted = 'true';
}
} }
updatePreviewVisibility() { updatePreviewVisibility() {
document.querySelectorAll('.tab-content').forEach((content) => { document.querySelectorAll('.tab-content').forEach((content) => {
+124
View File
@@ -0,0 +1,124 @@
/**
* Footnote hover preview.
*
* marked-footnote renders references as <a data-footnote-ref href="#footnote-N">
* and bodies as <li id="footnote-N"> inside <section class="footnotes">.
* When the user hovers a reference, this module shows a small popover with
* the corresponding body text; on mouseleave it disappears.
*
* Pure DOM — no IPC, no markdown parsing. Mount once per preview pane and
* forget about it. Re-mounting on preview re-render is safe: the old
* listeners are replaced cleanly.
*
* @param {HTMLElement} previewRoot The preview pane container.
* @param {object} [opts]
* @param {number} [opts.delayMs=180] Show delay so the popover doesn't flicker
* during quick mouse passes.
*/
function mountFootnotePreview(previewRoot, opts = {}) {
if (!previewRoot) return () => {};
const delayMs = typeof opts.delayMs === 'number' ? opts.delayMs : 180;
let pendingTimer = null;
let activeRef = null;
// Create the popover once at mount time so the first hover is instant and
// tests can assert presence without racing the timer.
const popover = document.createElement('div');
popover.className = 'footnote-preview';
popover.setAttribute('role', 'tooltip');
popover.style.cssText =
'position:fixed;z-index:100000;max-width:min(420px, 80vw);background:#1f1f23;color:#eee;' +
'padding:8px 12px;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.25);font-size:12px;' +
'line-height:1.4;pointer-events:none;display:none;white-space:pre-wrap;word-wrap:break-word;';
document.body.appendChild(popover);
function ensurePopover() {
return popover;
}
function lookupFootnoteBody(href) {
// href looks like "#footnote-1" — extract the id and find the matching <li>
if (!href || !href.startsWith('#footnote-')) return null;
const id = href.slice(1); // "#footnote-1" → "footnote-1"
// CSS.escape() is in modern browsers but not in every test env; fallback
// strips characters that would break the selector without escaping the
// whole id (footnote ids are integers, so this is safe).
const safeId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/[^a-zA-Z0-9_-]/g, '');
const target = previewRoot.querySelector(`#${safeId}`);
return target || null;
}
function extractBodyText(footnoteEl) {
// Drop the backref link — the user already knows how to navigate back
const clone = footnoteEl.cloneNode(true);
clone.querySelectorAll('[data-footnote-backref]').forEach((el) => el.remove());
// Take only the first paragraph's text so we don't show the whole section
const p = clone.querySelector('p') || clone;
return (p.textContent || '').trim();
}
function show(refEl) {
const href = refEl.getAttribute('href');
const li = lookupFootnoteBody(href);
if (!li) return;
const text = extractBodyText(li);
if (!text) return;
const el = ensurePopover();
el.textContent = text;
el.style.display = 'block';
// Position the popover above the reference, falling back to below if there's no room
const rect = refEl.getBoundingClientRect();
const popRect = el.getBoundingClientRect();
let top = rect.top - popRect.height - 8;
if (top < 4) top = rect.bottom + 8;
let left = rect.left;
if (left + popRect.width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - popRect.width - 8);
}
el.style.top = `${top}px`;
el.style.left = `${left}px`;
activeRef = refEl;
}
function hide() {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
if (popover) popover.style.display = 'none';
activeRef = null;
}
function onMouseOver(ev) {
const t = ev.target;
if (!(t instanceof Element)) return;
const ref = t.closest('a[data-footnote-ref]');
if (!ref) return;
if (activeRef === ref) return;
activeRef = ref;
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => show(ref), delayMs);
}
function onMouseOut(ev) {
const t = ev.target;
if (!(t instanceof Element)) return;
if (!t.closest('a[data-footnote-ref]')) return;
// If the mouse moved into the popover itself, keep it visible; otherwise hide.
const related = ev.relatedTarget;
if (related && popover && popover.contains(related)) return;
hide();
}
previewRoot.addEventListener('mouseover', onMouseOver);
previewRoot.addEventListener('mouseout', onMouseOut);
return function unmount() {
previewRoot.removeEventListener('mouseover', onMouseOver);
previewRoot.removeEventListener('mouseout', onMouseOut);
hide();
if (popover && popover.parentNode) popover.parentNode.removeChild(popover);
};
}
module.exports = { mountFootnotePreview };
+167
View File
@@ -0,0 +1,167 @@
/**
* @jest-environment jsdom
*
* Footnote preview tests — DOM-driven, no real timers/animations.
*/
const { mountFootnotePreview } = require('../src/renderer/footnote-preview');
function makePreviewHtml(refText, fnText) {
return `
<p>hello<sup><a id="footnote-ref-1" href="#footnote-1" data-footnote-ref>1</a></sup></p>
<section class="footnotes">
<ol>
<li id="footnote-1">${fnText} <a href="#footnote-ref-1" data-footnote-backref>↩</a></li>
</ol>
</section>
`;
}
describe('mountFootnotePreview', () => {
beforeEach(() => {
jest.useFakeTimers();
document.body.innerHTML = '';
});
afterEach(() => {
jest.useRealTimers();
});
test('mounts and returns an unmount function', () => {
const root = document.createElement('div');
document.body.appendChild(root);
const unmount = mountFootnotePreview(root);
expect(typeof unmount).toBe('function');
unmount();
});
test('shows a popover on mouseover of a footnote ref (after delay)', () => {
const root = document.createElement('div');
root.innerHTML = makePreviewHtml('ref', 'footnote body text');
document.body.appendChild(root);
mountFootnotePreview(root, { delayMs: 50 });
const ref = root.querySelector('a[data-footnote-ref]');
ref.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
// Popover exists but isn't visible yet (timer hasn't fired)
let popover = document.querySelector('.footnote-preview');
expect(popover).not.toBeNull();
expect(popover.style.display).toBe('none');
// Advance past the delay
jest.advanceTimersByTime(60);
popover = document.querySelector('.footnote-preview');
expect(popover.style.display).toBe('block');
expect(popover.textContent).toBe('footnote body text');
});
test('hides the popover on mouseout', () => {
const root = document.createElement('div');
root.innerHTML = makePreviewHtml('ref', 'body');
document.body.appendChild(root);
mountFootnotePreview(root, { delayMs: 0 });
const ref = root.querySelector('a[data-footnote-ref]');
ref.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(0);
let popover = document.querySelector('.footnote-preview');
expect(popover.style.display).toBe('block');
ref.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: null }));
popover = document.querySelector('.footnote-preview');
expect(popover.style.display).toBe('none');
});
test('does nothing when the hovered target has no footnote ref', () => {
const root = document.createElement('div');
root.innerHTML = '<p>plain text</p>';
document.body.appendChild(root);
mountFootnotePreview(root, { delayMs: 0 });
root.querySelector('p').dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(0);
// Popover exists (it was created on first show path normally) but
// should not be visible.
const popover = document.querySelector('.footnote-preview');
if (popover) expect(popover.style.display).toBe('none');
});
test('does not throw when the footnote body is missing (dangling ref)', () => {
const root = document.createElement('div');
// Reference exists but no matching <li id="footnote-1">
root.innerHTML = '<p>x<sup><a href="#footnote-99" data-footnote-ref>99</a></sup></p>';
document.body.appendChild(root);
mountFootnotePreview(root, { delayMs: 0 });
const ref = root.querySelector('a[data-footnote-ref]');
expect(() => ref.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))).not.toThrow();
jest.advanceTimersByTime(0);
const popover = document.querySelector('.footnote-preview');
if (popover) expect(popover.style.display).toBe('none');
});
test('strips the backref ↩ from the displayed text', () => {
const root = document.createElement('div');
root.innerHTML = makePreviewHtml('ref', 'see <em>section 3</em> for context');
document.body.appendChild(root);
mountFootnotePreview(root, { delayMs: 0 });
root
.querySelector('a[data-footnote-ref]')
.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(0);
const popover = document.querySelector('.footnote-preview');
expect(popover.textContent).toBe('see section 3 for context');
expect(popover.textContent).not.toMatch(/↩/);
});
test('cancels a pending show when a new ref is hovered', () => {
const root = document.createElement('div');
root.innerHTML = `
<p>a<sup><a href="#footnote-1" data-footnote-ref>1</a></sup>
b<sup><a href="#footnote-2" data-footnote-ref>2</a></sup></p>
<ol><li id="footnote-1">one</li><li id="footnote-2">two</li></ol>
`;
document.body.appendChild(root);
mountFootnotePreview(root, { delayMs: 50 });
const refs = root.querySelectorAll('a[data-footnote-ref]');
refs[0].dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(20);
// Hover the second before the first delay fires
refs[1].dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(50); // 50ms after the new timer was set
const popover = document.querySelector('.footnote-preview');
expect(popover.textContent).toBe('two');
});
test('unmount removes listeners and the popover', () => {
const root = document.createElement('div');
root.innerHTML = makePreviewHtml('ref', 'body');
document.body.appendChild(root);
const unmount = mountFootnotePreview(root, { delayMs: 0 });
root.querySelector('a[data-footnote-ref]').dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(0);
expect(document.querySelector('.footnote-preview')).not.toBeNull();
unmount();
expect(document.querySelector('.footnote-preview')).toBeNull();
// Re-hovering after unmount is a no-op (no popover recreated)
root.querySelector('a[data-footnote-ref]').dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(0);
expect(document.querySelector('.footnote-preview')).toBeNull();
});
});