import { ANIMATE_ATTRIBUTE, ANIMATE_DELAY_ATTRIBUTE } from "#configuration/constants/element.constants"; import type { Disposer } from "#types/base.types"; const STAGGER_ATTRIBUTE = "data-animate-stagger"; const TRIGGER_ATTRIBUTE = "data-animate-trigger"; const ANIMATED_CLASS = "animated"; const DELAY_PROPERTY = "--animate-delay"; const ROOT_MARGIN = "50px 0px 0px 0px"; const THRESHOLD = 0; const RADIX = 10; const LOAD_TRIGGER = "load"; const TRIGGER_EVENTS: ReadonlyMap = new Map([ ["hover", "mouseenter"], ["click", "click"], ]); const animatedSelector = `[${ANIMATE_ATTRIBUTE}]`; const staggerDelay = function staggerDelay(element: Element): number | null { const parent = element.closest(`[${STAGGER_ATTRIBUTE}]`); if (parent === null) { return null; } const step = Number.parseInt(parent.getAttribute(STAGGER_ATTRIBUTE) ?? "", RADIX); const index = [...parent.querySelectorAll(animatedSelector)].indexOf(element); return index > 0 && Number.isFinite(step) ? index * step : null; }; const animate = function animate(element: HTMLElement): void { const delay = element.getAttribute(ANIMATE_DELAY_ATTRIBUTE); if (delay !== null) { element.style.setProperty(DELAY_PROPERTY, `${delay}ms`); } const stagger = staggerDelay(element); if (stagger !== null) { element.style.setProperty(DELAY_PROPERTY, `${stagger}ms`); } element.classList.add(ANIMATED_CLASS); }; const replay = function replay(element: HTMLElement): void { element.classList.remove(ANIMATED_CLASS); element.getBoundingClientRect(); element.classList.add(ANIMATED_CLASS); }; const primeReplay = function primeReplay(element: HTMLElement, eventName: string): void { element.classList.add(ANIMATED_CLASS); element.addEventListener(eventName, () => { replay(element); }); }; type Tracker = (element: Element) => void; const trackElement = function trackElement(track: Tracker, element: HTMLElement): void { if (element.classList.contains(ANIMATED_CLASS)) { return; } const trigger = element.getAttribute(TRIGGER_ATTRIBUTE) ?? ""; const eventName = TRIGGER_EVENTS.get(trigger); if (eventName !== undefined) { primeReplay(element, eventName); return; } if (trigger === LOAD_TRIGGER) { animate(element); return; } track(element); }; const scan = function scan(track: Tracker, root: Element): void { for (const element of root.querySelectorAll(animatedSelector)) { trackElement(track, element); } }; const onIntersect = function onIntersect(entries: IntersectionObserverEntry[], observer: IntersectionObserver): void { for (const entry of entries) { if (entry.isIntersecting && entry.target instanceof HTMLElement) { animate(entry.target); observer.unobserve(entry.target); } } }; const onMutate = function onMutate(track: Tracker, mutations: MutationRecord[]): void { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) { continue; } if (node.hasAttribute(ANIMATE_ATTRIBUTE)) { trackElement(track, node); } scan(track, node); } } }; export const observeAnimations = function observeAnimations(root: Element): Disposer { const observer = new IntersectionObserver(onIntersect, { root: null, rootMargin: ROOT_MARGIN, threshold: THRESHOLD, }); const track = observer.observe.bind(observer); const mutations = new MutationObserver((records) => { onMutate(track, records); }); const trackTree = mutations.observe.bind(mutations); scan(track, root); trackTree(root, { childList: true, subtree: true }); return () => { observer.disconnect(); mutations.disconnect(); }; };