SelectorObserver.ts 11 KB

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