import { isElementHighlight } from "./highlights";
import { DATA_ATTR } from "../config";
export const NODE_TYPE = { ELEMENT_NODE: 1, TEXT_NODE: 3, COMMENT_NODE: 8 };
/**
* Utility functions to make DOM manipulation easier.
* @param {Node|HTMLElement} [el] - base DOM element to manipulate
* @returns {object}
*
*/
const dom = function(el) {
return /** @lends dom **/ {
/**
* Adds class to element.
* @param {string} className
*/
addClass: function(className) {
if (el.classList) {
el.classList.add(className);
} else {
el.className += " " + className;
}
},
/**
* Removes class from element.
* @param {string} className
*/
removeClass: function(className) {
if (el.classList) {
el.classList.remove(className);
} else {
el.className = el.className.replace(
new RegExp("(^|\\b)" + className + "(\\b|$)", "gi"),
" ",
);
}
},
/**
* Prepends child nodes to base element.
* @param {Node[]} nodesToPrepend
*/
prepend: function(nodesToPrepend) {
let nodes = Array.prototype.slice.call(nodesToPrepend),
i = nodes.length;
while (i--) {
el.insertBefore(nodes[i], el.firstChild);
}
},
/**
* Appends child nodes to base element.
* @param {Node[]} nodesToAppend
*/
append: function(nodesToAppend) {
let nodes = Array.prototype.slice.call(nodesToAppend);
for (let i = 0, len = nodes.length; i < len; ++i) {
el.appendChild(nodes[i]);
}
},
/**
* Inserts base element after refEl.
* @param {Node} refEl - node after which base element will be inserted
* @returns {Node} - inserted element
*/
insertAfter: function(refEl) {
return refEl.parentNode.insertBefore(el, refEl.nextSibling);
},
/**
* Inserts base element before refEl.
* @param {Node} refEl - node before which base element will be inserted
* @returns {Node} - inserted element
*/
insertBefore: function(refEl) {
return refEl.parentNode.insertBefore(el, refEl);
},
/**
* Removes base element from DOM.
*/
remove: function() {
el.parentNode.removeChild(el);
el = null;
},
/**
* Returns true if base element contains given child.
* @param {Node|HTMLElement} child
* @returns {boolean}
*/
contains: function(child) {
return el !== child && el.contains(child);
},
/**
* Wraps base element in wrapper element.
* @param {HTMLElement} wrapper
* @returns {HTMLElement} wrapper element
*/
wrap: function(wrapper) {
if (el.parentNode) {
el.parentNode.insertBefore(wrapper, el);
}
wrapper.appendChild(el);
return wrapper;
},
/**
* Unwraps base element.
* @returns {Node[]} - child nodes of unwrapped element.
*/
unwrap: function() {
let nodes = Array.prototype.slice.call(el.childNodes),
wrapper;
nodes.forEach(function(node) {
wrapper = node.parentNode;
dom(node).insertBefore(node.parentNode);
});
if (wrapper) {
dom(wrapper).remove();
}
return nodes;
},
/**
* Returns array of base element parents.
* @returns {HTMLElement[]}
*/
parents: function() {
let parent,
path = [];
while ((parent = el.parentNode)) {
path.push(parent);
el = parent;
}
return path;
},
/**
* Returns array of base element parents up to the
* provided root element.
*
* @param {HTMLElement} rootElement
* @returns {HTMLElement[]}
*/
parentsUpTo: function(rootElement) {
let parent,
path = [];
while ((parent = el.parentNode) && parent !== rootElement) {
path.push(parent);
el = parent;
}
return path;
},
/**
* Returns array of base element parents, excluding the document.
* @returns {HTMLElement[]}
*/
parentsWithoutDocument: function() {
return this.parents().filter((elem) => elem !== el.ownerDocument);
},
/**
* Traverses up the tree to to get the next closest sibling of a node
* or any of it's parents.
*
* This is used in scenarios where you have already consumed the parents while
* traversing the tree but not the siblings of parents.
*
* @param {HTMLElement | undefined} rootNode The root node which acts as a threshold
* for how deep we can go in the tree when getting siblings or their parents.
*
* @returns {HTMLElement | null}
*/
nextClosestSibling: function(rootNode) {
let current = el;
let nextClosestSibling;
do {
nextClosestSibling = current.nextSibling;
current = current.parentNode;
} while (!nextClosestSibling && current.parentNode && rootNode.contains(current));
if (!rootNode.contains(current)) {
nextClosestSibling = null;
}
return nextClosestSibling;
},
/**
* Traverses up the tree to to get the previous closest sibling of a node
* or any of it's parents.
*
* This is used in scenarios where you have already consumed the parents while
* traversing the tree but not the siblings of parents.
*
* @param {HTMLElement | undefined} rootNode The root node which acts as a threshold
* for how deep we can go in the tree when getting siblings or their parents.
*
* @returns {HTMLElement | null}
*/
previousClosestSibling: function(rootNode) {
let current = el;
let prevClosestSibling;
do {
prevClosestSibling = current.previousSibling;
current = current.parentNode;
} while (!prevClosestSibling && current.parentNode && rootNode.contains(current));
if (!rootNode.contains(current)) {
prevClosestSibling = null;
}
return prevClosestSibling;
},
/**
* Normalizes text nodes within base element, ie. merges sibling text nodes and assures that every
* element node has only one text node.
* It should does the same as standard element.normalize, but IE implements it incorrectly.
*/
normalizeTextNodes: function() {
if (!el) {
return;
}
if (el.nodeType === NODE_TYPE.TEXT_NODE) {
while (el.nextSibling && el.nextSibling.nodeType === NODE_TYPE.TEXT_NODE) {
el.nodeValue += el.nextSibling.nodeValue;
el.parentNode.removeChild(el.nextSibling);
}
} else {
dom(el.firstChild).normalizeTextNodes();
}
dom(el.nextSibling).normalizeTextNodes();
},
/**
* Normalizes elements that have the a same id and are next to eachother in the child list
*/
normalizeElements: function(highlightedClass, dataAttr = DATA_ATTR) {
if (!el) {
return;
}
if (el.nodeType !== NODE_TYPE.TEXT_NODE) {
if (isElementHighlight(el, dataAttr)) {
let className = el.className;
while (
className &&
el.nextSibling &&
el.nextSibling.nodeType !== NODE_TYPE.TEXT_NODE &&
el.nextSibling.className === className &&
className !== highlightedClass
) {
el.innerHTML += el.nextSibling.innerHTML;
el.parentNode.removeChild(el.nextSibling);
}
dom(el.firstChild).normalizeElements(highlightedClass, dataAttr);
} else {
let id = el.id;
while (
id &&
el.nextSibling &&
el.nextSibling.nodeType !== NODE_TYPE.TEXT_NODE &&
el.nextSibling.id === id
) {
el.innerHTML += el.nextSibling.innerHTML;
el.parentNode.removeChild(el.nextSibling);
}
dom(el.firstChild).normalizeElements(highlightedClass, dataAttr);
}
} else {
dom(el).normalizeTextNodes();
}
dom(el.nextSibling).normalizeElements(highlightedClass, dataAttr);
},
/**
* Returns element background color.
* @returns {CSSStyleDeclaration.backgroundColor}
*/
color: function() {
return el.style.backgroundColor;
},
/**
* Creates dom element from given html string.
* @param {string} html
* @param {Document} doc
* @returns {NodeList}
*/
fromHTML: function(html, doc = document) {
let div = doc.createElement("div");
div.innerHTML = html;
return div.childNodes;
},
/**
* Returns first range of the window of base element.
* @returns {Range}
*/
getRange: function() {
let selection = dom(el).getSelection(),
range;
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
}
return range;
},
/**
* Removes all ranges of the window of base element.
*/
removeAllRanges: function() {
let selection = dom(el).getSelection();
selection.removeAllRanges();
},
/**
* Returns selection object of the window of base element.
* @returns {Selection}
*/
getSelection: function() {
return dom(el)
.getWindow()
.getSelection();
},
/**
* Returns window of the base element.
* @returns {Window}
*/
getWindow: function() {
return dom(el).getDocument().defaultView;
},
/**
* Returns document of the base element.
* @returns {HTMLDocument}
*/
getDocument: function() {
// if ownerDocument is null then el is the document itself.
return el.ownerDocument || el;
},
/**
* Returns whether the provided element comes after the base element.
*
* @param {HTMLElement} otherElement
*
* @returns {boolean}
*/
isAfter: function(otherElement, rootElement) {
let sibling = el.nextSibling;
let isAfter = false;
while (sibling && !isAfter) {
if (sibling === otherElement) {
isAfter = true;
} else {
if (!sibling.nextSibling) {
sibling = el.parentNode.nextSibling;
} else {
sibling = sibling.nextSibling;
}
}
}
return isAfter;
},
/**
* Extracts all the text content for the root element excluding
* all the text content inside any of the provided excluded tags.
*
* @param {string[]} excludeTags lement tags to exclude
*
* @returns {string}
*/
textContentExcludingTags: function(excludeTags) {
if (el && el.nodeType === NODE_TYPE.COMMENT_NODE) {
return "";
}
if (el && el.nodeType !== NODE_TYPE.TEXT_NODE) {
// Ensure we simply return the text content in the case the element is a text node.
const elCopy = el.cloneNode(true);
let commentsInCopy = [elCopy.querySelectorAll("*")].filter(
(element) => element.nodeType === NODE_TYPE.COMMENT_NODE,
);
commentsInCopy.forEach((toExcludeFromCopy) => {
toExcludeFromCopy.remove();
});
const elementsToBeExcluded = excludeTags.reduce((accum, tag) => {
return [...accum, ...elCopy.querySelectorAll(tag)];
}, []);
elementsToBeExcluded.forEach((toExcludeFromCopy) => {
toExcludeFromCopy.remove();
});
return elCopy.textContent;
}
return el.textContent;
},
/**
* Gets the index of a child element in the base element.
* It is important we use childNodes as we want to include both
* html element nodes and text nodes.
*
* @param {HTMLElement} childElement
* @return {number}
*/
getChildIndex: function(childElement) {
let currentChild = el.firstChild;
let i = 0;
while (currentChild && childElement !== currentChild) {
if (currentChild !== childElement) {
currentChild = currentChild.nextSibling;
i++;
}
}
return currentChild ? i : -1;
},
/**
* Loop through the elements in the dom and remove any events attached to elements that are not text nodes and have no children.
*
* @param {listOfElementAttributes} - list of events and their values which have been turned off, along with a temporary id for each element which has been altered.
*/
turnOffEventHandlers: function(listOfElementAttributes) {
if (!el) {
return;
}
if (el.childNodes && el.childNodes.length > 0) {
dom(el.firstChild).turnOffEventHandlers(listOfElementAttributes);
} else if (el.nodeType !== NODE_TYPE.TEXT_NODE && el.attributes) {
let eventsForObject = dom(el).turnOffEventHandlersForElement();
if (eventsForObject) {
listOfElementAttributes.push(eventsForObject);
}
}
dom(el.nextSibling).turnOffEventHandlers(listOfElementAttributes);
},
/**
* Loop through the elements in the dom and add back in any events that were previously removed from elements.
*
* @param {listOfElementAttributes} - list of events and their values which have recently been turned off, along with a temporary id for each element which has been altered.
*/
turnOnEventHandlers: function(listOfElementAttributes) {
if (!el || !listOfElementAttributes || listOfElementAttributes.length === 0) {
return;
}
let elements = Array.prototype.slice.call(el.querySelectorAll("[temp-id]"));
listOfElementAttributes.forEach((elementAttribute) => {
let tempId = elementAttribute.tempId;
let attributeList = elementAttribute.listOfAttributes;
let element = elements.filter((element) => element.getAttribute("temp-id") === tempId)[0];
if (element) {
dom(element).addAttributes(attributeList);
element.removeAttribute("temp-id");
}
});
},
/**
* Loop through the attributes of an element and turn off all attributes that have names starting with 'on'.
* This will turn of all events for elements that have no children and are not text nodes (images etc.)
*
* @return {object} - list of events and their values which have been turned off
*/
turnOffEventHandlersForElement: function() {
if (!el) {
return null;
}
if (
el.nodeType !== NODE_TYPE.TEXT_NODE &&
el.nodeType !== NODE_TYPE.COMMENT_NODE &&
el.childNodes &&
el.childNodes.length === 0
) {
var attributes = [].slice.call(el.attributes);
var listOfAttributes = [];
let i;
for (i = 0; i < attributes.length; i++) {
var att = attributes[i].name;
if (att.indexOf("on") === 0) {
var eventHandlers = {};
eventHandlers.attribute = attributes[i].name;
eventHandlers.value = attributes[i].value;
listOfAttributes.push(eventHandlers);
el.attributes.removeNamedItem(att);
}
}
if (listOfAttributes.length > 0) {
const uniqueId = `hlt-${Math.random()
.toString(36)
.substring(2, 15) +
Math.random()
.toString(36)
.substring(2, 15)}`;
el.setAttribute("temp-id", uniqueId);
return { tempId: uniqueId, listOfAttributes };
}
}
},
/**
* Loop through the a list of attributes and add each and their value to the element.
*
* @param {array} - list of attributes and their values
*/
addAttributes: function(attributes) {
if (!el) {
return;
}
let i;
for (i = 0; i < attributes.length; i++) {
let attribute = attributes[i];
el.setAttribute(attribute.attribute, attribute.value);
}
},
};
};
export default dom;