123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- import { Debouncer, debounce, type DebouncerType } from "./Debouncer.js";
- import type { Prettify } from "./types.js";
- void ["type only", Debouncer];
- let domLoaded = false;
- document.addEventListener("DOMContentLoaded", () => domLoaded = true);
- /** Options for the `onSelector()` method of {@linkcode SelectorObserver} */
- export type SelectorListenerOptions<TElem extends Element = HTMLElement> = Prettify<SelectorOptionsOne<TElem> | SelectorOptionsAll<TElem>>;
- export type SelectorOptionsOne<TElem extends Element> = SelectorOptionsCommon & {
- /** Whether to use `querySelectorAll()` instead - default is false */
- all?: false;
- /** Gets called whenever the selector was found in the DOM */
- listener: (element: TElem) => void;
- };
- export type SelectorOptionsAll<TElem extends Element> = SelectorOptionsCommon & {
- /** Whether to use `querySelectorAll()` instead - default is false */
- all: true;
- /** Gets called whenever the selector was found in the DOM */
- listener: (elements: NodeListOf<TElem>) => void;
- };
- export type SelectorOptionsCommon = {
- /** Whether to call the listener continuously instead of once - default is false */
- continuous?: boolean;
- /** Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default) */
- debounce?: number;
- /** The edge type of the debouncer - default is "immediate" - refer to {@linkcode Debouncer} for more info */
- debounceType?: DebouncerType;
- };
- export type UnsubscribeFunction = () => void;
- export type SelectorObserverOptions = {
- /** If set, applies this debounce in milliseconds to all listeners that don't have their own debounce set */
- defaultDebounce?: number;
- /** If set, applies this debounce edge type to all listeners that don't have their own set - refer to {@linkcode Debouncer} for more info */
- defaultDebounceType?: DebouncerType;
- /** Whether to disable the observer when no listeners are present - default is true */
- disableOnNoListeners?: boolean;
- /** Whether to ensure the observer is enabled when a new listener is added - default is true */
- enableOnAddListener?: boolean;
- /** If set to a number, the checks will be run on interval instead of on mutation events - in that case all MutationObserverInit props will be ignored */
- checkInterval?: number;
- };
- export type SelectorObserverConstructorOptions = Prettify<SelectorObserverOptions & MutationObserverInit>;
- /** Observes the children of the given element for changes */
- export class SelectorObserver {
- private enabled = false;
- private baseElement: Element | string;
- private observer?: MutationObserver;
- private observerOptions: MutationObserverInit;
- private customOptions: SelectorObserverOptions;
- private listenerMap: Map<string, SelectorListenerOptions[]>;
- /**
- * Creates a new SelectorObserver that will observe the children of the given base element selector for changes (only creation and deletion of elements by default)
- * @param baseElementSelector The selector of the element to observe
- * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
- */
- constructor(baseElementSelector: string, options?: SelectorObserverConstructorOptions)
- /**
- * Creates a new SelectorObserver that will observe the children of the given base element for changes (only creation and deletion of elements by default)
- * @param baseElement The element to observe
- * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
- */
- constructor(baseElement: Element, options?: SelectorObserverConstructorOptions)
- constructor(baseElement: Element | string, options: SelectorObserverConstructorOptions = {}) {
- this.baseElement = baseElement;
- this.listenerMap = new Map<string, SelectorListenerOptions[]>();
- const {
- defaultDebounce,
- defaultDebounceType,
- disableOnNoListeners,
- enableOnAddListener,
- ...observerOptions
- } = options;
- this.observerOptions = {
- childList: true,
- subtree: true,
- ...observerOptions,
- };
- this.customOptions = {
- defaultDebounce: defaultDebounce ?? 0,
- defaultDebounceType: defaultDebounceType ?? "immediate",
- disableOnNoListeners: disableOnNoListeners ?? false,
- enableOnAddListener: enableOnAddListener ?? true,
- };
- if(typeof this.customOptions.checkInterval !== "number") {
- // if the arrow func isn't there, `this` will be undefined in the callback
- this.observer = new MutationObserver(() => this.checkAllSelectors());
- }
- else {
- this.checkAllSelectors();
- setInterval(() => this.checkAllSelectors(), this.customOptions.checkInterval);
- }
- }
- /** Call to check all selectors in the {@linkcode listenerMap} using {@linkcode checkSelector()} */
- protected checkAllSelectors(): void {
- if(!this.enabled || !domLoaded)
- return;
- for(const [selector, listeners] of this.listenerMap.entries())
- this.checkSelector(selector, listeners);
- }
- /** Checks if the element(s) with the given {@linkcode selector} exist in the DOM and calls the respective {@linkcode listeners} accordingly */
- protected checkSelector(selector: string, listeners: SelectorListenerOptions[]): void {
- if(!this.enabled)
- return;
- const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
- if(!baseElement)
- return;
- const all = listeners.some(listener => listener.all);
- const one = listeners.some(listener => !listener.all);
- const allElements = all ? baseElement.querySelectorAll<HTMLElement>(selector) : null;
- const oneElement = one ? baseElement.querySelector<HTMLElement>(selector) : null;
- for(const options of listeners) {
- if(options.all) {
- if(allElements && allElements.length > 0) {
- options.listener(allElements);
- if(!options.continuous)
- this.removeListener(selector, options);
- }
- }
- else {
- if(oneElement) {
- options.listener(oneElement);
- if(!options.continuous)
- this.removeListener(selector, options);
- }
- }
- if(this.listenerMap.get(selector)?.length === 0)
- this.listenerMap.delete(selector);
- if(this.listenerMap.size === 0 && this.customOptions.disableOnNoListeners)
- this.disable();
- }
- }
- /**
- * Starts observing the children of the base element for changes to the given {@linkcode selector} according to the set {@linkcode options}
- * @param selector The selector to observe
- * @param options Options for the selector observation
- * @param options.listener Gets called whenever the selector was found in the DOM
- * @param [options.all] Whether to use `querySelectorAll()` instead - default is false
- * @param [options.continuous] Whether to call the listener continuously instead of just once - default is false
- * @param [options.debounce] Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default)
- * @returns Returns a function that can be called to remove this listener more easily
- */
- public addListener<TElem extends Element = HTMLElement>(selector: string, options: SelectorListenerOptions<TElem>): UnsubscribeFunction {
- options = {
- all: false,
- continuous: false,
- debounce: 0,
- ...options,
- };
- if((options.debounce && options.debounce > 0) || (this.customOptions.defaultDebounce && this.customOptions.defaultDebounce > 0)) {
- options.listener = debounce(
- options.listener as ((arg: NodeListOf<Element> | Element) => void),
- (options.debounce || this.customOptions.defaultDebounce)!,
- (options.debounceType || this.customOptions.defaultDebounceType),
- ) as (arg: NodeListOf<Element> | Element) => void;
- }
- if(this.listenerMap.has(selector))
- this.listenerMap.get(selector)!.push(options as SelectorListenerOptions<Element>);
- else
- this.listenerMap.set(selector, [options as SelectorListenerOptions<Element>]);
- if(this.enabled === false && this.customOptions.enableOnAddListener)
- this.enable();
- this.checkSelector(selector, [options as SelectorListenerOptions<Element>]);
- return () => this.removeListener(selector, options as SelectorListenerOptions<Element>);
- }
- /** Disables the observation of the child elements */
- public disable(): void {
- if(!this.enabled)
- return;
- this.enabled = false;
- this.observer?.disconnect();
- }
- /**
- * Enables or reenables the observation of the child elements.
- * @param immediatelyCheckSelectors Whether to immediately check if all previously registered selectors exist (default is true)
- * @returns Returns true when the observation was enabled, false otherwise (e.g. when the base element wasn't found)
- */
- public enable(immediatelyCheckSelectors = true): boolean {
- const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
- if(this.enabled || !baseElement)
- return false;
- this.enabled = true;
- this.observer?.observe(baseElement, this.observerOptions);
- if(immediatelyCheckSelectors)
- this.checkAllSelectors();
- return true;
- }
- /** Returns whether the observation of the child elements is currently enabled */
- public isEnabled(): boolean {
- return this.enabled;
- }
- /** Removes all listeners that have been registered with {@linkcode addListener()} */
- public clearListeners(): void {
- this.listenerMap.clear();
- }
- /**
- * Removes all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()}
- * @returns Returns true when all listeners for the associated selector were found and removed, false otherwise
- */
- public removeAllListeners(selector: string): boolean {
- return this.listenerMap.delete(selector);
- }
- /**
- * Removes a single listener for the given {@linkcode selector} and {@linkcode options} that has been registered with {@linkcode addListener()}
- * @returns Returns true when the listener was found and removed, false otherwise
- */
- public removeListener(selector: string, options: SelectorListenerOptions): boolean {
- const listeners = this.listenerMap.get(selector);
- if(!listeners)
- return false;
- const index = listeners.indexOf(options);
- if(index > -1) {
- listeners.splice(index, 1);
- return true;
- }
- return false;
- }
- /** Returns all listeners that have been registered with {@linkcode addListener()} */
- public getAllListeners(): Map<string, SelectorListenerOptions<HTMLElement>[]> {
- return this.listenerMap;
- }
- /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
- public getListeners(selector: string): SelectorListenerOptions<HTMLElement>[] | undefined {
- return this.listenerMap.get(selector);
- }
- }
|