Source: devices/browserdevice.js

/**
 * @fileOverview Requirejs module containing base antie.devices.browserdevice class.
 * @preserve Copyright (c) 2013-present British Broadcasting Corporation. All rights reserved.
 * @license See https://github.com/fmtvp/tal/blob/master/LICENSE for full licence
 */

define(
    'antie/devices/browserdevice',
    [
        'antie/devices/device',
        'antie/events/keyevent',
        'antie/historian',
        'antie/devices/sanitiser'
    ],
    function(Device, KeyEvent, Historian, Sanitiser) {
        'use strict';

        function trim(str) {
            return str.replace(/^\s+/, '').replace(/\s+$/, '');
        }

        /**
         * Base class for Antie browser-based devices.
         * @name antie.devices.BrowserDevice
         * @class
         * @extends antie.devices.Device
         * @requires antie.events.KeyEvent
         * @param {Object} config Device configuration document.
         */
        return Device.extend(/** @lends antie.devices.BrowserDevice.prototype */ {
            /**
             * @constructor
             * @ignore
             */
            init: function init (config) {
                init.base.call(this, config);
                this._textSizeCache = {};

                this.addClassToElement(this.getTopLevelElement(), 'notanimating');
            },
            /**
             * Creates an element in the device's user-agent.
             * @private
             * @param {String} tagName The tag name of the element to create.
             * @param {String} [id] The id of the element to create.
             * @param {Array} [classNames] An array of class names to apply to the element.
             */
            _createElement: function _createElement (tagName, id, classNames) {
                var el = document.createElement(tagName);

                // don't add auto-generated IDs to the DOM
                if (id && (id.substring(0, 1) !== '#')) {
                    el.id = id;
                }
                if (classNames && (classNames.length > 0)) {
                    el.className = classNames.join(' ');
                }
                return el;
            },
            /**
             * Creates a generic container element in the device's user-agent.
             * @param {String} [id] The id of the element to create.
             * @param {Array} [classNames] An array of class names to apply to the element.
             * @returns A container element within the device's user-agent.
             */
            createContainer: function createContainer (id, classNames) {
                return this._createElement('div', id, classNames);
            },
            /**
             * Creates a label (an element that only contains text) in the device's user-agent.
             * @param {String} [id] The id of the element to create.
             * @param {Array} [classNames] An array of class names to apply to the element.
             * @param {String} [text] The text within the label.
             * @returns A label within the device's user-agent.
             */
            createLabel: function createLabel (id, classNames, text, enableHTML) {
                var el = this._createElement('span', id, classNames);
                this.setElementContent(el, text, enableHTML);
                return el;
            },
            /**
             * CreatesetElementContent a button (an element that can be selected by the user to perform an action) in the device's user-agent.
             * @param {String} [id] The id of the element to create.
             * @param {Array} [classNames] An array of class names to apply to the element.
             * @returns A button within the device's user-agent.
             */
            createButton: function createButton (id, classNames) {
                return this._createElement('div', id, classNames);
            },
            /**
             * Creates a list in the device's user-agent.
             * @param {String} [id] The id of the element to create.
             * @param {Array} [classNames] An array of class names to apply to the element.
             * @returns A list within the device's user-agent.
             */
            createList: function createList (id, classNames) {
                return this._createElement('ul', id, classNames);
            },
            /**
             * Creates a list item in the device's user-agent.
             * @param {String} [id] The id of the element to create.
             * @param {Array} [classNames] An array of class names to apply to the element.
             * @returns A list item within the device's user-agent.
             */
            createListItem: function createListItem (id, classNames) {
                return this._createElement('li', id, classNames);
            },
            /**
             * Creates an image in the device's user-agent.
             * @param {String} [id] The id of the element to create.
             * @param {Array} [classNames] An array of class names to apply to the element.
             * @param {String} src The source URL of the image.
             * @param {Size} [size] The size of the image.
             * @returns An image within the device's user-agent.
             */
            createImage: function createImage (id, classNames, src, size, onLoad, onError) {
                var el = this._createElement('img', id, classNames);
                el.src = src;
                el.alt = '';
                if (size) {
                    this.setElementSize(el, size);
                }
                if (onLoad !== undefined) {
                    el.onload = onLoad;
                }
                if (onError !== undefined) {
                    el.onerror = onError;
                }
                return el;
            },
            /**
             * Loads an external style sheet.
             * @param {String} url The URL of the style sheet.
             * @param {function(String)} [callback] Callback function when style has loaded/failed
             * @returns The link element that will load the style sheet.
             */
            loadStyleSheet: function loadStyleSheet (url, callback) {
                var self = this;
                function supportsCssRules() {
                    var style = self._createElement('style');
                    style.type = 'text/css';
                    style.innerHTML = 'body {};';
                    style.className = 'added-by-antie';
                    document.getElementsByTagName('head')[0].appendChild(style);
                    try {
                        style.sheet.cssRules;
                        return true;
                    } catch(e) {
                    } finally {
                        style.parentNode.removeChild(style);
                    }
                    return false;
                }

                if (callback && supportsCssRules()) {
                    var style = this._createElement('style');
                    style.type = 'text/css';
                    style.innerHTML = '@import url(\'' + url + '\');';
                    style.className = 'added-by-antie';
                    document.getElementsByTagName('head')[0].appendChild(style);

                    var interval = window.setInterval(function() {
                        try {
                            style.sheet.cssRules;
                            window.clearInterval(interval);
                        } catch(ex) {
                            return;
                        }
                        callback(url);
                    }, 200);
                } else {
                    var link = this._createElement('link');
                    link.type = 'text/css';
                    link.rel = 'stylesheet';
                    link.href = url;
                    link.className = 'added-by-antie';
                    document.getElementsByTagName('head')[0].appendChild(link);

                    // Onload trickery from:
                    // http://www.backalleycoder.com/2011/03/20/link-tag-css-stylesheet-load-event/
                    if (callback) {
                        var img = this._createElement('img');
                        var done = function() {
                            img.onerror = function() {};
                            callback(url);
                            img.parentNode.removeChild(img);
                        };
                        img.onerror = done;
                        this.getTopLevelElement().appendChild(img);
                        img.src = url;
                    }
                }
                return style;
            },
            /**
             * Appends an element as a child of another.
             * @param {Element} to Append as a child of this element.
             * @param {Element} el The new child element.
             */
            appendChildElement: function appendChildElement (to, el) {
                to.appendChild(el);
            },
            /**
             * Prepends an element as a child of another.
             * @param {Element} to Prepend as a child of this element.
             * @param {Element} el The new child element.
             */
            prependChildElement: function prependChildElement (to, el) {
                if (to.childNodes.length > 0) {
                    to.insertBefore(el, to.childNodes[0]);
                } else {
                    to.appendChild(el);
                }
            },
            /**
             * Inserts an element as a child of another before a reference element.
             * @param {Element} to Append as a child of this element.
             * @param {Element} el The new child element.
             * @param {Element} ref The reference element which will appear after the inserted element.
             */
            insertChildElementBefore: function insertChildElementBefore (to, el, ref) {
                to.insertBefore(el, ref);
            },
            /**
             * Inserts an element as a child of another at the given index.
             * @param {Element} to Append as a child of this element.
             * @param {Element} el The new child element.
             * @param {Integer} index The index at which the element will be inserted.
             */
            insertChildElementAt: function insertChildElementAt (to, el, index) {
                if (index >= to.childNodes.length) {
                    to.appendChild(el);
                } else {
                    to.insertBefore(el, to.childNodes[index]);
                }
            },
            /**
             * Gets the parent element of a given element.
             * @param {Element} el The element.
             * @returns The parent element.
             */
            getElementParent: function getElementParent (el) {
                return el.parentNode;
            },
            /**
             * Removes an element from its parent.
             * @param {Element} el The element to remove.
             */
            removeElement: function removeElement (el) {
                if (el.parentNode) {
                    el.parentNode.removeChild(el);
                }
            },
            /**
             * Clears the content of an element.
             * @param {Element} el The element you are removing the content from.
             */
            clearElement: function clearElement (el) {
                for (var i = el.childNodes.length - 1; i >= 0; i--) {
                    el.removeChild(el.childNodes[i]);
                }
            },
            /**
             * Sets the classes of an element.
             * @param {Element} el The element which will receive new class names.
             * @param {Array} classNames An array of class names.
             */
            setElementClasses: function setElementClasses (el, classNames) {
                el.className = classNames.join(' ');
            },
            /**
             * Removes a class from an element (and optionally descendants)
             * @param {Element} el The element from which to remove the class.
             * @param {String} className The class to remove.
             * @param {Boolean} [deep] If true, and this element has the given class, remove the class from it's children recursively.
             */
            removeClassFromElement: function removeClassFromElement (el, className, deep) {
                if (new RegExp(' ' + className + ' ').test(' ' + el.className + ' ')) {
                    el.className = trim((' ' + el.className + ' ').replace(' ' + className + ' ', ' '));
                }
                if (deep) {
                    for (var i = 0; i < el.childNodes.length; i++) {
                        this.removeClassFromElement(el.childNodes[i], className, true);
                    }
                }
            },
            /**
             * Adds a class name to an element
             * @param {Element} el The element which will receive new class name.
             * @param {String} className The new class name to add.
             */
            addClassToElement: function addClassToElement (el, className) {
                this.removeClassFromElement(el, className, false);
                el.className = trim(el.className + ' ' + className);
            },
            /**
             * Adds global key event listener(s) to the user-agent.
             * This must be added in a way that all key events within the user-agent
             * cause self._application.bubbleEvent(...) to be called with a {@link KeyEvent}
             * object with the mapped keyCode.
             *
             * @example
             * document.onkeydown = function(e) {
             *     self._application.bubbleEvent(new KeyEvent('keydown', keyMap[e.keyCode]));
             * };
             */
            addKeyEventListener: function addKeyEventListener () {
                var self = this;
                var _keyMap = this.getKeyMap();
                var _pressed = {};

                // We need to normalise these events on so that for every key pressed there's
                // one keydown event, followed by multiple keypress events whilst the key is
                // held down, followed by a single keyup event.

                document.onkeydown = function(e) {
                    e = e || window.event;
                    var _keyCode = _keyMap[e.keyCode.toString()];
                    if (_keyCode) {
                        if (!_pressed[e.keyCode.toString()]) {
                            self._application.bubbleEvent(new KeyEvent('keydown', _keyCode));
                            _pressed[e.keyCode.toString()] = true;
                        } else {
                            self._application.bubbleEvent(new KeyEvent('keypress', _keyCode));
                        }
                        e.preventDefault();
                    }
                };
                document.onkeyup = function(e) {
                    e = e || window.event;
                    var _keyCode = _keyMap[e.keyCode.toString()];
                    if (_keyCode) {
                        delete _pressed[e.keyCode.toString()];
                        self._application.bubbleEvent(new KeyEvent('keyup', _keyCode));
                        e.preventDefault();
                    }
                };
                document.onkeypress = function(e) {
                    e = e || window.event;
                    var _keyCode = _keyMap[e.keyCode.toString()];
                    if (_keyCode) {
                        self._application.bubbleEvent(new KeyEvent('keypress', _keyCode));
                        e.preventDefault();
                    }
                };
            },
            /**
             * Gets the size of an element.
             * @param {Element} el The element of which to return the size.
             * @returns A size object containing the width and height of the element.
             */
            getElementSize: function getElementSize (el) {
                return {
                    width: el.clientWidth || el.offsetWidth,
                    height: el.clientHeight || el.offsetHeight
                };
            },
            /**
             * Sets the size of an element.
             * @param {Element} el The element of which to set the size.
             * @param {Size} size The new size of the element.
             */
            setElementSize: function setElementSize (el, size) {
                if (size.width !== undefined) {
                    el.style.width = size.width + 'px';
                }
                if (size.height !== undefined) {
                    el.style.height = size.height + 'px';
                }
            },
            /**
             * Sets the position of an element
             * @param {Element} el The element of which to reposition.
             * @param {Size} size The new position of the element.
             */
            setElementPosition: function setElementPosition (el, pos) {
                if (pos.top !== undefined) {
                    el.style.top = pos.top + 'px';
                }
                if (pos.left !== undefined) {
                    el.style.left = pos.left + 'px';
                }
            },
            /**
             * Sets the inner content of an element.
             * @param {Element} el The element of which to change the content.
             * @param {String} content The new content for the element.
             */
            setElementContent: function setElementContent (el, content, enableHTML) {
                if (content === '') {
                    this.clearElement(el);
                    return;
                }

                var sanitiser = new Sanitiser(content);
                sanitiser.setElementContent(el, enableHTML);
            },
            /**
             * Clones an element.
             * @param {Element} el The element to clone.
             * @param {Boolean} [deep] If true, children are also cloned recursively.
             * @param {String} [appendClass] Append this class name to the clone (top level only).
             * @param {String} [appendID] Append this string to the ID of the clone (top level only).
             * @returns The clone.
             */
            cloneElement: function cloneElement (el, deep, appendClass, appendID) {
                var clone = el.cloneNode(deep);
                if (appendClass) {
                    clone.className += ' ' + appendClass;
                }
                if (appendID && el.id) {
                    clone.id = el.id + appendID;
                }
                return clone;
            },
            /**
             * Get the height (in pixels) of a given block of text (of a provided set of class names) when constrained to a fixed width.
             *
             * @deprecated This function does not always give accurate results. When measuring size, it only takes into account
             * the classes on the text element being measured. It doesn't consider any CSS styles that may have been passed down
             * through the DOM.
             *
             * @param {String} text The text to measure.
             * @param {Integer} maxWidth The width the text is constrained to.
             * @param {Array} classNames An array of class names which define the style of the text.
             * @returns The height (in pixels) that is required to display this block of text.
             */
            getTextHeight: function getTextHeight (text, maxWidth, classNames) {
                /// TODO: is there a more efficient way of doing this?
                var cacheKey = maxWidth + ':' + classNames.join(' ') + ':' + text;
                var height;
                if (!(height = this._textSizeCache[cacheKey])) {
                    if (!this._measureTextElement) {
                        this._measureTextElement = this.createLabel('measure', null, 'fW');
                        this._measureTextElement.style.display = 'block';
                        this._measureTextElement.style.position = 'absolute';
                        this._measureTextElement.style.top = '-10000px';
                        this._measureTextElement.style.left = '-10000px';
                        this.appendChildElement(document.body, this._measureTextElement);
                    }
                    this._measureTextElement.className = classNames.join(' ');
                    this._measureTextElement.style.width = (typeof maxWidth === 'number') ? maxWidth + 'px' : maxWidth;
                    this._measureTextElement.innerHTML = text;

                    height = this._textSizeCache[cacheKey] = this._measureTextElement.clientHeight;
                }
                return height;
            },
            /**
             * Returns all direct children of an element which have the provided tagName.
             * @param {Element} el The element who's children you wish to search.
             * @param {String} tagName The tag name you are looking for.
             * @returns An array of elements having the provided tag name.
             */
            getChildElementsByTagName: function getChildElementsByTagName (el, tagName) {
                var children = [];
                tagName = tagName.toLowerCase();
                for (var i = 0; i < el.childNodes.length; i++) {
                    if(el.childNodes[i].tagName){
                        if (el.childNodes[i].tagName.toLowerCase() === tagName) {
                            children.push(el.childNodes[i]);
                        }
                    }
                }
                return children;
            },
            /**
             * Returns the top-level element. This is the target of layout class names.
             * @return The top-level DOM element.
             */
            getTopLevelElement: function getTopLevelElement () {
                return document.documentElement || document.body.parentNode || document;
            },
            /**
             * Returns all the loaded stylesheet elements.
             * @return An array containing all stylesheet related DOM elements (link and style elements)
             */
            getStylesheetElements: function getStylesheetElements () {
                var stylesheetElements = [];

                var linkElements = document.getElementsByTagName('link');
                var styleElements = document.getElementsByTagName('style');

                // Loop over the node lists and push the dom elements into an array
                for (var i = 0; i < linkElements.length; i++) {
                    stylesheetElements.push(linkElements[i]);
                }

                for (var j = 0; j < styleElements.length; j++) {
                    stylesheetElements.push(styleElements[j]);
                }

                return stylesheetElements;
            },
            /**
             * Returns the offset of the element within its offset container.
             * @param {Element} el The element you wish to know the offset of.
             * @return An literal object containing properties, top and left.
             */
            getElementOffset: function getElementOffset (el) {
                var offsets;
                //                if (el && el.getBoundingClientRect && el.parentNode) {
                //                    var rect = el.getBoundingClientRect();
                //                    var parentRect = el.parentNode.getBoundingClientRect();
                //                    offsets = {
                //                        top: rect.top - parentRect.top,
                //                        left: rect.left - parentRect.left
                //                    };
                //                } else {
                offsets = {
                    top: el.offsetTop,
                    left: el.offsetLeft
                };
                //                }
                return offsets;
            },
            /**
             * Gets the available browser screen size.
             * @returns An object with width and height properties.
             */
            getScreenSize: function getScreenSize () {
                var w, h;
                if (typeof(window.innerWidth) === 'number') {
                    w = window.innerWidth;
                    h = window.innerHeight;
                } else {
                    var d = document.documentElement || document.body;
                    h = d.clientHeight || d.offsetHeight;
                    w = d.clientWidth || d.offsetWidth;
                }
                return {
                    width: w,
                    height: h
                };
            },
            /**
             * Sets the current route (a reference pointing to a location within the application).
             * @param {Array} route A route pointing to a location within the application.
             */
            setCurrentRoute: function setCurrentRoute (route) {
                var history = this.getHistorian().toString();

                if (route.length > 0) {
                    window.location.hash = '#' + route.join('/') + history;
                } else {
                    window.location.hash = (history === '') ? '' : '#' + history;
                }
            },
            /**
             * Gets the current route (a reference pointing to a location within the application).
             * @returns The current route (location within the application).
             */
            getCurrentRoute: function getCurrentRoute () {
                var unescaped = unescape(window.location.hash).split(Historian.HISTORY_TOKEN, 1)[0];
                return (unescaped.replace(/^#/, '').split('/'));
            },

            /**
             * gets historian for current location
             * @returns {antie.Historian} an object that can be used to get a back or forward url between applications while preserving history
             */
            getHistorian: function getHistorian () {
                return new Historian(decodeURI(this.getWindowLocation().href));
            },

            /**
             * Get an object giving access to the current URL, query string, hash etc.
             * @returns {Object} Object containing, at a minimum, the properties:
             * hash, host, href, pathname, protocol, search. These correspond to the properties
             * in the window.location DOM API.
             * Use getCurrentAppURL(), getCurrentAppURLParams() and getCurrentRoute() to get
             * this information in a more generic way.
             */
            getWindowLocation: function getWindowLocation () {
                var windowLocation, copyProps, prop, i, newLocation;
                windowLocation = this._windowLocation || window.location; // Allow stubbing for unit testing

                // Has the device missed the route off the href? Fix this.
                if (windowLocation.hash && windowLocation.hash.length > 1 && windowLocation.href && windowLocation.href.lastIndexOf('#') === -1) {
                    // Copy properties to new object, as modifying href on the original window.location triggers a navigation.
                    newLocation = {};
                    copyProps = ['assign', 'hash', 'host', 'href', 'pathname', 'protocol', 'search'];
                    for (i = 0; i < copyProps.length; i++) {
                        prop = copyProps[i];
                        if (windowLocation.hasOwnProperty(prop)) {
                            newLocation[prop] = windowLocation[prop];
                        }
                    }
                    newLocation.href = newLocation.href + newLocation.hash;
                }

                // Use copy of window.location if it was created, otherwise the original.
                return newLocation || windowLocation;
            },
            /**
             * Browse to the specified location. Use launchAppFromURL() and setCurrentRoute() under Application
             * to manipulate the current location more easily.
             * @param {String} url Full URL to navigate to, including search and hash if applicable.
             */
            setWindowLocationUrl: function setWindowLocationUrl (url) {
                var windowLocation = this._windowLocation || window.location; // Allow stubbing for unit testing

                // Prefer assign(), but some devices don't have this function.
                if (typeof windowLocation.assign === 'function') {
                    windowLocation.assign(url);
                } else {
                    windowLocation.href = url;
                }
            },
            /**
             * Gets the reference (e.g. URL) of the resource that launched the application.
             * @returns A reference (e.g. URL) of the resource that launched the application.
             */
            getReferrer: function getReferrer () {
                return document.referrer;
            },
            /**
             * Forces the device to pre-load an image.
             * @param {String} url The URL of the image to preload.
             */
            preloadImage: function preloadImage (url) {
                var img = new Image();
                img.src = url;
            },
            /**
             * Checks to see if HD output is currently enabled.
             * @returns True if HD is currently enabled.
             */
            isHDEnabled: function isHDEnabled () {
                return true;
            }
        });
    }
);