UNPKG

bitmovin-player-ui

Version:
646 lines (574 loc) 20.6 kB
import { Component, ComponentConfig } from './components/Component'; export interface Offset { left: number; top: number; } export interface Size { width: number; height: number; } export interface CssProperties { [propertyName: string]: string; } /** * Extends the {@link HTMLElement} interface with a component attribute to store the associated component. */ export interface HTMLElementWithComponent extends HTMLElement { component?: Component<ComponentConfig>; } /** * Simple DOM manipulation and DOM element event handling modeled after jQuery (as replacement for jQuery). * * Like jQuery, DOM operates on single elements and lists of elements. For example: creating an element returns a DOM * instance with a single element, selecting elements returns a DOM instance with zero, one, or many elements. Similar * to jQuery, setters usually affect all elements, while getters operate on only the first element. * Also similar to jQuery, most methods (except getters) return the DOM instance facilitating easy chaining of method * calls. * * Built with the help of: http://youmightnotneedjquery.com/ */ export class DOM { private readonly documentOrShadowRoot: Document | ShadowRoot; /** * The list of elements that the instance wraps. Take care that not all methods can operate on the whole list, * getters usually just work on the first element. */ private elements: HTMLElementWithComponent[]; /** * Creates a DOM element. * @param tagName the tag name of the DOM element * @param attributes a list of attributes of the element * @param component the {@link Component} the DOM element is associated with */ constructor(tagName: string, attributes: { [name: string]: string }, component?: Component<ComponentConfig>); /** * Selects all elements from the DOM that match the specified selector. * @param selector the selector to match DOM elements with */ constructor(selector: string); /** * Wraps a plain HTMLElement with a DOM instance. * @param element the HTMLElement to wrap with DOM */ constructor(element: HTMLElement); /** * Wraps a list of plain HTMLElements with a DOM instance. * @param elements the HTMLElements to wrap with DOM */ constructor(elements: HTMLElement[]); /** * Wraps the document with a DOM instance. Useful to attach event listeners to the document. * @param document the document to wrap */ constructor(document: Document); /** * Wraps the ShadowRoot with a DOM instance. Useful to attach event listeners to the ShadowRoot. * @param shadowRoot the ShadowRoot to wrap */ constructor(shadowRoot: ShadowRoot); constructor( something: string | HTMLElement | HTMLElement[] | Document | ShadowRoot, attributes?: { [name: string]: string }, component?: Component<ComponentConfig>, ) { if (something instanceof Array) { if (something.length > 0 && something[0] instanceof HTMLElement) { const elements = something as HTMLElementWithComponent[]; this.elements = elements; } } else if (something instanceof HTMLElement) { const element = something as HTMLElementWithComponent; this.elements = [element]; } else if (something instanceof Document || something instanceof ShadowRoot) { // When a document or the ShadowRoot is passed in, we do not do anything with it, but by setting // this.elements to null we give the event handling method a means to detect if the events should be // registered on the document or ShadowRoot instead of elements. this.documentOrShadowRoot = something; this.elements = null; } else if (attributes) { const tagName = something; const element = document.createElement(tagName) as HTMLElementWithComponent; for (const attributeName in attributes) { const attributeValue = attributes[attributeName]; if (attributeValue != null) { element.setAttribute(attributeName, attributeValue); } } if (component) { element.component = component; } this.elements = [element]; } else { const selector = something; this.elements = this.findChildElements(selector) as HTMLElementWithComponent[]; } } /** * Gets the number of elements that this DOM instance currently holds. * @returns {number} the number of elements */ get length(): number { return this.elements ? this.elements.length : 0; } /** * Gets the HTML elements that this DOM instance currently holds. * @returns {HTMLElement[]} the raw HTML elements */ get(): HTMLElementWithComponent[]; /** * Gets an HTML element from the list elements that this DOM instance currently holds. * @param index The zero-based index into the element list. Can be negative to return an element from the end, * e.g. -1 returns the last element. */ get(index: number): HTMLElementWithComponent; get(index?: number): HTMLElementWithComponent | HTMLElementWithComponent[] { if (index === undefined) { return this.elements; } else if (!this.elements || index >= this.elements.length || index < -this.elements.length) { return undefined; } else if (index < 0) { return this.elements[this.elements.length - index]; } else { return this.elements[index]; } } /** * A shortcut method for iterating all elements. Shorts this.elements.forEach(...) to this.forEach(...). * @param handler the handler to execute an operation on an element */ private forEach(handler: (element: HTMLElement) => void): void { if (!this.elements) { return; } this.elements.forEach(element => { handler(element); }); } private findChildElementsOfElement(element: HTMLElement | Document | ShadowRoot, selector: string): HTMLElement[] { const childElements = element.querySelectorAll(selector); // Convert NodeList to Array // https://toddmotto.com/a-comprehensive-dive-into-nodelists-arrays-converting-nodelists-and-understanding-the-dom/ return [].slice.call(childElements); } private findChildElements(selector: string): HTMLElement[] { let allChildElements = <HTMLElement[]>[]; if (this.elements) { this.forEach(element => { allChildElements = allChildElements.concat(this.findChildElementsOfElement(element, selector)); }); } else { return this.findChildElementsOfElement(document, selector); } return allChildElements; } /** * Finds all child elements of all elements matching the supplied selector. * @param selector the selector to match with child elements * @returns {DOM} a new DOM instance representing all matched children */ find(selector: string): DOM { const allChildElements = this.findChildElements(selector) as HTMLElementWithComponent[]; return new DOM(allChildElements); } /** * Focuses to the first input element */ focusToFirstInput() { const inputElements = this.findChildElements( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (inputElements.length > 0) { inputElements[0].focus(); } } /** * Focuses to the first input element */ scrollTo(x: number, y: number) { this.elements[0].scrollTo(x, y); } /** * Returns a string of the inner HTML content of the first element. */ html(): string; /** * Sets the inner HTML content of all elements. * @param content a string of plain text or HTML markup */ html(content: string): DOM; html(content?: string): string | DOM { if (arguments.length > 0) { return this.setHtml(content); } else { return this.getHtml(); } } private getHtml(): string | null { return this.elements[0].innerHTML; } private setHtml(content: string): DOM { if (content === undefined || content == null) { // Set to empty string to avoid innerHTML getting set to 'undefined' (all browsers) or 'null' (IE9) content = ''; } this.forEach(element => { element.innerHTML = content; }); return this; } /** * Clears the inner HTML of all elements (deletes all children). * @returns {DOM} */ empty(): DOM { this.forEach(element => { element.innerHTML = ''; }); return this; } /** * Returns the current value of the first form element, e.g. the selected value of a select box or the text if an * input field. * @returns {string} the value of a form element */ val(): string { const element = this.elements[0]; if (element instanceof HTMLSelectElement || element instanceof HTMLInputElement) { return element.value; } else { // TODO add support for missing form elements throw new Error(`val() not supported for ${typeof element}`); } } /** * Returns the value of an attribute on the first element. * @param attribute */ attr(attribute: string): string | null; /** * Sets an attribute on all elements. * @param attribute the name of the attribute * @param value the value of the attribute */ attr(attribute: string, value: string): DOM; attr(attribute: string, value?: string): string | null | DOM { if (arguments.length > 1) { return this.setAttr(attribute, value); } else { return this.getAttr(attribute); } } /** * Removes the attribute of the element. * @param attribute */ removeAttr(attribute: string) { this.forEach(element => { element.removeAttribute(attribute); }); } private getAttr(attribute: string): string | null { return this.elements[0].getAttribute(attribute); } private setAttr(attribute: string, value: string): DOM { this.forEach(element => { element.setAttribute(attribute, value); }); return this; } /** * Returns the value of a data element on the first element. * @param dataAttribute the name of the data attribute without the 'data-' prefix */ data(dataAttribute: string): string | null; /** * Sets a data attribute on all elements. * @param dataAttribute the name of the data attribute without the 'data-' prefix * @param value the value of the data attribute */ data(dataAttribute: string, value: string): DOM; data(dataAttribute: string, value?: string): string | null | DOM { if (arguments.length > 1) { return this.setData(dataAttribute, value); } else { return this.getData(dataAttribute); } } private getData(dataAttribute: string): string | null { return this.elements[0].getAttribute('data-' + dataAttribute); } private setData(dataAttribute: string, value: string): DOM { this.forEach(element => { element.setAttribute('data-' + dataAttribute, value); }); return this; } /** * Appends one or more DOM elements as children to all elements. * @param childElements the child elements to append * @returns {DOM} */ append(...childElements: DOM[]): DOM { const appendElements = (node: Node) => { childElements.forEach(childElement => { childElement.elements.forEach((_, index) => { node.appendChild(childElement.elements[index]); }); }); }; if (this.elements) { this.forEach(element => appendElements(element)); } else { appendElements(this.documentOrShadowRoot); } return this; } /** * Prepends one or more DOM elements as children to all elements. * @param childElements the child elements to prepend * @returns {DOM} */ prepend(...childElements: DOM[]): DOM { const insertElements = (node: Node) => { childElements.forEach(childElement => { childElement.elements.forEach((_, index) => { node.insertBefore(childElement.elements[index], node.firstChild); }); }); }; if (this.elements) { this.forEach(element => insertElements(element)); } else { insertElements(this.documentOrShadowRoot); } return this; } /** * Removes all elements from the DOM. */ remove(): void { const removeElements = (node: Node) => { const parent = node.parentNode; if (parent) { parent.removeChild(node); } }; if (this.elements) { this.forEach(element => removeElements(element)); } else { removeElements(this.documentOrShadowRoot); } } /** * Returns the offset of the first element from the document's top left corner. * @returns {Offset} */ offset(): Offset { const element = this.elements[0]; const elementRect = element.getBoundingClientRect(); const htmlRect = document.body.parentElement.getBoundingClientRect(); // Virtual viewport scroll handling (e.g. pinch zoomed viewports in mobile browsers or desktop Chrome/Edge) // 'normal' zooms and virtual viewport zooms (aka layout viewport) result in different // element.getBoundingClientRect() results: // - with normal scrolls, the clientRect decreases with an increase in scroll(Top|Left)/page(X|Y)Offset // - with pinch zoom scrolls, the clientRect stays the same while scroll/pageOffset changes // This means, that the combination of clientRect + scroll/pageOffset does not work to calculate the offset // from the document's upper left origin when pinch zoom is used. // To work around this issue, we do not use scroll/pageOffset but get the clientRect of the html element and // subtract it from the element's rect, which always results in the offset from the document origin. // NOTE: the current way of offset calculation was implemented specifically to track event positions on the // seek bar, and it might break compatibility with jQuery's offset() method. If this ever turns out to be a // problem, this method should be reverted to the old version and the offset calculation moved to the seek bar. return { top: elementRect.top - htmlRect.top, left: elementRect.left - htmlRect.left, }; } /** * Returns the width of the first element. * @returns {number} the width of the first element */ width(): number { // TODO check if this is the same as jQuery's width() (probably not) return this.elements[0].offsetWidth; } /** * Returns the height of the first element. * @returns {number} the height of the first element */ height(): number { // TODO check if this is the same as jQuery's height() (probably not) return this.elements[0].offsetHeight; } /** * Returns the size of the first element. * @return {Size} the size of the first element */ size(): Size { return { width: this.width(), height: this.height() }; } /** * Attaches an event handler to one or more events on all elements. * @param eventName the event name (or multiple names separated by space) to listen to * @param eventHandler the event handler to call when the event fires * @param options the options for this event handler * @returns {DOM} */ on( eventName: string, eventHandler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, ): DOM { const events = eventName.split(' '); events.forEach(event => { if (this.elements == null) { this.documentOrShadowRoot.addEventListener(event, eventHandler, options); } else { this.forEach(element => { element.addEventListener(event, eventHandler, options); }); } }); return this; } /** * Removes an event handler from one or more events on all elements. * @param eventName the event name (or multiple names separated by space) to remove the handler from * @param eventHandler the event handler to remove * @param options the options for this event handler * @returns {DOM} */ off( eventName: string, eventHandler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, ): DOM { const events = eventName.split(' '); events.forEach(event => { if (this.elements == null) { this.documentOrShadowRoot.removeEventListener(event, eventHandler, options); } else { this.forEach(element => { element.removeEventListener(event, eventHandler, options); }); } }); return this; } /** * Adds the specified class(es) to all elements. * @param className the class(es) to add, multiple classes separated by space * @returns {DOM} */ addClass(className: string): DOM { this.forEach(element => { if (element.classList) { const classNames = className.split(' ').filter(className => className.length > 0); if (classNames.length > 0) { element.classList.add(...classNames); } } else { element.className += ' ' + className; } }); return this; } /** * Removed the specified class(es) from all elements. * @param className the class(es) to remove, multiple classes separated by space * @returns {DOM} */ removeClass(className: string): DOM { this.forEach(element => { if (element.classList) { const classNames = className.split(' ').filter(className => className.length > 0); if (classNames.length > 0) { element.classList.remove(...classNames); } } else { element.className = element.className.replace( new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' ', ); } }); return this; } /** * Checks if any of the elements has the specified class. * @param className the class name to check * @returns {boolean} true if one of the elements has the class attached, else if no element has it attached */ hasClass(className: string): boolean { let hasClass = false; this.forEach(element => { if (element.classList) { if (element.classList.contains(className)) { // Since we are inside a handler, we can't just 'return true'. Instead, we save it to a variable // and return it at the end of the function body. hasClass = true; } } else { if (new RegExp('(^| )' + className + '( |$)', 'gi').test(element.className)) { // See comment above hasClass = true; } } }); return hasClass; } /** * Returns the value of a CSS property of the first element. * @param propertyName the name of the CSS property to retrieve the value of */ css(propertyName: string): string | null; /** * Sets the value of a CSS property on all elements. * @param propertyName the name of the CSS property to set the value for * @param value the value to set for the given CSS property */ css(propertyName: string, value: string): DOM; /** * Sets a collection of CSS properties and their values on all elements. * @param propertyValueCollection an object containing pairs of property names and their values */ css(propertyValueCollection: CssProperties): DOM; css(propertyNameOrCollection: string | CssProperties, value?: string): string | null | DOM { if (typeof propertyNameOrCollection === 'string') { const propertyName = propertyNameOrCollection; if (arguments.length === 2) { return this.setCss(propertyName, value); } else { return this.getCss(propertyName); } } else { const propertyValueCollection = propertyNameOrCollection; return this.setCssCollection(propertyValueCollection); } } /** * Removes an inline CSS property if it exists * @param propertyName name of the property to remove * @param elementIndex index of the element whose CSS property should get removed */ removeCss(propertyName: string, elementIndex = 0): string { return this.elements[elementIndex].style.removeProperty(propertyName); } private getCss(propertyName: string): string | null { return getComputedStyle(this.elements[0])[<any>propertyName]; } private setCss(propertyName: string, value: string): DOM { this.forEach(element => { // <any> cast to resolve TS7015: http://stackoverflow.com/a/36627114/370252 element.style[<any>propertyName] = value; }); return this; } private setCssCollection(ruleValueCollection: { [ruleName: string]: string }): DOM { this.forEach(element => { // http://stackoverflow.com/a/34490573/370252 Object.assign(element.style, ruleValueCollection); }); return this; } }