Files
OSGKeyboard/docs/assets/site.js
T
Rocky f3f0da707a docs(pages): match keyboard mockups to the real assistant UI
- Rebuild the story phone keyboard from AIKeyboardView: logo, AI/中文/EN
  tabs, translation and clipboard chips, mic capsule states (dictation red,
  hold-for-AI teal, busy spinner, inserted check), delete/space circles,
  return capsule, and undo/edit after insertion
- Replace invented tone chips with the real three reply cards (ordinary,
  formal, relaxed & playful) using the in-app demo copy
- Show the real copy flow: Paste plus ranked skill capsules in the top bar,
  "Finding events…" and "Adding to Calendar…" status text
- Replace the outdated clipboard-skills video (retired circular chips) with
  a live replica of the current top-bar skill flow
- Rework the hero capsule around the real mic capsule states
- Align FAQ, JSON-LD, and llms.txt wording with the shipped UI
2026-10-01 09:42:33 +08:00

967 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* OSGKeyboard 主页交互与动效引擎。
* 无 JavaScript 时页面内容完整可读;本文件负责:
* 1. 中 / 英双语切换(data-zh / data-en 系列属性,含视频源)
* 2. 深浅色主题(默认跟随系统,手动选择后持久化;支持时以圆形扩散过渡)
* 3. 移动端导航开合、导航滑块与当前区块高亮
* 4. 逐字拆分标题(data-split)
* 5. 滚动引擎:进度条、Hero 设备「立起」、媒体视差、三幕滚动叙事
* 6. 声波渲染、Hero 语音胶囊打字机、数字计数
* 7. 指针光斑 / 3D 倾斜 / 磁吸按钮、FAQ 高度过渡、离屏视频暂停
* 开启「减弱动态效果」时,持续动画与指针动效全部停用,内容直接呈现最终态。
*/
(function () {
"use strict";
const root = document.documentElement;
const siteNav = document.getElementById("siteNav");
const languageButton = document.getElementById("languageToggle");
const themeButton = document.getElementById("themeToggle");
const themeIconUse = document.getElementById("themeIconUse");
const menuButton = document.getElementById("menuToggle");
const menuIconUse = document.getElementById("menuIconUse");
const mobileNav = document.getElementById("mobileNav");
const themeColor = document.querySelector('meta[name="theme-color"]');
const themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
const supportedLanguages = new Set(["zh", "en"]);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const canObserve = "IntersectionObserver" in window;
const TITLES = {
zh: "OSGKeyboard|AI 语音输入法 · 嘴替输入法 · iPhone / iPad / Mac",
en: "OSGKeyboard — Agentic Keyboard & AI Voice Keyboard for iPhone and Mac"
};
const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
// 语言切换后需要重算的模块(胶囊文案、叙事文案、导航滑块)在此登记
const languageHooks = [];
/* ---------------- 逐字拆分 ---------------- */
// 每个分组是一段不可断开的文字:英文按词,中文按「到下一个标点为止」的短句,
// 与原标题的 word-break: keep-all 断行效果一致,标点也不会落到行首
const CJK_PUNCTUATION = /[,。、!?:;)」』》…~]/;
function groupCharacters(token) {
const groups = [];
let current = [];
for (const character of token) {
current.push(character);
if (CJK_PUNCTUATION.test(character)) {
groups.push(current);
current = [];
}
}
if (current.length) groups.push(current);
return groups;
}
function splitText(element) {
const text = element.textContent.trim();
// 读屏读取完整句子,逐字拆分的视觉层对辅助技术隐藏
const readable = document.createElement("span");
readable.className = "sr-only";
readable.textContent = text;
const visual = document.createElement("span");
visual.setAttribute("aria-hidden", "true");
let index = 0;
text.split(/(\s+)/).forEach((token) => {
if (!token) return;
if (/^\s+$/.test(token)) {
visual.append(" ");
return;
}
groupCharacters(token).forEach((group) => {
const word = document.createElement("span");
word.className = "w";
group.forEach((character) => {
const span = document.createElement("span");
span.className = "ch";
span.style.setProperty("--ci", String(index++));
span.textContent = character;
word.append(span);
});
visual.append(word);
});
});
element.replaceChildren(readable, visual);
element.classList.add("is-split");
}
function splitAll() {
document.querySelectorAll("[data-split]").forEach(splitText);
}
/* ---------------- 语言 ---------------- */
function preferredLanguage() {
const query = new URLSearchParams(window.location.search).get("lang");
if (supportedLanguages.has(query)) return query;
const saved = localStorage.getItem("osg-site-language");
if (supportedLanguages.has(saved)) return saved;
return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
}
function applyLanguage(language, persist) {
const lang = supportedLanguages.has(language) ? language : "zh";
root.lang = lang === "zh" ? "zh-Hans" : "en";
root.dataset.lang = lang;
document.querySelectorAll("[data-zh][data-en]").forEach((element) => {
element.textContent = element.dataset[lang] || "";
});
document.querySelectorAll("[data-zh-html][data-en-html]").forEach((element) => {
element.innerHTML = element.dataset[`${lang}Html`] || "";
});
document.querySelectorAll("[data-zh-alt][data-en-alt]").forEach((element) => {
element.alt = element.dataset[`${lang}Alt`] || "";
});
// 同时覆盖 <img> 与 <video>:切换语言后视频自动重新加载播放
document.querySelectorAll("[data-zh-src][data-en-src]").forEach((element) => {
const next = element.dataset[`${lang}Src`] || "";
if (element.getAttribute("src") !== next) element.src = next;
});
document.querySelectorAll("[data-zh-href][data-en-href]").forEach((element) => {
element.href = element.dataset[`${lang}Href`] || "";
});
document.querySelectorAll("[data-zh-label][data-en-label]").forEach((element) => {
element.setAttribute("aria-label", element.dataset[`${lang}Label`] || "");
});
document.querySelectorAll("[data-zh-content][data-en-content]").forEach((element) => {
element.setAttribute("content", element.dataset[`${lang}Content`] || "");
});
// 实机截图按 语言 + 主题 组合切换
document.querySelectorAll("[data-shot]").forEach((image) => {
const theme = root.dataset.theme || "light";
image.src = `assets/screenshots/${lang}/${theme}/${image.dataset.shot}`;
});
document.title = TITLES[lang];
if (languageButton) {
languageButton.textContent = lang === "zh" ? "EN" : "中文";
languageButton.setAttribute(
"aria-label",
lang === "zh" ? "切换到英文" : "Switch to Chinese"
);
}
if (menuButton) {
const isOpen = menuButton.getAttribute("aria-expanded") === "true";
menuButton.setAttribute(
"aria-label",
lang === "zh"
? (isOpen ? "关闭导航" : "打开导航")
: (isOpen ? "Close navigation" : "Open navigation")
);
}
// 文字已被 data-zh / data-en 重写,需要重新拆分才能继续逐字动效
splitAll();
languageHooks.forEach((hook) => hook(lang));
if (persist) {
localStorage.setItem("osg-site-language", lang);
const url = new URL(window.location.href);
if (lang === "en") {
url.searchParams.set("lang", "en");
} else {
url.searchParams.delete("lang");
}
history.replaceState({}, "", url);
}
}
/* ---------------- 主题 ---------------- */
function storedTheme() {
const saved = localStorage.getItem("osg-site-theme");
return saved === "light" || saved === "dark" ? saved : null;
}
function applyTheme(theme, persist) {
const resolved = theme === "dark" ? "dark" : "light";
root.dataset.theme = resolved;
root.style.colorScheme = resolved;
// 图标指向「点击后切换到的目标主题」
if (themeIconUse) {
themeIconUse.setAttribute("href", resolved === "dark" ? "#i-sun" : "#i-moon");
}
if (themeButton) {
const lang = root.dataset.lang || "zh";
const label = resolved === "dark"
? (lang === "zh" ? "切换到浅色模式" : "Switch to light mode")
: (lang === "zh" ? "切换到深色模式" : "Switch to dark mode");
themeButton.setAttribute("aria-label", label);
}
// 深色舞台在各主题下配色一致,theme-color 跟随舞台色
themeColor?.setAttribute("content", "#0e1013");
document.querySelectorAll("[data-shot]").forEach((image) => {
const lang = root.dataset.lang || "zh";
image.src = `assets/screenshots/${lang}/${resolved}/${image.dataset.shot}`;
});
if (persist) localStorage.setItem("osg-site-theme", resolved);
}
// 以主题按钮为圆心,新主题从一个点扩散覆盖整页(View Transitions API)
function toggleThemeWithTransition() {
const next = root.dataset.theme === "dark" ? "light" : "dark";
if (!document.startViewTransition || reduceMotion || !themeButton) {
applyTheme(next, true);
return;
}
const rect = themeButton.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
root.classList.add("vt-theme");
const transition = document.startViewTransition(() => applyTheme(next, true));
transition.ready
.then(() => {
root.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{
duration: 760,
easing: "cubic-bezier(0.65, 0, 0.35, 1)",
pseudoElement: "::view-transition-new(root)"
}
);
})
.catch(() => {});
transition.finished.finally(() => root.classList.remove("vt-theme"));
}
/* ---------------- 移动端导航 ---------------- */
function setMenuOpen(isOpen) {
if (!menuButton || !mobileNav) return;
const open = Boolean(isOpen);
menuButton.setAttribute("aria-expanded", String(open));
mobileNav.hidden = !open;
if (menuIconUse) menuIconUse.setAttribute("href", open ? "#i-close" : "#i-menu");
const lang = root.dataset.lang || "zh";
menuButton.setAttribute(
"aria-label",
lang === "zh"
? (open ? "关闭导航" : "打开导航")
: (open ? "Close navigation" : "Open navigation")
);
}
/* ---------------- 导航滑块与当前区块 ---------------- */
const navHost = document.getElementById("navLinks");
const navLinks = navHost ? Array.from(navHost.querySelectorAll("a")) : [];
let activeNavLink = null;
function moveIndicator(link) {
if (!navHost) return;
if (!link) {
navHost.classList.remove("has-indicator");
return;
}
navHost.style.setProperty("--ix", `${link.offsetLeft}px`);
navHost.style.setProperty("--iw", `${link.offsetWidth}px`);
navHost.classList.add("has-indicator");
}
function setActiveNav(link) {
activeNavLink = link;
navLinks.forEach((item) => item.classList.toggle("is-active", item === link));
moveIndicator(link);
}
navLinks.forEach((link) => {
link.addEventListener("mouseenter", () => moveIndicator(link));
link.addEventListener("focus", () => moveIndicator(link));
});
navHost?.addEventListener("mouseleave", () => moveIndicator(activeNavLink));
languageHooks.push(() => requestAnimationFrame(() => moveIndicator(activeNavLink)));
if (canObserve && navLinks.length) {
const sectionToLink = new Map();
navLinks.forEach((link) => {
const section = document.querySelector(link.getAttribute("href"));
if (section) sectionToLink.set(section, link);
});
const visibleSections = new Set();
const spy = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) visibleSections.add(entry.target);
else visibleSections.delete(entry.target);
});
// 取文档顺序中最后一个处于判定带内的区块
let current = null;
sectionToLink.forEach((link, section) => {
if (visibleSections.has(section)) current = link;
});
if (current !== activeNavLink) setActiveNav(current);
},
{ rootMargin: "-45% 0px -50% 0px" }
);
sectionToLink.forEach((_, section) => spy.observe(section));
}
/* ---------------- 声波渲染 ---------------- */
// 每个声波实例:bars 为条形元素,level 为目标音量,amp 为插值后的当前音量
const waves = [];
function createWave(container, options = {}) {
const count = Number(container.dataset.wave) || 16;
const bars = [];
for (let i = 0; i < count; i += 1) {
const bar = document.createElement("i");
container.append(bar);
bars.push(bar);
}
const wave = { bars, level: 0, amp: 0, active: true, mirror: Boolean(options.mirror), seed: Math.random() * 10 };
waves.push(wave);
return wave;
}
let waveFrame = 0;
function renderWaves(now) {
waveFrame = 0;
let running = false;
waves.forEach((wave) => {
if (!wave.active) return;
running = true;
wave.amp += (wave.level - wave.amp) * 0.12;
const length = wave.bars.length;
wave.bars.forEach((bar, index) => {
const i = wave.mirror ? length - 1 - index : index;
// 两路正弦叠加出「语音包络」,中间条更高,两端收敛
const envelope = Math.sin(((i + 0.5) / length) * Math.PI) * 0.6 + 0.4;
const n1 = Math.sin(now * 0.009 + i * 0.85 + wave.seed) * 0.5 + 0.5;
const n2 = Math.sin(now * 0.0047 + i * 1.9 + wave.seed * 2) * 0.5 + 0.5;
const value = 0.1 + wave.amp * envelope * (0.25 + n1 * n2 * 0.95);
bar.style.transform = `scaleY(${Math.min(1, value).toFixed(3)})`;
});
});
if (running) waveFrame = requestAnimationFrame(renderWaves);
}
function wakeWaves() {
if (!reduceMotion && !waveFrame) waveFrame = requestAnimationFrame(renderWaves);
}
/* ---------------- Hero 语音胶囊 ---------------- */
// 复刻键盘真实的麦克风状态流:轻点听写(红色声波 → 打勾)、长按问 AI(青色声波 → 转圈 → 回答)、
// 复制消息后的嘴替回复(转圈 → 回复)。文案取自 App 的实际界面字符串与演示素材。
const CAPSULE_SCRIPT = {
zh: [
{ tag: "轻点听写", mode: "rec", text: "帮我订周五晚上七点的位子,四个人" },
{ tag: "长按问 AI", mode: "ai", text: "周末去哪儿玩比较合适?", busy: "AI 正在思考…", answer: "可以去近郊走走:上午逛古镇,下午找家咖啡馆。" },
{ tag: "复制 · 嘴替回复", busy: "AI 正在思考…", answer: "好的,我整理一下预算版,明天上午发你。" }
],
en: [
{ tag: "Tap to dictate", mode: "rec", text: "Book a table for four, Friday at seven" },
{ tag: "Hold to ask AI", mode: "ai", text: "Where should we go this weekend?", busy: "AI is thinking…", answer: "Try a day trip: an old town in the morning, a café in the afternoon." },
{ tag: "Copy · Reply in your voice", busy: "AI is thinking…", answer: "Sure, I’ll put the budget version together and send it over tomorrow morning." }
]
};
const CAPSULE_STATES = ["is-rec", "is-ai", "is-busy", "is-ai-busy", "is-done"];
const hero = document.getElementById("top");
const capsule = document.querySelector(".voice-capsule");
const capsuleTag = capsule?.querySelector(".capsule-tag");
const capsuleLine = capsule?.querySelector(".capsule-line");
const capsuleWaveHost = capsule?.querySelector(".capsule-wave");
const capsuleWave = capsuleWaveHost ? createWave(capsuleWaveHost) : null;
let heroVisible = true;
let capsuleRun = 0;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Hero 离屏或标签页隐藏时暂停打字,回来后从原处继续
async function whenActive(run) {
while (run === capsuleRun && (!heroVisible || document.hidden)) {
await wait(400);
}
return run === capsuleRun;
}
function setCapsuleState(...states) {
CAPSULE_STATES.forEach((name) => capsule.classList.toggle(name, states.includes(name)));
const listening = states.includes("is-rec") || states.includes("is-ai");
if (capsuleWave) capsuleWave.level = listening ? 1 : 0;
if (listening) wakeWaves();
}
// 逐字写入;返回 false 表示本轮已被新一轮(如切换语言)取代
async function typeLine(run, text, perChar) {
const characters = Array.from(text);
for (let i = 1; i <= characters.length; i += 1) {
if (!(await whenActive(run))) return false;
capsuleLine.textContent = characters.slice(0, i).join("");
await wait(perChar());
}
return true;
}
async function playCapsule() {
if (!capsule || !capsuleLine || !capsuleTag) return;
const run = ++capsuleRun;
const lang = root.dataset.lang === "en" ? "en" : "zh";
const script = CAPSULE_SCRIPT[lang];
if (reduceMotion) {
setCapsuleState();
capsuleTag.textContent = script[0].tag;
capsuleLine.textContent = script[0].text;
return;
}
const base = lang === "zh" ? 72 : 34;
const speech = () => base + Math.random() * base * 0.8;
let index = 0;
while (run === capsuleRun) {
const item = script[index % script.length];
setCapsuleState();
capsuleTag.textContent = item.tag;
capsuleLine.textContent = "";
await wait(650);
// 1. 说话:红色(听写)或青色(问 AI)胶囊 + 声波,边说边出字
if (item.text) {
setCapsuleState(item.mode === "ai" ? "is-ai" : "is-rec");
if (!(await typeLine(run, item.text, speech))) return;
await wait(420);
}
// 2. 处理中:胶囊转圈,显示真实界面的状态文案
if (item.busy) {
setCapsuleState("is-busy", item.mode === "ai" ? "is-ai-busy" : "");
capsuleLine.textContent = item.busy;
await wait(1300);
if (!(await whenActive(run))) return;
}
// 3. 结果:回答快速流出
if (item.answer) {
capsuleLine.textContent = "";
if (!(await typeLine(run, item.answer, () => 16))) return;
}
// 4. 插入成功:胶囊打勾
setCapsuleState("is-done");
await wait(2100);
if (!(await whenActive(run))) return;
// 快速回删,交给下一个能力
const characters = Array.from(capsuleLine.textContent);
for (let i = characters.length; i >= 0; i -= 2) {
if (run !== capsuleRun) return;
capsuleLine.textContent = characters.slice(0, i).join("");
await wait(12);
}
index += 1;
}
}
languageHooks.push(() => playCapsule());
/* ---------------- 键盘复刻:共用状态写入 ---------------- */
const KB_DEFAULTS = { bar: "tabs", mic: "idle", ctx: "hint", surface: "main", pick: "", undo: "0", action: "off" };
// 把键盘状态写到容器的 data-* 上(只写变化的值,避免无谓的样式重算)
function applyKeyboardState(element, state) {
Object.keys(KB_DEFAULTS).forEach((key) => {
const value = state[key] ?? KB_DEFAULTS[key];
if (element.dataset[key] !== value) element.dataset[key] = value;
});
}
/* ---------------- 三幕滚动叙事 ---------------- */
// 与气泡文案保持一致:听写结果、选中的「普通」回复
const STORY_TEXT = {
zh: {
dictation: "收到了,我下午看完给你反馈。",
reply: "好的,我整理一下预算版,明天上午发你。"
},
en: {
dictation: "Got it — I’ll review it this afternoon and get back to you.",
reply: "Sure, I’ll put the budget version together and send it over tomorrow morning."
}
};
// 键盘时间轴(滚动进度 0 → 1),每段是完整状态,取最后一个 at ≤ p 的段
// 第一幕 听写:录音 → 打勾插入 → 发送
// 第二幕 嘴替:复制消息 → AI 思考 → 三条回复 → 点「普通」插入 → 发送
// 第三幕 Agentic:复制会议通知 → 顶栏技能 → 点「日程」→ 提取 → 写入日历
const STORY_PHASES = [
{ at: 0 },
{ at: 0.065, bar: "cancel", mic: "rec", ctx: "live" },
{ at: 0.235, mic: "done", ctx: "none", undo: "1", action: "on", compose: "dictation" },
{ at: 0.27, undo: "1", action: "on", compose: "dictation", send: true },
{ at: 0.29 },
{ at: 0.375, bar: "paste", mic: "busy", ctx: "think" },
{ at: 0.44, bar: "paste", surface: "variants", ctx: "none" },
{ at: 0.545, bar: "paste", surface: "variants", ctx: "none", pick: "0" },
{ at: 0.575, mic: "done", ctx: "none", undo: "1", action: "on", compose: "reply" },
{ at: 0.6, undo: "1", action: "on", compose: "reply", send: true },
{ at: 0.62 },
{ at: 0.715, bar: "skills", ctx: "none" },
{ at: 0.775, bar: "skills", ctx: "extract", mic: "busy", pick: "event" },
{ at: 0.84, ctx: "tip" },
{ at: 0.93 }
];
// 对话气泡:到达阈值即出现;复制提示只在区间内显示
const STORY_MESSAGES = { m1: 0.02, o1: 0.29, m2: 0.33, o2: 0.62, m3: 0.67, ev: 0.885 };
const STORY_COPIES = { c2: [0.36, 0.44], c3: [0.705, 0.775] };
const LIVE_RANGE = [0.075, 0.225];
const ACT_RANGES = [[0, 0.31], [0.31, 0.655], [0.655, 1]];
const story = document.getElementById("storyStage");
const storyText = document.getElementById("storyText");
const storyLive = document.getElementById("storyLive");
const storyActs = story ? Array.from(story.querySelectorAll(".story-act")) : [];
const storyWaves = story
? Array.from(story.querySelectorAll(".mic-wave")).map((host) => createWave(host))
: [];
let storyLastText = null;
let storyLastLive = null;
let storyLastAct = -1;
function updateStory(force) {
if (!story) return;
const rect = story.getBoundingClientRect();
const viewport = innerHeight;
const inView = rect.bottom > 0 && rect.top < viewport;
storyWaves.forEach((wave) => { wave.active = inView; });
if (!inView && !force) return;
const total = Math.max(1, story.offsetHeight - viewport);
const p = clamp(-rect.top / total);
const copy = STORY_TEXT[root.dataset.lang === "en" ? "en" : "zh"];
let phase = STORY_PHASES[0];
STORY_PHASES.forEach((item) => { if (p >= item.at) phase = item; });
applyKeyboardState(story, phase);
story.classList.toggle("press-send", Boolean(phase.send));
Object.entries(STORY_MESSAGES).forEach(([name, at]) => story.classList.toggle(name, p >= at));
Object.entries(STORY_COPIES).forEach(([name, [from, to]]) => story.classList.toggle(name, p >= from && p < to));
// 实时转写:录音阶段按滚动进度逐字出现
let live = "";
if (phase.ctx === "live") {
const characters = Array.from(copy.dictation);
const count = Math.round(clamp((p - LIVE_RANGE[0]) / (LIVE_RANGE[1] - LIVE_RANGE[0])) * characters.length);
live = characters.slice(0, count).join("");
}
if (storyLive && live !== storyLastLive) {
storyLive.textContent = live;
storyLastLive = live;
}
// 输入框:插入时整句落入(与真实插入行为一致),发送后清空
const text = phase.compose ? copy[phase.compose] : "";
if (storyText && text !== storyLastText) {
storyText.textContent = text;
if (text && !storyLastText && !reduceMotion) {
storyText.classList.remove("is-swapping");
void storyText.offsetWidth;
storyText.classList.add("is-swapping");
}
storyLastText = text;
}
story.classList.toggle("has-text", text.length > 0);
// 左侧三幕:高亮当前幕,幕内进度条随滚动填充
let act = 0;
ACT_RANGES.forEach(([start], i) => { if (p >= start) act = i; });
if (act !== storyLastAct) {
storyActs.forEach((element, i) => element.classList.toggle("is-active", i === act));
storyLastAct = act;
}
storyActs.forEach((element, i) => {
const [start, end] = ACT_RANGES[i];
element.style.setProperty("--ap", clamp((p - start) / (end - start)).toFixed(3));
});
storyWaves.forEach((wave) => { wave.level = phase.mic === "rec" ? 1 : 0; });
if (inView) wakeWaves();
}
if (story) {
story.classList.add("is-live");
languageHooks.push(() => {
storyLastText = null;
storyLastLive = null;
updateStory(true);
});
}
/* ---------------- 技能区循环演示 ---------------- */
// 复制 → 顶栏换成技能胶囊 → 点「日程」→ 提取 → 加入日历 → 横幅确认,然后重来
const SKILL_DEMO_STEPS = [
{ ms: 1300 },
{ ms: 1900, bar: "skills", ctx: "none", copied: true },
{ ms: 550, bar: "skills", ctx: "none", pick: "event", copied: true },
{ ms: 1500, bar: "skills", ctx: "extract", mic: "busy", pick: "event" },
{ ms: 1200, ctx: "tip" },
{ ms: 2800, done: true }
];
const skillsDemo = document.getElementById("skillsDemo");
let skillsDemoVisible = false;
let skillsDemoTimer = 0;
let skillsDemoIndex = 0;
function showSkillsStep(step) {
applyKeyboardState(skillsDemo, step);
skillsDemo.classList.toggle("is-copied", Boolean(step.copied));
skillsDemo.classList.toggle("is-done", Boolean(step.done));
}
function tickSkillsDemo() {
clearTimeout(skillsDemoTimer);
if (!skillsDemoVisible || document.hidden) return;
const step = SKILL_DEMO_STEPS[skillsDemoIndex];
showSkillsStep(step);
skillsDemoIndex = (skillsDemoIndex + 1) % SKILL_DEMO_STEPS.length;
skillsDemoTimer = setTimeout(tickSkillsDemo, step.ms);
}
if (skillsDemo) {
if (reduceMotion) {
// 减弱动态效果:停在「技能胶囊已出现」这一帧
showSkillsStep(SKILL_DEMO_STEPS[1]);
} else {
new IntersectionObserver((entries) => {
skillsDemoVisible = entries[0].isIntersecting;
tickSkillsDemo();
}, { threshold: 0.35 }).observe(skillsDemo);
document.addEventListener("visibilitychange", tickSkillsDemo);
}
}
/* ---------------- 滚动引擎 ---------------- */
const progressBar = document.querySelector(".scroll-progress");
const parallaxItems = new Set();
let scrollTicking = false;
let lastHeroProgress = -1;
function updateNavState() {
siteNav?.classList.toggle("is-scrolled", window.scrollY > 24);
}
function updateScroll() {
scrollTicking = false;
const y = window.scrollY;
const viewport = innerHeight;
updateNavState();
if (progressBar) {
const max = Math.max(1, root.scrollHeight - viewport);
progressBar.style.transform = `scaleX(${clamp(y / max).toFixed(4)})`;
}
// Hero:滚动 0.75 屏内完成「文字淡出 + 设备立起」
if (hero && !reduceMotion) {
const heroProgress = clamp(y / (viewport * 0.75));
if (Math.abs(heroProgress - lastHeroProgress) > 0.001) {
hero.style.setProperty("--hp", heroProgress.toFixed(4));
lastHeroProgress = heroProgress;
}
}
// 视差:元素中心偏离视口中心越远,位移越大(方向相反,制造景深)
if (!reduceMotion) {
parallaxItems.forEach((element) => {
const rect = element.getBoundingClientRect();
const offset = rect.top + rect.height / 2 - viewport / 2;
const factor = Number(element.dataset.parallax) || 0.05;
element.style.transform = `translate3d(0, ${(-offset * factor).toFixed(1)}px, 0)`;
});
}
updateStory(false);
}
function requestScrollUpdate() {
if (!scrollTicking) {
scrollTicking = true;
requestAnimationFrame(updateScroll);
}
}
/* ---------------- 数字计数 ---------------- */
const numberFormat = new Intl.NumberFormat("en-US");
function formatCount(element, value) {
return `${element.dataset.prefix || ""}${numberFormat.format(value)}`;
}
function runCounter(element) {
const target = Number(element.dataset.count);
const from = element.dataset.from ? Number(element.dataset.from) : 0;
const duration = 1700;
const start = performance.now();
function frame(now) {
const t = clamp((now - start) / duration);
const eased = 1 - Math.pow(1 - t, 4);
element.textContent = formatCount(element, Math.round(from + (target - from) * eased));
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/* ---------------- 初始化 ---------------- */
const initialLanguage = preferredLanguage();
const initialTheme = storedTheme() || (themeMedia.matches ? "dark" : "light");
applyLanguage(initialLanguage, false);
applyTheme(initialTheme, false);
languageButton?.addEventListener("click", () => {
const next = root.dataset.lang === "zh" ? "en" : "zh";
const swap = () => {
applyLanguage(next, true);
applyTheme(root.dataset.theme, false);
};
// 支持时以交叉淡化切换语言,整页文字不会「跳」
if (document.startViewTransition && !reduceMotion) {
document.startViewTransition(swap);
} else {
swap();
}
});
themeButton?.addEventListener("click", toggleThemeWithTransition);
menuButton?.addEventListener("click", () => {
setMenuOpen(menuButton.getAttribute("aria-expanded") !== "true");
});
mobileNav?.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", () => setMenuOpen(false));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") setMenuOpen(false);
});
themeMedia.addEventListener("change", (event) => {
if (!storedTheme()) applyTheme(event.matches ? "dark" : "light", false);
});
window.addEventListener("scroll", requestScrollUpdate, { passive: true });
window.addEventListener("resize", () => {
requestScrollUpdate();
moveIndicator(activeNavLink);
});
updateScroll();
/* ---------------- 滚动显现、计数与离屏视频暂停 ---------------- */
const counters = Array.from(document.querySelectorAll("[data-count]"));
if (reduceMotion || !canObserve) {
document.querySelectorAll(".reveal").forEach((element) => {
element.classList.add("is-visible");
});
} else {
// 首屏以下的计数器先归位到起点,进入视口时再滚动到目标值
counters.forEach((element) => {
if (element.getBoundingClientRect().top > innerHeight) {
const from = element.dataset.from ? Number(element.dataset.from) : 0;
element.textContent = formatCount(element, from);
element.dataset.pending = "true";
}
});
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
entry.target.querySelectorAll("[data-pending]").forEach((counter) => {
delete counter.dataset.pending;
runCounter(counter);
});
revealObserver.unobserve(entry.target);
});
},
{ threshold: 0.12, rootMargin: "0px 0px -8% 0px" }
);
document.querySelectorAll(".reveal").forEach((element) => {
revealObserver.observe(element);
});
// 视差只计算视口附近的元素
const parallaxObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) parallaxItems.add(entry.target);
else parallaxItems.delete(entry.target);
});
requestScrollUpdate();
},
{ rootMargin: "20% 0px 20% 0px" }
);
document.querySelectorAll("[data-parallax]").forEach((element) => {
parallaxObserver.observe(element);
});
if (hero) {
new IntersectionObserver((entries) => {
heroVisible = entries[0].isIntersecting;
if (capsuleWave) capsuleWave.active = heroVisible;
if (heroVisible) wakeWaves();
}).observe(hero);
}
}
if (canObserve) {
// 视频滚出视口时暂停,回来时恢复,省电且避免分散注意力
const videoObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const video = entry.target;
if (entry.isIntersecting) {
video.play().catch(() => {});
} else {
video.pause();
}
});
},
{ threshold: 0.15 }
);
document.querySelectorAll("video[autoplay]").forEach((video) => {
videoObserver.observe(video);
});
}
document.addEventListener("visibilitychange", () => {
if (!document.hidden) wakeWaves();
});
/* ---------------- 指针动效:光斑 / 倾斜 / 磁吸 / Hero 光斑 ---------------- */
if (finePointer && !reduceMotion) {
document.querySelectorAll(".spot").forEach((element) => {
element.addEventListener("pointermove", (event) => {
const rect = element.getBoundingClientRect();
element.style.setProperty("--mx", `${event.clientX - rect.left}px`);
element.style.setProperty("--my", `${event.clientY - rect.top}px`);
});
});
document.querySelectorAll(".tilt").forEach((element) => {
element.addEventListener("pointermove", (event) => {
const rect = element.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
element.style.setProperty("--rx", `${((0.5 - y) * 9).toFixed(2)}deg`);
element.style.setProperty("--ry", `${((x - 0.5) * 11).toFixed(2)}deg`);
});
element.addEventListener("pointerleave", () => {
element.style.setProperty("--rx", "0deg");
element.style.setProperty("--ry", "0deg");
});
});
// 磁吸按钮:指针靠近时按钮被「吸」向指针,离开时弹簧回位
document.querySelectorAll(".magnetic").forEach((element) => {
element.addEventListener("pointermove", (event) => {
const rect = element.getBoundingClientRect();
const dx = (event.clientX - rect.left - rect.width / 2) * 0.22;
const dy = (event.clientY - rect.top - rect.height / 2) * 0.32;
element.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px)`;
});
element.addEventListener("pointerleave", () => {
element.style.transform = "";
});
});
// Hero 光斑以插值追随指针,产生柔和的拖尾
if (hero) {
let targetX = 50;
let targetY = 30;
let currentX = 50;
let currentY = 30;
let spotFrame = 0;
const step = () => {
currentX += (targetX - currentX) * 0.08;
currentY += (targetY - currentY) * 0.08;
hero.style.setProperty("--hx", `${currentX.toFixed(2)}%`);
hero.style.setProperty("--hy", `${currentY.toFixed(2)}%`);
const settled = Math.abs(targetX - currentX) < 0.05 && Math.abs(targetY - currentY) < 0.05;
spotFrame = settled ? 0 : requestAnimationFrame(step);
};
hero.addEventListener("pointermove", (event) => {
const rect = hero.getBoundingClientRect();
targetX = ((event.clientX - rect.left) / rect.width) * 100;
targetY = ((event.clientY - rect.top) / rect.height) * 100;
if (!spotFrame) spotFrame = requestAnimationFrame(step);
});
}
}
/* ---------------- FAQ 展开高度过渡 ---------------- */
document.querySelectorAll(".faq-item").forEach((details) => {
const summary = details.querySelector("summary");
if (!summary || reduceMotion || !details.animate) return;
let animation = null;
let closing = false;
summary.addEventListener("click", (event) => {
event.preventDefault();
const startHeight = details.offsetHeight;
const borders = details.offsetHeight - details.clientHeight;
if (animation) {
animation.cancel();
animation = null;
}
closing = details.open && !closing;
if (!closing) details.open = true;
const endHeight = closing ? summary.offsetHeight + borders : details.offsetHeight;
animation = details.animate(
{ height: [`${startHeight}px`, `${endHeight}px`] },
{ duration: 460, easing: "cubic-bezier(0.16, 1, 0.3, 1)" }
);
animation.onfinish = () => {
animation = null;
if (closing) details.open = false;
closing = false;
};
});
});
})();