// ==UserScript==
// @name DOM
// @namespace https://rafaelgssa.gitlab.io/monkey-scripts
// @version 4.0.0
// @author rafaelgssa
// @description Useful library for dealing with the DOM.
// @match *://*/*
// @require https://greasyfork.org/scripts/405813-monkey-utils/code/Monkey%20Utils.js
// ==/UserScript==
/* global Utils */
/**
* @typedef {(element?: Element) => void} ElementCallback
*
* @typedef {InsertPosition | 'atouter' | 'atinner'} ExtendedInsertPosition
*
* @typedef {ElementArrayConstructor<ElementArrayBase, 8>} ElementArray Any higher than 8 is too deep and does not work.
*
* **The definition for ElementArrayConstructor is in DOM.d.ts, as it is too complex for JSDoc:**
* declare type ElementArrayConstructor<T extends [any, any] | string, N extends number> = T extends [
* infer A,
* infer B
* ]
* ? {
* done: [A, B, string | null];
* recurse: [
* A,
* B,
* ElementArrayConstructor<ElementArrayBase, ElementArrayDepth[N]>[] | string | null
* ];
* }[N extends 0 ? 'done' : 'recurse']
* : T extends string
* ? T
* : never;
*
* @typedef {[never, 0, 1, 2, 3, 4, 5, 6, 7]} ElementArrayDepth
*
* @typedef {{ [K in ElementTag]: [K, ElementAttributes<K> | null] }[ElementTag] | string} ElementArrayBase
*
* @typedef {keyof HTMLElementTagNameMap} ElementTag
*
* @typedef {ElementArray[] | string} ElementArrayChildren
*
* @typedef {Object} MutationTypes
* @property {boolean} [attributes]
* @property {boolean} [childList]
* @property {boolean} [subtree]
*
* @typedef {(node: Node) => void} NodeCallback
*/
/**
* @template {ElementTag} T
* @typedef {{ [K in keyof ExtendedElement<T>]?: Partial<ExtendedElement<T>[K]> | null }} ElementAttributes
*/
/**
* @template {ElementTag} T
* @typedef {HTMLElementTagNameMap[T] & { ref: NodeCallback }} ExtendedElement
*/
// eslint-disable-next-line
const DOM = (() => {
const parser = new DOMParser();
/**
* Waits for an element.
* @param {string} selectors The selectors to query for the element.
* @param {number} timeout How long to wait for the element in seconds. Defaults to 60 (1 minute).
* @returns {Promise<Element | undefined>} The element, if found.
*/
const dynamicQuerySelector = (selectors, timeout = 60) => {
return new Promise((resolve) => _checkElementExists(selectors, resolve, timeout));
};
/**
* @param {string} selectors
* @param {ElementCallback} callback
* @param {number} timeout
*/
const _checkElementExists = (selectors, callback, timeout = 60) => {
const element = document.querySelector(selectors);
if (element) {
callback(element);
} else if (timeout > 0) {
window.setTimeout(_checkElementExists, 1000, selectors, callback, timeout - 1);
} else {
callback();
}
};
/**
* Inserts elements in reference to another element based on element arrays.
* @param {Element} reference The element to use as reference.
* @param {ExtendedInsertPosition} position Where to insert the elements.
* @param {ElementArray[]} arrays The arrays to use.
* @returns {HTMLElement[] | []} The inserted elements from the root level, if successful.
*
* @example
* // 'pElement' will contain the P element.
* // 'elements' will be an array containing the DIV and the SPAN elements, in this order, if successful.
* let pElement;
* const elements = DOM.insertElement(document.body, 'beforeend', [
* ['div', { className: 'hello', onclick: () => {} }, [
* 'Hello, ', // This is added as a text node.
* ['p', { ref: (ref) => pElement = ref }, 'John'],
* '!' // This is added as a text node.
* ]],
* ['span', null, 'How are you?']
* ]);
*
* @example
* // Using array destructuring.
* // 'divElement' will contain the DIV element and 'spanElement' will contain the SPAN element, if successful.
* let pElement;
* const [divElement, spanElement] = DOM.insertElement(document.body, 'beforeend', [
* ['div', { className: 'hello', onclick: () => {} }, [
* 'Hello, ', // This is added as a text node.
* ['p', { ref: (ref) => pElement = ref }, 'John'],
* '!' // This is added as a text node.
* ]],
* ['span', null, 'How are you?']
* ]);
*/
const insertElements = (reference, position, arrays) => {
const fragment = _buildFragment(arrays);
if (!fragment) {
return [];
}
const elements = /** @type {HTMLElement[]} */ (Array.from(fragment.children));
const referenceParent = reference.parentElement;
switch (position) {
case 'beforebegin':
if (referenceParent) {
referenceParent.insertBefore(fragment, reference);
}
break;
case 'afterbegin':
reference.insertBefore(fragment, reference.firstElementChild);
break;
case 'beforeend':
reference.appendChild(fragment);
break;
case 'afterend':
if (referenceParent) {
referenceParent.insertBefore(fragment, reference.nextElementSibling);
}
break;
case 'atouter':
if (referenceParent) {
referenceParent.insertBefore(fragment, reference.nextElementSibling);
reference.remove();
}
break;
case 'atinner':
reference.innerHTML = '';
reference.appendChild(fragment);
break;
// no default
}
if (fragment.children.length > 0) {
return [];
}
return elements;
};
/**
* Builds a fragment from element arrays.
* @param {ElementArray[]} arrays
* @returns {DocumentFragment | null} The built fragment, if successful.
*/
const _buildFragment = (arrays) => {
if (!Array.isArray(arrays)) {
return null;
}
// @ts-ignore
const filteredArrays = arrays.filter(Utils.isSet);
const fragment = document.createDocumentFragment();
for (const array of filteredArrays) {
const element = _buildElement(array);
fragment.appendChild(element);
}
return fragment;
};
/**
* Builds an element from an element array.
* @param {ElementArray} array
* @returns {HTMLElement} The built element.
*/
const _buildElement = (array) => {
if (typeof array === 'string') {
const textNode = document.createTextNode(array);
return /** @type {HTMLElement} */ (/** @type {unknown} */ (textNode));
}
const [tag, attributes, children] = array;
const element = document.createElement(tag);
if (attributes) {
_setElementAttributes(element, attributes);
}
if (children) {
_appendElementChildren(element, children);
}
return element;
};
/**
* Sets attributes for an element.
* @template {ElementTag} T
* @param {HTMLElement} element
* @param {ElementAttributes<T>} attributes
*/
const _setElementAttributes = (element, attributes) => {
const filteredAttributes = Object.entries(attributes).filter(([, value]) => Utils.isSet(value));
for (const [key, value] of filteredAttributes) {
if (key === 'ref' && typeof value === 'function') {
value(element);
} else if (key.startsWith('on') && typeof value === 'function') {
const eventType = key.slice(2);
element.addEventListener(eventType, value);
} else if (typeof value === 'object') {
_setElementProperties(element, key, value);
} else {
// @ts-ignore
element[key] = value;
}
}
};
/**
* Sets properties for the attribute of an element.
* @param {HTMLElement} element
* @param {string} attribute
* @param {Object} properties
*/
const _setElementProperties = (element, attribute, properties) => {
const filteredProperties = Object.entries(properties).filter(([, value]) => Utils.isSet(value));
for (const [key, value] of filteredProperties) {
// @ts-ignore
element[attribute][key] = value;
}
};
/**
* Appends children to an element from an element array.
* @param {HTMLElement} element
* @param {ElementArrayChildren} children
*/
const _appendElementChildren = (element, children) => {
if (Array.isArray(children)) {
const fragment = _buildFragment(children);
if (fragment) {
element.appendChild(fragment);
}
} else if (typeof children === 'string') {
const textNode = document.createTextNode(children);
element.appendChild(textNode);
}
};
/**
* Observes a node for mutations.
* @param {Node} node The node to observe.
* @param {MutationTypes | null} types The types of mutations to observe. Defaults to child list of the node and all its descendants.
* @param {NodeCallback} callback The callback to call with each updated / added node.
* @returns {MutationObserver} The observer.
*/
const observeNode = (node, types, callback) => {
const observer = new MutationObserver((mutations) =>
_processNodeMutations(mutations, callback)
);
observer.observe(
node,
types || {
childList: true,
subtree: true,
}
);
return observer;
};
/**
* @param {MutationRecord[]} mutations
* @param {NodeCallback} callback
*/
const _processNodeMutations = (mutations, callback) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
callback(mutation.target);
} else {
mutation.addedNodes.forEach(callback);
}
}
};
/**
* Parses an HTML string into a DOM.
* @param {string} html The HTML string to parse.
* @returns {Document} The parsed DOM.
*/
const parse = (html) => {
return parser.parseFromString(html, 'text/html');
};
return {
dynamicQuerySelector,
insertElements,
observeNode,
parse,
};
})();