Monkey DOM

Useful library for dealing with the DOM.

2020-06-25 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.greatest.deepsurf.us/scripts/405802/820081/Monkey%20DOM.js

// ==UserScript==
// @name Monkey DOM
// @namespace https://rafaelgssa.gitlab.io/monkey-scripts
// @version 3.0.0
// @author rafaelgssa
// @description Useful library for dealing with the DOM.
// @match *://*/*
// @require https://greatest.deepsurf.us/scripts/405813-monkey-utils/code/Monkey%20Utils.js
// ==/UserScript==

/* global MonkeyUtils */

/**
 * @typedef {(element?: Element) => void} ElementCallback
 * @typedef {InsertPosition | 'atouter' | 'atinner'} ExtendedInsertPosition
 * @typedef {keyof HTMLElementTagNameMap} ElementTag
 * @typedef {any[] | string} ElementArrayChildren
 * @typedef {Object} MutationTypes
 * @property {boolean} [attributes]
 * @property {boolean} [childList]
 * @property {boolean} [subtree]
 * @typedef {(node: Node) => void} NodeCallback
 */

/**
 * @template {ElementTag} T
 * @typedef {[T, ElementAttributes<T> | null, ElementArrayChildren | null]} ElementArray
 */

/**
 * @template {ElementTag} T
 * @typedef {{ [K in keyof ExtendedElement<T>]?: Partial<ExtendedElement<T>[K]> | null }} ElementAttributes
 */

/**
 * @template {ElementTag} T
 * @typedef {HTMLElementTagNameMap[T] & { ref: NodeCallback }} ExtendedElement
 */

/**
 * The definition for MappedElementArray is in MonkeyDom.d.ts, as it is not supported by JSDoc:
 *
 * declare type MappedElementArray<T> = {
 *   [K in keyof T]: T[K] extends ElementArray<infer L> ? ExtendedElement<L> : never;
 * };
 */

// eslint-disable-next-line
const MonkeyDom = (() => {
	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.
	 * @template {ElementArray<ElementTag>[] | []} T
	 * @param {Element} reference The element to use as reference.
	 * @param {ExtendedInsertPosition} position Where to insert the elements.
	 * @param {T} arrays The arrays to use.
	 * @returns {MappedElementArray<T> | []} 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: 'example', onclick: () => {} }, [
	 *     ['p', { ref: (ref) => pElement = ref }, 'Example']
	 *   ]],
	 *   ['span', null, 'Example']
	 * ]);
	 *
	 * @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.insertElements(document.body, 'beforeend', [
	 *   ['div', { className: 'example', onclick: () => {} }, [
	 *     ['p', { ref: (ref) => pElement = ref }, 'Example']
	 *   ]],
	 *   ['span', null, 'Example']
	 * ]);
	 */
	const insertElements = (reference, position, arrays) => {
		const fragment = _buildFragment(arrays);
		if (!fragment) {
			return [];
		}
		const elements = /** @type {MappedElementArray<T>} */ (
			/** @type {unknown} */ (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.
	 * @template {ElementArray<ElementTag>[] | []} T
	 * @param {T} arrays
	 * @returns {DocumentFragment | null} The built fragment, if successful.
	 */
	const _buildFragment = (arrays) => {
		if (!Array.isArray(arrays)) {
			return null;
		}
		const filteredArrays = arrays.filter(MonkeyUtils.isSet);
		if (!Array.isArray(filteredArrays[0])) {
			return null;
		}
		const fragment = document.createDocumentFragment();
		for (const array of filteredArrays) {
			const element = _buildElement(array);
			if (element) {
				fragment.appendChild(element);
			}
		}
		return fragment;
	};

	/**
	 * Builds an element from an element array.
	 * @template {ElementTag} T
	 * @param {ElementArray<T>} array
	 * @returns {HTMLElement | undefined} The built element, if successful.
	 */
	const _buildElement = ([tag, attributes, children]) => {
		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]) =>
			MonkeyUtils.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]) =>
			MonkeyUtils.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,
	};
})();