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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+