site.js: typewriter output for command results
print() now queues line nodes; a small state machine writes them character by character with a blinking caret (5ms/char, 0 when prefers-reduced-motion). Any keypress or click flushes pending output instantly; clear aborts it.
This commit is contained in:
+141
-18
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user