Isolate element picker's svg layers from page content

Related issue:
- https://github.com/uBlockOrigin/uBlock-issues/issues/1226

Related commit:
- 9eb455ab5e

In the previous commit, the element picker dialog was
isolated from the page content. This commit is to also
isolate the svg layers from the page content.

With this commit, there is no longer a need for an anonymous
iframe and the isolated world iframe is now directly
embedded in the page.

As a result, pages are now unable to interfere with any
of the element picker user interface. Pages can now only
see an iframe, but are unable to see the content of that
iframe. The styles applied to the iframe are from a user
stylesheet, so as to ensure pages can't override the
iframe's style properties set by uBO.
This commit is contained in:
Raymond Hill 2020-09-03 10:27:35 -04:00
parent c02bba3cfa
commit d23f9c6a8b
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
7 changed files with 615 additions and 612 deletions

View File

@ -153,6 +153,9 @@ vAPI.MessagingConnection = class {
listeners.add(listener);
vAPI.messaging.getPort(); // Ensure a port instance exists
}
static removeListener(listener) {
listeners.delete(listener);
}
static connectTo(from, to, handler) {
const port = vAPI.messaging.getPort();
if ( port === null ) { return; }

View File

@ -12,6 +12,22 @@ html#ublock0-epicker,
#ublock0-epicker :focus {
outline: none;
}
#ublock0-epicker aside {
background-color: #eee;
border: 1px solid #aaa;
bottom: 4px;
box-sizing: border-box;
cursor: default;
display: none;
min-width: 24em;
padding: 4px;
position: fixed;
right: 4px;
width: calc(40% - 4px);
}
#ublock0-epicker.paused:not(.zap) aside {
display: block;
}
#ublock0-epicker ul,
#ublock0-epicker li,
#ublock0-epicker div {
@ -53,7 +69,7 @@ html#ublock0-epicker,
#ublock0-epicker #preview {
float: left;
}
#ublock0-epicker body.preview #preview {
#ublock0-epicker.preview #preview {
background-color: hsl(204, 100%, 83%);
border-color: hsl(204, 50%, 60%);
}
@ -141,18 +157,7 @@ html#ublock0-epicker,
#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
@ -162,23 +167,55 @@ html#ublock0-epicker,
60% { opacity: 1.0; }
100% { opacity: 0.1; }
}
#ublock0-epicker body.paused > aside {
#ublock0-epicker.paused aside {
opacity: 0.1;
visibility: visible;
z-index: 100;
}
#ublock0-epicker body.paused > aside:not(:hover):not(.show) {
#ublock0-epicker.paused:not(.show):not(.hide) aside:not(:hover) {
animation-duration: 1.6s;
animation-name: startDialog;
animation-timing-function: linear;
}
#ublock0-epicker body.paused > aside:hover {
#ublock0-epicker.paused aside:hover {
opacity: 1;
}
#ublock0-epicker body.paused > aside.show {
#ublock0-epicker.paused.show aside {
opacity: 1;
}
#ublock0-epicker body.paused > aside.hide {
#ublock0-epicker.paused.hide aside {
opacity: 0.1;
}
#ublock0-epicker svg {
cursor: crosshair;
box-sizing: border-box;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
#ublock0-epicker.paused svg {
cursor: not-allowed;
}
#ublock0-epicker svg > path:first-child {
fill: rgba(0,0,0,0.5);
fill-rule: evenodd;
}
#ublock0-epicker svg > path + path {
stroke: #F00;
stroke-width: 0.5px;
fill: rgba(255,63,63,0.20);
}
#ublock0-epicker.zap svg > path + path {
stroke: #FF0;
stroke-width: 0.5px;
fill: rgba(255,255,63,0.20);
}
#ublock0-epicker.preview svg > path {
fill: rgba(0,0,0,0.10);
}
#ublock0-epicker.preview svg > path + path {
stroke: none;
}

View File

@ -1,60 +0,0 @@
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

