Skip to content

Commit

Permalink
feat: better SelectorObserver chaining
Browse files Browse the repository at this point in the history
  • Loading branch information
Sv443 committed Nov 12, 2023

Verified

This commit was signed with the committer’s verified signature.
cognifloyd Jacob Floyd
1 parent 3ae212a commit 17e0b41
Showing 2 changed files with 122 additions and 64 deletions.
96 changes: 67 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -131,21 +131,23 @@ See the [license file](./LICENSE.txt) for details.
Usage:
```ts
new SelectorObserver(baseElement: Element, options?: SelectorObserverOptions)
new SelectorObserver(baseElementSelector: string, options?: SelectorObserverOptions)
```
A class that manages listeners that are called when elements at given selectors are found in the DOM.
This is useful for userscripts that need to wait for elements to be added to the DOM at an indeterminate point in time before they can be interacted with.
The constructor takes a `baseElement`, which is a parent of the elements you want to observe.
If you want to observe the entire document, you can pass `document.body`.
If a selector string is passed instead, it will be used to find the element.
If you want to observe the entire document, you can pass `document.body`
The `options` parameter is optional and will be passed to the MutationObserver that is used internally.
The default options are `{ childList: true, subtree: true }` - you may see the [MutationObserver.observe() documentation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#options) for more information and a list of options.
For example, if you want to trigger the listeners when certain attributes change, pass `{ attributes: true, attributeFilter: ["class", "data-my-attribute"] }`
Additionally, there are the following extra options:
- `defaultDebounce` - if set to a number, this debounce will be applied to every listener that doesn't have a custom debounce set (defaults to 0)
⚠️ The instances of this class need to be created after the `baseElement` is available in the DOM (at the earliest when using `@run-at document-end` or after `DOMContentLoaded` has fired).
⚠️ Make sure to call `enable()` to actually start observing. This will need to be done after the DOM has loaded (when using `@run-at document-end` or after `DOMContentLoaded` has fired) **and** as soon as the `baseElement` or `baseElementSelector` is available.
<br>
@@ -176,14 +178,16 @@ The listener will be called immediately if the selector already exists in the DO
<br>
`disable(): void`
Disables the observation of the child elements.
If selectors are currently being checked, the current selector will be finished before disabling.
`enable(immediatelyCheckSelectors?: boolean): boolean`
Enables the observation of the child elements for the first time or if it was disabled before.
`immediatelyCheckSelectors` is set to true by default, which means all previously registered selectors will be checked. Set to false to only check them on the first detected mutation.
Returns true if the observation was enabled, false if it was already enabled or the passed `baseElementSelector` couldn't be found.
<br>
`enable(): void`
Enables the observation of the child elements if it was disabled before.
`disable(): void`
Disables the observation of the child elements.
If selectors are currently being checked, the current selector will be finished before disabling.
<br>
@@ -224,16 +228,18 @@ Returns all listeners for the given selector or undefined if there are none.
```ts
import { SelectorObserver } from "@sv443-network/userutils";

document.addEventListener("DOMContentLoaded", () => {
// adding a single-shot listener:
// adding a single-shot listener before the element exists:
const fooObserver = new SelectorObserver("body");

const fooObserver = new SelectorObserver(document.body);
fooObserver.addListener("#my-element", {
listener: (element) => {
console.log("Element found:", element);
},
});

fooObserver.addListener("#my-element", {
listener: (element) => {
console.log("Element found:", element);
},
});
document.addEventListener("DOMContentLoaded", () => {
// starting observation after the <body> element is available:
fooObserver.enable();


// adding custom observer options:
@@ -245,20 +251,22 @@ document.addEventListener("DOMContentLoaded", () => {
defaultDebounce: 100,
});

observer.addListener("#my-element", {
barObserver.addListener("#my-element", {
listener: (element) => {
console.log("Element's attributes changed:", element);
},
});

observer.addListener("#my-other-element", {
barObserver.addListener("#my-other-element", {
// set the debounce higher than provided by the defaultDebounce property:
debounce: 250,
listener: (element) => {
console.log("Other element's attributes changed:", element);
},
});

barObserver.enable();


// using custom listener options:

@@ -275,6 +283,8 @@ document.addEventListener("DOMContentLoaded", () => {
},
});

bazObserver.enable();


// use a different element as the base:

@@ -287,6 +297,8 @@ document.addEventListener("DOMContentLoaded", () => {
console.log("Child element found:", element);
},
});

quxObserver.enable();
}
});
```
@@ -314,6 +326,8 @@ document.addEventListener("DOMContentLoaded", () => {
},
});

observer.enable();


// get all listeners:

