/**
* @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);
}
});
}