diff options
Diffstat (limited to 'vrsite/assets/app.js')
| -rw-r--r-- | vrsite/assets/app.js | 212 |
1 files changed, 204 insertions, 8 deletions
diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js index 104cc85..696041c 100644 --- a/vrsite/assets/app.js +++ b/vrsite/assets/app.js @@ -32,13 +32,202 @@ document.addEventListener("keydown", e => { }); // ── theme ───────────────────────────────────────────────────────────────── -const themesel = document.getElementById("themesel"); -if (themesel) { - themesel.value = localStorage.getItem("vrsite-theme") || "system"; - themesel.addEventListener("change", () => { - localStorage.setItem("vrsite-theme", themesel.value); - if (themesel.value === "system") delete root.dataset.theme; - else root.dataset.theme = themesel.value; +// Every theme this machine has is a :root block in themes.css, so wearing one +// is a single attribute — and the menu can therefore preview as it moves. +// Arrow through it or hover and the page changes under you; enter keeps what +// you are wearing, escape puts back the one you came with. +// +// themes.css and themes.js are fetched the first time the menu opens (the +// stylesheet earlier than that, from the head, when a theme is already +// chosen), so a reader who never switches pays nothing for them. +const pick = document.getElementById("themepick"); +if (pick) { + const btn = document.getElementById("themebtn"), + menu = document.getElementById("thememenu"), + find = document.getElementById("themefind"), + list = document.getElementById("themelist"); + const builtin = [ + {id: "system", name: "system", src: "follows the browser"}, + {id: "dark", name: pick.dataset.dark, src: "built with · dark"}, + {id: "light", name: pick.dataset.light, src: "built with · light"}, + ]; + let cur = localStorage.getItem("vrsite-theme") || "system"; + let active = null, filled = false, pending = null; + + const wear = id => { + if (id === "system") delete root.dataset.theme; + else root.dataset.theme = id; + }; + // the name of a theme we may not have the menu for yet: kept beside the id + // so the button says something true before themes.js has loaded + const label = id => { + const b = builtin.find(t => t.id === id); + if (b) return b.name; + const t = (window.VRSITE_THEMES || []).find(t => t.id === id); + return t ? t.name : localStorage.getItem("vrsite-theme-name") || + id.replace(/^[^/]*\//, "").replace(/[-_]/g, " "); + }; + btn.textContent = label(cur); + + // themes.css is a link and themes.js a script, not a fetch: the static + // export is opened from the filesystem, where fetch() is not allowed + const ensure = () => { + if (!document.getElementById("themescss")) { + const l = document.createElement("link"); + l.rel = "stylesheet"; l.id = "themescss"; l.href = pick.dataset.css; + document.head.append(l); + } + if (window.VRSITE_THEMES) return Promise.resolve(); + if (!pending) pending = new Promise(done => { + const s = document.createElement("script"); + s.src = pick.dataset.js; + s.onload = s.onerror = done; + document.head.append(s); + }); + return pending; + }; + + const row = t => { + const li = document.createElement("li"); + li.setAttribute("role", "option"); + li.id = "themeopt-" + list.children.length; + li.dataset.id = t.id; + li.dataset.q = (t.name + " " + t.src + " " + t.id).toLowerCase(); + const sw = document.createElement("span"); + sw.className = t.sw ? "sw" : "sw none"; // empty, but the names still line up + for (const c of t.sw || []) { + const chip = document.createElement("i"); + chip.style.background = c; + sw.append(chip); + } + li.append(sw); + const nm = document.createElement("span"); + nm.className = "nm"; + nm.textContent = t.name; + const src = document.createElement("span"); + src.className = "src"; + src.textContent = t.src; + li.append(nm, src); + return li; + }; + + const fill = () => { + if (filled) return; + filled = true; + // the two the site was built with are usually in the menu as well, so the + // rows for them can wear the same swatch — and "system", both at once + const all = window.VRSITE_THEMES || []; + const swOf = id => (all.find(t => t.id === id) || {}).sw; + const dsw = swOf(pick.dataset.darkid), lsw = swOf(pick.dataset.lightid); + builtin[0].sw = dsw && lsw && [...dsw.slice(0, 3), ...lsw.slice(0, 3)]; + builtin[1].sw = dsw; + builtin[2].sw = lsw; + for (const t of builtin) list.append(row(t)); + for (const t of window.VRSITE_THEMES || []) + list.append(row({...t, src: t.src + (t.dark ? " · dark" : " · light")})); + if (list.children.length === builtin.length) { + const p = document.createElement("li"); + p.className = "meta"; + p.textContent = "no zed or helix themes found on this machine"; + list.append(p); + } + }; + + const setActive = (li, preview = true) => { + if (!li || li === active) return; + if (active) { + active.classList.remove("on"); + active.removeAttribute("aria-selected"); + } + active = li; + li.classList.add("on"); + li.setAttribute("aria-selected", "true"); + find.setAttribute("aria-activedescendant", li.id); + li.scrollIntoView({block: "nearest"}); + if (preview) wear(li.dataset.id); + }; + + const rows = () => [...list.querySelectorAll("li[data-id]:not([hidden])")]; + const rowFor = id => list.querySelector('li[data-id="' + CSS.escape(id) + '"]'); + + const filter = () => { + const q = find.value.trim().toLowerCase(); + for (const li of list.children) + li.hidden = !!q && !(li.dataset.q || "").includes(q); + if (!active || active.hidden) setActive(rows()[0]); + }; + find.addEventListener("input", filter); + + const open = () => { + if (!menu.hidden) return; + menu.hidden = false; + btn.setAttribute("aria-expanded", "true"); + find.value = ""; + find.focus(); + ensure().then(() => { + fill(); + filter(); + for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur"); + const here = rowFor(cur); + if (here) here.classList.add("cur"); + setActive(here || rows()[0], false); + }); + }; + + // leaving the menu either keeps what is on screen or puts back what was + // there before it opened — nothing is stored until it is kept + const close = keep => { + if (menu.hidden) return; + if (keep && active) { + cur = active.dataset.id; + localStorage.setItem("vrsite-theme", cur); + localStorage.setItem("vrsite-theme-name", label(cur)); + btn.textContent = label(cur); + for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur"); + active.classList.add("cur"); + } + wear(cur); + menu.hidden = true; + btn.setAttribute("aria-expanded", "false"); + }; + + btn.addEventListener("click", () => (menu.hidden ? open() : close(false))); + // mousemove, not mouseover: arrowing scrolls rows under a still pointer, + // and the pointer must not then take the selection back + list.addEventListener("mousemove", e => { + const li = e.target.closest("li[data-id]"); + if (li) setActive(li); + }); + list.addEventListener("click", e => { + const li = e.target.closest("li[data-id]"); + if (li) { setActive(li); close(true); } + }); + find.addEventListener("keydown", e => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const all = rows(); + if (!all.length) return; + const i = all.indexOf(active), step = e.key === "ArrowDown" ? 1 : -1; + setActive(all[(i + step + all.length) % all.length]); + } else if (e.key === "Enter") { + e.preventDefault(); + close(true); + btn.focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + close(false); + btn.focus(); + } + }); + document.addEventListener("click", e => { + if (!menu.hidden && !pick.contains(e.target)) close(false); + }); + document.addEventListener("keydown", e => { + if (e.key === "Escape") close(false); + if (e.key === "T" && !e.metaKey && !e.ctrlKey && !e.altKey && !typing(e)) { + e.preventDefault(); // or the T lands in the filter we just focused + open(); + } }); } @@ -165,6 +354,10 @@ for (const w of document.querySelectorAll(".notewrap[data-href]")) { // ?note=N means someone followed a note here rather than opening the file: // show what they came for — notes focus (coverage hides the note), the card // flashed in the pane, the lines it covers marked. +// A permalink is the same arrival with nothing to scroll to: the page is the +// note, so it only needs the focus that shows notes at all. +if (document.body.dataset.kind === "note") setMode("notes"); + const want = new URLSearchParams(location.search).get("note"); if (want && document.body.dataset.kind === "file" && /^\d+$/.test(want)) { setMode("notes"); @@ -200,7 +393,10 @@ if (filters) { from = val("from"), to = val("to"); let shown = 0; for (const art of document.querySelectorAll("#notelist .note")) { - const d = art.dataset, body = art.querySelector("p").textContent.toLowerCase(); + // the whole rendered body, not its first <p>: a note is markdown, so it + // may open with a list or a code fence and run to several blocks + const d = art.dataset, el = art.querySelector(".body"); + const body = (el ? el.textContent : "").toLowerCase(); const ok = (!text || body.includes(text)) && (!file || (d.file || "").toLowerCase().includes(file)) && |
