diff --git a/platform/chromium/vapi-client-extra.js b/platform/chromium/vapi-client-extra.js index 383678f84..0328ec7a9 100644 --- a/platform/chromium/vapi-client-extra.js +++ b/platform/chromium/vapi-client-extra.js @@ -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; } diff --git a/src/css/epicker-dialog.css b/src/css/epicker-ui.css similarity index 79% rename from src/css/epicker-dialog.css rename to src/css/epicker-ui.css index 5b0c0e44f..0ff6eecd5 100644 --- a/src/css/epicker-dialog.css +++ b/src/css/epicker-ui.css @@ -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; +} diff --git a/src/css/epicker.css b/src/css/epicker.css deleted file mode 100644 index 1010fb24e..000000000 --- a/src/css/epicker.css +++ /dev/null @@ -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; -} diff --git a/src/js/epicker-dialog.js b/src/js/epicker-ui.js similarity index 61% rename from src/js/epicker-dialog.js rename to src/js/epicker-ui.js index cea9d4397..3b01cd4c3 100644 --- a/src/js/epicker-dialog.js +++ b/src/js/epicker-ui.js @@ -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; } }; diff --git a/src/js/messaging.js b/src/js/messaging.js index 38f38fda5..6dac63db5 100644 --- a/src/js/messaging.js +++ b/src/js/messaging.js @@ -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; diff --git a/src/js/scriptlets/epicker.js b/src/js/scriptlets/epicker.js index 0d420b5d4..7cb13a7e6 100644 --- a/src/js/scriptlets/epicker.js +++ b/src/js/scriptlets/epicker.js @@ -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; +} /******************************************************************************/ diff --git a/src/web_accessible_resources/epicker-dialog.html b/src/web_accessible_resources/epicker-ui.html similarity index 90% rename from src/web_accessible_resources/epicker-dialog.html rename to src/web_accessible_resources/epicker-ui.html index d87b46566..b1f4b4862 100644 --- a/src/web_accessible_resources/epicker-dialog.html +++ b/src/web_accessible_resources/epicker-ui.html @@ -4,7 +4,7 @@ uBlock Origin Element Picker - + @@ -35,13 +35,14 @@ + - +