Files
deepdrft/DeepDrftShared.Client/Interop/parallax/parallax.ts
T

138 lines
4.0 KiB
TypeScript

/**
* 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<unknown>;
}
interface Handle {
element: HTMLElement;
options: RegisterOptions;
observer: IntersectionObserver;
scrollListener: (() => void) | null;
rafId: number | null;
dotNetRef: DotNetObjectReference | null;
}
const handles = new Map<string, Handle>();
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);
}