[mv3] Add support for custom CSS-based cosmetic filters

Related issue:
https://github.com/uBlockOrigin/uBOL-home/issues/325

This is a first version, with support only for custom filters which are
plain CSS selectors. Future versions will extend support to style-based
and procedural cosmetic filters.

Manually text-editing existing custom filters is currently not supported,
this will be added in a future version in the Develop pane. To remove
existing custom filters, the "Remove a custom filter" tool can be used.
This commit is contained in:
Raymond Hill 2025-07-06 16:43:22 -04:00
parent cf13c4b4dc
commit 0b0294af4f
No known key found for this signature in database
GPG key ID: 25E1490B761470C2
36 changed files with 2463 additions and 778 deletions

View file

@ -65,6 +65,24 @@
"<all_urls>"
],
"use_dynamic_url": true
},
{
"resources": [
"/picker-ui.html"
],
"matches": [
"<all_urls>"
],
"use_dynamic_url": true
},
{
"resources": [
"/unpicker-ui.html"
],
"matches": [
"<all_urls>"
],
"use_dynamic_url": true
}
]
}

View file

@ -35,8 +35,12 @@
"message": "filtering mode",
"description": "Label in the popup panel for the current filtering mode"
},
"popupLocalToolsLabel": {
"message": "On this website",
"description": "Label in the popup panel got the local tools section"
},
"popupTipReport": {
"message": "Report an issue on this website",
"message": "Report an issue",
"description": "Tooltip used for the 'chat' icon in the panel"
},
"popupTipDashboard": {
@ -288,13 +292,21 @@
"description": "A button to navigate to the blocked page"
},
"zapperTipEnter": {
"message": "Enter element zapper mode",
"message": "Remove an element",
"description": "Tooltip for the button used to enter zapper mode"
},
"zapperTipQuit": {
"message": "Exit element zapper mode",
"description": "Tooltip for the button used to exit zapper mode"
},
"pickerTipEnter": {
"message": "Create a custom filter",
"description": "Label for the menu entry to create cosmetic filters"
},
"unpickerTipEnter": {
"message": "Remove a custom filter",
"description": "Label for the menu entry to delete cosmetic filters"
},
"developDropdownLabel": {
"message": "View:",
"description": "A label of a dropdown list"
@ -342,5 +354,25 @@
"dnrRulesCountInfo": {
"message": "Number of registered rules: {count}",
"description": "Short sentence to report the number of currently registered DNR rules"
},
"pickerSliderLabel": {
"message": "Move the slider to select the best match",
"description": "Label to describe the purpose of the slider"
},
"pickerPick": {
"message": "Pick",
"description": "Text for the button to re-enter element-picking mode"
},
"pickerPreview": {
"message": "Preview",
"description": "Text for the button to activate preview mode"
},
"pickerCreate": {
"message": "Create",
"description": "Text for the button to create the filter"
},
"unpickerUsage": {
"message": "Select a filter below to highlight matching elements in the webpage. Click the trash can to remove a filter.",
"description": "Summary description on how to use the tool to remove custom filters"
}
}

View file

@ -58,7 +58,6 @@ body[data-pane="about"] > section[data-pane="about"] {
/* high dpi devices */
:root.hidpi .tabButton {
font-family: Metropolis, sans-serif;
font-weight: 600;
letter-spacing: 0.5px;
}

View file

@ -0,0 +1,146 @@
:root#ubol-picker {
--ubol-overlay-fill: rgba(255,64,64,0.10);
--ubol-overlay-border: #F00;
}
#ubol-picker.paused svg#overlay {
cursor: not-allowed;
}
:root aside {
background-color: var(--surface-1);
border: 1px solid var(--border-2);
max-height: 40vh;
max-width: min(32rem, 100vw - 4px);
min-width: min(24rem, 100vw - 4px);
row-gap: 1em;
width: min(32rem, 100vw - 4px);
}
#ubol-picker aside > section:last-of-type {
margin-block-end: 0;
}
#ubol-picker aside > section:not(#windowbar,#moreOrLess) {
padding: 0 4px;
}
#ubol-picker[data-view="0"] aside section[data-view="1"],
#ubol-picker[data-view="0"] aside section[data-view="2"] {
display: none;
}
#ubol-picker[data-view="1"] aside section[data-view="2"] {
display: none;
}
#ubol-picker:not(.paused) aside > section:not(#windowbar) {
display: none;
}
#ubol-picker textarea {
border: 0;
box-sizing: border-box;
height: 6em;
min-height: 3em;
resize: none;
width: 100%;
}
#ubol-picker.mobile textarea {
height: unset;
}
#ubol-picker .resultsetWidgets {
color: var(--ink-2);
display: flex;
flex-direction: column;
font-size: smaller;
gap: 0.25em;
}
#ubol-picker .resultsetWidgets > span:first-of-type {
display: flex;
margin: 0 1em;
}
#ubol-picker .resultsetWidgets label {
flex-grow: 1;
}
#ubol-picker .resultsetWidgets #resultsetCount {
display: inline-block;
text-align: right;
width: 8ch;
}
#ubol-picker #toolbar {
display: flex;
justify-content: space-between;
}
#ubol-picker #toolbar button {
min-width: 5em;
}
#ubol-picker #candidateFilters {
font-family: monospace;
font-size: small;
min-height: 6em;
overflow-y: auto;
word-break: break-all;
}
#ubol-picker #candidateFilters ul {
margin: 0;
padding-inline-start: calc(2ch + 4px);
user-select: none;
-webkit-user-select: none;
}
#ubol-picker #candidateFilters ul > li {
list-style-type: '\25A0\00A0';
}
#ubol-picker #candidateFilters ul >li:has(:not(span.on)) {
list-style-type: '\25A1\00A0';
}
#ubol-picker #candidateFilters ul > li:nth-of-type(2n+1) {
background-color: var(--surface-2);
}
#ubol-picker #candidateFilters ul > li > span {
border: 1px solid transparent;
padding: 1px 2px;
}
#ubol-picker #candidateFilters ul > li > span.on {
background-color: var(--ink-1);
color: var(--surface-1);
}
#ubol-picker #candidateFilters ul > li > span:hover {
border: 1px solid var(--ink-1);
}
#ubol-picker #moreOrLess {
color: var(--ink-2);
column-gap: 0;
display: grid;
font-size: small;
grid-template: auto / 1fr 1fr;
justify-items: stretch;
user-select: none;
-webkit-user-select: none;
white-space: nowrap;
}
#ubol-picker #moreOrLess > span {
cursor: pointer;
padding: var(--default-gap-xxsmall) var(--default-gap-xsmall);
}
#ubol-picker #moreOrLess > span:last-of-type {
text-align: end;
}
#ubol-picker[data-view="2"] aside #moreOrLess > span:first-of-type {
visibility: hidden;
}
#ubol-picker[data-view="0"] aside #moreOrLess > span:last-of-type {
visibility: hidden;
}
#ubol-picker.desktop aside #moreOrLess > span:hover {
background-color: var(--surface-2);
}
#ubol-picker.preview #toolbar #preview {
color: var(--accent-ink-1);
background-color: var(--accent-surface-1);
}
#ubol-picker.preview #overlay path {
display: none;
}

View file

@ -21,7 +21,7 @@
min-width: var(--popup-min-width);
}
:root body.loading {
opacity: 0;
visibility: hidden;
}
a {
color: var(--ink-1);
@ -36,16 +36,12 @@ a {
align-self: flex-start;
display: flex;
flex-direction: column;
max-width: 340px;
}
:root.portrait #main {
align-self: inherit;
}
hr {
border: 0;
border-top: 1px solid var(--hr-ink);
margin: 0;
padding: 0;
margin: 0.5em 0;
}
#hostname {
@ -69,8 +65,9 @@ body[data-forbid~="filteringMode"] .filteringModeSlider {
pointer-events: none;
}
body[data-forbid~="dashboard"] #gotoDashboard,
body[data-forbid~="zapper"] #gotoZapper {
visibility: hidden;
body[data-forbid~="zapper"] #gotoZapper,
body[data-forbid~="picker"] #gotoPicker {
display: none;
}
#filteringModeText {
@ -131,102 +128,46 @@ body.needReload #refresh {
visibility: visible;
}
#rulesetStats {
padding: 0 var(--popup-gap-thin);
}
#rulesetStats .rulesetDetails h1 {
font-size: 1em;
font-weight: normal;
margin: 0.5em 0 0.25em 0;
}
#rulesetStats .rulesetDetails p {
color: var(--ink-2);
font-size: var(--font-size-smaller);
margin: 0.25em 0 0.5em 0.5em;
}
.itemRibbon {
column-gap: var(--popup-gap);
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
grid-template: auto / 1fr 1fr;
margin: var(--popup-gap);
}
.itemRibbon > span + span {
text-align: end;
}
.toolRibbon {
align-items: start;
background-color: var(--popup-toolbar-surface);
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
grid-template: auto / repeat(4, 1fr);
justify-items: stretch;
white-space: normal;
display: flex;
flex-direction: column;
font-size: calc(var(--font-size) - 1px);
line-height: calc(var(--font-size) * 2.2);
white-space: nowrap;
}
:root:not(.isHTTP) .needHTTP {
display: none;
}
.toolRibbon > label {
font-size: small;
color: var(--ink-2);
line-height: calc(var(--font-size) * 1.8);
margin-inline-start: 0.5em;
}
.toolRibbon .tool {
cursor: pointer;
display: flex;
flex-direction: column;
font-size: 1.4em;
min-width: 32px;
padding: var(--popup-gap)
var(--popup-gap-extra-thin);
unicode-bidi: embed;
visibility: hidden;
}
.toolRibbon .tool:hover {
color: var(--ink-1);
fill: var(--ink-1);
}
.toolRibbon .tool.enabled {
visibility: visible;
.toolRibbon .tool .fa-icon {
font-size: 1.4em;
min-width: 1.4em;
}
.toolRibbon .tool .caption {
color: var(--ink-2);
font: 10px/12px sans-serif;
margin-top: 6px;
text-align: center;
.toolRibbon .tool:not(.enabled) {
display: none;
}
body.mobile.no-tooltips .toolRibbon .tool {
font-size: 1.6em;
}
.toolRibbon.genericTools {
margin-bottom: 0;
margin-bottom: 0;
}
#moreOrLess {
column-gap: 0;
display: grid;
grid-template: auto / 1fr 1fr;
justify-items: stretch;
margin: 1px 0 0 0;
}
#moreOrLess > span {
cursor: pointer;
margin: 0;
padding: var(--popup-gap-thin) var(--popup-gap);
user-select: none;
white-space: nowrap;
}
#moreButton .fa-icon {
transform: rotate(180deg);
}
#lessButton {
border-inline-start: 1px solid var(--surface-1);
text-align: end;
}
body[data-section="a b"] #moreButton {
pointer-events: none;
visibility: hidden;
}
body[data-section=""] #lessButton {
pointer-events: none;
visibility: hidden;
}
body:not([data-section~="a"]) [data-section="a"] {
display: none;
}
@ -234,25 +175,6 @@ body:not([data-section~="b"]) [data-section="b"] {
display: none;
}
/* configurable UI elements */
:root:not(.mobile) .toolRibbon .caption,
:root.mobile body.no-tooltips .toolRibbon .caption,
:root.mobile body[data-ui~="-captions"] .toolRibbon .caption {
display: none;
}
:root.mobile .toolRibbon .caption,
:root:not(.mobile) body[data-ui~="+captions"] .toolRibbon .caption {
display: inherit;
}
:root:not(.mobile) .toolRibbon .tool,
:root.mobile body.no-tooltips .toolRibbon .tool,
:root.mobile body[data-ui~="-captions"] .toolRibbon .tool {
padding: var(--popup-gap) var(--popup-gap-thin);
}
:root.mobile #moreOrLess > span {
padding: var(--popup-gap);
}
/* horizontally-constrained viewport */
:root.portrait body {
overflow-y: auto;
@ -265,9 +187,6 @@ body:not([data-section~="b"]) [data-section="b"] {
:root.desktop {
display: flex;
}
:root.desktop body {
--popup-gap: calc(var(--font-size) * 0.875);
}
:root.desktop .rulesetTools [id]:hover {
background-color: var(--popup-ruleset-tool-surface-hover);
}
@ -277,11 +196,3 @@ body:not([data-section~="b"]) [data-section="b"] {
:root.desktop .tool:hover {
background-color: var(--popup-toolbar-surface-hover);
}
:root.desktop #moreOrLess > span:hover {
background-color: var(--surface-2);
/* background-color: var(--popup-toolbar-surface-hover); */
}
#templates {
display: none;
}

View file

@ -0,0 +1,99 @@
:root {
--ubol-overlay-fill: rgba(255,255,255,0.2);
--ubol-overlay-border: #FFF;
--ubol-overlay-canvas: rgba(128,128,128,0.3);
}
:root.dark aside {
color-scheme: dark;
}
:root,
:root > body {
background: transparent;
height: 100vh;
height: 100svh;
margin: 0;
overflow: hidden;
width: 100vw;
}
:root > body.loading {
visibility: hidden;
}
:root :focus {
outline: none;
}
svg#overlay {
cursor: crosshair;
box-sizing: border-box;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
svg#overlay > path:first-child {
fill: var(--ubol-overlay-canvas);
fill-rule: evenodd;
}
svg#overlay > path + path {
stroke: var(--ubol-overlay-border);
stroke-width: 1.5px;
fill: var(--ubol-overlay-fill);
}
:root aside {
box-sizing: border-box;
cursor: default;
display: flex;
flex-direction: column;
position: fixed;
z-index: 100;
}
:root body[dir="rtl"] aside {
left: 2px;
right: unset;
}
:root.minimized aside > section:not(#windowbar) {
display: none !important;
}
:root.minimized aside {
min-width: min(12em, 100vw - 4px);
width: min(12em, 100vw - 4px);
}
:root aside #windowbar {
border-bottom: 1px solid var(--border-1);
display: flex;
}
:root aside #windowbar > div {
fill: none;
height: 2em;
stroke: var(--ink-1);
stroke-width: 2px;
width: 2em;
}
:root.minimized aside #windowbar > div {
height: 3em;
width: 3em;
}
:root.minimized aside #windowbar > #minimize svg > path,
#windowbar #minimize svg > rect {
display: none;
}
:root.minimized aside #windowbar > #minimize svg > rect {
display: initial;
}
:root #windowbar > #move {
align-items: center;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAECAYAAACtBE5DAAAAFElEQVQI12NgwAfKy8v/M5ANYLoBshgEyQo6H9UAAAAASUVORK5CYII=');
cursor: grab;
display: flex;
justify-content: center;
flex-grow: 1;
}
:root #windowbar > div:hover {
background-color: var(--surface-2)
}

