diff options
Diffstat (limited to 'src/js/utils')
-rw-r--r-- | src/js/utils/animation.js | 30 | ||||
-rw-r--r-- | src/js/utils/arrays.js | 23 | ||||
-rw-r--r-- | src/js/utils/browser.js | 13 | ||||
-rw-r--r-- | src/js/utils/elements.js | 285 | ||||
-rw-r--r-- | src/js/utils/events.js | 120 | ||||
-rw-r--r-- | src/js/utils/fetch.js | 42 | ||||
-rw-r--r-- | src/js/utils/is.js | 67 | ||||
-rw-r--r-- | src/js/utils/loadImage.js | 19 | ||||
-rw-r--r-- | src/js/utils/loadScript.js | 14 | ||||
-rw-r--r-- | src/js/utils/loadSprite.js | 75 | ||||
-rw-r--r-- | src/js/utils/objects.js | 42 | ||||
-rw-r--r-- | src/js/utils/strings.js | 85 | ||||
-rw-r--r-- | src/js/utils/time.js | 36 | ||||
-rw-r--r-- | src/js/utils/urls.js | 39 |
14 files changed, 890 insertions, 0 deletions
diff --git a/src/js/utils/animation.js b/src/js/utils/animation.js new file mode 100644 index 00000000..95e39f03 --- /dev/null +++ b/src/js/utils/animation.js @@ -0,0 +1,30 @@ +// ========================================================================== +// Animation utils +// ========================================================================== + +import { toggleHidden } from './elements'; +import is from './is'; + +export const transitionEndEvent = (() => { + const element = document.createElement('span'); + + const events = { + WebkitTransition: 'webkitTransitionEnd', + MozTransition: 'transitionend', + OTransition: 'oTransitionEnd otransitionend', + transition: 'transitionend', + }; + + const type = Object.keys(events).find(event => element.style[event] !== undefined); + + return is.string(type) ? events[type] : false; +})(); + +// Force repaint of element +export function repaint(element) { + setTimeout(() => { + toggleHidden(element, true); + element.offsetHeight; // eslint-disable-line + toggleHidden(element, false); + }, 0); +} diff --git a/src/js/utils/arrays.js b/src/js/utils/arrays.js new file mode 100644 index 00000000..69ef242c --- /dev/null +++ b/src/js/utils/arrays.js @@ -0,0 +1,23 @@ +// ========================================================================== +// Array utils +// ========================================================================== + +import is from './is'; + +// Remove duplicates in an array +export function dedupe(array) { + if (!is.array(array)) { + return array; + } + + return array.filter((item, index) => array.indexOf(item) === index); +} + +// Get the closest value in an array +export function closest(array, value) { + if (!is.array(array) || !array.length) { + return null; + } + + return array.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev)); +} diff --git a/src/js/utils/browser.js b/src/js/utils/browser.js new file mode 100644 index 00000000..d574f683 --- /dev/null +++ b/src/js/utils/browser.js @@ -0,0 +1,13 @@ +// ========================================================================== +// Browser sniffing +// Unfortunately, due to mixed support, UA sniffing is required +// ========================================================================== + +const browser = { + isIE: /* @cc_on!@ */ false || !!document.documentMode, + isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent), + isIPhone: /(iPhone|iPod)/gi.test(navigator.platform), + isIos: /(iPad|iPhone|iPod)/gi.test(navigator.platform), +}; + +export default browser; diff --git a/src/js/utils/elements.js b/src/js/utils/elements.js new file mode 100644 index 00000000..69e4d46c --- /dev/null +++ b/src/js/utils/elements.js @@ -0,0 +1,285 @@ +// ========================================================================== +// Element utils +// ========================================================================== + +import { toggleListener } from './events'; +import is from './is'; + +// Wrap an element +export function wrap(elements, wrapper) { + // Convert `elements` to an array, if necessary. + const targets = elements.length ? elements : [elements]; + + // Loops backwards to prevent having to clone the wrapper on the + // first element (see `child` below). + Array.from(targets) + .reverse() + .forEach((element, index) => { + const child = index > 0 ? wrapper.cloneNode(true) : wrapper; + + // Cache the current parent and sibling. + const parent = element.parentNode; + const sibling = element.nextSibling; + + // Wrap the element (is automatically removed from its current + // parent). + child.appendChild(element); + + // If the element had a sibling, insert the wrapper before + // the sibling to maintain the HTML structure; otherwise, just + // append it to the parent. + if (sibling) { + parent.insertBefore(child, sibling); + } else { + parent.appendChild(child); + } + }); +} + +// Set attributes +export function setAttributes(element, attributes) { + if (!is.element(element) || is.empty(attributes)) { + return; + } + + // Assume null and undefined attributes should be left out, + // Setting them would otherwise convert them to "null" and "undefined" + Object.entries(attributes) + .filter(([, value]) => !is.nullOrUndefined(value)) + .forEach(([key, value]) => element.setAttribute(key, value)); +} + +// Create a DocumentFragment +export function createElement(type, attributes, text) { + // Create a new <element> + const element = document.createElement(type); + + // Set all passed attributes + if (is.object(attributes)) { + setAttributes(element, attributes); + } + + // Add text node + if (is.string(text)) { + element.innerText = text; + } + + // Return built element + return element; +} + +// Inaert an element after another +export function insertAfter(element, target) { + target.parentNode.insertBefore(element, target.nextSibling); +} + +// Insert a DocumentFragment +export function insertElement(type, parent, attributes, text) { + // Inject the new <element> + parent.appendChild(createElement(type, attributes, text)); +} + +// Remove element(s) +export function removeElement(element) { + if (is.nodeList(element) || is.array(element)) { + Array.from(element).forEach(removeElement); + return; + } + + if (!is.element(element) || !is.element(element.parentNode)) { + return; + } + + element.parentNode.removeChild(element); +} + +// Remove all child elements +export function emptyElement(element) { + let { length } = element.childNodes; + + while (length > 0) { + element.removeChild(element.lastChild); + length -= 1; + } +} + +// Replace element +export function replaceElement(newChild, oldChild) { + if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) { + return null; + } + + oldChild.parentNode.replaceChild(newChild, oldChild); + + return newChild; +} + +// Get an attribute object from a string selector +export function getAttributesFromSelector(sel, existingAttributes) { + // For example: + // '.test' to { class: 'test' } + // '#test' to { id: 'test' } + // '[data-test="test"]' to { 'data-test': 'test' } + + if (!is.string(sel) || is.empty(sel)) { + return {}; + } + + const attributes = {}; + const existing = existingAttributes; + + sel.split(',').forEach(s => { + // Remove whitespace + const selector = s.trim(); + const className = selector.replace('.', ''); + const stripped = selector.replace(/[[\]]/g, ''); + + // Get the parts and value + const parts = stripped.split('='); + const key = parts[0]; + const value = parts.length > 1 ? parts[1].replace(/["']/g, '') : ''; + + // Get the first character + const start = selector.charAt(0); + + switch (start) { + case '.': + // Add to existing classname + if (is.object(existing) && is.string(existing.class)) { + existing.class += ` ${className}`; + } + + attributes.class = className; + break; + + case '#': + // ID selector + attributes.id = selector.replace('#', ''); + break; + + case '[': + // Attribute selector + attributes[key] = value; + + break; + + default: + break; + } + }); + + return attributes; +} + +// Toggle hidden +export function toggleHidden(element, hidden) { + if (!is.element(element)) { + return; + } + + let hide = hidden; + + if (!is.boolean(hide)) { + hide = !element.hasAttribute('hidden'); + } + + if (hide) { + element.setAttribute('hidden', ''); + } else { + element.removeAttribute('hidden'); + } +} + +// Mirror Element.classList.toggle, with IE compatibility for "force" argument +export function toggleClass(element, className, force) { + if (is.element(element)) { + let method = 'toggle'; + if (typeof force !== 'undefined') { + method = force ? 'add' : 'remove'; + } + + element.classList[method](className); + return element.classList.contains(className); + } + + return null; +} + +// Has class name +export function hasClass(element, className) { + return is.element(element) && element.classList.contains(className); +} + +// Element matches selector +export function matches(element, selector) { + const prototype = { Element }; + + function match() { + return Array.from(document.querySelectorAll(selector)).includes(this); + } + + const matches = + prototype.matches || + prototype.webkitMatchesSelector || + prototype.mozMatchesSelector || + prototype.msMatchesSelector || + match; + + return matches.call(element, selector); +} + +// Find all elements +export function getElements(selector) { + return this.elements.container.querySelectorAll(selector); +} + +// Find a single element +export function getElement(selector) { + return this.elements.container.querySelector(selector); +} + +// Get the focused element +export function getFocusElement() { + let focused = document.activeElement; + + if (!focused || focused === document.body) { + focused = null; + } else { + focused = document.querySelector(':focus'); + } + + return focused; +} + +// Trap focus inside container +export function trapFocus(element = null, toggle = false) { + if (!is.element(element)) { + return; + } + + const focusable = getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]'); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + const trap = event => { + // Bail if not tab key or not fullscreen + if (event.key !== 'Tab' || event.keyCode !== 9) { + return; + } + + // Get the current focused element + const focused = getFocusElement(); + + if (focused === last && !event.shiftKey) { + // Move focus to first element that can be tabbed if Shift isn't used + first.focus(); + event.preventDefault(); + } else if (focused === first && event.shiftKey) { + // Move focus to last element that can be tabbed if Shift is used + last.focus(); + event.preventDefault(); + } + }; + + toggleListener.call(this, this.elements.container, 'keydown', trap, toggle, false); +} diff --git a/src/js/utils/events.js b/src/js/utils/events.js new file mode 100644 index 00000000..9f734f04 --- /dev/null +++ b/src/js/utils/events.js @@ -0,0 +1,120 @@ +// ========================================================================== +// Event utils +// ========================================================================== + +import is from './is'; + +// Check for passive event listener support +// https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md +// https://www.youtube.com/watch?v=NPM6172J22g +const supportsPassiveListeners = (() => { + // Test via a getter in the options object to see if the passive property is accessed + let supported = false; + try { + const options = Object.defineProperty({}, 'passive', { + get() { + supported = true; + return null; + }, + }); + window.addEventListener('test', null, options); + window.removeEventListener('test', null, options); + } catch (e) { + // Do nothing + } + + return supported; +})(); + +// Toggle event listener +export function toggleListener(element, event, callback, toggle = false, passive = true, capture = false) { + // Bail if no element, event, or callback + if (!element || !('addEventListener' in element) || is.empty(event) || !is.function(callback)) { + return; + } + + // Allow multiple events + const events = event.split(' '); + + // Build options + // Default to just the capture boolean for browsers with no passive listener support + let options = capture; + + // If passive events listeners are supported + if (supportsPassiveListeners) { + options = { + // Whether the listener can be passive (i.e. default never prevented) + passive, + // Whether the listener is a capturing listener or not + capture, + }; + } + + // If a single node is passed, bind the event listener + events.forEach(type => { + if (this && this.eventListeners && toggle) { + // Cache event listener + this.eventListeners.push({ element, type, callback, options }); + } + + element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options); + }); +} + +// Bind event handler +export function on(element, events = '', callback, passive = true, capture = false) { + toggleListener.call(this, element, events, callback, true, passive, capture); +} + +// Unbind event handler +export function off(element, events = '', callback, passive = true, capture = false) { + toggleListener.call(this, element, events, callback, false, passive, capture); +} + +// Bind once-only event handler +export function once(element, events = '', callback, passive = true, capture = false) { + function onceCallback(...args) { + off(element, events, onceCallback, passive, capture); + callback.apply(this, args); + } + + toggleListener.call(this, element, events, onceCallback, true, passive, capture); +} + +// Trigger event +export function triggerEvent(element, type = '', bubbles = false, detail = {}) { + // Bail if no element + if (!is.element(element) || is.empty(type)) { + return; + } + + // Create and dispatch the event + const event = new CustomEvent(type, { + bubbles, + detail: Object.assign({}, detail, { + plyr: this, + }), + }); + + // Dispatch the event + element.dispatchEvent(event); +} + +// Unbind all cached event listeners +export function unbindListeners() { + if (this && this.eventListeners) { + this.eventListeners.forEach(item => { + const { element, type, callback, options } = item; + element.removeEventListener(type, callback, options); + }); + + this.eventListeners = []; + } +} + +// Run method when / if player is ready +export function ready() { + return new Promise( + resolve => (this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve)), + ).then(() => {}); +} diff --git a/src/js/utils/fetch.js b/src/js/utils/fetch.js new file mode 100644 index 00000000..1e506cd0 --- /dev/null +++ b/src/js/utils/fetch.js @@ -0,0 +1,42 @@ +// ========================================================================== +// Fetch wrapper +// Using XHR to avoid issues with older browsers +// ========================================================================== + +export default function fetch(url, responseType = 'text') { + return new Promise((resolve, reject) => { + try { + const request = new XMLHttpRequest(); + + // Check for CORS support + if (!('withCredentials' in request)) { + return; + } + + request.addEventListener('load', () => { + if (responseType === 'text') { + try { + resolve(JSON.parse(request.responseText)); + } catch (e) { + resolve(request.responseText); + } + } else { + resolve(request.response); + } + }); + + request.addEventListener('error', () => { + throw new Error(request.statusText); + }); + + request.open('GET', url, true); + + // Set the required response type + request.responseType = responseType; + + request.send(); + } catch (e) { + reject(e); + } + }); +} diff --git a/src/js/utils/is.js b/src/js/utils/is.js new file mode 100644 index 00000000..cb2c07c6 --- /dev/null +++ b/src/js/utils/is.js @@ -0,0 +1,67 @@ +// ========================================================================== +// Type checking utils +// ========================================================================== + +const getConstructor = input => (input !== null && typeof input !== 'undefined' ? input.constructor : null); + +const instanceOf = (input, constructor) => Boolean(input && constructor && input instanceof constructor); + +const is = { + object(input) { + return getConstructor(input) === Object; + }, + number(input) { + return getConstructor(input) === Number && !Number.isNaN(input); + }, + string(input) { + return getConstructor(input) === String; + }, + boolean(input) { + return getConstructor(input) === Boolean; + }, + function(input) { + return getConstructor(input) === Function; + }, + array(input) { + return !is.nullOrUndefined(input) && Array.isArray(input); + }, + weakMap(input) { + return instanceOf(input, WeakMap); + }, + nodeList(input) { + return instanceOf(input, NodeList); + }, + element(input) { + return instanceOf(input, Element); + }, + textNode(input) { + return getConstructor(input) === Text; + }, + event(input) { + return instanceOf(input, Event); + }, + cue(input) { + return instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue); + }, + track(input) { + return instanceOf(input, TextTrack) || (!is.nullOrUndefined(input) && is.string(input.kind)); + }, + url(input) { + return ( + !is.nullOrUndefined(input) && + /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(input) + ); + }, + nullOrUndefined(input) { + return input === null || typeof input === 'undefined'; + }, + empty(input) { + return ( + is.nullOrUndefined(input) || + ((is.string(input) || is.array(input) || is.nodeList(input)) && !input.length) || + (is.object(input) && !Object.keys(input).length) + ); + }, +}; + +export default is; diff --git a/src/js/utils/loadImage.js b/src/js/utils/loadImage.js new file mode 100644 index 00000000..8acd2496 --- /dev/null +++ b/src/js/utils/loadImage.js @@ -0,0 +1,19 @@ +// ========================================================================== +// Load image avoiding xhr/fetch CORS issues +// Server status can't be obtained this way unfortunately, so this uses "naturalWidth" to determine if the image has loaded +// By default it checks if it is at least 1px, but you can add a second argument to change this +// ========================================================================== + +export default function loadImage(src, minWidth = 1) { + return new Promise((resolve, reject) => { + const image = new Image(); + + const handler = () => { + delete image.onload; + delete image.onerror; + (image.naturalWidth >= minWidth ? resolve : reject)(image); + }; + + Object.assign(image, { onload: handler, onerror: handler, src }); + }); +} diff --git a/src/js/utils/loadScript.js b/src/js/utils/loadScript.js new file mode 100644 index 00000000..81ae36f4 --- /dev/null +++ b/src/js/utils/loadScript.js @@ -0,0 +1,14 @@ +// ========================================================================== +// Load an external script +// ========================================================================== + +import loadjs from 'loadjs'; + +export default function loadScript(url) { + return new Promise((resolve, reject) => { + loadjs(url, { + success: resolve, + error: reject, + }); + }); +} diff --git a/src/js/utils/loadSprite.js b/src/js/utils/loadSprite.js new file mode 100644 index 00000000..dbb00cf2 --- /dev/null +++ b/src/js/utils/loadSprite.js @@ -0,0 +1,75 @@ +// ========================================================================== +// Sprite loader +// ========================================================================== + +import Storage from './../storage'; +import is from './is'; + +// Load an external SVG sprite +export default function loadSprite(url, id) { + if (!is.string(url)) { + return; + } + + const prefix = 'cache'; + const hasId = is.string(id); + let isCached = false; + + const exists = () => document.getElementById(id) !== null; + + const update = (container, data) => { + container.innerHTML = data; + + // Check again incase of race condition + if (hasId && exists()) { + return; + } + + // Inject the SVG to the body + document.body.insertAdjacentElement('afterbegin', container); + }; + + // Only load once if ID set + if (!hasId || !exists()) { + const useStorage = Storage.supported; + + // Create container + const container = document.createElement('div'); + container.setAttribute('hidden', ''); + + if (hasId) { + container.setAttribute('id', id); + } + + // Check in cache + if (useStorage) { + const cached = window.localStorage.getItem(`${prefix}-${id}`); + isCached = cached !== null; + + if (isCached) { + const data = JSON.parse(cached); + update(container, data.content); + } + } + + // Get the sprite + fetch(url) + .then(result => { + if (is.empty(result)) { + return; + } + + if (useStorage) { + window.localStorage.setItem( + `${prefix}-${id}`, + JSON.stringify({ + content: result, + }), + ); + } + + update(container, result); + }) + .catch(() => {}); + } +} diff --git a/src/js/utils/objects.js b/src/js/utils/objects.js new file mode 100644 index 00000000..225bb459 --- /dev/null +++ b/src/js/utils/objects.js @@ -0,0 +1,42 @@ +// ========================================================================== +// Object utils +// ========================================================================== + +import is from './is'; + +// Clone nested objects +export function cloneDeep(object) { + return JSON.parse(JSON.stringify(object)); +} + +// Get a nested value in an object +export function getDeep(object, path) { + return path.split('.').reduce((obj, key) => obj && obj[key], object); +} + +// Deep extend destination object with N more objects +export function extend(target = {}, ...sources) { + if (!sources.length) { + return target; + } + + const source = sources.shift(); + + if (!is.object(source)) { + return target; + } + + Object.keys(source).forEach(key => { + if (is.object(source[key])) { + if (!Object.keys(target).includes(key)) { + Object.assign(target, { [key]: {} }); + } + + extend(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + }); + + return extend(target, ...sources); +} diff --git a/src/js/utils/strings.js b/src/js/utils/strings.js new file mode 100644 index 00000000..c872498c --- /dev/null +++ b/src/js/utils/strings.js @@ -0,0 +1,85 @@ +// ========================================================================== +// String utils +// ========================================================================== + +import is from './is'; + +// Generate a random ID +export function generateId(prefix) { + return `${prefix}-${Math.floor(Math.random() * 10000)}`; +} + +// Format string +export function format(input, ...args) { + if (is.empty(input)) { + return input; + } + + return input.toString().replace(/{(\d+)}/g, (match, i) => args[i].toString()); +} + +// Get percentage +export function getPercentage(current, max) { + if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) { + return 0; + } + + return (current / max * 100).toFixed(2); +} + +// Replace all occurances of a string in a string +export function replaceAll(input = '', find = '', replace = '') { + return input.replace( + new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), + replace.toString(), + ); +} + +// Convert to title case +export function toTitleCase(input = '') { + return input.toString().replace(/\w\S*/g, text => text.charAt(0).toUpperCase() + text.substr(1).toLowerCase()); +} + +// Convert string to pascalCase +export function toPascalCase(input = '') { + let string = input.toString(); + + // Convert kebab case + string = replaceAll(string, '-', ' '); + + // Convert snake case + string = replaceAll(string, '_', ' '); + + // Convert to title case + string = toTitleCase(string); + + // Convert to pascal case + return replaceAll(string, ' ', ''); +} + +// Convert string to pascalCase +export function toCamelCase(input = '') { + let string = input.toString(); + + // Convert to pascal case + string = toPascalCase(string); + + // Convert first character to lowercase + return string.charAt(0).toLowerCase() + string.slice(1); +} + +// Remove HTML from a string +export function stripHTML(source) { + const fragment = document.createDocumentFragment(); + const element = document.createElement('div'); + fragment.appendChild(element); + element.innerHTML = source; + return fragment.firstChild.innerText; +} + +// Like outerHTML, but also works for DocumentFragment +export function getHTML(element) { + const wrapper = document.createElement('div'); + wrapper.appendChild(element); + return wrapper.innerHTML; +} diff --git a/src/js/utils/time.js b/src/js/utils/time.js new file mode 100644 index 00000000..7c9860fd --- /dev/null +++ b/src/js/utils/time.js @@ -0,0 +1,36 @@ +// ========================================================================== +// Time utils +// ========================================================================== + +import is from './is'; + +// Time helpers +export const getHours = value => parseInt((value / 60 / 60) % 60, 10); +export const getMinutes = value => parseInt((value / 60) % 60, 10); +export const getSeconds = value => parseInt(value % 60, 10); + +// Format time to UI friendly string +export function formatTime(time = 0, displayHours = false, inverted = false) { + // Bail if the value isn't a number + if (!is.number(time)) { + return formatTime(null, displayHours, inverted); + } + + // Format time component to add leading zero + const format = value => `0${value}`.slice(-2); + + // Breakdown to hours, mins, secs + let hours = getHours(time); + const mins = getMinutes(time); + const secs = getSeconds(time); + + // Do we need to display hours? + if (displayHours || hours > 0) { + hours = `${hours}:`; + } else { + hours = ''; + } + + // Render + return `${inverted && time > 0 ? '-' : ''}${hours}${format(mins)}:${format(secs)}`; +} diff --git a/src/js/utils/urls.js b/src/js/utils/urls.js new file mode 100644 index 00000000..3ebe622e --- /dev/null +++ b/src/js/utils/urls.js @@ -0,0 +1,39 @@ +// ========================================================================== +// URL utils +// ========================================================================== + +import is from './is'; + +/** + * Parse a string to a URL object + * @param {string} input - the URL to be parsed + * @param {boolean} safe - failsafe parsing + */ +export function parseUrl(input, safe = true) { + let url = input; + + if (safe) { + const parser = document.createElement('a'); + parser.href = url; + url = parser.href; + } + + try { + return new URL(url); + } catch (e) { + return null; + } +} + +// Convert object to URLSearchParams +export function buildUrlParams(input) { + const params = new URLSearchParams(); + + if (is.object(input)) { + Object.entries(input).forEach(([key, value]) => { + params.set(key, value); + }); + } + + return params; +} |