Browse Source

feat: migrate onSelector to class & add debounce

Sven 1 year ago
parent
commit
b41a218513
5 changed files with 165 additions and 109 deletions
  1. 1 0
      README.md
  2. 0 0
      lib/ConfigManager.ts
  3. 162 0
      lib/SelectorObserver.ts
  4. 2 2
      lib/index.ts
  5. 0 107
      lib/onSelector.ts

+ 1 - 0
README.md

@@ -112,6 +112,7 @@ See the [license file](./LICENSE.txt) for details.
 
 ## DOM:
 
+<!-- TODO: write docs for SelectorObserver class -->
 ### onSelector()
 Usage:  
 ```ts

+ 0 - 0
lib/config.ts → lib/ConfigManager.ts


+ 162 - 0
lib/SelectorObserver.ts

@@ -0,0 +1,162 @@
+/** Options for the `onSelector()` method of {@linkcode SelectorObserver} */
+export type OnSelectorOptions<TElem extends Element = HTMLElement> = SelectorOptionsOne<TElem> | SelectorOptionsAll<TElem>;
+
+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;
+};
+
+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;
+};
+
+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;
+};
+
+/** Observes the children of the given element for changes */
+export class SelectorObserver {
+  private enabled = true;
+  private baseElement: Element;
+  private observer: MutationObserver;
+  private observerOptions: MutationObserverInit;
+  private listenerMap = new Map<string, OnSelectorOptions[]>();
+
+  /**
+   * 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 options fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
+   */
+  constructor(baseElement: Element, observerOptions?: MutationObserverInit) {
+    this.baseElement = baseElement;
+
+    this.observer = new MutationObserver(this.checkSelectors);
+    this.observerOptions = observerOptions ?? {};
+
+    this.enable();
+  }
+
+  private checkSelectors() {
+    for(const [selector, listeners] of this.listenerMap.entries()) {
+      if(!this.enabled)
+        return;
+
+      const all = listeners.some(listener => listener.all);
+      const one = listeners.some(listener => !listener.all);
+
+      const allElements = all ? this.baseElement.querySelectorAll<HTMLElement>(selector) : null;
+      const oneElement = one ? this.baseElement.querySelector<HTMLElement>(selector) : null;
+
+      for(const options of listeners) {
+        if(!this.enabled)
+          return;
+        if(options.all) {
+          if(allElements && allElements.length > 0) {
+            options.listener(allElements);
+            if(!options.continuous)
+              this.listenerMap.get(selector)!.splice(this.listenerMap.get(selector)!.indexOf(options), 1);
+          }
+        } else {
+          if(oneElement) {
+            options.listener(oneElement);
+            if(!options.continuous)
+              this.listenerMap.get(selector)!.splice(this.listenerMap.get(selector)!.indexOf(options), 1);
+          }
+        }
+      }
+    }
+  }
+
+  private debounce<TArgs>(func: (...args: TArgs[]) => void, time: number): (...args: TArgs[]) => void {
+    let timeout: number;
+    return function(...args: TArgs[]) {
+      clearTimeout(timeout);
+      timeout = setTimeout(() => func.apply(this, args), time) as unknown as number;
+    };
+  }
+
+  /**
+   * 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)
+   */
+  public addListener<TElem extends Element = HTMLElement>(selector: string, options: OnSelectorOptions<TElem>) {
+    options = { all: false, continuous: false, debounce: 0, ...options };
+    if(options.debounce && options.debounce > 0)
+      options.listener = this.debounce(options.listener as ((arg: NodeListOf<Element> | Element) => void), options.debounce);
+    if(this.listenerMap.has(selector))
+      this.listenerMap.get(selector)!.push(options as OnSelectorOptions<Element>);
+    else
+      this.listenerMap.set(selector, [options as OnSelectorOptions<Element>]);
+  }
+
+  /** Disables the observation of the child elements */
+  public disable() {
+    if(!this.enabled)
+      return;
+    this.enabled = false;
+    this.observer.disconnect();
+  }
+
+  /** Reenables the observation of the child elements */
+  public enable() {
+    if(this.enabled)
+      return;
+    this.enabled = true;
+    this.observer.observe(this.baseElement, {
+      childList: true,
+      subtree: true,
+      ...this.observerOptions,
+    });
+  }
+
+  /** Removes all listeners that have been registered with {@linkcode addListener()} */
+  public clearListeners() {
+    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) {
+    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: OnSelectorOptions) {
+    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() {
+    return this.listenerMap;
+  }
+
+  /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
+  public getListeners(selector: string) {
+    return this.listenerMap.get(selector);
+  }
+}

+ 2 - 2
lib/index.ts

@@ -1,7 +1,7 @@
 export * from "./array";
-export * from "./config";
+export * from "./ConfigManager";
 export * from "./dom";
 export * from "./math";
 export * from "./misc";
-export * from "./onSelector";
+export * from "./SelectorObserver";
 export * from "./translation";

+ 0 - 107
lib/onSelector.ts

@@ -1,107 +0,0 @@
-/** Options for the {@linkcode onSelector()} function */
-export type OnSelectorOpts<TElem extends Element = HTMLElement> = SelectorOptsOne<TElem> | SelectorOptsAll<TElem>;
-
-type SelectorOptsOne<TElem extends Element> = SelectorOptsCommon & {
-  /** Whether to use `querySelectorAll()` instead - default is false */
-  all?: false;
-  /** Gets called whenever the selector was found in the DOM */
-  listener: (element: TElem) => void;
-};
-
-type SelectorOptsAll<TElem extends Element> = SelectorOptsCommon & {
-  /** 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;
-};
-
-type SelectorOptsCommon = {
-  /** Whether to call the listener continuously instead of once - default is false */
-  continuous?: boolean;
-};
-
-const selectorMap = new Map<string, OnSelectorOpts[]>();
-
-/**
- * Calls the {@linkcode listener} as soon as the {@linkcode selector} exists in the DOM.  
- * Listeners are deleted when they are called once, unless `options.continuous` is set.  
- * Multiple listeners with the same selector may be registered.
- * @param selector The selector to listen for
- * @param options Used for switching to `querySelectorAll()` and for calling the listener continuously
- * @template TElem The type of element that the listener will return as its argument (defaults to the generic type HTMLElement)
- */
-export function onSelector<TElem extends Element = HTMLElement>(
-  selector: string,
-  options: OnSelectorOpts<TElem>,
-) {
-  let selectorMapItems: OnSelectorOpts[] = [];
-  if(selectorMap.has(selector))
-    selectorMapItems = selectorMap.get(selector)!;
-
-  // I don't feel like dealing with intersecting types, this should work just fine at runtime
-  // @ts-ignore
-  selectorMapItems.push(options);
-
-  selectorMap.set(selector, selectorMapItems);
-  checkSelectorExists(selector, selectorMapItems);
-}
-
-/**
- * Removes all listeners registered in {@linkcode onSelector()} that have the given selector
- * @returns Returns true when all listeners with the associated selector were found and removed, false otherwise
- */
-export function removeOnSelector(selector: string) {
-  return selectorMap.delete(selector);
-}
-
-function checkSelectorExists<TElem extends Element = HTMLElement>(selector: string, options: OnSelectorOpts<TElem>[]) {
-  const deleteIndices: number[] = [];
-  options.forEach((option, i) => {
-    try {
-      const elements = option.all ? document.querySelectorAll<TElem>(selector) : document.querySelector<TElem>(selector);
-      if((elements !== null && elements instanceof NodeList && elements.length > 0) || elements !== null) {
-        // I don't feel like dealing with intersecting types, this should work just fine at runtime
-        // @ts-ignore
-        option.listener(elements);
-        if(!option.continuous)
-          deleteIndices.push(i);
-      }
-    }
-    catch(err) {
-      console.error(`Couldn't call listener for selector '${selector}'`, err);
-    }
-  });
-
-  if(deleteIndices.length > 0) {
-    const newOptsArray = options.filter((_, i) => !deleteIndices.includes(i));
-    if(newOptsArray.length === 0)
-      selectorMap.delete(selector);
-    else {
-      // once again laziness strikes
-      // @ts-ignore
-      selectorMap.set(selector, newOptsArray);
-    }
-  }
-}
-
-/**
- * Initializes a MutationObserver that checks for all registered selectors whenever an element is added to or removed from the `<body>`
- * @param options For fine-tuning what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
- */
-export function initOnSelector(options: MutationObserverInit = {}) {
-  const observer = new MutationObserver(() => {
-    for(const [selector, options] of selectorMap.entries())
-      checkSelectorExists(selector, options);
-  });
-
-  observer.observe(document.body, {
-    subtree: true,
-    childList: true,
-    ...options,
-  });
-}
-
-/** Returns all currently registered selectors, as a map of selector strings to their associated options */
-export function getSelectorMap() {
-  return selectorMap;
-}