Files
AshuaiXsinfo/src/components/terminal/terminal.html
T
xiaoshuai 9b63424740 chore: 初始化仓库,提交 vibevideo 源码
- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具
- projects/: 各项目 project.yaml 与文案
- interactive/: 井字棋演示页
- .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
2026-10-12 18:06:20 +08:00

220 lines
8.3 KiB
HTML

<!doctype html>
<html lang="zh-CN"
data-composition-variables='[{"id": "title", "type": "string", "label": "窗口标题", "default": "终端"},{"id": "prompt", "type": "string", "label": "命令提示符", "default": "$ "},{"id": "script", "type": "string", "label": "脚本(JSON 数组)", "default": "[{\"text\":\"npm run build\",\"kind\":\"cmd\"}]"}]'>
<head>
<meta charset="UTF-8" />
<title>终端</title>
<link rel="stylesheet" href="assets/brand/tokens.css" />
<link rel="stylesheet" href="assets/brand/base.css" />
<style>
.stage {
display: flex;
align-items: center;
justify-content: center;
}
.term {
width: var(--panel-w);
background: #0d0d10;
border: calc(1 * var(--u)) solid var(--border);
border-radius: calc(16 * var(--u));
overflow: hidden;
box-shadow: 0 calc(32 * var(--u)) calc(90 * var(--u)) rgba(0, 0, 0, 0.55);
}
.titlebar {
display: flex;
align-items: center;
gap: calc(18 * var(--u));
padding: calc(15 * var(--u)) calc(22 * var(--u));
background: var(--surface-2);
border-bottom: calc(1 * var(--u)) solid var(--border);
}
.dots { display: flex; gap: calc(10 * var(--u)); }
.dot { width: calc(14 * var(--u)); height: calc(14 * var(--u)); border-radius: 50%; }
.dot.r { background: #ff5f57; }
.dot.y { background: #febc2e; }
.dot.g { background: #28c840; }
.title {
font-family: var(--font-mono);
font-size: calc(19 * var(--u));
color: var(--text-dim);
}
.screen {
padding: calc(22 * var(--u)) calc(26 * var(--u)) calc(26 * var(--u));
font-family: var(--font-mono);
font-size: calc(21 * var(--u));
line-height: calc(36 * var(--u));
min-height: calc(200 * var(--u));
}
/* 每行:外层固定宽度负责裁切,内层 auto 宽度负责正常排版。
注意:不能把 width 直接动画到 .row 上——那样容器宽度被锁死,
长行(尤其中文)不会换行,会被挤成竖排。实测踩过。 */
/* 每行固定行高,内容自然宽度。
打字效果用 clip-path 的 inset 从右往左揭开——
不要动画 width:容器宽度被锁死后长行不换行,中文会挤成竖排。
clip-path 只影响显示,不影响布局,所以换行照常。 */
.row { position: relative; height: calc(36 * var(--u)); }
.row .inner { white-space: pre-wrap; word-break: break-word; }
/* 光标钉在「已揭示宽度」的右边缘 */
.row > .caret {
position: absolute;
top: calc(6 * var(--u));
z-index: 2;
margin-left: 0;
}
/* 提示符 */
.prompt { color: var(--syn-string); }
.cmd { color: var(--text); }
/* 输出类型 */
.out { color: var(--text-dim); }
.ok { color: var(--syn-string); }
.err { color: var(--err); }
.warn { color: var(--warn); }
.dim { color: var(--text-faint); }
.path { color: var(--syn-type); }
.caret {
display: inline-block;
width: calc(10 * var(--u));
height: calc(22 * var(--u));
background: var(--accent);
vertical-align: text-bottom;
margin-left: calc(2 * var(--u));
}
</style>
</head>
<body>
<div
class="stage"
data-composition-id="terminal"
data-width="1920"
data-height="1080"
data-duration="6"
>
<div class="term">
<div class="titlebar">
<div class="dots">
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
</div>
<span id="title" class="title">终端</span>
</div>
<div id="screen" class="screen"></div>
</div>
<script>
const v = window.hv ? hv.readVars() : null;
const str = (k, d) => (v ? v.str(k, d) : d);
document.getElementById("title").textContent = str("title", "终端");
/* script 是一段 JSON 数组,每项 { text, kind }。
kind: cmd 输入命令 / out 普通输出 / ok 成功 / err 报错 / warn 警告 / dim 次要 */
let steps;
try {
steps = JSON.parse(str("script", '[{"text":"npm run build","kind":"cmd"},{"text":"✓ 构建完成","kind":"ok"}]'));
} catch {
steps = [{ text: "脚本格式错误,应为 JSON 数组", kind: "err" }];
}
if (!Array.isArray(steps)) steps = [{ text: "脚本格式错误,应为 JSON 数组", kind: "err" }];
// 时长跟随宿主(可能由配音决定),拿不到才用组件默认值
const DUR = (window.hv && hv.duration()) || 6;
const promptStr = str("prompt", "$ ");
const screen = document.getElementById("screen");
const rows = [];
const carets = [];
steps.forEach((s) => {
const row = document.createElement("div");
row.className = "row";
const inner = document.createElement("div");
inner.className = "inner";
if (s.kind === "cmd") {
const p = document.createElement("span");
p.className = "prompt";
p.textContent = promptStr;
const c = document.createElement("span");
c.className = "cmd";
c.textContent = s.text || "";
inner.appendChild(p);
inner.appendChild(c);
} else {
inner.classList.add(s.kind || "out");
inner.textContent = s.text || "";
}
const caret = document.createElement("span");
caret.className = "caret";
row.appendChild(inner);
row.appendChild(caret);
screen.appendChild(row);
rows.push(row);
carets.push({ el: caret, row, inner });
});
// 量出每行自然宽度:光标钉在这个宽度的位置
const widths = rows.map((r) => Math.max(1, r.querySelector(".inner").getBoundingClientRect().width));
const START = 0.35;
const typeWindow = Math.max(0.8, DUR - START - 0.7);
const weights = steps.map((s) => Math.max(4, String(s.text || "").length));
const totalW = weights.reduce((a, b) => a + b, 0);
const rowStart = [];
const rowDur = [];
{
let acc = 0;
steps.forEach((s, i) => {
const d = Math.max(0.12, (weights[i] / totalW) * typeWindow);
rowStart.push(START + acc);
rowDur.push(d);
acc += d;
});
}
const tl = gsap.timeline({ paused: true });
tl.fromTo(".term", { opacity: 0, y: 30, scale: 0.98 }, { opacity: 1, y: 0, scale: 1, duration: 0.4, ease: "power2.out" }, 0);
rows.forEach((r, i) => {
const inner = carets[i].inner;
const caret = carets[i].el;
// clip-path 从右往左揭开 = 打字。只影响显示,布局宽度不变
gsap.set(inner, { clipPath: "inset(0 100% 0 0)" });
gsap.set(caret, { opacity: 0 });
tl.fromTo(
inner,
{ clipPath: "inset(0 100% 0 0)" },
{ clipPath: "inset(0 0% 0 0)", duration: rowDur[i], ease: "none" },
rowStart[i]
);
// 光标跟着揭示进度走
tl.fromTo(caret, { left: 0 }, { left: widths[i], duration: rowDur[i], ease: "none" }, rowStart[i]);
tl.set(caret, { opacity: 1 }, rowStart[i]);
tl.set(caret, { opacity: 0 }, rowStart[i] + rowDur[i]);
// 输入行打字期间闪烁
for (let k = 0; rowStart[i] + k * 0.45 < rowStart[i] + rowDur[i]; k++) {
tl.set(caret, { opacity: k % 2 === 0 ? 1 : 0.2 }, rowStart[i] + k * 0.45);
}
});
const last = rows.length - 1;
if (last >= 0) {
// 打完最后一行后,光标停在末尾常亮
for (let k = 0; rowStart[last] + rowDur[last] + k * 0.45 < DUR - 0.3; k++) {
tl.set(carets[last].el, { opacity: k % 2 === 0 ? 1 : 0.2 }, rowStart[last] + rowDur[last] + k * 0.45);
}
}
tl.to(".term", { opacity: 0, y: -14, duration: 0.28, ease: "power1.in" }, DUR - 0.28);
window.__timelines = window.__timelines || {};
window.__timelines["terminal"] = tl;
tl.seek(0);
</script>
</div>
</body>
</html>