utils_dom.js


/**
 * @file dom.js
 * @module dom
 */
import document from 'global/document';
import window from 'global/window';
import fs from '../fullscreen-api';
import log from './log.js';
import {isObject} from './obj';
import * as browser from './browser';

/**
 * Detect if a value is a string with any non-whitespace characters.
 *
 * @private
 * @param  {string} str
 *         The string to check
 *
 * @return {boolean}
 *         Will be `true` if the string is non-blank, `false` otherwise.
 *
 */
function isNonBlankString(str) {
  // we use str.trim as it will trim any whitespace characters
  // from the front or back of non-whitespace characters. aka
  // Any string that contains non-whitespace characters will
  // still contain them after `trim` but whitespace only strings
  // will have a length of 0, failing this check.
  return typeof str === 'string' && Boolean(str.trim());
}

/**
 * Throws an error if the passed string has whitespace. This is used by
 * class methods to be relatively consistent with the classList API.
 *
 * @private
 * @param  {string} str
 *         The string to check for whitespace.
 *
 * @throws {Error}
 *         Throws an error if there is whitespace in the string.
 */
function throwIfWhitespace(str) {
  // str.indexOf instead of regex because str.indexOf is faster performance wise.
  if (str.indexOf(' ') >= 0) {
    throw new Error('class has illegal whitespace characters');
  }
}

/**
 * Whether the current DOM interface appears to be real (i.e. not simulated).
 *
 * @return {boolean}
 *         Will be `true` if the DOM appears to be real, `false` otherwise.
 */
export function isReal() {
  // Both document and window will never be undefined thanks to `global`.
  return document === window.document;
}

/**
 * Determines, via duck typing, whether or not a value is a DOM element.
 *
 * @param  {*} value
 *         The value to check.
 *
 * @return {boolean}
 *         Will be `true` if the value is a DOM element, `false` otherwise.
 */
export function isEl(value) {
  return isObject(value) && value.nodeType === 1;
}

/**
 * Determines if the current DOM is embedded in an iframe.
 *
 * @return {boolean}
 *         Will be `true` if the DOM is embedded in an iframe, `false`
 *         otherwise.
 */
export function isInFrame() {

  // We need a try/catch here because Safari will throw errors when attempting
  // to get either `parent` or `self`
  try {
    return window.parent !== window.self;
  } catch (x) {
    return true;
  }
}

/**
 * Creates functions to query the DOM using a given method.
 *
 * @private
 * @param   {string} method
 *          The method to create the query with.
 *
 * @return  {Function}
 *          The query method
 */
function createQuerier(method) {
  return function(selector, context) {
    if (!isNonBlankString(selector)) {
      return document[method](null);
    }
    if (isNonBlankString(context)) {
      context = document.querySelector(context);
    }

    const ctx = isEl(context) ? context : document;

    return ctx[method] && ctx[method](selector);
  };
}

/**
 * Creates an element and applies properties, attributes, and inserts content.
 *
 * @param  {string} [tagName='div']
 *         Name of tag to be created.
 *
 * @param  {Object} [properties={}]
 *         Element properties to be applied.
 *
 * @param  {Object} [attributes={}]
 *         Element attributes to be applied.
 *
 * @param {ContentDescriptor} [content]
 *        A content descriptor object.
 *
 * @return {Element}
 *         The element that was created.
 */
export function createEl(tagName = 'div', properties = {}, attributes = {}, content) {
  const el = document.createElement(tagName);

  Object.getOwnPropertyNames(properties).forEach(function(propName) {
    const val = properties[propName];

    // Handle textContent since it's not supported everywhere and we have a
    // method for it.
    if (propName === 'textContent') {
      textContent(el, val);
    } else if (el[propName] !== val || propName === 'tabIndex') {
      el[propName] = val;
    }
  });

  Object.getOwnPropertyNames(attributes).forEach(function(attrName) {
    el.setAttribute(attrName, attributes[attrName]);
  });

  if (content) {
    appendContent(el, content);
  }

  return el;
}

/**
 * Injects text into an element, replacing any existing contents entirely.
 *
 * @param  {HTMLElement} el
 *         The element to add text content into
 *
 * @param  {string} text
 *         The text content to add.
 *
 * @return {Element}
 *         The element with added text content.
 */
export function textContent(el, text) {
  if (typeof el.textContent === 'undefined') {
    el.innerText = text;
  } else {
    el.textContent = text;
  }
  return el;
}

/**
 * Insert an element as the first child node of another
 *
 * @param {Element} child
 *        Element to insert
 *
 * @param {Element} parent
 *        Element to insert child into
 */
