/** * 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; this module * writes only the `--parallax-pos` CSS custom property — never concrete style. */ interface RegisterOptions { speed: number; // ParallaxSpeed, clamped [0,1] invertDirection: boolean; // selects the formula branch onNaturalHeight?: boolean; // if true, report natural height via dotNetRef callback image1?: string; // primary image URL, used by reportNaturalHeight probe } interface DotNetObjectReference { invokeMethodAsync(methodName: string, ...args: unknown[]): Promise; } interface Handle { element: HTMLElement; options: RegisterOptions; observer: IntersectionObserver; scrollListener: (() => void) | null; rafId: number | null; dotNetRef: DotNetObjectReference | 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; element.style.setProperty('--parallax-pos', `${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 }); // Prime position immediately so entry isn't a frame behind the first scroll. 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 reportNaturalHeight(handle: Handle): void { if (!handle.dotNetRef || !handle.options.image1) return; const probe = new Image(); probe.onload = (): void => { handle.dotNetRef?.invokeMethodAsync('SetNaturalHeight', probe.naturalHeight); }; probe.src = handle.options.image1; } export function register( element: HTMLElement, options: RegisterOptions, dotNetRef?: DotNetObjectReference, ): 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, scrollListener: null, rafId: null, dotNetRef: dotNetRef ?? null, }; handle.observer.observe(element); handles.set(id, handle); if (options.onNaturalHeight) { reportNaturalHeight(handle); } return id; } export function unregister(handleId: string): void { const handle = handles.get(handleId); if (!handle) return; detachScrollListener(handle); handle.observer.disconnect(); handle.dotNetRef = null; handles.delete(handleId); }