@mirawision/domino
Version:
Lightweight DOM utilities for Chrome Extension content scripts
334 lines (333 loc) • 13.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.watchSelector = exports.watchModified = exports.watchRemoved = exports.watchAdded = void 0;
/**
* Internal utility to check if an element matches a target specification.
*
* @param el - The DOM element to check
* @param target - The target to match against (selector string, Element, or predicate function)
* @returns True if the element matches the target
*/
function matchesTarget(el, target) {
if (typeof target === 'string') {
return el.matches(target);
}
else if (target instanceof Element) {
return el === target;
}
else {
return target(el);
}
}
/**
* Creates a debounced version of a function that delays its execution until after a specified time.
*
* @param fn - The function to debounce
* @param ms - The number of milliseconds to delay
* @returns A debounced version of the input function
*/
function debounce(fn, ms) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
return undefined;
};
}
/**
* Creates a throttled version of a function that limits its execution frequency.
*
* @param fn - The function to throttle
* @param ms - The minimum number of milliseconds between function executions
* @returns A throttled version of the input function
*/
function throttle(fn, ms) {
let lastRun = 0;
let timeoutId;
return function (...args) {
const now = Date.now();
if (now - lastRun >= ms) {
const result = fn.apply(this, args);
lastRun = now;
return result;
}
else {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(this, args);
lastRun = Date.now();
}, ms - (now - lastRun));
return undefined;
}
};
}
/**
* Watches for elements being added to the DOM that match the specified target.
*
* @param target - A selector string, Element, or predicate function to match elements against
* @param onAdd - Callback function called when a matching element is added
* @param options - Configuration options for the observer
* @param options.root - The root element to observe (defaults to document)
* @param options.subtree - Whether to observe the entire subtree (defaults to true)
* @param options.debounce - Optional debounce time in milliseconds
* @param options.throttle - Optional throttle time in milliseconds
* @param options.once - Whether to stop observing after the first match (defaults to false)
* @param options.signal - Optional AbortSignal to stop the observer
* @returns A function to dispose of the observer
*
* @example
* ```typescript
* const dispose = watchAdded('.my-class', (element) => {
* console.log('Element added:', element);
* }, {
* debounce: 100,
* once: true
* });
*
* // Later: stop observing
* dispose();
* ```
*/
function watchAdded(target, onAdd, options = {}) {
const { root = document, subtree = true, debounce: debounceMs, throttle: throttleMs, once = false, signal } = options;
let callback = onAdd;
if (debounceMs) {
callback = debounce(onAdd, debounceMs);
}
else if (throttleMs) {
callback = throttle(onAdd, throttleMs);
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
if (matchesTarget(node, target)) {
callback(node);
if (once) {
observer.disconnect();
return;
}
}
if (subtree && typeof target === 'string') {
node.querySelectorAll(target).forEach((el) => {
callback(el);
if (once) {
observer.disconnect();
return;
}
});
}
}
}
}
}
});
observer.observe(root, {
childList: true,
subtree
});
signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', () => observer.disconnect());
return () => observer.disconnect();
}
exports.watchAdded = watchAdded;
/**
* Watches for elements being removed from the DOM that match the specified target.
*
* @param target - A selector string, Element, or predicate function to match elements against
* @param onRemove - Callback function called when a matching element is removed
* @param options - Configuration options for the observer
* @param options.root - The root element to observe (defaults to document)
* @param options.subtree - Whether to observe the entire subtree (defaults to true)
* @param options.debounce - Optional debounce time in milliseconds
* @param options.throttle - Optional throttle time in milliseconds
* @param options.once - Whether to stop observing after the first match (defaults to false)
* @param options.signal - Optional AbortSignal to stop the observer
* @returns A function to dispose of the observer
*
* @example
* ```typescript
* const dispose = watchRemoved('.my-class', (element) => {
* console.log('Element removed:', element);
* }, {
* throttle: 100
* });
* ```
*/
function watchRemoved(target, onRemove, options = {}) {
const { root = document, subtree = true, debounce: debounceMs, throttle: throttleMs, once = false, signal } = options;
let callback = onRemove;
if (debounceMs) {
callback = debounce(onRemove, debounceMs);
}
else if (throttleMs) {
callback = throttle(onRemove, throttleMs);
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.removedNodes) {
if (node instanceof Element) {
if (matchesTarget(node, target)) {
callback(node);
if (once) {
observer.disconnect();
return;
}
}
if (subtree && typeof target === 'string') {
const matches = node.querySelectorAll(target);
matches.forEach((el) => {
callback(el);
if (once) {
observer.disconnect();
return;
}
});
}
}
}
}
}
});
observer.observe(root, {
childList: true,
subtree
});
signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', () => observer.disconnect());
return () => observer.disconnect();
}
exports.watchRemoved = watchRemoved;
/**
* Watches for modifications to elements that match the specified target.
* This includes attribute changes, text content changes, and child element changes.
*
* @param target - A selector string, Element, or predicate function to match elements against
* @param onChange - Callback function called when a matching element is modified
* @param options - Configuration options for the observer
* @param options.root - The root element to observe (defaults to document)
* @param options.subtree - Whether to observe the entire subtree (defaults to true)
* @param options.attributes - Whether to watch for attribute changes or an array of specific attributes to watch
* @param options.characterData - Whether to watch for text content changes (defaults to true)
* @param options.childList - Whether to watch for child element changes (defaults to true)
* @param options.debounce - Optional debounce time in milliseconds
* @param options.throttle - Optional throttle time in milliseconds
* @param options.once - Whether to stop observing after the first change (defaults to false)
* @param options.signal - Optional AbortSignal to stop the observer
* @returns A function to dispose of the observer
*
* @example
* ```typescript
* const dispose = watchModified('.my-class', (element, change) => {
* if (change.attrs) {
* console.log('Attributes changed:', Array.from(change.attrs));
* }
* if (change.text) {
* console.log('Text content changed');
* }
* if (change.childList) {
* console.log('Child elements changed');
* }
* }, {
* attributes: ['class', 'style'],
* characterData: true
* });
* ```
*/
function watchModified(target, onChange, options = {}) {
const { root = document, subtree = true, attributes = true, characterData = true, childList = true, debounce: debounceMs, throttle: throttleMs, once = false, signal } = options;
let callback = onChange;
if (debounceMs) {
callback = debounce(onChange, debounceMs);
}
else if (throttleMs) {
callback = throttle(onChange, throttleMs);
}
const observer = new MutationObserver((records) => {
const changes = new Map();
for (const record of records) {
const mutationTarget = record.target;
if (!(mutationTarget instanceof Element))
continue;
// Check if the target itself matches
if (matchesTarget(mutationTarget, target)) {
processChange(mutationTarget);
}
// Check subtree elements if enabled
if (subtree && typeof target === 'string') {
mutationTarget.querySelectorAll(target).forEach(processChange);
}
function processChange(element) {
let change = changes.get(element) || { records: [] };
change.records.push(record);
if (record.type === 'attributes') {
change.attrs = change.attrs || new Set();
change.attrs.add(record.attributeName);
}
else if (record.type === 'characterData') {
change.text = true;
}
else if (record.type === 'childList') {
change.childList = true;
}
changes.set(element, change);
}
}
changes.forEach((change, element) => {
callback(element, change);
if (once)
observer.disconnect();
});
});
observer.observe(root, {
attributes: true,
attributeFilter: Array.isArray(attributes) ? attributes : undefined,
characterData,
childList,
subtree
});
signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', () => observer.disconnect());
return () => observer.disconnect();
}
exports.watchModified = watchModified;
/**
* A high-level function that combines watchAdded, watchRemoved, and watchModified into a single observer.
* This is useful when you need to track multiple types of changes to elements matching a selector.
*
* @param target - A selector string, Element, or predicate function to match elements against
* @param handlers - Object containing callback functions for different types of changes
* @param handlers.onEnter - Optional callback for when elements are added
* @param handlers.onExit - Optional callback for when elements are removed
* @param handlers.onChange - Optional callback for when elements are modified
* @param options - Configuration options passed to all observers
* @returns A function to dispose of all observers
*
* @example
* ```typescript
* const dispose = watchSelector('.my-class', {
* onEnter: (element) => console.log('Element added:', element),
* onExit: (element) => console.log('Element removed:', element),
* onChange: (element, change) => console.log('Element modified:', element, change)
* }, {
* debounce: 100,
* attributes: ['class', 'data-status']
* });
*
* // Later: stop all observers
* dispose();
* ```
*/
function watchSelector(target, handlers, options = {}) {
const disposers = [];
if (handlers.onEnter) {
disposers.push(watchAdded(target, handlers.onEnter, options));
}
if (handlers.onExit) {
disposers.push(watchRemoved(target, handlers.onExit, options));
}
if (handlers.onChange) {
disposers.push(watchModified(target, handlers.onChange, options));
}
return () => disposers.forEach(dispose => dispose());
}
exports.watchSelector = watchSelector;