export function prependTo(child, parent) {
  if (parent.firstChild) {
    parent.insertBefore(child, parent.firstChild);
  } else {
    parent.appendChild(child);
  }
}

/**
 * Check if an element has a class name.
 *
 * @param  {Element} element
 *         Element to check
 *
 * @param  {string} classToCheck
 *         Class name to check for
 *
 * @return {boolean}
 *         Will be `true` if the element has a class, `false` otherwise.
 *
 * @throws {Error}
 *         Throws an error if `classToCheck` has white space.
 */
export function hasClass(element, classToCheck) {
  throwIfWhitespace(classToCheck);

  return element.classList.contains(classToCheck);
}

/**
 * Add a class name to an element.
 *
 * @param  {Element} element
 *         Element to add class name to.
 *
 * @param  {...string} classesToAdd
 *         One or more class name to add.
 *
 * @return {Element}
 *         The DOM element with the added class name.
 */
export function addClass(element, ...classesToAdd) {
  element.classList.add(...classesToAdd.reduce((prev, current) => prev.concat(current.split(/\s+/)), []));

  return element;
}

/**
 * Remove a class name from an element.
 *
 * @param  {Element} element
 *         Element to remove a class name from.
 *
 * @param  {...string} classesToRemove
 *         One or more class name to remove.
 *
 * @return {Element}
 *         The DOM element with class name removed.
 */
export function removeClass(element, ...classesToRemove) {
  // Protect in case the player gets disposed
  if (!element) {
    log.warn("removeClass was called with an element that doesn't exist");
    return null;
  }
  element.classList.remove(...classesToRemove.reduce((prev, current) => prev.concat(current.split(/\s+/)), []));

  return element;
}

/**
 * The callback definition for toggleClass.
 *
 * @callback module:dom~PredicateCallback
 * @param    {Element} element
 *           The DOM element of the Component.
 *
 * @param    {string} classToToggle
 *           The `className` that wants to be toggled
 *
 * @return   {boolean|undefined}
 *           If `true` is returned, the `classToToggle` will be added to the
 *           `element`. If `false`, the `classToToggle` will be removed from
 *           the `element`. If `undefined`, the callback will be ignored.
 */

/**
 * Adds or removes a class name to/from an element depending on an optional
 * condition or the presence/absence of the class name.
 *
 * @param  {Element} element
 *         The element to toggle a class name on.
 *
 * @param  {string} classToToggle
 *         The class that should be toggled.
 *
 * @param  {boolean|module:dom~PredicateCallback} [predicate]
 *         See the return value for {@link module:dom~PredicateCallback}
 *
 * @return {Element}
 *         The element with a class that has been toggled.
 */
export function toggleClass(element, classToToggle, predicate) {
  if (typeof predicate === 'function') {
    predicate = predicate(element, classToToggle);
  }
  if (typeof predicate !== 'boolean') {
    predicate = undefined;
  }
  classToToggle.split(/\s+/).forEach(className => element.classList.toggle(className, predicate));

  return element;
}

/**
 * Apply attributes to an HTML element.
 *
 * @param {Element} el
 *        Element to add attributes to.
 *
 * @param {Object} [attributes]
 *        Attributes to be applied.
 */
export function setAttributes(el, attributes) {
  Object.getOwnPropertyNames(attributes).forEach(function(attrName) {
    const attrValue = attributes[attrName];

    if (attrValue === null || typeof attrValue === 'undefined' || attrValue === false) {
      el.removeAttribute(attrName);
    } else {
      el.setAttribute(attrName, (attrValue === true ? '' : attrValue));
    }
  });
}

/**
 * Get an element's attribute values, as defined on the HTML tag.
 *
 * Attributes are not the same as properties. They're defined on the tag
 * or with setAttribute.
 *
 * @param  {Element} tag
 *         Element from which to get tag attributes.
 *
 * @return {Object}
 *         All attributes of the element. Boolean attributes will be `true` or
 *         `false`, others will be strings.
 */
export function getAttributes(tag) {
  const obj = {};

  // known boolean attributes
  // we can check for matching boolean properties, but not all browsers
  // and not all tags know about these attributes, so, we still want to check them manually
  const knownBooleans = ['autoplay', 'controls', 'playsinline', 'loop', 'muted', 'default', 'defaultMuted'];

  if (tag && tag.attributes && tag.attributes.length > 0) {
    const attrs = tag.attributes;

    for (let i = attrs.length - 1; i >= 0; i--) {
      const attrName = attrs[i].name;
      /** @type {boolean|string} */
      let attrVal = attrs[i].value;

      // check for known booleans
      // the matching element property will return a value for typeof
      if (knownBooleans.includes(attrName)) {
        // the value of an included boolean attribute is typically an empty
        // string ('') which would equal false if we just check for a false value.
        // we also don't want support bad code like autoplay='false'
        attrVal = (attrVal !== null) ? true : false;
      }

      obj[attrName] = attrVal;
    }
  }

  return obj;
}

