From 4c5197322f7f4c74412f01747e7e0c5e2a023677 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Fri, 16 Oct 2020 17:12:22 -0400 Subject: [PATCH] Improve specificity slider in element picker The specificity slider will now be more intuitive by ordering candidates by match count from highest match count to the left to the lowest match count to the right. Candidates with same match counts will be discarded and replaced with the shortest candidate. --- src/js/epicker-ui.js | 162 +++++++++++++++++++---------------- src/js/scriptlets/epicker.js | 48 ++++++++--- 2 files changed, 123 insertions(+), 87 deletions(-) diff --git a/src/js/epicker-ui.js b/src/js/epicker-ui.js index bc81e9e72..2d1767a0b 100644 --- a/src/js/epicker-ui.js +++ b/src/js/epicker-ui.js @@ -63,6 +63,7 @@ let netFilterCandidates = []; let cosmeticFilterCandidates = []; let computedCandidateSlot = 0; let computedCandidate = ''; +let computedSpecificityCandidates = []; let needBody = false; /******************************************************************************/ @@ -194,7 +195,13 @@ const candidateFromFilterChoice = function(filterChoice) { $stor(`#cosmeticFilters li:nth-of-type(${slot+1})`) .classList.add('active'); - const specificity = [ + return cosmeticCandidatesFromFilterChoice(filterChoice); +}; + +/******************************************************************************/ + +const cosmeticCandidatesFromFilterChoice = function(filterChoice) { + const specificities = [ 0b0000, // remove hierarchy; remove id, nth-of-type, attribute values 0b0010, // remove hierarchy; remove id, nth-of-type 0b0011, // remove hierarchy @@ -203,89 +210,100 @@ const candidateFromFilterChoice = function(filterChoice) { 0b1100, // remove id, nth-of-type, attribute values 0b1110, // remove id, nth-of-type 0b1111, // keep all = most specific - ][ parseInt($stor('#resultsetSpecificity input').value, 10) ]; + ]; - // Return path: the target element, then all siblings prepended - const paths = []; - for ( let i = slot; i < filters.length; i++ ) { - filter = filters[i].slice(2); - // Remove id, nth-of-type - // https://github.com/uBlockOrigin/uBlock-issues/issues/162 - // Mind escaped periods: they do not denote a class identifier. - if ( (specificity & 0b0001) === 0 ) { - filter = filter.replace(/:nth-of-type\(\d+\)/, ''); - if ( - filter.charAt(0) === '#' && ( - (specificity & 0b1000) === 0 || i === slot - ) - ) { - const pos = filter.search(/[^\\]\./); - if ( pos !== -1 ) { - filter = filter.slice(pos + 1); + const candidates = []; + + let { slot, filters } = filterChoice; + let filter = filters[slot]; + + for ( const specificity of specificities ) { + // Return path: the target element, then all siblings prepended + const paths = []; + for ( let i = slot; i < filters.length; i++ ) { + filter = filters[i].slice(2); + // Remove id, nth-of-type + // https://github.com/uBlockOrigin/uBlock-issues/issues/162 + // Mind escaped periods: they do not denote a class identifier. + if ( (specificity & 0b0001) === 0 ) { + filter = filter.replace(/:nth-of-type\(\d+\)/, ''); + if ( + filter.charAt(0) === '#' && ( + (specificity & 0b1000) === 0 || i === slot + ) + ) { + const pos = filter.search(/[^\\]\./); + if ( pos !== -1 ) { + filter = filter.slice(pos + 1); + } + } + } + // Remove attribute values. + if ( (specificity & 0b0010) === 0 ) { + const match = /^\[([^^=]+)\^?=.+\]$/.exec(filter); + if ( match !== null ) { + filter = `[${match[1]}]`; + } + } + // Remove all classes when an id exists. + // https://github.com/uBlockOrigin/uBlock-issues/issues/162 + // Mind escaped periods: they do not denote a class identifier. + if ( filter.charAt(0) === '#' ) { + filter = filter.replace(/([^\\])\..+$/, '$1'); + } + if ( paths.length !== 0 ) { + filter += ' > '; + } + paths.unshift(filter); + // Stop at any element with an id: these are unique in a web page + if ( (specificity & 0b1000) === 0 || filter.startsWith('#') ) { + break; + } + } + + // Trim hierarchy: remove generic elements from path + if ( (specificity & 0b1100) === 0b1000 ) { + let i = 0; + while ( i < paths.length - 1 ) { + if ( /^[a-z0-9]+ > $/.test(paths[i+1]) ) { + if ( paths[i].endsWith(' > ') ) { + paths[i] = paths[i].slice(0, -2); + } + paths.splice(i + 1, 1); + } else { + i += 1; } } } - // Remove attribute values. - if ( (specificity & 0b0010) === 0 ) { - const match = /^\[([^^=]+)\^?=.+\]$/.exec(filter); - if ( match !== null ) { - filter = `[${match[1]}]`; - } - } - // Remove all classes when an id exists. - // https://github.com/uBlockOrigin/uBlock-issues/issues/162 - // Mind escaped periods: they do not denote a class identifier. - if ( filter.charAt(0) === '#' ) { - filter = filter.replace(/([^\\])\..+$/, '$1'); - } - if ( paths.length !== 0 ) { - filter += ' > '; - } - paths.unshift(filter); - // Stop at any element with an id: these are unique in a web page - if ( (specificity & 0b1000) === 0 || filter.startsWith('#') ) { break; } - } - // Trim hierarchy: remove generic elements from path - if ( (specificity & 0b1100) === 0b1000 ) { - let i = 0; - while ( i < paths.length - 1 ) { - if ( /^[a-z0-9]+ > $/.test(paths[i+1]) ) { - if ( paths[i].endsWith(' > ') ) { - paths[i] = paths[i].slice(0, -2); - } - paths.splice(i + 1, 1); - } else { - i += 1; - } + if ( + needBody && + paths.length !== 0 && + paths[0].startsWith('#') === false && + (specificity & 0b1100) !== 0 + ) { + paths.unshift('body > '); } - } - if ( - needBody && - paths.length !== 0 && - paths[0].startsWith('#') === false && - (specificity & 0b1100) !== 0 - ) { - paths.unshift('body > '); + candidates.push(paths); } - if ( paths.length === 0 ) { return ''; } - renderRange('resultsetDepth', slot, true); renderRange('resultsetSpecificity'); vAPI.MessagingConnection.sendTo(epickerConnectionId, { - what: 'optimizeCandidate', - paths, + what: 'optimizeCandidates', + candidates, }); }; /******************************************************************************/ -const onCandidateOptimized = function(details) { +const onCandidatesOptimized = function(details) { $id('resultsetModifiers').classList.remove('hide'); - computedCandidate = details.filter; + const i = parseInt($stor('#resultsetSpecificity input').value, 10); + computedSpecificityCandidates = details.candidates; + computedCandidate = computedSpecificityCandidates[i]; cmEditor.setValue(computedCandidate); cmEditor.clearHistory(); onCandidateChanged(); @@ -501,13 +519,11 @@ const onDepthChanged = function() { /******************************************************************************/ const onSpecificityChanged = function() { + renderRange('resultsetSpecificity'); if ( rawFilterFromTextarea() !== computedCandidate ) { return; } - const text = candidateFromFilterChoice({ - filters: cosmeticFilterCandidates, - slot: computedCandidateSlot, - }); - if ( text === undefined ) { return; } - cmEditor.setValue(text); + const i = parseInt($stor('#resultsetSpecificity input').value, 10); + computedCandidate = computedSpecificityCandidates[i]; + cmEditor.setValue(computedCandidate); cmEditor.clearHistory(); onCandidateChanged(); }; @@ -808,8 +824,8 @@ const quitPicker = function() { const onPickerMessage = function(msg) { switch ( msg.what ) { - case 'candidateOptimized': - onCandidateOptimized(msg); + case 'candidatesOptimized': + onCandidatesOptimized(msg); break; case 'showDialog': showDialog(msg); diff --git a/src/js/scriptlets/epicker.js b/src/js/scriptlets/epicker.js index 2b4b98d98..4571ebba3 100644 --- a/src/js/scriptlets/epicker.js +++ b/src/js/scriptlets/epicker.js @@ -821,21 +821,41 @@ const filterToDOMInterface = (( ) => { /******************************************************************************/ -const onOptmizeCandidate = function(details) { - const { paths } = details; - let count = Number.MAX_SAFE_INTEGER; - let selector = ''; - for ( let i = 0, n = paths.length; i < n; i++ ) { - const s = paths.slice(n - i - 1).join(''); - const elems = document.querySelectorAll(s); - if ( elems.length < count ) { - selector = s; - count = elems.length; +const onOptmizeCandidates = function(details) { + const { candidates } = details; + const results = []; + for ( const paths of candidates ) { + let count = Number.MAX_SAFE_INTEGER; + let selector = ''; + for ( let i = 0, n = paths.length; i < n; i++ ) { + const s = paths.slice(n - i - 1).join(''); + const elems = document.querySelectorAll(s); + if ( elems.length < count ) { + selector = s; + count = elems.length; + } } + results.push({ selector: `##${selector}`, count }); + } + // Sort by most match count and shortest selector to least match count and + // longest selector. + results.sort((a, b) => { + const r = b.count - a.count; + if ( r !== 0 ) { return r; } + return a.selector.length - b.selector.length; + }); + // Discard selectors with same match count as shorter ones. + for ( let i = 0; i < results.length - 1; i++ ) { + const a = results[i+0]; + const b = results[i+1]; + if ( b.count !== a.count ) { continue; } + if ( b.selector.length === a.selector.length ) { continue; } + b.selector = a.selector; + b.count = a.count; } vAPI.MessagingConnection.sendTo(epickerConnectionId, { - what: 'candidateOptimized', - filter: `##${selector}`, + what: 'candidatesOptimized', + candidates: results.map(a => a.selector), }); }; @@ -1064,8 +1084,8 @@ const onDialogMessage = function(msg) { highlightElements([], true); } break; - case 'optimizeCandidate': - onOptmizeCandidate(msg); + case 'optimizeCandidates': + onOptmizeCandidates(msg); break; case 'dialogCreate': filterToDOMInterface.queryAll(msg);