diff options
Diffstat (limited to 'javascript/videojs/src/js/utils/dom.js')
| -rw-r--r-- | javascript/videojs/src/js/utils/dom.js | 901 |
1 files changed, 901 insertions, 0 deletions
diff --git a/javascript/videojs/src/js/utils/dom.js b/javascript/videojs/src/js/utils/dom.js new file mode 100644 index 0000000..0505850 --- /dev/null +++ b/javascript/videojs/src/js/utils/dom.js @@ -0,0 +1,901 @@ + +/** + * @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 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`, but not removed. If `false`, the `classToToggle` will be removed from + * the `element`, but not added. 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|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]; + } + + if (item.assignedSlot && item.assignedSlot.parentElement && window.WebKitCSSMatrix) { + const transformValue = window.getComputedStyle(item.assignedSlot.parentElement).transform; + const matrix = new window.WebKitCSSMatrix(transformValue); + + translated.x += matrix.m41; + translated.y += matrix.m42; + } + + item = item.parentNode || item.host; + } + } + + 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; + } + + // MacOS Sonoma trackpad when "tap to click enabled" + if (event.type === 'mousedown' && 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); + } + }); +} |
