/** * parallax - scroll-driven background-position panning for ParallaxImage. * * Single Responsibility: own the parallax math and scroll/observer lifecycle. * Blazor owns the component lifecycle and calls register/unregister. When the * IntersectionObserver fires and JS attaches the scroll listener, this module * sets data-parallax-active and immediately primes background-position-y — * atomically cancelling the pre-WASM CSS animation and writing the correct * position in the same turn, so there is no flash at the handoff. */ interface RegisterOptions { speed: number; // ParallaxSpeed, clamped [0,1] invertDirection: boolean; // selects the formula branch heightFraction?: number; // null/absent = use whatever CSS sets; present = auto-compute height image1?: string; // primary image URL, used by the auto-height probe } interface Handle { element: HTMLElement; options: RegisterOptions; observer: IntersectionObserver; // scroll gating resizeObserver: ResizeObserver | null; // height recalc on resize scrollListener: (() => void) | null; rafId: number | null; } const handles = new Map(); let _handleCounter = 0; const reducedMotion = (): boolean => window.matchMedia('(prefers-reduced-motion: reduce)').matches; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function applyParallax(handle: Handle): void { const { element, options } = handle; const rect = element.getBoundingClientRect(); const viewportH = window.innerHeight; let progress = options.invertDirection ? rect.top / viewportH : 1 - rect.top / viewportH; progress = clamp(progress, 0, 1); const pos = progress * clamp(options.speed, 0, 1) * 100; // Write background-position-y on each layer directly — the same property the // pre-WASM CSS animation drives (now cancelled via data-parallax-active). const layers = element.querySelectorAll(':scope > .layer'); for (const layer of layers) { layer.style.backgroundPositionY = `${pos}%`; } } function attachScrollListener(handle: Handle): void { if (handle.scrollListener || reducedMotion()) return; const listener = (): void => { if (handle.rafId !== null) return; handle.rafId = requestAnimationFrame(() => { handle.rafId = null; applyParallax(handle); }); }; handle.scrollListener = listener; window.addEventListener('scroll', listener, { passive: true }); // Cancel CSS animation and prime position atomically — no gap where neither drives. handle.element.setAttribute('data-parallax-active', ''); applyParallax(handle); } function detachScrollListener(handle: Handle): void { if (handle.scrollListener) { window.removeEventListener('scroll', handle.scrollListener); handle.scrollListener = null; } if (handle.rafId !== null) { cancelAnimationFrame(handle.rafId); handle.rafId = null; } } function setupAutoHeight(handle: Handle): void { if (!handle.options.heightFraction || !handle.options.image1) return; const fraction = handle.options.heightFraction; const element = handle.element; const applyHeight = (naturalW: number, naturalH: number): void => { const containerW = element.offsetWidth; if (containerW === 0 || naturalW === 0) return; const h = Math.round(containerW * (naturalH / naturalW) * fraction); element.style.setProperty('--window-height', `${h}px`); }; const probe = new Image(); probe.onload = (): void => { const nw = probe.naturalWidth; const nh = probe.naturalHeight; applyHeight(nw, nh); // Watch container width changes (orientation, responsive layout, etc.) handle.resizeObserver = new ResizeObserver(() => { applyHeight(nw, nh); }); handle.resizeObserver.observe(element); }; probe.src = handle.options.image1; } export function register(element: HTMLElement, options: RegisterOptions): string { const id = `parallax-${++_handleCounter}`; const realObserver = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { attachScrollListener(handle); } else { detachScrollListener(handle); } } }); const handle: Handle = { element, options: { ...options, speed: clamp(options.speed, 0, 1) }, observer: realObserver, resizeObserver: null, scrollListener: null, rafId: null, }; handle.observer.observe(element); handles.set(id, handle); setupAutoHeight(handle); // Prime position synchronously so enhanced navigation and WASM handoff have // zero-frame gap. The init script covers cold page load; this covers nav. // Skip under reduced motion — parallax.ts never drives position then. if (!reducedMotion()) { element.setAttribute('data-parallax-active', ''); applyParallax(handle); } return id; } export function unregister(handleId: string): void { const handle = handles.get(handleId); if (!handle) return; detachScrollListener(handle); handle.element.removeAttribute('data-parallax-active'); handle.observer.disconnect(); handle.resizeObserver?.disconnect(); handles.delete(handleId); }