Fix some element picker-related issues

Related discussion:
- https://www.reddit.com/r/uBlockOrigin/comments/c5do7w/

Make the element picker better reflect network filters as
parsed by the static network filtering engine. Additionally,
discard single alphanumeric character-based filters.

Related discussion:
- https://www.reddit.com/r/uBlockOrigin/comments/c62irc/

Inject newly created cosmetic filters into the DOM
filterer, in order for these filters to be enforced by
the DOM filterer in subsequent dynamic DOM changes.
This commit is contained in:
Raymond Hill 2019-06-29 11:06:03 -04:00
parent dba075af59
commit cf4345ffc4
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
2 changed files with 128 additions and 93 deletions

View File

@ -114,7 +114,7 @@
/******************************************************************************/ /******************************************************************************/
/******************************************************************************/ /******************************************************************************/
(function() { (( ) => {
/******************************************************************************/ /******************************************************************************/
@ -127,9 +127,7 @@ if (
} }
var pickerRoot = document.getElementById(vAPI.sessionId); var pickerRoot = document.getElementById(vAPI.sessionId);
if ( pickerRoot ) { if ( pickerRoot ) { return; }
return;
}
var pickerBody = null; var pickerBody = null;
var svgOcean = null; var svgOcean = null;
@ -154,8 +152,7 @@ var lastNetFilterUnion = '';
// For browsers not supporting `:scope`, it's not the end of the world: the // For browsers not supporting `:scope`, it's not the end of the world: the
// suggested CSS selectors may just end up being more verbose. // suggested CSS selectors may just end up being more verbose.
var cssScope = ':scope > '; let cssScope = ':scope > ';
try { try {
document.querySelector(':scope *'); document.querySelector(':scope *');
} catch (e) { } catch (e) {
@ -164,7 +161,7 @@ try {
/******************************************************************************/ /******************************************************************************/
var safeQuerySelectorAll = function(node, selector) { const safeQuerySelectorAll = function(node, selector) {
if ( node !== null ) { if ( node !== null ) {
try { try {
return node.querySelectorAll(selector); return node.querySelectorAll(selector);
@ -176,18 +173,18 @@ var safeQuerySelectorAll = function(node, selector) {
/******************************************************************************/ /******************************************************************************/
var rawFilterFromTextarea = function() { const rawFilterFromTextarea = function() {
var s = taCandidate.value, const s = taCandidate.value;
pos = s.indexOf('\n'); const pos = s.indexOf('\n');
return pos === -1 ? s.trim() : s.slice(0, pos).trim(); return pos === -1 ? s.trim() : s.slice(0, pos).trim();
}; };
/******************************************************************************/ /******************************************************************************/
var getElementBoundingClientRect = function(elem) { const getElementBoundingClientRect = function(elem) {
var rect = typeof elem.getBoundingClientRect === 'function' ? let rect = typeof elem.getBoundingClientRect === 'function'
elem.getBoundingClientRect() : ? elem.getBoundingClientRect()
{ height: 0, left: 0, top: 0, width: 0 }; : { height: 0, left: 0, top: 0, width: 0 };
// https://github.com/gorhill/uBlock/issues/1024 // https://github.com/gorhill/uBlock/issues/1024
// Try not returning an empty bounding rect. // Try not returning an empty bounding rect.
@ -195,16 +192,13 @@ var getElementBoundingClientRect = function(elem) {
return rect; return rect;
} }
var left = rect.left, let left = rect.left,
right = rect.right, right = rect.right,
top = rect.top, top = rect.top,
bottom = rect.bottom; bottom = rect.bottom;
var children = elem.children, for ( const child of elem.children ) {
i = children.length; rect = getElementBoundingClientRect(child);
while ( i-- ) {
rect = getElementBoundingClientRect(children[i]);
if ( rect.width === 0 || rect.height === 0 ) { if ( rect.width === 0 || rect.height === 0 ) {
continue; continue;
} }
@ -224,7 +218,7 @@ var getElementBoundingClientRect = function(elem) {
/******************************************************************************/ /******************************************************************************/
var highlightElements = function(elems, force) { const highlightElements = function(elems, force) {
// To make mouse move handler more efficient // To make mouse move handler more efficient
if ( !force && elems.length === targetElements.length ) { if ( !force && elems.length === targetElements.length ) {
if ( elems.length === 0 || elems[0] === targetElements[0] ) { if ( elems.length === 0 || elems[0] === targetElements[0] ) {
@ -233,24 +227,23 @@ var highlightElements = function(elems, force) {
} }
targetElements = elems; targetElements = elems;
var ow = pickerRoot.contentWindow.innerWidth; const ow = pickerRoot.contentWindow.innerWidth;
var oh = pickerRoot.contentWindow.innerHeight; const oh = pickerRoot.contentWindow.innerHeight;
var ocean = [ const ocean = [
'M0 0', 'M0 0',
'h', ow, 'h', ow,
'v', oh, 'v', oh,
'h-', ow, 'h-', ow,
'z' 'z'
]; ];
var islands = []; const islands = [];
var elem, rect, poly; for ( let i = 0; i < elems.length; i++ ) {
for ( var i = 0; i < elems.length; i++ ) { const elem = elems[i];
elem = elems[i];
if ( elem === pickerRoot ) { if ( elem === pickerRoot ) {
continue; continue;
} }
rect = getElementBoundingClientRect(elem); const rect = getElementBoundingClientRect(elem);
// Ignore if it's not on the screen // Ignore if it's not on the screen
if ( rect.left > ow || rect.top > oh || if ( rect.left > ow || rect.top > oh ||
@ -258,7 +251,7 @@ var highlightElements = function(elems, force) {
continue; continue;
} }
poly = 'M' + rect.left + ' ' + rect.top + const poly = 'M' + rect.left + ' ' + rect.top +
'h' + rect.width + 'h' + rect.width +
'v' + rect.height + 'v' + rect.height +
'h-' + rect.width + 'h-' + rect.width +
@ -489,7 +482,7 @@ const filterTypes = {
// Also take into account the `src` attribute for `img` elements -- and limit // Also take into account the `src` attribute for `img` elements -- and limit
// the value to the 1024 first characters. // the value to the 1024 first characters.
var cosmeticFilterFromElement = function(elem) { const cosmeticFilterFromElement = function(elem) {
if ( elem === null ) { return 0; } if ( elem === null ) { return 0; }
if ( elem.nodeType !== 1 ) { return 0; } if ( elem.nodeType !== 1 ) { return 0; }
@ -616,7 +609,7 @@ var cosmeticFilterFromElement = function(elem) {
/******************************************************************************/ /******************************************************************************/
var filtersFrom = function(x, y) { const filtersFrom = function(x, y) {
bestCandidateFilter = null; bestCandidateFilter = null;
netFilterCandidates.length = 0; netFilterCandidates.length = 0;
cosmeticFilterCandidates.length = 0; cosmeticFilterCandidates.length = 0;
@ -701,34 +694,51 @@ var filtersFrom = function(x, y) {
TODO: need to be revised once I implement chained cosmetic operators. TODO: need to be revised once I implement chained cosmetic operators.
*/ */
const filterToDOMInterface = (function() {
const filterToDOMInterface = (( ) => {
const reHnAnchorPrefix = '^[\\w-]+://(?:[^/?#]+\\.)?';
const reCaret = '(?:[^%.0-9a-z_-]|$)';
// Net filters: we need to lookup manually -- translating into a foolproof // Net filters: we need to lookup manually -- translating into a foolproof
// CSS selector is just not possible. // CSS selector is just not possible.
//
// https://github.com/chrisaljoudi/uBlock/issues/945
// Transform into a regular expression, this allows the user to
// edit and insert wildcard(s) into the proposed filter.
// https://www.reddit.com/r/uBlockOrigin/comments/c5do7w/
// Better handling of pure hostname filters. Also, discard single
// alphanumeric character filters.
const fromNetworkFilter = function(filter) { const fromNetworkFilter = function(filter) {
const out = []; const out = [];
// https://github.com/chrisaljoudi/uBlock/issues/945 if ( /^[0-9a-z]$/i.test(filter) ) { return out; }
// Transform into a regular expression, this allows the user to edit and
// insert wildcard(s) into the proposed filter.
let reStr = ''; let reStr = '';
if ( filter.length > 1 && filter.charAt(0) === '/' && filter.slice(-1) === '/' ) { if (
filter.length > 2 &&
filter.startsWith('/') &&
filter.endsWith('/')
) {
reStr = filter.slice(1, -1); reStr = filter.slice(1, -1);
} } else if ( /^\w[\w.-]*[a-z]$/i.test(filter) ) {
else { reStr = reHnAnchorPrefix +
let rePrefix = '', reSuffix = ''; filter.toLowerCase().replace(/\./g, '\\.') +
if ( filter.slice(0, 2) === '||' ) { reCaret;
filter = filter.replace('||', '');
} else { } else {
if ( filter.charAt(0) === '|' ) { let rePrefix = '', reSuffix = '';
if ( filter.startsWith('||') ) {
rePrefix = reHnAnchorPrefix;
filter = filter.slice(2);
} else if ( filter.startsWith('|') ) {
rePrefix = '^'; rePrefix = '^';
filter = filter.slice(1); filter = filter.slice(1);
} }
} if ( filter.endsWith('|') ) {
if ( filter.slice(-1) === '|' ) {
reSuffix = '$'; reSuffix = '$';
filter = filter.slice(0, -1); filter = filter.slice(0, -1);
} }
reStr = rePrefix + reStr = rePrefix +
filter.replace(/[.+?${}()|[\]\\]/g, '\\$&').replace(/[\*^]+/g, '.*') + filter.replace(/[.+?${}()|[\]\\]/g, '\\$&')
.replace(/\*+/g, '.*')
.replace(/\^/g, reCaret) +
reSuffix; reSuffix;
} }
let reFilter = null; let reFilter = null;
@ -740,7 +750,7 @@ const filterToDOMInterface = (function() {
} }
// Lookup by tag names. // Lookup by tag names.
let elems = document.querySelectorAll( const elems = document.querySelectorAll(
Object.keys(netFilter1stSources).join() Object.keys(netFilter1stSources).join()
); );
for ( const elem of elems ) { for ( const elem of elems ) {
@ -781,19 +791,15 @@ const filterToDOMInterface = (function() {
}; };
// Cosmetic filters: these are straight CSS selectors. // Cosmetic filters: these are straight CSS selectors.
// TODO: This is still not working well for a[href], because there are many
// ways to compose a valid href to the same effective URL. One idea is to
// normalize all a[href] on the page, but for now I will wait and see, as I
// prefer to refrain from tampering with the page content if I can avoid it.
// //
// https://github.com/uBlockOrigin/uBlock-issues/issues/389 // https://github.com/uBlockOrigin/uBlock-issues/issues/389
// Test filter using comma-separated list to better detect invalid CSS // Test filter using comma-separated list to better detect invalid CSS
// selectors. // selectors.
const fromPlainCosmeticFilter = function(filter) { const fromPlainCosmeticFilter = function(raw) {
let elems; let elems;
try { try {
document.documentElement.matches(`${filter},\na`); document.documentElement.matches(`${raw},\na`);
elems = document.querySelectorAll(filter); elems = document.querySelectorAll(raw);
} }
catch (e) { catch (e) {
return; return;
@ -801,7 +807,7 @@ const filterToDOMInterface = (function() {
const out = []; const out = [];
for ( const elem of elems ) { for ( const elem of elems ) {
if ( elem === pickerRoot ) { continue; } if ( elem === pickerRoot ) { continue; }
out.push({ type: 'cosmetic', elem }); out.push({ type: 'cosmetic', elem, raw });
} }
return out; return out;
}; };
@ -826,7 +832,7 @@ const filterToDOMInterface = (function() {
if ( !elems ) { return; } if ( !elems ) { return; }
const out = []; const out = [];
for ( const elem of elems ) { for ( const elem of elems ) {
out.push({ type: 'cosmetic', elem }); out.push({ type: 'cosmetic', elem, raw });
} }
return out; return out;
}; };
@ -853,7 +859,7 @@ const filterToDOMInterface = (function() {
} }
lastFilter = filter; lastFilter = filter;
lastAction = undefined; lastAction = undefined;
if ( filter.lastIndexOf('##', 0) === -1 ) { if ( filter.startsWith('##') === false ) {
lastResultset = fromNetworkFilter(filter); lastResultset = fromNetworkFilter(filter);
if ( previewing ) { apply(); } if ( previewing ) { apply(); }
callback(lastResultset); callback(lastResultset);
@ -870,7 +876,7 @@ const filterToDOMInterface = (function() {
vAPI.messaging.send( vAPI.messaging.send(
'elementPicker', 'elementPicker',
{ what: 'compileCosmeticFilterSelector', selector: selector }, { what: 'compileCosmeticFilterSelector', selector: selector },
function(response) { response => {
lastResultset = fromCompiledCosmeticFilter(response); lastResultset = fromCompiledCosmeticFilter(response);
if ( previewing ) { apply(); } if ( previewing ) { apply(); }
callback(lastResultset); callback(lastResultset);
@ -878,11 +884,12 @@ const filterToDOMInterface = (function() {
); );
}; };
// https://github.com/gorhill/uBlock/issues/1629
// Avoid hiding the element picker's related elements.
const applyHide = function() { const applyHide = function() {
const htmlElem = document.documentElement; const htmlElem = document.documentElement;
for ( const item of lastResultset ) { for ( const item of lastResultset ) {
const elem = item.elem; const elem = item.elem;
// https://github.com/gorhill/uBlock/issues/1629
if ( elem === pickerRoot ) { continue; } if ( elem === pickerRoot ) { continue; }
if ( if (
(elem !== htmlElem) && (elem !== htmlElem) &&
@ -959,17 +966,38 @@ const filterToDOMInterface = (function() {
applied = false; applied = false;
}; };
const preview = function(filter) { // https://www.reddit.com/r/uBlockOrigin/comments/c62irc/
previewing = filter !== false; // Support injecting the cosmetic filters into the DOM filterer
if ( previewing ) { // immediately rather than wait for the next page load.
queryAll(filter, items => { const preview = function(rawFilter, permanent = false) {
previewing = rawFilter !== false;
pickerBody.classList.toggle('preview', previewing);
if ( previewing === false ) {
return unapply();
}
queryAll(rawFilter, items => {
if ( items === undefined ) { return; } if ( items === undefined ) { return; }
apply(); apply();
}); if ( permanent === false ) { return; }
if ( vAPI.domFilterer instanceof Object === false ) { return; }
const cssSelectors = new Set();
const proceduralSelectors = new Set();
for ( const item of items ) {
if ( item.type !== 'cosmetic' ) { continue; }
if ( item.raw.startsWith('{') ) {
proceduralSelectors.add(item.raw);
} else { } else {
unapply(); cssSelectors.add(item.raw);
} }
pickerBody.classList.toggle('preview', previewing); }
vAPI.domFilterer.addCSSRule(
Array.from(cssSelectors),
'display:none!important;'
);
vAPI.domFilterer.addProceduralSelectors(
Array.from(proceduralSelectors)
);
});
}; };
return { return {
@ -983,7 +1011,7 @@ const filterToDOMInterface = (function() {
const userFilterFromCandidate = function(callback) { const userFilterFromCandidate = function(callback) {
let v = rawFilterFromTextarea(); let v = rawFilterFromTextarea();
filterToDOMInterface.set(v, function(items) { filterToDOMInterface.set(v, items => {
if ( !items || items.length === 0 ) { if ( !items || items.length === 0 ) {
callback(); callback();
return; return;
@ -997,7 +1025,7 @@ const userFilterFromCandidate = function(callback) {
} }
// Cosmetic filter? // Cosmetic filter?
if ( v.lastIndexOf('##', 0) === 0 ) { if ( v.startsWith('##') ) {
callback(hostname + v); callback(hostname + v);
return; return;
} }
@ -1006,8 +1034,8 @@ const userFilterFromCandidate = function(callback) {
const opts = []; const opts = [];
// If no domain included in filter, we need domain option // If no domain included in filter, we need domain option
if ( v.lastIndexOf('||', 0) === -1 ) { if ( v.startsWith('||') === false ) {
opts.push('domain=' + hostname); opts.push(`domain=${hostname}`);
} }
const item = items[0]; const item = items[0];
@ -1113,9 +1141,9 @@ const candidateFromFilterChoice = function(filterChoice) {
/******************************************************************************/ /******************************************************************************/
const filterChoiceFromEvent = function(ev) { const filterChoiceFromEvent = function(ev) {
var li = ev.target; let li = ev.target;
var isNetFilter = li.textContent.slice(0, 2) !== '##'; const isNetFilter = li.textContent.startsWith('##') === false;
var r = { const r = {
filters: isNetFilter ? netFilterCandidates : cosmeticFilterCandidates, filters: isNetFilter ? netFilterCandidates : cosmeticFilterCandidates,
slot: 0, slot: 0,
modifier: ev.ctrlKey || ev.metaKey modifier: ev.ctrlKey || ev.metaKey
@ -1146,7 +1174,7 @@ const onDialogClicked = function(ev) {
// We have to exit from preview mode: this guarantees matching elements // We have to exit from preview mode: this guarantees matching elements
// will be found for the candidate filter. // will be found for the candidate filter.
filterToDOMInterface.preview(false); filterToDOMInterface.preview(false);
userFilterFromCandidate(function(filter) { userFilterFromCandidate(filter => {
if ( !filter ) { return; } if ( !filter ) { return; }
vAPI.messaging.send( vAPI.messaging.send(
'elementPicker', 'elementPicker',
@ -1158,7 +1186,7 @@ const onDialogClicked = function(ev) {
pageDomain: window.location.hostname pageDomain: window.location.hostname
} }
); );
filterToDOMInterface.preview(rawFilterFromTextarea()); filterToDOMInterface.preview(rawFilterFromTextarea(), true);
stopPicker(); stopPicker();
}); });
} }
@ -1291,10 +1319,10 @@ const zap = function() {
/******************************************************************************/ /******************************************************************************/
var elementFromPoint = (function() { const elementFromPoint = (( ) => {
var lastX, lastY; let lastX, lastY;
return function(x, y) { return (x, y) => {
if ( x !== undefined ) { if ( x !== undefined ) {
lastX = x; lastY = y; lastX = x; lastY = y;
} else if ( lastX !== undefined ) { } else if ( lastX !== undefined ) {
@ -1304,7 +1332,7 @@ var elementFromPoint = (function() {
} }
if ( !pickerRoot ) { return null; } if ( !pickerRoot ) { return null; }
pickerRoot.style.setProperty('pointer-events', 'none', 'important'); pickerRoot.style.setProperty('pointer-events', 'none', 'important');
var elem = document.elementFromPoint(x, y); let elem = document.elementFromPoint(x, y);
if ( elem === document.body || elem === document.documentElement ) { if ( elem === document.body || elem === document.documentElement ) {
elem = null; elem = null;
} }
@ -1347,7 +1375,7 @@ const onSvgHovered = (function() {
*/ */
var onSvgTouchStartStop = (function() { const onSvgTouchStartStop = (function() {
var startX, var startX,
startY; startY;
return function onTouch(ev) { return function onTouch(ev) {
@ -1407,7 +1435,7 @@ var onSvgTouchStartStop = (function() {
/******************************************************************************/ /******************************************************************************/
var onSvgClicked = function(ev) { const onSvgClicked = function(ev) {
if ( ev.isTrusted === false ) { return; } if ( ev.isTrusted === false ) { return; }
// If zap mode, highlight element under mouse, this makes the zapper usable // If zap mode, highlight element under mouse, this makes the zapper usable
@ -1448,14 +1476,14 @@ var onSvgClicked = function(ev) {
/******************************************************************************/ /******************************************************************************/
var svgListening = function(on) { const svgListening = function(on) {
var action = (on ? 'add' : 'remove') + 'EventListener'; var action = (on ? 'add' : 'remove') + 'EventListener';
svgRoot[action]('mousemove', onSvgHovered, { passive: true }); svgRoot[action]('mousemove', onSvgHovered, { passive: true });
}; };
/******************************************************************************/ /******************************************************************************/
var onKeyPressed = function(ev) { const onKeyPressed = function(ev) {
// Delete // Delete
if ( ev.key === 'Delete' && pickerBody.classList.contains('zap') ) { if ( ev.key === 'Delete' && pickerBody.classList.contains('zap') ) {
ev.stopPropagation(); ev.stopPropagation();
@ -1479,20 +1507,20 @@ var onKeyPressed = function(ev) {
// May need to dynamically adjust the height of the overlay + new position // May need to dynamically adjust the height of the overlay + new position
// of highlighted elements. // of highlighted elements.
var onScrolled = function() { const onScrolled = function() {
highlightElements(targetElements, true); highlightElements(targetElements, true);
}; };
/******************************************************************************/ /******************************************************************************/
var pausePicker = function() { const pausePicker = function() {
pickerBody.classList.add('paused'); pickerBody.classList.add('paused');
svgListening(false); svgListening(false);
}; };
/******************************************************************************/ /******************************************************************************/
var unpausePicker = function() { const unpausePicker = function() {
filterToDOMInterface.preview(false); filterToDOMInterface.preview(false);
pickerBody.classList.remove('paused'); pickerBody.classList.remove('paused');
svgListening(true); svgListening(true);
@ -1503,7 +1531,7 @@ var unpausePicker = function() {
// Let's have the element picker code flushed from memory when no longer // Let's have the element picker code flushed from memory when no longer
// in use: to ensure this, release all local references. // in use: to ensure this, release all local references.
var stopPicker = function() { const stopPicker = function() {
vAPI.shutdown.remove(stopPicker); vAPI.shutdown.remove(stopPicker);
targetElements = []; targetElements = [];

View File

@ -2077,6 +2077,13 @@ FilterParser.prototype.parse = function(raw) {
let s = this.raw = raw; let s = this.raw = raw;
// Filters which are a single alphanumeric character are discarded
// as unsupported.
if ( s.length === 1 && /[0-9a-z]/i.test(s) ) {
this.unsupported = true;
return this;
}
// plain hostname? (from HOSTS file) // plain hostname? (from HOSTS file)
if ( this.reHostnameRule1.test(s) ) { if ( this.reHostnameRule1.test(s) ) {
this.f = s.toLowerCase(); this.f = s.toLowerCase();