// 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(); })();