238 lines
8.8 KiB
JavaScript
238 lines
8.8 KiB
JavaScript
/* sidenotes.js — Collision avoidance and hover linking for sidenotes.
|
||
*
|
||
* HTML structure produced by Filters/Sidenotes.hs:
|
||
* <sup class="sidenote-ref" id="snref-N"><a href="#sn-N">N</a></sup>
|
||
* <span class="sidenote" id="sn-N">…</span>
|
||
*
|
||
* #markdownBody must be position: relative (layout.css guarantees this).
|
||
* The .sidenote spans are position: absolute; left: calc(100% + 2.5rem).
|
||
* Without an explicit top they sit at their "hypothetical static position",
|
||
* which is fine for isolated notes but causes overlaps when notes are close.
|
||
*
|
||
* This script:
|
||
* 1. Anchors each sidenote's top to its reference's offsetTop so the
|
||
* alignment is explicit and stable across all browsers.
|
||
* 2. Walks notes top-to-bottom and pushes each one below the previous
|
||
* if they would overlap (with a small gap between them).
|
||
* 3. Wires bidirectional hover highlights between each ref↔sidenote pair.
|
||
*/
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
const GAP = 12; /* minimum px gap between successive sidenotes */
|
||
|
||
function positionSidenotes() {
|
||
const body = document.getElementById('markdownBody');
|
||
if (!body) return;
|
||
|
||
const notes = Array.from(body.querySelectorAll('.sidenote'));
|
||
if (notes.length === 0) return;
|
||
|
||
/* Only run on wide viewports where sidenotes are visible. */
|
||
if (getComputedStyle(notes[0]).display === 'none') return;
|
||
|
||
let prevBottom = 0;
|
||
|
||
notes.forEach(function (sn) {
|
||
/* Reset any prior JS-applied top so offsetTop reads correctly. */
|
||
sn.style.top = '';
|
||
|
||
const id = sn.id; /* "sn-N" */
|
||
const refId = 'snref-' + id.slice(3); /* "snref-N" */
|
||
const ref = document.getElementById(refId);
|
||
|
||
/* Preferred top: align with the reference superscript. */
|
||
const preferred = ref ? ref.offsetTop : sn.offsetTop;
|
||
const top = Math.max(preferred, prevBottom + GAP);
|
||
|
||
sn.style.top = top + 'px';
|
||
prevBottom = top + sn.offsetHeight;
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Hover + click + keyboard wiring */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/* At most one sidenote is "focused" (click-sticky) at a time. */
|
||
let focusedPair = null;
|
||
|
||
function wireHover(ref, sn) {
|
||
/* Idempotent: skip pairs already wired so 'reinitSidenotes'
|
||
after a transclusion injection cannot stack listeners. */
|
||
if (ref.dataset.snBound === '1') return;
|
||
ref.dataset.snBound = '1';
|
||
sn.dataset.snBound = '1';
|
||
|
||
let hovering = false;
|
||
let focused = false;
|
||
|
||
function update() {
|
||
const active = focused || hovering;
|
||
ref.classList.toggle('is-active', active);
|
||
sn.classList.toggle('is-active', active);
|
||
}
|
||
|
||
function unfocus() { focused = false; update(); }
|
||
|
||
function toggleFocus() {
|
||
/* Sticky focus only makes sense on wide viewports where the
|
||
sidenote is actually visible. On narrow screens there's
|
||
nothing to pin. */
|
||
if (getComputedStyle(sn).display === 'none') return;
|
||
if (focused) {
|
||
focused = false;
|
||
focusedPair = null;
|
||
} else {
|
||
if (focusedPair) focusedPair.unfocus();
|
||
focused = true;
|
||
focusedPair = { ref: ref, sn: sn, unfocus: unfocus };
|
||
}
|
||
update();
|
||
}
|
||
|
||
ref.addEventListener('mouseenter', function () { hovering = true; update(); });
|
||
ref.addEventListener('mouseleave', function () { hovering = false; update(); });
|
||
sn.addEventListener('mouseenter', function () { hovering = true; update(); });
|
||
sn.addEventListener('mouseleave', function () { hovering = false; update(); });
|
||
|
||
/* Click on the superscript link: sticky focus on wide viewports;
|
||
mobile popup on narrow viewports (sidenote hidden). */
|
||
const link = ref.querySelector('a');
|
||
if (link) {
|
||
link.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
if (getComputedStyle(sn).display === 'none') {
|
||
openMobilePopup(sn);
|
||
return;
|
||
}
|
||
toggleFocus();
|
||
});
|
||
|
||
/* Keyboard activation: Enter follows the link by default (the
|
||
browser synthesizes a click), but Space does not. Both are
|
||
expected to toggle focus on a focus-activated element, so
|
||
we normalize: Enter/Space toggle, Escape clears if focused.
|
||
The <a href="#sn-N"> retains its native focusability so
|
||
Tab reaches it; we only intercept the key. */
|
||
link.addEventListener('keydown', function (e) {
|
||
if (e.key === ' ') {
|
||
e.preventDefault();
|
||
toggleFocus();
|
||
} else if (e.key === 'Escape' && focused) {
|
||
e.preventDefault();
|
||
focused = false;
|
||
focusedPair = null;
|
||
update();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Mobile popup — bottom sheet for narrow viewports where .sidenote */
|
||
/* is display:none. Created lazily on first use. */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
var mobileOverlay = null;
|
||
|
||
function ensureMobileOverlay() {
|
||
if (mobileOverlay) return;
|
||
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'sidenote-popup-overlay';
|
||
overlay.setAttribute('role', 'dialog');
|
||
overlay.setAttribute('aria-modal', 'true');
|
||
overlay.setAttribute('aria-label', 'Note');
|
||
|
||
var sheet = document.createElement('div');
|
||
sheet.className = 'sidenote-popup';
|
||
sheet.setAttribute('tabindex', '-1');
|
||
|
||
var closeBtn = document.createElement('button');
|
||
closeBtn.className = 'sidenote-popup-close';
|
||
closeBtn.setAttribute('aria-label', 'Close note');
|
||
closeBtn.textContent = '\u00d7'; /* × */
|
||
|
||
var body = document.createElement('div');
|
||
body.className = 'sidenote-popup-body';
|
||
|
||
sheet.appendChild(closeBtn);
|
||
sheet.appendChild(body);
|
||
overlay.appendChild(sheet);
|
||
document.body.appendChild(overlay);
|
||
|
||
overlay.addEventListener('click', function (e) {
|
||
if (!sheet.contains(e.target)) closeMobilePopup();
|
||
});
|
||
closeBtn.addEventListener('click', closeMobilePopup);
|
||
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && mobileOverlay &&
|
||
mobileOverlay.classList.contains('is-open')) {
|
||
closeMobilePopup();
|
||
}
|
||
});
|
||
|
||
mobileOverlay = overlay;
|
||
}
|
||
|
||
function openMobilePopup(sn) {
|
||
ensureMobileOverlay();
|
||
mobileOverlay.querySelector('.sidenote-popup-body').innerHTML = sn.innerHTML;
|
||
mobileOverlay.classList.add('is-open');
|
||
mobileOverlay.querySelector('.sidenote-popup').focus();
|
||
}
|
||
|
||
function closeMobilePopup() {
|
||
if (mobileOverlay) mobileOverlay.classList.remove('is-open');
|
||
}
|
||
|
||
/* Click anywhere outside a focused pair dismisses it. */
|
||
document.addEventListener('click', function (e) {
|
||
if (focusedPair &&
|
||
!focusedPair.ref.contains(e.target) &&
|
||
!focusedPair.sn.contains(e.target)) {
|
||
focusedPair.unfocus();
|
||
focusedPair = null;
|
||
}
|
||
});
|
||
|
||
/* Global Escape dismisses any sticky-focused sidenote, even if focus
|
||
has moved away from the ref link. */
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && focusedPair) {
|
||
focusedPair.unfocus();
|
||
focusedPair = null;
|
||
}
|
||
});
|
||
|
||
function wireAll(root) {
|
||
root.querySelectorAll('.sidenote').forEach(function (sn) {
|
||
const refId = 'snref-' + sn.id.slice(3);
|
||
const ref = document.getElementById(refId);
|
||
if (ref) wireHover(ref, sn);
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
const body = document.getElementById('markdownBody');
|
||
if (!body) return;
|
||
|
||
wireAll(body);
|
||
positionSidenotes();
|
||
}
|
||
|
||
/* Public re-init hook used by transclude.js after it injects new
|
||
content. wireAll is idempotent so calling this multiple times is
|
||
safe. */
|
||
window.reinitSidenotes = function (container) {
|
||
wireAll(container || document.getElementById('markdownBody') || document);
|
||
positionSidenotes();
|
||
};
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
window.addEventListener('resize', positionSidenotes);
|
||
}());
|