mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
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:
@@ -62,6 +62,8 @@ module.exports = [
|
||||
HTMLInputElement: 'readonly',
|
||||
HTMLTextAreaElement: 'readonly',
|
||||
getComputedStyle: 'readonly',
|
||||
CSS: 'readonly',
|
||||
Element: 'readonly',
|
||||
// Electron
|
||||
electronAPI: 'readonly',
|
||||
// Libraries
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const { ipcRenderer, webUtils } = require('electron');
|
||||
const { AutosaveController } = require('./renderer/autosave-client');
|
||||
const writingStats = require('./utils/writing-stats');
|
||||
const { mountFootnotePreview } = require('./renderer/footnote-preview');
|
||||
|
||||
// Renderer-side autosave controller. The TabManager calls into this when a
|
||||
// tab becomes dirty so the current buffer is periodically persisted under
|
||||
@@ -1181,6 +1182,14 @@ class TabManager {
|
||||
preview.innerHTML =
|
||||
'<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() {
|
||||
document.querySelectorAll('.tab-content').forEach((content) => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user