SelectorObserver.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * @module lib/SelectorObserver
  3. * This module contains the SelectorObserver class, allowing you to register listeners that get called whenever the element(s) behind a selector exist in the DOM - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#selectorobserver)
  4. */
  5. import { Debouncer, debounce, type DebouncerType } from "./Debouncer.js";
  6. import type { Prettify } from "./types.js";
  7. void ["type only", Debouncer];
  8. let domLoaded = false;
  9. document.addEventListener("DOMContentLoaded", () => domLoaded = true);
  10. /** Options for the `onSelector()` method of {@linkcode SelectorObserver} */
  11. export type SelectorListenerOptions<TElem extends Element = HTMLElement> = Prettify<SelectorOptionsOne<TElem> | SelectorOptionsAll<TElem>>;
  12. export type SelectorOptionsOne<TElem extends Element> = SelectorOptionsCommon & {
  13. /** Whether to use `querySelectorAll()` instead - default is false */
  14. all?: false;
  15. /** Gets called whenever the selector was found in the DOM */
  16. listener: (element: TElem) => void;
  17. };
  18. export type SelectorOptionsAll<TElem extends Element> = SelectorOptionsCommon & {
  19. /** Whether to use `querySelectorAll()` instead - default is false */
  20. all: true;
  21. /** Gets called whenever the selector was found in the DOM */
  22. listener: (elements: NodeListOf<TElem>) => void;
  23. };
  24. export type SelectorOptionsCommon = {
  25. /** Whether to call the listener continuously instead of once - default is false */
  26. continuous?: boolean;
  27. /** Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default) */
  28. debounce?: number;
  29. /** The edge type of the debouncer - default is "immediate" - refer to {@linkcode Debouncer} for more info */
  30. debounceType?: DebouncerType;
  31. };
  32. export type UnsubscribeFunction = () => void;
  33. export type SelectorObserverOptions = {
  34. /** If set, applies this debounce in milliseconds to all listeners that don't have their own debounce set */
  35. defaultDebounce?: number;
  36. /** If set, applies this debounce edge type to all listeners that don't have their own set - refer to {@linkcode Debouncer} for more info */
  37. defaultDebounceType?: DebouncerType;
  38. /** Whether to disable the observer when no listeners are present - default is true */
  39. disableOnNoListeners?: boolean;
  40. /** Whether to ensure the observer is enabled when a new listener is added - default is true */
  41. enableOnAddListener?: boolean;
  42. /** 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 */
  43. checkInterval?: number;
  44. };
  45. export type SelectorObserverConstructorOptions = Prettify<SelectorObserverOptions & MutationObserverInit>;
  46. /** Observes the children of the given element for changes */
  47. export class SelectorObserver {
  48. private enabled = false;
  49. private baseElement: Element | string;
  50. private observer?: MutationObserver;
  51. private observerOptions: MutationObserverInit;
  52. private customOptions: SelectorObserverOptions;
  53. private listenerMap: Map<string, SelectorListenerOptions[]>;
  54. /**
  55. * 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)
  56. * @param baseElementSelector The selector of the element to observe
  57. * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
  58. */
  59. constructor(baseElementSelector: string, options?: SelectorObserverConstructorOptions)
  60. /**
  61. * Creates a new SelectorObserver that will observe the children of the given base element for changes (only creation and deletion of elements by default)
  62. * @param baseElement The element to observe
  63. * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
  64. */
  65. constructor(baseElement: Element, options?: SelectorObserverConstructorOptions)
  66. constructor(baseElement: Element | string, options: SelectorObserverConstructorOptions = {}) {
  67. this.baseElement = baseElement;
  68. this.listenerMap = new Map<string, SelectorListenerOptions[]>();
  69. const {
  70. defaultDebounce,
  71. defaultDebounceType,
  72. disableOnNoListeners,
  73. enableOnAddListener,
  74. ...observerOptions
  75. } = options;
  76. this.observerOptions = {
  77. childList: true,
  78. subtree: true,
  79. ...observerOptions,
  80. };
  81. this.customOptions = {
  82. defaultDebounce: defaultDebounce ?? 0,
  83. defaultDebounceType: defaultDebounceType ?? "immediate",
  84. disableOnNoListeners: disableOnNoListeners ?? false,
  85. enableOnAddListener: enableOnAddListener ?? true,
  86. };
  87. if(typeof this.customOptions.checkInterval !== "number") {
  88. // if the arrow func isn't there, `this` will be undefined in the callback
  89. this.observer = new MutationObserver(() => this.checkAllSelectors());
  90. }
  91. else {
  92. this.checkAllSelectors();
  93. setInterval(() => this.checkAllSelectors(), this.customOptions.checkInterval);
  94. }
  95. }
  96. /** Call to check all selectors in the {@linkcode listenerMap} using {@linkcode checkSelector()} */
  97. protected checkAllSelectors(): void {
  98. if(!this.enabled || !domLoaded)
  99. return;
  100. for(const [selector, listeners] of this.listenerMap.entries())
  101. this.checkSelector(selector, listeners);
  102. }
  103. /** Checks if the element(s) with the given {@linkcode selector} exist in the DOM and calls the respective {@linkcode listeners} accordingly */
  104. protected checkSelector(selector: string, listeners: SelectorListenerOptions[]): void {
  105. if(!this.enabled)
  106. return;
  107. const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
  108. if(!baseElement)
  109. return;
  110. const all = listeners.some(listener => listener.all);
  111. const one = listeners.some(listener => !listener.all);
  112. const allElements = all ? baseElement.querySelectorAll<HTMLElement>(selector) : null;
  113. const oneElement = one ? baseElement.querySelector<HTMLElement>(selector) : null;
  114. for(const options of listeners) {
  115. if(options.all) {
  116. if(allElements && allElements.length > 0) {
  117. options.listener(allElements);
  118. if(!options.continuous)
  119. this.removeListener(selector, options);
  120. }
  121. }
  122. else {
  123. if(oneElement) {
  124. options.listener(oneElement);
  125. if(!options.continuous)
  126. this.removeListener(selector, options);
  127. }
  128. }
  129. if(this.listenerMap.get(selector)?.length === 0)
  130. this.listenerMap.delete(selector);
  131. if(this.listenerMap.size === 0 && this.customOptions.disableOnNoListeners)
  132. this.disable();
  133. }
  134. }
  135. /**
  136. * Starts observing the children of the base element for changes to the given {@linkcode selector} according to the set {@linkcode options}
  137. * @param selector The selector to observe
  138. * @param options Options for the selector observation
  139. * @param options.listener Gets called whenever the selector was found in the DOM
  140. * @param [options.all] Whether to use `querySelectorAll()` instead - default is false
  141. * @param [options.continuous] Whether to call the listener continuously instead of just once - default is false
  142. * @param [options.debounce] Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default)
  143. * @returns Returns a function that can be called to remove this listener more easily
  144. */
  145. public addListener<TElem extends Element = HTMLElement>(selector: string, options: SelectorListenerOptions<TElem>): UnsubscribeFunction {
  146. options = {
  147. all: false,
  148. continuous: false,
  149. debounce: 0,
  150. ...options,
  151. };
  152. if((options.debounce && options.debounce > 0) || (this.customOptions.defaultDebounce && this.customOptions.defaultDebounce > 0)) {
  153. options.listener = debounce(
  154. options.listener as ((arg: NodeListOf<Element> | Element) => void),
  155. (options.debounce || this.customOptions.defaultDebounce)!,
  156. (options.debounceType || this.customOptions.defaultDebounceType),
  157. ) as (arg: NodeListOf<Element> | Element) => void;
  158. }
  159. if(this.listenerMap.has(selector))
  160. this.listenerMap.get(selector)!.push(options as SelectorListenerOptions<Element>);
  161. else
  162. this.listenerMap.set(selector, [options as SelectorListenerOptions<Element>]);
  163. if(this.enabled === false && this.customOptions.enableOnAddListener)
  164. this.enable();
  165. this.checkSelector(selector, [options as SelectorListenerOptions<Element>]);
  166. return () => this.removeListener(selector, options as SelectorListenerOptions<Element>);
  167. }
  168. /** Disables the observation of the child elements */
  169. public disable(): void {
  170. if(!this.enabled)
  171. return;
  172. this.enabled = false;
  173. this.observer?.disconnect();
  174. }
  175. /**
  176. * Enables or reenables the observation of the child elements.
  177. * @param immediatelyCheckSelectors Whether to immediately check if all previously registered selectors exist (default is true)
  178. * @returns Returns true when the observation was enabled, false otherwise (e.g. when the base element wasn't found)
  179. */
  180. public enable(immediatelyCheckSelectors = true): boolean {
  181. const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
  182. if(this.enabled || !baseElement)
  183. return false;
  184. this.enabled = true;
  185. this.observer?.observe(baseElement, this.observerOptions);
  186. if(immediatelyCheckSelectors)
  187. this.checkAllSelectors();
  188. return true;
  189. }
  190. /** Returns whether the observation of the child elements is currently enabled */
  191. public isEnabled(): boolean {
  192. return this.enabled;
  193. }
  194. /** Removes all listeners that have been registered with {@linkcode addListener()} */
  195. public clearListeners(): void {
  196. this.listenerMap.clear();
  197. }
  198. /**
  199. * Removes all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()}
  200. * @returns Returns true when all listeners for the associated selector were found and removed, false otherwise
  201. */
  202. public removeAllListeners(selector: string): boolean {
  203. return this.listenerMap.delete(selector);
  204. }
  205. /**
  206. * Removes a single listener for the given {@linkcode selector} and {@linkcode options} that has been registered with {@linkcode addListener()}
  207. * @returns Returns true when the listener was found and removed, false otherwise
  208. */
  209. public removeListener(selector: string, options: SelectorListenerOptions): boolean {
  210. const listeners = this.listenerMap.get(selector);
  211. if(!listeners)
  212. return false;
  213. const index = listeners.indexOf(options);
  214. if(index > -1) {
  215. listeners.splice(index, 1);
  216. return true;
  217. }
  218. return false;
  219. }
  220. /** Returns all listeners that have been registered with {@linkcode addListener()} */
  221. public getAllListeners(): Map<string, SelectorListenerOptions<HTMLElement>[]> {
  222. return this.listenerMap;
  223. }
  224. /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
  225. public getListeners(selector: string): SelectorListenerOptions<HTMLElement>[] | undefined {
  226. return this.listenerMap.get(selector);
  227. }
  228. }