2025-01-14 14:47:41 +08:00

1360 lines
44 KiB
JavaScript

/*******************************************************************************
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
*/
(async ( ) => {
/******************************************************************************/
if ( typeof vAPI !== 'object' ) { return; }
if ( 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-]+)$/;
const matchElemToRegex = (elem, re) => {
const srcProp = netFilter1stSources[elem.localName];
let src = elem[srcProp];
if ( src instanceof SVGAnimatedString ) {
src = src.baseVal;
}
if ( typeof src === 'string' && /^https?:\/\//.test(src) ) {
if ( re.test(src) ) { return srcProp; }
}
src = elem.currentSrc;
if ( typeof src === 'string' && /^https?:\/\//.test(src) ) {
if ( re.test(src) ) { return srcProp; }
}
};
// 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.
//
// https://github.com/uBlockOrigin/uBlock-issues/issues/3142
// Don't try to match against non-network URIs.
const elems = document.querySelectorAll(
Object.keys(netFilter1stSources).join()
);
for ( const elem of elems ) {
const srcProp = matchElemToRegex(elem, reFilter);
if ( srcProp === undefined ) { continue; }
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',
' height: 100svh',
'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;