diff --git a/platform/mv3/chromium/manifest.json b/platform/mv3/chromium/manifest.json index f42001a9d..2984baabc 100644 --- a/platform/mv3/chromium/manifest.json +++ b/platform/mv3/chromium/manifest.json @@ -65,6 +65,24 @@ "" ], "use_dynamic_url": true + }, + { + "resources": [ + "/picker-ui.html" + ], + "matches": [ + "" + ], + "use_dynamic_url": true + }, + { + "resources": [ + "/unpicker-ui.html" + ], + "matches": [ + "" + ], + "use_dynamic_url": true } ] } diff --git a/platform/mv3/extension/_locales/en/messages.json b/platform/mv3/extension/_locales/en/messages.json index f248475c1..02f3939c3 100644 --- a/platform/mv3/extension/_locales/en/messages.json +++ b/platform/mv3/extension/_locales/en/messages.json @@ -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" } } diff --git a/platform/mv3/extension/css/dashboard.css b/platform/mv3/extension/css/dashboard.css index 84f75d158..a33c13f42 100644 --- a/platform/mv3/extension/css/dashboard.css +++ b/platform/mv3/extension/css/dashboard.css @@ -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; } diff --git a/platform/mv3/extension/css/picker-ui.css b/platform/mv3/extension/css/picker-ui.css new file mode 100644 index 000000000..e21267895 --- /dev/null +++ b/platform/mv3/extension/css/picker-ui.css @@ -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; +} diff --git a/platform/mv3/extension/css/popup.css b/platform/mv3/extension/css/popup.css index 411ba3aa4..d7d57e14c 100644 --- a/platform/mv3/extension/css/popup.css +++ b/platform/mv3/extension/css/popup.css @@ -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; - } diff --git a/platform/mv3/extension/css/tool-overlay-ui.css b/platform/mv3/extension/css/tool-overlay-ui.css new file mode 100644 index 000000000..e04950238 --- /dev/null +++ b/platform/mv3/extension/css/tool-overlay-ui.css @@ -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) +} diff --git a/platform/mv3/extension/css/unpicker-ui.css b/platform/mv3/extension/css/unpicker-ui.css new file mode 100644 index 000000000..6762abdd6 --- /dev/null +++ b/platform/mv3/extension/css/unpicker-ui.css @@ -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; +} diff --git a/platform/mv3/extension/css/zapper-ui.css b/platform/mv3/extension/css/zapper-ui.css index 5f66d49a5..93c322716 100644 --- a/platform/mv3/extension/css/zapper-ui.css +++ b/platform/mv3/extension/css/zapper-ui.css @@ -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); diff --git a/platform/mv3/extension/dashboard.html b/platform/mv3/extension/dashboard.html index bc71b3398..d45605c65 100644 --- a/platform/mv3/extension/dashboard.html +++ b/platform/mv3/extension/dashboard.html @@ -133,7 +133,6 @@
- diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js index ffeb0314f..57e25b2ec 100644 --- a/platform/mv3/extension/js/background.js +++ b/platform/mv3/extension/js/background.js @@ -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; diff --git a/platform/mv3/extension/js/ext-compat.js b/platform/mv3/extension/js/ext-compat.js index 06f0c8c91..0d431c472 100644 --- a/platform/mv3/extension/js/ext-compat.js +++ b/platform/mv3/extension/js/ext-compat.js @@ -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'; diff --git a/platform/mv3/extension/js/ext.js b/platform/mv3/extension/js/ext.js index a2e6f18f5..ea0f90425 100644 --- a/platform/mv3/extension/js/ext.js +++ b/platform/mv3/extension/js/ext.js @@ -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) { diff --git a/platform/mv3/extension/js/filter-manager.js b/platform/mv3/extension/js/filter-manager.js new file mode 100644 index 000000000..282c97243 --- /dev/null +++ b/platform/mv3/extension/js/filter-manager.js @@ -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; +} diff --git a/platform/mv3/extension/js/picker-ui.js b/platform/mv3/extension/js/picker-ui.js new file mode 100644 index 000000000..abaf3c60f --- /dev/null +++ b/platform/mv3/extension/js/picker-ui.js @@ -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); + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/popup.js b/platform/mv3/extension/js/popup.js index 0556d6953..ce0fe08da 100644 --- a/platform/mv3/extension/js/popup.js +++ b/platform/mv3/extension/js/popup.js @@ -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'); diff --git a/platform/mv3/extension/js/scripting-manager.js b/platform/mv3/extension/js/scripting-manager.js index 4cbf52d07..17bd5ac8d 100644 --- a/platform/mv3/extension/js/scripting-manager.js +++ b/platform/mv3/extension/js/scripting-manager.js @@ -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())); diff --git a/platform/mv3/extension/js/scripting/css-user.js b/platform/mv3/extension/js/scripting/css-user.js new file mode 100644 index 000000000..55e728ab6 --- /dev/null +++ b/platform/mv3/extension/js/scripting/css-user.js @@ -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; diff --git a/platform/mv3/extension/js/scripting/isolated-api.js b/platform/mv3/extension/js/scripting/isolated-api.js index 9c867bc41..4781f935c 100644 --- a/platform/mv3/extension/js/scripting/isolated-api.js +++ b/platform/mv3/extension/js/scripting/isolated-api.js @@ -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-- ) { diff --git a/platform/mv3/extension/js/scripting/picker.js b/platform/mv3/extension/js/scripting/picker.js new file mode 100644 index 000000000..124d4dcb7 --- /dev/null +++ b/platform/mv3/extension/js/scripting/picker.js @@ -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; diff --git a/platform/mv3/extension/js/scripting/tool-overlay.js b/platform/mv3/extension/js/scripting/tool-overlay.js new file mode 100644 index 000000000..e2708b159 --- /dev/null +++ b/platform/mv3/extension/js/scripting/tool-overlay.js @@ -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; diff --git a/platform/mv3/extension/js/scripting/unpicker.js b/platform/mv3/extension/js/scripting/unpicker.js new file mode 100644 index 000000000..41c5fb71c --- /dev/null +++ b/platform/mv3/extension/js/scripting/unpicker.js @@ -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; diff --git a/platform/mv3/extension/js/scripting/zapper.js b/platform/mv3/extension/js/scripting/zapper.js index d3b77b723..9d9628f76 100644 --- a/platform/mv3/extension/js/scripting/zapper.js +++ b/platform/mv3/extension/js/scripting/zapper.js @@ -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); /******************************************************************************/ diff --git a/platform/mv3/extension/js/tool-overlay-ui.js b/platform/mv3/extension/js/tool-overlay-ui.js new file mode 100644 index 000000000..609d548bf --- /dev/null +++ b/platform/mv3/extension/js/tool-overlay-ui.js @@ -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, +}; + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/unpicker-ui.js b/platform/mv3/extension/js/unpicker-ui.js new file mode 100644 index 000000000..afc9f64bd --- /dev/null +++ b/platform/mv3/extension/js/unpicker-ui.js @@ -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); + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/zapper-ui.js b/platform/mv3/extension/js/zapper-ui.js index ec9e08c19..3562f6b36 100644 --- a/platform/mv3/extension/js/zapper-ui.js +++ b/platform/mv3/extension/js/zapper-ui.js @@ -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); /******************************************************************************/ diff --git a/platform/mv3/extension/picker-ui.html b/platform/mv3/extension/picker-ui.html new file mode 100644 index 000000000..2ec0df305 --- /dev/null +++ b/platform/mv3/extension/picker-ui.html @@ -0,0 +1,56 @@ + + + + + + +uBO Lite Zapper + + + + + + + + + + + + + + + + + + + diff --git a/platform/mv3/extension/popup.html b/platform/mv3/extension/popup.html index 44387bead..1fc18542d 100644 --- a/platform/mv3/extension/popup.html +++ b/platform/mv3/extension/popup.html @@ -28,29 +28,38 @@

