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
+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();
});
});