// ==UserScript==
// @name Monkey DOM
// @namespace https://rafaelgssa.gitlab.io/monkey-scripts
// @version 1.1.3
// @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 MonkeyUtils */
/**
* @typedef {(element?: Element) => void} ElementCallback
* @typedef {InsertPosition | 'atouter' | 'atinner'} ExtendedInsertPosition
* @typedef {[ElementTag, ElementAttributes | null, ElementChildren | null]} InsertNodeStructure
* @typedef {keyof HTMLElementTagNameMap} ElementTag
* @typedef {Partial<HTMLElementTagNameMap[ElementTag] & { ref: NodeCallback }>} ElementAttributes
* @typedef {[ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, [ElementTag, ElementAttributes | null, null][] | string | null][] | string | null][] | string | null][] | string | null][] | string | null][] | string | null][] | string | null][] | string | null][] | string | null][] | string} ElementChildren Because JSDoc doesn't support circular references, we limit the type check to the first 10 levels.
* @typedef {Object} MutationTypes
* @property {boolean} [attributes]
* @property {boolean} [childList]
* @property {boolean} [subtree]
* @typedef {(node: Node) => void} NodeCallback
*/
// 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 nodes in reference to an element based on array structures.
* @param {Element} reference The element to use as reference.
* @param {ExtendedInsertPosition} position Where to insert the nodes.
* @param {InsertNodeStructure[]} structures The structures to use.
* @returns {Node[] | null} The inserted nodes 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.
* let pElelement;
* let elements = DOM.insertNode(document.body, 'beforeend', [
* ['div', { className: 'example', onclick: () => {} }, [
* ['p', { ref: (node) => pElement = node }, 'Example']
* ]],
* ['span', null, 'Example']
* ]);
*
* @example
* // Using array destructuring.
* // 'divElement' will contain the DIV element and 'spanElement' will contain the SPAN element.
* let pElelement;
* let [divElement, spanElement] = DOM.insertNode(document.body, 'beforeend', [
* ['div', { className: 'example', onclick: () => {} }, [
* ['p', { ref: (node) => pElement = node }, 'Example']
* ]],
* ['span', null, 'Example']
* ]);
*/
const insertNodes = (reference, position, structures) => {
const fragment = _buildFragment(structures);
if (!fragment) {
return null;
}
const nodes = 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 null;
}
return nodes;
};
/**
* Builds a fragment from array structures.
* @param {InsertNodeStructure[]} structures
* @returns {DocumentFragment | null} The built fragment, if successful.
*/
const _buildFragment = (structures) => {
if (!Array.isArray(structures)) {
return null;
}
const filteredStructures = structures.filter(MonkeyUtils.isSet);
if (!Array.isArray(filteredStructures[0])) {
return null;
}
const fragment = document.createDocumentFragment();
for (const structure of filteredStructures) {
const node = _buildNode(structure);
if (node) {
fragment.appendChild(node);
}
}
return fragment;
};
/**
* Builds a node from an array structure.
* @param {InsertNodeStructure} structure
* @returns {Node | undefined} The built node, if successful.
*/
const _buildNode = ([tag, attributes, children]) => {
const node = document.createElement(tag);
if (attributes) {
_setNodeAttributes(node, attributes);
}
if (children) {
_appendNodeChildren(node, children);
}
return node;
};
/**
* Sets attributes for a node.
* @param {Node} node
* @param {ElementAttributes} attributes
*/
const _setNodeAttributes = (node, attributes) => {
const filteredAttributes = Object.entries(attributes).filter(([, value]) =>
MonkeyUtils.isSet(value)
);
for (const [key, value] of filteredAttributes) {
if (key === 'ref' && typeof value === 'function') {
value(node);
} else if (key.startsWith('on') && typeof value === 'function') {
const eventType = key.slice(2);
node.addEventListener(eventType, value);
} else if (typeof value === 'object') {
_setNodeProperties(node, key, value);
} else {
// @ts-ignore
node[key] = value;
}
}
};
/**
* Sets properties for the attribute of a node.
* @param {Node} node
* @param {string} attribute
* @param {Object} properties
*/
const _setNodeProperties = (node, attribute, properties) => {
const filteredProperties = Object.entries(properties).filter(([, value]) =>
MonkeyUtils.isSet(value)
);
for (const [key, value] of filteredProperties) {
// @ts-ignore
node[attribute][key] = value;
}
};
/**
* Appends children to a node.
* @param {Node} node
* @param {ElementChildren} children
*/
const _appendNodeChildren = (node, children) => {
if (Array.isArray(children)) {
const fragment = _buildFragment(children);
if (fragment) {
node.appendChild(fragment);
}
} else if (typeof children === 'string') {
const textNode = document.createTextNode(children);
node.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,
insertNodes,
observeNode,
parse,
};
})();