Home Reference Source

lib/DomObserver.js

/*!
 * The MIT License (MIT)
 *
 * Copyright (c) 2016 Mykhailo Stadnyk <[email protected]>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
/**
 * @typedef {{ constructor: function(options: GenericOptions): GaugeInterface, draw: function(): GaugeInterface, destroy: function, update: function(options: GenericOptions) }} GaugeInterface
 */
/**
 * @typedef {{parse: function, stringify: function}} JSON
 * @external {JSON} https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON
 */
/**
 * @ignore
 * @typedef {{MutationObserver: function}} ns
 */

/**
 * DOM Observer.
 * It will observe DOM document for a configured element types and
 * instantiate associated Types for an existing or newly added DOM elements
 *
 * @example
 * class ProgressBar {
 *     constructor(options) {}
 *     draw() {}
 * }
 *
 * // It will observe DOM document for elements <div>
 * // having attribute 'data-type="progress"'
 * // and instantiate for each new instance of ProgressBar
 *
 * new DomParser({color: 'red'}, 'div', 'progress', ProgressBar);
 *
 * // assume we could have HTML like this
 * // <div data-type="progress" color="blue"></div>
 * // in this case all matching attributes names for a given options will be
 * // parsed and bypassed to an instance from HTML attributes
 */
export default class DomObserver {

    /**
     * @constructor
     * @param {object} options
     * @param {string} element
     * @param {string} type
     */
    constructor(options, element, type) {
        //noinspection JSUnresolvedVariable
        /**
         * Default instantiation options for the given type
         *
         * @type {Object}
         */
        this.options = options;

        /**
         * Name of an element to lookup/observe
         *
         * @type {string}
         */
        this.element = element.toLowerCase();

        /**
         * data-type attribute value to lookup
         *
         * @type {string}
         */
        this.type = DomObserver.toDashed(type);

        /**
         * Actual type constructor to instantiate for each found element
         *
         * @type {Function}
         */
        this.Type = ns[type];

        /**
         * Signals if mutations observer for this type or not
         *
         * @type {boolean}
         */
        this.mutationsObserved = false;

        /**
         * Flag specifies whenever the browser supports observing
         * of DOM tree mutations or not
         *
         * @type {boolean}
         */
        this.isObservable = !!window.MutationObserver;

        /* istanbul ignore next: this should be tested with end-to-end tests */
        if (!window.GAUGES_NO_AUTO_INIT) {
            DomObserver.domReady(this.traverse.bind(this));
        }
    }

    /**
     * Checks if given node is valid node to process
     *
     * @param {Node|HTMLElement} node
     * @returns {boolean}
     */
    isValidNode(node) {
        //noinspection JSUnresolvedVariable
        return !!(
            node.tagName &&
            node.tagName.toLowerCase() === this.element &&
            node.getAttribute('data-type') === this.type
        );
    }

    /**
     * Traverse entire current DOM tree and process matching nodes.
     * Usually it should be called only once on document initialization.
     */
    traverse() {
        let elements = document.getElementsByTagName(this.element);
        let i = 0, s = elements.length;

        /* istanbul ignore next: this should be tested with end-to-end tests */
        for (; i < s; i++) {
            this.process(elements[i]);
        }

        if (this.isObservable && !this.mutationsObserved) {
            new MutationObserver(this.observe.bind(this))
                .observe(document.body, {
                    childList: true,
                    subtree: true,
                    attributes: true,
                    characterData: true,
                    attributeOldValue: true,
                    characterDataOldValue: true
                });

            this.mutationsObserved = true;
        }
    }

    /**
     * Observes given mutation records for an elements to process
     *
     * @param {MutationRecord[]} records
     */
    observe(records) {
        let i = 0;
        let s = records.length;

        /* istanbul ignore next: this should be tested with end-to-end tests */
        for (; i < s; i++) {
            let record = records[i];

            if (record.type === 'attributes' &&
                record.attributeName === 'data-type' &&
                this.isValidNode(record.target) &&
                record.oldValue !== this.type) // skip false-positive mutations
            {
                setTimeout(this.process.bind(this, record.target));
            }

            else if (record.addedNodes && record.addedNodes.length) {
                let ii = 0;
                let ss = record.addedNodes.length;

                for (; ii < ss; ii++) {
                    setTimeout(this.process.bind(this, record.addedNodes[ii]));
                }
            }
        }
    }

