Files
OSGKeyboard/docs/assets/site.js
T
Rocky b61b348cac docs(pages): redesign homepage motion and target voice-keyboard SEO keywords
- Move all canonical, hreflang, Open Graph, sitemap, robots, and llms.txt
  URLs to the custom domain https://osg.osglab.com/
- Put category keywords (AI 语音输入法, 嘴替输入法, Agentic keyboard) in
  the title, description, H1, section eyebrows, FAQ, and JSON-LD
- Add a 1200x630 social card and large-image Twitter card
- Add a scroll-driven three-act story (dictation, reply in your voice,
  agentic action), a live voice capsule, split-text headings, number
  counters, parallax media reveals, pointer spotlight and tilt, and a
  circular theme transition
- Keep the page fully readable without JavaScript and under reduced motion
- Point legacy redirect pages at sections that exist
2026-10-01 08:51:20 +08:00

867 lines
31 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 语音胶囊 ---------------- */
const CAPSULE_SCRIPT = {
zh: [
{ tag: "听写", text: "帮我订周五晚上七点的位子,四个人" },
{ tag: "嘴替 · 轻松", text: "这周有点累,先不去啦,下次我请!" },
{ tag: "替你做", text: "已建日程:周五 19:00 · 四人晚餐", agent: true }
],
en: [
{ tag: "Dictate", text: "Book a table for four, Friday at seven" },
{ tag: "Your voice", text: "Bit wiped this week — rain check? Next one’s on me!" },
{ tag: "Done for you", text: "Event added: Fri 7:00 PM · Dinner for four", agent: true }
]
};
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;
}
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) {
capsuleTag.textContent = script[0].tag;
capsuleLine.textContent = script[0].text;
return;
}
let index = 0;
while (run === capsuleRun) {
const item = script[index % script.length];
capsuleTag.textContent = item.tag;
capsuleTag.classList.toggle("is-agent", Boolean(item.agent));
capsuleLine.textContent = "";
await wait(260);
// 听写与嘴替模拟「边说边出字」,Agentic 结果直接以完成态弹出
const listening = !item.agent;
capsule.classList.toggle("is-listening", listening);
if (capsuleWave) capsuleWave.level = listening ? 1 : 0.15;
wakeWaves();
const characters = Array.from(item.text);
const base = lang === "zh" ? 70 : 34;
for (let i = 1; i <= characters.length; i += 1) {
if (!(await whenActive(run))) return;
capsuleLine.textContent = characters.slice(0, i).join("");
await wait(item.agent ? 14 : base + Math.random() * base * 0.8);
}
capsule.classList.remove("is-listening");
if (capsuleWave) capsuleWave.level = 0.12;
await wait(item.agent ? 2400 : 1900);
if (!(await whenActive(run))) return;
// 快速回删,像是把这一句交给下一个能力
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 STORY_TEXT = {
zh: {
draft: "可以啊 周五七点 位子我来订",
tones: [
"可以,周五七点,位子我来订。",
"好的,周五晚七点见,座位由我来预订。",
"好呀!周五七点不见不散,位子包在我身上~"
]
},
en: {
draft: "sure friday at seven ill book a table",
tones: [
"Sure — Friday at seven. I’ll book a table.",
"Sounds good. Friday at 7 p.m.; I’ll make the reservation.",
"Yes! Friday at 7 — leave the table to me."
]
}
};
// 进度阈值(0 → 1):s1 好友来信 · s2 开始聆听 · s3 嘴替改写 · s4 发送
// s5 好友回地址 · s6 复制 · s7 技能弹出 · s8 写入日历
const STORY_STEPS = [0.03, 0.08, 0.31, 0.585, 0.66, 0.735, 0.805, 0.885];
const TYPE_START = 0.08;
const TYPE_END = 0.29;
const TONE_STEPS = [0.36, 0.44, 0.515];
const ACT_RANGES = [[0, 0.31], [0.31, 0.66], [0.66, 1]];
const story = document.getElementById("storyStage");
const storyText = document.getElementById("storyText");
const storyActs = story ? Array.from(story.querySelectorAll(".story-act")) : [];
const storyWaves = story
? Array.from(story.querySelectorAll(".kb-wave")).map((host, i) => createWave(host, { mirror: i === 0 }))
: [];
let storyLastText = null;
let storyLastTone = -1;
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 lang = root.dataset.lang === "en" ? "en" : "zh";
const copy = STORY_TEXT[lang];
const reached = STORY_STEPS.map((step) => p >= step);
reached.forEach((on, i) => story.classList.toggle(`s${i + 1}`, on));
const [, listening, rewriting, sent] = reached;
// 语气:仅在「改写中」阶段可见,按进度依次切到 普通 → 正式 → 轻松
let tone = -1;
if (rewriting && !sent) {
TONE_STEPS.forEach((step, i) => { if (p >= step) tone = i; });
}
if (tone !== storyLastTone) {
[0, 1, 2].forEach((i) => story.classList.toggle(`tone-${i}`, i === tone));
}
// 输入框:聆听阶段按滚动进度逐字「听写」,改写阶段显示当前语气的版本
let text = "";
if (listening && !rewriting) {
const characters = Array.from(copy.draft);
const count = Math.round(clamp((p - TYPE_START) / (TYPE_END - TYPE_START)) * characters.length);
text = characters.slice(0, count).join("");
} else if (rewriting && !sent) {
text = tone >= 0 ? copy.tones[tone] : copy.draft;
}
if (storyText && text !== storyLastText) {
const isRewrite = tone !== storyLastTone && text && storyLastText;
storyText.textContent = text;
if (isRewrite && !reduceMotion) {
storyText.classList.remove("is-swapping");
void storyText.offsetWidth;
storyText.classList.add("is-swapping");
}
storyLastText = text;
}
storyLastTone = tone;
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 = listening && !rewriting ? 1 : 0.06; });
if (inView) wakeWaves();
}
if (story) {
story.classList.add("is-live");
languageHooks.push(() => {
storyLastText = null;
storyLastTone = -2;
updateStory(true);
});
}
/* ---------------- 滚动引擎 ---------------- */
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;
};
});
});
})();