functional DOM inspector

This commit is contained in:
gorhill 2015-06-30 18:02:29 -04:00
parent 8305b8990a
commit 9becb466d4
6 changed files with 431 additions and 161 deletions

View File

@ -75,7 +75,7 @@ li.listEntry > a:nth-of-type(3) {
}
/* I designed the button with: http://charliepark.org/bootstrap_buttons/ */
button.custom {
padding: 5px;
padding: 0.6em 1em;
border: 1px solid transparent;
border-color: #80b3ff #80b3ff hsl(216, 100%, 75%);
border-radius: 3px;
@ -98,7 +98,6 @@ button.custom:hover {
#buttonApply {
display: initial;
margin: 1em 0;
padding: 1em;
position: fixed;
right: 1em;
top: 0;

View File

@ -18,7 +18,7 @@ body {
font: 14px/1.3 sans-serif;
}
button.important {
padding: 5px;
padding: 0.6em 1em;
border: 1px solid transparent;
border-color: #ffcc7f #ffcc7f hsl(36, 100%, 73%);
border-radius: 3px;

View File

@ -1,8 +1,8 @@
#domInspector {
border-top: 1px solid #ccc;
display: none;
max-height: 40%;
min-height: 40%;
max-height: 70%;
min-height: 70%;
overflow: auto;
}
#domInspector.enabled {
@ -83,6 +83,11 @@
display: block;
}
#cosmeticFilteringDialog .dialog {
text-align: center;
}
#cosmeticFilteringDialog .dialog textarea {
height: 40vh;
}
white-space: pre;
word-wrap: normal;
}

View File

