From e2dd008388340a3cc18d57c959ee9d3d6ace6d02 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Sat, 11 Mar 2023 15:55:51 -0500 Subject: [PATCH] Add 'View source...' entry in context menu This new context menu entry will be available only when the advanced setting `filterAuthorMode` is set to `true`. See: https://github.com/gorhill/uBlock/wiki/Advanced-settings#filterauthormode The purpose is for filter list maintainers to easily access the source code of web pages when investigating filter issues, without having to necessarily go through the logger. Additionally an input field to enter URL directly has been added to the code viewer for convenience. --- src/_locales/en/messages.json | 4 + src/code-viewer.html | 4 + src/css/code-viewer.css | 37 ++++++ src/js/code-viewer.js | 214 +++++++++++++++++++++------------- src/js/contextmenu.js | 54 +++++++-- 5 files changed, 224 insertions(+), 89 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index ec47ffeb76f7a..aaf0ad1b51d54 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -1217,6 +1217,10 @@ "message": "Temporarily allow large media elements", "description": "A context menu entry, present when large media elements have been blocked on the current site" }, + "contextMenuViewSource": { + "message": "View source…", + "description": "A context menu entry, to view the source code of the target resource" + }, "shortcutCapturePlaceholder": { "message": "Type a shortcut", "description": "Placeholder string for input field used to capture a keyboard shortcut" diff --git a/src/code-viewer.html b/src/code-viewer.html index f721be412ff7a..d2e0ce339e490 100644 --- a/src/code-viewer.html +++ b/src/code-viewer.html @@ -15,6 +15,10 @@ +
diff --git a/src/css/code-viewer.css b/src/css/code-viewer.css index a3c4d0f70d389..0d296c8860625 100644 --- a/src/css/code-viewer.css +++ b/src/css/code-viewer.css @@ -8,6 +8,43 @@ body { padding: 0; width: 100vw; } +#header { + background-color: var(--cm-gutter-surface); + border-bottom: 1px solid var(--surface-1); + padding: var(--default-gap-xsmall); + position: relative; + z-index: 1000000; + } +#header input[type="url"] { + box-sizing: border-box; + font-size: var(--font-size-smaller); + width: 100%; + } +#header:focus-within #pastURLs { + display: flex; + } +#pastURLs { + background-color: var(--surface-0); + border: 1px solid var(--border-1); + display: none; + flex-direction: column; + font-size: var(--font-size-smaller); + position: absolute; + } +#pastURLs > span { + cursor: pointer; + overflow: hidden; + padding: 2px 4px; + text-overflow: ellipsis; + white-space: nowrap; + width: 75vw; + } +#pastURLs > span.selected { + font-weight: bold; + } +#pastURLs > span:hover { + background-color: var(--surface-1); + } #content { flex-grow: 1; } diff --git a/src/js/code-viewer.js b/src/js/code-viewer.js index 15a9698c60249..6479672c94168 100644 --- a/src/js/code-viewer.js +++ b/src/js/code-viewer.js @@ -29,107 +29,161 @@ import { dom, qs$ } from './dom.js'; /******************************************************************************/ -(async ( ) => { - const params = new URLSearchParams(document.location.search); - const url = params.get('url'); - - const a = qs$('.cm-search-widget .sourceURL'); - dom.attr(a, 'href', url); - dom.attr(a, 'title', url); +const urlToTextMap = new Map(); +const params = new URLSearchParams(document.location.search); +let fromURL = ''; + +const cmEditor = new CodeMirror(qs$('#content'), { + autofocus: true, + gutters: [ 'CodeMirror-linenumbers' ], + lineNumbers: true, + lineWrapping: true, + matchBrackets: true, + styleActiveLine: { + nonEmpty: true, + }, +}); + +uBlockDashboard.patchCodeMirrorEditor(cmEditor); +if ( dom.cl.has(dom.html, 'dark') ) { + dom.cl.add('#content .cm-s-default', 'cm-s-night'); + dom.cl.remove('#content .cm-s-default', 'cm-s-default'); +} + +// Convert resource URLs into clickable links to code viewer +cmEditor.addOverlay({ + re: /\b(?:href|src)=["']([^"']+)["']/g, + match: null, + token: function(stream) { + if ( stream.sol() ) { + this.re.lastIndex = 0; + this.match = this.re.exec(stream.string); + } + if ( this.match === null ) { + stream.skipToEnd(); + return null; + } + const end = this.re.lastIndex - 1; + const beg = end - this.match[1].length; + if ( stream.pos < beg ) { + stream.pos = beg; + return null; + } + if ( stream.pos < end ) { + stream.pos = end; + return 'href'; + } + if ( stream.pos < this.re.lastIndex ) { + stream.pos = this.re.lastIndex; + this.match = this.re.exec(stream.string); + return null; + } + stream.skipToEnd(); + return null; + }, +}); - const response = await fetch(url); - const text = await response.text(); +/******************************************************************************/ +async function fetchResource(url) { + if ( urlToTextMap.has(url) ) { + return urlToTextMap.get(url); + } + let response, text; + try { + response = await fetch(url); + text = await response.text(); + } catch(reason) { + return; + } let mime = response.headers.get('Content-Type') || ''; mime = mime.replace(/\s*;.*$/, '').trim(); - let value = ''; switch ( mime ) { case 'text/css': - value = beautifier.css(text, { indent_size: 2 }); + text = beautifier.css(text, { indent_size: 2 }); break; case 'text/html': case 'application/xhtml+xml': case 'application/xml': case 'image/svg+xml': - value = beautifier.html(text, { indent_size: 2 }); + text = beautifier.html(text, { indent_size: 2 }); break; case 'text/javascript': case 'application/javascript': case 'application/x-javascript': - value = beautifier.js(text, { indent_size: 4 }); + text = beautifier.js(text, { indent_size: 4 }); break; case 'application/json': - value = beautifier.js(text, { indent_size: 2 }); + text = beautifier.js(text, { indent_size: 2 }); break; default: - value = text; break; } + urlToTextMap.set(url, { mime, text }); + return { mime, text }; +} + +/******************************************************************************/ - const cmEditor = new CodeMirror(qs$('#content'), { - autofocus: true, - gutters: [ 'CodeMirror-linenumbers' ], - lineNumbers: true, - lineWrapping: true, - matchBrackets: true, - mode: mime, - styleActiveLine: { - nonEmpty: true, - }, - value, - }); - - uBlockDashboard.patchCodeMirrorEditor(cmEditor); - if ( dom.cl.has(dom.html, 'dark') ) { - dom.cl.add('#content .cm-s-default', 'cm-s-night'); - dom.cl.remove('#content .cm-s-default', 'cm-s-default'); +function updatePastURLs(url) { + const list = qs$('#pastURLs'); + let current; + for ( let i = 0; i < list.children.length; i++ ) { + const span = list.children[i]; + dom.cl.remove(span, 'selected'); + if ( span.textContent !== url ) { continue; } + current = span; } + if ( current === undefined ) { + current = document.createElement('span'); + current.textContent = url; + list.prepend(current); + } + dom.cl.add(current, 'selected'); +} - // Convert resource URLs into clickable links to code viewer - cmEditor.addOverlay({ - re: /\b(?:href|src)=["']([^"']+)["']/g, - match: null, - token: function(stream) { - if ( stream.sol() ) { - this.re.lastIndex = 0; - this.match = this.re.exec(stream.string); - } - if ( this.match === null ) { - stream.skipToEnd(); - return null; - } - const end = this.re.lastIndex - 1; - const beg = end - this.match[1].length; - if ( stream.pos < beg ) { - stream.pos = beg; - return null; - } - if ( stream.pos < end ) { - stream.pos = end; - return 'href'; - } - if ( stream.pos < this.re.lastIndex ) { - stream.pos = this.re.lastIndex; - this.match = this.re.exec(stream.string); - return null; - } - stream.skipToEnd(); - return null; - }, - }); - - dom.on('#content', 'click', '.cm-href', ev => { - const href = ev.target.textContent; - try { - const toURL = new URL(href, url); - vAPI.messaging.send('codeViewer', { - what: 'gotoURL', - details: { - url: `code-viewer.html?url=${encodeURIComponent(toURL.href)}`, - select: true, - }, - }); - } catch(ex) { - } - }); -})(); +/******************************************************************************/ + +async function setURL(resourceURL) { + const input = qs$('#header input[type="url"]'); + let to; + try { + to = new URL(resourceURL, fromURL || undefined); + } catch(ex) { + } + if ( to === undefined ) { return; } + if ( /^https?:\/\/./.test(to.href) === false ) { return; } + if ( to.href === fromURL ) { return; } + let r; + try { + r = await fetchResource(to.href); + } catch(reason) { + } + if ( r === undefined ) { return; } + fromURL = to.href; + dom.attr(input, 'value', to.href); + input.value = to; + const a = qs$('.cm-search-widget .sourceURL'); + dom.attr(a, 'href', to); + dom.attr(a, 'title', to); + cmEditor.setOption('mode', r.mime || ''); + cmEditor.setValue(r.text); + updatePastURLs(to.href); + cmEditor.focus(); +} + +/******************************************************************************/ + +setURL(params.get('url')); + +dom.on('#header input[type="url"]', 'change', ev => { + setURL(ev.target.value); +}); + +dom.on('#pastURLs', 'mousedown', 'span', ev => { + setURL(ev.target.textContent); +}); + +dom.on('#content', 'click', '.cm-href', ev => { + setURL(ev.target.textContent); +}); diff --git a/src/js/contextmenu.js b/src/js/contextmenu.js index c62f3053d967a..486e721df9ccf 100644 --- a/src/js/contextmenu.js +++ b/src/js/contextmenu.js @@ -40,6 +40,14 @@ if ( vAPI.contextMenu === undefined ) { /******************************************************************************/ +const BLOCK_ELEMENT_BIT = 0b00001; +const BLOCK_RESOURCE_BIT = 0b00010; +const TEMP_ALLOW_LARGE_MEDIA_BIT = 0b00100; +const SUBSCRIBE_TO_LIST_BIT = 0b01000; +const VIEW_SOURCE_BIT = 0b10000; + +/******************************************************************************/ + const onBlockElement = function(details, tab) { if ( tab === undefined ) { return; } if ( /^https?:\/\//.test(tab.url) === false ) { return; } @@ -112,6 +120,18 @@ const onTemporarilyAllowLargeMediaElements = function(details, tab) { /******************************************************************************/ +const onViewSource = function(details, tab) { + if ( tab === undefined ) { return; } + const url = details.linkUrl || details.frameUrl || details.pageUrl || ''; + if ( /^https?:\/\//.test(url) === false ) { return; } + µb.openNewTab({ + url: `code-viewer.html?url=${self.encodeURIComponent(url)}`, + select: true, + }); +}; + +/******************************************************************************/ + const onEntryClicked = function(details, tab) { if ( details.menuItemId === 'uBlock0-blockElement' ) { return onBlockElement(details, tab); @@ -128,6 +148,9 @@ const onEntryClicked = function(details, tab) { if ( details.menuItemId === 'uBlock0-temporarilyAllowLargeMediaElements' ) { return onTemporarilyAllowLargeMediaElements(details, tab); } + if ( details.menuItemId === 'uBlock0-viewSource' ) { + return onViewSource(details, tab); + } }; /******************************************************************************/ @@ -162,7 +185,14 @@ const menuEntries = { title: i18n$('contextMenuTemporarilyAllowLargeMediaElements'), contexts: [ 'all' ], documentUrlPatterns: [ 'http://*/*', 'https://*/*' ], - } + }, + viewSource: { + id: 'uBlock0-viewSource', + title: i18n$('contextMenuViewSource'), + contexts: [ 'page', 'frame', 'link' ], + documentUrlPatterns: [ 'http://*/*', 'https://*/*' ], + targetUrlPatterns: [ 'http://*/*', 'https://*/*' ], + }, }; /******************************************************************************/ @@ -175,32 +205,38 @@ const update = function(tabId = undefined) { const pageStore = µb.pageStoreFromTabId(tabId); if ( pageStore && pageStore.getNetFilteringSwitch() ) { if ( pageStore.shouldApplySpecificCosmeticFilters(0) ) { - newBits |= 0b0001; + newBits |= BLOCK_ELEMENT_BIT; } else { - newBits |= 0b0010; + newBits |= BLOCK_RESOURCE_BIT; } if ( pageStore.largeMediaCount !== 0 ) { - newBits |= 0b0100; + newBits |= TEMP_ALLOW_LARGE_MEDIA_BIT; } } - newBits |= 0b1000; + newBits |= SUBSCRIBE_TO_LIST_BIT; + } + if ( µb.hiddenSettings.filterAuthorMode ) { + newBits |= VIEW_SOURCE_BIT; } if ( newBits === currentBits ) { return; } currentBits = newBits; const usedEntries = []; - if ( newBits & 0b0001 ) { + if ( (newBits & BLOCK_ELEMENT_BIT) !== 0 ) { usedEntries.push(menuEntries.blockElement); usedEntries.push(menuEntries.blockElementInFrame); } - if ( newBits & 0b0010 ) { + if ( (newBits & BLOCK_RESOURCE_BIT) !== 0 ) { usedEntries.push(menuEntries.blockResource); } - if ( newBits & 0b0100 ) { + if ( (newBits & TEMP_ALLOW_LARGE_MEDIA_BIT) !== 0 ) { usedEntries.push(menuEntries.temporarilyAllowLargeMediaElements); } - if ( newBits & 0b1000 ) { + if ( (newBits & SUBSCRIBE_TO_LIST_BIT) !== 0 ) { usedEntries.push(menuEntries.subscribeToList); } + if ( (newBits & VIEW_SOURCE_BIT) !== 0 ) { + usedEntries.push(menuEntries.viewSource); + } vAPI.contextMenu.setEntries(usedEntries, onEntryClicked); };