/**
 * Get the value of an element's attribute.
 *
 * @param {Element} el
 *        A DOM element.
 *
 * @param {string} attribute
 *        Attribute to get the value of.
 *
 * @return {string}
 *         The value of the attribute.
 */
export function getAttribute(el, attribute) {
  return el.getAttribute(attribute);
}

/**
 * Set the value of an element's attribute.
 *
 * @param {Element} el
 *        A DOM element.
 *
 * @param {string} attribute
 *        Attribute to set.
 *
 * @param {string} value
 *        Value to set the attribute to.
 */
export function setAttribute(el, attribute, value) {
  el.setAttribute(attribute, value);
}

/**
 * Remove an element's attribute.
 *
 * @param {Element} el
 *        A DOM element.
 *
 * @param {string} attribute
 *        Attribute to remove.
 */
export function removeAttribute(el, attribute) {
  el.removeAttribute(attribute);
}

/**
 * Attempt to block the ability to select text.
 */
export function blockTextSelection() {
  document.body.focus();
  document.onselectstart = function() {
    return false;
  };
}

/**
 * Turn off text selection blocking.
 */
export function unblockTextSelection() {
  document.onselectstart = function() {
    return true;
  };
}

/**
 * Identical to the native `getBoundingClientRect` function, but ensures that
 * the method is supported at all (it is in all browsers we claim to support)
 * and that the element is in the DOM before continuing.
 *
 * This wrapper function also shims properties which are not provided by some
 * older browsers (namely, IE8).
 *
 * Additionally, some browsers do not support adding properties to a
 * `ClientRect`/`DOMRect` object; so, we shallow-copy it with the standard
 * properties (except `x` and `y` which are not widely supported). This helps
 * avoid implementations where keys are non-enumerable.
 *
 * @param  {Element} el
 *         Element whose `ClientRect` we want to calculate.
 *
 * @return {Object|undefined}
 *         Always returns a plain object - or `undefined` if it cannot.
 */
export function getBoundingClientRect(el) {
  if (el && el.getBoundingClientRect && el.parentNode) {
    const rect = el.getBoundingClientRect();
    const result = {};

    ['bottom', 'height', 'left', 'right', 'top', 'width'].forEach(k => {
      if (rect[k] !== undefined) {
        result[k] = rect[k];
      }
    });

    if (!result.height) {
      result.height = parseFloat(computedStyle(el, 'height'));
    }

    if (!result.width) {
      result.width = parseFloat(computedStyle(el, 'width'));
    }

    return result;
  }
}

/**
 * Represents the position of a DOM element on the page.
 *
 * @typedef  {Object} module:dom~Position
 *
 * @property {number} left
 *           Pixels to the left.
 *
 * @property {number} top
 *           Pixels from the top.
 */

/**
 * Get the position of an element in the DOM.
 *
 * Uses `getBoundingClientRect` technique from John Resig.
 *
 * @see http://ejohn.org/blog/getboundingclientrect-is-awesome/
 *
 * @param  {Element} el
 *         Element from which to get offset.
 *
 * @return {module:dom~Position}
 *         The position of the element that was passed in.
 */
export function findPosition(el) {
  if (!el || (el && !el.offsetParent)) {
    return {
      left: 0,
      top: 0,
      width: 0,
      height: 0
    };
  }
  const width = el.offsetWidth;
  const height = el.offsetHeight;
  let left = 0;
  let top = 0;

  while (el.offsetParent && el !== document[fs.fullscreenElement]) {
    left += el.offsetLeft;
    top += el.offsetTop;

    el = el.offsetParent;
  }

  return {
    left,
    top,
    width,
    height
  };
}

/**
 * Represents x and y coordinates for a DOM element or mouse pointer.
 *
 * @typedef  {Object} module:dom~Coordinates
 *
 * @property {number} x
 *           x coordinate in pixels
 *
 * @property {number} y
 *           y coordinate in pixels
 */

/**
 * Get the pointer position within an element.
 *
 * The base on the coordinates are the bottom left of the element.
 *
 * @param  {Element} el
 *         Element on which to get the pointer position on.
 *
 * @param  {Event} event
 *         Event object.
 *
 * @return {module:dom~Coordinates}
 *         A coordinates object corresponding to the mouse position.
 *
 */
