diff --git a/static/js/annotations.js b/static/js/annotations.js index 0baebae..9abac29 100644 --- a/static/js/annotations.js +++ b/static/js/annotations.js @@ -12,6 +12,8 @@ var STORAGE_KEY = 'site-annotations'; var tooltip = null; var tooltipTimer = null; + var tooltipPinned = false; /* keyboard-opened: blur must not dismiss */ + var tooltipMark = null; /* mark that opened the tooltip, for focus return */ /* ------------------------------------------------------------------ Storage @@ -148,6 +150,18 @@ tooltip.addEventListener('mouseenter', function () { clearTimeout(tooltipTimer); }); tooltip.addEventListener('mouseleave', function () { hideTooltip(false); }); + + /* Keyboard flow: Escape closes a pinned tooltip and returns focus + to its mark; tabbing out of the tooltip dismisses it. */ + tooltip.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + hideTooltip(true); + if (tooltipMark) tooltipMark.focus(); + } + }); + tooltip.addEventListener('focusout', function (e) { + if (!tooltip.contains(e.relatedTarget)) hideTooltip(false); + }); } /* Defer to the shared utility (loaded synchronously from @@ -159,6 +173,8 @@ function showTooltip(mark, ann) { clearTimeout(tooltipTimer); + tooltipPinned = false; + tooltipMark = mark; var note = ann.note || ''; var created = ann.created ? new Date(ann.created).toLocaleDateString() : ''; @@ -197,6 +213,7 @@ function hideTooltip(immediate) { clearTimeout(tooltipTimer); + tooltipPinned = false; if (immediate) { if (tooltip) tooltip.classList.remove('is-visible'); } else { @@ -212,6 +229,28 @@ showTooltip(mark, ann); }); mark.addEventListener('mouseleave', function () { hideTooltip(false); }); + + /* Keyboard: focus mirrors hover; Enter/Space pins the tooltip and + moves focus to its Delete button; Escape dismisses. */ + mark.setAttribute('tabindex', '0'); + mark.addEventListener('focus', function () { + clearTimeout(tooltipTimer); + showTooltip(mark, ann); + }); + mark.addEventListener('blur', function () { + if (!tooltipPinned) hideTooltip(false); + }); + mark.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + showTooltip(mark, ann); + tooltipPinned = true; + var del = tooltip.querySelector('.ann-tooltip-delete'); + if (del) del.focus(); + } else if (e.key === 'Escape') { + hideTooltip(true); + } + }); } /* ------------------------------------------------------------------ diff --git a/static/js/collapse.js b/static/js/collapse.js index a7f4630..43f6d3d 100644 --- a/static/js/collapse.js +++ b/static/js/collapse.js @@ -16,6 +16,11 @@ var store = window.lnUtils && window.lnUtils.safeStorage; function initHeading(heading) { + // Idempotence guard: reinitCollapse may be called more than once on + // the same container — never re-wrap a section or stack toggle + // buttons (matches the popups.js/sidenotes.js convention). + if (heading.dataset.collapseBound === '1') return; + var level = parseInt(heading.tagName[1], 10); var content = []; var node = heading.nextElementSibling; @@ -28,6 +33,7 @@ node = node.nextElementSibling; } if (!content.length) return; + heading.dataset.collapseBound = '1'; // Wrap collected nodes in a .section-body div. var wrapper = document.createElement('div'); diff --git a/static/js/copy.js b/static/js/copy.js index fb2f6e3..b9c9f26 100644 --- a/static/js/copy.js +++ b/static/js/copy.js @@ -17,9 +17,18 @@ btn.setAttribute('aria-label', 'Copy code to clipboard'); btn.addEventListener('click', function () { - var text = pre.querySelector('code') - ? pre.querySelector('code').innerText - : pre.innerText; + var code = pre.querySelector('code'); + var text; + if (code) { + text = code.innerText; + } else { + /* Code-less
: clone and strip the injected button so
+ its label is not copied along with the content. */
+ var clone = pre.cloneNode(true);
+ var cloneBtn = clone.querySelector('.copy-btn');
+ if (cloneBtn) cloneBtn.remove();
+ text = clone.innerText;
+ }
navigator.clipboard.writeText(text).then(function () {
btn.textContent = 'copied';
diff --git a/static/js/gallery.js b/static/js/gallery.js
index cd8aa64..5e53c0c 100644
--- a/static/js/gallery.js
+++ b/static/js/gallery.js
@@ -88,6 +88,21 @@
return exhibit.dataset.exhibitCaption || '';
}
+ /* Make an exhibit wrapper keyboard-operable: role=button, tabindex,
+ and Enter/Space sharing the click path. closeOverlay()'s focus
+ return relies on the wrapper being focusable. */
+ function bindActivation(el, activate) {
+ el.setAttribute('role', 'button');
+ el.setAttribute('tabindex', '0');
+ el.addEventListener('click', activate);
+ el.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ activate();
+ }
+ });
+ }
+
function discoverFocusableMath(markdownBody) {
markdownBody.querySelectorAll('.katex-display').forEach(function (katexEl) {
var source = getSource(katexEl);
@@ -118,8 +133,8 @@
};
focusables.push(entry);
- /* Click anywhere on the wrapper opens the overlay */
- wrapper.addEventListener('click', function () {
+ /* Click or Enter/Space anywhere on the wrapper opens the overlay */
+ bindActivation(wrapper, function () {
openOverlay(focusables.indexOf(entry));
});
});
@@ -151,7 +166,7 @@
};
focusables.push(entry);
- figEl.addEventListener('click', function () {
+ bindActivation(figEl, function () {
openOverlay(focusables.indexOf(entry));
});
});
diff --git a/static/js/score-reader.js b/static/js/score-reader.js
index d65557f..bb95cca 100644
--- a/static/js/score-reader.js
+++ b/static/js/score-reader.js
@@ -23,6 +23,9 @@
/* Read ?p= from the query string for deep linking. */
var qs = new URLSearchParams(window.location.search);
+ /* Keep the canonical URL clean on plain loads: only sync ?p= back to
+ the URL when one was already present or the user navigates. */
+ var syncUrl = qs.has('p');
var initPage = parseInt(qs.get('p'), 10);
if (!isNaN(initPage) && initPage >= 1 && initPage <= pageCount) {
currentPage = initPage;
@@ -47,7 +50,7 @@
/* Replace URL so the page is bookmarkable at the current position.
The back button still returns to the landing page. */
- history.replaceState(null, '', '?p=' + currentPage);
+ if (syncUrl) history.replaceState(null, '', '?p=' + currentPage);
/* Preload the adjacent pages for smooth turning. */
if (currentPage > 1) new Image().src = pages[currentPage - 2];
@@ -132,4 +135,5 @@
------------------------------------------------------------------ */
navigate(currentPage);
+ syncUrl = true; /* any later navigate() is a user action — sync from here on */
}());
diff --git a/static/js/search-filters.js b/static/js/search-filters.js
index 38e5625..83d03a7 100644
--- a/static/js/search-filters.js
+++ b/static/js/search-filters.js
@@ -273,7 +273,12 @@
if (!el) return;
el.addEventListener('input', function () {
var v = el.value.trim();
- state[field] = v !== '' ? Math.max(0, Math.min(100, parseInt(v, 10) || 0)) : null;
+ var n = parseInt(v, 10);
+ /* Non-numeric input deactivates the filter (null) rather
+ than coercing to an always-matching >= 0 threshold. */
+ state[field] = (v !== '' && !isNaN(n))
+ ? Math.max(0, Math.min(100, n))
+ : null;
loadMeta().then(applyFilters);
});
});
diff --git a/static/js/selection-popup.js b/static/js/selection-popup.js
index 5ab064b..e8c62ff 100644
--- a/static/js/selection-popup.js
+++ b/static/js/selection-popup.js
@@ -88,6 +88,15 @@
}
function onKeyUp(e) {
+ /* Typing capitals in the annotation picker's note input (or any
+ other editable field) releases Shift — don't re-summon the
+ toolbar over the UI the user is typing into. */
+ var t = e.target;
+ if (t && t.nodeType === Node.ELEMENT_NODE) {
+ if (popup.contains(t)) return;
+ if (picker && picker.contains(t)) return;
+ if (t.isContentEditable || t.closest('input, textarea')) return;
+ }
if (e.shiftKey || e.key === 'End' || e.key === 'Home') {
clearTimeout(showTimer);
showTimer = setTimeout(tryShow, SHOW_DELAY);
diff --git a/static/js/transclude.js b/static/js/transclude.js
index 8aa4882..6895dc7 100644
--- a/static/js/transclude.js
+++ b/static/js/transclude.js
@@ -108,11 +108,26 @@
}
}
- function loadTransclusion(el) {
+ /* Nested transclusion limits: ancestors carries the chain of srcs
+ * currently being expanded (cycle guard — a self-transcluding page
+ * must not loop), and MAX_DEPTH caps pathological nesting. */
+ var MAX_DEPTH = 3;
+
+ function loadTransclusion(el, depth, ancestors) {
+ depth = depth || 0;
+ ancestors = ancestors || [];
+
var src = el.dataset.src;
var section = el.dataset.section || null;
if (!src) return;
+ if (depth >= MAX_DEPTH || ancestors.indexOf(src) !== -1) {
+ el.classList.add('transclude--error');
+ el.textContent = '[transclusion omitted (cycle or depth limit): '
+ + src + (section ? '#' + section : '') + ']';
+ return;
+ }
+
el.classList.add('transclude--loading');
fetchPage(src)
@@ -138,6 +153,14 @@
el.classList.replace('transclude--loading', 'transclude--loaded');
el.appendChild(wrapper);
+ /* The fetched page may itself contain transclusion
+ placeholders — process them too, extending the
+ ancestor chain for cycle/depth guarding. */
+ var chain = ancestors.concat(src);
+ wrapper.querySelectorAll('div.transclude').forEach(function (nested) {
+ loadTransclusion(nested, depth + 1, chain);
+ });
+
reinitFragment(el);
})
.catch(function (err) {
@@ -147,6 +170,8 @@
}
document.addEventListener('DOMContentLoaded', function () {
- document.querySelectorAll('div.transclude').forEach(loadTransclusion);
+ document.querySelectorAll('div.transclude').forEach(function (el) {
+ loadTransclusion(el);
+ });
});
}());