Markdown preview: footnotes open in a click popover
Click a footnote marker in the Code-tab Markdown preview and its definition opens in a dismissible popover anchored to the marker — no more scrolling to the bottom of the document and back. Reuses the wikilink-disambiguation seam; no new package, nothing to install.
Shipped in #1514. The document preview already
renders GFM footnotes (marked-footnote) — the reference is a superscript
[n] that links to a definitions list at the very bottom. Reading one means
scrolling down, reading, and scrolling back to where you were. This change keeps
that bottom list but adds a click popover: tap the marker, read the
footnote in place, dismiss. It threads the same click seam the wikilink and
relative-link previews already use, so it added no dependency and no new
package. Verdict: ~half a day, low risk — the decisions below
took the simplest branch at every fork (dismiss-on-scroll, click/tap only,
useAnchoredPopover untouched).
What the reader sees
A footnote marker becomes a button. Click (or tap) the small [1] — the marker
carries a pointer cursor and a subtle hover highlight so it reads as clickable —
and its definition opens in a card anchored just under the marker, where your
eye already is. Click anywhere else, scroll the document, or click the marker
again, and it closes. The gesture is identical on a trackpad and on a phone —
there is no hover step, so nothing is stranded on touch.
The details that matter to a reader:
- The bottom “Footnotes” section stays exactly as it is. It is still the printable, copyable, screen-reader-navigable record of every note — and it is where the popover reads its content from. The popover is an additional way in, not a replacement.
- The popover keeps a “see all ↓” link. A small footer link scrolls to the matching entry in the bottom section and closes the popover — today’s scroll-to-definition, preserved as a deliberate secondary path for readers who want the whole list.
- Footnote bodies can be rich. A note may run to several paragraphs or carry
a link, a
[[wikilink]], inline code, or an image. The panel renders the full body and scrolls if it is tall; any link inside it still opens the right way, because it rides the preview’s existing resolvers (relative links open the file, wikilinks resolve vault-wide, images load through the repo route). - The back-reference
↩is unaffected. It still lives in the bottom section and still jumps back to the marker; it never opens a popover. - Click and tap only, for now. Opening is a pointer gesture: the interception is gated on a pointer activation, so pressing Enter on a focused marker keeps the old jump-to-definition scroll to the bottom section — the accessible record — rather than opening an unmanaged popover. A managed-focus popover with a screen-reader relationship is a deliberate follow-up (see §Implementation).
Architecture — a leaf that reuses the wikilink seam
This is a leaf, not a new @kolu/* package. Apply the electricity test: a
footnote popover hides only bounded logic — pair a marker to its definition
<li> by id, then place a panel beside it. It hides no hard volatility —
no transport, no reconnect, no persistence, no GPU-context loss — so it fails
all three extraction tests. Nothing to receptacle; nothing to install.
The boundary it must respect is the dependency arrow. @kolu/solid-markdown
depends only on solid-js, marked*, dompurify, shiki, yaml, and two
tiny leaf utils — it has zero knowledge of the client app, of Corvu, or of
Portal, and the arrow points outward (client → package). The package already
hands hard, host-specific decisions back across that arrow rather than owning
them: onNavigateRelative and onNavigateWikilink exist precisely because
resolving a link is host volatility. Footnote popovers split along the same
seam:
- Inside the package (
@kolu/solid-markdown): the delegatedonClickinbindInteractionsalready inspects every clicked anchor and already does theel.querySelector('#…')lookup that scrolls an in-page anchor into view. It grows one branch — recognise a footnote marker, find its definition node, and fire a newonFootnote(anchor, definition)callback. That callback is the package’s entire new surface. - Inside the client (
BrowseFileDispatcher): the host catchesonFootnoteand renders the popover withuseAnchoredPopover+<Portal>— the exact hook the wikilink-disambiguation menu already uses to anchor a panel to a clicked marker in this same preview. Overlay rendering — and any positioning library — stays on the client side of the arrow, where it already lives.
The verdict is contingent on the click model (§ the Architecture⇄Implementation
loop). Because we open on click, the existing useAnchoredPopover fits with
no new dependency — it is built for click-to-open, outside-click/Escape-dismiss
panels. Had we chosen a hover hovercard, we would have needed hover-intent
timing plus floating-ui-style autoUpdate scroll-tracking that
useAnchoredPopover does not have, which would have pulled a positioning
dependency (likely @corvu/tooltip) into play and pressured this boundary. The
“leaf, no new package, no new dep” verdict is the click decision; a different
trigger model would have produced a different shape.
Implementation — one PR, threading the existing seams
One PR, built test-first — the change is small because it is almost entirely
wiring into seams that already exist. It threads the same render → sanitize →
interact → host-overlay path that alerts (data-md-alert) and wikilinks
(data-md-wikilink) already proved, so there is nothing to stage across
releases: the package gains one callback and the client renders one panel, in
the same diff.
Package side — pin the contract, then one marker and one callback
DOMPurify is brutal on footnote markup. [email protected] (the version
^1.2.4 actually resolves to) emits a forward ref as
<sup><a id="footnote-ref-1" href="#footnote-1" data-footnote-ref aria-describedby="footnote-label">1</a></sup>
— note there is no class on it (the plan’s earlier “class="footnote-ref"”
was wrong); the distinctive marker is the bare data-footnote-ref attribute, and
the back-ref carries data-footnote-backref. The sanitizer strips data-footnote-*
and aria-describedby and namespaces ids/#-hrefs with an md- prefix, so what
survives is <sup><a id="md-footnote-ref-1" href="#md-footnote-1" data-md-footnote>1</a></sup>.
The whole feature stands on that markup, and the version floats — so pin the
contract the way this package already does: a node render test. @kolu/solid-markdown’s
vitest.config.ts deliberately keeps a Node-only environment (“the sanitize
layer is covered by the browser e2e suite, not here”), and data-md-wikilink /
data-md-alert survival is e2e-covered, not unit — adding happy-dom (absent
from the repo) just to unit-test sanitization would contradict that boundary. So
the red-first test lives in render.test.ts: feed footnote markdown through
renderMarkdownToRawHtml and assert rewriteFootnotes flags exactly the forward
refs with data-md-footnote (the back-ref untagged, every re-cite tagged) — a
marked-footnote bump that changed the marker then fails loudly. Marker→popover
survival rides the e2e. With that pinned, three small changes mint the marker and
route the click — the package’s only new surface:
render.ts—rewriteFootnotes(html), a sibling of the existingrewriteAlerts. It runs on the pre-sanitize string, wheremarked-footnote’s baredata-footnote-refmarker still exists (the back-ref carries the distinctdata-footnote-backref), and a single regex stamps an allowlist-safedata-md-footnoteflag on the forward-ref anchor — a bare flag likedata-md-rel, carrying no value; the definition is found from the anchor’s ownhref(the back-ref↩is deliberately not tagged, since itsdata-footnote-backrefnever matches). This is the same move alerts make, and it sidesteps the two traps of detecting the ref by structure after sanitization: a back-ref↩link also points at#md-footnote-…, and a heading literally titled “Footnote 1” would mint the sameid. An explicit parser-minted marker is unambiguous.sanitize.ts— adddata-md-footnotetoDOCUMENT_ATTR(one line, besidedata-md-wikilinkatsanitize.ts:197; security-reviewed, since it widens the allowlist).Markdown.tsx—bindInteractionsgrows a footnote branch inonClick, placed before the generic#-anchor scroll branch (Markdown.tsx:98):target.closest('[data-md-footnote]')→ resolve the definition via the sameel.querySelector('#' + CSS.escape(…))the scroll branch uses →preventDefault→props.onFootnote(anchor, definition). The back-ref↩keeps the existing scroll-up behaviour. Add theonFootnote(anchor, definition)prop (mirroronNavigateWikilink,Markdown.tsx:153).markdown.css— the discoverability affordance: give.kolu-md [data-md-footnote]acursor: pointerand a subtle hover highlight (acolor-mixtint, matching the stylesheet’scurrentColoridiom) so the marker reads as clickable. No tip is registered (the marker styling carries it).
Client side — render the popover (reuse useAnchoredPopover)
solid-fileview/src/renderers/markdown.tsx— threadonFootnotethroughMarkdownRenderer’s props, mirroringonNavigateWikilink(markdown.tsx:29). Nothing else changes here.BrowseFileDispatcher.tsx— owns the popover, because it already defines the preview’s resolvers (onNavigateRelative/onNavigateWikilink/resolveImageSrc) and can hand the same ones to the popover’s inner links. AcreateSignal<{ anchor, definition } | null>()set inonFootnote(mirrorwikiMenu,BrowseFileDispatcher.tsx:147), and aFootnotePopoverrendered via<Portal>+useAnchoredPopover({ triggerRef: () => fn()?.anchor, open: () => fn() != null, onDismiss: () => setFn(null), anchor: "bottom-start", flip: true, panelMinWidth: 320 })— the same recipe as theOptionMenudisambiguation list (BrowseFileDispatcher.tsx:462). Concrete decisions baked in:- Toggle. If
onFootnotefires for the marker that is already open, close instead of reopening (compareanchoridentity). - Dismiss on scroll. While open, attach a capture-phase
scrolllistener ondocument(createEventListener(() => fn() ? document : undefined, "scroll", () => setFn(null), { capture: true })) so a scroll in either nestedoverflow:autoancestor closes it.useAnchoredPopoveris used unchanged. - Right-edge fit. Passing
panelMinWidth: 320lets the hook’s existing left-clamp keep a marker-near-the-edge panel on-screen (it has no horizontal shift). - Size. Panel
width: min(360px, calc(100vw - 2rem)),max-height: min(50vh, 22rem),overflow: auto; reusesurface()for chrome (asOptionMenudoes). - Content.
definition.cloneNode(true), then on the clone remove three things: everyid— the root<li>’s and every descendant’s (a rich note body can hold a heading or raw allowed HTML the sanitizer minted anmd-…id on); the live nodes keep theirs, so a portalled clone that kept a duplicate id would weaken the sanitizer’s id-namespacing and make an in-page#md-…lookup ambiguous. Every back-ref↩(keyed on the renderer’s owndata-md-footnote-backrefflag, not marked-footnote’s-ref-id scheme — so amarked-footnotebump fails loudly inrender.test.tsrather than leaving stray↩links), since a re-cited footnote has several. And thedata-md-footnoteflag from any nested ref markers (so they are inert in the popover — see the nested-footnote risk). Re-inject the clone’sinnerHTML. Images need no handling —resolveImageSrcalready ran on this node when the document was sanitized, so the clone carries resolvedsrcs. - Inner links. Bind one click listener on the popover panel that routes the
click-time links only:
a[data-md-rel]→ the host’sonNavigateRelative,a[data-md-wikilink]→onNavigateWikilink(the same handlersBrowseFileDispatcheralready passes to the preview). External links keep thetarget="_blank"the sanitizer stamped, so they need no handler. This is the ~10-line relative/wikilink slice ofbindInteractions; if duplication grates, export that slice from@kolu/solid-markdownand call it here. - “See all ↓”. A footer link that calls
fn().definition.scrollIntoView({ behavior: "smooth", block: "start" })on the live<li>(not the clone — the clone’sidis gone), thensetFn(null).
- Toggle. If
- Out of scope (tracked follow-up): a managed-focus popover with a
screen-reader relationship. Opening is pointer-only by enforcement — the
footnote branch is gated on
click.detail > 0, so a keyboard activation (Enter/Space) falls through to the in-page jump-to-definition scroll and lands on the bottom section, which remains the accessible record. A follow-up can add Enter/Space-to-open on the focused marker plus anaria-detailslink — note it in the PR description so it isn’t mistaken for an oversight.
Sharp edges, and how each is handled
Each was a real trap; none is left open.
useAnchoredPopoverdoesn’t track scroll — and that is fine, because we dismiss on scroll. The hook repositions only on open/trigger-change (useAnchoredPopover.ts:135), and the preview sits inside nestedoverflow:autocontainers, so a panel anchored withposition:fixedwould drift off the marker as the reader scrolls. Decided: close the popover on scroll (the capture-phase listener above), so the hook is used unchanged and there is no drift — reopen with a click.flipis vertical-only (no horizontal shift), so a marker near the right edge could overflow; thepanelMinWidth: 320left-clamp handles it. (Follow-the-marker re-anchoring was considered and declined — it is the only thing that would have touched the shared hook.)- The comment overlay — handled by the
Portalroute. The preview is wrapped inCommentTextSurfacewith aMutationObserverover its subtree, so an in-flow popup would trip it. We mount the panel through<Portal>todocument.body(whatuseAnchoredPopoveralready expects), so it lives outside the watched subtree and never perturbs comment anchoring. - Touch + the drawer. Click-to-open already covers touch (no hover step). On
mobile the right panel can mount inside a
@corvu/drawer(modal: truesetsbody { pointer-events: none });useAnchoredPopoveralready re-enablespointer-events: autoon its panel (useAnchoredPopover.ts:154), so the popover stays tappable there. - Nested footnotes — made inert by the clone. A footnote body can cite another
footnote, so a ref marker can appear inside a popover. The clone strips
data-md-footnotefrom those nested markers (Content step above) and the popover’s click listener routes onlydata-md-rel/data-md-wikilink— so a nested marker is a plain, inert superscript. No popover stacking, no recursion. - An in-page anchor inside the clone never escapes the preview. The popover
binds the package’s own click dispatcher to the cloned
<li>, whose in-page targets (a nested ref’s#md-footnote-…, a#headingoutside the note) live in the bottom list, not the clone — so the in-clone lookup misses. The in-page-anchor branch thereforepreventDefaults unconditionally (before the target lookup), so a miss can’t fall through to a real browser hash navigation that would change the app URL or scroll outside the preview from inside the popover. It still only scrolls when the target resolves. - Raw HTML can’t spoof the marker.
data-md-footnoteis allowlisted, so a README’s raw inline HTML could pre-seed it to opt an arbitrary anchor into the host callback. Like the wikilink guard, the renderer strips any document-authoreddata-md-footnote*token before re-minting it only beside marked-footnote’s owndata-footnote-ref/data-footnote-backref— so the marker only ever rides the parser’s own refs (render.test.tscovers the spoof; the e2e covers the popover behaviour). - Content re-renders don’t drop the handler. The new branch lives in the
delegated listener
bindInteractionsbinds on the stable.kolu-mdroot, not on per-marker nodes. Editing the file re-runs thehtml()memo and swaps the element’sinnerHTMLin place — the root element (and itsref-bound listener) is not remounted — so the footnote branch keeps firing with no re-binding, exactly as the existing link/scroll branches do today.