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
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 306 KiB |
+1896
-82
File diff suppressed because it is too large
Load Diff
+650
-27
@@ -1,10 +1,14 @@
|
||||
/*!
|
||||
* OSGKeyboard 主页交互。
|
||||
* OSGKeyboard 主页交互与动效引擎。
|
||||
* 无 JavaScript 时页面内容完整可读;本文件负责:
|
||||
* 1. 中 / 英双语切换(data-zh / data-en 系列属性,含视频源)
|
||||
* 2. 深浅色主题(默认跟随系统,手动选择后持久化)
|
||||
* 3. 移动端导航开合
|
||||
* 4. 导航栏滚动状态、滚动显现动效、离屏视频暂停
|
||||
* 2. 深浅色主题(默认跟随系统,手动选择后持久化;支持时以圆形扩散过渡)
|
||||
* 3. 移动端导航开合、导航滑块与当前区块高亮
|
||||
* 4. 逐字拆分标题(data-split)
|
||||
* 5. 滚动引擎:进度条、Hero 设备「立起」、媒体视差、三幕滚动叙事
|
||||
* 6. 声波渲染、Hero 语音胶囊打字机、数字计数
|
||||
* 7. 指针光斑 / 3D 倾斜 / 磁吸按钮、FAQ 高度过渡、离屏视频暂停
|
||||
* 开启「减弱动态效果」时,持续动画与指针动效全部停用,内容直接呈现最终态。
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
@@ -21,6 +25,78 @@
|
||||
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() {
|
||||
@@ -50,7 +126,8 @@
|
||||
|
||||
// 同时覆盖 <img> 与 <video>:切换语言后视频自动重新加载播放
|
||||
document.querySelectorAll("[data-zh-src][data-en-src]").forEach((element) => {
|
||||
element.src = element.dataset[`${lang}Src`] || "";
|
||||
const next = element.dataset[`${lang}Src`] || "";
|
||||
if (element.getAttribute("src") !== next) element.src = next;
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-zh-href][data-en-href]").forEach((element) => {
|
||||
@@ -71,9 +148,7 @@
|
||||
image.src = `assets/screenshots/${lang}/${theme}/${image.dataset.shot}`;
|
||||
});
|
||||
|
||||
document.title = lang === "zh"
|
||||
? "OSGKeyboard — 开口即文字"
|
||||
: "OSGKeyboard — Say it. It’s typed.";
|
||||
document.title = TITLES[lang];
|
||||
|
||||
if (languageButton) {
|
||||
languageButton.textContent = lang === "zh" ? "EN" : "中文";
|
||||
@@ -93,6 +168,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
// 文字已被 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);
|
||||
@@ -141,6 +220,35 @@
|
||||
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) {
|
||||
@@ -158,6 +266,378 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 导航滑块与当前区块 ---------------- */
|
||||
|
||||
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();
|
||||
@@ -166,13 +646,20 @@
|
||||
applyTheme(initialTheme, false);
|
||||
|
||||
languageButton?.addEventListener("click", () => {
|
||||
applyLanguage(root.dataset.lang === "zh" ? "en" : "zh", true);
|
||||
applyTheme(root.dataset.theme, false);
|
||||
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", () => {
|
||||
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
|
||||
});
|
||||
themeButton?.addEventListener("click", toggleThemeWithTransition);
|
||||
|
||||
menuButton?.addEventListener("click", () => {
|
||||
setMenuOpen(menuButton.getAttribute("aria-expanded") !== "true");
|
||||
@@ -190,30 +677,41 @@
|
||||
if (!storedTheme()) applyTheme(event.matches ? "dark" : "light", false);
|
||||
});
|
||||
|
||||
/* ---------------- 导航栏滚动状态 ---------------- */
|
||||
window.addEventListener("scroll", requestScrollUpdate, { passive: true });
|
||||
window.addEventListener("resize", () => {
|
||||
requestScrollUpdate();
|
||||
moveIndicator(activeNavLink);
|
||||
});
|
||||
updateScroll();
|
||||
|
||||
function updateNavState() {
|
||||
siteNav?.classList.toggle("is-scrolled", window.scrollY > 24);
|
||||
}
|
||||
updateNavState();
|
||||
window.addEventListener("scroll", updateNavState, { passive: true });
|
||||
/* ---------------- 滚动显现、计数与离屏视频暂停 ---------------- */
|
||||
|
||||
/* ---------------- 滚动显现与离屏视频暂停 ---------------- */
|
||||
const counters = Array.from(document.querySelectorAll("[data-count]"));
|
||||
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
if (reduceMotion || !("IntersectionObserver" in window)) {
|
||||
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) {
|
||||
entry.target.classList.add("is-visible");
|
||||
revealObserver.unobserve(entry.target);
|
||||
}
|
||||
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" }
|
||||
@@ -222,6 +720,31 @@
|
||||
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) => {
|
||||
@@ -240,4 +763,104 @@
|
||||
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;
|
||||
};
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user