export function getPointerPosition(el, event) {
  const translated = {
    x: 0,
    y: 0
  };

  if (browser.IS_IOS) {
    let item = el;

    while (item && item.nodeName.toLowerCase() !== 'html') {
      const transform = computedStyle(item, 'transform');

      if (/^matrix/.test(transform)) {
        const values = transform.slice(7, -1).split(/,\s/).map(Number);

        translated.x += values[4];
        translated.y += values[5];
      } else if (/^matrix3d/.test(transform)) {
        const values = transform.slice(9, -1).split(/,\s/).map(Number);

        translated.x += values[12];
        translated.y += values[13];
      }

      item = item.parentNode;
    }
  }

  const position = {};
  const boxTarget = findPosition(event.target);
  const box = findPosition(el);
  const boxW = box.width;
  const boxH = box.height;
  let offsetY = event.offsetY - (box.top - boxTarget.top);
  let offsetX = event.offsetX - (box.left - boxTarget.left);

  if (event.changedTouches) {
    offsetX = event.changedTouches[0].pageX - box.left;
    offsetY = event.changedTouches[0].pageY + box.top;
    if (browser.IS_IOS) {
      offsetX -= translated.x;
      offsetY -= translated.y;
    }
  }

  position.y = (1 - Math.max(0, Math.min(1, offsetY / boxH)));
  position.x = Math.max(0, Math.min(1, offsetX / boxW));
  return position;
}

/**
 * Determines, via duck typing, whether or not a value is a text node.
 *
 * @param  {*} value
 *         Check if this value is a text node.
 *
 * @return {boolean}
 *         Will be `true` if the value is a text node, `false` otherwise.
 */
export function isTextNode(value) {
  return isObject(value) && value.nodeType === 3;
}

/**
 * Empties the contents of an element.
 *
 * @param  {Element} el
 *         The element to empty children from
 *
 * @return {Element}
 *         The element with no children
 */
export function emptyEl(el) {
  while (el.firstChild) {
    el.removeChild(el.firstChild);
  }
  return el;
}

/**
 * This is a mixed value that describes content to be injected into the DOM
 * via some method. It can be of the following types:
 *
 * Type       | Description
 * -----------|-------------
 * `string`   | The value will be normalized into a text node.
 * `Element`  | The value will be accepted as-is.
 * `Text`     | A TextNode. The value will be accepted as-is.
 * `Array`    | A one-dimensional array of strings, elements, text nodes, or functions. These functions should return a string, element, or text node (any other return value, like an array, will be ignored).
 * `Function` | A function, which is expected to return a string, element, text node, or array - any of the other possible values described above. This means that a content descriptor could be a function that returns an array of functions, but those second-level functions must return strings, elements, or text nodes.
 *
 * @typedef {string|Element|Text|Array|Function} ContentDescriptor
 */

/**
 * Normalizes content for eventual insertion into the DOM.
 *
 * This allows a wide range of content definition methods, but helps protect
 * from falling into the trap of simply writing to `innerHTML`, which could
 * be an XSS concern.
 *
 * The content for an element can be passed in multiple types and
 * combinations, whose behavior is as follows:
 *
 * @param {ContentDescriptor} content
 *        A content descriptor value.
 *
 * @return {Array}
 *         All of the content that was passed in, normalized to an array of
 *         elements or text nodes.
 */
export function normalizeContent(content) {

  // First, invoke content if it is a function. If it produces an array,
  // that needs to happen before normalization.
  if (typeof content === 'function') {
    content = content();
  }

  // Next up, normalize to an array, so one or many items can be normalized,
  // filtered, and returned.
  return (Array.isArray(content) ? content : [content]).map(value => {

    // First, invoke value if it is a function to produce a new value,
    // which will be subsequently normalized to a Node of some kind.
    if (typeof value === 'function') {
      value = value();
    }

    if (isEl(value) || isTextNode(value)) {
      return value;
    }

    if (typeof value === 'string' && (/\S/).test(value)) {
      return document.createTextNode(value);
    }
  }).filter(value => value);
}

/**
 * Normalizes and appends content to an element.
 *
 * @param  {Element} el
 *         Element to append normalized content to.
 *
 * @param {ContentDescriptor} content
 *        A content descriptor value.
 *
 * @return {Element}
 *         The element with appended normalized content.
 */
export function appendContent(el, content) {
  normalizeContent(content).forEach(node => el.appendChild(node));
  return el;
}