@ -40,6 +40,7 @@ var logger = self.logger;
var messager = logger.messager;
var inspectedTabId = '';
var inspectedURL = '';
var inspectedHostname = '';
var pollTimer = null;
var fingerprint = null;
@ -282,7 +283,48 @@ var nidFromNode = function(node) {
var startDialog = (function() {
var dialog = uDom.nodeFromId('cosmeticFilteringDialog');
var candidateFilters = [];
var textarea = dialog.querySelector('textarea');
var hideSelectors = [];
var unhideSelectors = [];
var inputTimer = null;
var onInputChanged = (function() {
var parse = function() {
inputTimer = null;
hideSelectors = [];
unhideSelectors = [];
var line, matches;
var re = /^([^#]*)(#@?#)(.+)$/;
var lines = textarea.value.split(/\s*\n\s*/);
for ( var i = 0; i < lines.length; i++ ) {
line = lines[i].trim();
if ( line === '' || line.charAt(0) === '!' ) {
continue;
}
matches = re.exec(line);
if ( matches === null || matches.length !== 4 ) {
continue;
}
if ( inspectedHostname.lastIndexOf(matches[1]) === -1 ) {
continue;
}
if ( matches[2] === '##' ) {
hideSelectors.push(matches[3]);
} else {
unhideSelectors.push(matches[3]);
}
}
showCommitted();
};
return function parseAsync() {
if ( inputTimer === null ) {
inputTimer = vAPI.setTimeout(parse, 743);
}
};
})();
var onClick = function(ev) {
var target = ev.target;
@ -292,45 +334,94 @@ var startDialog = (function() {
return stop();
}
ev.stopPropagation();
if ( target.id === 'createCosmeticFilters' ) {
messager.send({ what: 'createUserFilter', filters: textarea.value });
// Force a reload for the new cosmetic filter(s) to take effect
messager.send({ what: 'reloadTab', tabId: inspectedTabId });
return stop();
}
};
var stop = function() {
dialog.removeEventListener('click', onClick, true);
document.body.removeChild(dialog);
var onCooked = function(entries) {
if ( Array.isArray(entries) === false ) {
return;
}
hideSelectors = entries;
var taValue = [], i, node;
var d = new Date();
taValue.push('! ' + d.toLocaleString() + ' ' + inspectedURL);
for ( i = 0; i < entries.length; i++ ) {
taValue.push(inspectedHostname + '##' + entries[i]);
}
var nodes = domTree.querySelectorAll('code.filter.off');
for ( i = 0; i < nodes.length; i++ ) {
node = nodes[i];
unhideSelectors.push(node.textContent);
taValue.push(inspectedHostname + '#@#' + node.textContent);
}
textarea.value = taValue.join('\n');
document.body.appendChild(dialog);
dialog.addEventListener('click', onClick, true);
showCommitted();
};
var showCommitted = function() {
messager.sendTo(
{
what: 'showCommitted',
hide: hideSelectors.join(',\n'),
unhide: unhideSelectors.join(',\n')
},
inspectedTabId,
'dom-inspector.js'
);
};
var showInteractive = function() {
messager.sendTo(
{
what: 'showInteractive',
hide: hideSelectors.join(',\n'),
unhide: unhideSelectors.join(',\n')
},
inspectedTabId,
'dom-inspector.js'
);
};
var start = function() {
// Collect all selectors which are currently toggled
var node, filters = [];
textarea.addEventListener('input', onInputChanged);
var node, entries = [];
var nodes = domTree.querySelectorAll('code.off');
for ( var i = 0; i < nodes.length; i++ ) {
node = nodes[i];
if ( node.classList.contains('filter') ) {
filters.push({
prefix: '#@#',
nid: '',
selector: node.textContent
});
} else {
filters.push({
prefix: '##',
if ( node.classList.contains('filter') === false ) {
entries.push({
nid: nidFromNode(node),
selector: node.textContent
selector: selectorFromNode(node)
});
}
}
messager.sendTo(
{ what: 'cookFilters', entries: entries },
inspectedTabId,
'dom-inspector.js',
onCooked
);
};
// TODO: Send filters through dom-inspector.js for further processing.
candidateFilters = filters;
var taValue = [], filter;
for ( i = 0; i < filters.length; i++ ) {
filter = filters[i];
taValue.push(inspectedHostname + filter.prefix + filter.selector);
var stop = function() {
if ( inputTimer !== null ) {
clearTimeout(inputTimer);
inputTimer = null;
}
dialog.querySelector('textarea').value = taValue.join('\n');
document.body.appendChild(dialog);
dialog.addEventListener('click', onClick, true);
showInteractive();
hideSelectors = [];
unhideSelectors = [];
textarea.removeEventListener('input', onInputChanged);
dialog.removeEventListener('click', onClick, true);
document.body.removeChild(dialog);
};
return start;
@ -404,7 +495,10 @@ var onMouseOver = (function() {
if ( inspectedTabId === '' ) {
return;
}
// Convenience: skip real-time highlighting if shift key is pressed.
if ( ev.shiftKey ) {
return;
}
// Find closest `li`
var target = ev.target;
while ( target !== null ) {
@ -447,6 +541,7 @@ var fetchDOMAsync = (function() {
case 'full':
renderDOMFull(response);
fingerprint = response.fingerprint;
inspectedURL = response.url;
inspectedHostname = response.hostname;
break;

View File

@ -140,11 +140,9 @@ var cssEscape = (function(root) {
var localMessager = vAPI.messaging.channel('dom-inspector.js');
// Highlighter-related
var svgOcean = null;
var svgIslands = null;
var svgRoot = null;
var pickerRoot = null;
var highlightedElements = [];
var highlightedElementLists = [ [], [], [] ];
var nodeToIdMap = new WeakMap(); // No need to iterate
var toggledNodes = new Map();
@ -209,20 +207,7 @@ var domLayout = (function() {
}
return out;
})();
/*
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;
@ -362,13 +347,12 @@ var domLayout = (function() {
// 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 journalEntries = [];
var journalNodes = Object.create(null);
var previousElementSiblingId = function(node) {
var sibling = node;
@ -499,20 +483,14 @@ var domLayout = (function() {
// 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);
if ( fingerprint !== domFingerprint() ) {
reset();
}
var response = {
what: 'domLayout',
fingerprint: domFingerprint(),
url: window.location.href,
hostname: window.location.hostname
};
@ -542,6 +520,10 @@ var domLayout = (function() {
return response;
};
var reset = function() {
shutdown();
};
var shutdown = function() {
if ( mutationTimer !== null ) {
clearTimeout(mutationTimer);
@ -551,32 +533,195 @@ var domLayout = (function() {
mutationObserver.disconnect();
mutationObserver = null;
}
addedNodelists = [];
removedNodelist = [];
journalEntries = [];
journalNodes = Object.create(null);
nodeToIdMap = new WeakMap();
};
return {
get: getLayout,
reset: reset,
shutdown: shutdown
};
})();
// https://www.youtube.com/watch?v=qo8zKhd4Cf0
/******************************************************************************/
/******************************************************************************/
// For browsers not supporting `:scope`, it's not the end of the world: the
// suggested CSS selectors may just end up being more verbose.
var cssScope = ':scope > ';
try {
document.querySelector(':scope *');
} catch (e) {
cssScope = '';
}
/******************************************************************************/
var cosmeticFilterFromEntries = function(entries) {
var out = [];
var entry, i = entries.length;
while ( i-- ) {
entry = entries[i];
out.push(cosmeticFilterFromTarget(entry.nid, entry.selector));
}
return out;
};
/******************************************************************************/
// Extract the best possible cosmetic filter, i.e. as specific as possible.
var cosmeticFilterFromNode = function(elem) {
var tagName = elem.localName;
var prefix = '';
var suffix = [];
var v, i;
// Id
v = typeof elem.id === 'string' && cssEscape(elem.id);
if ( v ) {
suffix.push('#', v);
}
// Class(es)
if ( suffix.length === 0 ) {
v = elem.classList;
if ( v ) {
i = v.length || 0;
while ( i-- ) {
suffix.push('.' + cssEscape(v.item(i)));
}
}
}
// Tag name
if ( suffix.length === 0 ) {
prefix = tagName;
}
// Attributes (depends on tag name)
var attributes = [], attr;
switch ( tagName ) {
case 'a':
v = elem.getAttribute('href');
if ( v ) {
v = v.replace(/\?.*$/, '');
if ( v.length ) {
attributes.push({ k: 'href', v: v });
}
}
break;
case 'img':
v = elem.getAttribute('alt');
if ( v && v.length !== 0 ) {
attributes.push({ k: 'alt', v: v });
}
break;
default:
break;
}
while ( attr = attributes.pop() ) {
if ( attr.v.length === 0 ) {
continue;
}
suffix.push('[', attr.k, '="', attr.v, '"]');
}
var selector = prefix + suffix.join('');
// https://github.com/chrisaljoudi/uBlock/issues/637
// If the selector is still ambiguous at this point, further narrow using
// `nth-of-type`. It is preferable to use `nth-of-type` as opposed to
// `nth-child`, as `nth-of-type` is less volatile.
var parent = elem.parentElement;
if ( elementsFromSelector(cssScope + selector, parent).length > 1 ) {
i = 1;
while ( elem.previousElementSibling !== null ) {
elem = elem.previousElementSibling;
if ( elem.localName !== tagName ) {
continue;
}
i++;
}
selector += ':nth-of-type(' + i + ')';
}
return selector;
};
/******************************************************************************/
var cosmeticFilterFromTarget = function(nid, coarseSelector) {
var elems = elementsFromSelector(coarseSelector);
var target = null;
var i = elems.length;
while ( i-- ) {
if ( nodeToIdMap.get(elems[i]) === nid ) {
target = elems[i];
break;
}
}
if ( target === null ) {
return coarseSelector;
}
// Find the most concise selector from the target node
var segments = [], segment;
var node = target;
while ( node !== document.body ) {
segment = cosmeticFilterFromNode(node);
segments.unshift(segment);
if ( segment.charAt(0) === '#' ) {
break;
}
node = node.parentElement;
}
var fineSelector = segments.join(' > ');
if ( fineSelector.charAt(0) === '#' ) {
return fineSelector;
}
if ( fineSelector.charAt(0) === '.' && elementsFromSelector(fineSelector).length === 1 ) {
return fineSelector;
}
return 'body > ' + fineSelector;
};
/******************************************************************************/
var elementsFromSelector = function(selector, context) {
if ( !context ) {
context = document;
}
var out = [];
try {
out = context.querySelectorAll(selector);
} catch (ex) {
}
return out;
};
/******************************************************************************/
var highlightElements = function(scrollTo) {
var elems = highlightedElements;
var wv = pickerRoot.contentWindow.innerWidth;
var hv = pickerRoot.contentWindow.innerHeight;
var ocean = ['M0 0h' + wv + 'v' + hv + 'h-' + wv, 'z'];
var islands = [];
var elem, rect, poly;
var ocean = ['M0 0h' + wv + 'v' + hv + 'h-' + wv, 'z'], islands;
var elems, elem, rect, poly;
var xl, xr, yt, yb, w, h, ws;
var xlu = Number.MAX_VALUE, xru = 0, ytu = Number.MAX_VALUE, ybu = 0;
var lists = highlightedElementLists;
for ( var i = 0; i < elems.length; i++ ) {
elem = elems[i];
for ( var i = 0; i < lists.length; i++ ) {
elems = lists[i];
islands = [];
for ( var j = 0; j < elems.length; j++ ) {
elem = elems[j];
if ( elem === pickerRoot ) {
continue;
}
@ -610,8 +755,10 @@ var highlightElements = function(scrollTo) {
if ( yt < ytu ) { ytu = yt; }
if ( yb > ybu ) { ybu = yb; }
}
svgOcean.setAttribute('d', ocean.join(''));
svgIslands.setAttribute('d', islands.join('') || 'M0 0');
svgRoot.children[i+1].setAttribute('d', islands.join('') || 'M0 0');
}
svgRoot.children[0].setAttribute('d', ocean.join(''));
if ( !scrollTo ) {
return;
@ -646,13 +793,30 @@ var highlightElements = function(scrollTo) {
/******************************************************************************/
var elementsFromSelector = function(filter) {
var out = [];
try {
out = document.querySelectorAll(filter);
} catch (ex) {
var onScrolled = function() {
highlightElements();
};
/******************************************************************************/
var resetToggledNodes = function() {
var value;
// Chromium does not support destructuring as of v43.
for ( var node of toggledNodes.keys() ) {
value = toggledNodes.get(node);
if ( value !== null ) {
node.style.removeProperty('display');
} else {
node.style.setProperty('display', value);
}
return out;
}
toggledNodes.clear();
};
/******************************************************************************/
var forgetToggledNodes = function() {
toggledNodes.clear();
};
/******************************************************************************/
@ -673,15 +837,16 @@ var selectNodes = function(selector, nid) {
/******************************************************************************/
var hightlightNodes = function(selector, nid, scrollTo) {
highlightedElements = selectNodes(selector, nid);
highlightElements(scrollTo);
};
/******************************************************************************/
var onScrolled = function() {
highlightElements();
var shutdown = function() {
resetToggledNodes();
domLayout.shutdown();
localMessager.removeAllListeners();
localMessager.close();
localMessager = null;
window.removeEventListener('scroll', onScrolled, true);
document.documentElement.removeChild(pickerRoot);
pickerRoot = svgRoot = null;
highlightedElementLists = [ [], [], [] ];
};
/******************************************************************************/
@ -718,8 +883,10 @@ var toggleNodes = function(nodes, originalState, targetState) {
}
} else { // hidden, ?
if ( targetState ) { // hidden, any
toggledNodes.set(node, 'none');
node.style.setProperty('display', 'initial', 'important');
} else { // hidden, hidden
toggledNodes.delete(node);
node.style.setProperty('display', 'none', 'important');
}
}
@ -729,41 +896,25 @@ var toggleNodes = function(nodes, originalState, targetState) {
// https://www.youtube.com/watch?v=L5jRewnxSBY
/******************************************************************************/
var resetToggledNodes = function() {
var value;
// Chromium does not support destructuring as of v43.
for ( var node of toggledNodes.keys() ) {
value = toggledNodes.get(node);
if ( value !== null ) {
node.style.removeProperty('display');
} else {
node.style.setProperty('display', value);
}
}
toggledNodes.clear();
};
/******************************************************************************/
var shutdown = function() {
resetToggledNodes();
domLayout.shutdown();
localMessager.removeAllListeners();
localMessager.close();
localMessager = null;
window.removeEventListener('scroll', onScrolled, true);
document.documentElement.removeChild(pickerRoot);
pickerRoot = svgRoot = svgOcean = svgIslands = null;
highlightedElements = [];
};
/******************************************************************************/
var onMessage = function(request) {
var response;
switch ( request.what ) {
case 'commitFilters':
resetToggledNodes();
toggleNodes(selectNodes(request.hide, ''), true, false);
toggleNodes(selectNodes(request.unhide, ''), false, true);
forgetToggledNodes();
highlightedElementLists = [ [], [], [] ];
highlightElements();
break;
case 'cookFilters':
response = cosmeticFilterFromEntries(request.entries);
break;
case 'domLayout':
response = domLayout.get(request.fingerprint);
break;
@ -773,16 +924,34 @@ var onMessage = function(request) {
break;
case 'highlightOne':
hightlightNodes(request.selector, request.nid, request.scrollTo);
highlightedElementLists[0] = selectNodes(request.selector, request.nid);
highlightElements(request.scrollTo);
break;
case 'resetToggledNodes':
resetToggledNodes();
break;
case 'showCommitted':
resetToggledNodes();
highlightedElementLists[0] = [];
highlightedElementLists[1] = selectNodes(request.hide, '');
highlightedElementLists[2] = selectNodes(request.unhide, '');
toggleNodes(highlightedElementLists[2], false, true);
highlightElements(true);
break;
case 'showInteractive':
resetToggledNodes();
toggleNodes(selectNodes(request.hide, ''), true, false);
toggleNodes(selectNodes(request.unhide, ''), false, true);
highlightedElementLists = [ [], [], [] ];
highlightElements();
break;
case 'toggleNodes':
highlightedElements = selectNodes(request.selector, request.nid);
toggleNodes(highlightedElements, request.original, request.target);
highlightedElementLists[0] = selectNodes(request.selector, request.nid);
toggleNodes(highlightedElementLists[0], request.original, request.target);
highlightElements(true);
break;
@ -845,27 +1014,28 @@ pickerRoot.onload = function() {
'fill: rgba(0,0,0,0.75);',
'fill-rule: evenodd;',
'}',
'svg > path + path {',
'svg > path:nth-of-type(2) {',
'fill: rgba(0,0,255,0.1);',
'stroke: #FFF;',
'stroke-width: 0.5px;',
'}',
'svg.invert > path:first-child {',
'fill: rgba(0,0,255,0.1);',
'svg > path:nth-of-type(3) {',
'fill: rgba(255,0,0,0.2);',
'stroke: #F00;',
'}',
'svg.invert > path + path {',
'fill: rgba(0,0,0,0.75);',
'stroke: #000;',
'svg > path:nth-of-type(4) {',
'fill: rgba(0,255,0,0.2);',
'stroke: #0F0;',
'}',
''
].join('\n');
pickerDoc.body.appendChild(style);
svgRoot = pickerDoc.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgOcean = pickerDoc.createElementNS('http://www.w3.org/2000/svg', 'path');
svgRoot.appendChild(svgOcean);
svgIslands = pickerDoc.createElementNS('http://www.w3.org/2000/svg', 'path');
svgRoot.appendChild(svgIslands);
svgRoot.appendChild(pickerDoc.createElementNS('http://www.w3.org/2000/svg', 'path'));
svgRoot.appendChild(pickerDoc.createElementNS('http://www.w3.org/2000/svg', 'path'));
svgRoot.appendChild(pickerDoc.createElementNS('http://www.w3.org/2000/svg', 'path'));
svgRoot.appendChild(pickerDoc.createElementNS('http://www.w3.org/2000/svg', 'path'));
pickerDoc.body.appendChild(svgRoot);
window.addEventListener('scroll', onScrolled, true);

View File

@ -23,7 +23,7 @@
<div id="domInspector">
<div class="permatoolbar">
<div>
<span class="button fa highlightMode">&#xf042;</span>
<span class="button fa highlightMode" style="display: none">&#xf042;</span>
<span class="button fa revert disabled">&#xf12d;</span>
<span class="button fa commit disabled">&#xf0c7;</span>
</div>
@ -98,7 +98,8 @@
</div>
<div id="cosmeticFilteringDialog" class="modalDialog">
<div class="dialog">
<p><textarea class="cosmeticFilters" value=""></textarea>
<textarea class="cosmeticFilters" value=""></textarea>
<button id="createCosmeticFilters" class="important" type="button" data-i18n="pickerCreate"></button>
</div>
</div>
<div id="filterFinderDialogSentence1"><span><span></span><code></code><span></span></span></div>