/** * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ define([ 'jquery', 'underscore', 'domReady!' ], function ($, _) { 'use strict'; var counter = 1, watchers, globalObserver, disabledNodes = []; watchers = { selectors: {}, nodes: {} }; /** * Checks if node represents an element node (nodeType === 1). * * @param {HTMLElement} node * @returns {Boolean} */ function isElementNode(node) { return node.nodeType === 1; } /** * Extracts all child descendant * elements of a specified node. * * @param {HTMLElement} node * @returns {Array} */ function extractChildren(node) { var children = node.querySelectorAll('*'); return _.toArray(children); } /** * Extracts node identifier. If ID is not specified, * then it will be created for the provided node. * * @param {HTMLElement} node * @returns {Number} */ function getNodeId(node) { var id = node._observeId; if (!id) { id = node._observeId = counter++; } return id; } /** * Invokes callback passing node to it. * * @param {HTMLElement} node * @param {Object} data */ function trigger(node, data) { var id = getNodeId(node), ids = data.invoked; if (_.contains(ids, id)) { return; } data.callback(node); data.invoked.push(id); } /** * Adds node to the observer list. * * @param {HTMLElement} node * @returns {Object} */ function createNodeData(node) { var nodes = watchers.nodes, id = getNodeId(node); nodes[id] = nodes[id] || {}; return nodes[id]; } /** * Returns data associated with a specified node. * * @param {HTMLElement} node * @returns {Object|Undefined} */ function getNodeData(node) { var nodeId = node._observeId; return watchers.nodes[nodeId]; } /** * Removes data associated with a specified node. * * @param {HTMLElement} node */ function removeNodeData(node) { var nodeId = node._observeId; delete watchers.nodes[nodeId]; } /** * Adds removal listener for a specified node. * * @param {HTMLElement} node * @param {Object} data */ function addRemovalListener(node, data) { var nodeData = createNodeData(node); (nodeData.remove = nodeData.remove || []).push(data); } /** * Adds listener for the nodes which matches specified selector. * * @param {String} selector - CSS selector. * @param {Object} data */ function addSelectorListener(selector, data) { var storage = watchers.selectors; (storage[selector] = storage[selector] || []).push(data); } /** * Calls handlers associated with an added node. * Adds listeners for the node removal. * * @param {HTMLElement} node - Added node. */ function processAdded(node) { _.each(watchers.selectors, function (listeners, selector) { listeners.forEach(function (data) { if (!data.ctx.contains(node) || !$(node, data.ctx).is(selector)) { return; } if (data.type === 'add') { trigger(node, data); } else if (data.type === 'remove') { addRemovalListener(node, data); } }); }); } /** * Calls handlers associated with a removed node. * * @param {HTMLElement} node - Removed node. */ function processRemoved(node) { var nodeData = getNodeData(node), listeners = nodeData && nodeData.remove; if (!listeners) { return; } listeners.forEach(function (data) { trigger(node, data); }); removeNodeData(node); } /** * Removes all non-element nodes from provided array * and appends to it descendant elements. * * @param {Array} nodes * @returns {Array} */ function formNodesList(nodes) { var result = [], children; nodes = _.toArray(nodes).filter(isElementNode); nodes.forEach(function (node) { result.push(node); children = extractChildren(node); result = result.concat(children); }); return result; } /** * Collects all removed and added nodes from * mutation records into separate arrays * while removing duplicates between both types of changes. * * @param {Array} mutations - An array of mutation records. * @returns {Object} Object with 'removed' and 'added' nodes arrays. */ function formChangesLists(mutations) { var removed = [], added = []; mutations.forEach(function (record) { removed = removed.concat(_.toArray(record.removedNodes)); added = added.concat(_.toArray(record.addedNodes)); }); removed = removed.filter(function (node) { var addIndex = added.indexOf(node), wasAdded = !!~addIndex; if (wasAdded) { added.splice(addIndex, 1); } return !wasAdded; }); return { removed: formNodesList(removed), added: formNodesList(added) }; } /** * Verify if the DOM node is a child of a defined disabled node, if so we shouldn't observe provided mutation. * * @param {Object} mutation - a single mutation * @returns {Boolean} */ function shouldObserveMutation(mutation) { var isDisabled; if (disabledNodes.length > 0) { // Iterate through the disabled nodes and determine if this mutation is occurring inside one of them isDisabled = _.find(disabledNodes, function (node) { return node === mutation.target || $.contains(node, mutation.target); }); // If we find a matching node we should not observe the mutation return !isDisabled; } return true; } /** * Should we observe these mutations? Check the first and last mutation to determine if this is a disabled mutation, * we check both the first and last in case one has been removed from the DOM during the process of the mutation. * * @param {Array} mutations - An array of mutation records. * @returns {Boolean} */ function shouldObserveMutations(mutations) { var firstMutation, lastMutation; if (mutations.length > 0) { firstMutation = mutations[0]; lastMutation = mutations[mutations.length - 1]; return shouldObserveMutation(firstMutation) && shouldObserveMutation(lastMutation); } return true; } globalObserver = new MutationObserver(function (mutations) { var changes; if (shouldObserveMutations(mutations)) { changes = formChangesLists(mutations); changes.removed.forEach(processRemoved); changes.added.forEach(processAdded); } }); globalObserver.observe(document.body, { subtree: true, childList: true }); return { /** * Disable a node from being observed by the mutations, you may want to disable specific aspects of the * application which are heavy on DOM changes. The observer running on some actions could cause significant * delays and degrade the performance of that specific part of the application exponentially. * * @param {HTMLElement} node - a HTML node within the document */ disableNode: function (node) { disabledNodes.push(node); }, /** * Adds listener for the appearance of nodes that matches provided * selector and which are inside of the provided context. Callback will be * also invoked on elements which a currently present. * * @param {String} selector - CSS selector. * @param {Function} callback - Function that will invoked when node appears. * @param {HTMLElement} [ctx=document.body] - Context inside of which to search for the node. */ get: function (selector, callback, ctx) { var data, nodes; data = { ctx: ctx || document.body, type: 'add', callback: callback, invoked: [] }; nodes = $(selector, data.ctx).toArray(); nodes.forEach(function (node) { trigger(node, data); }); addSelectorListener(selector, data); }, /** * Adds listener for the nodes removal. * * @param {(jQueryObject|HTMLElement|Array|String)} selector * @param {Function} callback - Function that will invoked when node is removed. * @param {HTMLElement} [ctx=document.body] - Context inside of which to search for the node. */ remove: function (selector, callback, ctx) { var nodes = [], data; data = { ctx: ctx || document.body, type: 'remove', callback: callback, invoked: [] }; if (typeof selector === 'object') { nodes = !_.isUndefined(selector.length) ? _.toArray(selector) : [selector]; } else if (_.isString(selector)) { nodes = $(selector, ctx).toArray(); addSelectorListener(selector, data); } nodes.forEach(function (node) { addRemovalListener(node, data); }); }, /** * Removes listeners. * * @param {String} selector * @param {Function} [fn] */ off: function (selector, fn) { var selectors = watchers.selectors, listeners = selectors[selector]; if (selector && !fn) { delete selectors[selector]; } else if (listeners && fn) { selectors[selector] = listeners.filter(function (data) { return data.callback !== fn; }); } } }; });