/******************************************************************************* uBlock Origin - a comprehensive, efficient content blocker Copyright (C) 2014-present Raymond Hill This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see {http://www.gnu.org/licenses/}. Home: https://github.com/gorhill/uBlock */ /* global CSS */ 'use strict'; /******************************************************************************/ /******************************************************************************/ (async ( ) => { /******************************************************************************/ if ( typeof vAPI !== 'object' ) { return; } if ( typeof vAPI === null ) { return; } if ( vAPI.pickerFrame ) { return; } vAPI.pickerFrame = true; const pickerUniqueId = vAPI.randomToken(); const reCosmeticAnchor = /^#(\$|\?|\$\?)?#/; const netFilterCandidates = []; const cosmeticFilterCandidates = []; let targetElements = []; let candidateElements = []; let bestCandidateFilter = null; const lastNetFilterSession = window.location.host + window.location.pathname; let lastNetFilterHostname = ''; let lastNetFilterUnion = ''; const hideBackgroundStyle = 'background-image:none!important;'; /******************************************************************************/ const safeQuerySelectorAll = function(node, selector) { if ( node !== null ) { try { return node.querySelectorAll(selector); } catch (e) { } } return []; }; /******************************************************************************/ const getElementBoundingClientRect = function(elem) { let rect = typeof elem.getBoundingClientRect === 'function' ? elem.getBoundingClientRect() : { height: 0, left: 0, top: 0, width: 0 }; // https://github.com/gorhill/uBlock/issues/1024 // Try not returning an empty bounding rect. if ( rect.width !== 0 && rect.height !== 0 ) { return rect; } if ( elem.shadowRoot instanceof DocumentFragment ) { return getElementBoundingClientRect(elem.shadowRoot); } let left = rect.left, right = left + rect.width, top = rect.top, bottom = top + rect.height; for ( const child of elem.children ) { rect = getElementBoundingClientRect(child); if ( rect.width === 0 || rect.height === 0 ) { continue; } if ( rect.left < left ) { left = rect.left; } if ( rect.right > right ) { right = rect.right; } if ( rect.top < top ) { top = rect.top; } if ( rect.bottom > bottom ) { bottom = rect.bottom; } } return { bottom, height: bottom - top, left, right, top, width: right - left }; }; /******************************************************************************/ const highlightElements = function(elems, force) { // To make mouse move handler more efficient if ( (force !== true) && (elems.length === targetElements.length) && (elems.length === 0 || elems[0] === targetElements[0]) ) { return; } targetElements = []; const ow = self.innerWidth; const oh = self.innerHeight; const islands = []; for ( const elem of elems ) { if ( elem === pickerFrame ) { continue; } targetElements.push(elem); const rect = getElementBoundingClientRect(elem); // Ignore offscreen areas if ( rect.left > ow || rect.top > oh || rect.left + rect.width < 0 || rect.top + rect.height < 0 ) { continue; } islands.push( `M${rect.left} ${rect.top}h${rect.width}v${rect.height}h-${rect.width}z` ); } pickerFramePort.postMessage({ what: 'svgPaths', ocean: `M0 0h${ow}v${oh}h-${ow}z`, islands: islands.join(''), }); }; /******************************************************************************/ const mergeStrings = function(urls) { if ( urls.length === 0 ) { return ''; } if ( urls.length === 1 || self.diff_match_patch instanceof Function === false ) { return urls[0]; } const differ = new self.diff_match_patch(); let merged = urls[0]; for ( let i = 1; i < urls.length; i++ ) { // 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].split('').join('\n'), merged.split('').join('\n') ); const result = []; for ( const diff of diffs ) { if ( diff[0] !== 0 ) { result.push('*'); } else { result.push(diff[1].replace(/\n+/g, '')); } merged = result.join(''); } } // Keep usage of wildcards to a sane level, too many of them can cause // high overhead filters merged = merged.replace(/^\*+$/, '') .replace(/\*{2,}/g, '*') .replace(/([^*]{1,3}\*)(?:[^*]{1,3}\*)+/g, '$1'); // https://github.com/uBlockOrigin/uBlock-issues/issues/1494 let pos = merged.indexOf('/'); if ( pos === -1 ) { pos = merged.length; } return merged.slice(0, pos).includes('*') ? urls[0] : merged; }; /******************************************************************************/ // Remove fragment part from a URL. const trimFragmentFromURL = function(url) { const pos = url.indexOf('#'); return pos !== -1 ? url.slice(0, pos) : url; }; /******************************************************************************/ // https://github.com/gorhill/uBlock/issues/1897 // Ignore `data:` URI, they can't be handled by an HTTP observer. const backgroundImageURLFromElement = function(elem) { const style = window.getComputedStyle(elem); const bgImg = style.backgroundImage || ''; const matches = /^url\((["']?)([^"']+)\1\)$/.exec(bgImg); const url = matches !== null && matches.length === 3 ? matches[2] : ''; return url.lastIndexOf('data:', 0) === -1 ? trimFragmentFromURL(url.slice(0, 1024)) : ''; }; /******************************************************************************/ // https://github.com/gorhill/uBlock/issues/1725#issuecomment-226479197 // Limit returned string to 1024 characters. // Also, return only URLs which will be seen by an HTTP observer. // https://github.com/uBlockOrigin/uBlock-issues/issues/2260 // Maybe get to the actual URL indirectly. const resourceURLsFromElement = function(elem) { const urls = []; const tagName = elem.localName; const prop = netFilter1stSources[tagName]; if ( prop === undefined ) { const url = backgroundImageURLFromElement(elem); if ( url !== '' ) { urls.push(url); } return urls; } let s = elem[prop]; if ( s instanceof SVGAnimatedString ) { s = s.baseVal; } if ( typeof s === 'string' && /^https?:\/\//.test(s) ) { urls.push(trimFragmentFromURL(s.slice(0, 1024))); } resourceURLsFromSrcset(elem, urls); resourceURLsFromPicture(elem, urls); return urls; }; // https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute // https://github.com/uBlockOrigin/uBlock-issues/issues/1071 const resourceURLsFromSrcset = function(elem, out) { let srcset = elem.srcset; if ( typeof srcset !== 'string' || srcset === '' ) { return; } for(;;) { // trim whitespace srcset = srcset.trim(); if ( srcset.length === 0 ) { break; } // abort in case of leading comma if ( /^,/.test(srcset) ) { break; } // collect and consume all non-whitespace characters let match = /^\S+/.exec(srcset); if ( match === null ) { break; } srcset = srcset.slice(match.index + match[0].length); let url = match[0]; // consume descriptor, if any if ( /,$/.test(url) ) { url = url.replace(/,$/, ''); if ( /,$/.test(url) ) { break; } } else { match = /^[^,]*(?:\(.+?\))?[^,]*(?:,|$)/.exec(srcset); if ( match === null ) { break; } srcset = srcset.slice(match.index + match[0].length); } const parsedURL = new URL(url, document.baseURI); if ( parsedURL.pathname.length === 0 ) { continue; } out.push(trimFragmentFromURL(parsedURL.href)); } }; // https://github.com/uBlockOrigin/uBlock-issues/issues/2069#issuecomment-1080600661 // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture const resourceURLsFromPicture = function(elem, out) { if ( elem.localName === 'source' ) { return; } const picture = elem.parentElement; if ( picture === null || picture.localName !== 'picture' ) { return; } const sources = picture.querySelectorAll(':scope > source'); for ( const source of sources ) { const urls = resourceURLsFromElement(source); if ( urls.length === 0 ) { continue; } out.push(...urls); } }; /******************************************************************************/ const netFilterFromUnion = function(patternIn, out) { // Reset reference filter when dealing with unrelated URLs const currentHostname = self.location.hostname; if ( lastNetFilterUnion === '' || currentHostname === '' || currentHostname !== lastNetFilterHostname ) { lastNetFilterHostname = currentHostname; lastNetFilterUnion = patternIn; vAPI.messaging.send('elementPicker', { what: 'elementPickerEprom', lastNetFilterSession, lastNetFilterHostname, lastNetFilterUnion, }); return; } // Related URLs lastNetFilterHostname = currentHostname; let patternOut = mergeStrings([ patternIn, lastNetFilterUnion ]); if ( patternOut !== '/*' && patternOut !== patternIn ) { const filter = `||${patternOut}`; if ( out.indexOf(filter) === -1 ) { out.push(filter); } lastNetFilterUnion = patternOut; } // Remember across element picker sessions vAPI.messaging.send('elementPicker', { what: 'elementPickerEprom', lastNetFilterSession, lastNetFilterHostname, lastNetFilterUnion, }); }; /******************************************************************************/ // Extract the best possible net filter, i.e. as specific as possible. const netFilterFromElement = function(elem) { if ( elem === null ) { return 0; } if ( elem.nodeType !== 1 ) { return 0; } const urls = resourceURLsFromElement(elem); if ( urls.length === 0 ) { return 0; } if ( candidateElements.indexOf(elem) === -1 ) { candidateElements.push(elem); } const candidates = netFilterCandidates; const len = candidates.length; for ( let i = 0; i < urls.length; i++ ) { urls[i] = urls[i].replace(/^https?:\/\//, ''); } const pattern = mergeStrings(urls); if ( bestCandidateFilter === null && elem.matches('html,body') === false ) { bestCandidateFilter = { type: 'net', filters: candidates, slot: candidates.length }; } candidates.push(`||${pattern}`); // Suggest a less narrow filter if possible const pos = pattern.indexOf('?'); if ( pos !== -1 ) { candidates.push(`||${pattern.slice(0, pos)}`); } // Suggest a filter which is a result of combining more than one URL. netFilterFromUnion(pattern, candidates); return candidates.length - len; }; const netFilter1stSources = { 'audio': 'src', 'embed': 'src', 'iframe': 'src', 'img': 'src', 'image': 'href', 'object': 'data', 'source': 'src', 'video': 'src' }; const filterTypes = { 'audio': 'media', 'embed': 'object', 'iframe': 'subdocument', 'img': 'image', 'object': 'object', 'video': 'media', }; /******************************************************************************/ // Extract the best possible cosmetic filter, i.e. as specific as possible. // https://github.com/gorhill/uBlock/issues/1725 // Also take into account the `src` attribute for `img` elements -- and limit // the value to the 1024 first characters. const cosmeticFilterFromElement = function(elem) { if ( elem === null ) { return 0; } if ( elem.nodeType !== 1 ) { return 0; } if ( noCosmeticFiltering ) { return 0; } if ( candidateElements.indexOf(elem) === -1 ) { candidateElements.push(elem); } let selector = ''; // Id let v = typeof elem.id === 'string' && CSS.escape(elem.id); if ( v ) { selector = '#' + v; } // Class(es) v = elem.classList; if ( v ) { let i = v.length || 0; while ( i-- ) { selector += '.' + CSS.escape(v.item(i)); } } // Tag name const tagName = CSS.escape(elem.localName); // Use attributes if still no selector found. // https://github.com/gorhill/uBlock/issues/1901 // Trim attribute value, this may help in case of malformed HTML. // // https://github.com/uBlockOrigin/uBlock-issues/issues/1923 // Escape unescaped `"` in attribute values if ( selector === '' ) { let attributes = [], attr; switch ( tagName ) { case 'a': v = elem.getAttribute('href'); if ( v ) { v = v.trim().replace(/\?.*$/, ''); if ( v.length ) { attributes.push({ k: 'href', v: v }); } } break; case 'iframe': case 'img': v = elem.getAttribute('src'); if ( v && v.length !== 0 ) { v = v.trim(); if ( v.startsWith('data:') ) { let pos = v.indexOf(','); if ( pos !== -1 ) { v = v.slice(0, pos + 1); } } else if ( v.startsWith('blob:') ) { v = new URL(v.slice(5)); v.pathname = ''; v = 'blob:' + v.href; } attributes.push({ k: 'src', v: v.slice(0, 256) }); break; } v = elem.getAttribute('alt'); if ( v && v.length !== 0 ) { attributes.push({ k: 'alt', v: v }); break; } break; default: break; } while ( (attr = attributes.pop()) ) { if ( attr.v.length === 0 ) { continue; } const w = attr.v.replace(/([^\\])"/g, '$1\\"'); v = elem.getAttribute(attr.k); if ( attr.v === v ) { selector += `[${attr.k}="${w}"]`; } else if ( v.startsWith(attr.v) ) { selector += `[${attr.k}^="${w}"]`; } else { selector += `[${attr.k}*="${w}"]`; } } } // https://github.com/uBlockOrigin/uBlock-issues/issues/17 // If selector is ambiguous at this point, add the element name to // further narrow it down. const parentNode = elem.parentNode; if ( selector === '' || safeQuerySelectorAll(parentNode, `:scope > ${selector}`).length > 1 ) { selector = tagName + selector; } // https://github.com/chrisaljoudi/uBlock/issues/637 // If the selector is still ambiguous at this point, further narrow using // `nth-of-type`. It is preferable to use `nth-of-type` as opposed to // `nth-child`, as `nth-of-type` is less volatile. if ( safeQuerySelectorAll(parentNode, `:scope > ${selector}`).length > 1 ) { let i = 1; while ( elem.previousSibling !== null ) { elem = elem.previousSibling; if ( typeof elem.localName === 'string' && elem.localName === tagName ) { i++; } } selector += `:nth-of-type(${i})`; } if ( bestCandidateFilter === null ) { bestCandidateFilter = { type: 'cosmetic', filters: cosmeticFilterCandidates, slot: cosmeticFilterCandidates.length }; } cosmeticFilterCandidates.push(`##${selector}`); return 1; }; /******************************************************************************/ const filtersFrom = function(x, y) { bestCandidateFilter = null; netFilterCandidates.length = 0; cosmeticFilterCandidates.length = 0; candidateElements.length = 0; // We need at least one element. let first = null; if ( typeof x === 'number' ) { first = elementFromPoint(x, y); } else if ( x instanceof HTMLElement ) { first = x; x = undefined; } // https://github.com/gorhill/uBlock/issues/1545 // Network filter candidates from all other elements found at [x,y]. // https://www.reddit.com/r/uBlockOrigin/comments/qmjk36/ // Extract network candidates first. if ( typeof x === 'number' ) { const magicAttr = `${pickerUniqueId}-clickblind`; pickerFrame.setAttribute(magicAttr, ''); const elems = document.elementsFromPoint(x, y); pickerFrame.removeAttribute(magicAttr); for ( const elem of elems ) { netFilterFromElement(elem); } } else if ( first !== null ) { netFilterFromElement(first); } // Cosmetic filter candidates from ancestors. // https://github.com/gorhill/uBlock/issues/2519 // https://github.com/uBlockOrigin/uBlock-issues/issues/17 // Prepend `body` if full selector is ambiguous. let elem = first; while ( elem && elem !== document.body ) { cosmeticFilterFromElement(elem); elem = elem.parentNode; } // The body tag is needed as anchor only when the immediate child // uses `nth-of-type`. let i = cosmeticFilterCandidates.length; if ( i !== 0 ) { const selector = cosmeticFilterCandidates[i-1].slice(2); if ( safeQuerySelectorAll(document.body, selector).length > 1 ) { cosmeticFilterCandidates.push('##body'); } } // https://github.com/gorhill/uBlock/commit/ebaa8a8bb28aef043a68c99965fe6c128a3fe5e4#commitcomment-63818019 // If still no best candidate, just use whatever is available in network // filter candidates -- which may have been previously skipped in favor // of cosmetic filters. if ( bestCandidateFilter === null && netFilterCandidates.length !== 0 ) { bestCandidateFilter = { type: 'net', filters: netFilterCandidates, slot: 0 }; } return netFilterCandidates.length + cosmeticFilterCandidates.length; }; /******************************************************************************* filterToDOMInterface.queryAll @desc Look-up all the HTML elements matching the filter passed in argument. @param string, a cosmetic or network filter. @param function, called once all items matching the filter have been collected. @return array, or undefined if the filter is invalid. filterToDOMInterface.preview @desc Apply/unapply filter to the DOM. @param string, a cosmetic of network filter, or literal false to remove the effects of the filter on the DOM. @return undefined. TODO: need to be revised once I implement chained cosmetic operators. */ const filterToDOMInterface = (( ) => { const reHnAnchorPrefix = '^[\\w-]+://(?:[^/?#]+\\.)?'; const reCaret = '(?:[^%.0-9a-z_-]|$)'; const rePseudoElements = /:(?::?after|:?before|:[a-z-]+)$/; // Net filters: we need to lookup manually -- translating into a foolproof // CSS selector is just not possible. // // https://github.com/chrisaljoudi/uBlock/issues/945 // Transform into a regular expression, this allows the user to // edit and insert wildcard(s) into the proposed filter. // https://www.reddit.com/r/uBlockOrigin/comments/c5do7w/ // Better handling of pure hostname filters. Also, discard single // alphanumeric character filters. const fromNetworkFilter = function(filter) { const out = []; if ( /^[0-9a-z]$/i.test(filter) ) { return out; } let reStr = ''; if ( filter.length > 2 && filter.startsWith('/') && filter.endsWith('/') ) { reStr = filter.slice(1, -1); } else if ( /^\w[\w.-]*[a-z]$/i.test(filter) ) { reStr = reHnAnchorPrefix + filter.toLowerCase().replace(/\./g, '\\.') + reCaret; } else { let rePrefix = '', reSuffix = ''; if ( filter.startsWith('||') ) { rePrefix = reHnAnchorPrefix; filter = filter.slice(2); } else if ( filter.startsWith('|') ) { rePrefix = '^'; filter = filter.slice(1); } if ( filter.endsWith('|') ) { reSuffix = '$'; filter = filter.slice(0, -1); } reStr = rePrefix + filter.replace(/[.+?${}()|[\]\\]/g, '\\$&') .replace(/\*+/g, '.*') .replace(/\^/g, reCaret) + reSuffix; } let reFilter = null; try { reFilter = new RegExp(reStr, 'i'); } catch (e) { return out; } // Lookup by tag names. // https://github.com/uBlockOrigin/uBlock-issues/issues/2260 // Maybe get to the actual URL indirectly. const elems = document.querySelectorAll( Object.keys(netFilter1stSources).join() ); for ( const elem of elems ) { const srcProp = netFilter1stSources[elem.localName]; let src = elem[srcProp]; if ( src instanceof SVGAnimatedString ) { src = src.baseVal; } if ( typeof src === 'string' && reFilter.test(src) || typeof elem.currentSrc === 'string' && reFilter.test(elem.currentSrc) ) { out.push({ elem, src: srcProp, opt: filterTypes[elem.localName], style: vAPI.hideStyle, }); } } // Find matching background image in current set of candidate elements. for ( const elem of candidateElements ) { if ( reFilter.test(backgroundImageURLFromElement(elem)) ) { out.push({ elem, bg: true, opt: 'image', style: hideBackgroundStyle, }); } } return out; }; // Cosmetic filters: these are straight CSS selectors. // // https://github.com/uBlockOrigin/uBlock-issues/issues/389 // Test filter using comma-separated list to better detect invalid CSS // selectors. // // https://github.com/gorhill/uBlock/issues/2515 // Remove trailing pseudo-element when querying. const fromPlainCosmeticFilter = function(raw) { let elems; try { document.documentElement.matches(`${raw},\na`); elems = document.querySelectorAll( raw.replace(rePseudoElements, '') ); } catch (e) { return; } const out = []; for ( const elem of elems ) { if ( elem === pickerFrame ) { continue; } out.push({ elem, raw, style: vAPI.hideStyle }); } return out; }; // https://github.com/gorhill/uBlock/issues/1772 // Handle procedural cosmetic filters. // // https://github.com/gorhill/uBlock/issues/2515 // Remove trailing pseudo-element when querying. const fromCompiledCosmeticFilter = function(raw) { if ( noCosmeticFiltering ) { return; } if ( typeof raw !== 'string' ) { return; } let elems, style; try { const o = JSON.parse(raw); elems = vAPI.domFilterer.createProceduralFilter(o).exec(); switch ( o.action && o.action[0] || '' ) { case '': case 'remove': style = vAPI.hideStyle; break; case 'style': style = o.action[1]; break; default: break; } } catch(ex) { return; } if ( !elems ) { return; } const out = []; for ( const elem of elems ) { out.push({ elem, raw, style }); } return out; }; vAPI.epickerStyleProxies = vAPI.epickerStyleProxies || new Map(); let lastFilter; let lastResultset; let previewing = false; const queryAll = function(details) { let { filter, compiled } = details; filter = filter.trim(); if ( filter === lastFilter ) { return lastResultset; } unapply(); if ( filter === '' || filter === '!' ) { lastFilter = ''; lastResultset = undefined; return; } lastFilter = filter; if ( reCosmeticAnchor.test(filter) === false ) { lastResultset = fromNetworkFilter(filter); if ( previewing ) { apply(); } return lastResultset; } lastResultset = fromPlainCosmeticFilter(compiled); if ( lastResultset ) { if ( previewing ) { apply(); } return lastResultset; } // Procedural cosmetic filter lastResultset = fromCompiledCosmeticFilter(compiled); if ( previewing ) { apply(); } return lastResultset; }; const apply = function() { unapply(); if ( Array.isArray(lastResultset) === false ) { return; } const rootElem = document.documentElement; for ( const { elem, style } of lastResultset ) { if ( elem === pickerFrame ) { continue; } if ( style === undefined ) { continue; } if ( elem === rootElem && style === vAPI.hideStyle ) { continue; } let styleToken = vAPI.epickerStyleProxies.get(style); if ( styleToken === undefined ) { styleToken = vAPI.randomToken(); vAPI.epickerStyleProxies.set(style, styleToken); vAPI.userStylesheet.add(`[${styleToken}]\n{${style}}`, true); } elem.setAttribute(styleToken, ''); } }; const unapply = function() { for ( const styleToken of vAPI.epickerStyleProxies.values() ) { for ( const elem of document.querySelectorAll(`[${styleToken}]`) ) { elem.removeAttribute(styleToken); } } }; // https://www.reddit.com/r/uBlockOrigin/comments/c62irc/ // Support injecting the cosmetic filters into the DOM filterer // immediately rather than wait for the next page load. const preview = function(state, permanent = false) { previewing = state !== false; if ( previewing === false ) { return unapply(); } if ( Array.isArray(lastResultset) === false ) { return; } if ( permanent === false || reCosmeticAnchor.test(lastFilter) === false ) { return apply(); } if ( noCosmeticFiltering ) { return; } const cssSelectors = new Set(); const proceduralSelectors = new Set(); for ( const { raw } of lastResultset ) { if ( raw.startsWith('{') ) { proceduralSelectors.add(raw); } else { cssSelectors.add(raw); } } if ( cssSelectors.size !== 0 ) { vAPI.domFilterer.addCSS( `${Array.from(cssSelectors).join('\n')}\n{${vAPI.hideStyle}}`, { mustInject: true } ); } if ( proceduralSelectors.size !== 0 ) { vAPI.domFilterer.addProceduralSelectors( Array.from(proceduralSelectors) ); } }; return { preview, queryAll }; })(); /******************************************************************************/ const onOptimizeCandidates = function(details) { const { candidates } = details; const results = []; for ( const paths of candidates ) { let count = Number.MAX_SAFE_INTEGER; let selector = ''; for ( let i = 0, n = paths.length; i < n; i++ ) { const s = paths.slice(n - i - 1).join(''); const elems = document.querySelectorAll(s); if ( elems.length < count ) { selector = s; count = elems.length; } } results.push({ selector: `##${selector}`, count }); } // Sort by most match count and shortest selector to least match count and // longest selector. results.sort((a, b) => { const r = b.count - a.count; if ( r !== 0 ) { return r; } return a.selector.length - b.selector.length; }); pickerFramePort.postMessage({ what: 'candidatesOptimized', candidates: results.map(a => a.selector), slot: details.slot, }); }; /******************************************************************************/ const showDialog = function(options) { pickerFramePort.postMessage({ what: 'showDialog', url: self.location.href, netFilters: netFilterCandidates, cosmeticFilters: cosmeticFilterCandidates, filter: bestCandidateFilter, 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 ( !pickerFrame ) { return null; } const magicAttr = `${pickerUniqueId}-clickblind`; pickerFrame.setAttribute(magicAttr, ''); let elem = document.elementFromPoint(x, y); if ( elem === null || /* to skip following tests */ elem === document.body || elem === document.documentElement || ( pickerBootArgs.zap !== true && noCosmeticFiltering && resourceURLsFromElement(elem).length === 0 ) ) { elem = null; } // https://github.com/uBlockOrigin/uBlock-issues/issues/380 pickerFrame.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. // 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 elemToRemove = targetElements.length !== 0 && targetElements[0] || null; if ( elemToRemove === null && mx !== undefined ) { elemToRemove = elementFromPoint(mx, my); } if ( elemToRemove instanceof Element === false ) { return; } const getStyleValue = (elem, prop) => { const style = window.getComputedStyle(elem); return style ? style[prop] : ''; }; // Heuristic to detect scroll-locking: remove such lock when detected. let maybeScrollLocked = elemToRemove.shadowRoot instanceof DocumentFragment; if ( maybeScrollLocked === false ) { let elem = elemToRemove; do { maybeScrollLocked = parseInt(getStyleValue(elem, 'zIndex'), 10) >= 1000 || getStyleValue(elem, 'position') === 'fixed'; elem = elem.parentElement; } while ( elem !== null && maybeScrollLocked === false ); } if ( maybeScrollLocked ) { const doc = document; if ( getStyleValue(doc.body, 'overflowY') === 'hidden' ) { doc.body.style.setProperty('overflow', 'auto', 'important'); } if ( getStyleValue(doc.body, 'position') === 'fixed' ) { doc.body.style.setProperty('position', 'initial', 'important'); } if ( getStyleValue(doc.documentElement, 'position') === 'fixed' ) { doc.documentElement.style.setProperty('position', 'initial', 'important'); } if ( getStyleValue(doc.documentElement, 'overflowY') === 'hidden' ) { doc.documentElement.style.setProperty('overflow', 'auto', 'important'); } } elemToRemove.remove(); highlightElementAtPoint(mx, my); }; /******************************************************************************/ const onKeyPressed = function(ev) { // Delete if ( (ev.key === 'Delete' || ev.key === 'Backspace') && pickerBootArgs.zap ) { ev.stopPropagation(); ev.preventDefault(); zapElementAtPoint(); return; } // Esc if ( ev.key === 'Escape' || ev.which === 27 ) { ev.stopPropagation(); ev.preventDefault(); filterToDOMInterface.preview(false); quitPicker(); return; } }; /******************************************************************************/ // https://github.com/chrisaljoudi/uBlock/issues/190 // May need to dynamically adjust the height of the overlay + new position // of highlighted elements. const onViewportChanged = function() { highlightElements(targetElements, true); }; /******************************************************************************/ // Auto-select a specific target, if any, and if possible const startPicker = function() { pickerFrame.focus(); self.addEventListener('scroll', onViewportChanged, { passive: true }); self.addEventListener('resize', onViewportChanged, { passive: true }); self.addEventListener('keydown', onKeyPressed, true); // Try using mouse position if ( pickerBootArgs.mouse && vAPI.mouseClick instanceof Object && typeof vAPI.mouseClick.x === 'number' && vAPI.mouseClick.x > 0 ) { if ( filtersFrom(vAPI.mouseClick.x, vAPI.mouseClick.y) !== 0 ) { return showDialog(); } } // No mouse position available, use suggested target const target = pickerBootArgs.target || ''; const pos = target.indexOf('\t'); if ( pos === -1 ) { return; } const srcAttrMap = { 'a': 'href', 'audio': 'src', 'embed': 'src', 'iframe': 'src', 'img': 'src', 'video': 'src', }; const tagName = target.slice(0, pos); const url = target.slice(pos + 1); const attr = srcAttrMap[tagName]; if ( attr === undefined ) { return; } const elems = document.getElementsByTagName(tagName); for ( const elem of elems ) { if ( elem === pickerFrame ) { continue; } const srcs = resourceURLsFromElement(elem); if ( (srcs.length !== 0 && srcs.includes(url) === false) || (srcs.length === 0 && url !== 'about:blank') ) { continue; } filtersFrom(elem); if ( netFilterCandidates.length !== 0 || cosmeticFilterCandidates.length !== 0 ) { if ( pickerBootArgs.mouse !== true ) { elem.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); } showDialog({ broad: true }); } return; } // A target was specified, but it wasn't found: abort. 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); if ( pickerFramePort ) { pickerFramePort.close(); pickerFramePort = null; } if ( pickerFrame ) { pickerFrame.remove(); pickerFrame = null; } vAPI.userStylesheet.remove(pickerCSS); vAPI.userStylesheet.apply(); vAPI.pickerFrame = false; self.focus(); }; vAPI.shutdown.add(quitPicker); /******************************************************************************/ const onDialogMessage = function(msg) { switch ( msg.what ) { case 'start': startPicker(); if ( pickerFramePort === null ) { break; } if ( targetElements.length === 0 ) { highlightElements([], true); } break; case 'optimizeCandidates': onOptimizeCandidates(msg); 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; } pickerFramePort.postMessage({ what: 'resultsetDetails', count: resultset.length, opt: resultset.length !== 0 ? resultset[0].opt : undefined, }); 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); if ( msg.state === false ) { highlightElements(targetElements, true); } break; default: break; } }; /******************************************************************************/ // 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. // The DOM filterer will not be present when cosmetic filtering is disabled. const noCosmeticFiltering = vAPI.domFilterer instanceof Object === false || vAPI.noSpecificCosmeticFiltering === true; // 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', 'color-scheme: light dark', 'display: block', 'filter: none', 'height: 100vh', 'left: 0', 'margin: 0', 'max-height: none', 'max-width: none', 'min-height: unset', 'min-width: unset', 'opacity: 1', 'outline: 0', 'padding: 0', 'pointer-events: auto', 'position: fixed', 'top: 0', 'transform: none', 'visibility: hidden', 'width: 100%', 'z-index: 2147483647', '' ].join(' !important;\n'); const pickerCSS = ` :root > [${pickerUniqueId}] { ${pickerCSSStyle} } :root > [${pickerUniqueId}-loaded] { visibility: visible !important; } :root [${pickerUniqueId}-clickblind] { pointer-events: none !important; } `; vAPI.userStylesheet.add(pickerCSS); vAPI.userStylesheet.apply(); let pickerBootArgs; let pickerFramePort = null; const bootstrap = async ( ) => { pickerBootArgs = await vAPI.messaging.send('elementPicker', { what: 'elementPickerArguments', }); if ( typeof pickerBootArgs !== 'object' ) { return; } if ( 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 || ''; } const url = new URL(pickerBootArgs.pickerURL); if ( pickerBootArgs.zap ) { url.searchParams.set('zap', '1'); } return new Promise(resolve => { const iframe = document.createElement('iframe'); iframe.setAttribute(pickerUniqueId, ''); document.documentElement.append(iframe); iframe.addEventListener('load', ( ) => { iframe.setAttribute(`${pickerUniqueId}-loaded`, ''); const channel = new MessageChannel(); pickerFramePort = channel.port1; pickerFramePort.onmessage = ev => { onDialogMessage(ev.data || {}); }; pickerFramePort.onmessageerror = ( ) => { quitPicker(); }; iframe.contentWindow.postMessage( { what: 'epickerStart' }, url.href, [ channel.port2 ] ); resolve(iframe); }, { once: true }); iframe.contentWindow.location = url.href; }); }; let pickerFrame = await bootstrap(); if ( Boolean(pickerFrame) === false ) { quitPicker(); } /******************************************************************************/ })(); /******************************************************************************* DO NOT: - Remove the following code - Add code beyond the following code Reason: - https://github.com/gorhill/uBlock/pull/3721 - uBO never uses the return value from injected content scripts **/ void 0;