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
+9
View File
@@ -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) => {