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',
|
HTMLInputElement: 'readonly',
|
||||||
HTMLTextAreaElement: 'readonly',
|
HTMLTextAreaElement: 'readonly',
|
||||||
getComputedStyle: 'readonly',
|
getComputedStyle: 'readonly',
|
||||||
|
CSS: 'readonly',
|
||||||
|
Element: 'readonly',
|
||||||
// Electron
|
// Electron
|
||||||
electronAPI: 'readonly',
|
electronAPI: 'readonly',
|
||||||
// Libraries
|
// Libraries
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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