308 lines
12 KiB
JavaScript
308 lines
12 KiB
JavaScript
// Page startup and verse modal wiring.
|
|
//
|
|
// 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 three things a composed document cannot do for itself:
|
|
// mark up the verse numbers as controls, run the modal, and tell the bookmark
|
|
// slot when a worker has appeared to answer it.
|
|
//
|
|
// 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.
|
|
(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
|
|
// swap, because the new verses arrive bare.
|
|
function markVerses(root) {
|
|
var verses = (root || document).querySelectorAll('.chapter .v');
|
|
for (var i = 0; i < verses.length; i++) {
|
|
verses[i].setAttribute('role', 'button');
|
|
verses[i].setAttribute('tabindex', '0');
|
|
verses[i].setAttribute('aria-label',
|
|
'Verse ' + (verses[i].textContent || '').trim() + ', options');
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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);
|
|
});
|
|
|
|
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);
|
|
if (target.id === 'content') { fillReference(); land(); }
|
|
});
|
|
|
|
// -- reference picker ---------------------------------------------------
|
|
|
|
// The "GEN 1:1" in the navbar. Built from the book on the page rather than
|
|
// from a table shipped beside it: the chapters are the sections and a
|
|
// chapter's verses are the anchors inside it, so an option can never name a
|
|
// target that is not there to scroll to, and there is no second file to keep
|
|
// in step, cache, or version.
|
|
//
|
|
// It is not kept in step with the scroll position, on purpose. It says where
|
|
// you are going, not where you are, and it starts at 1:1 in every book.
|
|
|
|
function byId(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function fillOptions(select, items) {
|
|
select.textContent = '';
|
|
for (var i = 0; i < items.length; i++) {
|
|
var option = document.createElement('option');
|
|
// The value is the id of the element to land on, so nothing downstream
|
|
// has to know how a verse anchor is spelled.
|
|
option.value = items[i].value;
|
|
option.textContent = items[i].label;
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
|
|
function fillVerses(chapterId) {
|
|
var section = byId(chapterId);
|
|
var verses = section ? section.querySelectorAll('.v') : [];
|
|
var items = [];
|
|
for (var i = 0; i < verses.length; i++) {
|
|
items.push({ value: verses[i].id,
|
|
label: (verses[i].textContent || '').trim() });
|
|
}
|
|
fillOptions(byId('reference-verse'), items);
|
|
}
|
|
|
|
function fillReference() {
|
|
var picker = byId('reference');
|
|
if (!picker) return;
|
|
var book = document.querySelector('#content article.book');
|
|
picker.classList.toggle('d-none', !book);
|
|
picker.classList.toggle('d-flex', !!book);
|
|
if (!book) return;
|
|
|
|
byId('reference-book').textContent = book.getAttribute('data-book') || '';
|
|
|
|
var chapters = book.querySelectorAll('.chapter[data-chapter]');
|
|
var items = [];
|
|
for (var i = 0; i < chapters.length; i++) {
|
|
items.push({ value: chapters[i].id,
|
|
label: chapters[i].getAttribute('data-chapter') });
|
|
}
|
|
fillOptions(byId('reference-chapter'), items);
|
|
if (items.length) fillVerses(items[0].value);
|
|
}
|
|
|
|
// The URL is the whole mechanism: name the anchor, then land on it. Nothing
|
|
// is stored anywhere else, so a reload or a shared link ends up in the same
|
|
// place.
|
|
//
|
|
// replaceState rather than assigning location.hash: spinning through a
|
|
// select should not stack one history entry per option, and assigning the
|
|
// hash hands the scroll to the browser, which Bootstrap has set to smooth.
|
|
function goTo(id) {
|
|
var target = byId(id);
|
|
if (!target) return;
|
|
history.replaceState(null, '', '#' + id);
|
|
jumpTo(target);
|
|
}
|
|
|
|
// Delegated at the document, so it survives every swap and binds once.
|
|
// Changing the chapter rebuilds the verses and lands on the chapter heading,
|
|
// which is what 1 is directly below; changing the verse lands on the verse.
|
|
document.addEventListener('change', function (event) {
|
|
var target = event.target;
|
|
if (!target || !target.id) return;
|
|
if (target.id === 'reference-chapter') {
|
|
fillVerses(target.value);
|
|
goTo(target.value);
|
|
} else if (target.id === 'reference-verse') {
|
|
goTo(target.value);
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
fillReference();
|
|
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#v1_1 land on the verse in the second.
|
|
function scrollToHash() {
|
|
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
|
|
// there is nothing to ask until the worker is controlling this page.
|
|
// controller, not ready: ready can resolve a tick before clients.claim()
|
|
// takes effect, and a request sent in that gap reaches the network and 404s.
|
|
function whenControlled(fn) {
|
|
if (!('serviceWorker' in navigator)) return;
|
|
if (navigator.serviceWorker.controller) return fn();
|
|
navigator.serviceWorker.addEventListener('controllerchange', fn, { once: true });
|
|
}
|
|
|
|
// Deferred scripts run before DOMContentLoaded fires, so this always lands,
|
|
// and it lands after htmx has processed the shell.
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
markVerses();
|
|
fillReference();
|
|
scrollToHash();
|
|
|
|
// Registered from the page it is going to serve, not from a page of its
|
|
// own: there is nothing to wait for and nothing to reload. This load is
|
|
// already the real app -- the worker installs alongside it, and takes over
|
|
// composing, the bookmark and the offline download from the next
|
|
// navigation on.
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('sw.js').catch(function () {});
|
|
}
|
|
whenControlled(function () {
|
|
document.body.dispatchEvent(new CustomEvent('worker-ready'));
|
|
});
|
|
});
|
|
})();
|