diff --git a/assets/js/app.js b/assets/js/app.js index 3a10997..a8593fd 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -121,26 +121,71 @@ } }); - // Close the modal once a bookmark write has come back and swapped, leaving - // the status line up long enough to be read. document.body.addEventListener('htmx:afterSwap', function (event) { markVerses(event.target); - if (event.detail.target && event.detail.target.id === 'bookmark') { - setTimeout(hideModal, 900); - } + var target = event.detail.target; + if (!target) return; + // Close the modal once a bookmark write has come back and swapped, leaving + // the status line up long enough to be read. + if (target.id === 'bookmark') return setTimeout(hideModal, 900); + if (target.id === 'content') land(); + }); + + // A history restore does not go through the request pipeline -- htmx swaps + // the element straight in and fires this instead -- so the work afterSwap + // does has to be repeated here or a page reached by the back button arrives + // with dead verse numbers. + document.body.addEventListener('htmx:historyRestore', function () { + markVerses(); + land(); }); // -- startup ------------------------------------------------------------ + function hashTarget() { + if (!location.hash) return null; + return document.getElementById(decodeURIComponent(location.hash.slice(1))); + } + + // Every scroll here is a correction, not a gesture: the page is meant to + // arrive already in position. So they all say instant. + // + // Saying nothing would not mean instant. The default is "auto", which means + // "whatever scroll-behavior computes to" -- and Bootstrap's Reboot sets + // :root { scroll-behavior: smooth }, so the default animates. Bootstrap + // wraps that in prefers-reduced-motion, which is the right call for the + // chapter nav's own anchors; these are not that. + function jumpTo(el) { + el.scrollIntoView({ block: 'start', behavior: 'instant' }); + } + // A worker-composed page is an ordinary document and the browser scrolls to // the anchor itself. One composed by boot.html is not: it replaced a // document the browser had already finished with, so the fragment identifier // has been spent. Repeating it costs nothing in the first case and is what - // makes a link to GEN.html#v1_1 land on the verse in the second. + // makes a link to GEN#v1_1 land on the verse in the second. function scrollToHash() { - if (!location.hash) return; - var el = document.getElementById(decodeURIComponent(location.hash.slice(1))); - if (el) el.scrollIntoView(); + var el = hashTarget(); + if (el) jumpTo(el); + } + + // Where a swapped-in page should sit before it is ever seen. + // + // htmx does this itself -- scrolling to the anchor a boosted href named, or + // to the top of the target when it named none -- but from the settle + // callback, which is a setTimeout of defaultSettleDelay, 20ms. The browser + // paints the new content at the old scroll offset first, so the jump is + // visible every time. Here it runs in the same task as the swap, before any + // paint, so the page simply appears where it belongs. htmx's own scroll + // still follows and finds nothing left to do. + // + // htmx has already pushed the new URL by this point -- history is updated + // before the swap, not after -- so location.hash is the anchor that was + // asked for. + function land() { + var el = hashTarget(); + if (el) jumpTo(el); + else window.scrollTo({ top: 0, behavior: 'instant' }); } // api/bookmark has no origin server -- it is answered by the worker -- so diff --git a/assets/js/router.js b/assets/js/router.js index 7f6918e..ea00693 100644 --- a/assets/js/router.js +++ b/assets/js/router.js @@ -32,6 +32,11 @@ // htmx stamps every request it makes; a navigation never carries it. this.htmx = this.request.headers.get('HX-Request') === 'true'; this.navigate = this.request.mode === 'navigate'; + // Going back or forward. htmx asks for the page again and expects a whole + // document back, the same as a navigation -- it takes the history element + // out of the response itself. + this.restore = + this.request.headers.get('HX-History-Restore-Request') === 'true'; } Call.prototype.form = function () { diff --git a/assets/sw.js b/assets/sw.js index ce74de9..6a14314 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -257,12 +257,12 @@ async function fragmentFor(path, event) { return res.ok ? res.text() : view.notice(...MISSING_NOTICE); } -// One handler for both callers. htmx is swapping into a shell that is already -// on screen and wants the fragment; a navigation has no shell yet and gets the -// fragment composed into one. +// One handler for every caller. htmx swapping a link into a shell already on +// screen wants the bare fragment; a navigation has no shell yet, and a history +// restore is rebuilding one, so both get the fragment composed into a document. async function servePage(path, call) { const fragment = await fragmentFor(path, call.event); - if (!call.navigate) return fragment; + if (!call.navigate && !call.restore) return fragment; try { return await view.compose(fragment, await getBookmark()); } catch (err) { diff --git a/assets/templates/base.njk b/assets/templates/base.njk index 2769216..3beb370 100644 --- a/assets/templates/base.njk +++ b/assets/templates/base.njk @@ -19,10 +19,22 @@ scripture; Bootstrap styles the chrome. #} {# History entries are whole books, and htmx keeps its history cache in - localStorage -- a couple of them would blow the quota. Turned off: going - back re-requests the fragment, which the worker answers from its cache. #} + localStorage -- a couple of them would blow the quota. Turned off, so every + back or forward is a cache miss, and refreshOnHistoryMiss decides what a + miss does. + + False on purpose. True makes htmx call location.reload(true) -- a forced + reload, which is exactly the kind of navigation a browser takes past the + service worker, so it reaches server.py and comes back as boot.html: the + spinner flashes by and then reloads into the real page. False makes htmx + fetch the page over XHR instead, which the worker answers itself, from its + own cache when offline. No navigation, no boot page, nothing to flash. + + That request carries HX-History-Restore-Request, and the worker answers it + with a whole composed document; htmx lifts the element marked + hx-history-elt out of the response and puts it back in the page. #} + content='{"historyCacheSize":0,"refreshOnHistoryMiss":false}'> {# The bundle carries Popper, which the navbar collapse and the modal do not need -- but a dropdown or tooltip added later would, and this is one file in the precache either way. Deferred scripts run in order, so `bootstrap` @@ -40,9 +52,15 @@ {# Boosted links swap only the fragment, so the navbar, the modal and the - scripts above survive navigation and never reload. #} + scripts above survive navigation and never reload. + + No show: modifier, deliberately. htmx adds show:top itself for a boosted + link, but skips it when the href carries a fragment identifier and scrolls + to that anchor after the swap instead. Spelling show:top out here overrides + that decision for every link, which leaves a cross-reference like ISA#v61_6 + sitting at the top of Isaiah. #}
+ hx-boost="true" hx-target="#content" hx-swap="innerHTML">