/**
 * Normalizes and inserts content into an element; this is identical to
 * `appendContent()`, except it empties the element first.
 *
 * @param {Element} el
 *        Element to insert normalized content into.
 *
 * @param {ContentDescriptor} content
 *        A content descriptor value.
 *
 * @return {Element}
 *         The element with inserted normalized content.
 */
export function insertContent(el, content) {
  return appendContent(emptyEl(el), content);
}

/**
 * Check if an event was a single left click.
 *
 * @param  {MouseEvent} event
 *         Event object.
 *
 * @return {boolean}
 *         Will be `true` if a single left click, `false` otherwise.
 */
export function isSingleLeftClick(event) {
  // Note: if you create something draggable, be sure to
  // call it on both `mousedown` and `mousemove` event,
  // otherwise `mousedown` should be enough for a button

  if (event.button === undefined && event.buttons === undefined) {
    // Why do we need `buttons` ?
    // Because, middle mouse sometimes have this:
    // e.button === 0 and e.buttons === 4
    // Furthermore, we want to prevent combination click, something like
    // HOLD middlemouse then left click, that would be
    // e.button === 0, e.buttons === 5
    // just `button` is not gonna work

    // Alright, then what this block does ?
    // this is for chrome `simulate mobile devices`
    // I want to support this as well

    return true;
  }

  if (event.button === 0 && event.buttons === undefined) {
    // Touch screen, sometimes on some specific device, `buttons`
    // doesn't have anything (safari on ios, blackberry...)

    return true;
  }

  // `mouseup` event on a single left click has
  // `button` and `buttons` equal to 0
  if (event.type === 'mouseup' && event.button === 0 &&
      event.buttons === 0) {
    return true;
  }

  if (event.button !== 0 || event.buttons !== 1) {
    // This is the reason we have those if else block above
    // if any special case we can catch and let it slide
    // we do it above, when get to here, this definitely
    // is-not-left-click

    return false;
  }

  return true;
}

/**
 * Finds a single DOM element matching `selector` within the optional
 * `context` of another DOM element (defaulting to `document`).
 *
 * @param  {string} selector
 *         A valid CSS selector, which will be passed to `querySelector`.
 *
 * @param  {Element|String} [context=document]
 *         A DOM element within which to query. Can also be a selector
 *         string in which case the first matching element will be used
 *         as context. If missing (or no element matches selector), falls
 *         back to `document`.
 *
 * @return {Element|null}
 *         The element that was found or null.
 */
export const $ = createQuerier('querySelector');

/**
 * Finds a all DOM elements matching `selector` within the optional
 * `context` of another DOM element (defaulting to `document`).
 *
 * @param  {string} selector
 *         A valid CSS selector, which will be passed to `querySelectorAll`.
 *
 * @param  {Element|String} [context=document]
 *         A DOM element within which to query. Can also be a selector
 *         string in which case the first matching element will be used
 *         as context. If missing (or no element matches selector), falls
 *         back to `document`.
 *
 * @return {NodeList}
 *         A element list of elements that were found. Will be empty if none
 *         were found.
 *
 */
export const $$ = createQuerier('querySelectorAll');

/**
 * A safe getComputedStyle.
 *
 * This is needed because in Firefox, if the player is loaded in an iframe with
 * `display:none`, then `getComputedStyle` returns `null`, so, we do a
 * null-check to make sure that the player doesn't break in these cases.
 *
 * @param    {Element} el
 *           The element you want the computed style of
 *
 * @param    {string} prop
 *           The property name you want
 *
 * @see      https://bugzilla.mozilla.org/show_bug.cgi?id=548397
 */
export function computedStyle(el, prop) {
  if (!el || !prop) {
    return '';
  }

  if (typeof window.getComputedStyle === 'function') {
    let computedStyleValue;

    try {
      computedStyleValue = window.getComputedStyle(el);
    } catch (e) {
      return '';
    }

    return computedStyleValue ? computedStyleValue.getPropertyValue(prop) || computedStyleValue[prop] : '';
  }

  return '';
}

/**
 * Copy document style sheets to another window.
 *
 * @param    {Window} win
 *           The window element you want to copy the document style sheets to.
 *
 */
export function copyStyleSheetsToWindow(win) {
  [...document.styleSheets].forEach((styleSheet) => {
    try {
      const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
      const style = document.createElement('style');

      style.textContent = cssRules;
      win.document.head.appendChild(style);
    } catch (e) {
      const link = document.createElement('link');

      link.rel = 'stylesheet';
      link.type = styleSheet.type;
      // For older Safari this has to be the string; on other browsers setting the MediaList works
      link.media = styleSheet.media.mediaText;
      link.href = styleSheet.href;
      win.document.head.appendChild(link);
    }
  });
}