Files
kjv_api/js/verse_menu.js

380 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Every verse card gets an actions button (and the card itself is tappable)
// that opens a small menu: favorite the verse, write a note on it, look up the
// 1828 Webster definition of its words, or see its cross references.
// Everything saved lives in this browser's localStorage (see the /saved page).
(function () {
var KEYS = { fav: "kjv.favorites", defs: "kjv.definitions", xrefs: "kjv.crossrefs", notes: "kjv.notes" };
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 noteFor(ref) {
var list = load(KEYS.notes);
var at = indexOf(list, function (n) { return n.ref === ref; });
return at >= 0 ? list[at].note : "";
}
// Shows the saved note (if any) under the verse text.
function renderNote(box) {
var old = box.querySelector(".verse-note");
if (old) {
old.remove();
}
var info = verseInfo(box);
var note = noteFor(info.ref);
box.classList.toggle("has-note", !!note);
if (!note) {
return;
}
var text = box.querySelector(".verse-text");
var node = el("div", "verse-note", note);
node.setAttribute("title", "Your note");
if (text) {
text.after(node);
} else {
box.appendChild(node);
}
}
function markSaved(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);
renderNote(boxes[i]);
}
}
// A visible way in to the menu on every verse, so nobody has to guess that the card is tappable.
function addMenuButtons() {
var boxes = document.querySelectorAll(".verse-container");
for (var i = 0; i < boxes.length; i++) {
var header = boxes[i].querySelector(".verse-reference");
if (!header || header.querySelector(".vm-more")) {
continue;
}
var button = el("button", "vm-more", "\u22ef");
button.type = "button";
button.setAttribute("aria-label", "Verse actions: notes, favorite, define, cross references");
button.setAttribute("title", "Notes, favorite, define, cross references");
button.addEventListener("click", (function (box) {
return function () { toggleMenu(box); };
})(boxes[i]));
header.appendChild(button);
}
var count = document.querySelector(".results-count");
if (count && boxes.length && !document.querySelector(".verse-hint")) {
count.after(el("div", "verse-hint", "Tap a verse, or its \u22ef button, to add a note, favorite it, define words or see cross references."));
}
}
function toggleMenu(box) {
if (open && open.box === box) {
closeMenu();
} else {
openMenu(box);
}
}
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."; });
}
// Notes save as you type; clearing the box deletes the note.
function showNote(info, panel, box, onChange) {
panel.textContent = "";
var area = el("textarea", "vm-note-input");
area.rows = 4;
area.placeholder = "Your note on " + info.ref + "...";
area.setAttribute("aria-label", "Note for " + info.ref);
area.value = noteFor(info.ref);
var status = el("div", "vm-hint", "Saved in this browser as you type.");
var timer = null;
function commit() {
var value = area.value.trim();
var list = load(KEYS.notes);
var at = indexOf(list, function (n) { return n.ref === info.ref; });
if (!value) {
if (at >= 0) {
list.splice(at, 1);
}
} else if (at >= 0) {
list[at].note = value;
list[at].t = Date.now();
} else {
list.unshift({ ref: info.ref, text: info.text, note: value, t: Date.now() });
}
save(KEYS.notes, list);
renderNote(box);
onChange();
status.textContent = value ? "Saved." : "Note removed.";
}
area.addEventListener("input", function () {
status.textContent = "Saving\u2026";
clearTimeout(timer);
timer = setTimeout(commit, 400);
});
area.addEventListener("blur", function () {
clearTimeout(timer);
commit();
});
panel.appendChild(area);
panel.appendChild(status);
area.focus();
}
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 noteBtn = el("button", "vm-btn");
noteBtn.type = "button";
function paintNote() {
var has = !!noteFor(info.ref);
noteBtn.textContent = has ? "\u270e Edit note" : "\u270e Note";
noteBtn.classList.toggle("saved", has);
}
paintNote();
noteBtn.addEventListener("click", function () {
panel.hidden = false;
showNote(info, panel, box, paintNote);
});
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, noteBtn, 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, textarea, .verse-menu")) {
return;
}
var selection = window.getSelection && window.getSelection().toString();
if (selection) {
return;
}
toggleMenu(box);
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && !(event.target && event.target.tagName === "TEXTAREA")) {
closeMenu();
}
});
markSaved();
addMenuButtons();
})();