9b63424740
- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具 - projects/: 各项目 project.yaml 与文案 - interactive/: 井字棋演示页 - .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
220 lines
8.3 KiB
HTML
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> |