import type { DomTools } from './domtools.classes.domtools.js'; import * as plugins from './domtools.plugins.js'; export class Scroller { public domtoolsInstance: DomTools; private disposed = false; // Array to store scroll callback functions. private scrollCallbacks: Array<() => void> = []; // Lenis instance (if activated) or null. private lenisInstance: plugins.Lenis | null = null; private lenisScrollUnsubscribe: (() => void) | null = null; private nativeScrollListenerAttached = false; // Bound handlers to allow removal from event listeners. private handleNativeScroll = (_event: Event): void => { this.executeScrollCallbacks(); }; private handleLenisScroll = (_info: plugins.Lenis): void => { this.executeScrollCallbacks(); }; constructor(domtoolsInstanceArg: DomTools) { this.domtoolsInstance = domtoolsInstanceArg; // Attach the native scroll listener by default. this.attachNativeScrollListener(); } private sweetScroller = new plugins.SweetScroll({}); /** * Scrolls to a given element with options. */ public async toElement( elementArg: HTMLElement, optionsArg: Parameters[1] ) { this.sweetScroller.toElement(elementArg, optionsArg); if (optionsArg?.duration) { await plugins.smartdelay.delayFor(optionsArg.duration); } } /** * Detects whether native smooth scrolling is enabled. */ public async detectNativeSmoothScroll() { const rootScrollBehavior = getComputedStyle(document.documentElement).scrollBehavior; const bodyScrollBehavior = document.body ? getComputedStyle(document.body).scrollBehavior : 'auto'; if (rootScrollBehavior === 'smooth' || bodyScrollBehavior === 'smooth') { return true; } const done = plugins.smartpromise.defer(); const sampleSize = 12; const acceptableDeltaDifference = 3; const minimumSmoothRatio = 0.75; const timeoutInMs = 1200; const eventDeltas: number[] = []; const finalize = (result: boolean) => { window.removeEventListener('wheel', onWheel); window.clearTimeout(timeoutId); done.resolve(result); }; function onWheel(event: WheelEvent) { eventDeltas.push(event.deltaY); if (eventDeltas.length >= sampleSize) { analyzeEvents(); } } function analyzeEvents() { if (eventDeltas.length < 2) { finalize(false); return; } const totalDiffs = eventDeltas.length - 1; let smallDiffCount = 0; for (let i = 0; i < totalDiffs; i++) { const diff = Math.abs(eventDeltas[i + 1] - eventDeltas[i]); if (diff <= acceptableDeltaDifference) { smallDiffCount++; } } const smoothRatio = smallDiffCount / totalDiffs; finalize(smoothRatio >= minimumSmoothRatio); } const timeoutId = window.setTimeout(() => { analyzeEvents(); }, timeoutInMs); window.addEventListener('wheel', onWheel, { passive: true }); return done.promise; } /** * Enables Lenis scrolling. * If optionsArg.disableOnNativeSmoothScroll is true and native smooth scrolling is detected, * Lenis will be destroyed immediately. */ public async enableLenisScroll(optionsArg?: { disableOnNativeSmoothScroll?: boolean }) { if (this.disposed) { return; } if (this.lenisInstance) { this.disableLenisScroll(); } const lenis = new plugins.Lenis({ autoRaf: true, }); if (optionsArg?.disableOnNativeSmoothScroll) { if (await this.detectNativeSmoothScroll()) { lenis.destroy(); return; } } // Activate Lenis scrolling. this.lenisInstance = lenis; // Switch from native scroll listener to Lenis scroll listener. this.detachNativeScrollListener(); this.attachLenisScrollListener(); } public disableLenisScroll() { if (!this.lenisInstance) { return; } this.detachLenisScrollListener(); this.lenisInstance.destroy(); this.lenisInstance = null; this.attachNativeScrollListener(); } /** * Registers a callback to be executed on scroll. * @param callback A function to execute on each scroll event. */ public onScroll(callback: () => void): () => void { this.scrollCallbacks.push(callback); return () => { this.scrollCallbacks = this.scrollCallbacks.filter((existingCallback) => existingCallback !== callback); }; } /** * Executes all registered scroll callbacks concurrently. */ private executeScrollCallbacks(): void { // Execute all callbacks in parallel. this.scrollCallbacks.forEach((callback) => { try { callback(); } catch (error) { console.error('Error in scroll callback:', error); } }); } /** * Attaches the native scroll event listener. */ private attachNativeScrollListener(): void { if (this.nativeScrollListenerAttached || this.disposed) { return; } window.addEventListener('scroll', this.handleNativeScroll); this.nativeScrollListenerAttached = true; } /** * Detaches the native scroll event listener. */ private detachNativeScrollListener(): void { if (!this.nativeScrollListenerAttached) { return; } window.removeEventListener('scroll', this.handleNativeScroll); this.nativeScrollListenerAttached = false; } /** * Attaches the Lenis scroll event listener. */ private attachLenisScrollListener(): void { if (this.lenisInstance && !this.lenisScrollUnsubscribe) { this.lenisScrollUnsubscribe = this.lenisInstance.on('scroll', this.handleLenisScroll); } } /** * Detaches the Lenis scroll event listener. */ private detachLenisScrollListener(): void { if (this.lenisScrollUnsubscribe) { this.lenisScrollUnsubscribe(); this.lenisScrollUnsubscribe = null; } } public dispose() { if (this.disposed) { return; } this.disposed = true; this.detachLenisScrollListener(); this.lenisInstance?.destroy(); this.lenisInstance = null; this.detachNativeScrollListener(); this.scrollCallbacks = []; this.sweetScroller.destroy(); } }