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.
This commit is contained in:
Raymond Hill 2020-09-01 12:32:12 -04:00
parent 43dba2bd0e
commit 9eb455ab5e
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
9 changed files with 1043 additions and 819 deletions

View File

@ -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 ) {

184
src/css/epicker-dialog.css Normal file
View File

@ -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;
}

60
src/css/epicker.css Normal file
View File

@ -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;
}

View File

@ -1,250 +0,0 @@
<head>
<meta charset="utf-8">
<title>uBlock Origin Element Picker</title>
<style>
html#ublock0-epicker,
#ublock0-epicker body {
background: transparent !important;
color: black !important;
font: 12px sans-serif !important;
height: 100% !important;
margin: 0 !important;
overflow: hidden !important;
width: 100% !important;
}
#ublock0-epicker :focus {
outline: none !important;
}
#ublock0-epicker ul,
#ublock0-epicker li,
#ublock0-epicker div {
display: block !important;
}
#ublock0-epicker #toolbar {
cursor: grab;
display: flex !important;
justify-content: space-between;
}
#ublock0-epicker aside.moving #toolbar {
cursor: grabbing;
}
#ublock0-epicker ul {
margin: 0.25em 0 0 0 !important;
}
#ublock0-epicker button {
background-color: #ccc !important;
border: 1px solid #aaa !important;
border-radius: 3px !important;
box-sizing: border-box !important;
box-shadow: none !important;
color: #000 !important;
cursor: pointer !important;
opacity: 0.7 !important;
padding: 4px 6px !important;
}
#ublock0-epicker button:disabled {
color: #999 !important;
background-color: #ccc !important;
}
#ublock0-epicker button:not(:disabled):hover {
opacity: 1 !important;
}
#ublock0-epicker #create:not(:disabled) {
background-color: hsl(36, 100%, 83%) !important;
border-color: hsl(36, 50%, 60%) !important;
}
#ublock0-epicker #preview {
float: left !important;
}
#ublock0-epicker body.preview #preview {
background-color: hsl(204, 100%, 83%) !important;
border-color: hsl(204, 50%, 60%) !important;
}
#ublock0-epicker section {
border: 0 !important;
box-sizing: border-box !important;
display: inline-block !important;
width: 100% !important;
}
#ublock0-epicker section > div:first-child {
border: 1px solid #aaa !important;
margin: 0 !important;
position: relative !important;
}
#ublock0-epicker section.invalidFilter > div:first-child {
border-color: red !important;
}
#ublock0-epicker section > div:first-child > textarea {
background-color: #fff !important;
border: none !important;
box-sizing: border-box !important;
color: #000 !important;
font: 11px monospace !important;
height: 8em !important;
margin: 0 !important;
overflow: hidden !important;
overflow-y: auto !important;
padding: 2px !important;
resize: none !important;
width: 100% !important;
word-break: break-all !important;
}
#ublock0-epicker #resultsetCount {
background-color: #aaa !important;
bottom: 0 !important;
color: white !important;
padding: 2px 4px !important;
position: absolute !important;
right: 0 !important;
}
#ublock0-epicker section.invalidFilter #resultsetCount {
background-color: red !important;
}
#ublock0-epicker section > div:first-child + div {
direction: ltr !important;
margin: 2px 0 !important;
text-align: right !important;
}
#ublock0-epicker ul {
padding: 0 !important;
list-style-type: none !important;
text-align: left !important;
overflow: hidden !important;
}
#ublock0-epicker #candidateFilters {
max-height: 16em !important;
overflow-y: auto !important;
}
#ublock0-epicker #candidateFilters > li:first-of-type {
margin-bottom: 0.5em !important;
}
#ublock0-epicker .changeFilter > li > span:nth-of-type(1) {
font-weight: bold !important;
}
#ublock0-epicker .changeFilter > li > span:nth-of-type(2) {
font-size: smaller !important;
color: gray !important;
}
#ublock0-epicker #candidateFilters .changeFilter {
list-style-type: none !important;
margin: 0 0 0 1em !important;
overflow: hidden !important;
text-align: left !important;
}
#ublock0-epicker #candidateFilters .changeFilter li {
border: 1px solid transparent;
cursor: pointer !important;
direction: ltr !important;
font: 11px monospace !important;
white-space: nowrap !important;
}
#ublock0-epicker #candidateFilters .changeFilter li.active {
border: 1px dotted gray;
}
#ublock0-epicker #candidateFilters .changeFilter li:hover {
background-color: white !important;
}
#ublock0-epicker svg {
position: fixed !important;
top: 0 !important;
left: 0 !important;
cursor: crosshair !important;
width: 100% !important;
height: 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 aside {
background-color: #eee !important;
border: 1px solid #aaa !important;
bottom: 4px !important;
box-sizing: border-box !important;
min-width: 24em !important;
padding: 4px !important;
position: fixed !important;
right: 4px !important;
visibility: hidden !important;
width: calc(40% - 4px) !important;
}
#ublock0-epicker body.paused > aside {
opacity: 0.1;
visibility: visible !important;
z-index: 100 !important;
}
/**
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:not(:hover):not(.show) {
animation-duration: 1.6s !important;
animation-name: startDialog !important;
animation-timing-function: linear !important;
}
#ublock0-epicker body.paused > aside:hover {
opacity: 1 !important;
}
#ublock0-epicker body.paused > aside.show {
opacity: 1 !important;
}
#ublock0-epicker body.paused > aside.hide {
opacity: 0.1 !important;
}
</style>
</head>
<body direction="{{bidi_dir}}">
<svg><path d></path><path d></path></svg>
<aside>
<section>
<div>
<textarea lang="en" dir="ltr" spellcheck="false"></textarea>
<div id="resultsetCount"></div>
</div>
<div id="toolbar">
<div>
<button id="preview" type="button">{{preview}}</button>
</div>
<div>
<button id="create" type="button" disabled>{{create}}</button>
<button id="pick" type="button">{{pick}}</button>
<button id="quit" type="button">{{quit}}</button>
</div>
</div>
</section>
<ul id="candidateFilters">
<li id="netFilters">
<span>{{netFilters}}</span><ul lang="en" class="changeFilter"></ul>
</li>
<li id="cosmeticFilters">
<span>{{cosmeticFilters}}</span> <span>{{cosmeticFiltersHint}}</span>
<ul lang="en" class="changeFilter"></ul>
</li>
</ul>
</aside>
</body>

499
src/js/epicker-dialog.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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,20 +866,19 @@ 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; }
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 items ) {
for ( const item of lastResultset ) {
if ( item.type !== 'cosmetic' ) { continue; }
if ( item.raw.startsWith('{') ) {
proceduralSelectors.add(item.raw);
@ -928,246 +897,17 @@ const filterToDOMInterface = (( ) => {
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();
@ -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);
}
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',
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;
}),
});
startPicker(details);
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}`;
/******************************************************************************/

