diff --git a/k8s/site/site.js b/k8s/site/site.js index 2768262..6fdc6df 100644 --- a/k8s/site/site.js +++ b/k8s/site/site.js @@ -138,33 +138,153 @@ const make = (tag, cls, value) => { const scrollDown = () => (screen.scrollTop = screen.scrollHeight); +// ---------- typewriter ---------- +// print() never renders directly: it queues fully-built line nodes and this +// machine writes them character by character. A keypress or click while +// typing is active dumps the pending output instantly; `clear` drops it. +const TYPE_MS = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 5; +let queue = []; +let typing = null; +let fast = false; +let gen = 0; +let dumpNow = null; + +// finish every pending line synchronously, then let the queue drain instantly +const flatten = (node) => + node.segs.forEach((seg) => node.append(seg.href ? anchor(seg) : seg.text)); + +const flush = () => { + if (!typing && !queue.length) return; + fast = true; + if (dumpNow) dumpNow(); + else if (typing) write(); + else pump(); +}; + +const kill = () => { + gen++; + queue.length = 0; + typing = null; + dumpNow = null; + fast = false; +}; + function print(value, arg) { const resolved = typeof value === "function" ? value(arg) : value; + const lines = []; if (resolved && resolved.error) { - out.append(make("div", "line err", resolved.error)); - resolved.more.forEach((line) => out.append(make("div", "line dim", line))); + lines.push(build(resolved.error, "line err")); + resolved.more.forEach((line) => lines.push(build(line, "line dim"))); } else { - split(render(resolved, arg)).forEach((line) => out.append(linked(line))); + split(render(resolved, arg)).forEach((line) => lines.push(build(line, "line out"))); } - scrollDown(); + queue.push(lines); + pump(); } -// Turn `label -> url` (or a bare url) into text plus real anchors. +// Split a line into typed fragments: plain text, or a link that lands whole. const LINK = /(https?:\/\/\S+|mailto:\S+)/; -function linked(line) { - const node = make("span", "line out"); - if (!LINK.test(line)) return (node.textContent = line), node; - const arrow = line.indexOf("->"); - if (arrow > 0) node.append(line.slice(0, arrow)); - (arrow > 0 ? line.slice(arrow + 2) : line).split(LINK).forEach((part) => { - if (!LINK.test(part)) return node.append(part); - const anchor = make("a", null, part); - anchor.href = part; - if (part.startsWith("http")) anchor.rel = "noopener me"; - node.append(anchor); - }); +function build(line, cls) { + const node = make("span", cls); + const segs = []; + if (!LINK.test(line)) { + segs.push({ text: line || "\u200b" }); + } else { + const arrow = line.indexOf("->"); + if (arrow > 0) segs.push({ text: line.slice(0, arrow) }); + (arrow > 0 ? line.slice(arrow + 2) : line).split(LINK).forEach((part) => { + if (!part) return; + if (!LINK.test(part)) return segs.push({ text: part }); + segs.push({ href: part, text: part }); + }); + } + node.segs = segs; return node; +}; + +const anchor = (seg) => { + const a = make("a", null, seg.text); + a.href = seg.href; + if (!seg.href.startsWith("mailto:")) a.rel = "noopener me"; + return a; +}; + +function pump() { + if (typing || !queue.length) return; + typing = { g: gen, lines: queue.shift(), i: 0 }; + write(); +} + +function write() { + if (!typing) return; + if (typing.i >= typing.lines.length) { + typing = null; + if (!queue.length) fast = false; + scrollDown(); + return pump(); + } + const node = typing.lines[typing.i]; + out.append(node); + if (fast) { + flatten(node); + typing.i++; + scrollDown(); + return write(); + } + const g = typing.g; + typeSegs(node, g, () => { + if (typing && typing.g === g) typing.i++; + write(); + }); +} + +function typeSegs(node, g, ready) { + const segs = node.segs; + if (!TYPE_MS) return dump(segs, null); + const caret = make("span", "cursor"); + let si = 0, ci = 0, tn = null; + node.append(caret); + dumpNow = () => { dumpNow = null; dump(segs, caret); }; + + (function tick() { + if (g !== gen) return; + if (fast) return dump(segs, caret); + if (si >= segs.length) return dump(segs, caret); + const seg = segs[si]; + if (seg.href) { + node.insertBefore(anchor(seg), caret); + si++; + return setTimeout(tick, TYPE_MS * 6); + } + if (!tn) { + tn = document.createTextNode(""); + node.insertBefore(tn, caret); + } + if (ci < seg.text.length) { + tn.appendData(seg.text[ci++]); + if (ci % 8 === 0) scrollDown(); + return setTimeout(tick, TYPE_MS); + } + si++, (ci = 0), (tn = null); + tick(); + })(); + + function dump(segs, caret) { + dumpNow = null; + if (tn) { + const seg = segs[si]; + if (seg && !seg.href) tn.appendData(seg.text.slice(ci)); + si++, (ci = 0); + } + caret && caret.remove(); + for (; si < segs.length; si++) { + const seg = segs[si]; + seg.href ? node.append(anchor(seg)) : node.append(seg.text); + } + scrollDown(); + ready(); + } } // ---------- the loop ---------- @@ -186,9 +306,10 @@ function execute(raw) { const arg = rest.join(" "); const name = word.toLowerCase(); + flush(); out.append(make("div", "line cmd", raw)); if (!name) return scrollDown(); - if (name === "clear") return (out.textContent = ""); + if (name === "clear") return kill(), (out.textContent = ""); const wanted = ALIASES[name] || name; const found = @@ -221,6 +342,7 @@ form.addEventListener("submit", (event) => { }); input.addEventListener("keydown", (event) => { + flush(); if (event.key === "ArrowUp") { event.preventDefault(); recall(-1); } if (event.key === "ArrowDown") { event.preventDefault(); recall(1); } if (event.key !== "Tab") return; @@ -230,6 +352,7 @@ input.addEventListener("keydown", (event) => { }); screen.addEventListener("click", () => { + flush(); if (!getSelection().toString()) input.focus(); });