From 6d08c138d8a4234ba46fde000c0ea4f82a914e26 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 14 Sep 2026 08:56:31 +0530 Subject: [PATCH] feat(preview): footnote hover preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering a footnote reference (rendered by marked-footnote as ) 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
  • 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 --- eslint.config.js | 2 + src/renderer.js | 9 ++ src/renderer/footnote-preview.js | 124 +++++++++++++++++++++++ tests/footnote-preview.test.js | 167 +++++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 src/renderer/footnote-preview.js create mode 100644 tests/footnote-preview.test.js diff --git a/eslint.config.js b/eslint.config.js index 5c51f90..ca7ed42 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -62,6 +62,8 @@ module.exports = [ HTMLInputElement: 'readonly', HTMLTextAreaElement: 'readonly', getComputedStyle: 'readonly', + CSS: 'readonly', + Element: 'readonly', // Electron electronAPI: 'readonly', // Libraries diff --git a/src/renderer.js b/src/renderer.js index ee98788..cc198b0 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -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 = '

    Error rendering preview. Please check your markdown syntax.

    '; } + + // 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) => { diff --git a/src/renderer/footnote-preview.js b/src/renderer/footnote-preview.js new file mode 100644 index 0000000..64d28d6 --- /dev/null +++ b/src/renderer/footnote-preview.js @@ -0,0 +1,124 @@ +/** + * Footnote hover preview. + * + * marked-footnote renders references as
    + * and bodies as
  • inside
    . + * 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
  • + 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 }; \ No newline at end of file diff --git a/tests/footnote-preview.test.js b/tests/footnote-preview.test.js new file mode 100644 index 0000000..1a5cc0c --- /dev/null +++ b/tests/footnote-preview.test.js @@ -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 ` +

    hello1

    +
    +
      +
    1. ${fnText} ↩
    2. +
    +
    + `; +} + +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 = '

    plain text

    '; + 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
  • + root.innerHTML = '

    x99

    '; + 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 section 3 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 = ` +

    a1 + b2

    +
    1. one
    2. two
    + `; + 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(); + }); +}); \ No newline at end of file