_
-
- bolt - list-altShow matched rules - comment-alt - cogs -
- -
-
-
- -
- - _angle-up +
+
+ + + bolt + _ - - angle-up_ + + eye-slash + _ + + + eye-open + _ + + + comment-alt + _ + + + list-alt + Show matched rules + +
+ +
+
+ + cogs + _
-
- -
-

diff --git a/platform/mv3/extension/unpicker-ui.html b/platform/mv3/extension/unpicker-ui.html new file mode 100644 index 000000000..3402f0267 --- /dev/null +++ b/platform/mv3/extension/unpicker-ui.html @@ -0,0 +1,40 @@ + + + + + + +uBO Lite Zapper + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/mv3/extension/zapper-ui.html b/platform/mv3/extension/zapper-ui.html index c80208fb6..7e51230e1 100644 --- a/platform/mv3/extension/zapper-ui.html +++ b/platform/mv3/extension/zapper-ui.html @@ -6,6 +6,7 @@ uBO Lite Zapper + @@ -21,7 +22,7 @@
- + diff --git a/platform/mv3/firefox/manifest.json b/platform/mv3/firefox/manifest.json index c2dd5a3aa..c4a9cc1c4 100644 --- a/platform/mv3/firefox/manifest.json +++ b/platform/mv3/firefox/manifest.json @@ -72,6 +72,22 @@ "matches": [ "" ] + }, + { + "resources": [ + "/picker-ui.html" + ], + "matches": [ + "" + ] + }, + { + "resources": [ + "/unpicker-ui.html" + ], + "matches": [ + "" + ] } ] } diff --git a/platform/mv3/safari/manifest.json b/platform/mv3/safari/manifest.json index d18525c1a..9fe5b3aaf 100644 --- a/platform/mv3/safari/manifest.json +++ b/platform/mv3/safari/manifest.json @@ -65,6 +65,22 @@ "matches": [ "" ] + }, + { + "resources": [ + "picker-ui.html" + ], + "matches": [ + "" + ] + }, + { + "resources": [ + "unpicker-ui.html" + ], + "matches": [ + "" + ] } ] } diff --git a/src/css/fonts/Inter/Inter-Regular.woff2 b/src/css/fonts/Inter/Inter-Regular.woff2 index d5ffd2a1f..2bcd222ec 100644 Binary files a/src/css/fonts/Inter/Inter-Regular.woff2 and b/src/css/fonts/Inter/Inter-Regular.woff2 differ diff --git a/src/css/fonts/Inter/Inter-SemiBold.woff2 b/src/css/fonts/Inter/Inter-SemiBold.woff2 index df746af99..fbae113d2 100644 Binary files a/src/css/fonts/Inter/Inter-SemiBold.woff2 and b/src/css/fonts/Inter/Inter-SemiBold.woff2 differ diff --git a/src/css/themes/default.css b/src/css/themes/default.css index 7136b6c47..28b592f62 100644 --- a/src/css/themes/default.css +++ b/src/css/themes/default.css @@ -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%); diff --git a/src/web_accessible_resources/epicker-ui.html b/src/web_accessible_resources/epicker-ui.html index ef5e9d72e..9be0103d8 100644 --- a/src/web_accessible_resources/epicker-ui.html +++ b/src/web_accessible_resources/epicker-ui.html @@ -16,7 +16,7 @@