// =========================
// App
// =========================
document.addEventListener('DOMContentLoaded', () => {
const app = {
body: document.body,
loader: document.querySelector('.page-loader'),
startRevealAnimations: null
};
initLoadingState(app);
initMenu(app);
initHeaderState();
initAccordion();
initModal(app);
initReviewSliders();
initSceneSlider();
initVideoChapters();
initVideoChapterScroll();
initTextReveal();
initRevealObservers(app);
initActiveNav();
initFaqImageModal();
initImageDragScroll()
});
// =========================
// Loading
// =========================
function initLoadingState(app) {
app.body.classList.add('is-loading');
window.addEventListener('load', () => {
setTimeout(() => {
app.loader?.classList.add('is-hide');
app.body.classList.remove('is-loading');
app.startRevealAnimations?.();
}, 600);
});
}
// =========================
// Menu
// =========================
function initMenu(app) {
const menuBtn = document.querySelector('.menu-btn');
const mobileMenu = document.querySelector('.mobile-menu');
const tabletMenu = document.querySelector('.tablet-menu');
const productDropdown = document.querySelector('.product-dropdown');
const closeMenu = () => {
app.body.classList.remove('menu-open');
mobileMenu?.setAttribute('aria-hidden', 'true');
tabletMenu?.setAttribute('aria-hidden', 'true');
};
menuBtn?.addEventListener('click', () => {
app.body.classList.toggle('menu-open');
const isOpen = app.body.classList.contains('menu-open');
const ariaValue = isOpen ? 'false' : 'true';
mobileMenu?.setAttribute('aria-hidden', ariaValue);
tabletMenu?.setAttribute('aria-hidden', ariaValue);
});
document.querySelectorAll('.mobile-menu a, .tablet-menu a').forEach((link) => {
link.addEventListener('click', closeMenu);
});
productDropdown?.addEventListener('mouseenter', closeMenu);
}
// =========================
// Header / Product Tabs
// =========================
function initHeaderState() {
const header = document.querySelector('.site-header');
const productTabs = document.querySelector('.product-tabs');
const hero = document.querySelector('.hero');
const channelSection = document.querySelector('#channel');
if (!hero) return;
let lastScrollY = window.scrollY;
const updateHeaderState = () => {
const currentScrollY = window.scrollY;
const isScrollingUp = currentScrollY < lastScrollY;
const passedHero = currentScrollY >= hero.offsetTop;
header?.classList.toggle('is-scrolled', passedHero);
productTabs?.classList.toggle('is-compact', passedHero);
if (channelSection && productTabs) {
const rect = channelSection.getBoundingClientRect();
const passedChannelHalf = rect.top + rect.height / 2 <= window.innerHeight / 2;
productTabs.classList.toggle('is-hidden', passedChannelHalf && !isScrollingUp);
}
lastScrollY = currentScrollY;
};
window.addEventListener('scroll', updateHeaderState);
window.addEventListener('resize', updateHeaderState);
updateHeaderState();
}
// =========================
// Accordion
// =========================
function initAccordion() {
document.querySelectorAll('.accordion').forEach((item) => {
const btn = item.querySelector('button');
const icon = item.querySelector('.accordion-icon');
btn?.addEventListener('click', () => {
const isOpen = item.classList.toggle('is-open');
if (!icon) return;
icon.src = isOpen
? icon.dataset.open
: icon.dataset.close;
});
});
}
function initFaqImageModal() {
const modal = document.querySelector('[data-faq-image-modal]');
const openBtns = document.querySelectorAll(
'[data-faq-image-open], .faq-answer__img > img'
);
const closeBtns = document.querySelectorAll('[data-faq-image-close]');
if (!modal || !openBtns.length) return;
const openModal = () => {
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
};
const closeModal = () => {
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
};
openBtns.forEach((btn) => {
btn.addEventListener('click', openModal);
});
closeBtns.forEach((btn) => {
btn.addEventListener('click', closeModal);
});
}
function initImageDragScroll() {
const viewport = document.querySelector('.faq-image-modal__viewport');
if (!viewport) return;
let isDown = false;
let startX = 0;
let scrollLeft = 0;
viewport.addEventListener('mousedown', (e) => {
isDown = true;
viewport.classList.add('is-dragging');
startX = e.pageX;
scrollLeft = viewport.scrollLeft;
});
window.addEventListener('mouseup', () => {
isDown = false;
viewport.classList.remove('is-dragging');
});
window.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
const walk = e.pageX - startX;
viewport.scrollLeft = scrollLeft - walk;
});
viewport.addEventListener('mouseleave', () => {
isDown = false;
viewport.classList.remove('is-dragging');
});
}
// =========================
// Modal
// =========================
function initModal(app) {
const openModal = (id) => {
const modal = document.getElementById(id);
if (!modal) return;
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
app.body.style.overflow = 'hidden';
};
const closeModal = (modal) => {
if (!modal) return;
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
app.body.style.overflow = '';
};
document.querySelectorAll('[data-modal-open]').forEach((btn) => {
btn.addEventListener('click', () => {
openModal(btn.dataset.modalOpen);
});
});
document.querySelectorAll('[data-modal-close]').forEach((btn) => {
btn.addEventListener('click', () => {
closeModal(btn.closest('.modal'));
});
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
document.querySelectorAll('.modal.is-open').forEach(closeModal);
});
}
// =========================
// Review Slider
// =========================
function initReviewSliders() {
document.querySelectorAll('[data-review-slider]').forEach(initReviewSlider);
}
function initReviewSlider(slider) {
const els = getReviewSliderElements(slider);
if (!els.track || !els.viewport || !els.cards.length || !els.controls) return;
const state = {
currentIndex: 0,
resizeTimer: null,
startX: 0,
currentX: 0,
isDragging: false
};
const update = (nextIndex) => updateReviewSlider(els, state, nextIndex);
els.prev?.addEventListener('click', () => update(state.currentIndex - 1));
els.next?.addEventListener('click', () => update(state.currentIndex + 1));
bindReviewSliderSwipe(els, state, update);
bindReviewSliderResize(els, state, update);
updateReviewControls(els);
syncReviewCardHeight(els.cards);
update(state.currentIndex);
}
function getReviewSliderElements(slider) {
return {
slider,
track: slider.querySelector('.review-track'),
viewport: slider.querySelector('.review-viewport'),
cards: [...slider.querySelectorAll('.review-card')],
prev: slider.querySelector('.prev'),
next: slider.querySelector('.next'),
controls: slider.querySelector('.review-controls'),
currentText: slider.querySelector('.review-count__current'),
totalText: slider.querySelector('.review-count__total')
};
}
function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
function getReviewVisibleCount() {
return window.innerWidth <= 600 ? 1 : 2;
}
function getReviewMaxIndex(cards) {
return Math.max(0, cards.length - 1);
}
function getReviewMoveX(track, cards) {
const gap = parseFloat(getComputedStyle(track).gap) || 0;
return cards[0].offsetWidth + gap;
}
function syncReviewCardHeight(cards) {
cards.forEach((card) => {
card.style.height = 'auto';
});
const maxHeight = cards.reduce((height, card) => {
return Math.max(height, card.offsetHeight);
}, 0);
cards.forEach((card) => {
card.style.height = `${maxHeight}px`;
});
}
function updateReviewControls(els) {
els.controls.style.display = els.cards.length <= getReviewVisibleCount()
? 'none'
: '';
if (els.totalText) {
els.totalText.textContent = els.cards.length;
}
}
function updateReviewSlider(els, state, nextIndex) {
const maxIndex = getReviewMaxIndex(els.cards);
state.currentIndex = clamp(nextIndex, 0, maxIndex);
els.cards.forEach((card) => {
card.hidden = false;
});
els.track.style.transition = 'transform .45s ease';
els.track.style.transform = `translateX(${-state.currentIndex * getReviewMoveX(els.track, els.cards)}px)`;
if (els.currentText) {
els.currentText.textContent = state.currentIndex + 1;
}
if (els.prev) els.prev.disabled = state.currentIndex === 0;
if (els.next) els.next.disabled = state.currentIndex === maxIndex;
}
function bindReviewSliderSwipe(els, state, update) {
els.viewport.addEventListener('touchstart', (e) => {
if (!isTouchDevice()) return;
state.startX = e.touches[0].clientX;
state.currentX = state.startX;
state.isDragging = true;
els.track.style.transition = 'none';
});
els.viewport.addEventListener('touchmove', (e) => {
if (!state.isDragging || !isTouchDevice()) return;
state.currentX = e.touches[0].clientX;
const diffX = state.currentX - state.startX;
const baseX = -state.currentIndex * getReviewMoveX(els.track, els.cards);
els.track.style.transform = `translateX(${baseX + diffX}px)`;
});
els.viewport.addEventListener('touchend', () => {
if (!state.isDragging || !isTouchDevice()) return;
const diffX = state.currentX - state.startX;
const threshold = 50;
state.isDragging = false;
if (diffX < -threshold) {
update(state.currentIndex + 1);
} else if (diffX > threshold) {
update(state.currentIndex - 1);
} else {
update(state.currentIndex);
}
});
}
function bindReviewSliderResize(els, state, update) {
window.addEventListener('resize', () => {
clearTimeout(state.resizeTimer);
state.resizeTimer = setTimeout(() => {
state.currentIndex = Math.min(state.currentIndex, getReviewMaxIndex(els.cards));
updateReviewControls(els);
syncReviewCardHeight(els.cards);
update(state.currentIndex);
}, 150);
});
}
// =========================
// Scene Slider
// =========================
function initSceneSlider() {
const slider = document.querySelector('[data-scene-slider]');
if (!slider) return;
const viewport = slider.querySelector('.nh-scene__viewport');
const track = slider.querySelector('.nh-scene__track');
const cards = [...slider.querySelectorAll('.nh-scene-card')];
const prev = slider.querySelector('[data-scene-prev]');
const next = slider.querySelector('[data-scene-next]');
const current = slider.querySelector('[data-scene-current]');
const total = slider.querySelector('[data-scene-total]');
if (!viewport || !track || !cards.length) return;
let currentIndex = 0;
let currentTranslate = 0;
let startX = 0;
let startY = 0;
let startTranslate = 0;
let isDragging = false;
let isHorizontalDrag = false;
let resizeTimer = null;
const isMobile = () => window.innerWidth <= 820;
const maxIndex = () => cards.length - 1;
const getGap = () => parseFloat(getComputedStyle(track).gap) || 0;
const syncTrackPadding = () => {
if (!isMobile()) {
track.style.removeProperty('--scene-track-pad');
return;
}
const pad = Math.max(0, (viewport.clientWidth - cards[0].offsetWidth) / 2);
track.style.setProperty('--scene-track-pad', `${pad}px`);
};
const getTargetTranslate = (index) => {
const card = cards[index];
if (!card) return 0;
if (isMobile()) {
const cardCenter = card.offsetLeft + card.offsetWidth / 2;
const viewportCenter = viewport.clientWidth / 2;
return viewportCenter - cardCenter;
}
const maxMove = Math.max(0, track.scrollWidth - viewport.clientWidth);
return -Math.min(card.offsetLeft, maxMove);
};
const minTranslate = () => getTargetTranslate(maxIndex());
const clampTranslate = (value) => {
return Math.max(minTranslate(), Math.min(0, value));
};
const getIndexByTranslate = (translate) => {
let nearestIndex = 0;
let nearestDistance = Infinity;
cards.forEach((_, index) => {
const target = getTargetTranslate(index);
const distance = Math.abs(translate - target);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
return nearestIndex;
};
const updateControls = () => {
if (current) current.textContent = currentIndex + 1;
if (total) total.textContent = cards.length;
if (prev) prev.disabled = currentIndex <= 0;
if (next) next.disabled = currentIndex >= maxIndex();
};
const setTranslate = (value, transition = false) => {
currentTranslate = clampTranslate(value);
track.style.transition = transition ? 'transform .45s ease' : 'none';
track.style.transform = `translate3d(${currentTranslate}px, 0, 0)`;
};
const render = (transition = true) => {
syncTrackPadding();
const target = getTargetTranslate(currentIndex);
setTranslate(target, transition);
updateControls();
};
const goToIndex = (index, transition = true) => {
currentIndex = Math.max(0, Math.min(index, maxIndex()));
render(transition);
};
const dragTo = (clientX) => {
const diffX = clientX - startX;
setTranslate(startTranslate + diffX, false);
};
const endDrag = () => {
if (!isDragging) return;
isDragging = false;
isHorizontalDrag = false;
viewport.classList.remove('is-dragging');
currentIndex = getIndexByTranslate(currentTranslate);
setTranslate(currentTranslate, false);
updateControls();
};
prev?.addEventListener('click', () => {
goToIndex(currentIndex - 1);
});
next?.addEventListener('click', () => {
goToIndex(currentIndex + 1);
});
viewport.addEventListener(
'touchstart',
(e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
startTranslate = currentTranslate;
isDragging = true;
isHorizontalDrag = false;
track.style.transition = 'none';
},
{ passive: true }
);
viewport.addEventListener(
'touchmove',
(e) => {
if (!isDragging) return;
const diffX = e.touches[0].clientX - startX;
const diffY = e.touches[0].clientY - startY;
if (!isHorizontalDrag) {
isHorizontalDrag = Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 8;
}
if (!isHorizontalDrag) return;
e.preventDefault();
dragTo(e.touches[0].clientX);
},
{ passive: false }
);
viewport.addEventListener('touchend', endDrag);
viewport.addEventListener('touchcancel', endDrag);
viewport.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
startX = e.clientX;
startY = e.clientY;
startTranslate = currentTranslate;
isDragging = true;
isHorizontalDrag = true;
track.style.transition = 'none';
viewport.classList.add('is-dragging');
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault();
dragTo(e.clientX);
});
window.addEventListener('mouseup', endDrag);
viewport.addEventListener('mouseleave', endDrag);
track.querySelectorAll('img').forEach((img) => {
img.addEventListener('dragstart', (e) => e.preventDefault());
});
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
syncTrackPadding();
setTranslate(currentTranslate, false);
updateControls();
}, 150);
});
render(false);
}
// =========================
// YouTube Chapters
// =========================
function initVideoChapters() {
const section = document.querySelector('[data-video-chapters]');
if (!section) return;
const videoTarget = section.querySelector('[data-youtube-id]');
const buttons = [...section.querySelectorAll('[data-start][data-end]')];
if (!videoTarget || !buttons.length) return;
const videoId = videoTarget.dataset.youtubeId;
let player = null;
let activeIndex = 0;
let progressTimer = null;
const loadYouTubeAPI = () => {
if (window.YT?.Player) {
createPlayer();
return;
}
const existingScript = document.querySelector('script[src="https://www.youtube.com/iframe_api"]');
if (!existingScript) {
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.body.appendChild(tag);
}
const previousCallback = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
if (typeof previousCallback === 'function') previousCallback();
createPlayer();
};
};
const createPlayer = () => {
if (player) return;
player = new YT.Player(videoTarget.id, {
videoId,
playerVars: {
rel: 0,
modestbranding: 1,
playsinline: 1
},
events: {
onReady: () => {
setActiveChapter(0, false);
startProgressLoop();
},
onStateChange: (event) => {
if (event.data === YT.PlayerState.PLAYING) {
startProgressLoop();
}
}
}
});
};
const getChapter = (chapterIndex) => {
const btn = buttons[chapterIndex];
return {
btn,
start: Number(btn.dataset.start),
end: Number(btn.dataset.end)
};
};
const setActiveChapter = (chapterIndex, shouldPlay = true) => {
activeIndex = clamp(chapterIndex, 0, buttons.length - 1);
buttons.forEach((button, index) => {
button.classList.toggle('is-active', index === activeIndex);
button.style.setProperty('--progress', '0deg');
});
const { start } = getChapter(activeIndex);
if (player?.seekTo) {
player.seekTo(start, true);
if (shouldPlay) player.playVideo();
}
};
const updateProgress = () => {
if (!player?.getCurrentTime) return;
const currentTime = player.getCurrentTime();
const matchedIndex = buttons.findIndex((button) => {
const start = Number(button.dataset.start);
const end = Number(button.dataset.end);
return currentTime >= start && currentTime < end;
});
if (matchedIndex !== -1 && matchedIndex !== activeIndex) {
activeIndex = matchedIndex;
buttons.forEach((button, index) => {
button.classList.toggle('is-active', index === activeIndex);
button.style.setProperty('--progress', '0deg');
});
}
const { btn, start, end } = getChapter(activeIndex);
if (currentTime >= end) {
const nextIndex = activeIndex >= buttons.length - 1 ? 0 : activeIndex + 1;
setActiveChapter(nextIndex, true);
return;
}
const progress = (currentTime - start) / (end - start);
btn.style.setProperty('--progress', `${clamp(progress, 0, 1) * 360}deg`);
};
const startProgressLoop = () => {
clearInterval(progressTimer);
progressTimer = setInterval(updateProgress, 250);
};
buttons.forEach((button, index) => {
button.addEventListener('click', () => {
setActiveChapter(index, true);
startProgressLoop();
});
});
loadYouTubeAPI();
}
function initVideoChapterScroll() {
const section = document.querySelector('[data-video-chapters]');
if (!section) return;
const scrollArea = section.querySelector('.nh-video__buttons');
const arrow = section.querySelector('[data-video-scroll-arrow]');
if (!scrollArea || !arrow) return;
const mobileMedia = window.matchMedia('(max-width: 1080px)');
const state = {
destination: 'bottom',
resizeTimer: null,
isProgrammaticScrolling: false
};
const getMaxScroll = () => {
return Math.max(
0,
scrollArea.scrollHeight - scrollArea.clientHeight
);
};
const resetToTop = () => {
if (!mobileMedia.matches) return;
scrollArea.scrollTop = 0;
state.destination = 'bottom';
state.isProgrammaticScrolling = false;
arrow.classList.remove('is-up', 'is-hidden');
arrow.setAttribute(
'aria-label',
'移至最後一個影片章節'
);
};
const updateArrowState = () => {
if (!mobileMedia.matches) {
arrow.classList.remove('is-up', 'is-hidden');
return;
}
const maxScroll = getMaxScroll();
if (maxScroll <= 1) {
arrow.classList.add('is-hidden');
return;
}
const tolerance = 4;
const isAtTop = scrollArea.scrollTop <= tolerance;
const isAtBottom =
scrollArea.scrollTop >= maxScroll - tolerance;
if (isAtTop) {
state.destination = 'bottom';
arrow.classList.remove('is-up', 'is-hidden');
arrow.setAttribute(
'aria-label',
'移至最後一個影片章節'
);
return;
}
if (isAtBottom) {
state.destination = 'top';
arrow.classList.add('is-up');
arrow.classList.remove('is-hidden');
arrow.setAttribute(
'aria-label',
'移至第一個影片章節'
);
return;
}
arrow.classList.add('is-hidden');
};
const scrollToDestination = () => {
if (!mobileMedia.matches) return;
const shouldGoToBottom =
state.destination === 'bottom';
state.isProgrammaticScrolling = true;
scrollArea.scrollTo({
top: shouldGoToBottom ? getMaxScroll() : 0,
behavior: 'smooth'
});
};
const handleScroll = () => {
updateArrowState();
const maxScroll = getMaxScroll();
const tolerance = 4;
const reachedTop =
scrollArea.scrollTop <= tolerance;
const reachedBottom =
scrollArea.scrollTop >= maxScroll - tolerance;
if (reachedTop || reachedBottom) {
state.isProgrammaticScrolling = false;
}
};
arrow.addEventListener('click', scrollToDestination);
scrollArea.addEventListener('scroll', handleScroll, {
passive: true
});
mobileMedia.addEventListener('change', () => {
if (mobileMedia.matches) {
requestAnimationFrame(resetToTop);
} else {
scrollArea.scrollTop = 0;
arrow.classList.remove('is-up', 'is-hidden');
}
});
window.addEventListener('resize', () => {
clearTimeout(state.resizeTimer);
state.resizeTimer = setTimeout(() => {
if (!mobileMedia.matches) return;
updateArrowState();
}, 150);
});
window.addEventListener('pageshow', () => {
requestAnimationFrame(() => {
requestAnimationFrame(resetToTop);
});
});
requestAnimationFrame(() => {
requestAnimationFrame(resetToTop);
});
}
// =========================
// Text Reveal
// =========================
function initTextReveal() {
document.querySelectorAll('.js-text-reveal').forEach(splitRevealText);
}
function splitRevealText(el) {
const lines = el.querySelectorAll('.reveal-line');
let index = 0;
if (lines.length) {
lines.forEach((line, lineIndex) => {
const text = line.textContent.trim();
line.innerHTML = [...text].map((char) => {
return `${char}`;
}).join('');
});
} else {
const inner = el.querySelector('.text-reveal__inner') || el;
const text = inner.textContent.trim();
inner.innerHTML = [...text].map((char) => {
return `${char}`;
}).join('');
}
el.classList.add('is-ready');
}
// =========================
// Reveal Observers
// =========================
function initRevealObservers(app) {
const revealObserver = createRevealObserver('is-active', 0.4);
const upgradeDecoObserver = createRevealObserver('is-active', 0.35);
const reviewHexObserver = createSingleTargetObserver({
target: document.querySelector('.js-review-hex'),
className: 'is-active',
threshold: 0.35
});
const footerObserver = createSingleTargetObserver({
target: document.querySelector('.site-footer'),
className: 'is-active',
threshold: 0.2
});
const sectionBgObserver = createSingleTargetObserver({
target: document.querySelector('.section-bg'),
className: 'is-active',
threshold: 0.2
});
app.startRevealAnimations = () => {
document.querySelectorAll('.js-text-reveal, .js-underline-reveal').forEach((el) => {
revealObserver.observe(el);
});
document.querySelectorAll('.js-upgrade-deco').forEach((block) => {
upgradeDecoObserver.observe(block);
});
const reviewSection = document.querySelector('.reviews');
const reviewHex = document.querySelector('.js-review-hex');
const footer = document.querySelector('.site-footer');
const sectionBg = document.querySelector('.section-bg');
if (reviewSection && reviewHex) {
reviewHexObserver.observe(reviewSection);
}
if (footer) {
footerObserver.observe(footer);
}
if (sectionBg) {
sectionBgObserver.observe(sectionBg);
}
};
}
function createRevealObserver(className, threshold) {
return new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add(className);
observer.unobserve(entry.target);
});
}, {
threshold
});
}
function createSingleTargetObserver({ target, className, threshold }) {
return new IntersectionObserver(([entry], observer) => {
if (!entry.isIntersecting) return;
target?.classList.add(className);
observer.unobserve(entry.target);
}, {
threshold
});
}
// =========================
// Active Nav
// =========================
function initActiveNav() {
const navLinks = document.querySelectorAll(
'.desktop-nav a, .tablet-menu__nav a, .mobile-menu__nav a'
);
const sections = [
'#feature',
'#recommend',
'#review',
'#faq',
'#channel'
]
.map((selector) => document.querySelector(selector))
.filter(Boolean);
let isNavClickScrolling = false;
let navClickTimer = null;
const updateActiveNav = () => {
if (isNavClickScrolling) return;
const offset = window.innerHeight * 0.35;
let currentId = sections[0]?.id;
sections.forEach((section) => {
const rect = section.getBoundingClientRect();
if (rect.top <= offset) {
currentId = section.id;
}
});
setActiveById(navLinks, currentId);
};
navLinks.forEach((link) => {
link.addEventListener('click', () => {
const targetId = link.getAttribute('href')?.replace('#', '');
const targetSection = document.getElementById(targetId);
if (!targetId || !targetSection) return;
isNavClickScrolling = true;
setActiveById(navLinks, targetId);
clearTimeout(navClickTimer);
navClickTimer = setTimeout(() => {
isNavClickScrolling = false;
updateActiveNav();
}, 900);
});
});
window.addEventListener('scroll', updateActiveNav);
window.addEventListener('resize', updateActiveNav);
window.addEventListener('load', updateActiveNav);
updateActiveNav();
}
function setActiveById(navLinks, id) {
navLinks.forEach((link) => {
link.classList.toggle(
'active',
link.getAttribute('href') === `#${id}`
);
});
}
function setClickedNavActive(navLinks, activeLink) {
const id = activeLink.getAttribute('href')?.replace('#', '');
navLinks.forEach((link) => {
link.classList.toggle(
'active',
link.getAttribute('href') === `#${id}`
);
});
}
// =========================
// Utilities
// =========================
function clamp(value, min, max) {
return Math.max(min, Math.min(value, max));
}