diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index ec47ffeb7..aaf0ad1b5 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 f721be412..d2e0ce339 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 a3c4d0f70..0d296c886 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 15a9698c6..6479672c9 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 urlToTextMap = new Map(); +const params = new URLSearchParams(document.location.search); +let fromURL = ''; - const a = qs$('.cm-search-widget .sourceURL'); - dom.attr(a, 'href', url); - dom.attr(a, 'title', url); +const cmEditor = new CodeMirror(qs$('#content'), { + autofocus: true, + gutters: [ 'CodeMirror-linenumbers' ], + lineNumbers: true, + lineWrapping: true, + matchBrackets: true, + styleActiveLine: { + nonEmpty: true, + }, +}); - const response = await fetch(url); - const text = await response.text(); +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; + }, +}); + +/******************************************************************************/ + +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 c62f3053d..486e721df 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); };