adobe/brackets

View on GitHub
src/LiveDevelopment/MultiBrowserImpl/protocol/remote/DocumentObserver.js

Summary

Maintainability
C
1 day
Test Coverage
/*
 * Copyright (c) 2014 - present Adobe Systems Incorporated. All rights reserved.
 *
 * 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.
 *
 */

/*global setInterval, clearInterval */

(function (global) {
    "use strict";

    var ProtocolManager = global._Brackets_LiveDev_ProtocolManager;

    var _document = null;
    var _transport;


    /**
     * Retrieves related documents (external CSS and JS files)
     *
     * @return {{scripts: object, stylesheets: object}} Related scripts and stylesheets
     */
    function related() {

        var rel = {
            scripts: {},
            stylesheets: {}
        };
        var i;
        // iterate on document scripts (HTMLCollection doesn't provide forEach iterator).
        for (i = 0; i < _document.scripts.length; i++) {
            // add only external scripts
            if (_document.scripts[i].src) {
                rel.scripts[_document.scripts[i].src] = true;
            }
        }

        var s, j;
        //traverse @import rules
        var traverseRules = function _traverseRules(sheet, base) {
            var i,
                href = sheet.href,
                cssRules;

            // Deal with Firefox's SecurityError when accessing sheets
            // from other domains. Chrome will safely return `undefined`.
            try {
                cssRules = sheet.cssRules;
            } catch (e) {
                if (e.name !== "SecurityError") {
                    throw e;
                }
            }

            if (href && cssRules) {
                if (rel.stylesheets[href] === undefined) {
                    rel.stylesheets[href] = [];
                }
                rel.stylesheets[href].push(base);

                for (i = 0; i < cssRules.length; i++) {
                    if (cssRules[i].href) {
                        traverseRules(cssRules[i].styleSheet, base);
                    }
                }
            }
        };
        //iterate on document.stylesheets (StyleSheetList doesn't provide forEach iterator).
        for (j = 0; j < window.document.styleSheets.length; j++) {
            s = window.document.styleSheets[j];
            traverseRules(s, s.href);
        }
        return rel;
    }

    /**
     * Common functions.
     */
    var Utils = {

        isExternalStylesheet: function (node) {
            return (node.nodeName.toUpperCase() === "LINK" && node.rel === "stylesheet" && node.href);
        },
        isExternalScript: function (node) {
            return (node.nodeName.toUpperCase() === "SCRIPT" && node.src);
        }
    };

    /**
     * CSS related commands and notifications
     */
    var CSS = {

            /**
            * Maintains a map of stylesheets loaded thorugh @import rules and their parents.
            * Populated by extractImports, consumed by notifyImportsAdded / notifyImportsRemoved.
            * @type {
            */
            stylesheets : {},

            /**
             * Check the stylesheet that was just added be really loaded
             * to be able to extract potential import-ed stylesheets.
             * It invokes notifyStylesheetAdded once the sheet is loaded.
             * @param  {string} href Absolute URL of the stylesheet.
             */
            checkForStylesheetLoaded : function (href) {
                var self = this;


                // Inspect CSSRules for @imports:
                // styleSheet obejct is required to scan CSSImportRules but
                // browsers differ on the implementation of MutationObserver interface.
                // Webkit triggers notifications before stylesheets are loaded,
                // Firefox does it after loading.
                // There are also differences on when 'load' event is triggered for
                // the 'link' nodes. Webkit triggers it before stylesheet is loaded.
                // Some references to check:
                //      http://www.phpied.com/when-is-a-stylesheet-really-loaded/
                //      http://stackoverflow.com/questions/17747616/webkit-dynamically-created-stylesheet-when-does-it-really-load
                //        http://stackoverflow.com/questions/11425209/are-dom-mutation-observers-slower-than-dom-mutation-events
                //
                // TODO: This is just a temporary 'cross-browser' solution, it needs optimization.
                var loadInterval = setInterval(function () {
                    var i;
                    for (i = 0; i < window.document.styleSheets.length; i++) {
                        if (window.document.styleSheets[i].href === href) {
                            //clear interval
                            clearInterval(loadInterval);
                            // notify stylesheets added
                            self.notifyStylesheetAdded(href);
                            break;
                        }
                    }
                }, 50);
            },

            onStylesheetRemoved : function (url) {
                // get style node created when setting new text for stylesheet.
                var s = window.document.getElementById(url);
                // remove
                if (s && s.parentNode && s.parentNode.removeChild) {
                    s.parentNode.removeChild(s);
                }
            },

            /**
             * Send a notification for the stylesheet added and
             * its import-ed styleshets based on document.stylesheets diff
             * from previous status. It also updates stylesheets status.
             */
            notifyStylesheetAdded : function () {
                var added = {},
                    current,
                    newStatus;

                current = this.stylesheets;
                newStatus = related().stylesheets;

                Object.keys(newStatus).forEach(function (v, i) {
                    if (!current[v]) {
                        added[v] = newStatus[v];
                    }
                });

                Object.keys(added).forEach(function (v, i) {
                    _transport.send(JSON.stringify({
                        method: "StylesheetAdded",
                        href: v,
                        roots: [added[v]]
                    }));
                });

                this.stylesheets = newStatus;
            },

            /**
             * Send a notification for the removed stylesheet and
             * its import-ed styleshets based on document.stylesheets diff
             * from previous status. It also updates stylesheets status.
             */
            notifyStylesheetRemoved : function () {

                var self = this;
                var removed = {},
                    newStatus,
                    current;

                current = self.stylesheets;
                newStatus = related().stylesheets;

                Object.keys(current).forEach(function (v, i) {
                    if (!newStatus[v]) {
                        removed[v] = current[v];
                        // remove node created by setStylesheetText if any
                        self.onStylesheetRemoved(current[v]);
                    }
                });

                Object.keys(removed).forEach(function (v, i) {
                    _transport.send(JSON.stringify({
                        method: "StylesheetRemoved",
                        href: v,
                        roots: [removed[v]]
                    }));
                });

                self.stylesheets = newStatus;
            }
        };


    /* process related docs added */
    function _onNodesAdded(nodes) {
        var i;
        for (i = 0; i < nodes.length; i++) {
            //check for Javascript files
            if (Utils.isExternalScript(nodes[i])) {
                _transport.send(JSON.stringify({
                    method: 'ScriptAdded',
                    src: nodes[i].src
                }));
            }
            //check for stylesheets
            if (Utils.isExternalStylesheet(nodes[i])) {
                CSS.checkForStylesheetLoaded(nodes[i].href);
            }
        }
    }
    /* process related docs removed */
    function _onNodesRemoved(nodes) {
        var i;
        //iterate on removed nodes
        for (i = 0; i < nodes.length; i++) {

            // check for external JS files
            if (Utils.isExternalScript(nodes[i])) {
                _transport.send(JSON.stringify({
                    method: 'ScriptRemoved',
                    src: nodes[i].src
                }));
            }
            //check for external StyleSheets
            if (Utils.isExternalStylesheet(nodes[i])) {
                CSS.notifyStylesheetRemoved(nodes[i].href);
            }
        }
    }

    function _enableListeners() {
        // enable MutationOberver if it's supported
        var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
        if (MutationObserver) {
            var observer = new MutationObserver(function (mutations) {
                mutations.forEach(function (mutation) {
                    if (mutation.addedNodes.length > 0) {
                        _onNodesAdded(mutation.addedNodes);
                    }
                    if (mutation.removedNodes.length > 0) {
                        _onNodesRemoved(mutation.removedNodes);
                    }
                });
            });
            observer.observe(_document, {
                childList: true,
                subtree: true
            });
        } else {
            // use MutationEvents as fallback
            window.document.addEventListener('DOMNodeInserted', function niLstnr(e) {
                _onNodesAdded([e.target]);
            });
            window.document.addEventListener('DOMNodeRemoved', function nrLstnr(e) {
                _onNodesRemoved([e.target]);
            });
        }
    }


    /**
     * Start listening for events and send initial related documents message.
     *
     * @param {HTMLDocument} document
     * @param {object} transport Live development transport connection
     */
    function start(document, transport) {
        _transport = transport;
        _document = document;
        // start listening to node changes
        _enableListeners();

        var rel = related();

        // send the current status of related docs.
        _transport.send(JSON.stringify({
            method: "DocumentRelated",
            related: rel
        }));
        // initialize stylesheets with current status for further notifications.
        CSS.stylesheets = rel.stylesheets;
    }

    /**
     * Stop listening.
     * TODO currently a no-op.
     */
    function stop() {

    }

    var DocumentObserver = {
        start: start,
        stop: stop,
        related: related
    };

    ProtocolManager.setDocumentObserver(DocumentObserver);

}(this));