Skill Tracer
Skills › Visual Explainer › visual-explainer › ask.js

ask.js

39 lines · plugins/visual-explainer/kit/ask.js at 9d5f880

1// <ve-ask> on a kit page (kit/render.mjs inlines this only when the page has one, after kit/reply.js).
2// An answer the reader gives counts toward Reply (N) and goes into the reply; a question left alone is listed as no answer, even with a default.
3{
4const R = window.veReply, asks = R ? R.$$("main fieldset.ask[data-ask]") : [];
5if (asks.length) {
6 const KEY = `ve-page-ask:${location.pathname}`;
7 const inputs = (a) => R.$$("input", a);
8 const read = (a) => { const ins = inputs(a); return ins[0]?.type === "checkbox" ? ins.filter((i) => i.checked).map((i) => i.value) : ins.find((i) => i.checked)?.value ?? null; };
9 const write = (a, v) => { for (const i of inputs(a)) i.checked = Array.isArray(v) ? v.includes(i.value) : i.value === v; };
10 const defaults = new Map(asks.map((a) => [a, read(a)]));
11 const clean = (s) => s.trim().replace(/\s+/g, " ");
12 const label = (a, v) => clean(inputs(a).find((i) => i.value === v)?.closest("label").querySelector(".opt-label").textContent ?? v);
13 const pick = (a, v = read(a)) => (Array.isArray(v) ? v.map((x) => label(a, x)).join(", ") || "none" : v == null ? "no answer" : label(a, v));
14 const q = (a) => clean(R.$(".ask-q", a).textContent);
15 let done = {};
16 try { done = JSON.parse(localStorage.getItem(KEY) || "{}"); } catch { /* storage can be off for file:// pages */ }
17 const answered = (a) => Object.hasOwn(done, a.dataset.ask);
18 // a saved answer whose options are gone (the page was revised) is dropped
19 for (const a of asks) if (answered(a)) { const v = done[a.dataset.ask]; if ([v].flat().every((x) => inputs(a).some((i) => i.value === x))) write(a, v); else delete done[a.dataset.ask]; }
20 const save = () => { try { if (Object.keys(done).length) localStorage.setItem(KEY, JSON.stringify(done)); else localStorage.removeItem(KEY); } catch { /* see above */ } };
21 const record = (a) => { done[a.dataset.ask] = read(a); save(); R.refresh(); };
22 for (const a of asks) {
23 a.addEventListener("change", () => record(a));
24 // a click, Space, or Enter on the option already selected is an answer too, though no change fires;
25 // where a change does follow, it records the final value
26 a.addEventListener("click", (e) => { if (e.target.matches("input")) record(a); });
27 a.addEventListener("keydown", (e) => { if ((e.key === "Enter" || e.key === " ") && e.target.matches("input:checked")) record(a); });
28 }
29 const state = (a) => (answered(a) ? pick(a) : "no answer");
30 R.parts.push({
31 count: () => asks.filter(answered).length,
32 lines: () => asks.map((a) => `- ${a.dataset.ask} (${q(a)}): ${state(a)}`),
33 rows: () => R.h("section", { class: "r-sec" }, R.h("p", { class: "ve-label caps" }, `Questions · ${asks.filter(answered).length} of ${asks.length} answered`),
34 asks.map((a) => R.h("div", { class: "r-row" }, R.h("span", { class: "k" }, answered(a) ? "✓" : "·"), R.h("span", {}, state(a), R.h("small", {}, q(a))),
35 R.h("span", { class: "acts" }, R.h("button", { type: "button", onclick: () => { R.close(); a.scrollIntoView({ block: "center" }); (inputs(a).find((i) => i.checked) || inputs(a)[0]).focus({ preventScroll: true }); } }, answered(a) ? "Change" : "Answer"))))),
36 clear: () => { done = {}; save(); for (const a of asks) write(a, defaults.get(a)); },
37 });