From 4dccbfd787ace8b5c8c19a598aaff3711b3aa854 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Tue, 30 Apr 2019 09:02:14 -0400 Subject: [PATCH] Add new cosmetic procedural operator: `:nth-ancestor(n)` The purpose of this new `:nth-ancestor(n)` operator is to lookup the nth ancestor relative to the currently selected node. It is essentially equivalent to `:xpath(..)`, where ancestor distance is expressed as a number rather than a sequence of slash-separated `..`. The rationale to introduce this new procedural selector is to have a low overhead way to accomplish ancestor selection. --- src/js/contentscript.js | 378 ++++++++++++++++++--------------- src/js/static-ext-filtering.js | 30 ++- 2 files changed, 228 insertions(+), 180 deletions(-) diff --git a/src/js/contentscript.js b/src/js/contentscript.js index 90a4194a6..1e3f4d3b8 100644 --- a/src/js/contentscript.js +++ b/src/js/contentscript.js @@ -369,217 +369,253 @@ vAPI.DOMFilterer = (function() { // 'P' stands for 'Procedural' - const PSelectorHasTextTask = function(task) { - let arg0 = task[1], arg1; - if ( Array.isArray(task[1]) ) { - arg1 = arg0[1]; arg0 = arg0[0]; - } - this.needle = new RegExp(arg0, arg1); - }; - PSelectorHasTextTask.prototype.exec = function(input) { - const output = []; - for ( const node of input ) { - if ( this.needle.test(node.textContent) ) { - output.push(node); + const PSelectorHasTextTask = class { + constructor(task) { + let arg0 = task[1], arg1; + if ( Array.isArray(task[1]) ) { + arg1 = arg0[1]; arg0 = arg0[0]; } + this.needle = new RegExp(arg0, arg1); } - return output; - }; - - const PSelectorIfTask = function(task) { - this.pselector = new PSelector(task[1]); - }; - PSelectorIfTask.prototype.target = true; - PSelectorIfTask.prototype.exec = function(input) { - const output = []; - for ( const node of input ) { - if ( this.pselector.test(node) === this.target ) { - output.push(node); + exec(input) { + const output = []; + for ( const node of input ) { + if ( this.needle.test(node.textContent) ) { + output.push(node); + } } + return output; } - return output; }; - const PSelectorIfNotTask = function(task) { - PSelectorIfTask.call(this, task); - this.target = false; - }; - PSelectorIfNotTask.prototype = Object.create(PSelectorIfTask.prototype); - PSelectorIfNotTask.prototype.constructor = PSelectorIfNotTask; - - const PSelectorMatchesCSSTask = function(task) { - this.name = task[1].name; - let arg0 = task[1].value, arg1; - if ( Array.isArray(arg0) ) { - arg1 = arg0[1]; arg0 = arg0[0]; + const PSelectorIfTask = class { + constructor(task) { + this.pselector = new PSelector(task[1]); } - this.value = new RegExp(arg0, arg1); - }; - PSelectorMatchesCSSTask.prototype.pseudo = null; - PSelectorMatchesCSSTask.prototype.exec = function(input) { - const output = []; - for ( const node of input ) { - const style = window.getComputedStyle(node, this.pseudo); - if ( style === null ) { return null; } /* FF */ - if ( this.value.test(style[this.name]) ) { - output.push(node); + exec(input) { + const output = []; + for ( const node of input ) { + if ( this.pselector.test(node) === this.target ) { + output.push(node); + } } + return output; } - return output; - }; - - const PSelectorMatchesCSSAfterTask = function(task) { - PSelectorMatchesCSSTask.call(this, task); - this.pseudo = ':after'; }; - PSelectorMatchesCSSAfterTask.prototype = Object.create(PSelectorMatchesCSSTask.prototype); - PSelectorMatchesCSSAfterTask.prototype.constructor = PSelectorMatchesCSSAfterTask; + PSelectorIfTask.prototype.target = true; - const PSelectorMatchesCSSBeforeTask = function(task) { - PSelectorMatchesCSSTask.call(this, task); - this.pseudo = ':before'; + const PSelectorIfNotTask = class extends PSelectorIfTask { + constructor(task) { + super(task); + this.target = false; + } }; - PSelectorMatchesCSSBeforeTask.prototype = Object.create(PSelectorMatchesCSSTask.prototype); - PSelectorMatchesCSSBeforeTask.prototype.constructor = PSelectorMatchesCSSBeforeTask; - const PSelectorSpathTask = function(task) { - this.spath = task[1]; - }; - PSelectorSpathTask.prototype.exec = function(input) { - const output = []; - for ( let node of input ) { - const parent = node.parentElement; - if ( parent === null ) { continue; } - let pos = 1; - for (;;) { - node = node.previousElementSibling; - if ( node === null ) { break; } - pos += 1; + const PSelectorMatchesCSSTask = class { + constructor(task) { + this.name = task[1].name; + let arg0 = task[1].value, arg1; + if ( Array.isArray(arg0) ) { + arg1 = arg0[1]; arg0 = arg0[0]; } - const nodes = parent.querySelectorAll( - ':scope > :nth-child(' + pos + ')' + this.spath - ); - for ( const node of nodes ) { - output.push(node); + this.value = new RegExp(arg0, arg1); + } + exec(input) { + const output = []; + for ( const node of input ) { + const style = window.getComputedStyle(node, this.pseudo); + if ( style === null ) { return null; } /* FF */ + if ( this.value.test(style[this.name]) ) { + output.push(node); + } } + return output; } - return output; }; + PSelectorMatchesCSSTask.prototype.pseudo = null; - const PSelectorWatchAttrs = function(task) { - this.observer = null; - this.observed = new WeakSet(); - this.observerOptions = { - attributes: true, - subtree: true, - }; - const attrs = task[1]; - if ( Array.isArray(attrs) && attrs.length !== 0 ) { - this.observerOptions.attributeFilter = task[1]; + const PSelectorMatchesCSSAfterTask = class extends PSelectorMatchesCSSTask { + constructor(task) { + super(task); + this.pseudo = ':after'; } }; - // TODO: Is it worth trying to re-apply only the current selector? - PSelectorWatchAttrs.prototype.handler = function() { - const filterer = - vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer; - if ( filterer instanceof Object ) { - filterer.onDOMChanged([ null ]); + + const PSelectorMatchesCSSBeforeTask = class extends PSelectorMatchesCSSTask { + constructor(task) { + super(task); + this.pseudo = ':before'; } }; - PSelectorWatchAttrs.prototype.exec = function(input) { - if ( input.length === 0 ) { return input; } - if ( this.observer === null ) { - this.observer = new MutationObserver(this.handler); + + const PSelectorNthAncestorTask = class { + constructor(task) { + this.nth = task[1]; } - for ( const node of input ) { - if ( this.observed.has(node) ) { continue; } - this.observer.observe(node, this.observerOptions); - this.observed.add(node); + exec(input) { + const output = []; + for ( let node of input ) { + let nth = this.nth; + for (;;) { + node = node.parentElement; + if ( node === null ) { break; } + nth -= 1; + if ( nth !== 0 ) { continue; } + output.push(node); + break; + } + } + return output; } - return input; }; - const PSelectorXpathTask = function(task) { - this.xpe = document.createExpression(task[1], null); - this.xpr = null; - }; - PSelectorXpathTask.prototype.exec = function(input) { - const output = []; - for ( const node of input ) { - this.xpr = this.xpe.evaluate( - node, - XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, - this.xpr - ); - let j = this.xpr.snapshotLength; - while ( j-- ) { - const node = this.xpr.snapshotItem(j); - if ( node.nodeType === 1 ) { + const PSelectorSpathTask = class { + constructor(task) { + this.spath = task[1]; + } + exec(input) { + const output = []; + for ( let node of input ) { + const parent = node.parentElement; + if ( parent === null ) { continue; } + let pos = 1; + for (;;) { + node = node.previousElementSibling; + if ( node === null ) { break; } + pos += 1; + } + const nodes = parent.querySelectorAll( + ':scope > :nth-child(' + pos + ')' + this.spath + ); + for ( const node of nodes ) { output.push(node); } } + return output; } - return output; }; - const PSelector = function(o) { - if ( PSelector.prototype.operatorToTaskMap === undefined ) { - PSelector.prototype.operatorToTaskMap = new Map([ - [ ':has', PSelectorIfTask ], - [ ':has-text', PSelectorHasTextTask ], - [ ':if', PSelectorIfTask ], - [ ':if-not', PSelectorIfNotTask ], - [ ':matches-css', PSelectorMatchesCSSTask ], - [ ':matches-css-after', PSelectorMatchesCSSAfterTask ], - [ ':matches-css-before', PSelectorMatchesCSSBeforeTask ], - [ ':not', PSelectorIfNotTask ], - [ ':spath', PSelectorSpathTask ], - [ ':watch-attrs', PSelectorWatchAttrs ], - [ ':xpath', PSelectorXpathTask ], - ]); - } - this.budget = 200; // I arbitrary picked a 1/5 second - this.raw = o.raw; - this.cost = 0; - this.lastAllowanceTime = 0; - this.selector = o.selector; - this.tasks = []; - const tasks = o.tasks; - if ( !tasks ) { return; } - for ( const task of tasks ) { - this.tasks.push(new (this.operatorToTaskMap.get(task[0]))(task)); + const PSelectorWatchAttrs = class { + constructor(task) { + this.observer = null; + this.observed = new WeakSet(); + this.observerOptions = { + attributes: true, + subtree: true, + }; + const attrs = task[1]; + if ( Array.isArray(attrs) && attrs.length !== 0 ) { + this.observerOptions.attributeFilter = task[1]; + } } - }; - PSelector.prototype.operatorToTaskMap = undefined; - PSelector.prototype.prime = function(input) { - const root = input || document; - if ( this.selector !== '' ) { - return root.querySelectorAll(this.selector); + // TODO: Is it worth trying to re-apply only the current selector? + handler() { + const filterer = + vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer; + if ( filterer instanceof Object ) { + filterer.onDOMChanged([ null ]); + } } - return [ root ]; - }; - PSelector.prototype.exec = function(input) { - let nodes = this.prime(input); - for ( const task of this.tasks ) { - if ( nodes.length === 0 ) { break; } - nodes = task.exec(nodes); + exec(input) { + if ( input.length === 0 ) { return input; } + if ( this.observer === null ) { + this.observer = new MutationObserver(this.handler); + } + for ( const node of input ) { + if ( this.observed.has(node) ) { continue; } + this.observer.observe(node, this.observerOptions); + this.observed.add(node); + } + return input; } - return nodes; }; - PSelector.prototype.test = function(input) { - const nodes = this.prime(input); - const AA = [ null ]; - for ( const node of nodes ) { - AA[0] = node; - let aa = AA; + + const PSelectorXpathTask = class { + constructor(task) { + this.xpe = document.createExpression(task[1], null); + this.xpr = null; + } + exec(input) { + const output = []; + for ( const node of input ) { + this.xpr = this.xpe.evaluate( + node, + XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, + this.xpr + ); + let j = this.xpr.snapshotLength; + while ( j-- ) { + const node = this.xpr.snapshotItem(j); + if ( node.nodeType === 1 ) { + output.push(node); + } + } + } + return output; + } + }; + + const PSelector = class { + constructor(o) { + if ( PSelector.prototype.operatorToTaskMap === undefined ) { + PSelector.prototype.operatorToTaskMap = new Map([ + [ ':has', PSelectorIfTask ], + [ ':has-text', PSelectorHasTextTask ], + [ ':if', PSelectorIfTask ], + [ ':if-not', PSelectorIfNotTask ], + [ ':matches-css', PSelectorMatchesCSSTask ], + [ ':matches-css-after', PSelectorMatchesCSSAfterTask ], + [ ':matches-css-before', PSelectorMatchesCSSBeforeTask ], + [ ':not', PSelectorIfNotTask ], + [ ':nth-ancestor', PSelectorNthAncestorTask ], + [ ':spath', PSelectorSpathTask ], + [ ':watch-attrs', PSelectorWatchAttrs ], + [ ':xpath', PSelectorXpathTask ], + ]); + } + this.budget = 200; // I arbitrary picked a 1/5 second + this.raw = o.raw; + this.cost = 0; + this.lastAllowanceTime = 0; + this.selector = o.selector; + this.tasks = []; + const tasks = o.tasks; + if ( !tasks ) { return; } + for ( const task of tasks ) { + this.tasks.push(new (this.operatorToTaskMap.get(task[0]))(task)); + } + } + prime(input) { + const root = input || document; + if ( this.selector !== '' ) { + return root.querySelectorAll(this.selector); + } + return [ root ]; + } + exec(input) { + let nodes = this.prime(input); for ( const task of this.tasks ) { - aa = task.exec(aa); - if ( aa.length === 0 ) { break; } + if ( nodes.length === 0 ) { break; } + nodes = task.exec(nodes); } - if ( aa.length !== 0 ) { return true; } + return nodes; + } + test(input) { + const nodes = this.prime(input); + const AA = [ null ]; + for ( const node of nodes ) { + AA[0] = node; + let aa = AA; + for ( const task of this.tasks ) { + aa = task.exec(aa); + if ( aa.length === 0 ) { break; } + } + if ( aa.length !== 0 ) { return true; } + } + return false; } - return false; }; + PSelector.prototype.operatorToTaskMap = undefined; const DOMProceduralFilterer = function(domFilterer) { this.domFilterer = domFilterer; diff --git a/src/js/static-ext-filtering.js b/src/js/static-ext-filtering.js index 55ea29b20..91a80cbca 100644 --- a/src/js/static-ext-filtering.js +++ b/src/js/static-ext-filtering.js @@ -167,6 +167,7 @@ 'matches-css-after', 'matches-css-before', 'not', + 'nth-ancestor', 'watch-attrs', 'xpath' ].join('|'), @@ -236,6 +237,13 @@ } }; + const compileNthAncestorSelector = function(s) { + const n = parseInt(s, 10); + if ( isNaN(n) === false && n >= 1 && n < 256 ) { + return n; + } + }; + const compileSpathExpression = function(s) { if ( isValidCSSSelector('*' + s) ) { return s; @@ -278,6 +286,7 @@ [ ':matches-css-after', compileCSSDeclaration ], [ ':matches-css-before', compileCSSDeclaration ], [ ':not', compileNotSelector ], + [ ':nth-ancestor', compileNthAncestorSelector ], [ ':spath', compileSpathExpression ], [ ':watch-attrs', compileAttrList ], [ ':xpath', compileXpathExpression ] @@ -301,42 +310,45 @@ switch ( task[0] ) { case ':has': case ':if': - raw.push(':has', '(', decompile(task[1]), ')'); + raw.push(`:has(${decompile(task[1])})`); break; case ':has-text': if ( Array.isArray(task[1]) ) { - value = '/' + task[1][0] + '/' + task[1][1]; + value = `/${task[1][0]}/${task[1][1]}`; } else { value = regexToRawValue.get(task[1]); if ( value === undefined ) { - value = '/' + task[1] + '/'; + value = `/${task[1]}/`; } } - raw.push(task[0], '(', value, ')'); + raw.push(`:has-text(${value})`); break; case ':matches-css': case ':matches-css-after': case ':matches-css-before': if ( Array.isArray(task[1].value) ) { - value = '/' + task[1].value[0] + '/' + task[1].value[1]; + value = `/${task[1].value[0]}/${task[1].value[1]}`; } else { value = regexToRawValue.get(task[1].value); if ( value === undefined ) { - value = '/' + task[1].value + '/'; + value = `/${task[1].value}/`; } } - raw.push(task[0], '(', task[1].name, ': ', value, ')'); + raw.push(`${task[0]}(${task[1].name}: ${value})`); break; case ':not': case ':if-not': - raw.push(':not', '(', decompile(task[1]), ')'); + raw.push(`:not(${decompile(task[1])})`); + break; + case ':nth-ancestor': + raw.push(`:nth-ancestor(${task[1]})`); break; case ':spath': raw.push(task[1]); break; case ':watch-attrs': case ':xpath': - raw.push(task[0], '(', task[1], ')'); + raw.push(`${task[0]}(${task[1]})`); break; } }