Frontend Features and refactor
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// Book name suggestions for reference/search boxes: type "joh" and get
|
||||
// John, 1 John...; Tab (or tap/click) completes. Only offered while the text
|
||||
// could still be the start of a book name, so keyword searches are unaffected.
|
||||
(function () {
|
||||
var BOOKS = __BOOKS__;
|
||||
var MAX_SHOWN = 8;
|
||||
var list = null;
|
||||
var current = { input: null, matches: [], active: 0, explicit: false };
|
||||
|
||||
function norm(text) {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
function injectStyle() {
|
||||
var style = document.createElement("style");
|
||||
style.textContent =
|
||||
".book-suggest{position:absolute;z-index:2000;margin:4px 0 0;padding:4px 0;list-style:none;" +
|
||||
"background:var(--color-bg-secondary,#fff);color:var(--color-text-primary,#111);" +
|
||||
"border:1px solid var(--color-border,#ccc);border-radius:6px;" +
|
||||
"box-shadow:0 6px 18px var(--color-shadow,rgba(0,0,0,.25));font:1rem var(--font-ui,sans-serif)}" +
|
||||
".book-suggest li{padding:10px 14px;cursor:pointer;min-height:44px;display:flex;align-items:center}" +
|
||||
".book-suggest li[aria-selected=true]{background:var(--color-accent-primary,#2b5c94);" +
|
||||
"color:var(--color-on-accent,#fff)}" +
|
||||
".book-suggest .hint{min-height:0;padding:4px 14px;font-size:.8rem;cursor:default;" +
|
||||
"color:var(--color-text-muted,#666);background:none}" +
|
||||
"@media (hover:none){.book-suggest .hint{display:none}}";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function ensureList() {
|
||||
if (list) {
|
||||
return list;
|
||||
}
|
||||
injectStyle();
|
||||
list = document.createElement("ul");
|
||||
list.className = "book-suggest";
|
||||
list.id = "book-suggest-list";
|
||||
list.setAttribute("role", "listbox");
|
||||
list.hidden = true;
|
||||
// keep focus in the input while clicking an item
|
||||
list.addEventListener("mousedown", function (event) { event.preventDefault(); });
|
||||
document.body.appendChild(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (list) {
|
||||
list.hidden = true;
|
||||
}
|
||||
if (current.input) {
|
||||
current.input.setAttribute("aria-expanded", "false");
|
||||
current.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
current.matches = [];
|
||||
}
|
||||
|
||||
function accept(book) {
|
||||
var input = current.input;
|
||||
input.value = book + " ";
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
close();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function paint() {
|
||||
var items = list.querySelectorAll("li[role=option]");
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var selected = i === current.active;
|
||||
items[i].setAttribute("aria-selected", selected ? "true" : "false");
|
||||
if (selected) {
|
||||
current.input.setAttribute("aria-activedescendant", items[i].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function show(input, matches) {
|
||||
var box = input.getBoundingClientRect();
|
||||
ensureList();
|
||||
list.textContent = "";
|
||||
matches.forEach(function (book, i) {
|
||||
var item = document.createElement("li");
|
||||
item.setAttribute("role", "option");
|
||||
item.id = "book-suggest-" + i;
|
||||
item.textContent = book;
|
||||
item.addEventListener("click", function () { accept(book); });
|
||||
list.appendChild(item);
|
||||
});
|
||||
var hint = document.createElement("li");
|
||||
hint.className = "hint";
|
||||
hint.textContent = "Tab to complete";
|
||||
list.appendChild(hint);
|
||||
list.style.left = box.left + window.pageXOffset + "px";
|
||||
list.style.top = box.bottom + window.pageYOffset + "px";
|
||||
list.style.minWidth = Math.min(box.width, 320) + "px";
|
||||
list.hidden = false;
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
paint();
|
||||
}
|
||||
|
||||
function update(input) {
|
||||
current.input = input;
|
||||
var value = input.value.replace(/^\s+/, "");
|
||||
// "1 jo", "song of sol": optional leading book number, then letters only
|
||||
var n = /^[1-3]?\s?[A-Za-z ]*$/.test(value) ? norm(value) : "";
|
||||
var matches = n ? BOOKS.filter(function (b) { return norm(b).indexOf(n) === 0; }) : [];
|
||||
// already typed a whole book name: nothing left to complete
|
||||
if (matches.length === 1 && norm(matches[0]) === n) {
|
||||
matches = [];
|
||||
}
|
||||
matches = matches.slice(0, MAX_SHOWN);
|
||||
current.matches = matches;
|
||||
current.active = 0;
|
||||
current.explicit = false;
|
||||
if (matches.length) {
|
||||
show(input, matches);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
var open = list && !list.hidden && current.matches.length;
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
var count = current.matches.length;
|
||||
if (event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
accept(current.matches[current.active]);
|
||||
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
current.active = (current.active + (event.key === "ArrowDown" ? 1 : count - 1)) % count;
|
||||
current.explicit = true;
|
||||
paint();
|
||||
} else if (event.key === "Enter" && current.explicit) {
|
||||
event.preventDefault(); // pick the highlighted book instead of submitting
|
||||
accept(current.matches[current.active]);
|
||||
} else if (event.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function attach(input) {
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.setAttribute("aria-autocomplete", "list");
|
||||
input.setAttribute("aria-controls", "book-suggest-list");
|
||||
input.addEventListener("input", function () { update(input); });
|
||||
input.addEventListener("keydown", function (event) {
|
||||
current.input = input;
|
||||
onKeydown(event);
|
||||
});
|
||||
input.addEventListener("blur", close);
|
||||
}
|
||||
|
||||
var inputs = document.querySelectorAll("input[data-book-suggest]");
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
attach(inputs[i]);
|
||||
}
|
||||
window.addEventListener("resize", close);
|
||||
})();
|
||||
+21
-17
@@ -1,10 +1,28 @@
|
||||
var coll = document.getElementsByClassName("collapsible");
|
||||
var i;
|
||||
|
||||
function setOpen(button, open) {
|
||||
var content = button.nextElementSibling;
|
||||
button.classList.toggle("active", open);
|
||||
button.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
if (!open) {
|
||||
content.style.display = "none";
|
||||
} else if (content.classList.contains('chapter-grid')) {
|
||||
content.style.display = "grid";
|
||||
} else {
|
||||
content.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < coll.length; i++) {
|
||||
coll[i].setAttribute("aria-expanded", "false");
|
||||
coll[i].addEventListener("click", function() {
|
||||
var content = this.nextElementSibling;
|
||||
var isCurrentlyOpen = content.style.display === "block" || content.style.display === "grid";
|
||||
// computed style, so content hidden by a stylesheet counts as closed
|
||||
var isCurrentlyOpen = content && getComputedStyle(content).display !== "none";
|
||||
|
||||
// If this collapsible has a parent with book-grid class, close all siblings in that grid
|
||||
var parentGrid = this.closest('.book-grid');
|
||||
@@ -12,25 +30,11 @@ for (i = 0; i < coll.length; i++) {
|
||||
var siblings = parentGrid.querySelectorAll('.collapsible');
|
||||
for (var j = 0; j < siblings.length; j++) {
|
||||
if (siblings[j] !== this) {
|
||||
siblings[j].classList.remove("active");
|
||||
var siblingContent = siblings[j].nextElementSibling;
|
||||
if (siblingContent) {
|
||||
siblingContent.style.display = "none";
|
||||
}
|
||||
setOpen(siblings[j], false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle current element
|
||||
this.classList.toggle("active");
|
||||
if (isCurrentlyOpen) {
|
||||
content.style.display = "none";
|
||||
} else {
|
||||
if (content.classList.contains('chapter-grid')) {
|
||||
content.style.display = "grid";
|
||||
} else {
|
||||
content.style.display = "block";
|
||||
}
|
||||
}
|
||||
setOpen(this, !isCurrentlyOpen);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Tap a verse to open a small menu: favorite it, look up the 1828 Webster
|
||||
// definition of its words, or see its cross references. Everything saved
|
||||
// lives in this browser's localStorage (read back on the /saved page).
|
||||
(function () {
|
||||
var KEYS = { fav: "kjv.favorites", defs: "kjv.definitions", xrefs: "kjv.crossrefs" };
|
||||
var open = null; // { box, menu }
|
||||
|
||||
function load(key) {
|
||||
try {
|
||||
var value = JSON.parse(localStorage.getItem(key));
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(key, list) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(list));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function indexOf(list, match) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (match(list[i])) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Adds the item if it is not stored yet, removes it if it is. Returns the new "saved" state.
|
||||
function toggle(key, item, match) {
|
||||
var list = load(key);
|
||||
var at = indexOf(list, match);
|
||||
if (at >= 0) {
|
||||
list.splice(at, 1);
|
||||
} else {
|
||||
item.t = Date.now();
|
||||
list.unshift(item);
|
||||
}
|
||||
save(key, list);
|
||||
return at < 0;
|
||||
}
|
||||
|
||||
function el(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) {
|
||||
node.className = className;
|
||||
}
|
||||
if (text !== undefined) {
|
||||
node.textContent = text;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function verseInfo(box) {
|
||||
var link = box.querySelector(".verse-reference a");
|
||||
var text = box.querySelector(".verse-text");
|
||||
return {
|
||||
ref: link ? link.textContent.trim() : "",
|
||||
text: text ? text.textContent.trim() : "",
|
||||
id: box.getAttribute("data-verse-id") || ""
|
||||
};
|
||||
}
|
||||
|
||||
function findUrl(ref) {
|
||||
return "/find?kw=" + encodeURIComponent(ref) + "&view=rich";
|
||||
}
|
||||
|
||||
function markFavorites(root) {
|
||||
var favs = load(KEYS.fav);
|
||||
var boxes = (root || document).querySelectorAll(".verse-container");
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
var ref = verseInfo(boxes[i]).ref;
|
||||
boxes[i].classList.toggle("is-fav", indexOf(favs, function (f) { return f.ref === ref; }) >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (open) {
|
||||
open.box.classList.remove("selected");
|
||||
open.box.removeAttribute("aria-expanded");
|
||||
open.menu.remove();
|
||||
open = null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveButton(isSaved, onToggle) {
|
||||
var button = el("button", "vm-save");
|
||||
button.type = "button";
|
||||
function paint(saved) {
|
||||
button.textContent = saved ? "Saved ✓" : "Save";
|
||||
button.classList.toggle("saved", saved);
|
||||
}
|
||||
paint(isSaved);
|
||||
button.addEventListener("click", function () { paint(onToggle()); });
|
||||
return button;
|
||||
}
|
||||
|
||||
function showDefinitions(info, panel) {
|
||||
panel.textContent = "Looking up words…";
|
||||
var seen = {};
|
||||
var words = (info.text.toLowerCase().match(/[a-z]+/g) || []).filter(function (w) {
|
||||
if (w.length < 3 || seen[w]) {
|
||||
return false;
|
||||
}
|
||||
seen[w] = true;
|
||||
return true;
|
||||
});
|
||||
fetch("/defs?words=" + encodeURIComponent(words.join(",")))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (defs) {
|
||||
panel.textContent = "";
|
||||
var found = words.filter(function (w) { return defs[w]; });
|
||||
if (!found.length) {
|
||||
panel.textContent = "No definitions found for the words in this verse.";
|
||||
return;
|
||||
}
|
||||
var chips = el("div", "vm-chips");
|
||||
var detail = el("div", "vm-detail");
|
||||
detail.hidden = true;
|
||||
found.forEach(function (word) {
|
||||
var chip = el("button", "vm-chip", word);
|
||||
chip.type = "button";
|
||||
chip.addEventListener("click", function () {
|
||||
detail.hidden = false;
|
||||
detail.textContent = "";
|
||||
detail.appendChild(el("strong", "", word));
|
||||
defs[word].forEach(function (d) { detail.appendChild(el("p", "", d)); });
|
||||
var match = function (s) { return s.word === word; };
|
||||
detail.appendChild(saveButton(
|
||||
indexOf(load(KEYS.defs), match) >= 0,
|
||||
function () {
|
||||
return toggle(KEYS.defs, { word: word, defs: defs[word], ref: info.ref }, match);
|
||||
}
|
||||
));
|
||||
});
|
||||
chips.appendChild(chip);
|
||||
});
|
||||
panel.appendChild(el("div", "vm-hint", "Tap a word for its 1828 Webster definition."));
|
||||
panel.appendChild(chips);
|
||||
panel.appendChild(detail);
|
||||
})
|
||||
.catch(function () { panel.textContent = "Could not load definitions."; });
|
||||
}
|
||||
|
||||
function showCrossRefs(info, panel) {
|
||||
if (!info.id) {
|
||||
panel.textContent = "Cross references are not available here.";
|
||||
return;
|
||||
}
|
||||
panel.textContent = "Loading cross references…";
|
||||
fetch("/xref/" + encodeURIComponent(info.id))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (refs) {
|
||||
panel.textContent = "";
|
||||
if (!refs.length) {
|
||||
panel.textContent = "No cross references for this verse.";
|
||||
return;
|
||||
}
|
||||
refs.forEach(function (x) {
|
||||
var row = el("div", "vm-xref");
|
||||
var link = el("a", "", x.ref);
|
||||
link.href = findUrl(x.ref);
|
||||
row.appendChild(link);
|
||||
row.appendChild(el("div", "vm-xref-text", x.text));
|
||||
var match = function (s) { return s.from === info.ref && s.ref === x.ref; };
|
||||
row.appendChild(saveButton(
|
||||
indexOf(load(KEYS.xrefs), match) >= 0,
|
||||
function () {
|
||||
return toggle(KEYS.xrefs, { from: info.ref, ref: x.ref, text: x.text }, match);
|
||||
}
|
||||
));
|
||||
panel.appendChild(row);
|
||||
});
|
||||
})
|
||||
.catch(function () { panel.textContent = "Could not load cross references."; });
|
||||
}
|
||||
|
||||
function openMenu(box) {
|
||||
closeMenu();
|
||||
var info = verseInfo(box);
|
||||
var menu = el("div", "verse-menu");
|
||||
menu.setAttribute("role", "group");
|
||||
menu.setAttribute("aria-label", "Actions for " + info.ref);
|
||||
var actions = el("div", "vm-actions");
|
||||
var panel = el("div", "vm-panel");
|
||||
panel.hidden = true;
|
||||
|
||||
var favMatch = function (f) { return f.ref === info.ref; };
|
||||
var fav = el("button", "vm-btn");
|
||||
fav.type = "button";
|
||||
function paintFav() {
|
||||
var saved = indexOf(load(KEYS.fav), favMatch) >= 0;
|
||||
fav.textContent = saved ? "★ Favorited" : "☆ Favorite";
|
||||
fav.classList.toggle("saved", saved);
|
||||
box.classList.toggle("is-fav", saved);
|
||||
}
|
||||
paintFav();
|
||||
fav.addEventListener("click", function () {
|
||||
toggle(KEYS.fav, { ref: info.ref, text: info.text }, favMatch);
|
||||
paintFav();
|
||||
});
|
||||
|
||||
var define = el("button", "vm-btn", "Define words");
|
||||
define.type = "button";
|
||||
define.addEventListener("click", function () {
|
||||
panel.hidden = false;
|
||||
showDefinitions(info, panel);
|
||||
});
|
||||
|
||||
var xref = el("button", "vm-btn", "Cross references");
|
||||
xref.type = "button";
|
||||
xref.addEventListener("click", function () {
|
||||
panel.hidden = false;
|
||||
showCrossRefs(info, panel);
|
||||
});
|
||||
|
||||
var close = el("button", "vm-btn vm-close", "×");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Close menu");
|
||||
close.addEventListener("click", closeMenu);
|
||||
|
||||
[fav, define, xref, close].forEach(function (b) { actions.appendChild(b); });
|
||||
menu.appendChild(actions);
|
||||
menu.appendChild(panel);
|
||||
box.appendChild(menu);
|
||||
box.classList.add("selected");
|
||||
box.setAttribute("aria-expanded", "true");
|
||||
open = { box: box, menu: menu };
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
var box = event.target.closest && event.target.closest(".verse-container");
|
||||
if (!box) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
// links, buttons and the open menu keep their own behaviour; so does selecting text
|
||||
if (event.target.closest("a, button, input, .verse-menu")) {
|
||||
return;
|
||||
}
|
||||
var selection = window.getSelection && window.getSelection().toString();
|
||||
if (selection) {
|
||||
return;
|
||||
}
|
||||
if (open && open.box === box) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu(box);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
markFavorites();
|
||||
})();
|
||||
Reference in New Issue
Block a user