@ -30,8 +30,25 @@
if ( typeof vAPI !== 'object' ) { return; }
const $id = id => document.getElementById(id);
const $stor = selector => document.querySelector(selector);
const $storAll = selector => document.querySelectorAll(selector);
const pickerRoot = document.documentElement;
const dialog = $stor('aside');
const taCandidate = $stor('textarea');
let staticFilteringParser;
const svgRoot = $stor('svg');
const svgOcean = svgRoot.children[0];
const svgIslands = svgRoot.children[1];
const NoPaths = 'M0 0';
const epickerId = (( ) => {
const url = new URL(self.location.href);
if ( url.searchParams.has('zap') ) {
pickerRoot.classList.add('zap');
}
return url.searchParams.get('epid');
})();
if ( epickerId === null ) { return; }
@ -43,12 +60,6 @@ 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 ''; }
@ -121,7 +132,7 @@ const candidateFromFilterChoice = function(filterChoice) {
// - Discard narrowing directives.
// - Remove the id if one or more classes exist
// TODO: should remove tag name too? ¯\_(ツ)_/¯
if ( filterChoice.modifier ) {
if ( filterChoice.broad ) {
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.
@ -167,6 +178,127 @@ const candidateFromFilterChoice = function(filterChoice) {
/******************************************************************************/
const onSvgClicked = function(ev) {
// If zap mode, highlight element under mouse, this makes the zapper usable
// on touch screens.
if ( pickerRoot.classList.contains('zap') ) {
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'zapElementAtPoint',
mx: ev.clientX,
my: ev.clientY,
options: {
stay: ev.shiftKey || ev.type === 'touch',
highlight: ev.target !== svgIslands,
},
});
return;
}
// https://github.com/chrisaljoudi/uBlock/issues/810#issuecomment-74600694
// Unpause picker if:
// - click outside dialog AND
// - not in preview mode
if ( pickerRoot.classList.contains('paused') ) {
if ( pickerRoot.classList.contains('preview') === false ) {
unpausePicker();
}
return;
}
// Force dialog to always be visible when using a touch-driven device.
if ( ev.type === 'touch' ) {
pickerRoot.classList.add('show');
}
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'filterElementAtPoint',
mx: ev.clientX,
my: ev.clientY,
broad: ev.ctrlKey,
});
};
/*******************************************************************************
Swipe right:
If picker not paused: quit picker
If picker paused and dialog visible: hide dialog
If picker paused and dialog not visible: quit picker
Swipe left:
If picker paused and dialog not visible: show dialog
*/
const onSvgTouch = (( ) => {
let startX = 0, startY = 0;
let t0 = 0;
return ev => {
if ( ev.type === 'touchstart' ) {
startX = ev.touches[0].screenX;
startY = ev.touches[0].screenY;
t0 = ev.timeStamp;
return;
}
if ( startX === undefined ) { return; }
if ( ev.cancelable === false ) { return; }
const stopX = ev.changedTouches[0].screenX;
const stopY = ev.changedTouches[0].screenY;
const angle = Math.abs(Math.atan2(stopY - startY, stopX - startX));
const distance = Math.sqrt(
Math.pow(stopX - startX, 2),
Math.pow(stopY - startY, 2)
);
// Interpret touch events as a tap if:
// - Swipe is not valid; and
// - The time between start and stop was less than 200ms.
const duration = ev.timeStamp - t0;
if ( distance < 32 && duration < 200 ) {
onSvgClicked({
type: 'touch',
target: ev.target,
clientX: ev.changedTouches[0].pageX,
clientY: ev.changedTouches[0].pageY,
});
ev.preventDefault();
return;
}
if ( distance < 64 ) { return; }
const angleUpperBound = Math.PI * 0.25 * 0.5;
const swipeRight = angle < angleUpperBound;
if ( swipeRight === false && angle < Math.PI - angleUpperBound ) {
return;
}
ev.preventDefault();
// Swipe left.
if ( swipeRight === false ) {
if ( pickerRoot.classList.contains('paused') ) {
pickerRoot.classList.remove('hide');
pickerRoot.classList.add('show');
}
return;
}
// Swipe right.
if (
pickerRoot.classList.contains('zap') &&
svgIslands.getAttribute('d') !== NoPaths
) {
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'unhighlight'
});
return;
}
else if (
pickerRoot.classList.contains('paused') &&
pickerRoot.classList.contains('show')
) {
pickerRoot.classList.remove('show');
pickerRoot.classList.add('hide');
return;
}
quitPicker();
};
})();
/******************************************************************************/
const onCandidateChanged = function() {
const filter = filterFromTextarea();
const bad = filter === '!';
@ -188,9 +320,9 @@ const onCandidateChanged = function() {
/******************************************************************************/
const onPreviewClicked = function() {
const state = pickerBody.classList.toggle('preview');
const state = pickerRoot.classList.toggle('preview');
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'dialogPreview',
what: 'togglePreview',
state,
});
};
@ -221,38 +353,27 @@ const onCreateClicked = function() {
/******************************************************************************/
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 onPickClicked = function() {
unpausePicker();
};
/******************************************************************************/
const onQuitClicked = function() {
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'dialogQuit'
});
quitPicker();
};
/******************************************************************************/
const onCandidateClicked = function(ev) {
let li = ev.target.closest('li');
if ( li === null ) { return; }
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
broad: ev.ctrlKey || ev.metaKey
};
while ( li.previousElementSibling !== null ) {
li = li.previousElementSibling;
@ -275,6 +396,7 @@ const onKeyPressed = function(ev) {
/******************************************************************************/
const onStartMoving = (( ) => {
let isTouch = false;
let mx0 = 0, my0 = 0;
let mx1 = 0, my1 = 0;
let r0 = 0, b0 = 0;
@ -290,44 +412,101 @@ const onStartMoving = (( ) => {
};
const moveAsync = ev => {
if ( ev.isTrusted === false ) { return; }
eatEvent(ev);
if ( timer !== undefined ) { return; }
mx1 = ev.pageX;
my1 = ev.pageY;
if ( isTouch ) {
const touch = ev.touches[0];
mx1 = touch.pageX;
my1 = touch.pageY;
} else {
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 });
if ( isTouch ) {
self.removeEventListener('touchmove', moveAsync, { capture: true });
} else {
self.removeEventListener('mousemove', moveAsync, { capture: 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;
isTouch = ev.type.startsWith('touch');
if ( isTouch ) {
const touch = ev.touches[0];
mx0 = touch.pageX;
my0 = touch.pageY;
} else {
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;
rMax = pickerRoot.clientWidth - 4 - rect.width ;
bMax = pickerRoot.clientHeight - 4 - rect.height;
dialog.classList.add('moving');
self.addEventListener('mousemove', moveAsync, { capture: true });
self.addEventListener('mouseup', stop, { capture: true, once: true });
if ( isTouch ) {
self.addEventListener('touchmove', moveAsync, { capture: true });
self.addEventListener('touchend', stop, { capture: true, once: true });
} else {
self.addEventListener('mousemove', moveAsync, { capture: true });
self.addEventListener('mouseup', stop, { capture: true, once: true });
}
eatEvent(ev);
};
})();
/******************************************************************************/
const svgListening = (( ) => {
let on = false;
let timer;
let mx = 0, my = 0;
const onTimer = ( ) => {
timer = undefined;
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'highlightElementAtPoint',
mx,
my,
});
};
const onHover = ev => {
mx = ev.clientX;
my = ev.clientY;
if ( timer === undefined ) {
timer = self.requestAnimationFrame(onTimer);
}
};
return state => {
if ( state === on ) { return; }
on = state;
if ( on ) {
document.addEventListener('mousemove', onHover, { passive: true });
return;
}
document.removeEventListener('mousemove', onHover, { passive: true });
if ( timer !== undefined ) {
self.cancelAnimationFrame(timer);
timer = undefined;
}
};
})();
/******************************************************************************/
const eatEvent = function(ev) {
ev.stopPropagation();
ev.preventDefault();
@ -336,9 +515,9 @@ const eatEvent = function(ev) {
/******************************************************************************/
const showDialog = function(details) {
pickerBody.classList.add('paused');
pausePicker();
const { netFilters, cosmeticFilters, filter, options } = details;
const { netFilters, cosmeticFilters, filter, options = {} } = details;
// https://github.com/gorhill/uBlock/issues/738
// Trim dots.
@ -390,7 +569,7 @@ const showDialog = function(details) {
const filterChoice = {
filters: filter.filters,
slot: filter.slot,
modifier: options.modifier || false
broad: options.broad || false
};
taCandidate.value = candidateFromFilterChoice(filterChoice);
@ -399,52 +578,83 @@ const showDialog = function(details) {
/******************************************************************************/
// 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 pausePicker = function() {
pickerRoot.classList.add('paused');
svgListening(false);
};
/******************************************************************************/
const pickerBody = document.body;
const dialog = $stor('aside');
const taCandidate = $stor('textarea');
let staticFilteringParser;
const unpausePicker = function() {
pickerRoot.classList.remove('paused', 'preview');
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'togglePreview',
state: false,
});
svgListening(true);
};
/******************************************************************************/
const startDialog = function() {
dialog.addEventListener('click', eatEvent);
const startPicker = function() {
self.addEventListener('keydown', onKeyPressed, true);
const svg = $stor('svg');
svg.addEventListener('click', onSvgClicked);
svg.addEventListener('touchstart', onSvgTouch);
svg.addEventListener('touchend', onSvgTouch);
unpausePicker();
if ( pickerRoot.classList.contains('zap') ) { return; }
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);
$id('toolbar').addEventListener('touchstart', onStartMoving);
staticFilteringParser = new vAPI.StaticFilteringParser({ interactive: true });
};
/******************************************************************************/
const quitPicker = function() {
vAPI.MessagingConnection.sendTo(epickerConnectionId, { what: 'quitPicker' });
vAPI.MessagingConnection.disconnectFrom(epickerConnectionId);
};
/******************************************************************************/
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', '');
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;
}
break;
case 'svgListening': {
svgListening(msg.on);
break;
}
case 'svgPaths': {
let { ocean, islands } = msg;
ocean += islands;
svgOcean.setAttribute('d', ocean);
svgIslands.setAttribute('d', islands || NoPaths);
break;
}
default:
break;
}
};
@ -452,19 +662,18 @@ const onPickerMessage = function(msg) {
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;
case 'connectionBroken':
break;
case 'connectionMessage':
onPickerMessage(msg.payload);
break;
case 'connectionAccepted':
epickerConnectionId = msg.id;
startPicker();
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'start',
});
break;
}
};