View File

@ -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',
});

View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html id="ublock0-epicker">
<head>
<meta charset="utf-8">
<title>uBlock Origin Element Picker</title>
<link rel="stylesheet" href="../css/epicker-dialog.css">
</head>
<body>
<aside>
<section>
<div>
<textarea lang="en" dir="ltr" spellcheck="false"></textarea>
<div id="resultsetCount"></div>
</div>
<div id="toolbar">
<div>
<button id="preview" type="button" data-i18n="pickerPreview"></button>
</div>
<div>
<button id="create" type="button" disabled data-i18n="pickerCreate"></button>
<button id="pick" type="button" data-i18n="pickerPick"></button>
<button id="quit" type="button" data-i18n="pickerQuit"></button>
</div>
</div>
</section>
<ul id="candidateFilters">
<li id="netFilters">
<span data-i18n="pickerNetFilters"></span><ul lang="en" class="changeFilter"></ul>
</li>
<li id="cosmeticFilters">
<span data-i18n="pickerCosmeticFilters"></span> <span data-i18n="pickerCosmeticFiltersHint"></span>
<ul lang="en" class="changeFilter"></ul>
</li>
</ul>
</aside>
<script src="../js/vapi.js"></script>
<script src="../js/vapi-common.js"></script>
<script src="../js/vapi-client.js"></script>
<script src="../js/vapi-client-extra.js"></script>
<script src="../js/i18n.js"></script>
<script src="../js/epicker-dialog.js"></script>
<script src="../js/static-filtering-parser.js"></script>
</body>
</html>