From 9eb455ab5eb2d5640f4188e71011b7aa22ab8b43 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Tue, 1 Sep 2020 12:32:12 -0400 Subject: [PATCH] Isolate element picker dialog from page content world Related issues: - https://github.com/gorhill/uBlock/issues/3497 - https://github.com/uBlockOrigin/uBlock-issues/issues/1215 To solve above issues, the element picker's dialog is now isolated from the page content in which it is embedded. The highly interactive, mouse-driven part of the element picker is still visible by the page content. --- platform/chromium/vapi-client.js | 8 +- src/css/epicker-dialog.css | 184 +++++ src/css/epicker.css | 60 ++ src/epicker.html | 250 ------ src/js/epicker-dialog.js | 499 +++++++++++ src/js/messaging.js | 36 +- .../{element-picker.js => epicker.js} | 775 ++++++------------ src/js/ublock.js | 2 +- .../epicker-dialog.html | 48 ++ 9 files changed, 1043 insertions(+), 819 deletions(-) create mode 100644 src/css/epicker-dialog.css create mode 100644 src/css/epicker.css delete mode 100644 src/epicker.html create mode 100644 src/js/epicker-dialog.js rename src/js/scriptlets/{element-picker.js => epicker.js} (69%) create mode 100644 src/web_accessible_resources/epicker-dialog.html diff --git a/platform/chromium/vapi-client.js b/platform/chromium/vapi-client.js index c8a934dec..491e503ab 100644 --- a/platform/chromium/vapi-client.js +++ b/platform/chromium/vapi-client.js @@ -102,8 +102,14 @@ vAPI.messaging = { }, disconnectListenerBound: null, + // 2020-09-01: + // In Firefox, `details instanceof Object` resolves to `false` despite + // `details` being a valid object. Consequently, falling back to use + // `typeof details`. + // This is an issue which surfaced when the element picker code was + // revisited to isolate the picker dialog DOM from the page DOM. messageListener: function(details) { - if ( details instanceof Object === false ) { return; } + if ( typeof details !== 'object' || details === null ) { return; } // Response to specific message previously sent if ( details.msgId !== undefined ) { diff --git a/src/css/epicker-dialog.css b/src/css/epicker-dialog.css new file mode 100644 index 000000000..5b0c0e44f --- /dev/null +++ b/src/css/epicker-dialog.css @@ -0,0 +1,184 @@ +html#ublock0-epicker, +#ublock0-epicker body { + background: transparent; + color: black; + cursor: not-allowed; + font: 12px sans-serif; + height: 100vh; + margin: 0; + overflow: hidden; + width: 100vw; +} +#ublock0-epicker :focus { + outline: none; +} +#ublock0-epicker ul, +#ublock0-epicker li, +#ublock0-epicker div { + display: block; +} +#ublock0-epicker #toolbar { + cursor: grab; + display: flex; + justify-content: space-between; +} +#ublock0-epicker aside.moving #toolbar { + cursor: grabbing; +} +#ublock0-epicker ul { + margin: 0.25em 0 0 0; +} +#ublock0-epicker button { + background-color: #ccc; + border: 1px solid #aaa; + border-radius: 3px; + box-sizing: border-box; + box-shadow: none; + color: #000; + cursor: pointer; + opacity: 0.7; + padding: 4px 6px; +} +#ublock0-epicker button:disabled { + color: #999; + background-color: #ccc; +} +#ublock0-epicker button:not(:disabled):hover { + opacity: 1; +} +#ublock0-epicker #create:not(:disabled) { + background-color: hsl(36, 100%, 83%); + border-color: hsl(36, 50%, 60%); +} +#ublock0-epicker #preview { + float: left; +} +#ublock0-epicker body.preview #preview { + background-color: hsl(204, 100%, 83%); + border-color: hsl(204, 50%, 60%); +} +#ublock0-epicker section { + border: 0; + box-sizing: border-box; + display: inline-block; + width: 100%; +} +#ublock0-epicker section > div:first-child { + border: 1px solid #aaa; + margin: 0; + position: relative; +} +#ublock0-epicker section.invalidFilter > div:first-child { + border-color: red; +} +#ublock0-epicker section > div:first-child > textarea { + background-color: #fff; + border: none; + box-sizing: border-box; + color: #000; + font: 11px monospace; + height: 8em; + margin: 0; + overflow: hidden; + overflow-y: auto; + padding: 2px; + resize: none; + width: 100%; + word-break: break-all; +} +#ublock0-epicker #resultsetCount { + background-color: #aaa; + bottom: 0; + color: white; + padding: 2px 4px; + position: absolute; + right: 0; +} +#ublock0-epicker section.invalidFilter #resultsetCount { + background-color: red; +} +#ublock0-epicker section > div:first-child + div { + direction: ltr; + margin: 2px 0; + text-align: right; +} +#ublock0-epicker ul { + padding: 0; + list-style-type: none; + text-align: left; + overflow: hidden; +} +#ublock0-epicker #candidateFilters { + max-height: 16em; + overflow-y: auto; +} +#ublock0-epicker #candidateFilters > li:first-of-type { + margin-bottom: 0.5em; +} +#ublock0-epicker .changeFilter > li > span:nth-of-type(1) { + font-weight: bold; +} +#ublock0-epicker .changeFilter > li > span:nth-of-type(2) { + font-size: smaller; + color: gray; +} +#ublock0-epicker #candidateFilters .changeFilter { + list-style-type: none; + margin: 0 0 0 1em; + overflow: hidden; + text-align: left; +} +#ublock0-epicker #candidateFilters .changeFilter li { + border: 1px solid transparent; + cursor: pointer; + direction: ltr; + font: 11px monospace; + white-space: nowrap; +} +#ublock0-epicker #candidateFilters .changeFilter li.active { + border: 1px dotted gray; + } +#ublock0-epicker #candidateFilters .changeFilter li:hover { + background-color: white; +} +#ublock0-epicker aside { + background-color: #eee; + border: 1px solid #aaa; + bottom: 4px; + box-sizing: border-box; + cursor: default; + min-width: 24em; + padding: 4px; + position: fixed; + right: 4px; + width: calc(40% - 4px); +} +/** + https://github.com/gorhill/uBlock/issues/3449 + https://github.com/uBlockOrigin/uBlock-issues/issues/55 +**/ +@keyframes startDialog { + 0% { opacity: 1.0; } + 60% { opacity: 1.0; } + 100% { opacity: 0.1; } +} +#ublock0-epicker body.paused > aside { + opacity: 0.1; + visibility: visible; + z-index: 100; +} +#ublock0-epicker body.paused > aside:not(:hover):not(.show) { + animation-duration: 1.6s; + animation-name: startDialog; + animation-timing-function: linear; +} +#ublock0-epicker body.paused > aside:hover { + opacity: 1; +} +#ublock0-epicker body.paused > aside.show { + opacity: 1; +} +#ublock0-epicker body.paused > aside.hide { + opacity: 0.1; +} + diff --git a/src/css/epicker.css b/src/css/epicker.css new file mode 100644 index 000000000..1010fb24e --- /dev/null +++ b/src/css/epicker.css @@ -0,0 +1,60 @@ +html#ublock0-epicker, +#ublock0-epicker body { + background: transparent !important; + box-sizing: border-box !important; + color: black !important; + font: 12px sans-serif !important; + height: 100vh !important; + margin: 0 !important; + overflow: hidden !important; + position: fixed !important; + width: 100vw !important; +} +#ublock0-epicker :focus { + outline: none !important; +} +#ublock0-epicker svg { + cursor: crosshair !important; + box-sizing: border-box; + height: 100% !important; + left: 0 !important; + position: absolute !important; + top: 0 !important; + width: 100% !important; +} +#ublock0-epicker .paused > svg { + cursor: not-allowed !important; +} +#ublock0-epicker svg > path:first-child { + fill: rgba(0,0,0,0.5) !important; + fill-rule: evenodd !important; +} +#ublock0-epicker svg > path + path { + stroke: #F00 !important; + stroke-width: 0.5px !important; + fill: rgba(255,63,63,0.20) !important; +} +#ublock0-epicker body.zap svg > path + path { + stroke: #FF0 !important; + stroke-width: 0.5px !important; + fill: rgba(255,255,63,0.20) !important; +} +#ublock0-epicker body.preview svg > path { + fill: rgba(0,0,0,0.10) !important; +} +#ublock0-epicker body.preview svg > path + path { + stroke: none !important; +} +#ublock0-epicker body > iframe { + border: 0 !important; + box-sizing: border-box !important; + display: none !important; + height: 100% !important; + left: 0 !important; + position: absolute !important; + top: 0 !important; + width: 100% !important; +} +#ublock0-epicker body.paused > iframe { + display: initial !important; +} diff --git a/src/epicker.html b/src/epicker.html deleted file mode 100644 index 795526e37..000000000 --- a/src/epicker.html +++ /dev/null @@ -1,250 +0,0 @@ - - -uBlock Origin Element Picker - - - - - - - diff --git a/src/js/epicker-dialog.js b/src/js/epicker-dialog.js new file mode 100644 index 000000000..cea9d4397 --- /dev/null +++ b/src/js/epicker-dialog.js @@ -0,0 +1,499 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + Copyright (C) 2014-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +/******************************************************************************/ +/******************************************************************************/ + +(( ) => { + +/******************************************************************************/ + +if ( typeof vAPI !== 'object' ) { return; } + +const epickerId = (( ) => { + const url = new URL(self.location.href); + return url.searchParams.get('epid'); +})(); +if ( epickerId === null ) { return; } + +let epickerConnectionId; +let filterHostname = ''; +let filterOrigin = ''; +let filterResultset = []; + +/******************************************************************************/ + +const $id = id => document.getElementById(id); +const $stor = selector => document.querySelector(selector); +const $storAll = selector => document.querySelectorAll(selector); + +/******************************************************************************/ + +const filterFromTextarea = function() { + const s = taCandidate.value.trim(); + if ( s === '' ) { return ''; } + const pos = s.indexOf('\n'); + const filter = pos === -1 ? s.trim() : s.slice(0, pos).trim(); + staticFilteringParser.analyze(filter); + staticFilteringParser.analyzeExtra(); + return staticFilteringParser.shouldDiscard() ? '!' : filter; +}; + +/******************************************************************************/ + +const userFilterFromCandidate = function(filter) { + if ( filter === '' || filter === '!' ) { return; } + + // Cosmetic filter? + if ( filter.startsWith('##') ) { + return filterHostname + filter; + } + + // Assume net filter + const opts = []; + + // If no domain included in filter, we need domain option + if ( filter.startsWith('||') === false ) { + opts.push(`domain=${filterHostname}`); + } + + if ( filterResultset.length !== 0 ) { + const item = filterResultset[0]; + if ( item.opts ) { + opts.push(item.opts); + } + } + + if ( opts.length ) { + filter += '$' + opts.join(','); + } + + return filter; +}; + +/******************************************************************************/ + +const candidateFromFilterChoice = function(filterChoice) { + let { slot, filters } = filterChoice; + let filter = filters[slot]; + + // https://github.com/uBlockOrigin/uBlock-issues/issues/47 + for ( const elem of $storAll('#candidateFilters li') ) { + elem.classList.remove('active'); + } + + if ( filter === undefined ) { return ''; } + + // For net filters there no such thing as a path + if ( filter.startsWith('##') === false ) { + $stor(`#netFilters li:nth-of-type(${slot+1})`) + .classList.add('active'); + return filter; + } + + // At this point, we have a cosmetic filter + + $stor(`#cosmeticFilters li:nth-of-type(${slot+1})`) + .classList.add('active'); + + // Modifier means "target broadly". Hence: + // - Do not compute exact path. + // - Discard narrowing directives. + // - Remove the id if one or more classes exist + // TODO: should remove tag name too? ¯\_(ツ)_/¯ + if ( filterChoice.modifier ) { + filter = filter.replace(/:nth-of-type\(\d+\)/, ''); + // https://github.com/uBlockOrigin/uBlock-issues/issues/162 + // Mind escaped periods: they do not denote a class identifier. + if ( filter.charAt(2) === '#' ) { + const pos = filter.search(/[^\\]\./); + if ( pos !== -1 ) { + filter = '##' + filter.slice(pos + 1); + } + } + return filter; + } + + // Return path: the target element, then all siblings prepended + let selector = '', joiner = ''; + for ( ; slot < filters.length; slot++ ) { + filter = filters[slot]; + // 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(2) === '#' ) { + filter = filter.replace(/([^\\])\..+$/, '$1'); + } + selector = filter.slice(2) + joiner + selector; + // Stop at any element with an id: these are unique in a web page + if ( filter.startsWith('###') ) { break; } + // Stop if current selector matches only one element on the page + if ( document.querySelectorAll(selector).length === 1 ) { break; } + joiner = ' > '; + } + + // https://github.com/gorhill/uBlock/issues/2519 + // https://github.com/uBlockOrigin/uBlock-issues/issues/17 + if ( + slot === filters.length && + selector.startsWith('body > ') === false && + document.querySelectorAll(selector).length > 1 + ) { + selector = 'body > ' + selector; + } + + return '##' + selector; +}; + +/******************************************************************************/ + +const onCandidateChanged = function() { + const filter = filterFromTextarea(); + const bad = filter === '!'; + $stor('section').classList.toggle('invalidFilter', bad); + $id('create').disabled = bad; + if ( bad ) { + $id('resultsetCount').textContent = 'E'; + $id('create').setAttribute('disabled', ''); + } + vAPI.MessagingConnection.sendTo(epickerConnectionId, { + what: 'dialogSetFilter', + filter, + compiled: filter.startsWith('##') + ? staticFilteringParser.result.compiled + : undefined, + }); +}; + +/******************************************************************************/ + +const onPreviewClicked = function() { + const state = pickerBody.classList.toggle('preview'); + vAPI.MessagingConnection.sendTo(epickerConnectionId, { + what: 'dialogPreview', + state, + }); +}; + +/******************************************************************************/ + +const onCreateClicked = function() { + const candidate = filterFromTextarea(); + const filter = userFilterFromCandidate(candidate); + if ( filter !== undefined ) { + vAPI.messaging.send('elementPicker', { + what: 'createUserFilter', + autoComment: true, + filters: filter, + origin: filterOrigin, + pageDomain: filterHostname, + killCache: /^#[$?]?#/.test(candidate) === false, + }); + } + vAPI.MessagingConnection.sendTo(epickerConnectionId, { + what: 'dialogCreate', + filter: candidate, + compiled: candidate.startsWith('##') + ? staticFilteringParser.result.compiled + : undefined, + }); +}; + +/******************************************************************************/ + +const onPickClicked = function(ev) { + if ( + (ev instanceof MouseEvent) && + (ev.type === 'mousedown') && + (ev.which !== 1 || ev.target !== document.body) + ) { + return; + } + pickerBody.classList.remove('paused'); + vAPI.MessagingConnection.sendTo(epickerConnectionId, { + what: 'dialogPick' + }); +}; + +/******************************************************************************/ + +const onQuitClicked = function() { + vAPI.MessagingConnection.sendTo(epickerConnectionId, { + what: 'dialogQuit' + }); +}; + +/******************************************************************************/ + +const onCandidateClicked = function(ev) { + let li = ev.target.closest('li'); + const ul = li.closest('.changeFilter'); + if ( ul === null ) { return; } + const choice = { + filters: Array.from(ul.querySelectorAll('li')).map(a => a.textContent), + slot: 0, + modifier: ev.ctrlKey || ev.metaKey + }; + while ( li.previousElementSibling !== null ) { + li = li.previousElementSibling; + choice.slot += 1; + } + taCandidate.value = candidateFromFilterChoice(choice); + onCandidateChanged(); +}; + +/******************************************************************************/ + +const onKeyPressed = function(ev) { + // Esc + if ( ev.key === 'Escape' || ev.which === 27 ) { + onQuitClicked(); + return; + } +}; + +/******************************************************************************/ + +const onStartMoving = (( ) => { + let mx0 = 0, my0 = 0; + let mx1 = 0, my1 = 0; + let r0 = 0, b0 = 0; + let rMax = 0, bMax = 0; + let timer; + + const move = ( ) => { + timer = undefined; + let r1 = Math.min(Math.max(r0 - mx1 + mx0, 4), rMax); + let b1 = Math.min(Math.max(b0 - my1 + my0, 4), bMax); + dialog.style.setProperty('right', `${r1}px`, 'important'); + dialog.style.setProperty('bottom', `${b1}px`, 'important'); + }; + + const moveAsync = ev => { + if ( ev.isTrusted === false ) { return; } + eatEvent(ev); + if ( timer !== undefined ) { return; } + mx1 = ev.pageX; + my1 = ev.pageY; + timer = self.requestAnimationFrame(move); + }; + + const stop = ev => { + if ( ev.isTrusted === false ) { return; } + if ( dialog.classList.contains('moving') === false ) { return; } + dialog.classList.remove('moving'); + self.removeEventListener('mousemove', moveAsync, { capture: true }); + self.removeEventListener('mouseup', stop, { capture: true, once: true }); + eatEvent(ev); + }; + + return function(ev) { + if ( ev.isTrusted === false ) { return; } + const target = dialog.querySelector('#toolbar'); + if ( ev.target !== target ) { return; } + if ( dialog.classList.contains('moving') ) { return; } + mx0 = ev.pageX; my0 = ev.pageY; + const style = self.getComputedStyle(dialog); + r0 = parseInt(style.right, 10); + b0 = parseInt(style.bottom, 10); + const rect = dialog.getBoundingClientRect(); + rMax = pickerBody.clientWidth - 4 - rect.width ; + bMax = pickerBody.clientHeight - 4 - rect.height; + dialog.classList.add('moving'); + self.addEventListener('mousemove', moveAsync, { capture: true }); + self.addEventListener('mouseup', stop, { capture: true, once: true }); + eatEvent(ev); + }; +})(); + +/******************************************************************************/ + +const eatEvent = function(ev) { + ev.stopPropagation(); + ev.preventDefault(); +}; + +/******************************************************************************/ + +const showDialog = function(details) { + pickerBody.classList.add('paused'); + + const { netFilters, cosmeticFilters, filter, options } = details; + + // https://github.com/gorhill/uBlock/issues/738 + // Trim dots. + filterHostname = details.hostname; + if ( filterHostname.slice(-1) === '.' ) { + filterHostname = filterHostname.slice(0, -1); + } + filterOrigin = details.origin; + + // Create lists of candidate filters + const populate = function(src, des) { + const root = dialog.querySelector(des); + const ul = root.querySelector('ul'); + while ( ul.firstChild !== null ) { + ul.firstChild.remove(); + } + for ( let i = 0; i < src.length; i++ ) { + const li = document.createElement('li'); + li.textContent = src[i]; + ul.appendChild(li); + } + if ( src.length !== 0 ) { + root.style.removeProperty('display'); + } else { + root.style.setProperty('display', 'none', 'important'); + } + }; + + populate(netFilters, '#netFilters'); + populate(cosmeticFilters, '#cosmeticFilters'); + + dialog.querySelector('ul').style.display = + netFilters.length || cosmeticFilters.length ? '' : 'none'; + dialog.querySelector('#create').disabled = true; + + // Auto-select a candidate filter + + // 2020-09-01: + // In Firefox, `details instanceof Object` resolves to `false` despite + // `details` being a valid object. Consequently, falling back to use + // `typeof details`. + // This is an issue which surfaced when the element picker code was + // revisited to isolate the picker dialog DOM from the page DOM. + if ( typeof filter !== 'object' || filter === null ) { + taCandidate.value = ''; + return; + } + + const filterChoice = { + filters: filter.filters, + slot: filter.slot, + modifier: options.modifier || false + }; + + taCandidate.value = candidateFromFilterChoice(filterChoice); + onCandidateChanged(); +}; + +/******************************************************************************/ + +// Let's have the element picker code flushed from memory when no longer +// in use: to ensure this, release all local references. + +const stopPicker = function() { + vAPI.shutdown.remove(stopPicker); +}; + +/******************************************************************************/ + +const pickerBody = document.body; +const dialog = $stor('aside'); +const taCandidate = $stor('textarea'); +let staticFilteringParser; + +/******************************************************************************/ + +const startDialog = function() { + dialog.addEventListener('click', eatEvent); + taCandidate.addEventListener('input', onCandidateChanged); + $stor('body').addEventListener('mousedown', onPickClicked); + $id('preview').addEventListener('click', onPreviewClicked); + $id('create').addEventListener('click', onCreateClicked); + $id('pick').addEventListener('click', onPickClicked); + $id('quit').addEventListener('click', onQuitClicked); + $id('candidateFilters').addEventListener('click', onCandidateClicked); + $id('toolbar').addEventListener('mousedown', onStartMoving); + self.addEventListener('keydown', onKeyPressed, true); + staticFilteringParser = new vAPI.StaticFilteringParser({ interactive: true }); +}; + +/******************************************************************************/ + +const onPickerMessage = function(msg) { + switch ( msg.what ) { + case 'showDialog': + showDialog(msg); + break; + case 'filterResultset': + filterResultset = msg.resultset; + $id('resultsetCount').textContent = filterResultset.length; + if ( filterResultset.length !== 0 ) { + $id('create').removeAttribute('disabled'); + } else { + $id('create').setAttribute('disabled', ''); + } + break; + } +}; + +/******************************************************************************/ + +const onConnectionMessage = function(msg) { + switch ( msg.what ) { + case 'connectionBroken': + stopPicker(); + break; + case 'connectionMessage': + onPickerMessage(msg.payload); + break; + case 'connectionAccepted': + epickerConnectionId = msg.id; + startDialog(); + vAPI.MessagingConnection.sendTo(epickerConnectionId, { + what: 'dialogInit', + }); + break; + } +}; + +vAPI.MessagingConnection.connectTo( + `epickerDialog-${epickerId}`, + `epicker-${epickerId}`, + onConnectionMessage +); + +/******************************************************************************/ + +})(); + + + + + + + + +/******************************************************************************* + + DO NOT: + - Remove the following code + - Add code beyond the following code + Reason: + - https://github.com/gorhill/uBlock/pull/3721 + - uBO never uses the return value from injected content scripts + +**/ + +void 0; diff --git a/src/js/messaging.js b/src/js/messaging.js index af97adfa2..38f38fda5 100644 --- a/src/js/messaging.js +++ b/src/js/messaging.js @@ -713,34 +713,19 @@ const onMessage = function(request, sender, callback) { switch ( request.what ) { case 'elementPickerArguments': const xhr = new XMLHttpRequest(); - xhr.open('GET', 'epicker.html', true); + xhr.open('GET', 'css/epicker.css', true); xhr.overrideMimeType('text/html;charset=utf-8'); xhr.responseType = 'text'; xhr.onload = function() { this.onload = null; - const i18n = { - bidi_dir: document.body.getAttribute('dir'), - create: vAPI.i18n('pickerCreate'), - pick: vAPI.i18n('pickerPick'), - quit: vAPI.i18n('pickerQuit'), - preview: vAPI.i18n('pickerPreview'), - netFilters: vAPI.i18n('pickerNetFilters'), - cosmeticFilters: vAPI.i18n('pickerCosmeticFilters'), - cosmeticFiltersHint: vAPI.i18n('pickerCosmeticFiltersHint') - }; - const reStrings = /\{\{(\w+)\}\}/g; - const replacer = function(a0, string) { - return i18n[string]; - }; - callback({ - frameContent: this.responseText.replace(reStrings, replacer), + frameCSS: this.responseText, target: µb.epickerArgs.target, mouse: µb.epickerArgs.mouse, zap: µb.epickerArgs.zap, eprom: µb.epickerArgs.eprom, + dialogURL: vAPI.getURL(`/web_accessible_resources/epicker-dialog.html${vAPI.warSecret()}`), }); - µb.epickerArgs.target = ''; }; xhr.send(); @@ -754,21 +739,6 @@ const onMessage = function(request, sender, callback) { let response; switch ( request.what ) { - case 'compileCosmeticFilterSelector': { - const parser = new vAPI.StaticFilteringParser(); - parser.analyze(request.selector); - if ( (parser.flavorBits & parser.BITFlavorExtCosmetic) !== 0 ) { - response = parser.result.compiled; - } - break; - } - - // https://github.com/gorhill/uBlock/issues/3497 - // This needs to be removed once issue is fixed. - case 'createUserFilter': - µb.createUserFilters(request); - break; - case 'elementPickerEprom': µb.epickerArgs.eprom = request; break; diff --git a/src/js/scriptlets/element-picker.js b/src/js/scriptlets/epicker.js similarity index 69% rename from src/js/scriptlets/element-picker.js rename to src/js/scriptlets/epicker.js index 182a215a9..0d420b5d4 100644 --- a/src/js/scriptlets/element-picker.js +++ b/src/js/scriptlets/epicker.js @@ -26,27 +26,28 @@ /******************************************************************************/ /******************************************************************************/ -(( ) => { +(async ( ) => { /******************************************************************************/ -if ( - window.top !== window || - typeof vAPI !== 'object' || - vAPI.domFilterer instanceof Object === false -) { - return; -} +if ( window.top !== window || typeof vAPI !== 'object' ) { return; } -let pickerRoot = document.getElementById(vAPI.sessionId); -if ( pickerRoot ) { return; } +/******************************************************************************/ +const epickerId = vAPI.randomToken(); +let epickerConnectionId; + +/******************************************************************************/ + +let pickerRoot = document.querySelector(`[${vAPI.sessionId}]`); +if ( pickerRoot !== null ) { return; } + +let pickerBootArgs; let pickerBody = null; let svgOcean = null; let svgIslands = null; let svgRoot = null; let dialog = null; -let taCandidate = null; const netFilterCandidates = []; const cosmeticFilterCandidates = []; @@ -61,18 +62,6 @@ let lastNetFilterUnion = ''; /******************************************************************************/ -// For browsers not supporting `:scope`, it's not the end of the world: the -// suggested CSS selectors may just end up being more verbose. - -let cssScope = ':scope > '; -try { - document.querySelector(':scope *'); -} catch (e) { - cssScope = ''; -} - -/******************************************************************************/ - const safeQuerySelectorAll = function(node, selector) { if ( node !== null ) { try { @@ -85,14 +74,6 @@ const safeQuerySelectorAll = function(node, selector) { /******************************************************************************/ -const rawFilterFromTextarea = function() { - const s = taCandidate.value; - const pos = s.indexOf('\n'); - return pos === -1 ? s.trim() : s.slice(0, pos).trim(); -}; - -/******************************************************************************/ - const getElementBoundingClientRect = function(elem) { let rect = typeof elem.getBoundingClientRect === 'function' ? elem.getBoundingClientRect() @@ -152,9 +133,7 @@ const highlightElements = function(elems, force) { for ( let i = 0; i < elems.length; i++ ) { const elem = elems[i]; - if ( elem === pickerRoot ) { - continue; - } + if ( elem === pickerRoot ) { continue; } const rect = getElementBoundingClientRect(elem); // Ignore if it's not on the screen @@ -480,11 +459,11 @@ const cosmeticFilterFromElement = function(elem) { if ( attr.v.length === 0 ) { continue; } v = elem.getAttribute(attr.k); if ( attr.v === v ) { - selector += '[' + attr.k + '="' + attr.v + '"]'; + selector += `[${attr.k}="${attr.v}"]`; } else if ( v.startsWith(attr.v) ) { - selector += '[' + attr.k + '^="' + attr.v + '"]'; + selector += `[${attr.k}^="${attr.v}"]`; } else { - selector += '[' + attr.k + '*="' + attr.v + '"]'; + selector += `[${attr.k}*="${attr.v}"]`; } } } @@ -495,7 +474,7 @@ const cosmeticFilterFromElement = function(elem) { const parentNode = elem.parentNode; if ( selector === '' || - safeQuerySelectorAll(parentNode, cssScope + selector).length > 1 + safeQuerySelectorAll(parentNode, `:scope > ${selector}`).length > 1 ) { selector = tagName + selector; } @@ -504,7 +483,7 @@ const cosmeticFilterFromElement = function(elem) { // 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. - if ( safeQuerySelectorAll(parentNode, cssScope + selector).length > 1 ) { + if ( safeQuerySelectorAll(parentNode, `:scope > ${selector}`).length > 1 ) { let i = 1; while ( elem.previousSibling !== null ) { elem = elem.previousSibling; @@ -515,7 +494,7 @@ const cosmeticFilterFromElement = function(elem) { i++; } } - selector += ':nth-of-type(' + i + ')'; + selector += `:nth-of-type(${i})`; } if ( bestCandidateFilter === null ) { @@ -526,7 +505,7 @@ const cosmeticFilterFromElement = function(elem) { }; } - cosmeticFilterCandidates.push('##' + selector); + cosmeticFilterCandidates.push(`##${selector}`); return 1; }; @@ -575,7 +554,7 @@ const filtersFrom = function(x, y) { // https://github.com/gorhill/uBlock/issues/1545 // Network filter candidates from all other elements found at point (x, y). if ( typeof x === 'number' ) { - let attrName = pickerRoot.id + '-clickblind'; + let attrName = vAPI.sessionId + '-clickblind'; let previous; elem = first; while ( elem !== null ) { @@ -587,7 +566,7 @@ const filtersFrom = function(x, y) { } netFilterFromElement(elem); } - let elems = document.querySelectorAll('[' + attrName + ']'); + let elems = document.querySelectorAll(`[${attrName}]`); i = elems.length; while ( i-- ) { elems[i].removeAttribute(attrName); @@ -601,7 +580,7 @@ const filtersFrom = function(x, y) { /******************************************************************************* - filterToDOMInterface.set + filterToDOMInterface.queryAll @desc Look-up all the HTML elements matching the filter passed in argument. @param string, a cosmetic or network filter. @@ -767,24 +746,21 @@ const filterToDOMInterface = (( ) => { return out; }; - let lastFilter, - lastResultset, - lastAction, - appliedStyleTag, - applied = false, - previewing = false; + let lastFilter; + let lastResultset; + let lastAction; + let appliedStyleTag; + let applied = false; + let previewing = false; - const queryAll = async function(filter, callback) { + const queryAll = function(details) { + let { filter, compiled } = details; filter = filter.trim(); - if ( filter === lastFilter ) { - callback(lastResultset); - return; - } + if ( filter === lastFilter ) { return lastResultset; } unapply(); - if ( filter === '' ) { + if ( filter === '' || filter === '!' ) { lastFilter = ''; lastResultset = []; - callback(lastResultset); return; } lastFilter = filter; @@ -792,23 +768,17 @@ const filterToDOMInterface = (( ) => { if ( filter.startsWith('##') === false ) { lastResultset = fromNetworkFilter(filter); if ( previewing ) { apply(); } - callback(lastResultset); - return; + return lastResultset; } - lastResultset = fromPlainCosmeticFilter(filter.slice(2)); + lastResultset = fromPlainCosmeticFilter(compiled); if ( lastResultset ) { if ( previewing ) { apply(); } - callback(lastResultset); - return; + return lastResultset; } // Procedural cosmetic filter - const response = await vAPI.messaging.send('elementPicker', { - what: 'compileCosmeticFilterSelector', - selector: filter, - }); - lastResultset = fromCompiledCosmeticFilter(response); + lastResultset = fromCompiledCosmeticFilter(compiled); if ( previewing ) { apply(); } - callback(lastResultset); + return lastResultset; }; // https://github.com/gorhill/uBlock/issues/1629 @@ -896,278 +866,48 @@ const filterToDOMInterface = (( ) => { // https://www.reddit.com/r/uBlockOrigin/comments/c62irc/ // Support injecting the cosmetic filters into the DOM filterer // immediately rather than wait for the next page load. - const preview = function(rawFilter, permanent = false) { - previewing = rawFilter !== false; + const preview = function(state, permanent = false) { + previewing = state !== false; pickerBody.classList.toggle('preview', previewing); if ( previewing === false ) { return unapply(); } - queryAll(rawFilter, items => { - if ( items === undefined ) { return; } - 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 { - cssSelectors.add(item.raw); - } + if ( lastResultset === undefined ) { return; } + apply(); + if ( permanent === false ) { return; } + if ( vAPI.domFilterer instanceof Object === false ) { return; } + const cssSelectors = new Set(); + const proceduralSelectors = new Set(); + for ( const item of lastResultset ) { + if ( item.type !== 'cosmetic' ) { continue; } + if ( item.raw.startsWith('{') ) { + proceduralSelectors.add(item.raw); + } else { + cssSelectors.add(item.raw); } - if ( cssSelectors.size !== 0 ) { - vAPI.domFilterer.addCSSRule( - Array.from(cssSelectors), - 'display:none!important;' - ); - } - if ( proceduralSelectors.size !== 0 ) { - vAPI.domFilterer.addProceduralSelectors( - Array.from(proceduralSelectors) - ); - } - }); + } + if ( cssSelectors.size !== 0 ) { + vAPI.domFilterer.addCSSRule( + Array.from(cssSelectors), + 'display:none!important;' + ); + } + if ( proceduralSelectors.size !== 0 ) { + vAPI.domFilterer.addProceduralSelectors( + Array.from(proceduralSelectors) + ); + } }; return { - previewing: function() { return previewing; }, - preview: preview, - set: queryAll + get previewing() { return previewing; }, + preview, + queryAll, }; })(); /******************************************************************************/ -const userFilterFromCandidate = function(callback) { - let v = rawFilterFromTextarea(); - filterToDOMInterface.set(v, items => { - if ( !items || items.length === 0 ) { - callback(); - return; - } - - // https://github.com/gorhill/uBlock/issues/738 - // Trim dots. - let hostname = window.location.hostname; - if ( hostname.slice(-1) === '.' ) { - hostname = hostname.slice(0, -1); - } - - // Cosmetic filter? - if ( v.startsWith('##') ) { - callback(hostname + v, true); - return; - } - - // Assume net filter - const opts = []; - - // If no domain included in filter, we need domain option - if ( v.startsWith('||') === false ) { - opts.push(`domain=${hostname}`); - } - - const item = items[0]; - if ( item.opts ) { - opts.push(item.opts); - } - - if ( opts.length ) { - v += '$' + opts.join(','); - } - - callback(v); - }); -}; - -/******************************************************************************/ - -const onCandidateChanged = (function() { - const process = function(items) { - const elems = []; - const valid = items !== undefined; - if ( valid ) { - for ( const item of items ) { - elems.push(item.elem); - } - } - pickerBody.querySelector('#resultsetCount').textContent = valid ? - items.length.toLocaleString() : - 'E'; - dialog.querySelector('section').classList.toggle('invalidFilter', !valid); - dialog.querySelector('#create').disabled = elems.length === 0; - highlightElements(elems, true); - }; - - return function() { - filterToDOMInterface.set(rawFilterFromTextarea(), process); - }; -})(); - -/******************************************************************************/ - -const candidateFromFilterChoice = function(filterChoice) { - let { slot, filters } = filterChoice; - let filter = filters[slot]; - - // https://github.com/uBlockOrigin/uBlock-issues/issues/47 - for ( const elem of dialog.querySelectorAll('#candidateFilters li') ) { - elem.classList.remove('active'); - } - - if ( filter === undefined ) { return ''; } - - // For net filters there no such thing as a path - if ( filter.startsWith('##') === false ) { - dialog.querySelector(`#netFilters li:nth-of-type(${slot+1})`) - .classList.add('active'); - return filter; - } - - // At this point, we have a cosmetic filter - - dialog.querySelector(`#cosmeticFilters li:nth-of-type(${slot+1})`) - .classList.add('active'); - - // Modifier means "target broadly". Hence: - // - Do not compute exact path. - // - Discard narrowing directives. - // - Remove the id if one or more classes exist - // TODO: should remove tag name too? ¯\_(ツ)_/¯ - if ( filterChoice.modifier ) { - filter = filter.replace(/:nth-of-type\(\d+\)/, ''); - // https://github.com/uBlockOrigin/uBlock-issues/issues/162 - // Mind escaped periods: they do not denote a class identifier. - if ( filter.charAt(2) === '#' ) { - let pos = filter.search(/[^\\]\./); - if ( pos !== -1 ) { - filter = '##' + filter.slice(pos + 1); - } - } - return filter; - } - - // Return path: the target element, then all siblings prepended - let selector = '', joiner = ''; - for ( ; slot < filters.length; slot++ ) { - filter = filters[slot]; - // 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(2) === '#' ) { - filter = filter.replace(/([^\\])\..+$/, '$1'); - } - selector = filter.slice(2) + joiner + selector; - // Stop at any element with an id: these are unique in a web page - if ( filter.startsWith('###') ) { break; } - // Stop if current selector matches only one element on the page - if ( document.querySelectorAll(selector).length === 1 ) { break; } - joiner = ' > '; - } - - // https://github.com/gorhill/uBlock/issues/2519 - // https://github.com/uBlockOrigin/uBlock-issues/issues/17 - if ( - slot === filters.length && - selector.startsWith('body > ') === false && - document.querySelectorAll(selector).length > 1 - ) { - selector = 'body > ' + selector; - } - - return '##' + selector; -}; - -/******************************************************************************/ - -const filterChoiceFromEvent = function(ev) { - let li = ev.target; - const isNetFilter = li.textContent.startsWith('##') === false; - const r = { - filters: isNetFilter ? netFilterCandidates : cosmeticFilterCandidates, - slot: 0, - modifier: ev.ctrlKey || ev.metaKey - }; - while ( li.previousElementSibling !== null ) { - li = li.previousElementSibling; - r.slot += 1; - } - return r; -}; - -/******************************************************************************/ - -const onDialogClicked = function(ev) { - if ( ev.isTrusted === false ) { return; } - - // If the dialog is hidden, clicking on it force it to become visible. - if ( dialog.classList.contains('hide') ) { - dialog.classList.add('show'); - dialog.classList.remove('hide'); - } - - else if ( ev.target === null ) { - /* do nothing */ - } - - else if ( ev.target.id === 'create' ) { - // We have to exit from preview mode: this guarantees matching elements - // will be found for the candidate filter. - filterToDOMInterface.preview(false); - userFilterFromCandidate((filter = undefined, isCosmetic = false) => { - if ( filter === undefined ) { return; } - vAPI.messaging.send('elementPicker', { - what: 'createUserFilter', - autoComment: true, - filters: filter, - origin: window.location.origin, - pageDomain: window.location.hostname, - killCache: isCosmetic === false, - }); - filterToDOMInterface.preview(rawFilterFromTextarea(), true); - stopPicker(); - }); - } - - else if ( ev.target.id === 'pick' ) { - unpausePicker(); - } - - else if ( ev.target.id === 'quit' ) { - filterToDOMInterface.preview(false); - stopPicker(); - } - - else if ( ev.target.id === 'preview' ) { - if ( filterToDOMInterface.previewing() ) { - filterToDOMInterface.preview(false); - } else { - filterToDOMInterface.preview(rawFilterFromTextarea()); - } - highlightElements(targetElements, true); - } - - else if ( ev.target.closest('.changeFilter') !== null ) { - taCandidate.value = candidateFromFilterChoice(filterChoiceFromEvent(ev)); - onCandidateChanged(); - } - - ev.stopPropagation(); - ev.preventDefault(); -}; - -/******************************************************************************/ - -const removeAllChildren = function(parent) { - while ( parent.firstChild ) { - parent.removeChild(parent.firstChild); - } -}; - -/******************************************************************************/ - const showDialog = function(options) { pausePicker(); @@ -1178,47 +918,15 @@ const showDialog = function(options) { dialog.classList.toggle('show', options.show === true); dialog.classList.remove('hide'); - // Create lists of candidate filters - const populate = function(src, des) { - const root = dialog.querySelector(des); - const ul = root.querySelector('ul'); - removeAllChildren(ul); - for ( let i = 0; i < src.length; i++ ) { - const li = document.createElement('li'); - li.textContent = src[i]; - ul.appendChild(li); - } - if ( src.length !== 0 ) { - root.style.removeProperty('display'); - } else { - root.style.setProperty('display', 'none', 'important'); - } - }; - - populate(netFilterCandidates, '#netFilters'); - populate(cosmeticFilterCandidates, '#cosmeticFilters'); - - dialog.querySelector('ul').style.display = - netFilterCandidates.length || cosmeticFilterCandidates.length - ? '' - : 'none'; - dialog.querySelector('#create').disabled = true; - - // Auto-select a candidate filter - - if ( bestCandidateFilter === null ) { - taCandidate.value = ''; - return; - } - - const filterChoice = { - filters: bestCandidateFilter.filters, - slot: bestCandidateFilter.slot, - modifier: options.modifier || false - }; - - taCandidate.value = candidateFromFilterChoice(filterChoice); - onCandidateChanged(); + vAPI.MessagingConnection.sendTo(epickerConnectionId, { + what: 'showDialog', + hostname: self.location.hostname, + origin: self.location.origin, + netFilters: netFilterCandidates, + cosmeticFilters: cosmeticFilterCandidates, + filter: bestCandidateFilter, + options, + }); }; /******************************************************************************/ @@ -1284,7 +992,7 @@ const elementFromPoint = (( ) => { /******************************************************************************/ -const onSvgHovered = (function() { +const onSvgHovered = (( ) => { let timer; let mx = 0, my = 0; @@ -1315,7 +1023,7 @@ const onSvgHovered = (function() { */ -const onSvgTouchStartStop = (function() { +const onSvgTouchStartStop = (( ) => { var startX, startY; return function onTouch(ev) { @@ -1380,8 +1088,8 @@ const onSvgClicked = function(ev) { // If zap mode, highlight element under mouse, this makes the zapper usable // on touch screens. - if ( pickerBody.classList.contains('zap') ) { - var elem = targetElements.lenght !== 0 && targetElements[0]; + if ( pickerBootArgs.zap ) { + let elem = targetElements.lenght !== 0 && targetElements[0]; if ( !elem || ev.target !== svgIslands ) { elem = elementFromPoint(ev.clientX, ev.clientY); if ( elem !== null ) { @@ -1400,7 +1108,7 @@ const onSvgClicked = function(ev) { // - click outside dialog AND // - not in preview mode if ( pickerBody.classList.contains('paused') ) { - if ( filterToDOMInterface.previewing() === false ) { + if ( filterToDOMInterface.previewing === false ) { unpausePicker(); } return; @@ -1417,7 +1125,7 @@ const onSvgClicked = function(ev) { /******************************************************************************/ const svgListening = function(on) { - var action = (on ? 'add' : 'remove') + 'EventListener'; + const action = (on ? 'add' : 'remove') + 'EventListener'; svgRoot[action]('mousemove', onSvgHovered, { passive: true }); }; @@ -1427,7 +1135,7 @@ const onKeyPressed = function(ev) { // Delete if ( (ev.key === 'Delete' || ev.key === 'Backspace') && - pickerBody.classList.contains('zap') + pickerBootArgs.zap ) { ev.stopPropagation(); ev.preventDefault(); @@ -1447,8 +1155,8 @@ const onKeyPressed = function(ev) { /******************************************************************************/ // https://github.com/chrisaljoudi/uBlock/issues/190 -// May need to dynamically adjust the height of the overlay + new position -// of highlighted elements. +// May need to dynamically adjust the height of the overlay + new position +// of highlighted elements. const onScrolled = function() { highlightElements(targetElements, true); @@ -1456,65 +1164,6 @@ const onScrolled = function() { /******************************************************************************/ -const onStartMoving = (( ) => { - let mx0 = 0, my0 = 0; - let mx1 = 0, my1 = 0; - let r0 = 0, b0 = 0; - let rMax = 0, bMax = 0; - let timer; - - const move = ( ) => { - timer = undefined; - let r1 = Math.min(Math.max(r0 - mx1 + mx0, 4), rMax); - let b1 = Math.min(Math.max(b0 - my1 + my0, 4), bMax); - dialog.style.setProperty('right', `${r1}px`, 'important'); - dialog.style.setProperty('bottom', `${b1}px`, 'important'); - }; - - const moveAsync = ev => { - if ( ev.isTrusted === false ) { return; } - ev.preventDefault(); - ev.stopPropagation(); - if ( timer !== undefined ) { return; } - mx1 = ev.pageX; - my1 = ev.pageY; - timer = self.requestAnimationFrame(move); - }; - - const stop = ev => { - if ( ev.isTrusted === false ) { return; } - if ( dialog.classList.contains('moving') === false ) { return; } - dialog.classList.remove('moving'); - const pickerWin = pickerRoot.contentWindow; - pickerWin.removeEventListener('mousemove', moveAsync, { capture: true }); - pickerWin.removeEventListener('mouseup', stop, { capture: true, once: true }); - ev.preventDefault(); - ev.stopPropagation(); - }; - - return function(ev) { - if ( ev.isTrusted === false ) { return; } - const target = dialog.querySelector('#toolbar'); - if ( ev.target !== target ) { return; } - if ( dialog.classList.contains('moving') ) { return; } - mx0 = ev.pageX; my0 = ev.pageY; - const pickerWin = pickerRoot.contentWindow; - const style = pickerWin.getComputedStyle(dialog); - r0 = parseInt(style.right, 10); - b0 = parseInt(style.bottom, 10); - const rect = dialog.getBoundingClientRect(); - rMax = pickerBody.clientWidth - 4 - rect.width ; - bMax = pickerBody.clientHeight - 4 - rect.height; - dialog.classList.add('moving'); - pickerWin.addEventListener('mousemove', moveAsync, { capture: true }); - pickerWin.addEventListener('mouseup', stop, { capture: true, once: true }); - ev.preventDefault(); - ev.stopPropagation(); - }; -})(); - -/******************************************************************************/ - const pausePicker = function() { pickerBody.classList.add('paused'); svgListening(false); @@ -1544,91 +1193,36 @@ const stopPicker = function() { // https://github.com/gorhill/uBlock/issues/2060 if ( vAPI.domFilterer instanceof Object ) { - vAPI.userStylesheet.remove(pickerCSS1); - vAPI.userStylesheet.remove(pickerCSS2); + vAPI.userStylesheet.remove(pickerCSS); vAPI.userStylesheet.apply(); + vAPI.domFilterer.unexcludeNode(pickerRoot); } - vAPI.domFilterer.unexcludeNode(pickerRoot); window.removeEventListener('scroll', onScrolled, true); svgListening(false); - pickerRoot.parentNode.removeChild(pickerRoot); - pickerRoot = pickerBody = - svgRoot = svgOcean = svgIslands = - dialog = taCandidate = null; + pickerRoot.remove(); + pickerRoot = pickerBody = svgRoot = svgOcean = svgIslands = dialog = null; window.focus(); }; /******************************************************************************/ -const startPicker = function(details) { - pickerRoot.addEventListener('load', stopPicker); +// Auto-select a specific target, if any, and if possible - const frameDoc = pickerRoot.contentDocument; - const parsedDom = (new DOMParser()).parseFromString( - details.frameContent, - 'text/html' - ); - - // Provide an id users can use as anchor to personalize uBO's element - // picker style properties. - parsedDom.documentElement.id = 'ublock0-epicker'; - - // https://github.com/gorhill/uBlock/issues/2240 - // https://github.com/uBlockOrigin/uBlock-issues/issues/170 - // Remove the already declared inline style tag: we will create a new - // one based on the removed one, and replace the old one. - let style = parsedDom.querySelector('style'); - const styleText = style.textContent; - style.parentNode.removeChild(style); - style = frameDoc.createElement('style'); - style.textContent = styleText; - parsedDom.head.appendChild(style); - - frameDoc.replaceChild( - frameDoc.adoptNode(parsedDom.documentElement), - frameDoc.documentElement - ); - - pickerBody = frameDoc.body; - pickerBody.setAttribute('lang', navigator.language); - pickerBody.classList.toggle('zap', details.zap === true); - - dialog = pickerBody.querySelector('aside'); - dialog.addEventListener('click', onDialogClicked); - - taCandidate = dialog.querySelector('textarea'); - taCandidate.addEventListener('input', onCandidateChanged); - - dialog.querySelector('#toolbar').addEventListener('mousedown', onStartMoving); - - svgRoot = pickerBody.querySelector('svg'); - svgOcean = svgRoot.firstChild; - svgIslands = svgRoot.lastChild; +const startPicker = function() { svgRoot.addEventListener('click', onSvgClicked); svgRoot.addEventListener('touchstart', onSvgTouchStartStop); svgRoot.addEventListener('touchend', onSvgTouchStartStop); svgListening(true); - window.addEventListener('scroll', onScrolled, true); + self.addEventListener('scroll', onScrolled, true); pickerRoot.contentWindow.addEventListener('keydown', onKeyPressed, true); pickerRoot.contentWindow.focus(); - // Restore net filter union data if it originate from the same URL. - const eprom = details.eprom || null; - if ( eprom !== null && eprom.lastNetFilterSession === lastNetFilterSession ) { - lastNetFilterHostname = eprom.lastNetFilterHostname || ''; - lastNetFilterUnion = eprom.lastNetFilterUnion || ''; - } - - // Auto-select a specific target, if any, and if possible - - highlightElements([], true); - // Try using mouse position if ( - details.mouse && + pickerBootArgs.mouse && typeof vAPI.mouseClick.x === 'number' && vAPI.mouseClick.x > 0 ) { @@ -1639,7 +1233,7 @@ const startPicker = function(details) { } // No mouse position available, use suggested target - const target = details.target || ''; + const target = pickerBootArgs.target || ''; const pos = target.indexOf('\t'); if ( pos === -1 ) { return; } @@ -1660,16 +1254,10 @@ const startPicker = function(details) { if ( elem === pickerRoot ) { continue; } const src = elem[attr]; if ( typeof src !== 'string' ) { continue; } - if ( - (src !== url) && - (src !== '' || url !== 'about:blank') - ) { + if ( (src !== url) && (src !== '' || url !== 'about:blank') ) { continue; } - elem.scrollIntoView({ - behavior: 'smooth', - block: 'start' - }); + elem.scrollIntoView({ behavior: 'smooth', block: 'start' }); filtersFrom(elem); showDialog({ modifier: true }); return; @@ -1681,18 +1269,71 @@ const startPicker = function(details) { /******************************************************************************/ -const bootstrapPicker = async function() { - vAPI.shutdown.add(stopPicker); - const details = await vAPI.messaging.send('elementPicker', { - what: 'elementPickerArguments', - }); - startPicker(details); +const onDialogMessage = function(msg) { + switch ( msg.what ) { + case 'dialogInit': + startPicker(); + break; + case 'dialogPreview': + filterToDOMInterface.preview(msg.state); + break; + case 'dialogCreate': + filterToDOMInterface.queryAll(msg); + filterToDOMInterface.preview(true, true); + stopPicker(); + break; + case 'dialogPick': + unpausePicker(); + break; + case 'dialogQuit': + filterToDOMInterface.preview(false); + stopPicker(); + break; + case 'dialogSetFilter': { + const resultset = filterToDOMInterface.queryAll(msg); + highlightElements(resultset.map(a => a.elem), true); + if ( msg.filter === '!' ) { break; } + vAPI.MessagingConnection.sendTo(epickerConnectionId, { + what: 'filterResultset', + resultset: resultset.map(a => { + const o = Object.assign({}, a); + o.elem = undefined; + return o; + }), + }); + break; + } + default: + break; + } +}; + +/******************************************************************************/ + +const onConnectionMessage = function(msg) { + if ( + msg.from !== `epickerDialog-${epickerId}` || + msg.to !== `epicker-${epickerId}` + ) { + return; + } + switch ( msg.what ) { + case 'connectionRequested': + epickerConnectionId = msg.id; + return true; + case 'connectionBroken': + stopPicker(); + break; + case 'connectionMessage': + onDialogMessage(msg.payload); + break; + } }; /******************************************************************************/ pickerRoot = document.createElement('iframe'); -pickerRoot.id = vAPI.sessionId; +pickerRoot.setAttribute(vAPI.sessionId, ''); const pickerCSSStyle = [ 'background: transparent', @@ -1720,38 +1361,104 @@ const pickerCSSStyle = [ pickerRoot.style.cssText = pickerCSSStyle; // https://github.com/uBlockOrigin/uBlock-issues/issues/393 -// This needs to be injected as an inline style, *never* as a user styles, +// This needs to be injected as an inline style, *never* as a user style, // hence why it's not added above as part of the pickerCSSStyle // properties. pickerRoot.style.setProperty('pointer-events', 'auto', 'important'); -const pickerCSS1 = [ - `#${pickerRoot.id} {`, - pickerCSSStyle, - '}' -].join('\n'); -const pickerCSS2 = [ - `[${pickerRoot.id}-clickblind] {`, - 'pointer-events: none !important;', - '}' -].join('\n'); +const pickerCSS = ` +[${vAPI.sessionId}] { + ${pickerCSSStyle} +} +[${vAPI.sessionId}-clickblind] { + pointer-events: none !important; +} +`; + +{ + const pickerRootLoaded = new Promise(resolve => { + pickerRoot.addEventListener('load', ( ) => { resolve(); }, { once: true }); + }); + document.documentElement.append(pickerRoot); + + const results = await Promise.all([ + vAPI.messaging.send('elementPicker', { what: 'elementPickerArguments' }), + pickerRootLoaded, + ]); + + pickerBootArgs = results[0]; + + // The DOM filterer will not be present when cosmetic filtering is + // disabled. + if ( + pickerBootArgs.zap !== true && + vAPI.domFilterer instanceof Object === false + ) { + pickerRoot.remove(); + return; + } + + // Restore net filter union data if origin is the same. + const eprom = pickerBootArgs.eprom || null; + if ( eprom !== null && eprom.lastNetFilterSession === lastNetFilterSession ) { + lastNetFilterHostname = eprom.lastNetFilterHostname || ''; + lastNetFilterUnion = eprom.lastNetFilterUnion || ''; + } + + const frameDoc = pickerRoot.contentDocument; + + // Provide an id users can use as anchor to personalize uBO's element + // picker style properties. + frameDoc.documentElement.id = 'ublock0-epicker'; + + // https://github.com/gorhill/uBlock/issues/2240 + // https://github.com/uBlockOrigin/uBlock-issues/issues/170 + // Remove the already declared inline style tag: we will create a new + // one based on the removed one, and replace the old one. + const style = frameDoc.createElement('style'); + style.textContent = pickerBootArgs.frameCSS; + frameDoc.head.appendChild(style); + + pickerBody = frameDoc.body; + pickerBody.setAttribute('lang', navigator.language); + pickerBody.classList.toggle('zap', pickerBootArgs.zap === true); + + svgRoot = frameDoc.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgOcean = frameDoc.createElementNS('http://www.w3.org/2000/svg', 'path'); + svgRoot.append(svgOcean); + svgIslands = frameDoc.createElementNS('http://www.w3.org/2000/svg', 'path'); + svgRoot.append(svgIslands); + pickerBody.append(svgRoot); + + dialog = frameDoc.createElement('iframe'); + pickerBody.append(dialog); +} + +highlightElements([], true); // https://github.com/gorhill/uBlock/issues/1529 // In addition to inline styles, harden the element picker styles by using // dedicated CSS rules. -vAPI.userStylesheet.add(pickerCSS1); -vAPI.userStylesheet.add(pickerCSS2); +vAPI.userStylesheet.add(pickerCSS); vAPI.userStylesheet.apply(); +vAPI.shutdown.add(stopPicker); + +// https://github.com/gorhill/uBlock/issues/3497 +// https://github.com/uBlockOrigin/uBlock-issues/issues/1215 +// Instantiate isolated element picker dialog. +if ( pickerBootArgs.zap === true ) { + startPicker(); + return; +} + // https://github.com/gorhill/uBlock/issues/2060 vAPI.domFilterer.excludeNode(pickerRoot); -pickerRoot.addEventListener( - 'load', - ( ) => { bootstrapPicker(); }, - { once: true } -); -document.documentElement.appendChild(pickerRoot); +if ( await vAPI.messaging.extend() !== true ) { return; } +vAPI.MessagingConnection.addListener(onConnectionMessage); + +dialog.contentWindow.location = `${pickerBootArgs.dialogURL}&epid=${epickerId}`; /******************************************************************************/ diff --git a/src/js/ublock.js b/src/js/ublock.js index e4d720471..1a0c0ac82 100644 --- a/src/js/ublock.js +++ b/src/js/ublock.js @@ -428,7 +428,7 @@ const matchBucket = function(url, hostname, bucket, start) { }); await vAPI.tabs.executeScript(tabId, { - file: '/js/scriptlets/element-picker.js', + file: '/js/scriptlets/epicker.js', runAt: 'document_end', }); diff --git a/src/web_accessible_resources/epicker-dialog.html b/src/web_accessible_resources/epicker-dialog.html new file mode 100644 index 000000000..d87b46566 --- /dev/null +++ b/src/web_accessible_resources/epicker-dialog.html @@ -0,0 +1,48 @@ + + + + + +uBlock Origin Element Picker + + + + + + + + + + + + + + + +