SelectorObserver.ts 9.8 KB

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