import { ScrollableWindow } from './scrollableWindow'; import { ScrollPosition } from './scrollPosition'; import { DebouncedFunc } from 'lodash'; import { EventEmitter } from '@wix/bex-core/events'; import { PartialLodash } from '@wix/bex-core'; const debounceOptions = { trailing: true }; const debounceDelay = 400; const scrollIdleDelay = 250; export interface ReachBottomListenerParams { // distance from the bottom to trigger the callback offsetPX?: number; onReachBottom: () => unknown; // throttle scroll event, debounce resize event delay?: number; container: ScrollableWindow; target?: HTMLElement | null; lodash: PartialLodash; } export interface ReachBottomListener extends ReachBottomListenerParams {} export class ReachBottomListener { scrollPosition!: ScrollPosition; offsetPX: number; onReachBottom: () => unknown; scrollListener: () => Promise | undefined; container: ScrollableWindow; _target?: HTMLElement | null; targetScrollingParent?: HTMLElement | null; triggerListenerIfReached: DebouncedFunc<() => Promise>; isScrolling = false; events = new EventEmitter(); debounceSetScrollingIdle: () => unknown; constructor(params: ReachBottomListenerParams) { this.container = params.container; this.target = params.target; this.offsetPX = params.offsetPX ?? 0; this.onReachBottom = params.onReachBottom; const delay = Math.max(params.delay || 0, 0); const { lodash: { throttle, debounce }, } = params; this.scrollListener = delay ? throttle(this.handleScroll, delay) : this.handleScroll; this.debounceSetScrollingIdle = debounce(() => { this.isScrolling = false; this.events.emit('idle'); }, Math.max(scrollIdleDelay, delay)); this.triggerListenerIfReached = debounce( async () => { this.scrollPosition = this.currentScrollPosition(); const { onReachBottom, scrollPosition, bottomX, offsetPX } = this; if ( onReachBottom != null && scrollPosition.reachedTo(bottomX, offsetPX) ) { await onReachBottom(); return true; } return false; }, debounceDelay, debounceOptions, ); } set target(value: HTMLElement | null | undefined) { this._target = value; this.targetScrollingParent = null; if (this._target != null) { let offsetParent = this._target.offsetParent; while ( offsetParent instanceof HTMLElement && offsetParent?.offsetParent !== this.container ) { offsetParent = offsetParent.offsetParent; } if (offsetParent instanceof HTMLElement) { this.targetScrollingParent = offsetParent; } } } get target() { return this._target; } currentScrollPosition() { const { scrollHeight, clientHeight, scrollTop } = this.container; return new ScrollPosition({ clientHeight, scrollHeight, scrollTop }); } handleScroll = async () => { const { clientHeight, scrollHeight, scrollTop } = this.container; this.isScrolling = true; this.debounceSetScrollingIdle(); const newScrollPosition = new ScrollPosition({ clientHeight, scrollHeight, scrollTop, }); const { offsetPX, onReachBottom, scrollPosition, bottomX } = this; if ( onReachBottom != null && newScrollPosition.reachingTo(scrollPosition, bottomX, offsetPX) ) { await onReachBottom(); } this.scrollPosition = newScrollPosition; }; get bottomX() { // either the container bottom, or the target element offset from the container const { target, targetScrollingParent, scrollPosition } = this; if (target != null && targetScrollingParent != null) { return target.offsetTop + targetScrollingParent.offsetTop; } return scrollPosition.scrollHeight; } resizeListener = () => { this.triggerListenerIfReached(); }; subscribe() { this.scrollPosition = this.currentScrollPosition(); this.container.addEventListener('scroll', this.scrollListener); window.addEventListener('resize', this.resizeListener); return this; } unsubscribe() { this.container.removeEventListener('scroll', this.scrollListener); window.removeEventListener('resize', this.resizeListener); } }