Links and GET forms to wiki pages swap the next page into .shell instead of loading a new document: the server answers boosted requests and history restores with a partial (title, page actions out-of-band, shell content). The header stays, browsers without cross-document view transitions (Firefox) no longer flash between pages, and the swap runs as a same-document view transition. Files, the editor, internal endpoints and POST forms still navigate normally. Back/Forward refetch pages (no snapshots that could predate a move or delete); the tree rail keeps its scroll position and the content column returns to where it was. Mutation redirects swap their result in the same way. Page scripts that assumed a fresh document now also run after a swap (toc, contents, companion, scroll-sync, search actions) or register once (fitness). The e2e suite now also runs in Firefox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
5.9 KiB
JavaScript
141 lines
5.9 KiB
JavaScript
// Site-wide htmx policy. Loaded right after htmx itself.
|
|
(function () {
|
|
// htmx wires up swapped-in content (e.g. a dialog's hx-post form) in its
|
|
// settle step, by default 20ms after the swap: a click or Enter in that
|
|
// window would submit the form natively. No settle transitions are used,
|
|
// so settle in the same tick.
|
|
htmx.config.defaultSettleDelay = 0;
|
|
|
|
function boosting() {
|
|
return document.body.getAttribute('hx-boost') === 'true';
|
|
}
|
|
|
|
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
|
|
// target into the current history entry rather than pushing a new one, so
|
|
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
|
|
// navigation; elsewhere replaceState + reload (not location.replace: a
|
|
// target differing only by #fragment would otherwise not re-fetch).
|
|
htmx.location = {
|
|
set href(target) {
|
|
if (boosting()) {
|
|
if (window.closeModal) closeModal();
|
|
htmx.ajax('GET', target, {
|
|
target: '.shell',
|
|
swap: 'innerHTML transition:true',
|
|
headers: { 'HX-Boosted': 'true' },
|
|
replace: target
|
|
});
|
|
return;
|
|
}
|
|
window.history.replaceState(null, '', target);
|
|
window.location.reload();
|
|
},
|
|
reload: function () { window.location.reload(); }
|
|
};
|
|
|
|
// Failed requests answer with a plain-text reason; show it. A failed
|
|
// boosted navigation falls back to loading the page normally instead.
|
|
function fallBack(e) {
|
|
if (!e.detail.boosted) return false;
|
|
window.location.href = e.detail.pathInfo.finalRequestPath || e.detail.pathInfo.requestPath;
|
|
return true;
|
|
}
|
|
document.addEventListener('htmx:responseError', function (e) {
|
|
if (fallBack(e)) return;
|
|
var xhr = e.detail.xhr;
|
|
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
|
|
});
|
|
document.addEventListener('htmx:sendError', function (e) {
|
|
if (fallBack(e)) return;
|
|
alert('Network error');
|
|
});
|
|
|
|
// --- Boosted navigation ---------------------------------------------
|
|
// Outside the editor, links and GET forms to wiki pages load the next page
|
|
// into .shell instead of replacing the document: the server answers with
|
|
// the "partial" (title, page actions out-of-band, shell content). The
|
|
// header stays put, nothing flashes in browsers without cross-document
|
|
// view transitions, and the swap runs as a same-document view transition.
|
|
// Back/Forward refetch the partial (hx-history="false" on .shell: no
|
|
// snapshots that could show a page from before a move or delete).
|
|
htmx.config.scrollIntoViewOnBoost = false;
|
|
|
|
// Only wiki pages are boosted: same origin, a slash-terminated path, no
|
|
// query except a search (?q=). Files, the editor (?edit), internal
|
|
// endpoints (/_…) and POST forms navigate normally.
|
|
function isPageURL(url) {
|
|
if (url.origin !== location.origin) return false;
|
|
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
|
|
var ok = true;
|
|
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
|
|
return ok;
|
|
}
|
|
|
|
// htmx decides per element whether it is boosted, right after this event.
|
|
document.addEventListener('htmx:beforeProcessNode', function (e) {
|
|
var elt = e.target;
|
|
if (elt.hasAttribute('hx-boost')) return;
|
|
var page = false;
|
|
if (elt.tagName === 'A') {
|
|
page = elt.hasAttribute('href') && !elt.hasAttribute('download') && isPageURL(new URL(elt.href));
|
|
} else if (elt.tagName === 'FORM') {
|
|
page = (elt.getAttribute('method') || 'get').toLowerCase() === 'get' && isPageURL(new URL(elt.action));
|
|
} else {
|
|
return;
|
|
}
|
|
if (!page) elt.setAttribute('hx-boost', 'false');
|
|
});
|
|
|
|
function isShell(elt) {
|
|
return elt && elt.classList && elt.classList.contains('shell');
|
|
}
|
|
|
|
// Scroll positions: the tree rail keeps its position across navigation;
|
|
// the content column (.center) starts at the top, or where it was when
|
|
// coming back via Back/Forward.
|
|
var treeTop = 0;
|
|
var restoring = false;
|
|
var centerTops = {};
|
|
function pageKey() {
|
|
return location.pathname + location.search;
|
|
}
|
|
function rememberTree() {
|
|
var tree = document.querySelector('.tree-scroll');
|
|
treeTop = tree ? tree.scrollTop : 0;
|
|
}
|
|
document.addEventListener('scroll', function (e) {
|
|
var t = e.target;
|
|
if (t.classList && t.classList.contains('center')) centerTops[pageKey()] = t.scrollTop;
|
|
}, { capture: true, passive: true });
|
|
|
|
document.addEventListener('htmx:beforeSwap', function (e) {
|
|
if (e.detail.boosted) {
|
|
e.detail.target = document.querySelector('.shell');
|
|
e.detail.swapOverride = 'innerHTML transition:true';
|
|
}
|
|
if (isShell(e.detail.target)) rememberTree();
|
|
});
|
|
document.addEventListener('htmx:historyCacheMissLoad', function () {
|
|
restoring = true;
|
|
rememberTree();
|
|
});
|
|
|
|
// After a new page is in .shell: restore scroll positions, then tell page
|
|
// scripts (companion.js, …) to apply themselves to the new content.
|
|
document.addEventListener('htmx:afterSettle', function (e) {
|
|
if (!isShell(e.target)) return;
|
|
var tree = document.querySelector('.tree-scroll');
|
|
if (tree) {
|
|
tree.scrollTop = treeTop;
|
|
var active = tree.querySelector('.tree-row.is-active');
|
|
if (active) active.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
if (restoring) {
|
|
restoring = false;
|
|
var center = document.querySelector('.center');
|
|
if (center) center.scrollTop = centerTops[pageKey()] || 0;
|
|
}
|
|
document.dispatchEvent(new CustomEvent('page:swapped'));
|
|
});
|
|
})();
|