mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
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
167 lines
6.1 KiB
JavaScript
167 lines
6.1 KiB
JavaScript
/**
|
|
* @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();
|
|
});
|
|
}); |