View file

@ -0,0 +1,64 @@
:root#ubol-unpicker {
--ubol-overlay-fill: rgba(64,255,64,0.10);
--ubol-overlay-border: #0F0;
}
#ubol-unpicker svg#overlay {
cursor: not-allowed;
}
:root aside {
background-color: var(--surface-1);
border: 1px solid var(--border-2);
max-height: 40vh;
max-width: min(32rem, 100vw - 4px);
min-width: min(24rem, 100vw - 4px);
width: min(32rem, 100vw - 4px);
}
#ubol-unpicker aside > section:not(#windowbar) {
margin: 1em 1em 0 1em;
}
#ubol-unpicker aside > section:not(#windowbar):last-of-type {
margin-bottom: 1em;
}
#ubol-unpicker aside > section [data-i18n="unpickerUsage"] {
color: var(--ink-2);
font-size: small;
}
#ubol-unpicker #customFilters {
font-family: monospace;
}
#ubol-unpicker .customFilter {
display: flex;
}
#ubol-unpicker .customFilter:nth-of-type(2n+1) {
background-color: var(--surface-2);
}
#ubol-unpicker .customFilter > span.selector {
flex-grow: 1;
font-size: small;
padding: 0.5em;
}
#ubol-unpicker .customFilter.on > span.selector {
background-color: var(--ink-1);
color: var(--surface-1);
}
#ubol-unpicker .customFilter.removed > span.selector {
text-decoration-line: line-through;
}
#ubol-unpicker .customFilter.removed > span.remove {
display: none;
}
#ubol-unpicker .customFilter.removed span.selector {
pointer-events: none;
}
#ubol-unpicker .customFilter > span.fa-icon {
flex-shrink: 0;
font-size: 1.25em;
padding: 0 0.5em;
}
#ubol-unpicker .customFilter:not(.removed) > span.undo {
display: none;
}

View file

@ -1,54 +1,16 @@
:root {
:root#ubol-zapper {
--quit-button-size: max(4em, min(6em, calc(100vw / 8), calc(100vh / 8)));
--ubol-overlay-fill: rgba(255,255,63,0.10);
--ubol-overlay-border: #FF0;
}
html#ubol-zapper,
#ubol-zapper body {
background: transparent;
cursor: not-allowed;
height: 100vh;
height: 100svh;
margin: 0;
overflow: hidden;
width: 100vw;
}
#ubol-zapper :focus {
outline: none;
}
#ubol-zapper aside {
box-sizing: border-box;
cursor: default;
display: flex;
flex-direction: column;
gap: 2px;
position: fixed;
right: 2px;
top: 50%;
transform: translateY(-50%);
z-index: 100;
}
#ubol-zapper body[dir="rtl"] aside {
left: 2px;
right: unset;
}
#ubol-zapper svg#sea {
cursor: crosshair;
box-sizing: border-box;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
#ubol-zapper svg#sea > path:first-child {
fill: rgba(0,0,0,0.5);
fill-rule: evenodd;
}
#ubol-zapper svg#sea > path + path {
stroke: #FF0;
stroke-width: 0.5px;
fill: rgba(255,255,63,0.20);
}
#ubol-zapper aside > div {
background-color: var(--surface-1);
border: 1px solid rgba(0,0,0,0.5);

View file

@ -133,7 +133,6 @@
</div>
<div class="li"><span data-i18n="aboutDependencies"></span></div>
<div class="liul">
<div class="li"><span><a href="https://github.com/chrismsimpson/Metropolis" target="_blank">Metropolis font family</a> by <a href="https://github.com/chrismsimpson">Chris Simpson</a></span></div>
<div class="li"><span><a href="https://github.com/rsms/inter" target="_blank">Inter font family</a> by <a href="https://github.com/rsms">Rasmus Andersson</a></span></div>
<div class="li"><span><a href="https://fontawesome.com/" target="_blank">FontAwesome font family</a> by <a href="https://github.com/davegandy">Dave Gandy</a></span></div>
<div class="li"><span><a href="https://github.com/mathiasbynens/punycode.js" target="_blank">Punycode.js</a> by <a href="https://github.com/mathiasbynens">Mathias Bynens</a></span></div>

View file

