UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

1,062 lines (1,061 loc) 41.4 kB
import TinyHtml from './TinyHtml.mjs'; import * as TinyCollision from '../basics/collision.mjs'; import TinyEvents from './TinyEvents.mjs'; /** * Represents the dimensions of a DOM element. * * @typedef {Object} NodeSizes * @property {number} height - The height of the element in pixels. * @property {number} width - The width of the element in pixels. */ /** * A callback function that receives node size change data and optionally returns a modified NodeSizes object. * * @callback NodeSizesEvent * @param {Element} elem - The DOM element whose size is being tracked. * @param {{ old: NodeSizes, now: NodeSizes }} sizes - The old and new size measurements of the element. * @param {{ old: number, now: number }} elemAmount - The number of matching elements before and after the update. * @returns {NodeSizes|undefined} A modified NodeSizes object to override the default measurement, or undefined to use the original. */ /** * A generic scroll-related event listener callback function. * * @callback ScrollListenersFunc * @param {any} payload - The data payload passed when the scroll event is triggered. The type may vary depending on the event. * @returns {void} */ /** * TinySmartScroller is a utility class designed to enhance and manage scroll behaviors within containers or the window. * * It enables advanced scroll monitoring, auto-scrolling to bottom, preserving scroll position during DOM changes, * and detecting visibility changes of elements. This is particularly useful for dynamic UIs like chat applications, * feed viewers, or live content containers. * * Features: * - Detects when the scroll reaches the top, bottom, or custom boundaries * - Supports automatic scrolling to the bottom unless the user scrolls away * - Observes DOM mutations and resizes, and adapts scroll position accordingly * - Emits scroll-related events such as 'onScrollBoundary', 'onAutoScroll', and 'onScrollPause' * - Includes customizable scroll correction filters for layout shift mitigation * - Handles media element load events (e.g. `<img>`, `<iframe>`, `<video>`) to prevent sudden scroll jumps * * This class is **not framework-dependent** and works with vanilla DOM elements and the window object. */ class TinySmartScroller { static Utils = { ...TinyCollision, TinyHtml }; #events = new TinyEvents(); /** * Enables or disables throwing an error when the maximum number of listeners is exceeded. * * @param {boolean} shouldThrow - If true, an error will be thrown when the max is exceeded. */ setThrowOnMaxListeners(shouldThrow) { return this.#events.setThrowOnMaxListeners(shouldThrow); } /** * Checks whether an error will be thrown when the max listener limit is exceeded. * * @returns {boolean} True if an error will be thrown, false if only a warning is shown. */ getThrowOnMaxListeners() { return this.#events.getThrowOnMaxListeners(); } ///////////////////////////////////////////////////////////// /** * Adds a listener to the beginning of the listeners array for the specified event. * * @param {string} event - Event name. * @param {ScrollListenersFunc} handler - The callback function. */ prependListener(event, handler) { return this.#events.prependListener(event, handler); } /** * Adds a one-time listener to the beginning of the listeners array for the specified event. * * @param {string} event - Event name. * @param {ScrollListenersFunc} handler - The callback function. * @returns {ScrollListenersFunc} - The wrapped handler used internally. */ prependListenerOnce(event, handler) { return this.#events.prependListenerOnce(event, handler); } ////////////////////////////////////////////////////////////////////// /** * Adds a event listener. * * @param {string} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'. * @param {ScrollListenersFunc} handler - Callback function to be called when event fires. */ appendListener(event, handler) { return this.#events.appendListener(event, handler); } /** * Registers an event listener that runs only once, then is removed. * * @param {string} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'. * @param {ScrollListenersFunc} handler - The callback function to run on event. * @returns {ScrollListenersFunc} - The wrapped version of the handler. */ appendListenerOnce(event, handler) { return this.#events.appendListenerOnce(event, handler); } /** * Adds a event listener. * * @param {string} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'. * @param {ScrollListenersFunc} handler - Callback function to be called when event fires. */ on(event, handler) { return this.#events.on(event, handler); } /** * Registers an event listener that runs only once, then is removed. * * @param {string} event - Event name, such as 'onScrollBoundary' or 'onAutoScroll'. * @param {ScrollListenersFunc} handler - The callback function to run on event. * @returns {ScrollListenersFunc} - The wrapped version of the handler. */ once(event, handler) { return this.#events.once(event, handler); } //////////////////////////////////////////////////////////////////// /** * Removes a previously registered event listener. * * @param {string} event - The name of the event to remove the handler from. * @param {ScrollListenersFunc} handler - The specific callback function to remove. */ off(event, handler) { return this.#events.off(event, handler); } /** * Removes all event listeners of a specific type from the element. * * @param {string} event - The event type to remove (e.g. 'onScrollBoundary'). */ offAll(event) { return this.#events.offAll(event); } /** * Removes all event listeners of all types from the element. */ offAllTypes() { return this.#events.offAllTypes(); } //////////////////////////////////////////////////////////// /** * Returns the number of listeners for a given event. * * @param {string} event - The name of the event. * @returns {number} Number of listeners for the event. */ listenerCount(event) { return this.#events.listenerCount(event); } /** * Returns a copy of the array of listeners for the specified event. * * @param {string} event - The name of the event. * @returns {ScrollListenersFunc[]} Array of listener functions. */ listeners(event) { return this.#events.listeners(event); } /** * Returns a copy of the array of listeners for the specified event. * * @param {string} event - The name of the event. * @returns {ScrollListenersFunc[]} Array of listener functions. */ onceListeners(event) { return this.#events.onceListeners(event); } /** * Returns a copy of the internal listeners array for the specified event, * including wrapper functions like those used by `.once()`. * @param {string | symbol} event - The event name. * @returns {ScrollListenersFunc[]} An array of raw listener functions. */ allListeners(event) { return this.#events.allListeners(event); } /** * Returns an array of event names for which there are registered listeners. * * @returns {string[]} Array of registered event names. */ eventNames() { return this.#events.eventNames(); } ////////////////////////////////////////////////////// /** * Emits an event, triggering all registered handlers for that event. * * @param {string} event - The event name to emit. * @param {...any} payload - Optional data to pass to each handler. * @returns {boolean} True if any listeners were called, false otherwise. */ emit(event, ...payload) { return this.#events.emit(event, ...payload); } /** * Sets the maximum number of listeners per event before a warning is shown. * * @param {number} n - The maximum number of listeners. */ setMaxListeners(n) { return this.#events.setMaxListeners(n); } /** * Gets the maximum number of listeners allowed per event. * * @returns {number} The maximum number of listeners. */ getMaxListeners() { return this.#events.getMaxListeners(); } /////////////////////////////////////////////////// /** @type {WeakMap<Element, NodeSizes>} */ #oldSizes = new WeakMap(); /** @type {WeakMap<Element, NodeSizes>} */ #newSizes = new WeakMap(); /** @type {WeakMap<Element, boolean>} */ #newVisibles = new WeakMap(); /** @type {WeakMap<Element, boolean>} */ #oldVisibles = new WeakMap(); /** @type {WeakMap<Element, boolean>} */ #newVisiblesByTime = new WeakMap(); /** @type {WeakMap<Element, boolean>} */ #oldVisiblesByTime = new WeakMap(); /** @type {ResizeObserver|null} */ #resizeObserver = null; /** @type {MutationObserver|null} */ #mutationObserver = null; /** @type {Set<string>} */ #loadTags = new Set(['IMG', 'IFRAME', 'VIDEO']); /** @type {null|EventListenerOrEventListenerObject} */ #handler = null; #isPastAtBottom = false; #isPastAtTop = false; #isPastAtCustomTop = false; #isPastAtCustomBottom = false; #isAtBottom = false; #isAtTop = false; #isAtCustomTop = false; #isAtCustomBottom = false; #querySelector = ''; #useWindow = false; #destroyed = false; #scrollPaused = false; #autoScrollBottom = false; #observeMutations = false; #preserveScrollOnLayoutShift = false; #debounceTime = 0; #elemAmount = 0; #elemOldAmount = 0; #lastKnownScrollBottomOffset = 0; #extraScrollBoundary = 0; /** @type {Set<string>} */ #attributeFilter; /** @type {Element} */ #target; /** @type {Set<NodeSizesEvent>} */ #sizeFilter = new Set(); /** * Creates a new instance of TinySmartScroller, attaching scroll and resize observers to manage * automatic scroll behaviors, layout shift correction, and visibility tracking. * * @param {Element|Window} target - The scroll container to monitor. Can be an element or `window`. * @param {Object} [options={}] - Optional settings to configure scroll behavior. * @param {number} [options.extraScrollBoundary=0] - Extra margin in pixels to extend scroll boundary detection. * @param {boolean} [options.autoScrollBottom=true] - Whether to auto-scroll to bottom on layout updates. * @param {boolean} [options.observeMutations=true] - Enables MutationObserver to detect DOM changes. * @param {boolean} [options.preserveScrollOnLayoutShift=true] - Prevents scroll jumps when layout changes. * @param {number} [options.debounceTime=100] - Debounce time in milliseconds for scroll events. * @param {string|null} [options.querySelector=null] - Optional CSS selector to filter observed child nodes. * @param {string[]|Set<string>|null} [options.attributeFilter=['class', 'style', 'src', 'data-*', 'height', 'width']] * - Which attributes to observe for changes. */ constructor(target, { extraScrollBoundary = 0, autoScrollBottom = true, observeMutations = true, preserveScrollOnLayoutShift = true, debounceTime = 100, querySelector = null, attributeFilter = ['class', 'style', 'src', 'data-*', 'height', 'width'], } = {}) { // === target === if (!(target instanceof Element || target === window)) throw new TypeError(`TinySmartScroller: 'target' must be a DOM Element or 'window', but got ${typeof target}`); // === extraScrollBoundary === if (typeof extraScrollBoundary !== 'number' || Number.isNaN(extraScrollBoundary)) throw new TypeError(`TinySmartScroller: 'extraScrollBoundary' must be a valid number, received ${extraScrollBoundary}`); // === autoScrollBottom === if (typeof autoScrollBottom !== 'boolean') throw new TypeError(`TinySmartScroller: 'autoScrollBottom' must be a boolean, received ${typeof autoScrollBottom}`); // === observeMutations === if (typeof observeMutations !== 'boolean') throw new TypeError(`TinySmartScroller: 'observeMutations' must be a boolean, received ${typeof observeMutations}`); // === preserveScrollOnLayoutShift === if (typeof preserveScrollOnLayoutShift !== 'boolean') throw new TypeError(`TinySmartScroller: 'preserveScrollOnLayoutShift' must be a boolean, received ${typeof preserveScrollOnLayoutShift}`); // === debounceTime === if (typeof debounceTime !== 'number' || debounceTime < 0 || Number.isNaN(debounceTime)) throw new TypeError(`TinySmartScroller: 'debounceTime' must be a non-negative number, received ${debounceTime}`); // === querySelector === if (querySelector !== null && typeof querySelector !== 'string') throw new TypeError(`TinySmartScroller: 'querySelector' must be a string or null, received ${typeof querySelector}`); // === attributeFilter === const isValidAttrList = attributeFilter === null || Array.isArray(attributeFilter) || attributeFilter instanceof Set; if (!isValidAttrList) throw new TypeError(`TinySmartScroller: 'attributeFilter' must be an array, Set, or null. Got ${typeof attributeFilter}`); // Start values this.#target = target instanceof Window ? document.documentElement : target; this.#useWindow = target instanceof Window; this.#autoScrollBottom = autoScrollBottom; this.#observeMutations = observeMutations; this.#preserveScrollOnLayoutShift = preserveScrollOnLayoutShift; this.#debounceTime = debounceTime; this.#extraScrollBoundary = extraScrollBoundary; this.#querySelector = querySelector || ''; this.#attributeFilter = new Set(attributeFilter || undefined); // Bind scroll /** @type {NodeJS.Timeout} */ let timeout; this.#handler = () => { this._scrollDataUpdater(); clearTimeout(timeout); timeout = setTimeout(() => this._onScroll(), this.#debounceTime); }; (this.#useWindow ? window : this.#target).addEventListener('scroll', this.#handler, { passive: true, }); // Mutations if (this.#observeMutations) { this._observeMutations(); this._observeResizes(this.#target.children); } this._scrollDataUpdater(); } /** * Returns a size difference callback that only reacts when height changes, filtered by tag name. * * @param {string[]} filter - List of tag names to allow. If empty, all tags are accepted. * @returns {NodeSizesEvent} A function that compares previous and current height, returning height delta. */ getSimpleOnHeight(filter = []) { if (!Array.isArray(filter)) throw new TypeError('getSimpleOnHeight(filter): filter must be an array of tag names'); return (elem, sizes, amounts) => { if ((filter.length > 0 && !filter.includes(elem.tagName)) || amounts.now !== amounts.old) return; const oldSize = sizes.old; const newSize = sizes.now; const height = newSize.height - oldSize.height; return { height, width: 0 }; }; } /** * Adds a height difference callback to the size filter system. * * @param {string[]} filter - List of tag names to allow. * @returns {NodeSizesEvent} The added size difference callback. */ addSimpleOnHeight(filter) { if (!Array.isArray(filter)) throw new TypeError('addSimpleOnHeight(filter): filter must be an array of tag names'); const result = this.getSimpleOnHeight(filter); this.onSize(result); return result; } /** * Returns a list of all currently tracked load tags. * * @returns {string[]} Array of tag names. */ getLoadTags() { return Array.from(this.#loadTags); } /** * Adds a new tag to the set of load tags. * * @param {string} tag - The tag name to add (e.g., 'IMG'). */ addLoadTag(tag) { if (typeof tag !== 'string') throw new TypeError('addLoadTag(tag): tag must be a string'); this.#loadTags.add(tag.toUpperCase()); } /** * Removes a tag from the set of load tags. * * @param {string} tag - The tag name to remove. */ removeLoadTag(tag) { if (typeof tag !== 'string') throw new TypeError('removeLoadTag(tag): tag must be a string'); this.#loadTags.delete(tag.toUpperCase()); } /** * Checks whether a tag is tracked as a load tag. * * @param {string} tag - The tag name to check. * @returns {boolean} True if the tag is being tracked. */ hasLoadTag(tag) { if (typeof tag !== 'string') throw new TypeError('hasLoadTag(tag): tag must be a string'); return this.#loadTags.has(tag.toUpperCase()); } /** * Clears the set of load tags. If `addDefault` is true, it will reset to the default tags: 'IMG', 'IFRAME', and 'VIDEO'. * * @param {boolean} [addDefault=false] - Whether to restore the default tags after clearing. * @throws {TypeError} If `addDefault` is not a boolean. */ resetLoadTags(addDefault = false) { if (typeof addDefault !== 'boolean') throw new TypeError('resetLoadTags(addDefault): addDefault must be a boolean'); this.#loadTags.clear(); if (!addDefault) return; this.#loadTags.add('IMG'); this.#loadTags.add('IFRAME'); this.#loadTags.add('VIDEO'); } /** * Returns a list of all currently tracked attribute filters. * * @returns {string[]} Array of attribute names. */ getAttributeFilters() { return Array.from(this.#attributeFilter); } /** * Adds an attribute to the filter list. * * @param {string} attr - The attribute name to add. */ addAttributeFilter(attr) { if (typeof attr !== 'string') throw new TypeError('addAttributeFilter(attr): attr must be a string'); this.#attributeFilter.add(attr); } /** * Removes an attribute from the filter list. * * @param {string} attr - The attribute name to remove. */ removeAttributeFilter(attr) { if (typeof attr !== 'string') throw new TypeError('removeAttributeFilter(attr): attr must be a string'); this.#attributeFilter.delete(attr); } /** * Checks whether a specific attribute is being filtered. * * @param {string} attr - The attribute name to check. * @returns {boolean} True if the attribute is being filtered. */ hasAttributeFilter(attr) { if (typeof attr !== 'string') throw new TypeError('hasAttributeFilter(attr): attr must be a string'); return this.#attributeFilter.has(attr); } /** * Clears the set of observed attribute filters. If `addDefault` is true, it will reset to the default attributes: * 'class', 'style', 'src', 'data-*', 'height', and 'width'. * * @param {boolean} [addDefault=false] - Whether to restore the default attribute filters after clearing. * @throws {TypeError} If `addDefault` is not a boolean. */ resetAttributeFilters(addDefault = false) { if (typeof addDefault !== 'boolean') throw new TypeError('resetAttributeFilters(addDefault): addDefault must be a boolean'); this.#attributeFilter.clear(); if (!addDefault) return; ['class', 'style', 'src', 'data-*', 'height', 'width'].forEach((attr) => this.#attributeFilter.add(attr)); } /** * Registers a custom node size change handler to the internal size filter set. * * @param {NodeSizesEvent} handler - Function that compares old and new sizes. */ onSize(handler) { if (this.#destroyed) return; if (typeof handler !== 'function') throw new TypeError('onSize(handler): handler must be a function'); this.#sizeFilter.add(handler); } /** * Unregisters a previously registered size handler from the internal filter set. * * @param {NodeSizesEvent} handler - The handler function to remove. */ offSize(handler) { if (this.#destroyed) return; if (typeof handler !== 'function') throw new TypeError('offSize(handler): handler must be a function'); this.#sizeFilter.delete(handler); } /** * Checks which elements inside the target are currently visible and updates internal maps. * * @returns {Map<Element, { oldIsVisible: boolean; isVisible: boolean }>} Visibility comparison results. */ _scrollDataUpdater() { const results = new Map(); this.#target.querySelectorAll(this.#querySelector || '*').forEach((target) => { const oldIsVisible = this.#newVisibles.get(target) ?? false; this.#oldVisibles.set(target, oldIsVisible); const isVisible = TinyHtml.isInContainer(this.#target, target); this.#newVisibles.set(target, isVisible); results.set(target, { oldIsVisible, isVisible }); }); return results; } /** * Emits a scroll-related event to all registered listeners. * * @param {string} event - Event name. * @param {*} [payload] - Optional event data payload. * @deprecated - Use emit() instead. */ _emit(event, payload) { this.emit(event, payload); } /** * Handles scroll events, calculates position-related statuses, and emits appropriate events. */ _onScroll() { if (this.#destroyed) return; // Get values const scrollCache = this._scrollDataUpdater(); const el = this.#target; const scrollTop = el.scrollTop; const scrollHeight = el.scrollHeight; const clientHeight = el.clientHeight; // Prepare sroll values const scrollResult = { scrollTop, scrollHeight, clientHeight }; let atResult = null; let atCustomResult = null; const atTop = scrollTop === 0; const atBottom = scrollTop + clientHeight >= scrollHeight - 1; const atCustomTop = scrollTop <= 0 + this.#extraScrollBoundary; const atCustomBottom = scrollTop + clientHeight >= scrollHeight - 1 - this.#extraScrollBoundary; // Scroll results if (atTop && atBottom) atResult = 'all'; else if (atTop) atResult = 'top'; else if (atBottom) atResult = 'bottom'; if (atCustomTop && atCustomBottom) atCustomResult = 'all'; else if (atCustomTop) atCustomResult = 'top'; else if (atCustomBottom) atCustomResult = 'bottom'; this.#isPastAtTop = this.#isAtTop ?? false; this.#isPastAtBottom = this.#isAtBottom ?? false; this.#isPastAtCustomTop = this.#isAtCustomTop ?? false; this.#isPastAtCustomBottom = this.#isAtCustomBottom ?? false; this.#isAtTop = atTop; this.#isAtBottom = atBottom; this.#isAtCustomTop = atCustomTop; this.#isAtCustomBottom = atCustomBottom; this.#scrollPaused = !(this.#autoScrollBottom && this.#isAtBottom); this.#lastKnownScrollBottomOffset = scrollHeight - scrollTop - clientHeight; // Send results this.emit('onScrollBoundary', { status: atResult, ...scrollResult, scrollCache }); this.emit('onExtraScrollBoundary', { status: atCustomResult, ...scrollResult, scrollCache }); if (!this.#scrollPaused) { this.emit('onAutoScroll', { ...scrollResult, scrollCache }); } else { this.emit('onScrollPause', { ...scrollResult, scrollCache }); } } /** * Attempts to correct the scroll position when layout shifts happen, preserving the user position if needed. * * @param {Element[]} [targets=[]] - List of elements involved in the size change. */ _fixScroll(targets = []) { if (this.#destroyed) return; // === Validation === if (!Array.isArray(targets)) throw new TypeError('_fixScroll: targets must be an array of Elements'); // Get Scroll data const prevScrollHeight = this.#target.scrollHeight; const prevScrollTop = this.#target.scrollTop; const prevBottomOffset = this.#target.scrollHeight - this.#target.scrollTop - this.#target.clientHeight; // Get new size const newScrollHeight = this.#target.scrollHeight; const heightDelta = newScrollHeight - prevScrollHeight; /** @type {() => NodeSizes} */ const calculateScrollSize = () => { // Run size getter const scrollSize = { height: 0, width: 0 }; for (const target of targets) { const tgOs = this.#oldSizes.get(target) || { height: 0, width: 0 }; const tgNs = this.#newSizes.get(target) || { height: 0, width: 0 }; this.#sizeFilter.forEach((fn) => { /** @type {NodeSizes| undefined} */ const sizes = fn(target, { old: tgOs, now: tgNs }, { old: this.#elemOldAmount, now: this.#elemAmount }); // Fix size if (this.#newVisibles.get(target) || this.#newVisiblesByTime.get(target)) { if (typeof sizes !== 'undefined' && typeof sizes !== 'object') throw new Error('_fixScroll: size filter must return an object or undefined'); if (typeof sizes === 'undefined') return; scrollSize.height = sizes.height; scrollSize.width = sizes.width; } }); } // Checker if (typeof scrollSize.height !== 'number' && scrollSize.height < 0) throw new Error('_fixScroll: invalid scrollSize.height value'); if (typeof scrollSize.width !== 'number' && scrollSize.width < 0) throw new Error('_fixScroll: invalid scrollSize.width value'); if (scrollSize.height !== 0 || scrollSize.width !== 0) { for (const target of targets) { this.#newVisiblesByTime.set(target, this.#newVisibles.get(target) ?? false); this.#oldVisiblesByTime.set(target, this.#oldVisibles.get(target) ?? false); } } return scrollSize; }; // Fix scroll size if (this.#elemOldAmount > 0 && TinyHtml.hasScroll(this.#target).v && this.#autoScrollBottom && this.#preserveScrollOnLayoutShift && !this.#isAtBottom && !this.#isAtTop) { const scrollSize = calculateScrollSize(); // Complete this.#target.scrollTop = prevScrollTop + heightDelta + scrollSize.height; if (scrollSize.width > 0) this.#target.scrollLeft = this.#target.scrollLeft + scrollSize.width; } // Normal stuff else if (!this.#scrollPaused && this.#autoScrollBottom) { calculateScrollSize(); this.scrollToBottom(); } else if (!this.#autoScrollBottom && !this.#isAtBottom) { calculateScrollSize(); this.#target.scrollTop = this.#target.scrollHeight - this.#target.clientHeight - prevBottomOffset; } } /** * Sets up a MutationObserver to watch for DOM changes and react accordingly to maintain scroll consistency. */ _observeMutations() { this.#mutationObserver = new MutationObserver((mutations) => { if (this.#destroyed) return; this._scrollDataUpdater(); this.#elemOldAmount = this.#elemAmount; this.#elemAmount = this.#target.childElementCount; mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (!(node instanceof Element) || node.nodeType !== 1) return; this._observeResizes([node]); this._listenLoadEvents(node); if (this.#querySelector) { const children = node.querySelectorAll(this.#querySelector); this._observeResizes(children); this._listenLoadEvents(children); } }); }); this._fixScroll(); }); // Install observer this.#mutationObserver.observe(this.#target, { childList: true, subtree: true, attributes: true, attributeFilter: this.#attributeFilter.size > 0 ? Array.from(this.#attributeFilter) : undefined, }); } /** * Adds a ResizeObserver to monitor elements' size changes and trigger layout adjustments. * * @param {NodeListOf<Element>|Element[]|HTMLCollection} elements - Elements to observe. */ _observeResizes(elements) { // Add resize observer if (!this.#resizeObserver) { this.#resizeObserver = new ResizeObserver((entries) => { if (this.#destroyed) return; this._scrollDataUpdater(); /** @type {Element[]} */ const targets = []; for (const entry of entries) { // Target const target = entry.target; // Update old size const oldSize = this.#newSizes.get(target); if (oldSize) this.#oldSizes.set(target, oldSize); // Set new size const { width, height } = entry.contentRect; this.#newSizes.set(target, { width, height }); targets.push(target); } // Animation frame this._fixScroll(targets); }); } // Execute observer Array.from(elements).forEach((el) => { if (!this.#resizeObserver) throw new Error('_observeResizes: ResizeObserver instance is not initialized'); this.#resizeObserver.observe(el); }); } /** * Listens for media/content load events (e.g., images, iframes, videos) to trigger scroll updates. * * @param {NodeListOf<Element>|Element} elements - Target element(s) to listen on. */ _listenLoadEvents(elements) { if (this.#destroyed) return; const list = elements instanceof NodeList ? Array.from(elements) : [elements]; list.forEach((el) => { if (this.#loadTags.has(el.tagName)) { // @ts-ignore if (!el.complete) { el.addEventListener('load', () => { this._scrollDataUpdater(); if (!this.#scrollPaused && this.#autoScrollBottom) { this.scrollToBottom(); } }); } } }); } /** * Returns the internal scroll container element being monitored. * * @returns {Element} The DOM element used as the scroll container target. */ get target() { return this.#target; } /** * Returns the previous size of a given element, or undefined if not tracked. * * @param {Element} el - The DOM element to query. * @returns {NodeSizes|null} The old size, or undefined. */ getOldSize(el) { return this.#oldSizes.get(el) ?? null; } /** * Returns the current size of a given element, or undefined if not tracked. * * @param {Element} el - The DOM element to query. * @returns {NodeSizes|null} The new size, or undefined. */ getNewSize(el) { return this.#newSizes.get(el) ?? null; } /** * Returns whether the given element was visible in the last scroll update. * * @param {Element} el - The DOM element to check. * @returns {boolean} True if visible, false if not, or undefined if not tracked. */ wasVisible(el) { return this.#oldVisibles.get(el) ?? false; } /** * Returns whether the given element is currently visible. * * @param {Element} el - The DOM element to check. * @returns {boolean} True if visible, false if not, or undefined if not tracked. */ isVisible(el) { return this.#newVisibles.get(el) ?? false; } /** * Returns whether the element was visible in the last time-based visibility check. * * @param {Element} el - The DOM element to check. * @returns {boolean} Visibility state from the previous timed check. */ wasTimedVisible(el) { return this.#oldVisiblesByTime.get(el) ?? false; } /** * Returns whether the element is currently visible in the time-based check. * * @param {Element} el - The DOM element to check. * @returns {boolean} Visibility state from the current timed check. */ isTimedVisible(el) { return this.#newVisiblesByTime.get(el) ?? false; } /** * Sets the extra scroll boundary margin used when determining if the user is at a "custom" bottom or top. * * @param {number} value - Pixels of additional margin to use. */ setExtraScrollBoundary(value) { if (typeof value !== 'number' || Number.isNaN(value)) throw new TypeError('setExtraScrollBoundary(value): value must be a valid number'); this.#extraScrollBoundary = value; } /** * Returns the current extra scroll boundary setting. * * @returns {number} */ getExtraScrollBoundary() { return this.#extraScrollBoundary; } /** * Returns the last known distance (in pixels) from the bottom of the scroll container. * * @returns {number} */ getLastKnownScrollBottomOffset() { return this.#lastKnownScrollBottomOffset; } /** * Forces the scroll position to move to the very bottom of the target. */ scrollToBottom() { this.#target.scrollTop = this.#target.scrollHeight; } /** * Forces the scroll position to move to the very top of the target. */ scrollToTop() { this.#target.scrollTop = 0; } /** * Checks if the user is within the defined extra scroll boundary from the bottom. * * @returns {boolean} */ isAtCustomBottom() { return this.#isAtCustomBottom; } /** * Checks if the user is within the defined extra scroll boundary from the top. * * @returns {boolean} */ isAtCustomTop() { return this.#isAtCustomTop; } /** * Returns true if the user is currently scrolled to the bottom of the element. * * @returns {boolean} */ isAtBottom() { return this.#isAtBottom; } /** * Returns true if the user is currently scrolled to the top of the element. * * @returns {boolean} */ isAtTop() { return this.#isAtTop; } /** * Returns true if the user has already passed beyond the bottom boundary at some point. * * @returns {boolean} */ isPastAtBottom() { return this.#isPastAtBottom; } /** * Returns true if the user has already passed beyond the top boundary at some point. * * @returns {boolean} */ isPastAtTop() { return this.#isPastAtTop; } /** * Returns true if the user has passed beyond the defined extra scroll boundary from the top at some point. * * @returns {boolean} */ isPastAtCustomTop() { return this.#isPastAtCustomTop; } /** * Returns true if the user has passed beyond the defined extra scroll boundary from the bottom at some point. * * @returns {boolean} */ isPastAtCustomBottom() { return this.#isPastAtCustomBottom; } /** * Checks if the user is within the defined extra scroll boundary from the bottom. * * @returns {boolean} * @deprecated - Use isAtCustomBottom instead. */ isUserAtCustomBottom() { return this.#isAtCustomBottom; } /** * Checks if the user is within the defined extra scroll boundary from the top. * * @returns {boolean} * @deprecated - Use isAtCustomTop instead. */ isUserAtCustomTop() { return this.#isAtCustomTop; } /** * Returns true if the user is currently scrolled to the bottom of the element. * * @returns {boolean} * @deprecated - Use isAtBottom instead. */ isUserAtBottom() { return this.#isAtBottom; } /** * Returns true if the user is currently scrolled to the top of the element. * * @returns {boolean} * @deprecated - Use isAtTop instead. */ isUserAtTop() { return this.#isAtTop; } /** * Returns true if automatic scrolling is currently paused. * * @returns {boolean} */ isScrollPaused() { return this.#scrollPaused; } /** * Returns whether the target is the window object. * * @returns {boolean} True if the scroll target is window, false otherwise. */ isWindow() { return this.#useWindow; } /** * Returns whether the instance has been destroyed. * * @returns {boolean} True if the instance is destroyed, false otherwise. */ isDestroyed() { return this.#destroyed; } /** * Returns whether auto-scroll-to-bottom is enabled. * * @returns {boolean} True if auto-scroll is active, false otherwise. */ getAutoScrollBottom() { return this.#autoScrollBottom; } /** * Returns whether MutationObserver is enabled. * * @returns {boolean} True if mutation observation is active, false otherwise. */ getObserveMutations() { return this.#observeMutations; } /** * Returns whether layout shift protection is enabled. * * @returns {boolean} True if scroll preservation is active, false otherwise. */ getPreserveScrollOnLayoutShift() { return this.#preserveScrollOnLayoutShift; } /** * Returns the debounce delay in milliseconds used for scroll events. * * @returns {number} Debounce delay time. */ getDebounceTime() { return this.#debounceTime; } /** * Returns the current number of matching elements observed inside the scroll target. * * @returns {number} Current count of matching elements. */ getElemAmount() { return this.#elemAmount; } /** * Returns the previous known count of matching elements from the last update. * * @returns {number} Previous count of matching elements. */ getPrevElemAmount() { return this.#elemOldAmount; } /** * Returns the query selector string used to filter observed elements. * * @returns {string} The CSS selector string, or an empty string if none was provided. */ getQuerySelector() { return this.#querySelector; } /** * Disconnects all listeners, observers, and clears memory structures. * Once destroyed, this instance should no longer be used. */ destroy() { if (this.#destroyed) return; this.#destroyed = true; // Disconnects MutationObserver if (this.#mutationObserver) { this.#mutationObserver.disconnect(); this.#mutationObserver = null; } // Disconnect ResizeObserver if (this.#resizeObserver) { this.#resizeObserver.disconnect(); this.#resizeObserver = null; } // Removes scroll listener const target = this.#useWindow ? window : this.#target; if (this.#handler) target.removeEventListener('scroll', this.#handler); // Clean the WeakMaps this.#oldSizes = new WeakMap(); this.#newSizes = new WeakMap(); this.#newVisibles = new WeakMap(); this.#oldVisibles = new WeakMap(); this.#newVisiblesByTime = new WeakMap(); this.#oldVisiblesByTime = new WeakMap(); // Cleans listeners and filters this.#events.offAllTypes(); this.#sizeFilter.clear(); this.#loadTags.clear(); } } export default TinySmartScroller;