diff --git a/assets/js/app.js b/assets/js/app.js index 9df8001..ae182de 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,99 +1,36 @@ -// Page startup and verse modal wiring. +// Page startup. // // Pages arrive whole -- composed by the service worker, or by boot.html on the // visit before there is one -- so there is nothing here that builds a page. // What is left is the four things a composed document cannot do for itself: -// mark up the verse numbers as controls, run the modal, put a swapped-in page -// where it belongs before it is painted, and tell the chrome when a worker has -// appeared to answer it. +// mark up the verse numbers as controls, put a swapped-in page where it +// belongs before it is painted, land the reference picker where the reader +// asked to go, and tell the chrome when a worker has appeared to answer it. // -// The reference picker used to be here as well, built by reading the chapters -// and verses off the book on the page. It is markup and htmx now: sw.js -// renders it from bible/verses.json and answers api/reference, and the scroll -// and the address-bar update that a change event used to run through here are -// response headers on the answer. See templates/reference.njk. +// Two things used to live here and no longer do, both for the same reason: +// they were reading the page to work out something the worker already knew. // -// The modal's markup is in templates/base.njk and the bookmark is in the -// worker, so all this file does for it is decide which verse was clicked, fill -// the form, and show it -- Bootstrap handles opening, dismissing and focus, -// and htmx carries the save. +// The reference picker was built by reading the chapters and verses off the +// book on screen. It is markup and htmx now: sw.js renders it from +// bible/verses.json and answers api/reference, and the address-bar update that +// a change event used to run through here is a response header on the answer. +// See templates/reference.njk. +// +// The verse dialog was built by scraping the book name out of article +// [data-name] and the chapter out of the enclosing section, then filling a +// form and showing it. sw.js answers api/verse with the whole of the dialog, +// htmx swaps it into the empty modal in templates/base.njk, and Bootstrap +// opens it -- which is htmx's own bootstrap-modal example, with the click +// delegated because the trigger is one of 38,000 generated verse numbers and +// cannot carry attributes of its own. See templates/verse.njk. +// +// What is left of the dialog here is markVerses, and only because the numbers +// have to look and behave like controls before anyone can click one. (function () { 'use strict'; - var currentUrl = ''; - - function modalEl() { - return document.getElementById('verse-dialog'); - } - - function status(text) { - var el = document.getElementById('verse-dialog-status'); - if (el) el.textContent = text || ''; - return el; - } - - // navigator.clipboard only exists in a secure context, so over plain http - // this always falls through to a selectable field rather than failing. - function copyLink(url) { - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(url).then(function () { - status('Link copied.'); - }, function () { offerManualCopy(url); }); - } else { - offerManualCopy(url); - } - } - - function offerManualCopy(url) { - var el = status('Copy this link:'); - if (!el) return; - var field = document.createElement('input'); - field.type = 'text'; - field.readOnly = true; - field.value = url; - field.className = 'verse-dialog-url form-control form-control-sm mt-2'; - el.appendChild(field); - field.focus(); - field.select(); - } - // -- verse numbers ------------------------------------------------------ - function verseInfo(span) { - var article = document.querySelector('article.book'); - var section = span.closest('.chapter'); - var book = article - ? (article.getAttribute('data-name') || article.getAttribute('data-book')) - : ''; - var chapter = section ? section.getAttribute('data-chapter') : ''; - var page = location.pathname.split('/').pop() || './'; - return { - ref: book + ' ' + chapter + ':' + (span.textContent || '').trim(), - // Relative, so a saved bookmark survives a change of host or port. - href: page + '#' + span.id, - url: location.origin + location.pathname + '#' + span.id - }; - } - - function openFor(span) { - var el = modalEl(); - if (!el || !window.bootstrap) return; - var info = verseInfo(span); - currentUrl = info.url; - document.getElementById('verse-dialog-ref').textContent = info.ref; - el.querySelector('input[name="ref"]').value = info.ref; - el.querySelector('input[name="href"]').value = info.href; - status(''); - window.bootstrap.Modal.getOrCreateInstance(el).show(); - } - - function hideModal() { - var el = modalEl(); - if (!el || !window.bootstrap) return; - var instance = window.bootstrap.Modal.getInstance(el); - if (instance) instance.hide(); - } - // Applied here rather than baked into the HTML: without this script the // numbers are plain text, and announcing them as buttons would lie. It also // earns Reboot's [role=button]{cursor:pointer}. Runs again after every htmx @@ -109,33 +46,19 @@ } // Delegated at the document, so it survives swaps and is bound only once. - // Dismissal is not handled here: the close button carries data-bs-dismiss, - // and Bootstrap owns the backdrop and Escape. + // The verse numbers and the dialog's own buttons are not here: opening the + // dialog is an hx-on handler on #content, and everything inside it is + // markup that sw.js sends. This is the reset button and nothing else. document.addEventListener('click', function (event) { if (!event.target.closest) return; - var verse = event.target.closest('.v'); - if (verse && verse.id) { event.preventDefault(); openFor(verse); return; } - if (event.target.closest('[data-action="copy"]')) copyLink(currentUrl); var reset = event.target.closest('#app-reset'); if (reset) resetApp(reset); }); - document.addEventListener('keydown', function (event) { - if (event.key !== 'Enter' && event.key !== ' ') return; - var el = document.activeElement; - if (el && el.classList && el.classList.contains('v') && el.id) { - event.preventDefault(); - openFor(el); - } - }); - document.body.addEventListener('htmx:afterSwap', function (event) { markVerses(event.target); 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); // Both selects answer into the verse list, so this is either of them. if (target.id === 'reference-verse') return landOnReference(); if (target.id === 'content') { land(); showVersion(); } diff --git a/assets/js/render.js b/assets/js/render.js index 0143f88..9a9ba3e 100644 --- a/assets/js/render.js +++ b/assets/js/render.js @@ -37,6 +37,7 @@ 'bookmark.njk', 'reference.njk', 'verses.njk', + 'verse.njk', 'version.njk', 'status.njk', 'notice.njk' diff --git a/assets/sw.js b/assets/sw.js index 0cfab0f..5f600a3 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -12,6 +12,7 @@ // GET /api/bookmark the navbar slot // POST /api/bookmark set the bookmark, and return the slot // GET /api/reference the navbar's chapter and verse picker +// GET /api/verse the dialog for one verse // // Anything with a dot in its last segment is a file, not a route, and is // served from the cache or the network without going near the router. Pages @@ -371,6 +372,40 @@ async function referenceOf(code, anchor) { } } +// ------------------------------------------------------------------- verse + +// The verse dialog's model: what to call this verse, and the two addresses for +// it. Null for anything that is not a verse of the book on screen. +// +// Checked against verses.json rather than taken at its word. at arrives from +// the page, and a dialog offering to bookmark an anchor the corpus does not +// have is a dialog offering a bookmark that lands nowhere -- so it is read +// back through the same map the options were built from, and the label comes +// out of that map rather than out of the id. Which is what makes a verse +// written "19-27" read as 26:19-27 and not as whatever splitting an id on an +// underscore would produce. +// +// href is relative, so a bookmark survives a change of host or port; url is +// absolute, because the only thing anyone does with it is paste it somewhere +// else. Both are spelled from the address bar, which is the only record of +// which book is on screen. +async function verseOf(page, at) { + const code = bookOf(page); + const anchor = parseAnchor(at); + if (!page || !code || !anchor || !anchor.verse) return null; + + const reference = await referenceOf(code, at); + if (!reference) return null; + const option = reference.verses.find((verse) => verse.id === at); + if (!option) return null; + + return { + ref: code + ' ' + anchor.chapter + ':' + option.label, + href: code + '#' + at, + url: page.origin + page.pathname + '#' + at + }; +} + const OFFLINE_NOTICE = ['Not available offline', 'This page has not been opened on this device, so there is no copy stored ' + 'here. Reconnect and try again, or open one you have read before.']; @@ -516,6 +551,24 @@ router.get('/api/reference', async (call) => { }); }); +// The dialog for one verse. +// +// at=v1_3 which verse, by the id of the element that is it -- the same +// spelling the picker's options use, read back the same way. +// +// The book is not in the URL, for the reason it is not in the picker's: this +// is chrome, it outlives the page under it, and HX-Current-URL is the address +// the page is actually showing. +// +// 204 for anything it cannot place. htmx swaps nothing on a 204, and the +// dialog opens on the swap, so a bad anchor is a click that does nothing +// rather than an empty dialog asking to bookmark it. +router.get('/api/verse', async (call) => { + const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at')); + if (!verse) return new Response('', { status: 204 }); + return view.render('verse.njk', { verse: verse }); +}); + router.post('/api/bookmark', async (call) => { try { const form = await call.form(); @@ -524,7 +577,16 @@ router.post('/api/bookmark', async (call) => { if (!ref || !href) return new Response('', { status: 400 }); await withStore('readwrite', (s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY)); - return bookmarkFragment('Bookmark set.'); + // Only on the way out of a write that committed. base.njk closes the + // dialog on this, and a dialog that closed itself on a failure would take + // the line explaining the failure with it. + return new Response(await bookmarkFragment('Bookmark set.'), { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'HX-Trigger-After-Settle': 'bookmark-saved' + } + }); } catch (err) { return bookmarkFragment('Could not save: storage unavailable.'); } @@ -596,7 +658,7 @@ const BOOKMARK_KEY = 'bookmark'; // who has never set one is treated as being at the beginning. Everything // downstream can assume a mark -- bookmark.njk's empty branch is for the boot // page, which has no worker to ask, not for a reader without one. -const DEFAULT_BOOKMARK = { ref: 'Genesis 1:1', href: 'GEN#v1_1' }; +const DEFAULT_BOOKMARK = { ref: 'GEN 1:1', href: 'GEN#v1_1' }; function openDb() { return new Promise((resolve, reject) => { diff --git a/assets/templates/base.njk b/assets/templates/base.njk index 0408b16..047e3e3 100644 --- a/assets/templates/base.njk +++ b/assets/templates/base.njk @@ -100,39 +100,68 @@
{# The swap target. hx-target on names this id, so every boosted link lands here -- remove it and a click does nothing at all while a - full navigation still works. #} -
+ full navigation still works. + + It is also where a verse click becomes a request. htmx's bootstrap-modal + example puts hx-get on the element that opens the dialog; the element + here is a verse number in a generated book, and there are 38,000 of + them -- an attribute apiece would cost more than the whole shell, and a + listener apiece more than the reading. So one handler sits on the + container they all swap into, and names the verse it found. Delegated, + so it survives every swap: this element is never replaced, only filled. + + hx-vals cannot do this. htmx evaluates it as Function("return (...)")() + with nothing passed in, so an expression there can read the document but + not the click -- and which verse was clicked is the one thing that has + to travel. + + Enter and space go through the same door rather than a second one: + markVerses() in app.js gives every verse role=button and tabindex, and + a button that answers the keyboard by being clicked is a button with one + behaviour instead of two. #} +
{% block content %}{% endblock %}
{% include "version.njk" %} -{# Chrome, so it is written once here instead of built in JavaScript on every - page. app.js only fills in the verse it was opened for and shows it. #} +{# The verse dialog: empty in the page, filled per request. Bootstrap owns + showing it, dismissing it, the backdrop and Escape; sw.js owns everything + inside, and templates/verse.njk is what lands in the modal-content below. + + Opened by the arrival of the content rather than by data-bs-toggle on + whatever was clicked. The example puts that attribute on its trigger, which + is not available here for the same reason hx-get is not -- see #content + above -- and the swap is the honest signal anyway: there is nothing worth + showing until it is here. A verse the corpus does not have is answered 204, + which swaps nothing, which opens nothing. + + after-swap and not after-settle: settling is the 20ms attribute pass and + this dialog has nothing to settle. Only the modal-content swap can reach + here -- the bookmark write targets the navbar slot, and the status line it + sends back rides in out of band, which fires its own event and not this one. + + bookmark-saved comes from sw.js once the write has committed, and only then: + a dialog that closed itself on a failed write would take the message + explaining the failure with it. #} diff --git a/assets/templates/verse.njk b/assets/templates/verse.njk new file mode 100644 index 0000000..5cb3dd0 --- /dev/null +++ b/assets/templates/verse.njk @@ -0,0 +1,60 @@ +{# The verse dialog's contents: what this verse is called, a link to it, and + the one thing you can do with it. + + Rendered by sw.js for the verse that was clicked and swapped into the empty + modal-content in base.njk -- the shape htmx's bootstrap-modal example uses. + Bootstrap owns showing, dismissing, the backdrop and Escape; everything + here is what is inside. + + Context: verse (ref, href, url). + + All three are built in the worker, from the book in the address bar and the + anchor that was clicked. app.js used to read them off the page -- the book + out of article[data-name], the chapter out of the enclosing section -- which + put the reference in two places and left the spelling of an anchor known to + both the file that generates them and the file that scrapes them back. + + The link is a field rather than a line of text because it is the fallback + for the button beside it: navigator.clipboard exists only in a secure + context, so over plain http:// from a LAN address there is nothing to write + with and a selected field is the whole of what is left. Readonly, so it + reads as something to take rather than something to fill in. #} + + +