@@ -346,25 +360,49 @@ document.addEventListener("DOMContentLoaded", () => {
```ts
import { SelectorObserver } from "@sv443-network/userutils";
import type { SelectorObserverOptions } from "@sv443-network/userutils";

// apply a default debounce to all SelectorObserver instances:
const defaultOptions: SelectorObserverOptions = {
defaultDebounce: 100,
};

document.addEventListener("DOMContentLoaded", () => {
// initialize generic observer that in turn initializes "sub-observers":
const fooObserver = new SelectorObserver(document.body);
const fooObserver = new SelectorObserver(document.body, {
...defaultOptions,
// define any other specific options here
});

fooObserver.addListener("#my-element", {
const myElementSelector = "#my-element";

// this relatively expensive listener (as it is in the full <body> scope) will only fire once:
fooObserver.addListener(myElementSelector, {
listener: (element) => {
// only initialize the observer once it is actually needed (when #my-element exists):
const barObserver = new SelectorObserver(element);

barObserver.addListener(".my-child-element", {
all: true,
continuous: true,
listener: (elements) => {
console.log("Child elements found:", elements);
},
});
// only enable barObserver once its baseElement exists:
barObserver.enable();
},
});

// barObserver is created at the same time as fooObserver, but only enabled once #my-element exists
const barObserver = new SelectorObserver(element, {
...defaultOptions,
// define any other specific options here
});

// this selector will be checked for immediately after `enable()` is called
// and on each subsequent mutation because `continuous` is set to true.
// however it is much less expensive as it is scoped to a lower element which will receive less DOM updates
barObserver.addListener(".my-child-element", {
all: true,
continuous: true,
listener: (elements) => {
console.log("Child elements found:", elements);
},
});

// immediately enable fooObserver as the <body> is available as soon as "DOMContentLoaded" fires:
fooObserver.enable();
});
```
90 changes: 55 additions & 35 deletions lib/SelectorObserver.ts
Original file line number Diff line number Diff line change
@@ -30,61 +30,73 @@ export type SelectorObserverOptions = MutationObserverInit & {
/** Observes the children of the given element for changes */
export class SelectorObserver {
private enabled = false;
private baseElement: Element;
private baseElement: Element | string;
private observer: MutationObserver;
private observerOptions: SelectorObserverOptions;
private listenerMap: Map<string, SelectorListenerOptions[]>;
private readonly dbgId = Math.floor(Math.random() * 1000000);

/**
* 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: SelectorObserverOptions)
/**
* 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
* TODO: support passing a selector for the base element to be able to queue listeners before the element is available
*/
constructor(baseElement: Element, options: SelectorObserverOptions = {}) {
constructor(baseElement: Element, options: SelectorObserverOptions)
constructor(baseElement: Element | string, options: SelectorObserverOptions = {}) {
this.baseElement = baseElement;

this.listenerMap = new Map<string, SelectorListenerOptions[]>();
// if the arrow func isn't there, `this` will be undefined in the callback
this.observer = new MutationObserver(() => this.checkSelectors());
this.observer = new MutationObserver(() => this.checkAllSelectors());
this.observerOptions = {
childList: true,
subtree: true,
...options,
};
}

this.enable();
private checkAllSelectors() {
for(const [selector, listeners] of this.listenerMap.entries())
this.checkSelector(selector, listeners);
}

private checkSelectors() {
for(const [selector, listeners] of this.listenerMap.entries()) {
if(!this.enabled)
return;
private checkSelector(selector: string, listeners: SelectorListenerOptions[]) {
if(!this.enabled)
return;

const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;

const all = listeners.some(listener => listener.all);
const one = listeners.some(listener => !listener.all);
if(!baseElement)
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;
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);
}
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);
}
}
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.get(selector)?.length === 0)
this.listenerMap.delete(selector);
}
}

@@ -118,7 +130,7 @@ export class SelectorObserver {
else
this.listenerMap.set(selector, [options as SelectorListenerOptions<Element>]);

this.checkSelectors();
this.checkSelector(selector, [options as SelectorListenerOptions<Element>]);
}

/** Disables the observation of the child elements */
@@ -129,12 +141,20 @@ export class SelectorObserver {
this.observer.disconnect();
}

/** Reenables the observation of the child elements */
public enable() {
if(this.enabled)
return;
/**
* 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) {
const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
if(this.enabled || !baseElement)
return false;
this.enabled = true;
this.observer.observe(this.baseElement, this.observerOptions);
this.observer.observe(baseElement, this.observerOptions);
if(immediatelyCheckSelectors)
this.checkAllSelectors();
return true;
}

/** Returns whether the observation of the child elements is currently enabled */

0 comments on commit 17e0b41

Please sign in to comment.