View File

@ -711,26 +711,6 @@ const onMessage = function(request, sender, callback) {
// Async
switch ( request.what ) {
case 'elementPickerArguments':
const xhr = new XMLHttpRequest();
xhr.open('GET', 'css/epicker.css', true);
xhr.overrideMimeType('text/html;charset=utf-8');
xhr.responseType = 'text';
xhr.onload = function() {
this.onload = null;
callback({
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();
return;
default:
break;
}
@ -739,6 +719,16 @@ const onMessage = function(request, sender, callback) {
let response;
switch ( request.what ) {
case 'elementPickerArguments':
response = {
target: µb.epickerArgs.target,
mouse: µb.epickerArgs.mouse,
zap: µb.epickerArgs.zap,
eprom: µb.epickerArgs.eprom,
pickerURL: vAPI.getURL(`/web_accessible_resources/epicker-ui.html${vAPI.warSecret()}`),
};
µb.epickerArgs.target = '';
break;
case 'elementPickerEprom':
µb.epickerArgs.eprom = request;
break;

View File

@ -30,24 +30,13 @@
/******************************************************************************/
if ( window.top !== window || typeof vAPI !== 'object' ) { 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;
const netFilterCandidates = [];
const cosmeticFilterCandidates = [];
@ -103,8 +92,8 @@ const getElementBoundingClientRect = function(elem) {
return {
height: bottom - top,
left: left,
top: top,
left,
top,
width: right - left
};
};
@ -113,45 +102,40 @@ const getElementBoundingClientRect = function(elem) {
const highlightElements = function(elems, force) {
// To make mouse move handler more efficient
if ( !force && elems.length === targetElements.length ) {
if ( elems.length === 0 || elems[0] === targetElements[0] ) {
return;
}
if (
(force !== true) &&
(elems.length === targetElements.length) &&
(elems.length === 0 || elems[0] === targetElements[0])
) {
return;
}
targetElements = elems;
targetElements = [];
const ow = pickerRoot.contentWindow.innerWidth;
const oh = pickerRoot.contentWindow.innerHeight;
const ocean = [
'M0 0',
'h', ow,
'v', oh,
'h-', ow,
'z'
];
const ow = self.innerWidth;
const oh = self.innerHeight;
const islands = [];
for ( let i = 0; i < elems.length; i++ ) {
const elem = elems[i];
for ( const elem of elems ) {
if ( elem === pickerRoot ) { continue; }
targetElements.push(elem);
const rect = getElementBoundingClientRect(elem);
// Ignore if it's not on the screen
if ( rect.left > ow || rect.top > oh ||
rect.left + rect.width < 0 || rect.top + rect.height < 0 ) {
// Ignore offscreen areas
if (
rect.left > ow || rect.top > oh ||
rect.left + rect.width < 0 || rect.top + rect.height < 0
) {
continue;
}
const poly = 'M' + rect.left + ' ' + rect.top +
'h' + rect.width +
'v' + rect.height +
'h-' + rect.width +
'z';
ocean.push(poly);
islands.push(poly);
islands.push(
`M${rect.left} ${rect.top}h${rect.width}v${rect.height}h-${rect.width}z`
);
}
svgOcean.setAttribute('d', ocean.join(''));
svgIslands.setAttribute('d', islands.join('') || 'M0 0');
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'svgPaths',
ocean: `M0 0h${ow}v${oh}h-${ow}z`,
islands: islands.join(''),
});
};
/******************************************************************************/
@ -170,8 +154,6 @@ const mergeStrings = function(urls) {
// The differ works at line granularity: we insert a linefeed after
// each character to trick the differ to work at character granularity.
const diffs = differ.diff_main(
//urls[i].replace(/.(?=.)/g, '$&\n'),
//merged.replace(/.(?=.)/g, '$&\n')
urls[i].split('').join('\n'),
merged.split('').join('\n')
);
@ -552,24 +534,20 @@ const filtersFrom = function(x, y) {
}
// https://github.com/gorhill/uBlock/issues/1545
// Network filter candidates from all other elements found at point (x, y).
// Network filter candidates from all other elements found at
// point (x, y).
if ( typeof x === 'number' ) {
let attrName = vAPI.sessionId + '-clickblind';
let previous;
const attrName = vAPI.sessionId + '-clickblind';
elem = first;
while ( elem !== null ) {
previous = elem;
const previous = elem;
elem.setAttribute(attrName, '');
elem = elementFromPoint(x, y);
if ( elem === null || elem === previous ) {
break;
}
if ( elem === null || elem === previous ) { break; }
netFilterFromElement(elem);
}
let elems = document.querySelectorAll(`[${attrName}]`);
i = elems.length;
while ( i-- ) {
elems[i].removeAttribute(attrName);
for ( const elem of document.querySelectorAll(`[${attrName}]`) ) {
elem.removeAttribute(attrName);
}
netFilterFromElement(document.body);
@ -761,7 +739,7 @@ const filterToDOMInterface = (( ) => {
if ( filter === '' || filter === '!' ) {
lastFilter = '';
lastResultset = [];
return;
return lastResultset;
}
lastFilter = filter;
lastAction = undefined;
@ -868,7 +846,6 @@ const filterToDOMInterface = (( ) => {
// immediately rather than wait for the next page load.
const preview = function(state, permanent = false) {
previewing = state !== false;
pickerBody.classList.toggle('preview', previewing);
if ( previewing === false ) {
return unapply();
}
@ -909,15 +886,6 @@ const filterToDOMInterface = (( ) => {
/******************************************************************************/
const showDialog = function(options) {
pausePicker();
options = options || {};
// Typically the dialog will be forced to be visible when using a
// touch-aware device.
dialog.classList.toggle('show', options.show === true);
dialog.classList.remove('hide');
vAPI.MessagingConnection.sendTo(epickerConnectionId, {
what: 'showDialog',
hostname: self.location.hostname,
@ -931,18 +899,73 @@ const showDialog = function(options) {
/******************************************************************************/
const elementFromPoint = (( ) => {
let lastX, lastY;
return (x, y) => {
if ( x !== undefined ) {
lastX = x; lastY = y;
} else if ( lastX !== undefined ) {
x = lastX; y = lastY;
} else {
return null;
}
if ( !pickerRoot ) { return null; }
const magicAttr = `${vAPI.sessionId}-clickblind`;
pickerRoot.setAttribute(magicAttr, '');
let elem = document.elementFromPoint(x, y);
if ( elem === document.body || elem === document.documentElement ) {
elem = null;
}
// https://github.com/uBlockOrigin/uBlock-issues/issues/380
pickerRoot.removeAttribute(magicAttr);
return elem;
};
})();
/******************************************************************************/
const highlightElementAtPoint = function(mx, my) {
const elem = elementFromPoint(mx, my);
highlightElements(elem ? [ elem ] : []);
};
/******************************************************************************/
const filterElementAtPoint = function(mx, my, broad) {
if ( filtersFrom(mx, my) === 0 ) { return; }
showDialog({ broad });
};
/******************************************************************************/
// https://www.reddit.com/r/uBlockOrigin/comments/bktxtb/scrolling_doesnt_work/emn901o
// Override 'fixed' position property on body element if present.
const zap = function() {
if ( targetElements.length === 0 ) { return; }
// With touch-driven devices, first highlight the element and remove only
// when tapping again the highlighted area.
const zapElementAtPoint = function(mx, my, options) {
if ( options.highlight ) {
const elem = elementFromPoint(mx, my);
if ( elem ) {
highlightElements([ elem ]);
}
return;
}
let elem = targetElements.length !== 0 && targetElements[0] || null;
if ( elem === null && mx !== undefined ) {
elem = elementFromPoint(mx, my);
}
if ( elem instanceof HTMLElement === false ) { return; }
const getStyleValue = function(elem, prop) {
const style = window.getComputedStyle(elem);
return style ? style[prop] : '';
};
let elem = targetElements[0];
// Heuristic to detect scroll-locking: remove such lock when detected.
if (
parseInt(getStyleValue(elem, 'zIndex'), 10) >= 1000 ||
@ -960,173 +983,8 @@ const zap = function() {
}
}
elem.parentNode.removeChild(elem);
elem = elementFromPoint();
highlightElements(elem ? [ elem ] : []);
};
/******************************************************************************/
const elementFromPoint = (( ) => {
let lastX, lastY;
return (x, y) => {
if ( x !== undefined ) {
lastX = x; lastY = y;
} else if ( lastX !== undefined ) {
x = lastX; y = lastY;
} else {
return null;
}
if ( !pickerRoot ) { return null; }
pickerRoot.style.setProperty('pointer-events', 'none', 'important');
let elem = document.elementFromPoint(x, y);
if ( elem === document.body || elem === document.documentElement ) {
elem = null;
}
// https://github.com/uBlockOrigin/uBlock-issues/issues/380
pickerRoot.style.setProperty('pointer-events', 'auto', 'important');
return elem;
};
})();
/******************************************************************************/
const onSvgHovered = (( ) => {
let timer;
let mx = 0, my = 0;
const onTimer = function() {
timer = undefined;
const elem = elementFromPoint(mx, my);
highlightElements(elem ? [elem] : []);
};
return function onMove(ev) {
mx = ev.clientX;
my = ev.clientY;
if ( timer === undefined ) {
timer = vAPI.setTimeout(onTimer, 40);
}
};
})();
/*******************************************************************************
Swipe right:
If picker not paused: quit picker
If picker paused and dialog visible: hide dialog
If picker paused and dialog not visible: quit picker
Swipe left:
If picker paused and dialog not visible: show dialog
*/
const onSvgTouchStartStop = (( ) => {
var startX,
startY;
return function onTouch(ev) {
if ( ev.type === 'touchstart' ) {
startX = ev.touches[0].screenX;
startY = ev.touches[0].screenY;
return;
}
if ( startX === undefined ) { return; }
if ( ev.cancelable === false ) { return; }
var stopX = ev.changedTouches[0].screenX,
stopY = ev.changedTouches[0].screenY,
angle = Math.abs(Math.atan2(stopY - startY, stopX - startX)),
distance = Math.sqrt(
Math.pow(stopX - startX, 2),
Math.pow(stopY - startY, 2)
);
// Interpret touch events as a click events if swipe is not valid.
if ( distance < 32 ) {
onSvgClicked({
type: 'touch',
target: ev.target,
clientX: ev.changedTouches[0].pageX,
clientY: ev.changedTouches[0].pageY,
isTrusted: ev.isTrusted
});
ev.preventDefault();
return;
}
if ( distance < 64 ) { return; }
var angleUpperBound = Math.PI * 0.25 * 0.5,
swipeRight = angle < angleUpperBound;
if ( swipeRight === false && angle < Math.PI - angleUpperBound ) {
return;
}
ev.preventDefault();
// Swipe left.
if ( swipeRight === false ) {
if ( pickerBody.classList.contains('paused') ) {
dialog.classList.remove('hide');
dialog.classList.add('show');
}
return;
}
// Swipe right.
if (
pickerBody.classList.contains('paused') &&
dialog.classList.contains('show')
) {
dialog.classList.remove('show');
dialog.classList.add('hide');
return;
}
stopPicker();
};
})();
/******************************************************************************/
const onSvgClicked = function(ev) {
if ( ev.isTrusted === false ) { return; }
// If zap mode, highlight element under mouse, this makes the zapper usable
// on touch screens.
if ( pickerBootArgs.zap ) {
let elem = targetElements.lenght !== 0 && targetElements[0];
if ( !elem || ev.target !== svgIslands ) {
elem = elementFromPoint(ev.clientX, ev.clientY);
if ( elem !== null ) {
highlightElements([elem]);
return;
}
}
zap();
if ( !ev.shiftKey ) {
stopPicker();
}
return;
}
// https://github.com/chrisaljoudi/uBlock/issues/810#issuecomment-74600694
// Unpause picker if:
// - click outside dialog AND
// - not in preview mode
if ( pickerBody.classList.contains('paused') ) {
if ( filterToDOMInterface.previewing === false ) {
unpausePicker();
}
return;
}
if ( filtersFrom(ev.clientX, ev.clientY) === 0 ) {
return;
}
showDialog({
show: ev.type === 'touch',
modifier: ev.ctrlKey
});
};
/******************************************************************************/
const svgListening = function(on) {
const action = (on ? 'add' : 'remove') + 'EventListener';
svgRoot[action]('mousemove', onSvgHovered, { passive: true });
elem.remove();
highlightElementAtPoint(mx, my);
};
/******************************************************************************/
@ -1139,7 +997,7 @@ const onKeyPressed = function(ev) {
) {
ev.stopPropagation();
ev.preventDefault();
zap();
zapElementAtPoint();
return;
}
// Esc
@ -1147,7 +1005,7 @@ const onKeyPressed = function(ev) {
ev.stopPropagation();
ev.preventDefault();
filterToDOMInterface.preview(false);
stopPicker();
quitPicker();
return;
}
};
@ -1158,67 +1016,18 @@ const onKeyPressed = function(ev) {
// May need to dynamically adjust the height of the overlay + new position
// of highlighted elements.
const onScrolled = function() {
const onViewportChanged = function() {
highlightElements(targetElements, true);
};
/******************************************************************************/
const pausePicker = function() {
pickerBody.classList.add('paused');
svgListening(false);
};
/******************************************************************************/
const unpausePicker = function() {
filterToDOMInterface.preview(false);
pickerBody.classList.remove('paused');
svgListening(true);
};
/******************************************************************************/
// 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);
targetElements = [];
candidateElements = [];
bestCandidateFilter = null;
if ( pickerRoot === null ) { return; }
// https://github.com/gorhill/uBlock/issues/2060
if ( vAPI.domFilterer instanceof Object ) {
vAPI.userStylesheet.remove(pickerCSS);
vAPI.userStylesheet.apply();
vAPI.domFilterer.unexcludeNode(pickerRoot);
}
window.removeEventListener('scroll', onScrolled, true);
svgListening(false);
pickerRoot.remove();
pickerRoot = pickerBody = svgRoot = svgOcean = svgIslands = dialog = null;
window.focus();
};
/******************************************************************************/
// Auto-select a specific target, if any, and if possible
const startPicker = function() {
svgRoot.addEventListener('click', onSvgClicked);
svgRoot.addEventListener('touchstart', onSvgTouchStartStop);
svgRoot.addEventListener('touchend', onSvgTouchStartStop);
svgListening(true);
self.addEventListener('scroll', onScrolled, true);
pickerRoot.contentWindow.addEventListener('keydown', onKeyPressed, true);
pickerRoot.contentWindow.focus();
self.addEventListener('scroll', onViewportChanged, { passive: true });
self.addEventListener('resize', onViewportChanged, { passive: true });
self.addEventListener('keydown', onKeyPressed, true);
// Try using mouse position
if (
@ -1227,8 +1036,7 @@ const startPicker = function() {
vAPI.mouseClick.x > 0
) {
if ( filtersFrom(vAPI.mouseClick.x, vAPI.mouseClick.y) !== 0 ) {
showDialog();
return;
return showDialog();
}
}
@ -1259,89 +1067,170 @@ const startPicker = function() {
}
elem.scrollIntoView({ behavior: 'smooth', block: 'start' });
filtersFrom(elem);
showDialog({ modifier: true });
return;
return showDialog({ broad: true });
}
// A target was specified, but it wasn't found: abort.
stopPicker();
quitPicker();
};
/******************************************************************************/
// Let's have the element picker code flushed from memory when no longer
// in use: to ensure this, release all local references.
const quitPicker = function() {
self.removeEventListener('scroll', onViewportChanged, { passive: true });
self.removeEventListener('resize', onViewportChanged, { passive: true });
self.removeEventListener('keydown', onKeyPressed, true);
vAPI.shutdown.remove(quitPicker);
vAPI.MessagingConnection.disconnectFrom(epickerConnectionId);
vAPI.MessagingConnection.removeListener(onConnectionMessage);
vAPI.userStylesheet.remove(pickerCSS);
vAPI.userStylesheet.apply();
if ( pickerRoot === null ) { return; }
// https://github.com/gorhill/uBlock/issues/2060
if ( vAPI.domFilterer instanceof Object ) {
vAPI.domFilterer.unexcludeNode(pickerRoot);
}
pickerRoot.remove();
pickerRoot = null;
self.focus();
};
/******************************************************************************/
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;
case 'start':
startPicker();
if ( targetElements.length === 0 ) {
highlightElements([], true);
}
break;
case 'dialogCreate':
filterToDOMInterface.queryAll(msg);
filterToDOMInterface.preview(true, true);
quitPicker();
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;
}
case 'quitPicker':
filterToDOMInterface.preview(false);
quitPicker();
break;
case 'highlightElementAtPoint':
highlightElementAtPoint(msg.mx, msg.my);
break;
case 'unhighlight':
highlightElements([]);
break;
case 'filterElementAtPoint':
filterElementAtPoint(msg.mx, msg.my, msg.broad);
break;
case 'zapElementAtPoint':
zapElementAtPoint(msg.mx, msg.my, msg.options);
if ( msg.options.highlight !== true && msg.options.stay !== true ) {
quitPicker();
}
break;
case 'togglePreview':
filterToDOMInterface.preview(msg.state);
break;
default:
break;
}
};
/******************************************************************************/
const onConnectionMessage = function(msg) {
if (
msg.from !== `epickerDialog-${epickerId}` ||
msg.to !== `epicker-${epickerId}`
) {
return;
}
if ( msg.from !== `epickerDialog-${epickerId}` ) { return; }
switch ( msg.what ) {
case 'connectionRequested':
epickerConnectionId = msg.id;
return true;
case 'connectionBroken':
stopPicker();
break;
case 'connectionMessage':
onDialogMessage(msg.payload);
break;
case 'connectionRequested':
epickerConnectionId = msg.id;
return true;
case 'connectionBroken':
quitPicker();
break;
case 'connectionMessage':
onDialogMessage(msg.payload);
break;
}
};
/******************************************************************************/
pickerRoot = document.createElement('iframe');
pickerRoot.setAttribute(vAPI.sessionId, '');
// epicker-ui.html will be injected in the page through an iframe, and
// is a sandboxed so as to prevent the page from interfering with its
// content and behavior.
//
// The purpose of epicker.js is to:
// - Install the element picker UI, and wait for the component to establish
// a direct communication channel.
// - Lookup candidate filters from elements at a specific position.
// - Highlight element(s) at a specific position or according to whether
// they match candidate filters;
// - Preview the result of applying a candidate filter;
//
// When the element picker is installed on a page, the only change the page
// sees is an iframe with a random attribute. The page can't see the content
// of the iframe, and cannot interfere with its style properties. However the
// page can remove the iframe.
// We need extra messaging capabilities + fetch/process picker arguments.
{
const results = await Promise.all([
vAPI.messaging.extend(),
vAPI.messaging.send('elementPicker', { what: 'elementPickerArguments' }),
]);
if ( results[0] !== true ) { return; }
pickerBootArgs = results[1];
if ( typeof pickerBootArgs !== 'object' || pickerBootArgs === null ) {
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 || '';
}
}
// The DOM filterer will not be present when cosmetic filtering is disabled.
if (
pickerBootArgs.zap !== true &&
vAPI.domFilterer instanceof Object === false
) {
return;
}
// https://github.com/gorhill/uBlock/issues/1529
// In addition to inline styles, harden the element picker styles by using
// dedicated CSS rules.
const pickerCSSStyle = [
'background: transparent',
'border: 0',
'border-radius: 0',
'box-shadow: none',
'display: block',
'height: 100%',
'height: 100vh',
'left: 0',
'margin: 0',
'max-height: none',
@ -1351,6 +1240,7 @@ const pickerCSSStyle = [
'opacity: 1',
'outline: 0',
'padding: 0',
'pointer-events: auto',
'position: fixed',
'top: 0',
'visibility: visible',
@ -1358,107 +1248,40 @@ const pickerCSSStyle = [
'z-index: 2147483647',
''
].join(' !important;');
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 style,
// hence why it's not added above as part of the pickerCSSStyle
// properties.
pickerRoot.style.setProperty('pointer-events', 'auto', 'important');
const pickerCSS = `
[${vAPI.sessionId}] {
:root [${vAPI.sessionId}] {
${pickerCSSStyle}
}
[${vAPI.sessionId}-clickblind] {
:root [${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(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;
}
pickerRoot = document.createElement('iframe');
pickerRoot.setAttribute(vAPI.sessionId, '');
document.documentElement.append(pickerRoot);
// https://github.com/gorhill/uBlock/issues/2060
vAPI.domFilterer.excludeNode(pickerRoot);
if ( vAPI.domFilterer instanceof Object ) {
vAPI.domFilterer.excludeNode(pickerRoot);
}
vAPI.shutdown.add(quitPicker);
if ( await vAPI.messaging.extend() !== true ) { return; }
vAPI.MessagingConnection.addListener(onConnectionMessage);
dialog.contentWindow.location = `${pickerBootArgs.dialogURL}&epid=${epickerId}`;
{
const url = new URL(pickerBootArgs.pickerURL);
url.searchParams.set('epid', epickerId);
if ( pickerBootArgs.zap ) {
url.searchParams.set('zap', '1');
}
pickerRoot.contentWindow.location = url.href;
}
/******************************************************************************/

View File

@ -4,7 +4,7 @@
<head>
<meta charset="utf-8">
<title>uBlock Origin Element Picker</title>
<link rel="stylesheet" href="../css/epicker-dialog.css">
<link rel="stylesheet" href="../css/epicker-ui.css">
</head>
<body>
@ -35,13 +35,14 @@
</li>
</ul>
</aside>
<svg><path d></path><path d></path></svg>
<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/epicker-ui.js"></script>
<script src="../js/static-filtering-parser.js"></script>
</body>