From f988d74b4dd98f224be2ddecee0f5ebf95bb506f Mon Sep 17 00:00:00 2001 From: gorhill Date: Sat, 27 Jun 2015 13:32:10 -0400 Subject: [PATCH] DOM inspector: incremental rendering when inspected DOM changes --- src/js/logger-ui.js | 118 +++++++++-- src/js/scriptlets/dom-inspector.js | 313 +++++++++++++++++++++++------ 2 files changed, 360 insertions(+), 71 deletions(-) diff --git a/src/js/logger-ui.js b/src/js/logger-ui.js index fe07b1a70..1ebc6717e 100644 --- a/src/js/logger-ui.js +++ b/src/js/logger-ui.js @@ -144,8 +144,9 @@ var tabIdFromClassName = function(className) { var tabSelector = uDom.nodeFromId('pageSelector'); var nodeFromDomEntry = function(entry) { - var node; + var node, value; var li = document.createElement('li'); + li.setAttribute('id', entry.nid); // expander/collapser node = document.createElement('span'); li.appendChild(node); @@ -154,11 +155,11 @@ var tabIdFromClassName = function(className) { node.textContent = entry.sel; li.appendChild(node); // descendant count - if ( entry.cnt !== 0 ) { - node = document.createElement('span'); - node.textContent = entry.cnt.toLocaleString(); - li.appendChild(node); - } + value = entry.cnt || 0; + node = document.createElement('span'); + node.textContent = value !== 0 ? value.toLocaleString() : ''; + node.setAttribute('data-cnt', value); + li.appendChild(node); // cosmetic filter if ( entry.filter !== undefined ) { node = document.createElement('code'); @@ -186,10 +187,10 @@ var tabIdFromClassName = function(className) { } }; - var renderDOM = function(response) { + var renderDOMFull = function(response) { var ul = document.createElement('ul'); var lvl = 0; - var entries = response; + var entries = response.layout; var n = entries.length; var li, entry; for ( var i = 0; i < n; i++ ) { @@ -229,6 +230,85 @@ var tabIdFromClassName = function(className) { inspector.appendChild(ul); }; + var patchIncremental = function(from, delta) { + var span, cnt; + var li = from.parentElement.parentElement; + var patchCosmeticHide = delta >= 0 && + from.classList.contains('isCosmeticFilter') && + li.classList.contains('hasCosmeticFilter') === false; + for ( ; li.localName === 'li'; li = li.parentElement.parentElement ) { + span = li.children[2]; + if ( delta !== 0 ) { + cnt = parseInt(span.getAttribute('data-cnt'), 10) + delta; + span.textContent = cnt !== 0 ? cnt.toLocaleString() : ''; + span.setAttribute('data-cnt', cnt); + } + if ( patchCosmeticHide ) { + li.classList.add('hasCosmeticFilter'); + } + } + }; + + var renderDOMIncremental = function(response) { + // Process each journal entry: + // 1 = node added + // -1 = node removed + var journal = response.journal; + var nodes = response.nodes; + var entry, previous, li, ul; + for ( var i = 0, n = journal.length; i < n; i++ ) { + entry = journal[i]; + // Remove node + if ( entry.what === -1 ) { + li = document.getElementById(entry.nid); + if ( li === null ) { + continue; + } + patchIncremental(li, -1); + li.parentNode.removeChild(li); + continue; + } + // Modify node + if ( entry.what === 0 ) { + // TODO: update selector/filter + continue; + } + // Add node as sibling + if ( entry.what === 1 && entry.l ) { + previous = document.getElementById(entry.l); + // This should not happen + if ( previous === null ) { + // throw new Error('No left sibling!?'); + continue; + } + ul = previous.parentElement; + li = nodeFromDomEntry(nodes[entry.nid]); + ul.insertBefore(li, previous.nextElementSibling); + patchIncremental(li, 1); + continue; + } + // Add node as child + if ( entry.what === 1 && entry.u ) { + li = document.getElementById(entry.u); + // This should not happen + if ( li === null ) { + // throw new Error('No parent!?'); + continue; + } + ul = li.querySelector('ul'); + if ( ul === null ) { + ul = document.createElement('ul'); + li.appendChild(ul); + li.classList.add('branch'); + } + li = nodeFromDomEntry(nodes[entry.nid]); + ul.appendChild(li); + patchIncremental(li, 1); + continue; + } + } + }; + var selectorFromNode = function(node, nth) { var selector = ''; var code; @@ -377,13 +457,23 @@ var tabIdFromClassName = function(className) { return; } - if ( response.layout === 'NOCHANGE' ) { - fetchDOMAsync(); - return; - } + switch ( response.status ) { + case 'full': + renderDOMFull(response); + fingerprint = response.fingerprint; + break; - renderDOM(response.layout); - fingerprint = response.fingerprint; + case 'incremental': + renderDOMIncremental(response); + break; + + case 'nochange': + case 'busy': + break; + + default: + break; + } fetchDOMAsync(); }; diff --git a/src/js/scriptlets/dom-inspector.js b/src/js/scriptlets/dom-inspector.js index 312779925..0d3446088 100644 --- a/src/js/scriptlets/dom-inspector.js +++ b/src/js/scriptlets/dom-inspector.js @@ -139,6 +139,7 @@ var cssEscape = (function(root) { var localMessager = vAPI.messaging.channel('dom-inspector.js'); +// Highlighter-related var svgOcean = null; var svgIslands = null; var svgRoot = null; @@ -149,6 +150,14 @@ var toggledNodes = new Map(); /******************************************************************************/ +// Some kind of fingerprint for the DOM, without incurring too much overhead. + +var domFingerprint = function() { + return vAPI.sessionId; +}; + +/******************************************************************************/ + var domLayout = (function() { var skipTagNames = { 'br': true, @@ -165,8 +174,20 @@ var domLayout = (function() { 'object': 'data' }; + var idGenerator = 0; + var nodeToIdMap = new WeakMap(); // No need to iterate + + // This will be used to uniquely identify nodes across process. + + var newNodeId = function(node) { + var nid = 'n' + (idGenerator++).toString(36); + nodeToIdMap.set(node, nid); + return nid; + }; + // Collect all nodes which are directly affected by cosmetic filters: these // will be reported in the layout data. + // TODO: take into account cosmetic filters added after the map is build. var nodeToCosmeticFilterMap = (function() { var out = new WeakMap(); @@ -189,25 +210,18 @@ var domLayout = (function() { return out; })(); - var DomRoot = function() { - this.lvl = 0; - this.sel = 'body'; - var url = window.location.href; - var pos = url.indexOf('#'); - if ( pos !== -1 ) { - url = url.slice(0, pos); + var matchesSelector = (function() { + if ( typeof Element.prototype.matches === 'function' ) { + return 'matches'; } - this.src = url; - this.top = window === window.top; - this.cnt = 0; - }; - - var DomNode = function(level, selector, filter) { - this.lvl = level; - this.sel = selector; - this.cnt = 0; - this.filter = filter; - }; + if ( typeof Element.prototype.mozMatchesSelector === 'function' ) { + return 'mozMatchesSelector'; + } + if ( typeof Element.prototype.webkitMatchesSelector === 'function' ) { + return 'webkitMatchesSelector'; + } + return ''; + })(); var hasManyMatches = function(node, selector) { var fnName = matchesSelector; @@ -228,19 +242,6 @@ var domLayout = (function() { return false; }; - var matchesSelector = (function() { - if ( typeof Element.prototype.matches === 'function' ) { - return 'matches'; - } - if ( typeof Element.prototype.mozMatchesSelector === 'function' ) { - return 'mozMatchesSelector'; - } - if ( typeof Element.prototype.webkitMatchesSelector === 'function' ) { - return 'webkitMatchesSelector'; - } - return ''; - })(); - var selectorFromNode = function(node) { var str, attr, pos, sw, i; var tag = node.localName; @@ -290,6 +291,22 @@ var domLayout = (function() { return selector; }; + var DomRoot = function() { + this.nid = newNodeId(document.body); + this.lvl = 0; + this.sel = 'body'; + this.cnt = 0; + this.filter = nodeToCosmeticFilterMap.get(document.body); + }; + + var DomNode = function(node, level) { + this.nid = newNodeId(node); + this.lvl = level; + this.sel = selectorFromNode(node); + this.cnt = 0; + this.filter = nodeToCosmeticFilterMap.get(node); + }; + var domNodeFactory = function(level, node) { var localName = node.localName; if ( skipTagNames.hasOwnProperty(localName) ) { @@ -302,15 +319,13 @@ var domLayout = (function() { if ( level === 0 && localName === 'body' ) { return new DomRoot(); } - var selector = selectorFromNode(node); - var filter = nodeToCosmeticFilterMap.get(node); - return new DomNode(level, selector, filter); + return new DomNode(node, level); }; // Collect layout data. var getLayoutData = function() { - var domLayout = []; + var layout = []; var stack = []; var node = document.body; var domNode; @@ -319,7 +334,7 @@ var domLayout = (function() { for (;;) { domNode = domNodeFactory(lvl, node); if ( domNode !== null ) { - domLayout.push(domNode); + layout.push(domNode); } // children if ( node.firstElementChild !== null ) { @@ -339,19 +354,20 @@ var domLayout = (function() { } node = node.nextElementSibling; } - return domLayout; + + return layout; }; // Descendant count for each node. - var patchLayoutData = function(domLayout) { + var patchLayoutData = function(layout) { var stack = [], ptr; var lvl = 0; var domNode, cnt; - var i = domLayout.length; + var i = layout.length; while ( i-- ) { - domNode = domLayout[i]; + domNode = layout[i]; if ( domNode.lvl === lvl ) { stack[ptr] += 1; continue; @@ -372,24 +388,211 @@ var domLayout = (function() { ptr = lvl - 1; stack[ptr] += cnt + 1; } - return domLayout; + return layout; }; - return function() { - return patchLayoutData(getLayoutData()); + // Track and report mutations to the DOM + + var journalEntries = []; + var journalNodes = Object.create(null); + + var mutationObserver = null; + var mutationTimer = null; + var addedNodelists = []; + var removedNodelist = []; + + var previousElementSiblingId = function(node) { + var sibling = node; + for (;;) { + sibling = sibling.previousElementSibling; + if ( sibling === null ) { + return null; + } + if ( skipTagNames.hasOwnProperty(sibling.localName) ) { + continue; + } + return nodeToIdMap.get(sibling); + } + }; + + var journalFromBranch = function(root, added) { + var domNode; + var node = root.firstElementChild; + while ( node !== null ) { + domNode = domNodeFactory(undefined, node); + if ( domNode !== null ) { + journalNodes[domNode.nid] = domNode; + added.push(node); + } + // down + if ( node.firstElementChild !== null ) { + node = node.firstElementChild; + continue; + } + // right + if ( node.nextElementSibling !== null ) { + node = node.nextElementSibling; + continue; + } + // up then right + for (;;) { + if ( node.parentElement === root ) { + return; + } + node = node.parentElement; + if ( node.nextElementSibling !== null ) { + node = node.nextElementSibling; + break; + } + } + } + }; + + var journalFromMutations = function() { + mutationTimer = null; + if ( mutationObserver === null ) { + addedNodelists = []; + removedNodelist = []; + return; + } + + var i, m, nodelist, j, n, node, domNode, nid; + + // This is used to temporarily hold all added nodes, before resolving + // their node id and relative position. + var added = []; + + for ( i = 0, m = addedNodelists.length; i < m; i++ ) { + nodelist = addedNodelists[i]; + for ( j = 0, n = nodelist.length; j < n; j++ ) { + node = nodelist[j]; + if ( node.nodeType !== 1 ) { + continue; + } + // I don't think this can ever happen + if ( node.parentElement === null ) { + continue; + } + domNode = domNodeFactory(undefined, node); + if ( domNode !== null ) { + journalNodes[domNode.nid] = domNode; + added.push(node); + } + journalFromBranch(node, added); + } + } + addedNodelists = []; + for ( i = 0, m = removedNodelist.length; i < m; i++ ) { + nodelist = removedNodelist[i]; + for ( j = 0, n = nodelist.length; j < n; j++ ) { + node = nodelist[j]; + if ( node.nodeType !== 1 ) { + continue; + } + nid = nodeToIdMap.get(node); + if ( nid === undefined ) { + continue; + } + journalEntries.push({ + what: -1, + nid: nid + }); + } + } + removedNodelist = []; + for ( i = 0, n = added.length; i < n; i++ ) { + node = added[i]; + journalEntries.push({ + what: 1, + nid: nodeToIdMap.get(node), + u: nodeToIdMap.get(node.parentElement), + l: previousElementSiblingId(node) + }); + } + }; + + var onMutationObserved = function(mutationRecords) { + var record; + for ( var i = 0, n = mutationRecords.length; i < n; i++ ) { + record = mutationRecords[i]; + if ( record.addedNodes.length !== 0 ) { + addedNodelists.push(record.addedNodes); + } + if ( record.removedNodes.length !== 0 ) { + removedNodelist.push(record.removedNodes); + } + } + if ( mutationTimer === null ) { + mutationTimer = vAPI.setTimeout(journalFromMutations, 1000); + } + }; + + // API + + var getLayout = function(fingerprint) { + if ( fingerprint !== domFingerprint() && mutationObserver !== null ) { + if ( mutationTimer !== null ) { + clearTimeout(mutationTimer); + mutationTimer = null; + } + mutationObserver.disconnect(); + mutationObserver = null; + journalEntries = []; + journalNodes = Object.create(null); + } + + var response = { + what: 'domLayout', + fingerprint: domFingerprint() + }; + + // No mutation observer means we need to send full layout + if ( mutationObserver === null ) { + mutationObserver = new MutationObserver(onMutationObserved); + mutationObserver.observe(document.body, { + childList: true, + subtree: true + }); + response.status = 'full'; + response.layout = patchLayoutData(getLayoutData()); + return response; + } + + // Incremental layout + if ( journalEntries.length !== 0 ) { + response.status = 'incremental'; + response.journal = journalEntries; + response.nodes = journalNodes; + journalEntries = []; + journalNodes = Object.create(null); + return response; + } + + response.status = 'nochange'; + return response; + }; + + var shutdown = function() { + if ( mutationTimer !== null ) { + clearTimeout(mutationTimer); + mutationTimer = null; + } + if ( mutationObserver !== null ) { + mutationObserver.disconnect(); + mutationObserver = null; + } + journalEntries = []; + journalNodes = Object.create(null); + }; + + return { + get: getLayout, + shutdown: shutdown }; })(); /******************************************************************************/ -// Some kind of fingerprint for the DOM, without incurring too much overhead. - -var domFingerprint = function() { - return vAPI.sessionId + '{' + document.getElementsByTagName('*').length + '}'; -}; - -/******************************************************************************/ - var highlightElements = function(elems, scrollTo) { var wv = pickerRoot.contentWindow.innerWidth; var hv = pickerRoot.contentWindow.innerHeight; @@ -555,6 +758,7 @@ var resetToggledNodes = function() { var shutdown = function() { resetToggledNodes(); + domLayout.shutdown(); localMessager.removeListener(onMessage); localMessager.close(); localMessager = null; @@ -572,12 +776,7 @@ var onMessage = function(request) { switch ( msg.what ) { case 'domLayout': - var fingerprint = domFingerprint(); - response = { - what: 'domLayout', - layout: msg.fingerprint !== fingerprint ? domLayout() : 'NOCHANGE', - fingerprint: fingerprint - }; + response = domLayout.get(msg.fingerprint); break; case 'highlight':