utils/dom.js

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;