Files
2026-07-30 20:55:20 +08:00

290 lines
10 KiB
JavaScript

(() => {
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const header = document.querySelector("[data-header]");
const menuButton = document.querySelector("[data-menu-button]");
const mobileMenu = document.querySelector("[data-mobile-menu]");
let headerScrollTimer = 0;
let lastHeaderScrollY = window.scrollY;
const updateHeader = () => header?.classList.toggle("is-scrolled", window.scrollY > 20);
const handleHeaderScroll = () => {
updateHeader();
if (!header) return;
const currentScrollY = window.scrollY;
if (!reducedMotion.matches && Math.abs(currentScrollY - lastHeaderScrollY) >= 2) {
header.classList.add("is-scroll-hidden");
}
lastHeaderScrollY = currentScrollY;
window.clearTimeout(headerScrollTimer);
headerScrollTimer = window.setTimeout(() => {
header.classList.remove("is-scroll-hidden");
}, 180);
};
updateHeader();
window.addEventListener("scroll", handleHeaderScroll, { passive: true });
reducedMotion.addEventListener("change", () => {
if (reducedMotion.matches) header?.classList.remove("is-scroll-hidden");
});
const glassNav = document.querySelector(".nav-island");
if (glassNav && !reducedMotion.matches && window.matchMedia("(pointer: fine)").matches) {
glassNav.addEventListener("pointermove", (event) => {
const rect = glassNav.getBoundingClientRect();
glassNav.style.setProperty("--glass-x", `${event.clientX - rect.left}px`);
glassNav.style.setProperty("--glass-y", `${event.clientY - rect.top}px`);
}, { passive: true });
glassNav.addEventListener("pointerleave", () => {
glassNav.style.setProperty("--glass-x", "50%");
glassNav.style.setProperty("--glass-y", "0%");
});
}
if (menuButton && mobileMenu) {
const closeMenu = () => {
menuButton.setAttribute("aria-expanded", "false");
menuButton.setAttribute("aria-label", "打开导航");
mobileMenu.hidden = true;
};
menuButton.addEventListener("click", () => {
const opening = menuButton.getAttribute("aria-expanded") !== "true";
menuButton.setAttribute("aria-expanded", String(opening));
menuButton.setAttribute("aria-label", opening ? "关闭导航" : "打开导航");
mobileMenu.hidden = !opening;
});
mobileMenu.addEventListener("click", (event) => {
if (event.target.closest("a")) closeMenu();
});
document.addEventListener("pointerdown", (event) => {
if (!mobileMenu.hidden && !header.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu();
});
window.addEventListener("resize", () => {
if (window.innerWidth > 960) closeMenu();
}, { passive: true });
}
const activateSet = (buttons, panels, button, buttonKey, panelKey) => {
const target = button.dataset[buttonKey];
buttons.forEach((item) => {
const active = item === button;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", String(active));
});
panels.forEach((panel) => {
const active = panel.dataset[panelKey] === target;
panel.hidden = !active;
panel.classList.toggle("is-active", active);
});
};
const bindButtonSet = (buttonSelector, panelSelector, buttonKey, panelKey) => {
const buttons = [...document.querySelectorAll(buttonSelector)];
const panels = [...document.querySelectorAll(panelSelector)];
buttons.forEach((button, index) => {
button.addEventListener("click", () => activateSet(buttons, panels, button, buttonKey, panelKey));
button.addEventListener("keydown", (event) => {
if (!["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) return;
event.preventDefault();
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
const next = buttons[(index + direction + buttons.length) % buttons.length];
next.focus();
activateSet(buttons, panels, next, buttonKey, panelKey);
});
});
};
bindButtonSet("[data-console-tab]", "[data-console-view]", "consoleTab", "consoleView");
const featureRail = document.querySelector("[data-feature-rail]");
const featurePrev = document.querySelector("[data-feature-prev]");
const featureNext = document.querySelector("[data-feature-next]");
if (featureRail && featurePrev && featureNext) {
const originalCards = [...featureRail.querySelectorAll(".feature-card")];
const autoPauses = new Set();
let railFrame = 0;
let motionFrame = 0;
let lastMotionTime = 0;
let manualPauseUntil = 0;
// Three identical sets keep both automatic and manual movement seamless.
const cloneSet = () => {
const fragment = document.createDocumentFragment();
originalCards.forEach((card) => {
const clone = card.cloneNode(true);
clone.setAttribute("aria-hidden", "true");
fragment.append(clone);
});
return fragment;
};
featureRail.prepend(cloneSet());
featureRail.append(cloneSet());
const getStep = () => {
const card = originalCards[0];
if (!card) return 0;
const gap = parseFloat(getComputedStyle(featureRail).columnGap) || 0;
return card.getBoundingClientRect().width + gap;
};
const getLoopSpan = () => getStep() * originalCards.length;
const normalizeRail = () => {
const loopSpan = getLoopSpan();
if (!loopSpan) return;
if (featureRail.scrollLeft >= loopSpan * 2) featureRail.scrollLeft -= loopSpan;
if (featureRail.scrollLeft <= 1) featureRail.scrollLeft += loopSpan;
};
featureRail.scrollLeft = getLoopSpan();
const scheduleRailUpdate = () => {
if (railFrame) return;
railFrame = requestAnimationFrame(() => {
railFrame = 0;
normalizeRail();
});
};
const scrollRail = (direction) => {
const step = getStep();
if (!step) return;
manualPauseUntil = performance.now() + 700;
featureRail.scrollBy({
left: direction * step,
behavior: reducedMotion.matches ? "auto" : "smooth"
});
};
const stopAuto = () => {
cancelAnimationFrame(motionFrame);
motionFrame = 0;
lastMotionTime = 0;
};
const animateRail = (time) => {
if (!lastMotionTime) lastMotionTime = time;
const elapsed = Math.min(time - lastMotionTime, 40);
lastMotionTime = time;
if (!autoPauses.size && time >= manualPauseUntil) {
featureRail.scrollLeft += elapsed * 0.032;
normalizeRail();
}
motionFrame = requestAnimationFrame(animateRail);
};
const startAuto = () => {
if (reducedMotion.matches || document.hidden) {
stopAuto();
return;
}
if (!motionFrame) motionFrame = requestAnimationFrame(animateRail);
};
const pauseAuto = (reason) => autoPauses.add(reason);
const resumeAuto = (reason) => {
autoPauses.delete(reason);
startAuto();
};
const moveManually = (direction) => {
scrollRail(direction);
startAuto();
};
featurePrev.addEventListener("click", () => moveManually(-1));
featureNext.addEventListener("click", () => moveManually(1));
featureRail.addEventListener("scroll", scheduleRailUpdate, { passive: true });
featureRail.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
moveManually(event.key === "ArrowRight" ? 1 : -1);
});
featureRail.addEventListener("pointerdown", () => pauseAuto("pointer"), { passive: true });
window.addEventListener("pointerup", () => resumeAuto("pointer"), { passive: true });
window.addEventListener("pointercancel", () => resumeAuto("pointer"), { passive: true });
featureRail.addEventListener("wheel", () => {
manualPauseUntil = performance.now() + 900;
}, { passive: true });
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAuto();
else startAuto();
});
reducedMotion.addEventListener("change", startAuto);
window.addEventListener("resize", () => {
featureRail.scrollLeft = getLoopSpan();
scheduleRailUpdate();
}, { passive: true });
startAuto();
}
const productStage = document.querySelector("[data-product-stage]");
const productWindow = document.querySelector("[data-product-window]");
if (productStage && productWindow && !reducedMotion.matches && window.matchMedia("(pointer: fine)").matches) {
let currentX = 0;
let currentY = 0;
let targetX = 0;
let targetY = 0;
let velocityX = 0;
let velocityY = 0;
let frame = 0;
const animateTilt = () => {
velocityX = (velocityX + (targetX - currentX) * 0.16) * 0.72;
velocityY = (velocityY + (targetY - currentY) * 0.16) * 0.72;
currentX += velocityX;
currentY += velocityY;
productWindow.style.setProperty("--rx", `${currentX.toFixed(3)}deg`);
productWindow.style.setProperty("--ry", `${currentY.toFixed(3)}deg`);
const moving = Math.abs(targetX - currentX) + Math.abs(targetY - currentY) + Math.abs(velocityX) + Math.abs(velocityY) > 0.003;
frame = moving ? requestAnimationFrame(animateTilt) : 0;
};
const scheduleTilt = () => {
if (!frame) frame = requestAnimationFrame(animateTilt);
};
productStage.addEventListener("pointermove", (event) => {
const rect = productStage.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width - 0.5;
const y = (event.clientY - rect.top) / rect.height - 0.5;
targetX = Math.max(-1.5, Math.min(1.5, -y * 3));
targetY = Math.max(-2.1, Math.min(2.1, x * 4.2));
scheduleTilt();
}, { passive: true });
productStage.addEventListener("pointerleave", () => {
targetX = 0;
targetY = 0;
scheduleTilt();
});
}
const reveals = document.querySelectorAll(".reveal");
if (reducedMotion.matches || !("IntersectionObserver" in window)) {
reveals.forEach((item) => item.classList.add("is-visible"));
} else {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
}, { rootMargin: "0px 0px -7%", threshold: 0.08 });
reveals.forEach((item) => observer.observe(item));
}
})();