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:
+138
-15
@@ -138,33 +138,153 @@ const make = (tag, cls, value) => {
|
|||||||
|
|
||||||
const scrollDown = () => (screen.scrollTop = screen.scrollHeight);
|
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) {
|
function print(value, arg) {
|
||||||
const resolved = typeof value === "function" ? value(arg) : value;
|
const resolved = typeof value === "function" ? value(arg) : value;
|
||||||
|
const lines = [];
|
||||||
if (resolved && resolved.error) {
|
if (resolved && resolved.error) {
|
||||||
out.append(make("div", "line err", resolved.error));
|
lines.push(build(resolved.error, "line err"));
|
||||||
resolved.more.forEach((line) => out.append(make("div", "line dim", line)));
|
resolved.more.forEach((line) => lines.push(build(line, "line dim")));
|
||||||
} else {
|
} 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+)/;
|
const LINK = /(https?:\/\/\S+|mailto:\S+)/;
|
||||||
|
|
||||||
function linked(line) {
|
function build(line, cls) {
|
||||||
const node = make("span", "line out");
|
const node = make("span", cls);
|
||||||
if (!LINK.test(line)) return (node.textContent = line), node;
|
const segs = [];
|
||||||
|
if (!LINK.test(line)) {
|
||||||
|
segs.push({ text: line || "\u200b" });
|
||||||
|
} else {
|
||||||
const arrow = line.indexOf("->");
|
const arrow = line.indexOf("->");
|
||||||
if (arrow > 0) node.append(line.slice(0, arrow));
|
if (arrow > 0) segs.push({ text: line.slice(0, arrow) });
|
||||||
(arrow > 0 ? line.slice(arrow + 2) : line).split(LINK).forEach((part) => {
|
(arrow > 0 ? line.slice(arrow + 2) : line).split(LINK).forEach((part) => {
|
||||||
if (!LINK.test(part)) return node.append(part);
|
if (!part) return;
|
||||||
const anchor = make("a", null, part);
|
if (!LINK.test(part)) return segs.push({ text: part });
|
||||||
anchor.href = part;
|
segs.push({ href: part, text: part });
|
||||||
if (part.startsWith("http")) anchor.rel = "noopener me";
|
|
||||||
node.append(anchor);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
node.segs = segs;
|
||||||
return node;
|
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 ----------
|
// ---------- the loop ----------
|
||||||
@@ -186,9 +306,10 @@ function execute(raw) {
|
|||||||
const arg = rest.join(" ");
|
const arg = rest.join(" ");
|
||||||
const name = word.toLowerCase();
|
const name = word.toLowerCase();
|
||||||
|
|
||||||
|
flush();
|
||||||
out.append(make("div", "line cmd", raw));
|
out.append(make("div", "line cmd", raw));
|
||||||
if (!name) return scrollDown();
|
if (!name) return scrollDown();
|
||||||
if (name === "clear") return (out.textContent = "");
|
if (name === "clear") return kill(), (out.textContent = "");
|
||||||
|
|
||||||
const wanted = ALIASES[name] || name;
|
const wanted = ALIASES[name] || name;
|
||||||
const found =
|
const found =
|
||||||
@@ -221,6 +342,7 @@ form.addEventListener("submit", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener("keydown", (event) => {
|
input.addEventListener("keydown", (event) => {
|
||||||
|
flush();
|
||||||
if (event.key === "ArrowUp") { event.preventDefault(); recall(-1); }
|
if (event.key === "ArrowUp") { event.preventDefault(); recall(-1); }
|
||||||
if (event.key === "ArrowDown") { event.preventDefault(); recall(1); }
|
if (event.key === "ArrowDown") { event.preventDefault(); recall(1); }
|
||||||
if (event.key !== "Tab") return;
|
if (event.key !== "Tab") return;
|
||||||
@@ -230,6 +352,7 @@ input.addEventListener("keydown", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.addEventListener("click", () => {
|
screen.addEventListener("click", () => {
|
||||||
|
flush();
|
||||||
if (!getSelection().toString()) input.focus();
|
if (!getSelection().toString()) input.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user