@ -31,6 +31,15 @@ import {
syncWithBrowserPermissions,
} from './mode-manager.js';
import {
addCustomFilter,
hasCustomFilters,
injectCustomFilters,
removeCustomFilter,
selectorsFromCustomFilters,
uninjectCustomFilters,
} from './filter-manager.js';
import {
adminReadEx,
getAdminRulesets,
@ -56,7 +65,6 @@ import {
getEffectiveDynamicRules,
getEffectiveSessionRules,
getEffectiveUserRules,
getEnabledRulesetsDetails,
getRulesetDetails,
patchDefaultRulesets,
setStrictBlockMode,
@ -153,14 +161,15 @@ function setDeveloperMode(state) {
function onMessage(request, sender, callback) {
const tabId = sender?.tab?.id ?? false;
const frameId = tabId && (sender?.frameId ?? false);
// Does not require trusted origin.
switch ( request.what ) {
case 'insertCSS': {
const tabId = sender?.tab?.id ?? false;
const frameId = sender?.frameId ?? false;
if ( tabId === false || frameId === false ) { return; }
if ( frameId === false ) { return false; }
browser.scripting.insertCSS({
css: request.css,
origin: 'USER',
@ -172,9 +181,7 @@ function onMessage(request, sender, callback) {
}
case 'removeCSS': {
const tabId = sender?.tab?.id ?? false;
const frameId = sender?.frameId ?? false;
if ( tabId === false || frameId === false ) { return; }
if ( frameId === false ) { return false; }
browser.scripting.removeCSS({
css: request.css,
origin: 'USER',
@ -186,13 +193,26 @@ function onMessage(request, sender, callback) {
}
case 'toggleToolbarIcon': {
const tabId = sender?.tab?.id ?? false;
if ( tabId ) {
toggleToolbarIcon(tabId);
}
return false;
}
case 'injectCustomFilters':
if ( frameId === false ) { return false; }
injectCustomFilters(tabId, frameId, request.hostname).then(selectors => {
callback(selectors);
});
return true;
case 'uninjectCustomFilters':
if ( frameId === false ) { return false; }
uninjectCustomFilters(tabId, frameId, request.hostname).then(( ) => {
callback();
});
return true;
default:
break;
}
@ -301,24 +321,18 @@ function onMessage(request, sender, callback) {
case 'popupPanelData': {
Promise.all([
hasBroadHostPermissions(),
getFilteringMode(request.hostname),
getEnabledRulesetsDetails(),
getFilteringMode(request.normalHostname),
adminReadEx('disabledFeatures'),
hasCustomFilters(request.hostname),
]).then(results => {
const [
hasOmnipotence,
level,
rulesetDetails,
disabledFeatures,
] = results;
callback({
hasOmnipotence,
level,
hasOmnipotence: results[0],
level: results[1],
autoReload: rulesetConfig.autoReload,
rulesetDetails,
isSideloaded,
developerMode: rulesetConfig.developerMode,
disabledFeatures,
disabledFeatures: results[2],
hasCustomFilters: results[3],
});
});
return true;
@ -432,6 +446,30 @@ function onMessage(request, sender, callback) {
});
return true;
case 'addCustomFilter':
addCustomFilter(request.hostname, request.selector).then(modified => {
if ( modified !== true ) { return; }
return registerInjectables();
}).then(( ) => {
callback();
})
return true;
case 'removeCustomFilter':
removeCustomFilter(request.hostname, request.selector).then(modified => {
if ( modified !== true ) { return; }
return registerInjectables();
}).then(( ) => {
callback();
});
return true;
case 'selectorsFromCustomFilters':
selectorsFromCustomFilters(request.hostname).then(selectors => {
callback(selectors);
});
return true;
default:
break;
}
@ -446,7 +484,7 @@ function onCommand(command, tab) {
case 'enter-zapper-mode': {
if ( browser.scripting === undefined ) { return; }
browser.scripting.executeScript({
files: [ '/js/scripting/zapper.js' ],
files: [ '/js/scripting/tool-overlay.js', '/js/scripting/zapper.js' ],
target: { tabId: tab.id },
});
break;

View file

@ -20,7 +20,7 @@
*/
export const webext = self.browser || self.chrome;
export const dnr = webext.declarativeNetRequest;
export const dnr = webext.declarativeNetRequest || {};
export const INITIATOR_DOMAINS = 'initiatorDomains';
export const EXCLUDED_INITIATOR_DOMAINS = 'excludedInitiatorDomains';

View file

@ -63,6 +63,17 @@ export async function localRemove(key) {
return browser.storage.local.remove(key);
}
export async function localKeys() {
if ( browser.storage instanceof Object === false ) { return; }
if ( browser.storage.local instanceof Object === false ) { return; }
if ( browser.storage.local.getKeys ) {
return browser.storage.local.getKeys();
}
const bin = await browser.storage.local.get(null);
if ( bin instanceof Object === false ) { return; }
return Object.keys(bin);
}
/******************************************************************************/
export async function sessionRead(key) {

View file

@ -0,0 +1,157 @@
/*******************************************************************************
uBlock Origin Lite - a comprehensive, MV3-compliant content blocker
Copyright (C) 2022-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
*/
import {
browser,
localKeys,
localRead,
localRemove,
localWrite,
} from './ext.js';
import {
intersectHostnameIters,
matchesFromHostnames,
strArrayEq,
subtractHostnameIters,
} from './utils.js';
/******************************************************************************/
export async function selectorsFromCustomFilters(hostname) {
const promises = [];
let hn = hostname;
while ( hn !== '' ) {
promises.push(localRead(`site.${hn}`));
const pos = hn.indexOf('.');
if ( pos === -1 ) { break; }
hn = hn.slice(pos + 1);
}
const results = await Promise.all(promises);
const out = [];
for ( let i = 0; i < promises.length; i++ ) {
const selectors = results[i];
if ( selectors === undefined ) { continue; }
selectors.forEach(selector => { out.push(selector.slice(1)); });
}
return out.sort();
}
/******************************************************************************/
export async function hasCustomFilters(hostname) {
const selectors = await selectorsFromCustomFilters(hostname);
return selectors?.length ?? 0;
}
/******************************************************************************/
export async function injectCustomFilters(tabId, frameId, hostname) {
const selectors = await selectorsFromCustomFilters(hostname);
if ( selectors.length === 0 ) { return; }
await browser.scripting.insertCSS({
css: `${selectors.join(',\n')}{display:none!important;}`,
origin: 'USER',
target: { tabId, frameIds: [ frameId ] },
}).catch(reason => {
console.log(reason);
});
return selectors;
}
/******************************************************************************/
export async function uninjectCustomFilters(tabId, frameId, hostname) {
const selectors = await selectorsFromCustomFilters(hostname);
if ( selectors.length === 0 ) { return; }
return browser.scripting.removeCSS({
css: `${selectors.join(',\n')}{display:none!important;}`,
origin: 'USER',
target: { tabId, frameIds: [ frameId ] },
}).catch(reason => {
console.log(reason);
});
}
/******************************************************************************/
export async function registerCustomFilters(context) {
const storageKeys = await localKeys() || [];
const siteKeys = storageKeys.filter(a => a.startsWith('site.'));
if ( siteKeys.length === 0 ) { return; }
const { none } = context.filteringModeDetails;
let hostnames = siteKeys.map(a => a.slice(5));
if ( none.has('all-urls') ) {
const { basic, optimal, complete } = context.filteringModeDetails;
hostnames = intersectHostnameIters(hostnames, [
...basic, ...optimal, ...complete
]);
} else if ( none.size !== 0 ) {
hostnames = [ ...subtractHostnameIters(hostnames, none) ];
}
if ( hostnames.length === 0 ) { return; }
const registered = context.before.get('css-user');
context.before.delete('css-user'); // Important!
const directive = {
id: 'css-user',
js: [ '/js/scripting/css-user.js' ],
matches: matchesFromHostnames(hostnames),
runAt: 'document_start',
};
if ( registered === undefined ) {
context.toAdd.push(directive);
} else if ( strArrayEq(registered.matches, directive.matches) === false ) {
context.toRemove.push('css-user');
context.toAdd.push(directive);
}
}
/******************************************************************************/
export async function addCustomFilter(hostname, selector) {
const key = `site.${hostname}`;
const selectors = await localRead(key) || [];
const filter = `0${selector}`;
if ( selectors.includes(filter) ) { return false; }
selectors.push(filter);
selectors.sort();
await localWrite(key, selectors);
return true;
}
/******************************************************************************/
export async function removeCustomFilter(hostname, selector) {
const key = `site.${hostname}`;
const selectors = await localRead(key);
if ( selectors === undefined ) { return false; }
const i = selectors.indexOf(`0${selector}`);
if ( i === -1 ) { return false; }
selectors.splice(i, 1);
await selectors.length !== 0
? localWrite(key, selectors)
: localRemove(key);
return true;
}

View file

@ -0,0 +1,431 @@
/*******************************************************************************
uBlock Origin - a comprehensive, efficient content blocker
Copyright (C) 2025-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
*/
import { dom, qs$, qsa$ } from './dom.js';
import { localRead, localWrite } from './ext.js';
import { toolOverlay } from './tool-overlay-ui.js';
/******************************************************************************/
let selectorPartsDB = new Map();
let sliderParts = [];
let previewCSS = '';
/******************************************************************************/
function isValidSelector(selector) {
isValidSelector.error = undefined;
if ( selector === '' ) { return false; }
try {
void document.querySelector(`${selector},a`);
} catch (reason) {
isValidSelector.error = reason;
return false;
}
return true;
}
/******************************************************************************/
function onSvgTouch(ev) {
if ( ev.type === 'touchstart' ) {
onSvgTouch.x0 = ev.touches[0].screenX;
onSvgTouch.y0 = ev.touches[0].screenY;
onSvgTouch.t0 = ev.timeStamp;
return;
}
if ( onSvgTouch.x0 === undefined ) { return; }
const stopX = ev.changedTouches[0].screenX;
const stopY = ev.changedTouches[0].screenY;
const distance = Math.sqrt(
Math.pow(stopX - onSvgTouch.x0, 2) +
Math.pow(stopY - onSvgTouch.y0, 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 - onSvgTouch.t0;
if ( distance >= 32 || duration >= 200 ) { return; }
onSvgClicked({
type: 'touch',
target: ev.target,
clientX: ev.changedTouches[0].pageX,
clientY: ev.changedTouches[0].pageY,
});
ev.preventDefault();
}
onSvgTouch.x0 = onSvgTouch.y0 = 0;
onSvgTouch.t0 = 0;
/******************************************************************************/
function onSvgClicked(ev) {
// Unpause picker if:
// - click outside dialog AND
// - not in preview mode
if ( dom.cl.has(dom.root, 'paused') ) {
if ( dom.cl.has(dom.root, 'preview') ) {
updatePreview(false);
}
unpausePicker();
return;
}
// Force dialog to always be visible when using a touch-driven device.
if ( ev.type === 'touch' ) {
dom.cl.add(dom.root, 'show');
}
toolOverlay.postMessage({
what: 'candidatesAtPoint',
mx: ev.clientX,
my: ev.clientY,
broad: ev.ctrlKey,
}).then(details => {
showDialog(details);
});
}
/******************************************************************************/
function onKeyPressed(ev) {
if ( ev.key === 'Escape' || ev.which === 27 ) {
quitPicker();
return;
}
}
/******************************************************************************/
function onMinimizeClicked() {
if ( dom.cl.has(dom.root, 'paused') === false ) {
pausePicker();
highlightCandidate();
return;
}
dom.cl.toggle(dom.root, 'minimized');
}
/******************************************************************************/
function onFilterTextChanged() {
highlightCandidate();
}
/******************************************************************************/
function toggleView(view, persist = false) {
dom.root.dataset.view = `${view}`;
if ( persist !== true ) { return; }
localWrite('picker.view', dom.root.dataset.view);
}
function onViewToggled(dir) {
let view = parseInt(dom.root.dataset.view, 10);
view += dir;
if ( view < 0 ) { view = 0; }
if ( view > 2 ) { view = 2; }
toggleView(view, true);
}
/******************************************************************************/
function selectorFromCandidates() {
const selectorParts = [];
let liPrevious = null;
for ( const li of qsa$('#candidateFilters li') ) {
const selector = [];
for ( const span of qsa$(li, '.on[data-part]') ) {
selector.push(span.textContent);
}
if ( selector.length !== 0 ) {
if ( liPrevious !== null ) {
if ( li.previousElementSibling === liPrevious ) {
selectorParts.unshift(' > ');
} else if ( liPrevious !== li ) {
selectorParts.unshift(' ');
}
}
liPrevious = li;
selectorParts.unshift(selector.join(''));
}
}
return selectorParts.join('');
}
/******************************************************************************/
function onSliderChanged(ev) {
updateSlider(ev.target.valueAsNumber);
}
function updateSlider(i) {
qs$('#slider').value = i;
dom.cl.remove('#candidateFilters [data-part]', 'on');
const parts = sliderParts[i];
for ( const address of parts ) {
dom.cl.add(`#candidateFilters [data-part="${address}"]`, 'on');
}
const selector = selectorFromCandidates();
qs$('textarea').value = selector;
highlightCandidate();
}
/******************************************************************************/
function updateElementCount(details) {
const { count, error } = details;
const span = qs$('#resultsetCount');
if ( error ) {
span.textContent = 'Error';
span.setAttribute('title', error);
} else {
span.textContent = count;
span.removeAttribute('title');
}
const disabled = Boolean(count) === false ? '' : null;
dom.attr('#create', 'disabled', disabled);
updatePreview();
}
/******************************************************************************/
function onPreviewClicked() {
dom.cl.toggle(dom.root, 'preview');
updatePreview();
}
function updatePreview(state) {
if ( state === undefined ) {
state = dom.cl.has(dom.root, 'preview');
} else {
dom.cl.toggle(dom.root, 'preview', state)
}
if ( previewCSS !== '' ) {
toolOverlay.postMessage({ what: 'removeCSS', css: previewCSS });
previewCSS = '';
}
if ( state === false ) { return; }
const selector = qs$('textarea').value;
if ( isValidSelector(selector) === false ) { return; }
previewCSS = `${selector}{display:none!important;}`;
toolOverlay.postMessage({ what: 'insertCSS', css: previewCSS });
}
/******************************************************************************/
async function onCreateClicked() {
const selector = qs$('textarea').value;
if ( isValidSelector(selector) === false ) { return; }
await toolOverlay.postMessage({ what: 'uninjectCustomFilters' }).then(( ) =>
toolOverlay.sendMessage({
what: 'addCustomFilter',
hostname: toolOverlay.url.hostname,
selector,
})
).then(( ) =>
toolOverlay.postMessage({ what: 'injectCustomFilters' })
);
qs$('textarea').value = '';
dom.cl.remove(dom.root, 'preview');
quitPicker();
}
/******************************************************************************/
function attributeNameFromSelector(part) {
const pos = part.search(/\^?=/);
return part.slice(1, pos);
}
/******************************************************************************/
function onCandidateClicked(ev) {
const target = ev.target;
if ( target.matches('[data-part]') ) {
const address = target.dataset.part;
const part = selectorPartsDB.get(parseInt(address, 10));
if ( part.startsWith('[') ) {
if ( target.textContent === part ) {
target.textContent = `[${attributeNameFromSelector(part)}]`;
dom.cl.remove(target, 'on');
} else if ( dom.cl.has(target, 'on') ) {
target.textContent = part;
} else {
dom.cl.add(target, 'on');
}
} else {
dom.cl.toggle(target, 'on');
}
} else if ( target.matches('li') ) {
if ( qs$(target, ':scope > span:not(.on)') !== null ) {
dom.cl.add(qsa$(target, ':scope > [data-part]:not(.on)'), 'on');
} else {
dom.cl.remove(qsa$(target, ':scope > [data-part]'), 'on');
}
}
const selector = selectorFromCandidates();
qs$('textarea').value = selector;
highlightCandidate();
}
/******************************************************************************/
function showDialog(msg) {
pausePicker();
/* global */selectorPartsDB = new Map(msg.partsDB);
const { listParts } = msg;
const root = qs$('#candidateFilters');
const ul = qs$(root, 'ul');
while ( ul.firstChild !== null ) {
ul.firstChild.remove();
}
for ( const parts of listParts ) {
const li = document.createElement('li');
for ( const address of parts ) {
const span = document.createElement('span');
const part = selectorPartsDB.get(address);
span.dataset.part = address;
if ( part.startsWith('[') ) {
span.textContent = `[${attributeNameFromSelector(part)}]`;
} else {
span.textContent = part;
}
li.append(span);
}
ul.appendChild(li);
}
/* global */sliderParts = msg.sliderParts;
const slider = qs$('#slider');
const last = sliderParts.length - 1;
dom.attr(slider, 'max', last);
dom.attr(slider, 'value', last);
dom.attr(slider, 'disabled', last !== 0 ? null : '');
updateSlider(last);
}
/******************************************************************************/
function highlightCandidate() {
const selector = qs$('textarea').value;
if ( isValidSelector(selector) === false ) {
toolOverlay.postMessage({ what: 'unhighlight' });
updateElementCount({ count: 0, error: isValidSelector.error });
return;
}
toolOverlay.postMessage({
what: 'highlightFromSelector',
selector,
}).then(result => {
updateElementCount(result);
});
}
/*******************************************************************************
*
* paused:
* - select element mode disabled
* - preview mode enabled or disabled
* - dialog unminimized
*
* unpaused:
* - select element mode enabled
* - preview mode disabled
* - dialog minimized
*
* */
function pausePicker() {
dom.cl.add(dom.root, 'paused');
dom.cl.remove(dom.root, 'minimized');
toolOverlay.highlightElementUnderMouse(false);
}
function unpausePicker() {
dom.cl.remove(dom.root, 'paused', 'preview');
dom.cl.add(dom.root, 'minimized');
updatePreview();
toolOverlay.postMessage({
what: 'togglePreview',
state: false,
});
toolOverlay.highlightElementUnderMouse(true);
}
/******************************************************************************/
function startPicker() {
toolOverlay.postMessage({ what: 'startTool' });
localRead('picker.view').then(value => {
if ( Boolean(value) === false ) { return; }
toggleView(value);
});
self.addEventListener('keydown', onKeyPressed, true);
dom.on('svg#overlay', 'click', onSvgClicked);
dom.on('svg#overlay', 'touchstart', onSvgTouch, { passive: true });
dom.on('svg#overlay', 'touchend', onSvgTouch);
dom.on('#minimize', 'click', onMinimizeClicked);
dom.on('textarea', 'input', onFilterTextChanged);
dom.on('#quit', 'click', quitPicker);
dom.on('#slider', 'input', onSliderChanged);
dom.on('#pick', 'click', resetPicker);
dom.on('#preview', 'click', onPreviewClicked);
dom.on('#moreOrLess > span:first-of-type', 'click', ( ) => { onViewToggled(1); });
dom.on('#moreOrLess > span:last-of-type', 'click', ( ) => { onViewToggled(-1); });
dom.on('#create', 'click', ( ) => { onCreateClicked(); });
dom.on('#candidateFilters ul', 'click', onCandidateClicked);
toolOverlay.highlightElementUnderMouse(true);
}
/******************************************************************************/
function quitPicker() {
updatePreview(false);
toolOverlay.stop();
}
/******************************************************************************/
function resetPicker() {
toolOverlay.postMessage({ what: 'unhighlight' });
unpausePicker();
}
/******************************************************************************/
function onMessage(msg) {
switch ( msg.what ) {
case 'startTool':
startPicker();
break;
default:
break;
}
}
/******************************************************************************/
// Wait for the content script to establish communication
toolOverlay.start(onMessage);
/******************************************************************************/

View file

@ -19,15 +19,9 @@
Home: https://github.com/gorhill/uBlock
*/
import {
browser,
localRead, localWrite,
runtime,
sendMessage,
} from './ext.js';
import { browser, runtime, sendMessage } from './ext.js';
import { dom, qs$ } from './dom.js';
import { i18n, i18n$ } from './i18n.js';
import { i18n$ } from './i18n.js';
import punycode from './punycode.js';
/******************************************************************************/
@ -208,8 +202,7 @@ dom.on(
ev => {
const span = ev.target;
const level = parseInt(span.dataset.level, 10);
dom.text(
'#filteringModeText > span:nth-of-type(2)',
dom.text('#filteringModeText > span:nth-of-type(2)',
i18n$(`filteringMode${level}Name`)
);
}
@ -226,65 +219,7 @@ dom.on(
/******************************************************************************/
// The popup panel is made of sections. Visibility of sections can be
// toggled on/off.
const maxNumberOfSections = 2;
const sectionBitsFromAttribute = function() {
const value = dom.body.dataset.section;
if ( value === '' ) { return 0; }
let bits = 0;
for ( const c of value.split(' ') ) {
bits |= 1 << (c.charCodeAt(0) - 97);
}
return bits;
};
const sectionBitsToAttribute = function(bits) {
if ( typeof bits !== 'number' ) { return; }
if ( isNaN(bits) ) { return; }
const value = [];
for ( let i = 0; i < maxNumberOfSections; i++ ) {
const bit = 1 << i;
if ( (bits & bit) === 0 ) { continue; }
value.push(String.fromCharCode(97 + i));
}
dom.body.dataset.section = value.join(' ');
};
async function toggleSections(more) {
let currentBits = sectionBitsFromAttribute();
let newBits = currentBits;
for ( let i = 0; i < maxNumberOfSections; i++ ) {
const bit = 1 << (more ? i : maxNumberOfSections - i - 1);
if ( more ) {
newBits |= bit;
} else {
newBits &= ~bit;
}
if ( newBits !== currentBits ) { break; }
}
if ( newBits === currentBits ) { return; }
sectionBitsToAttribute(newBits);
localWrite('popupPanelSections', newBits);
}
localRead('popupPanelSections').then(bits => {
sectionBitsToAttribute(bits || 0);
});
dom.on('#moreButton', 'click', ( ) => {
toggleSections(true);
});
dom.on('#lessButton', 'click', ( ) => {
toggleSections(false);
});
/******************************************************************************/
dom.on('#showMatchedRules', 'click', ev => {
dom.on('#gotoMatchedRules', 'click', ev => {
if ( ev.isTrusted !== true ) { return; }
if ( ev.button !== 0 ) { return; }
sendMessage({
@ -295,7 +230,7 @@ dom.on('#showMatchedRules', 'click', ev => {
/******************************************************************************/
dom.on('[data-i18n-title="popupTipReport"]', 'click', ev => {
dom.on('#gotoReport', 'click', ev => {
if ( ev.isTrusted !== true ) { return; }
let url;
try {
@ -314,7 +249,7 @@ dom.on('[data-i18n-title="popupTipReport"]', 'click', ev => {
/******************************************************************************/
dom.on('[data-i18n-title="popupTipDashboard"]', 'click', ev => {
dom.on('#gotoDashboard', 'click', ev => {
if ( ev.isTrusted !== true ) { return; }
if ( ev.button !== 0 ) { return; }
runtime.openOptionsPage();
@ -325,7 +260,29 @@ dom.on('[data-i18n-title="popupTipDashboard"]', 'click', ev => {
dom.on('#gotoZapper', 'click', ( ) => {
if ( browser.scripting === undefined ) { return; }
browser.scripting.executeScript({
files: [ '/js/scripting/zapper.js' ],
files: [ '/js/scripting/tool-overlay.js', '/js/scripting/zapper.js' ],
target: { tabId: currentTab.id },
});
self.close();
});
/******************************************************************************/
dom.on('#gotoPicker', 'click', ( ) => {
if ( browser.scripting === undefined ) { return; }
browser.scripting.executeScript({
files: [ '/js/scripting/tool-overlay.js', '/js/scripting/picker.js' ],
target: { tabId: currentTab.id },
});
self.close();
});
/******************************************************************************/
dom.on('#gotoUnpicker', 'click', ( ) => {
if ( browser.scripting === undefined ) { return; }
browser.scripting.executeScript({
files: [ '/js/scripting/tool-overlay.js', '/js/scripting/unpicker.js' ],
target: { tabId: currentTab.id },
});
self.close();
@ -357,7 +314,8 @@ async function init() {
const response = await sendMessage({
what: 'popupPanelData',
origin: url.origin,
hostname: normalizedHostname(tabURL.hostname),
normalHostname: normalizedHostname(tabURL.hostname),
hostname: tabURL.hostname,
});
if ( response instanceof Object ) {
Object.assign(popupPanelData, response);
@ -370,46 +328,17 @@ async function init() {
dom.text('#hostname', punycode.toUnicode(tabURL.hostname));
dom.cl.toggle('#showMatchedRules', 'enabled',
dom.cl.toggle('#gotoMatchedRules', 'enabled',
popupPanelData.isSideloaded === true &&
popupPanelData.developerMode &&
typeof currentTab.id === 'number' &&
isNaN(currentTab.id) === false
);
const isNetworkPage = url.protocol === 'http:' || url.protocol === 'https:';
const isHTTP = url.protocol === 'http:' || url.protocol === 'https:';
dom.cl.toggle(dom.root, 'isHTTP', isHTTP);
dom.cl.toggle('#reportFilterIssue', 'enabled', isNetworkPage );
dom.cl.toggle('#gotoZapper', 'enabled', isNetworkPage);
const parent = qs$('#rulesetStats');
for ( const details of popupPanelData.rulesetDetails || [] ) {
const div = dom.clone('#templates .rulesetDetails');
qs$(div, 'h1').append(i18n.patchUnicodeFlags(details.name));
const { rules, filters, css } = details;
let ruleCount = rules.plain + rules.regex;
if ( popupPanelData.hasOmnipotence ) {
ruleCount += rules.removeparam + rules.redirect + rules.modifyHeaders;
}
let specificCount = 0;
if ( typeof css.specific === 'number' ) {
specificCount += css.specific;
}
if ( typeof css.declarative === 'number' ) {
specificCount += css.declarative;
}
if ( typeof css.procedural === 'number' ) {
specificCount += css.procedural;
}
dom.text(
qs$(div, 'p'),
i18n$('perRulesetStats')
.replace('{{ruleCount}}', ruleCount.toLocaleString())
.replace('{{filterCount}}', filters.accepted.toLocaleString())
.replace('{{cssSpecificCount}}', specificCount.toLocaleString())
);
parent.append(div);
}
dom.cl.toggle('#gotoUnpicker', 'enabled', popupPanelData.hasCustomFilters);
dom.cl.remove(dom.body, 'loading');

View file

@ -30,6 +30,7 @@ import {
import { fetchJSON } from './fetch.js';
import { getEnabledRulesetsDetails } from './ruleset-manager.js';
import { getFilteringModeDetails } from './mode-manager.js';
import { registerCustomFilters } from './filter-manager.js';
import { registerToolbarIconToggler } from './action.js';
import { ubolLog } from './debug.js';
@ -600,13 +601,16 @@ async function registerInjectables() {
toRemove,
};
registerDeclarative(context);
registerProcedural(context);
registerScriptlet(context, scriptletDetails);
registerSpecific(context);
registerGeneric(context, genericDetails);
registerHighGeneric(context, genericDetails);
registerToolbarIconToggler(context);
await Promise.all([
registerDeclarative(context),
registerProcedural(context),
registerScriptlet(context, scriptletDetails),
registerSpecific(context),
registerGeneric(context, genericDetails),
registerHighGeneric(context, genericDetails),
registerCustomFilters(context),
registerToolbarIconToggler(context),
]);
toRemove.push(...Array.from(before.keys()));

View file

@ -0,0 +1,39 @@
/*******************************************************************************
uBlock Origin Lite - a comprehensive, MV3-compliant content blocker
Copyright (C) 2019-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 function uBOL_cssUser() {
/******************************************************************************/
const docURL = new URL(document.baseURI);
chrome.runtime.sendMessage({
what: 'injectCustomFilters',
hostname: docURL.hostname,
}).then(selectors => {
self.ubolInjectedCustomFilters = selectors;
}).catch(( ) => {
});
/******************************************************************************/
})();
void 0;

View file

@ -51,7 +51,7 @@
const r = callback(`${hnparts.slice(i).join('.')}`, details);
if ( r !== undefined ) { return r; }
}
if ( details.hasEntities !== true ) { return; }
if ( details?.hasEntities !== true ) { return; }
const n = hnpartslen - 1;
for ( let i = 0; i < n; i++ ) {
for ( let j = n; j > i; j-- ) {

View file

@ -0,0 +1,270 @@
/*******************************************************************************
uBlock Origin - a comprehensive, efficient content blocker
Copyright (C) 2025-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 ( ) => {
/******************************************************************************/
const ubolOverlay = self.ubolOverlay;
if ( ubolOverlay === undefined ) { return; }
if ( ubolOverlay.file === '/picker-ui.html' ) { return; }
/******************************************************************************/
function attributeNameFromPart(part) {
const pos = part.search(/\^?=/);
return part.slice(1, pos);
}
function selectorFromAddresses(partsDB, addresses) {
const selector = [];
let majorLast = -1;
for ( const address of addresses ) {
const major = address >>> 12;
if ( majorLast !== -1 ) {
const delta = majorLast - major;
if ( delta > 1 ) {
selector.push(' ');
} else if ( delta === 1 ) {
selector.push(' > ');
}
}
majorLast = major;
const part = partsDB.get(address);
selector.push(
(address & 0xF) === 3
? `[${attributeNameFromPart(part)}]`
: part
);
}
return selector.join('');
}
/*******************************************************************************
*
* Selector part address:
* 0b00000000_00000000_0000
* | | |
* | | +-- 4-bit: Descriptor
* | +------- 8-bit: Part index
* +---------------- 8-bit: List index
* Descriptor:
* - 0: tag name
* - 1: id
* - 2: class
* - 3: attribute
* - 4: :nth-of-type
* List index: 0 is deepest
*
* Selector part addresses are used to reference parts in associated database.
*
* */
function candidatesAtPoint(x, y) {
// We need at least one element.
let elem = null;
if ( typeof x === 'number' ) {
elem = ubolOverlay.elementFromPoint(x, y);
} else if ( x instanceof HTMLElement ) {
elem = x;
x = undefined;
}
const partsDB = new Map();
const listParts = [];
while ( elem && elem !== document.body ) {
const tagName = elem.localName;
const addressMajor = listParts.length << 12;
partsDB.set(addressMajor, CSS.escape(tagName));
const parts = [ addressMajor ];
// Id
if ( typeof elem.id === 'string' && elem.id !== '' ) {
const address = addressMajor | parts.length << 4 | 1;
partsDB.set(address, `#${CSS.escape(elem.id)}`);
parts.push(address);
}
// Classes
for ( const name of elem.classList.values() ) {
const address = addressMajor | parts.length << 4 | 2;
partsDB.set(address, `.${CSS.escape(name)}`);
parts.push(address);
}
// Attributes
for ( const name of elem.getAttributeNames() ) {
if ( name === 'id' || name === 'class' ) { continue; }
if ( excludedAttributeExpansion.includes(name) ) {
const address = addressMajor | parts.length << 4 | 3;
partsDB.set(address, `[${CSS.escape(name)}]`);
parts.push(address);
continue;
}
let value = elem.getAttribute(name);
const pos = value.search(/[\n\r]/);
if ( pos !== -1 ) {
value = value.slice(0, pos);
}
const address = addressMajor | parts.length << 4 | 3;
partsDB.set(address, `[${CSS.escape(name)}="${value}"]`);
parts.push(address);
}
// https://github.com/chrisaljoudi/uBlock/issues/637
// If the selector is still ambiguous at this point, further narrow
// using `:nth-of-type`.
const parentNode = elem.parentNode;
if ( ubolOverlay.qsa(parentNode, `:scope > ${selectorFromAddresses(partsDB, parts)}`).length > 1 ) {
let i = 1;
while ( elem.previousSibling !== null ) {
elem = elem.previousSibling;
if ( typeof elem.localName !== 'string' ) { continue; }
if ( elem.localName !== tagName ) { continue; }
i++;
}
const address = addressMajor | parts.length << 4 | 4;
partsDB.set(address, `:nth-of-type(${i})`);
parts.push(address);
}
listParts.push(parts);
elem = elem.parentElement;
}
if ( listParts.length === 0 ) { return; }
const sliderCandidates = [];
for ( let i = 0, n = listParts.length; i < n; i++ ) {
sliderCandidates.push(listParts[i]);
for ( let j = i + 1; j < n; j++ ) {
sliderCandidates.push([
...listParts[j],
...sliderCandidates.at(-1),
]);
}
}
const sliderMap = new Map();
for ( const candidates of sliderCandidates ) {
if ( candidates.some(a => (a & 0xF) === 1) ) {
const selectorPath = candidates.filter(a => (a & 0xF) === 1);
sliderMap.set(JSON.stringify(selectorPath), 0);
} else if ( candidates.some(a => (a & 0xF) === 4) ) {
const selectorPath = candidates.filter(a => {
return a &= 0xF, a === 0 || a === 4;
});
sliderMap.set(JSON.stringify(selectorPath), 0);
}
if ( candidates.some(a => (a & 0xF) === 2) ) {
const selectorPath = candidates.filter(a => {
return a &= 0xF, a === 0 || a === 2;
});
sliderMap.set(JSON.stringify(selectorPath), 0);
}
const selectorPath = candidates.filter(a => {
return a &= 0xF, a === 0 || a === 3;
});
sliderMap.set(JSON.stringify(selectorPath), 0);
}
sliderMap.delete('[]');
const elemToIdMap = new Map();
const resultSetMap = new Map();
let elemId = 1;
for ( const json of sliderMap.keys() ) {
const addresses = JSON.parse(json);
const selector = selectorFromAddresses(partsDB, addresses);
if ( excludedSelectors.includes(selector) ) { continue; }
const elems = ubolOverlay.qsa(document, selector);
if ( elems.length === 0 ) { continue; }
const resultSet = [];
for ( const elem of elems ) {
if ( elemToIdMap.has(elem) === false ) {
elemToIdMap.set(elem, elemId++);
}
resultSet.push(elemToIdMap.get(elem));
}
const resultSetKey = JSON.stringify(resultSet.sort());
const current = resultSetMap.get(resultSetKey);
if ( current ) {
if ( current.length < addresses.length ) { continue; }
if ( current.length === addresses.length ) {
if ( addresses.some(a => (a & 0xF) === 2) === false ) {
if ( current.some(a => (a & 0xF) === 2) ) { continue; }
}
}
}
resultSetMap.set(resultSetKey, addresses);
}
const sliderParts = Array.from(resultSetMap).toSorted((a, b) => {
let amajor = a[1].at(-1) >>> 12;
let bmajor = b[1].at(-1) >>> 12;
if ( amajor !== bmajor ) { return bmajor - amajor; }
amajor = a[1].at(0) >>> 12;
bmajor = b[1].at(0) >>> 12;
if ( amajor !== bmajor ) { return bmajor - amajor; }
if ( a[0].length !== b[0].length ) {
return b[0].length - a[0].length;
}
return b[1].length - a[1].length;
}).map(a => a[1]);
return {
partsDB: Array.from(partsDB),
listParts,
sliderParts,
};
}
const excludedAttributeExpansion = [
'sizes',
'srcset',
];
const excludedSelectors = [
'div',
'span',
];
/******************************************************************************/
function onMessage(msg) {
switch ( msg.what ) {
case 'injectCustomFilters':
return ubolOverlay.sendMessage({ what: 'injectCustomFilters',
hostname: ubolOverlay.url.hostname,
});
case 'uninjectCustomFilters':
return ubolOverlay.sendMessage({ what: 'uninjectCustomFilters',
hostname: ubolOverlay.url.hostname,
});
case 'candidatesAtPoint':
return candidatesAtPoint(msg.mx, msg.my, msg.broad);
case 'insertCSS':
return ubolOverlay.sendMessage(msg);
case 'removeCSS':
return ubolOverlay.sendMessage(msg);
default:
break;
}
}
/******************************************************************************/
await ubolOverlay.install('/picker-ui.html', onMessage);
/******************************************************************************/
})();
void 0;

View file

@ -0,0 +1,360 @@
/*******************************************************************************
uBlock Origin - a comprehensive, efficient content blocker
Copyright (C) 2025-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
*/
(function uBOLOverlay() {
/******************************************************************************/
if ( self.ubolOverlay ) {
self.ubolOverlay.stop();
self.ubolOverlay = undefined;
}
self.ubolOverlay = {
file: '',
webext: typeof browser === 'object' ? browser : chrome,
url: new URL(document.baseURI),
port: null,
highlightedElements: [],
secretAttr: (( ) => {
let secret = String.fromCharCode((Math.random() * 26) + 97);
do {
secret += (Math.floor(Math.random() * 2147483647) + 2147483647)
.toString(36)
.slice(2);
} while ( secret.length < 8 );
return secret;
})(),
start() {
const cssStyle = [
'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');
this.pickerCSS = [
`:root > [${this.secretAttr}] { ${cssStyle} }`,
`:root > [${this.secretAttr}-loaded] { visibility: visible !important; }`,
`:root > [${this.secretAttr}-click] { pointer-events: none !important; }`,
].join('\n');
this.sendMessage({ what: 'insertCSS', css: this.pickerCSS });
self.addEventListener('scroll', this.onViewportChanged, { passive: true });
self.addEventListener('resize', this.onViewportChanged, { passive: true });
self.addEventListener('keydown', this.onKeyPressed, true);
},
stop() {
if ( this.pickerCSS ) {
this.sendMessage({ what: 'removeCSS', css: this.pickerCSS });
this.pickerCSS = undefined;
}
self.removeEventListener('scroll', this.onViewportChanged, { passive: true });
self.removeEventListener('resize', this.onViewportChanged, { passive: true });
self.removeEventListener('keydown', this.onKeyPressed, true);
if ( this.frame ) {
this.frame.remove();
this.frame = null;
}
if ( this.port ) {
this.port.close();
this.port.onmessage = null;
this.port.onmessageerror = null;
this.port = null;
}
this.onmessage = null;
self.ubolOverlay = undefined;
},
onViewportChanged() {
self.ubolOverlay.highlightUpdate();
},
onKeyPressed(ev) {
if ( ev.key !== 'Escape' && ev.which !== 27 ) { return; }
ev.stopPropagation();
ev.preventDefault();
if ( self.ubolOverlay.onmessage ) {
self.ubolOverlay.onmessage({ what: 'quitTool' });
}
},
sendMessage(msg) {
try {
return this.webext.runtime.sendMessage(msg).catch(( ) => { });
} catch {
}
},
onMessage(wrapped) {
// Response to script-initiated message?
if ( typeof wrapped?.fromScriptId === 'number' ) {
const resolve = this.pendingMessages.get(wrapped.fromScriptId);
if ( resolve ) {
this.pendingMessages.delete(wrapped.fromScriptId);
resolve(wrapped.msg);
}
return;
}
const onmessage = this.onmessage;
const msg = wrapped.msg || wrapped;
let response;
switch ( msg.what ) {
case 'startTool':
this.start();
break;
case 'quitTool':
this.stop();
break;
case 'highlightElementAtPoint':
this.highlightElementAtPoint(msg.mx, msg.my);
break;
case 'highlightFromSelector': {
const { elems, error } = this.elementsFromSelector(msg.selector);
this.highlightElements(elems);
if ( msg.scrollTo && elems.length !== 0 ) {
elems[0].scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
response = { count: elems.length, error };
break;
}
case 'unhighlight':
this.unhighlight();
break;
}
response = onmessage && onmessage(msg) || response;
// Send response if this is frame-initiated message
if ( wrapped?.fromFrameId && this.port ) {
const { fromFrameId } = wrapped;
if ( response instanceof Promise ) {
response.then(response => {
if ( this.port === null ) { return; }
this.port.postMessage({ fromFrameId, msg: response });
});
} else {
this.port.postMessage({ fromFrameId, msg: response });
}
}
},
postMessage(msg) {
if ( this.port === null ) { return; }
const wrapped = {
fromScriptId: this.messageId++,
msg,
};
return new Promise(resolve => {
this.pendingMessages.set(wrapped.fromScriptId, resolve);
this.port.postMessage(wrapped);
});
},
messageId: 1,
pendingMessages: new Map(),
getElementBoundingClientRect(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 this.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 = this.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 {
left, right,
top, bottom,
width: right - left,
height: bottom - top,
};
},
highlightUpdate() {
const ow = self.innerWidth;
const oh = self.innerHeight;
const islands = [];
for ( const elem of this.highlightedElements ) {
const rect = this.getElementBoundingClientRect(elem);
// Ignore offscreen areas
if ( rect.left > ow ) { continue; }
if ( rect.top > oh ) { continue; }
if ( rect.left + rect.width < 0 ) { continue; }
if ( rect.top + rect.height < 0 ) { continue; }
islands.push(
`M${rect.left} ${rect.top}h${rect.width}v${rect.height}h-${rect.width}z`
);
}
this.port.postMessage({
what: 'svgPaths',
ocean: `M0 0h${ow}v${oh}h-${ow}z`,
islands: islands.join(''),
});
},
highlightElements(iter = []) {
this.highlightedElements =
Array.from(iter).filter(a =>
a instanceof Element && a !== this.frame
);
this.highlightUpdate();
},
qsa(node, selector) {
if ( node !== null ) {
try {
const elems = node.querySelectorAll(selector);
this.qsa.error = undefined;
return elems;
} catch (reason) {
this.qsa.error = `${reason}`;
}
}
return [];
},
elementFromPoint(x, y) {
if ( x !== undefined ) {
this.lastX = x; this.lastY = y;
} else if ( this.lastX !== undefined ) {
x = this.lastX; y = this.lastY;
} else {
return null;
}
const magicAttr = `${this.secretAttr}-click`;
this.frame.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
this.frame.removeAttribute(magicAttr);
return elem;
},
elementsFromSelector(selector) {
const elems = this.qsa(document, selector);
return { elems, error: this.qsa.error };
},
highlightElementAtPoint(x, y) {
const elem = self.ubolOverlay.elementFromPoint(x, y);
this.highlightElements([ elem ]);
},
unhighlight() {
this.highlightElements([]);
},
async install(file, onmessage) {
this.file = file;
const dynamicURL = new URL(this.webext.runtime.getURL(file));
return new Promise(resolve => {
const frame = document.createElement('iframe');
const secretAttr = this.secretAttr;
frame.setAttribute(secretAttr, '');
const onLoad = ( ) => {
frame.onload = null;
frame.setAttribute(`${secretAttr}-loaded`, '');
const channel = new MessageChannel();
const port = channel.port1;
port.onmessage = ev => {
self.ubolOverlay &&
self.ubolOverlay.onMessage(ev.data || {})
};
port.onmessageerror = ( ) => {
self.ubolOverlay &&
self.ubolOverlay.onMessage({ what: 'quitTool' })
};
const realURL = new URL(dynamicURL);
realURL.hostname =
self.ubolOverlay.webext.i18n.getMessage('@@extension_id');
frame.contentWindow.postMessage(
{
what: 'startOverlay',
url: document.baseURI,
width: self.innerWidth,
height: self.innerHeight,
},
realURL.origin,
[ channel.port2 ]
);
frame.contentWindow.focus();
self.ubolOverlay.onmessage = onmessage;
self.ubolOverlay.port = port;
self.ubolOverlay.frame = frame;
resolve(true);
};
if ( dynamicURL.protocol !== 'safari-web-extension:' ) {
frame.onload = ( ) => {
frame.onload = onLoad;
frame.contentWindow.location = dynamicURL.href;
};
} else {
frame.onload = onLoad;
frame.setAttribute('src', dynamicURL.href);
}
document.documentElement.append(frame);
});
},
};
/******************************************************************************/
})();
void 0;

View file

@ -0,0 +1,61 @@
/*******************************************************************************
uBlock Origin - a comprehensive, efficient content blocker
Copyright (C) 2025-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 ( ) => {
/******************************************************************************/
const ubolOverlay = self.ubolOverlay;
if ( ubolOverlay === undefined ) { return; }
if ( ubolOverlay.file === '/unpicker-ui.html' ) { return; }
/******************************************************************************/
function onMessage(msg) {
switch ( msg.what ) {
case 'injectCustomFilters':
return ubolOverlay.sendMessage({ what: 'injectCustomFilters',
hostname: ubolOverlay.url.hostname,
});
case 'uninjectCustomFilters':
return ubolOverlay.sendMessage({ what: 'uninjectCustomFilters',
hostname: ubolOverlay.url.hostname,
});
case 'removeCustomFilter':
return ubolOverlay.sendMessage({ what: 'removeCustomFilter',
hostname: ubolOverlay.url.hostname,
selector: msg.selector,
});
default:
break;
}
}
/******************************************************************************/
await ubolOverlay.install('/unpicker-ui.html', onMessage);
/******************************************************************************/
})();
void 0;

View file

@ -23,187 +23,9 @@
/******************************************************************************/
const zapper = self.uBOLZapper = self.uBOLZapper || {};
if ( zapper.injected ) { return; }
zapper.injected = true;
const webext = typeof browser === 'object' ? browser : chrome;
/******************************************************************************/
const sendMessage = msg => {
try {
webext.runtime.sendMessage(msg).catch(( ) => { });
} catch {
}
};
/******************************************************************************/
const zapperSecret = (( ) => {
let secret = String.fromCharCode((Math.random() * 26) + 97);
do {
secret += (Math.floor(Math.random() * 2147483647) + 2147483647)
.toString(36)
.slice(2);
} while ( secret.length < 8 );
return secret;
})();
const zapperCSSStyle = [
'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 zapperCSS = `
:root > [${zapperSecret}] {
${zapperCSSStyle}
}
:root > [${zapperSecret}-loaded] {
visibility: visible !important;
}
:root [${zapperSecret}-click] {
pointer-events: none !important;
}
`;
sendMessage({ what: 'insertCSS', css: zapperCSS });
/******************************************************************************/
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 highlightElement = function(elem) {
if ( elem !== true ) {
if ( elem !== undefined ) {
if ( elem === highlightElement.current ) { return; }
if ( elem !== zapperFrame ) {
highlightElement.current = elem;
}
}
}
elem = highlightElement.current;
const ow = self.innerWidth;
const oh = self.innerHeight;
const islands = [];
if ( elem !== null ) {
const rect = getElementBoundingClientRect(elem);
// Ignore offscreen areas
if (
rect.left <= ow && rect.top <= oh &&
rect.left + rect.width >= 0 && rect.top + rect.height >= 0
) {
islands.push(
`M${rect.left} ${rect.top}h${rect.width}v${rect.height}h-${rect.width}z`
);
}
}
zapperFramePort.postMessage({
what: 'svgPaths',
ocean: `M0 0h${ow}v${oh}h-${ow}z`,
islands: islands.join(''),
});
};
highlightElement.current = null;
/******************************************************************************/
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 ( !zapperFrame ) { return null; }
const magicAttr = `${zapperSecret}-click`;
zapperFrame.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
zapperFrame.removeAttribute(magicAttr);
return elem;
};
})();
/******************************************************************************/
const highlightElementAtPoint = function(mx, my) {
const elem = elementFromPoint(mx, my);
highlightElement(elem);
};
const ubolOverlay = self.ubolOverlay;
if ( ubolOverlay === undefined ) { return; }
if ( ubolOverlay.file === '/zapper-ui.html' ) { return; }
/******************************************************************************/
@ -213,18 +35,18 @@ const highlightElementAtPoint = function(mx, my) {
// With touch-driven devices, first highlight the element and remove only
// when tapping again the highlighted area.
const zapElementAtPoint = function(mx, my, options) {
function zapElementAtPoint(mx, my, options) {
if ( options.highlight ) {
const elem = elementFromPoint(mx, my);
const elem = ubolOverlay.elementFromPoint(mx, my);
if ( elem ) {
highlightElement(elem);
ubolOverlay.highlightElements([ elem ]);
}
return;
}
let elemToRemove = highlightElement.current;
let elemToRemove = ubolOverlay.highlightedElements?.[0] ?? null;
if ( elemToRemove === null && mx !== undefined ) {
elemToRemove = elementFromPoint(mx, my);
elemToRemove = ubolOverlay.elementFromPoint(mx, my);
}
if ( elemToRemove instanceof Element === false ) { return; }
@ -261,86 +83,38 @@ const zapElementAtPoint = function(mx, my, options) {
}
}
elemToRemove.remove();
highlightElementAtPoint(mx, my);
};
ubolOverlay.highlightElementAtPoint(mx, my);
}
/******************************************************************************/
const onKeyPressed = function(ev) {
// Delete
if (
(ev.key === 'Delete' || ev.key === 'Backspace') ) {
ev.stopPropagation();
ev.preventDefault();
zapElementAtPoint();
return;
}
// Esc
if ( ev.key === 'Escape' || ev.which === 27 ) {
ev.stopPropagation();
ev.preventDefault();
quitZapper();
return;
}
};
function onKeyPressed(ev) {
if ( ev.key !== 'Delete' && ev.key !== 'Backspace' ) { return; }
ev.stopPropagation();
ev.preventDefault();
zapElementAtPoint();
}
/******************************************************************************/
// 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() {
highlightElement(true);
};
/******************************************************************************/
const startZapper = function() {
zapperFrame.focus();
self.addEventListener('scroll', onViewportChanged, { passive: true });
self.addEventListener('resize', onViewportChanged, { passive: true });
function startZapper() {
self.addEventListener('keydown', onKeyPressed, true);
};
}
/******************************************************************************/
const quitZapper = function() {
self.removeEventListener('scroll', onViewportChanged, { passive: true });
self.removeEventListener('resize', onViewportChanged, { passive: true });
function quitZapper() {
self.removeEventListener('keydown', onKeyPressed, true);
if ( zapperFramePort ) {
zapperFramePort.close();
zapperFramePort.onmessage = null;
zapperFramePort.onmessageerror = null;
zapperFramePort = null;
}
if ( zapperFrame ) {
zapperFrame.remove();
zapperFrame = null;
}
sendMessage({ what: 'removeCSS', css: zapperCSS });
zapper.injected = false;
};
}
/******************************************************************************/
const onFrameMessage = function(msg) {
function onMessage(msg) {
switch ( msg.what ) {
case 'start':
case 'startTool':
startZapper();
if ( Boolean(zapperFramePort) === false ) { break; }
highlightElement(true);
break;
case 'quitZapper':
case 'quitTool':
quitZapper();
break;
case 'highlightElementAtPoint':
highlightElementAtPoint(msg.mx, msg.my);
break;
case 'unhighlight':
highlightElement(null);
break;
case 'zapElementAtPoint':
zapElementAtPoint(msg.mx, msg.my, msg.options);
if ( msg.options.highlight !== true && msg.options.stay !== true ) {
@ -350,68 +124,11 @@ const onFrameMessage = function(msg) {
default:
break;
}
};
}
/******************************************************************************/
// zapper-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 zapper.js is to install the zapper UI, and wait for the
// component to establish a direct communication channel.
//
// When the zapper 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.
const bootstrap = async ( ) => {
const dynamicURL = new URL(webext.runtime.getURL('/zapper-ui.html'));
return new Promise(resolve => {
const frame = document.createElement('iframe');
frame.setAttribute(zapperSecret, '');
const onZapperLoad = ( ) => {
frame.onload = null;
frame.setAttribute(`${zapperSecret}-loaded`, '');
const channel = new MessageChannel();
const port = channel.port1;
port.onmessage = ev => {
onFrameMessage(ev.data || {});
};
port.onmessageerror = ( ) => {
quitZapper();
};
const realURL = new URL(dynamicURL);
realURL.hostname = webext.i18n.getMessage('@@extension_id');
frame.contentWindow.postMessage(
{ what: 'zapperStart' },
realURL.origin,
[ channel.port2 ]
);
frame.contentWindow.focus();
resolve({
zapperFrame: frame,
zapperFramePort: port,
});
};
if ( dynamicURL.protocol !== 'safari-web-extension:' ) {
frame.onload = ( ) => {
frame.onload = onZapperLoad;
frame.contentWindow.location = dynamicURL.href;
};
} else {
frame.onload = onZapperLoad;
frame.setAttribute('src', dynamicURL.href);
}
document.documentElement.append(frame);
});
};
let { zapperFrame, zapperFramePort } = await bootstrap();
if ( zapperFrame && zapperFramePort ) { return; }
quitZapper();
await ubolOverlay.install('/zapper-ui.html', onMessage);
/******************************************************************************/

View file

@ -0,0 +1,241 @@
/*******************************************************************************
uBlock Origin - a comprehensive, efficient content blocker
Copyright (C) 2025-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
*/
import { dom, qs$ } from './dom.js';
import { sendMessage } from './ext.js';
/******************************************************************************/
export const toolOverlay = {
url: new URL('about:blank'),
svgRoot: qs$('svg#overlay'),
svgOcean: qs$('svg#overlay > path'),
svgIslands: qs$('svg#overlay > path + path'),
emptyPath: 'M0 0',
port: null,
start(onmessage) {
this.onmessage = onmessage;
globalThis.addEventListener('message', ev => {
const msg = ev.data || {};
if ( msg.what !== 'startOverlay' ) { return; }
if ( Array.isArray(ev.ports) === false ) { return; }
if ( ev.ports.length === 0 ) { return; }
toolOverlay.port = ev.ports[0];
toolOverlay.port.onmessage = ev => {
this.onMessage(ev.data || {});
};
toolOverlay.port.onmessageerror = ( ) => {
this.onmessage({ what: 'stopTool' });
};
this.moveable = qs$('aside:has(#move)');
if ( this.moveable !== null ) {
dom.on('aside #move', 'pointerdown', ev => {
this.mover(ev);
});
}
this.onMessage({ what: 'startTool',
url: msg.url,
width: msg.width,
height: msg.height,
});
dom.cl.remove(dom.body, 'loading');
}, { once: true });
},
stop() {
this.highlightElementUnderMouse(false);
if ( this.port ) {
this.port.postMessage({ what: 'quitTool' });
this.port.onmessage = null;
this.port.onmessageerror = null;
this.port = null;
}
},
onMessage(wrapped) {
// Response to frame-initiated message?
if ( typeof wrapped?.fromFrameId === 'number' ) {
const resolve = this.pendingMessages.get(wrapped.fromFrameId);
if ( resolve ) {
this.pendingMessages.delete(wrapped.fromFrameId);
resolve(wrapped.msg);
}
return;
}
const msg = wrapped.msg || wrapped;
switch ( msg.what ) {
case 'startTool': {
this.url.href = msg.url;
const ow = msg.width;
const oh = msg.height;
this.svgOcean.setAttribute('d', `M0 0h${ow}v${oh}h-${ow}z`);
break;
}
case 'svgPaths':
this.svgOcean.setAttribute('d', msg.ocean + msg.islands);
this.svgIslands.setAttribute('d', msg.islands || this.emptyPath);
break;
default:
break;
}
const response = this.onmessage && this.onmessage(msg) || undefined;
// Send response if this is script-initiated message
if ( wrapped?.fromScriptId && this.port ) {
const { fromScriptId } = wrapped;
if ( response instanceof Promise ) {
response.then(response => {
if ( this.port === null ) { return; }
this.port.postMessage({ fromScriptId, msg: response });
});
} else {
this.port.postMessage({ fromScriptId, msg: response });
}
}
},
postMessage(msg) {
if ( this.port === null ) { return; }
const wrapped = {
fromFrameId: this.messageId++,
msg,
};
return new Promise(resolve => {
this.pendingMessages.set(wrapped.fromFrameId, resolve);
this.port.postMessage(wrapped);
});
},
messageId: 1,
pendingMessages: new Map(),
sendMessage(msg) {
return sendMessage(msg);
},
highlightElementUnderMouse(state) {
if ( dom.cl.has(dom.root, 'mobile') ) { return; }
if ( state === this.mstrackerOn ) { return; }
this.mstrackerOn = state;
if ( this.mstrackerOn ) {
dom.on(document, 'mousemove', this.onHover, { passive: true });
return;
}
dom.off(document, 'mousemove', this.onHover, { passive: true });
if ( this.mstrackerTimer === undefined ) { return; }
self.cancelAnimationFrame(this.mstrackerTimer);
this.mstrackerTimer = undefined;
},
onTimer() {
toolOverlay.mstrackerTimer = undefined;
if ( toolOverlay.port === null ) { return; }
toolOverlay.port.postMessage({
what: 'highlightElementAtPoint',
mx: toolOverlay.mstrackerX,
my: toolOverlay.mstrackerY,
});
},
onHover(ev) {
toolOverlay.mstrackerX = ev.clientX;
toolOverlay.mstrackerY = ev.clientY;
if ( toolOverlay.mstrackerTimer !== undefined ) { return; }
toolOverlay.mstrackerTimer =
self.requestAnimationFrame(toolOverlay.onTimer);
},
mstrackerOn: false,
mstrackerX: 0, mstrackerY: 0,
mstrackerTimer: undefined,
mover(ev) {
const target = ev.target;
if ( target.matches('#move') === false ) { return; }
if ( dom.cl.has(this.moveable, 'moving') ) { return; }
target.setPointerCapture(ev.pointerId);
this.moverIsTouch = ev.type.startsWith('touch');
if ( this.moverIsTouch ) {
const touch = ev.touches[0];
this.moverX0 = touch.pageX;
this.moverY0 = touch.pageY;
} else {
this.moverX0 = ev.pageX;
this.moverY0 = ev.pageY;
}
const rect = this.moveable.getBoundingClientRect();
this.moverCX0 = rect.x + rect.width / 2;
this.moverCY0 = rect.y + rect.height / 2;
dom.cl.add(this.moveable, 'moving');
self.addEventListener('pointermove', this.moverMoveAsync, { capture: true });
self.addEventListener('pointerup', this.moverStop, { capture: true, once: true });
ev.stopPropagation();
ev.preventDefault();
},
moverMove() {
this.moverTimer = undefined;
const cx1 = this.moverCX0 + this.moverX1 - this.moverX0;
const cy1 = this.moverCY0 + this.moverY1 - this.moverY0;
const rootW = dom.root.clientWidth;
const rootH = dom.root.clientHeight;
const moveableW = this.moveable.clientWidth;
const moveableH = this.moveable.clientHeight;
if ( cx1 < rootW / 2 ) {
this.moveable.style.setProperty('left', `${Math.max(cx1-moveableW/2,2)}px`);
this.moveable.style.removeProperty('right');
} else {
this.moveable.style.removeProperty('left');
this.moveable.style.setProperty('right', `${Math.max(rootW-cx1-moveableW/2,2)}px`);
}
if ( cy1 < rootH / 2 ) {
this.moveable.style.setProperty('top', `${Math.max(cy1-moveableH/2,2)}px`);
this.moveable.style.removeProperty('bottom');
} else {
this.moveable.style.removeProperty('top');
this.moveable.style.setProperty('bottom', `${Math.max(rootH-cy1-moveableH/2,2)}px`);
}
},
moverMoveAsync(ev) {
if ( toolOverlay.moverTimer !== undefined ) { return; }
if ( toolOverlay.moverIsTouch ) {
const touch = ev.touches[0];
toolOverlay.moverX1 = touch.pageX;
toolOverlay.moverY1 = touch.pageY;
} else {
toolOverlay.moverX1 = ev.pageX;
toolOverlay.moverY1 = ev.pageY;
}
toolOverlay.moverTimer = self.requestAnimationFrame(( ) => {
toolOverlay.moverMove();
});
},
moverStop(ev) {
if ( dom.cl.has(toolOverlay.moveable, 'moving') === false ) { return; }
dom.cl.remove(toolOverlay.moveable, 'moving');
self.removeEventListener('pointermove', toolOverlay.moverMoveAsync, { capture: true });
ev.target.releasePointerCapture(ev.pointerId);
ev.stopPropagation();
ev.preventDefault();
},
moveable: null,
moverIsTouch: false,
moverX0: 0, moverY0: 0,
moverX1: 0, moverY1: 0,
moverCX0: 0, moverCY0: 0,
moverTimer: undefined,
};
/******************************************************************************/

View file

@ -0,0 +1,164 @@
/*******************************************************************************
uBlock Origin - a comprehensive, efficient content blocker
Copyright (C) 2025-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
*/
import { dom, qs$, qsa$ } from './dom.js';
import { faIconsInit } from './fa-icons.js';
import { toolOverlay } from './tool-overlay-ui.js';
/******************************************************************************/
function onMinimizeClicked() {
dom.cl.toggle(dom.root, 'minimized');
}
/******************************************************************************/
function highlight() {
const selectors = [];
for ( const selectorElem of qsa$('#customFilters .customFilter.on > span.selector') ) {
selectors.push(selectorElem.textContent);
}
if ( selectors.length !== 0 ) {
toolOverlay.postMessage({
what: 'highlightFromSelector',
selector: selectors.join(','),
scrollTo: true,
});
} else {
toolOverlay.postMessage({ what: 'unhighlight' });
}
}
/******************************************************************************/
function onFilterClicked(ev) {
const target = ev.target;
const filterElem = target.closest('.customFilter');
if ( filterElem === null ) { return; }
const selectorElem = qs$(filterElem, ':scope > span.selector');
if ( target === selectorElem ) {
if ( dom.cl.has(filterElem, 'on') ) {
dom.cl.remove(filterElem, 'on');
} else {
dom.cl.remove('.customFilter.on', 'on');
dom.cl.add(filterElem, 'on');
}
highlight();
return;
}
const selector = selectorElem.textContent;
const trashElem = qs$(filterElem, ':scope > span.remove');
if ( target === trashElem ) {
dom.cl.add(filterElem, 'removed');
dom.cl.remove(filterElem, 'on');
toolOverlay.sendMessage({ what: 'removeCustomFilter',
hostname: toolOverlay.url.hostname,
selector,
}).then(( ) => {
autoSelectFilter();
});
return;
}
const undoElem = qs$(filterElem, ':scope > span.undo');
if ( target === undoElem ) {
dom.cl.remove(filterElem, 'removed');
toolOverlay.sendMessage({ what: 'addCustomFilter',
hostname: toolOverlay.url.hostname,
selector,
}).then(( ) => {
dom.cl.remove('.customFilter.on', 'on');
dom.cl.add(filterElem, 'on');
highlight();
});
return;
}
}
/******************************************************************************/
function autoSelectFilter() {
let filterElem = qs$('.customFilter.on');
if ( filterElem !== null ) { return; }
filterElem = qs$('.customFilter:not(.removed)');
if ( filterElem !== null ) {
dom.cl.add(filterElem, 'on');
}
highlight();
}
/******************************************************************************/
function populateFilters(selectors) {
const container = qs$('#customFilters');
dom.clear(container);
const rowTemplate = qs$('template#customFilterRow');
for ( const selector of selectors ) {
const row = rowTemplate.content.cloneNode(true);
qs$(row, '.customFilter > span.selector').textContent = selector;
container.append(row);
}
faIconsInit(container);
autoSelectFilter();
}
/******************************************************************************/
async function startUnpicker() {
const selectors = await toolOverlay.sendMessage({
what: 'selectorsFromCustomFilters',
hostname: toolOverlay.url.hostname,
})
if ( selectors.length === 0 ) {
return quitUnpicker();
}
await toolOverlay.postMessage({ what: 'startTool' });
await toolOverlay.postMessage({ what: 'uninjectCustomFilters' });
populateFilters(selectors);
dom.on('#minimize', 'click', onMinimizeClicked);
dom.on('#customFilters', 'click', onFilterClicked);
dom.on('#quit', 'click', quitUnpicker);
}
/******************************************************************************/
async function quitUnpicker() {
await toolOverlay.postMessage({ what: 'injectCustomFilters' });
toolOverlay.stop();
}
/******************************************************************************/
function onMessage(msg) {
switch ( msg.what ) {
case 'startTool':
startUnpicker();
break;
default:
break;
}
}
/******************************************************************************/
// Wait for the content script to establish communication
toolOverlay.start(onMessage);
/******************************************************************************/

View file

@ -19,40 +19,26 @@
Home: https://github.com/gorhill/uBlock
*/
/******************************************************************************/
const $id = id => document.getElementById(id);
const $stor = selector => document.querySelector(selector);
const svgRoot = $stor('svg#sea');
const svgOcean = svgRoot.children[0];
const svgIslands = svgRoot.children[1];
const NoPaths = 'M0 0';
let zapperScriptPort;
import { dom } from './dom.js';
import { toolOverlay } from './tool-overlay-ui.js';
/******************************************************************************/
const onSvgClicked = function(ev) {
function onSvgClicked(ev) {
// If zap mode, highlight element under mouse, this makes the zapper usable
// on touch screens.
zapperScriptPort.postMessage({
toolOverlay.postMessage({
what: 'zapElementAtPoint',
mx: ev.clientX,
my: ev.clientY,
options: {
stay: true,
highlight: ev.target !== svgIslands,
highlight: ev.target !== toolOverlay.svgIslands,
},
});
};
}
/*******************************************************************************
Swipe right:
Remove current highlight
*/
/******************************************************************************/
const onSvgTouch = (( ) => {
let startX = 0, startY = 0;
@ -67,7 +53,6 @@ const onSvgTouch = (( ) => {
if ( startX === undefined ) { 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)
@ -76,77 +61,23 @@ const onSvgTouch = (( ) => {
// - 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 ) { return; }
if ( ev.cancelable ) {
ev.preventDefault();
}
// Swipe right.
if ( svgIslands.getAttribute('d') === NoPaths ) { return; }
zapperScriptPort.postMessage({
what: 'unhighlight'
if ( distance >= 32 || duration >= 200 ) { return; }
onSvgClicked({
type: 'touch',
target: ev.target,
clientX: ev.changedTouches[0].pageX,
clientY: ev.changedTouches[0].pageY,
});
ev.preventDefault();
};
})();
/******************************************************************************/
const svgListening = (( ) => {
let on = false;
let timer;
let mx = 0, my = 0;
const onTimer = ( ) => {
timer = undefined;
zapperScriptPort.postMessage({
what: 'highlightElementAtPoint',
mx,
my,
});
};
const onHover = ev => {
mx = ev.clientX;
my = ev.clientY;
if ( timer === undefined ) {
timer = self.requestAnimationFrame(onTimer);
}
};
return state => {
if ( $stor(':root.mobile') !== null ) { return; }
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 onKeyPressed = function(ev) {
function onKeyPressed(ev) {
// Delete
if ( ev.key === 'Delete' || ev.key === 'Backspace' ) {
zapperScriptPort.postMessage({
toolOverlay.postMessage({
what: 'zapElementAtPoint',
options: { stay: true },
});
@ -157,81 +88,45 @@ const onKeyPressed = function(ev) {
quitZapper();
return;
}
};
}
/******************************************************************************/
const onScriptMessage = function(msg) {
function startZapper() {
toolOverlay.postMessage({ what: 'startTool' });
self.addEventListener('keydown', onKeyPressed, true);
dom.on('svg#overlay', 'click', onSvgClicked);
dom.on('svg#overlay', 'touchstart', onSvgTouch, { passive: true });
dom.on('svg#overlay', 'touchend', onSvgTouch);
dom.on('#quit', 'click', quitZapper );
dom.on('#pick', 'click', resetZapper );
toolOverlay.highlightElementUnderMouse(true);
}
function quitZapper() {
self.removeEventListener('keydown', onKeyPressed, true);
toolOverlay.stop();
}
function resetZapper() {
toolOverlay.postMessage({ what: 'unhighlight' });
}
/******************************************************************************/
function onMessage(msg) {
switch ( msg.what ) {
case 'svgPaths': {
let { ocean, islands } = msg;
ocean += islands;
svgOcean.setAttribute('d', ocean);
svgIslands.setAttribute('d', islands || NoPaths);
case 'startTool':
startZapper();
break;
}
default:
break;
}
};
/******************************************************************************/
const startZapper = function(port) {
zapperScriptPort = port;
zapperScriptPort.onmessage = ev => {
onScriptMessage(ev.data || {});
};
zapperScriptPort.onmessageerror = ( ) => {
quitZapper();
};
zapperScriptPort.postMessage({ what: 'start' });
self.addEventListener('keydown', onKeyPressed, true);
$stor('svg#sea').addEventListener('click', onSvgClicked);
$stor('svg#sea').addEventListener('touchstart', onSvgTouch, { passive: true });
$stor('svg#sea').addEventListener('touchend', onSvgTouch);
$id('quit').addEventListener('click', quitZapper );
$id('pick').addEventListener('click', resetZapper );
svgListening(true);
};
/******************************************************************************/
const quitZapper = function() {
self.removeEventListener('keydown', onKeyPressed, true);
$stor('svg#sea').removeEventListener('click', onSvgClicked);
$stor('svg#sea').removeEventListener('touchstart', onSvgTouch, { passive: true });
$stor('svg#sea').removeEventListener('touchend', onSvgTouch);
$id('quit').removeEventListener('click', quitZapper );
$id('pick').removeEventListener('click', resetZapper );
svgListening(false);
if ( zapperScriptPort ) {
zapperScriptPort.postMessage({ what: 'quitZapper' });
zapperScriptPort.close();
zapperScriptPort.onmessage = null;
zapperScriptPort.onmessageerror = null;
zapperScriptPort = null;
}
};
/******************************************************************************/
const resetZapper = function() {
zapperScriptPort.postMessage({
what: 'unhighlight'
});
};
}
/******************************************************************************/
// Wait for the content script to establish communication
globalThis.addEventListener('message', ev => {
const msg = ev.data || {};
if ( msg.what !== 'zapperStart' ) { return; }
if ( Array.isArray(ev.ports) === false ) { return; }
if ( ev.ports.length === 0 ) { return; }
startZapper(ev.ports[0]);
}, { once: true });
toolOverlay.start(onMessage);
/******************************************************************************/

View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html id="ubol-picker" class="minimized" data-view="0">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<title>uBO Lite Zapper</title>
<link rel="stylesheet" href="/css/default.css">
<link rel="stylesheet" href="/css/common.css">
<link rel="stylesheet" href="/css/fa-icons.css">
<link rel="stylesheet" href="/css/tool-overlay-ui.css">
<link rel="stylesheet" href="/css/picker-ui.css">
</head>
<body class="loading">
<aside style="right: 2px; bottom: 2px;">
<section id="windowbar">
<div id="minimize"><svg viewBox="0 0 64 64"><path d="M 16,48 H 48" /><rect x="16" y="16" height="32" width="32" /></svg></div>
<div id="move"></div>
<div id="quit" data-i18n-title="pickerQuit"><svg viewBox="0 0 64 64"><path d="M16 16L48 48M16 48L48 16" /></svg></div>
</section>
<section data-view="2">
<textarea spellcheck="false"></textarea>
</section>
<section class="resultsetWidgets" data-view="0">
<span>
<label for="slider" data-i18n="pickerSliderLabel">_</label>
<span id="resultsetCount"></span>
</span>
<input id="slider" type="range" min="0" max="10" step="1">
</section>
<section id="toolbar" data-view="0">
<div>
<button id="pick" type="button" data-i18n="pickerPick">_<span class="hover"></span></button>
<button id="preview" type="button" data-i18n="pickerPreview">_<span class="hover"></span></button>
</div>
<button id="create" type="button" class="preferred" disabled data-i18n="pickerCreate"><span class="hover"></span></button>
</section>
<section id="candidateFilters" data-view="1">
<ul></ul>
</section>
<section id="moreOrLess">
<span><span data-i18n="popupMoreButton"></span>&ensp;<span class="fa-icon fa-icon-vflipped">angle-up</span><span class="hover"></span></span>
<span><span class="fa-icon">angle-up</span>&ensp;<span data-i18n="popupLessButton"></span><span class="hover"></span></span>
</section>
</aside>
<svg id="overlay"><path d></path><path d="M0 0"></path></svg>
<script src="js/theme.js" type="module"></script>
<script src="js/fa-icons.js" type="module"></script>
<script src="js/i18n.js" type="module"></script>
<script src="js/picker-ui.js" type="module"></script>
</body>
</html>

View file

@ -28,29 +28,38 @@
<!-- -------- -->
<div id="filteringModeText"><label data-i18n="popupFilteringModeLabel">_</label><br><span>_</span><span></span></div>
<!-- -------- -->
<div class="toolRibbon pageTools">
<span id="gotoZapper" class="fa-icon tool enabled" data-i18n-title="zapperTipEnter">bolt<span class="caption" data-i18n="zapperTipEnter"></span></span>
<span id="showMatchedRules" class="fa-icon tool" tabindex="0" title="Show matched rules">list-alt<span class="caption">Show matched rules</span></span>
<span id="reportFilterIssue" class="fa-icon tool enabled" data-i18n-title="popupTipReport">comment-alt<span class="caption" data-i18n="popupTipReport"></span></span>
<span id="gotoDashboard" class="fa-icon tool enabled" tabindex="0" data-i18n-title="popupTipDashboard">cogs<span class="caption" data-i18n="popupTipDashboard"></span></span>
</div>
<!-- -------- -->
<div id="rulesetStats" data-section="a">
</div>
<hr data-section="a">
<!-- -------- -->
<div id="moreOrLess">
<span id="moreButton">
<span data-i18n="popupMoreButton">_</span>&emsp;<span class="fa-icon">angle-up</span>
<hr class="needHTTP">
<div class="toolRibbon localTools needHTTP">
<label>On this website</label>
<span id="gotoZapper" class="tool enabled" tabindex="0">
<span class="fa-icon">bolt</span>
<span data-i18n="zapperTipEnter">_</span>
</span>
<span id="lessButton">
<span class="fa-icon">angle-up</span>&emsp;<span data-i18n="popupLessButton">_</span>
<span id="gotoPicker" class="tool enabled" tabindex="0">
<span class="fa-icon">eye-slash</span>
<span data-i18n="pickerTipEnter">_</span>
</span>
<span id="gotoUnpicker" class="tool" tabindex="0">
<span class="fa-icon">eye-open</span>
<span data-i18n="unpickerTipEnter">_</span>
</span>
<span id="gotoReport" class="tool enabled" tabindex="0">
<span class="fa-icon">comment-alt</span>
<span data-i18n="popupTipReport">_</span>
</span>
<span id="gotoMatchedRules" class="tool" tabindex="0">
<span class="fa-icon">list-alt</span>
<span>Show matched rules</span>
</span>
</div>
<!-- -------- -->
<hr>
<div class="toolRibbon globalTools">
<span id="gotoDashboard" class="tool enabled" tabindex="0">
<span class="fa-icon">cogs</span>
<span data-i18n="popupTipDashboard">_</span>
</span>
</div>
</div>
<div id="templates">
<div class="rulesetDetails"><h1></h1><p data-section="b"></p></div>
</div>
<script src="js/theme.js" type="module"></script>

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html id="ubol-unpicker">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<title>uBO Lite Zapper</title>
<link rel="stylesheet" href="/css/default.css">
<link rel="stylesheet" href="/css/common.css">
<link rel="stylesheet" href="/css/fa-icons.css">
<link rel="stylesheet" href="/css/tool-overlay-ui.css">
<link rel="stylesheet" href="/css/unpicker-ui.css">
</head>
<body class="loading">
<aside style="right: 2px; bottom: 2px;">
<section id="windowbar">
<div id="minimize"><svg viewBox="0 0 64 64"><path d="M 16,48 H 48" /><rect x="16" y="16" height="32" width="32" /></svg></div>
<div id="move"></div>
<div id="quit" data-i18n-title="pickerQuit"><svg viewBox="0 0 64 64"><path d="M16 16L48 48M16 48L48 16" /></svg></div>
</section>
<section>
<span data-i18n="unpickerUsage">_</span>
</section>
<section id="customFilters"></section>
</aside>
<svg id="overlay"><path d></path><path d="M0 0"></path></svg>
<template id="customFilterRow">
<div class="customFilter"><span class="selector"></span><span class="remove fa-icon">trash-o</span><span class="undo fa-icon">undo</span></div>
</template>
<script src="js/theme.js" type="module"></script>
<script src="js/fa-icons.js" type="module"></script>
<script src="js/i18n.js" type="module"></script>
<script src="js/unpicker-ui.js" type="module"></script>
</body>
</html>

View file

@ -6,6 +6,7 @@
<meta name="color-scheme" content="light dark">
<title>uBO Lite Zapper</title>
<link rel="stylesheet" href="/css/default.css">
<link rel="stylesheet" href="/css/tool-overlay-ui.css">
<link rel="stylesheet" href="/css/zapper-ui.css">
</head>
@ -21,7 +22,7 @@
</svg>
</div>
</aside>
<svg id="sea"><path d></path><path d></path></svg>
<svg id="overlay"><path d></path><path d="M0 0"></path></svg>
<script src="js/theme.js" type="module"></script>
<script src="js/i18n.js" type="module"></script>

View file

@ -72,6 +72,22 @@
"matches": [
"<all_urls>"
]
},
{
"resources": [
"/picker-ui.html"
],
"matches": [
"<all_urls>"
]
},
{
"resources": [
"/unpicker-ui.html"
],
"matches": [
"<all_urls>"
]
}
]
}

View file

@ -65,6 +65,22 @@
"matches": [
"<all_urls>"
]
},
{
"resources": [
"picker-ui.html"
],
"matches": [
"<all_urls>"
]
},
{
"resources": [
"unpicker-ui.html"
],
"matches": [
"<all_urls>"
]
}
]
}

View file

@ -453,7 +453,7 @@
--popup-cell-block-surface: rgb(var(--popup-cell-block-surface-rgb));
--popup-power-ink: rgb(var(--popup-power-ink-rgb));
--popup-toolbar-surface: rgb(var(--primary-80) / 15%);
--popup-toolbar-surface-hover: rgb(var(--primary-80) / 20%);
--popup-toolbar-surface-hover: rgb(var(--primary-80) / 30%);
--popup-ruleset-tool-ink: var(--ink-1);
--popup-ruleset-tool-surface: rgb(var(--primary-80) / 15%);
--popup-ruleset-tool-surface-hover: rgb(var(--primary-80) / 20%);

View file

@ -16,7 +16,7 @@
<body>
<aside style="right: 2px; bottom: 2px;">
<div id="windowbar"><div id="minimize"><svg viewBox="0 0 64 64"><path d="M 16,48 H 48" /><rect x="16" y="16" height="32" width="32" /></div><div id="move"></div><div id="quit" data-i18n-title="pickerQuit"><svg viewBox="0 0 64 64"><path d="M16 16L48 48M16 48L48 16" /></div></div>
<div id="windowbar"><div id="minimize"><svg viewBox="0 0 64 64"><path d="M 16,48 H 48" /><rect x="16" y="16" height="32" width="32" /></svg></div><div id="move"></div><div id="quit" data-i18n-title="pickerQuit"><svg viewBox="0 0 64 64"><path d="M16 16L48 48M16 48L48 16" /></svg></div></div>
<section>
<div>
<div class="codeMirrorContainer codeMirrorBreakAll cm-theme-override"></div>

View file

@ -74,7 +74,7 @@ else
fi
echo "*** uBOLite.mv3: Copying common files"
cp -R "$UBO_DIR"/src/css/fonts/* "$UBOL_DIR"/css/fonts/
cp -R "$UBO_DIR"/src/css/fonts/Inter "$UBOL_DIR"/css/fonts/
cp "$UBO_DIR"/src/css/themes/default.css "$UBOL_DIR"/css/
cp "$UBO_DIR"/src/css/common.css "$UBOL_DIR"/css/
cp "$UBO_DIR"/src/css/dashboard-common.css "$UBOL_DIR"/css/