Boost page navigation with htmx
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>
This commit is contained in:
+11
-4
@@ -200,13 +200,20 @@
|
|||||||
// leaves the status icon and reveal buttons invisible on mobile.
|
// leaves the status icon and reveal buttons invisible on mobile.
|
||||||
if (isMobileDevice()) return;
|
if (isMobileDevice()) return;
|
||||||
probeStatus().then(function () {
|
probeStatus().then(function () {
|
||||||
updateFooterIcon();
|
wireFileLinks(); // delegated to document: once
|
||||||
wireFileLinks();
|
applyToPage();
|
||||||
wireRevealButton();
|
// Boosted navigation swaps in a new page without reloading this
|
||||||
wireFileRevealButtons();
|
// script: re-apply to its footer icon and reveal buttons.
|
||||||
|
document.addEventListener('page:swapped', applyToPage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyToPage() {
|
||||||
|
updateFooterIcon();
|
||||||
|
wireRevealButton();
|
||||||
|
wireFileRevealButtons();
|
||||||
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
// reloads the page with that chart's query parameter updated. Plain GET
|
// reloads the page with that chart's query parameter updated. Plain GET
|
||||||
// navigation — each view is distinct and bookmarkable, so no
|
// navigation — each view is distinct and bookmarkable, so no
|
||||||
// postReplace/history handling is needed.
|
// postReplace/history handling is needed.
|
||||||
|
// Boosted navigation re-runs this script on every visit; the listeners
|
||||||
|
// are delegated to document, so register them once.
|
||||||
|
if (!window.fitnessWired) {
|
||||||
|
window.fitnessWired = true;
|
||||||
|
|
||||||
document.addEventListener('change', function (e) {
|
document.addEventListener('change', function (e) {
|
||||||
var url = new URL(window.location.href);
|
var url = new URL(window.location.href);
|
||||||
var sel = e.target.closest('[data-fitness-range]');
|
var sel = e.target.closest('[data-fitness-range]');
|
||||||
@@ -72,3 +77,4 @@ document.addEventListener('pointermove', function (e) {
|
|||||||
tip.style.left = left + 'px';
|
tip.style.left = left + 'px';
|
||||||
tip.style.top = top + 'px';
|
tip.style.top = top + 'px';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+115
-5
@@ -6,25 +6,135 @@
|
|||||||
// so settle in the same tick.
|
// so settle in the same tick.
|
||||||
htmx.config.defaultSettleDelay = 0;
|
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
|
// 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
|
// target into the current history entry rather than pushing a new one, so
|
||||||
// Back skips the stale pre-mutation page. replaceState + reload rather than
|
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
|
||||||
// location.replace: a target differing only by #fragment would otherwise
|
// navigation; elsewhere replaceState + reload (not location.replace: a
|
||||||
// not re-fetch.
|
// target differing only by #fragment would otherwise not re-fetch).
|
||||||
htmx.location = {
|
htmx.location = {
|
||||||
set href(target) {
|
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.history.replaceState(null, '', target);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
},
|
},
|
||||||
reload: function () { window.location.reload(); }
|
reload: function () { window.location.reload(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Failed requests answer with a plain-text reason; show it.
|
// 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) {
|
document.addEventListener('htmx:responseError', function (e) {
|
||||||
|
if (fallBack(e)) return;
|
||||||
var xhr = e.detail.xhr;
|
var xhr = e.detail.xhr;
|
||||||
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
|
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
|
||||||
});
|
});
|
||||||
document.addEventListener('htmx:sendError', function () {
|
document.addEventListener('htmx:sendError', function (e) {
|
||||||
|
if (fallBack(e)) return;
|
||||||
alert('Network error');
|
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'));
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+61
-39
@@ -17,10 +17,14 @@
|
|||||||
<script src="{{asset "search-suggest.js"}}" defer></script>
|
<script src="{{asset "search-suggest.js"}}" defer></script>
|
||||||
<script src="{{asset "tree-picker.js"}}"></script>
|
<script src="{{asset "tree-picker.js"}}"></script>
|
||||||
<script src="{{asset "companion.js"}}" defer></script>
|
<script src="{{asset "companion.js"}}" defer></script>
|
||||||
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>{{end}}
|
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
|
||||||
{{block "headScripts" .}}{{end}}
|
<script src="{{asset "page/actions.js"}}"></script>
|
||||||
|
<script src="{{asset "search/actions.js"}}"></script>{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
{{/* Outside the editor, links to pages are boosted: htmx swaps .shell
|
||||||
|
(plus title and page actions, see "partial") instead of loading a new
|
||||||
|
document. hx.js decides which links count as pages. */}}
|
||||||
|
<body{{if not .EditMode}} hx-boost="true"{{end}}>
|
||||||
<header>
|
<header>
|
||||||
<nav class="breadcrumb row">
|
<nav class="breadcrumb row">
|
||||||
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
||||||
@@ -36,49 +40,67 @@
|
|||||||
<span class="dropdown actions">
|
<span class="dropdown actions">
|
||||||
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
|
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
|
||||||
<div class="dropdown-menu align-right actions-menu">
|
<div class="dropdown-menu align-right actions-menu">
|
||||||
<div class="actions-special">
|
{{template "page-actions" .}}
|
||||||
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
|
|
||||||
</div>
|
|
||||||
{{template "headerActions" . }}
|
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="shell">
|
<div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div>
|
||||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
|
||||||
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
|
|
||||||
<script>
|
|
||||||
// Keep the rail's scroll position across page loads (before
|
|
||||||
// first paint, so it doesn't jump), then reveal the active row.
|
|
||||||
(function () {
|
|
||||||
var el = document.currentScript.previousElementSibling;
|
|
||||||
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
|
|
||||||
var active = el.querySelector(".tree-row.is-active");
|
|
||||||
if (active) active.scrollIntoView({ block: "nearest" });
|
|
||||||
addEventListener("pagehide", function () {
|
|
||||||
try { sessionStorage.setItem("tree-scroll", el.scrollTop); } catch (e) {}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</aside>{{end}}
|
|
||||||
<div class="center">
|
|
||||||
<main>
|
|
||||||
{{block "content" .}}{{end}}
|
|
||||||
</main>
|
|
||||||
<footer>
|
|
||||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
|
||||||
{{block "footerExtras" .}}{{end}}
|
|
||||||
<span class="dropdown companion-status" data-companion-status hidden>
|
|
||||||
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
|
||||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
|
||||||
</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
|
||||||
</div>
|
|
||||||
{{block "extras" .}}{{end}}
|
{{block "extras" .}}{{end}}
|
||||||
<div id="modal-host"></div>
|
<div id="modal-host"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* The page-specific header actions; the partial swaps them out-of-band
|
||||||
|
(the attribute is inert in a full page). */}}
|
||||||
|
{{define "page-actions"}}<div id="page-actions" class="page-actions" hx-swap-oob="true">
|
||||||
|
<div class="actions-special">
|
||||||
|
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
|
||||||
|
</div>
|
||||||
|
{{template "headerActions" .}}
|
||||||
|
</div>{{end}}
|
||||||
|
|
||||||
|
{{define "shell"}}
|
||||||
|
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||||
|
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
|
||||||
|
<script>
|
||||||
|
// Full page loads: keep the rail's scroll position (before first
|
||||||
|
// paint, so it doesn't jump), then reveal the active row. Boosted
|
||||||
|
// navigation keeps it itself (hx.js); this script re-runs then and
|
||||||
|
// bails out.
|
||||||
|
(function () {
|
||||||
|
if (document.readyState !== "loading") return;
|
||||||
|
var el = document.currentScript.previousElementSibling;
|
||||||
|
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
|
||||||
|
var active = el.querySelector(".tree-row.is-active");
|
||||||
|
if (active) active.scrollIntoView({ block: "nearest" });
|
||||||
|
addEventListener("pagehide", function () {
|
||||||
|
var cur = document.querySelector(".tree-scroll");
|
||||||
|
try { sessionStorage.setItem("tree-scroll", cur ? cur.scrollTop : 0); } catch (e) {}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</aside>{{end}}
|
||||||
|
<div class="center">
|
||||||
|
<main>
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||||
|
{{block "footerExtras" .}}{{end}}
|
||||||
|
<span class="dropdown companion-status" data-companion-status hidden>
|
||||||
|
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
||||||
|
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Response to boosted navigation and history restores (hx.js swaps it into
|
||||||
|
.shell): the title, the page actions out-of-band, and the shell's content. */}}
|
||||||
|
{{define "partial"}}<title>{{.Title}}</title>
|
||||||
|
{{template "page-actions" .}}
|
||||||
|
{{template "shell" .}}{{end}}
|
||||||
|
|||||||
+6
-2
@@ -181,13 +181,17 @@
|
|||||||
// is open (e.g. the merge step replacing the move dialog); focus returns
|
// is open (e.g. the merge step replacing the move dialog); focus returns
|
||||||
// to where it was before the first one opened.
|
// to where it was before the first one opened.
|
||||||
var hostPrevFocus = null;
|
var hostPrevFocus = null;
|
||||||
|
// History restores swap without event details; they never target the host.
|
||||||
|
function isHost(e) {
|
||||||
|
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
|
||||||
|
}
|
||||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
document.addEventListener('htmx:beforeSwap', function (e) {
|
||||||
if (e.detail.target.id !== 'modal-host') return;
|
if (!isHost(e)) return;
|
||||||
hostPrevFocus = current ? detach(true) : document.activeElement;
|
hostPrevFocus = current ? detach(true) : document.activeElement;
|
||||||
});
|
});
|
||||||
document.addEventListener('htmx:afterSwap', function (e) {
|
document.addEventListener('htmx:afterSwap', function (e) {
|
||||||
|
if (!isHost(e)) return;
|
||||||
var host = e.detail.target;
|
var host = e.detail.target;
|
||||||
if (host.id !== 'modal-host') return;
|
|
||||||
var backdrop = host.querySelector('.modal-backdrop');
|
var backdrop = host.querySelector('.modal-backdrop');
|
||||||
if (!backdrop) return;
|
if (!backdrop) return;
|
||||||
show(backdrop, host, hostPrevFocus);
|
show(backdrop, host, hostPrevFocus);
|
||||||
|
|||||||
+20
-14
@@ -1,15 +1,21 @@
|
|||||||
document.addEventListener("DOMContentLoaded", function () {
|
// Runs on DOMContentLoaded for a full page load, and right away when this
|
||||||
var aside = document.querySelector("aside.sidebar");
|
// script arrives with a boosted navigation (the document is loaded then).
|
||||||
var specials = document.querySelector(".actions-special");
|
(function () {
|
||||||
if (!aside || !aside.children.length || !specials) return;
|
function run() {
|
||||||
|
var aside = document.querySelector("aside.sidebar");
|
||||||
|
var specials = document.querySelector(".actions-special");
|
||||||
|
if (!aside || !aside.children.length || !specials) return;
|
||||||
|
|
||||||
var item = document.createElement("button");
|
var item = document.createElement("button");
|
||||||
item.type = "button";
|
item.type = "button";
|
||||||
item.className = "btn btn-block";
|
item.className = "btn btn-block";
|
||||||
item.title = "Contents";
|
item.title = "Contents";
|
||||||
item.textContent = "CONTENTS";
|
item.textContent = "CONTENTS";
|
||||||
item.addEventListener("click", function () {
|
item.addEventListener("click", function () {
|
||||||
if (typeof openOverlay === "function") openOverlay(aside);
|
if (typeof openOverlay === "function") openOverlay(aside);
|
||||||
});
|
});
|
||||||
specials.appendChild(item);
|
specials.appendChild(item);
|
||||||
});
|
}
|
||||||
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||||
|
else run();
|
||||||
|
})();
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
{{define "headScripts"}}<script src="{{asset "page/actions.js"}}"></script>{{end}}
|
|
||||||
|
|
||||||
{{define "headerActions"}}{{if .CanEdit}}
|
{{define "headerActions"}}{{if .CanEdit}}
|
||||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
|
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
|
||||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
|
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
|
||||||
|
|||||||
+33
-27
@@ -1,32 +1,38 @@
|
|||||||
document.addEventListener("DOMContentLoaded", function () {
|
// Runs on DOMContentLoaded for a full page load, and right away when this
|
||||||
var content = document.querySelector("main");
|
// script arrives with a boosted navigation (the document is loaded then).
|
||||||
if (!content) return;
|
(function () {
|
||||||
|
function run() {
|
||||||
|
var content = document.querySelector("main");
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
var headings = content.querySelectorAll("h2, h3, h4");
|
var headings = content.querySelectorAll("h2, h3, h4");
|
||||||
if (headings.length < 2) return;
|
if (headings.length < 2) return;
|
||||||
|
|
||||||
var nav = document.createElement("nav");
|
var nav = document.createElement("nav");
|
||||||
nav.className = "toc panel";
|
nav.className = "toc panel";
|
||||||
|
|
||||||
var header = document.createElement("div");
|
var header = document.createElement("div");
|
||||||
header.className = "panel-header";
|
header.className = "panel-header";
|
||||||
header.textContent = "Contents";
|
header.textContent = "Contents";
|
||||||
nav.appendChild(header);
|
nav.appendChild(header);
|
||||||
|
|
||||||
var list = document.createElement("ul");
|
var list = document.createElement("ul");
|
||||||
headings.forEach(function (h) {
|
headings.forEach(function (h) {
|
||||||
if (!h.id) return;
|
if (!h.id) return;
|
||||||
var li = document.createElement("li");
|
var li = document.createElement("li");
|
||||||
li.className = "toc-" + h.tagName.toLowerCase();
|
li.className = "toc-" + h.tagName.toLowerCase();
|
||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
a.href = "#" + h.id;
|
a.href = "#" + h.id;
|
||||||
var clone = h.cloneNode(true);
|
var clone = h.cloneNode(true);
|
||||||
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
|
clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
|
||||||
a.textContent = clone.textContent.trim();
|
a.textContent = clone.textContent.trim();
|
||||||
li.appendChild(a);
|
li.appendChild(a);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
nav.appendChild(list);
|
nav.appendChild(list);
|
||||||
|
|
||||||
content.insertBefore(nav, content.firstChild);
|
content.insertBefore(nav, content.firstChild);
|
||||||
});
|
}
|
||||||
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||||
|
else run();
|
||||||
|
})();
|
||||||
|
|||||||
+10
-5
@@ -45,17 +45,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reading mode: keep the latest scroll fraction recorded (throttled to one
|
// Reading mode: keep the latest scroll fraction recorded (throttled to one
|
||||||
// write per frame) so it is ready the moment the editor opens.
|
// write per frame) so it is ready the moment the editor opens. Boosted
|
||||||
|
// navigation replaces .center and changes the page without a reload, so
|
||||||
|
// listen on document and resolve both when writing.
|
||||||
var pending = false;
|
var pending = false;
|
||||||
center.addEventListener('scroll', function () {
|
document.addEventListener('scroll', function (e) {
|
||||||
if (pending) return;
|
if (pending || !e.target.classList || !e.target.classList.contains('center')) return;
|
||||||
pending = true;
|
pending = true;
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
pending = false;
|
pending = false;
|
||||||
|
center = document.querySelector('.center');
|
||||||
|
if (!center) return;
|
||||||
var m = maxScroll();
|
var m = maxScroll();
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
sessionStorage.setItem('scrollsync:' + location.pathname,
|
||||||
|
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
}, { passive: true });
|
}, { capture: true, passive: true });
|
||||||
})();
|
})();
|
||||||
|
|||||||
+21
-12
@@ -52,19 +52,19 @@ function createSearchPage(name) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The "create page" link on results pages; delegated, since boosted
|
||||||
|
// navigation swaps the results in without a new DOMContentLoaded.
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var createLink = e.target.closest && e.target.closest('[data-create-page]');
|
||||||
|
if (!createLink) return;
|
||||||
|
e.preventDefault();
|
||||||
|
createSearchPage(createLink.getAttribute('data-create-page'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus the search input on results pages so Tab steps directly into the
|
||||||
|
// first match — the input sits immediately before the results in DOM order,
|
||||||
|
// so the natural tab sequence is input → first result → next, …
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
var createLink = document.querySelector('[data-create-page]');
|
|
||||||
if (createLink) {
|
|
||||||
createLink.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
createSearchPage(createLink.getAttribute('data-create-page'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus the search input on results pages so Tab steps directly into the
|
|
||||||
// first match — the input sits immediately before the results in DOM
|
|
||||||
// order, so the natural tab sequence is input → first result → next, …
|
|
||||||
var input = document.querySelector('.search-input');
|
var input = document.querySelector('.search-input');
|
||||||
if (input && input.value) {
|
if (input && input.value) {
|
||||||
input.focus();
|
input.focus();
|
||||||
@@ -72,3 +72,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
try { input.setSelectionRange(end, end); } catch (e) {}
|
try { input.setSelectionRange(end, end); } catch (e) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The header (and its search box) survives boosted navigation: show the
|
||||||
|
// current page's query, as a full load would.
|
||||||
|
document.addEventListener('page:swapped', function () {
|
||||||
|
var input = document.querySelector('.search-input');
|
||||||
|
if (input && document.activeElement !== input) {
|
||||||
|
input.value = new URLSearchParams(location.search).get('q') || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
{{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
|
|
||||||
|
|
||||||
{{define "searchQuery"}}{{.Query}}{{end}}
|
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
|||||||
@@ -425,6 +425,8 @@ main > h2 {
|
|||||||
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
|
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
|
||||||
|
|
||||||
.actions-special > .btn {text-align: left;}
|
.actions-special > .btn {text-align: left;}
|
||||||
|
/* Wrapper for the out-of-band swapped page actions; lays out as if absent. */
|
||||||
|
.page-actions { display: contents; }
|
||||||
/* Header actions carry an icon and a label: the mobile dropdown shows the
|
/* Header actions carry an icon and a label: the mobile dropdown shows the
|
||||||
label, the inline desktop bar (see responsive block) the icon. */
|
label, the inline desktop bar (see responsive block) the icon. */
|
||||||
.btn-icon { display: none; }
|
.btn-icon { display: none; }
|
||||||
|
|||||||
+12
@@ -15,6 +15,18 @@ func isHX(r *http.Request) bool {
|
|||||||
return r.Header.Get("HX-Request") == "true"
|
return r.Header.Get("HX-Request") == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// layoutTemplate names the template that renders a layout page for r: the
|
||||||
|
// whole document, or for boosted navigation and htmx history restores only
|
||||||
|
// the "partial" that hx.js swaps into the open page. The editor is never
|
||||||
|
// boosted.
|
||||||
|
func layoutTemplate(w http.ResponseWriter, r *http.Request, editMode bool) string {
|
||||||
|
w.Header().Set("Vary", "HX-Boosted, HX-History-Restore-Request")
|
||||||
|
if !editMode && (r.Header.Get("HX-Boosted") == "true" || r.Header.Get("HX-History-Restore-Request") == "true") {
|
||||||
|
return "partial"
|
||||||
|
}
|
||||||
|
return "layout"
|
||||||
|
}
|
||||||
|
|
||||||
// redirectAfter sends the client to target once a mutation succeeded. htmx
|
// redirectAfter sends the client to target once a mutation succeeded. htmx
|
||||||
// requests get HX-Redirect (assets/hx.js turns it into a history replace, so
|
// requests get HX-Redirect (assets/hx.js turns it into a history replace, so
|
||||||
// Back skips the pre-mutation page); plain form posts get a 303.
|
// Back skips the pre-mutation page); plain form posts get a 303.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const port = process.env.E2E_PORT || "8765";
|
const port = process.env.E2E_PORT || "8765";
|
||||||
|
|
||||||
@@ -11,11 +11,15 @@ export default defineConfig({
|
|||||||
reporter: "list",
|
reporter: "list",
|
||||||
use: {
|
use: {
|
||||||
baseURL: `http://127.0.0.1:${port}`,
|
baseURL: `http://127.0.0.1:${port}`,
|
||||||
// System Chrome; no Playwright browser download needed.
|
|
||||||
channel: "chrome",
|
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
},
|
},
|
||||||
|
projects: [
|
||||||
|
// The system Chrome; no browser download needed.
|
||||||
|
{ name: "chrome", use: { channel: "chrome" } },
|
||||||
|
// Playwright's Firefox build: npx playwright install firefox
|
||||||
|
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||||
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "node server.mjs",
|
command: "node server.mjs",
|
||||||
url: `http://127.0.0.1:${port}/`,
|
url: `http://127.0.0.1:${port}/`,
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Boosted navigation: outside the editor, page links swap the next page into
|
||||||
|
// the open document instead of loading a new one. A marker on window survives
|
||||||
|
// a boosted navigation and disappears with a full page load.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
|
||||||
|
|
||||||
|
test.beforeEach(({ page }) => {
|
||||||
|
page.on("pageerror", (err) => {
|
||||||
|
// Chrome aborts the cross-document view transition (style.css) when the
|
||||||
|
// next document can't take part, e.g. a plain-text file, and reports it
|
||||||
|
// as an unhandled rejection that no page script can catch.
|
||||||
|
if (err.message.includes("ViewTransition opt-in disabled")) return;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function randomWord(n) {
|
||||||
|
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sandbox(pages) {
|
||||||
|
const root = "t" + randomWord(7);
|
||||||
|
for (const [rel, body] of Object.entries(pages)) {
|
||||||
|
const dir = join(wiki, root, rel);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
if (body !== null) writeFileSync(join(dir, "index.md"), body);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebar = (page) => page.locator("aside.tree-sidebar");
|
||||||
|
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
|
||||||
|
const mark = (page) => page.evaluate(() => (window.__marker = 1));
|
||||||
|
const marked = (page) => page.evaluate(() => window.__marker === 1);
|
||||||
|
const long = (title) => `# ${title}\n\n` + Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\ntext\n`).join("\n");
|
||||||
|
|
||||||
|
test("sidebar navigation swaps the page in without a reload", async ({ page }) => {
|
||||||
|
const root = sandbox({
|
||||||
|
"": "# Parent\n",
|
||||||
|
Child: "# Child\n\n## First\n\none\n\n## Second\n\n- [ ] boosted task\n",
|
||||||
|
});
|
||||||
|
await page.goto(`/${root}/`);
|
||||||
|
await mark(page);
|
||||||
|
await link(sidebar(page), "Child").click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
|
||||||
|
await expect(page.locator("main h1")).toContainText("Child");
|
||||||
|
await expect(page).toHaveTitle("Child");
|
||||||
|
expect(await marked(page)).toBe(true);
|
||||||
|
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText("Child");
|
||||||
|
// Page scripts ran for the new content: TOC and section edit links.
|
||||||
|
await expect(page.locator("nav.toc")).toContainText("Second");
|
||||||
|
await expect(page.locator("main h2", { hasText: "First" }).getByRole("link", { name: "[edit]" })).toBeVisible();
|
||||||
|
// htmx attributes in the new content work: the task toggles.
|
||||||
|
await page.locator("main input.task-checkbox").check();
|
||||||
|
await expect.poll(() => readFileSync(join(wiki, root, "Child", "index.md"), "utf8")).toContain("- [x] boosted task");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Back and Forward restore pages and scroll without a reload", async ({ page }) => {
|
||||||
|
const root = sandbox({ "": long("Parent"), Child: long("Child") });
|
||||||
|
await page.goto(`/${root}/`);
|
||||||
|
await mark(page);
|
||||||
|
const center = page.locator(".center");
|
||||||
|
await center.evaluate((el) => (el.scrollTop = 600));
|
||||||
|
await link(sidebar(page), "Child").click();
|
||||||
|
await expect(page.locator("main h1")).toContainText("Child");
|
||||||
|
// A new page starts at the top.
|
||||||
|
expect(await center.evaluate((el) => el.scrollTop)).toBe(0);
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||||
|
await expect(page.locator("main h1")).toContainText("Parent");
|
||||||
|
await expect(page).toHaveTitle("Parent");
|
||||||
|
await expect.poll(() => center.evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
|
||||||
|
await page.goForward();
|
||||||
|
await expect(page.locator("main h1")).toContainText("Child");
|
||||||
|
expect(await marked(page)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Back to the first, fully loaded page works", async ({ page }) => {
|
||||||
|
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
||||||
|
await page.goto(`/${root}/Child/`);
|
||||||
|
await mark(page);
|
||||||
|
await link(sidebar(page), root).click();
|
||||||
|
await expect(page.locator("main h1")).toContainText("Parent");
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
|
||||||
|
await expect(page.locator("main h1")).toContainText("Child");
|
||||||
|
expect(await marked(page)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("files and the editor load as full pages", async ({ page }) => {
|
||||||
|
const root = sandbox({ "": "# Parent\n" });
|
||||||
|
writeFileSync(join(wiki, root, "note.txt"), "plain file");
|
||||||
|
await page.goto(`/${root}/`);
|
||||||
|
await mark(page);
|
||||||
|
await page.locator("main").getByRole("link", { name: "note.txt" }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${root}/note.txt$`));
|
||||||
|
await expect(page.locator("body")).toContainText("plain file");
|
||||||
|
expect(await marked(page)).toBe(false);
|
||||||
|
|
||||||
|
await page.goto(`/${root}/`);
|
||||||
|
await mark(page);
|
||||||
|
await page.locator("header").getByTitle("Edit page (E)").click();
|
||||||
|
await expect(page).toHaveURL(/\?edit/);
|
||||||
|
await expect(page.locator(".cm-content")).toBeVisible();
|
||||||
|
expect(await marked(page)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("header actions follow the page", async ({ page }) => {
|
||||||
|
const root = sandbox({ "": "# Parent\n" });
|
||||||
|
await page.goto("/");
|
||||||
|
await mark(page);
|
||||||
|
const move = page.locator("header").getByTitle("Move page (M)");
|
||||||
|
await expect(move).toHaveCount(0);
|
||||||
|
await link(sidebar(page), root).click();
|
||||||
|
await expect(page.locator("main h1")).toContainText("Parent");
|
||||||
|
await expect(move).toBeVisible();
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.locator("main h1")).toContainText("Home");
|
||||||
|
await expect(move).toHaveCount(0);
|
||||||
|
expect(await marked(page)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search results and the search box follow boosted navigation", async ({ page }) => {
|
||||||
|
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
||||||
|
expect((await page.request.post("/_reindex")).status()).toBe(204);
|
||||||
|
await page.goto(`/${root}/`);
|
||||||
|
await mark(page);
|
||||||
|
const input = page.locator("header .search-input");
|
||||||
|
await input.fill(root);
|
||||||
|
await input.press("Enter");
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/\?q=${root}$`));
|
||||||
|
await expect(page).toHaveTitle(`Search: ${root}`);
|
||||||
|
await expect(page.locator("main .search-card").first()).toBeVisible();
|
||||||
|
await link(sidebar(page), root).click();
|
||||||
|
await expect(page.locator("main h1")).toContainText("Parent");
|
||||||
|
await expect(input).toHaveValue("");
|
||||||
|
expect(await marked(page)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a mutation swaps in its result without a reload", async ({ page }) => {
|
||||||
|
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
|
||||||
|
await page.goto(`/${root}/Gone/`);
|
||||||
|
await mark(page);
|
||||||
|
await page.locator("header").getByTitle("Delete page").click();
|
||||||
|
await page.locator(".modal").getByRole("button", { name: "DELETE" }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||||
|
await expect(page.locator("main h1")).toContainText("Parent");
|
||||||
|
await expect(page.locator(".modal")).toHaveCount(0);
|
||||||
|
await expect(link(sidebar(page), "Gone")).toHaveCount(0);
|
||||||
|
expect(await marked(page)).toBe(true);
|
||||||
|
});
|
||||||
@@ -420,7 +420,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
t = editTmpl
|
t = editTmpl
|
||||||
}
|
}
|
||||||
data.renderTimer = renderTimer{requestStart(r)}
|
data.renderTimer = renderTimer{requestStart(r)}
|
||||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
if err := t.ExecuteTemplate(w, layoutTemplate(w, r, editMode), data); err != nil {
|
||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
data.renderTimer = renderTimer{requestStart(r)}
|
data.renderTimer = renderTimer{requestStart(r)}
|
||||||
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
if err := searchTmpl.ExecuteTemplate(w, layoutTemplate(w, r, false), data); err != nil {
|
||||||
log.Printf("search template error: %v", err)
|
log.Printf("search template error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user