    /**
     * Parses given attribute value to a proper JavaScript value.
     * For example it will parse some stringified value to a proper type
     * value, e.g. 'true' => true, 'null' => null, '{"prop": 20}' => {prop: 20}
     *
     * @param {*} value
     * @return {*}
     */
    static parse(value) {
        // parse boolean
        if (value === 'true') return true;
        if (value === 'false') return false;

        // parse undefined
        if (value === 'undefined') return undefined;

        // parse null
        if (value === 'null') return null;

        // Comma-separated strings to array parsing.
        // It won't match strings which contains non alphanumeric characters to
        // prevent strings like 'rgba(0,0,0,0)' or JSON-like from being parsed.
        // Typically it simply allows easily declare arrays as comma-separated
        // numbers or plain strings. If something more complicated is
        // required it can be declared using JSON format syntax
        if (/^[-+#.\w\d\s]+(?:,[-+#.\w\d\s]*)+$/.test(value)) {
            return value.split(',');
        }

        // parse JSON
        try { return JSON.parse(value); } catch(e) {}

        // plain value - no need to parse
        return value;
    }

    /**
     * Processes a given node, instantiating a proper type constructor for it
     *
     * @param {Node|HTMLElement} node
     * @returns {GaugeInterface|null}
     */
    process(node) {
        if (!this.isValidNode(node)) return null;

        let prop;
        let options = JSON.parse(JSON.stringify(this.options));
        let instance = null;

        for (prop in options) {
            /* istanbul ignore else: non-testable in most cases */
            if (options.hasOwnProperty(prop)) {
                let attributeName = DomObserver.toAttributeName(prop);
                let attributeValue = DomObserver.parse(
                    node.getAttribute(attributeName));

                if (attributeValue !== null && attributeValue !== undefined) {
                    options[prop] = attributeValue;
                }
            }
        }

        options.renderTo = node;
        instance = new (this.Type)(options);
        instance.draw && instance.draw();

        if (!this.isObservable) return instance;

        instance.observer = new MutationObserver(records => {
            records.forEach(record => {
                if (record.type === 'attributes') {
                    let attr = record.attributeName.toLowerCase();
                    let type = node.getAttribute(attr).toLowerCase();

                    if (attr === 'data-type' && type && type !== this.type) {
                        instance.observer.disconnect();
                        delete instance.observer;
                        instance.destroy && instance.destroy();
                    }

                    else if (attr.substr(0, 5) === 'data-') {
                        let prop = attr.substr(5).split('-').map((part, i) => {
                            return !i ? part :
                            part.charAt(0).toUpperCase() + part.substr(1);
                        }).join('');
                        let options = {};

                        options[prop] = DomObserver.parse(
                            node.getAttribute(record.attributeName));


                        if (prop === 'value') {
                            instance && (instance.value = options[prop]);
                        }

                        else {
                            instance.update && instance.update(options);
                        }
                    }
                }
            });
        });

        //noinspection JSCheckFunctionSignatures
        instance.observer.observe(node, { attributes: true });

        return instance;
    }

    /**
     * Transforms camelCase string to dashed string
     *
     * @static
     * @param {string} camelCase
     * @return {string}
     */
    static toDashed(camelCase) {
        let arr = camelCase.split(/(?=[A-Z])/);
        let i = 1;
        let s = arr.length;
        let str = arr[0].toLowerCase();

        for (; i < s; i++) {
            str += '-' + arr[i].toLowerCase();
        }

        return str;
    }

    /**
     * Transforms dashed string to CamelCase representation
     *
     * @param {string} dashed
     * @param {boolean} [capitalized]
     * @return {string}
     */
    static toCamelCase(dashed, capitalized = true) {
        let arr = dashed.split(/-/);
        let i = 0;
        let s = arr.length;
        let str = '';

        for (; i < s; i++) {
            if (!(i || capitalized)) {
                str += arr[i].toLowerCase();
            }

            else {
                str += arr[i][0].toUpperCase() + arr[i].substr(1).toLowerCase();
            }
        }

        return str;
    }

    /**
     * Transforms camel case property name to dash separated attribute name
     *
     * @static
     * @param {string} str
     * @returns {string}
     */
    static toAttributeName(str) {
        return 'data-' + DomObserver.toDashed(str);
    }

    /**
     * Cross-browser DOM ready handler
     *
     * @static
     * @param {Function} handler
     */
    static domReady(handler) {
        if (/comp|inter|loaded/.test((window.document || {}).readyState + ''))
            return handler();

        if (window.addEventListener)
            window.addEventListener('DOMContentLoaded', handler, false);

        else if (window.attachEvent)
            window.attachEvent('onload', handler);
    }

}

module.exports = DomObserver;