web/wp-content/themes/mozilla-builders/static/js/plugins/accelerator.js (118 lines of code) (raw):

import anime from 'animejs'; /** @type {import('alpinejs').PluginCallback} */ export function accelerator(Alpine) { Alpine.directive('accelerator', (el, { value }, { cleanup }) => { if (!value) { const unregister = registerRoot(Alpine, el); cleanup(() => unregister()); } else if (value === 'path') { const unregister = registerPath(Alpine, el); cleanup(() => unregister()); } else if (value === 'targets') { const unregister = registerTargets(Alpine, el); cleanup(() => unregister()); } }); } /** * Registers the track element to animate the marquee. * * @param {import('alpinejs').Alpine} Alpine * @param {HTMLElement} el */ function registerRoot(Alpine, el) { return Alpine.bind(el, { 'x-data': () => ({ __root: el }), }); } /** * Registers the track element to animate the marquee. * * @param {import('alpinejs').Alpine} Alpine * @param {HTMLElement} el */ function registerPath(Alpine, el) { // Add path to parent data const options = Alpine.$data(el); options.pathElement = el; } /** * Waits for all images to load. * * @param {HTMLElement} target * @return {Promise<void>} */ async function waitForImages(target) { const images = Array.from(target.querySelectorAll('img')); if (!images.length) { return Promise.resolve(); } const promises = images.map(img => { if (img.complete) { return Promise.resolve(); } return new Promise((resolve, reject) => { img.addEventListener('load', resolve); img.addEventListener('error', reject); }); }); return await Promise.allSettled(promises); } /** * Registers the track element to animate the marquee. * * @param {import('alpinejs').Alpine} Alpine * @param {HTMLElement} el */ async function registerTargets(Alpine, el) { el.dataset.state = 'inactive'; /** @type {{ pathElement?: HTMLElement }} */ const options = Alpine.$data(el); const pathElement = options.pathElement; if (!pathElement) { return () => {}; } function getTargets() { const children = Array.from(el.children); const visibleChildren = children.filter(target => getComputedStyle(target).display !== 'none'); return visibleChildren; } // Setup variables const abortController = new AbortController(); const initialDelay = 200; const duration = 1600; const staggerDelay = 80; // Loop through targets and animate let targets = getTargets(); let maxIndex = targets.length - 1; const animations = targets.map((target, i) => { const percentage = Math.max((i / maxIndex) * 100, 0.01); const path = anime.path(pathElement, percentage); const initial = anime({ targets: target, translateX: path('x'), translateY: path('y'), duration, delay: anime.stagger(staggerDelay), easing: 'easeInOutQuint', }); initial.pause(); return initial; }); // Play initial animation const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); if (mediaQuery.matches) { animations.forEach(animation => animation.seek(duration)); el.dataset.state = 'active'; } else { el.dataset.state = 'active'; const imageLoadPromises = targets.map(waitForImages); const currentTime = Date.now(); Promise.allSettled(imageLoadPromises).then(() => { const deltaTime = Date.now() - currentTime; const delay = Math.max(initialDelay - deltaTime, 0); setTimeout(() => { if (!abortController.signal.aborted) { animations.forEach(animation => animation.play()); } }, delay); }); } // Pause the animation when reduced motion is enabled or the window is resized function finishAnimation() { animations.forEach(animation => animation.pause()); abortController.abort(); targets = getTargets(); maxIndex = targets.length - 1; targets.forEach((target, i) => { const percentage = Math.max((i / maxIndex) * 100, 0.01); const path = anime.path(pathElement, percentage); const initial = anime({ targets: target, translateX: path('x'), translateY: path('y'), duration: 0, }); initial.pause(); }); } mediaQuery.addEventListener('change', finishAnimation); window.addEventListener('resize', finishAnimation); // Cleanup return () => { abortController.abort(); window.removeEventListener('resize', finishAnimation); mediaQuery.removeEventListener('change', finishAnimation); el.dataset.state = 'inactive'; }; }