290 lines
10 KiB
JavaScript
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));
|
|
}
|
|
})();
|