summaryrefslogtreecommitdiffstats
path: root/src/js/logger-ui-inspector.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/js/logger-ui-inspector.js')
-rw-r--r--src/js/logger-ui-inspector.js710
1 files changed, 710 insertions, 0 deletions
diff --git a/src/js/logger-ui-inspector.js b/src/js/logger-ui-inspector.js
new file mode 100644
index 0000000..092baf8
--- /dev/null
+++ b/src/js/logger-ui-inspector.js
@@ -0,0 +1,710 @@
+/*******************************************************************************
+
+ uBlock Origin - a comprehensive, efficient content blocker
+ Copyright (C) 2015-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
+*/
+
+/* globals browser */
+
+'use strict';
+
+import { dom, qs$, qsa$ } from './dom.js';
+
+/******************************************************************************/
+
+(( ) => {
+
+/******************************************************************************/
+
+const logger = self.logger;
+const showdomButton = qs$('#showdom');
+const inspector = qs$('#domInspector');
+const domTree = qs$('#domTree');
+const filterToIdMap = new Map();
+
+let inspectedTabId = 0;
+let inspectedURL = '';
+let inspectedHostname = '';
+let uidGenerator = 1;
+
+/*******************************************************************************
+ *
+ * How it works:
+ *
+ * 1. The logger/inspector is enabled from the logger window
+ *
+ * 2. The inspector content script is injected in the root frame of the tab
+ * currently selected in the logger
+ *
+ * 3. The inspector content script asks the logger/inspector to establish
+ * a two-way communication channel
+ *
+ * 3. The inspector content script embed an inspector frame in the document
+ * being inspected and waits for the inspector frame to be fully loaded
+ *
+ * 4. The inspector content script sends a messaging port object to the
+ * embedded inspector frame for a two-way communication channel between
+ * the inspector frame and the inspector content script
+ *
+ * 5. The inspector content script sends dom information to the
+ * logger/inspector
+ *
+ * */
+
+const contentInspectorChannel = (( ) => {
+ let bcChannel;
+ let toContentPort;
+
+ const start = ( ) => {
+ bcChannel = new globalThis.BroadcastChannel('contentInspectorChannel');
+ bcChannel.onmessage = ev => {
+ const msg = ev.data || {};
+ connect(msg.tabId, msg.frameId);
+ };
+ browser.webNavigation.onDOMContentLoaded.addListener(onContentLoaded);
+ };
+
+ const shutdown = ( ) => {
+ browser.webNavigation.onDOMContentLoaded.removeListener(onContentLoaded);
+ disconnect();
+ bcChannel.close();
+ bcChannel.onmessage = null;
+ bcChannel = undefined;
+ };
+
+ const connect = (tabId, frameId) => {
+ disconnect();
+ try {
+ toContentPort = browser.tabs.connect(tabId, { frameId });
+ toContentPort.onMessage.addListener(onContentMessage);
+ toContentPort.onDisconnect.addListener(onContentDisconnect);
+ } catch(_) {
+ }
+ };
+
+ const disconnect = ( ) => {
+ if ( toContentPort === undefined ) { return; }
+ toContentPort.onMessage.removeListener(onContentMessage);
+ toContentPort.onDisconnect.removeListener(onContentDisconnect);
+ toContentPort.disconnect();
+ toContentPort = undefined;
+ };
+
+ const send = msg => {
+ if ( toContentPort === undefined ) { return; }
+ toContentPort.postMessage(msg);
+ };
+
+ const onContentMessage = msg => {
+ if ( msg.what === 'domLayoutFull' ) {
+ inspectedURL = msg.url;
+ inspectedHostname = msg.hostname;
+ renderDOMFull(msg);
+ } else if ( msg.what === 'domLayoutIncremental' ) {
+ renderDOMIncremental(msg);
+ }
+ };
+
+ const onContentDisconnect = ( ) => {
+ disconnect();
+ };
+
+ const onContentLoaded = details => {
+ if ( details.tabId !== inspectedTabId ) { return; }
+ if ( details.frameId !== 0 ) { return; }
+ disconnect();
+ injectInspector();
+ };
+
+ return { start, disconnect, send, shutdown };
+})();
+
+/******************************************************************************/
+
+const nodeFromDomEntry = entry => {
+ const li = document.createElement('li');
+ dom.attr(li, 'id', entry.nid);
+ // expander/collapser
+ li.appendChild(document.createElement('span'));
+ // selector
+ let node = document.createElement('code');
+ node.textContent = entry.sel;
+ li.appendChild(node);
+ // descendant count
+ let value = entry.cnt || 0;
+ node = document.createElement('span');
+ node.textContent = value !== 0 ? value.toLocaleString() : '';
+ dom.attr(node, 'data-cnt', value);
+ li.appendChild(node);
+ // cosmetic filter
+ if ( entry.filter === undefined ) {
+ return li;
+ }
+ node = document.createElement('code');
+ dom.cl.add(node, 'filter');
+ value = filterToIdMap.get(entry.filter);
+ if ( value === undefined ) {
+ value = `${uidGenerator}`;
+ filterToIdMap.set(entry.filter, value);
+ uidGenerator += 1;
+ }
+ dom.attr(node, 'data-filter-id', value);
+ node.textContent = entry.filter;
+ li.appendChild(node);
+ dom.cl.add(li, 'isCosmeticHide');
+ return li;
+};
+
+/******************************************************************************/
+
+const appendListItem = (ul, li) => {
+ ul.appendChild(li);
+ // Ancestor nodes of a node which is affected by a cosmetic filter will
+ // be marked as "containing cosmetic filters", for user convenience.
+ if ( dom.cl.has(li, 'isCosmeticHide') === false ) { return; }
+ for (;;) {
+ li = li.parentElement.parentElement;
+ if ( li === null ) { break; }
+ dom.cl.add(li, 'hasCosmeticHide');
+ }
+};
+
+/******************************************************************************/
+
+const renderDOMFull = response => {
+ const domTreeParent = domTree.parentElement;
+ let ul = domTreeParent.removeChild(domTree);
+ logger.removeAllChildren(domTree);
+
+ filterToIdMap.clear();
+
+ let lvl = 0;
+ let li;
+ for ( const entry of response.layout ) {
+ if ( entry.lvl === lvl ) {
+ li = nodeFromDomEntry(entry);
+ appendListItem(ul, li);
+ continue;
+ }
+ if ( entry.lvl > lvl ) {
+ ul = document.createElement('ul');
+ li.appendChild(ul);
+ dom.cl.add(li, 'branch');
+ li = nodeFromDomEntry(entry);
+ appendListItem(ul, li);
+ lvl = entry.lvl;
+ continue;
+ }
+ // entry.lvl < lvl
+ while ( entry.lvl < lvl ) {
+ ul = li.parentNode;
+ li = ul.parentNode;
+ ul = li.parentNode;
+ lvl -= 1;
+ }
+ li = nodeFromDomEntry(entry);
+ appendListItem(ul, li);
+ }
+ while ( ul.parentNode !== null ) {
+ ul = ul.parentNode;
+ }
+ dom.cl.add(ul.firstElementChild, 'show');
+
+ domTreeParent.appendChild(domTree);
+};
+
+/******************************************************************************/
+
+const patchIncremental = (from, delta) => {
+ let li = from.parentElement.parentElement;
+ const patchCosmeticHide = delta >= 0 &&
+ dom.cl.has(from, 'isCosmeticHide') &&
+ dom.cl.has(li, 'hasCosmeticHide') === false;
+ // Include descendants count when removing a node
+ if ( delta < 0 ) {
+ delta -= countFromNode(from);
+ }
+ for ( ; li.localName === 'li'; li = li.parentElement.parentElement ) {
+ const span = li.children[2];
+ if ( delta !== 0 ) {
+ const cnt = countFromNode(li) + delta;
+ span.textContent = cnt !== 0 ? cnt.toLocaleString() : '';
+ dom.attr(span, 'data-cnt', cnt);
+ }
+ if ( patchCosmeticHide ) {
+ dom.cl.add(li, 'hasCosmeticHide');
+ }
+ }
+};
+
+/******************************************************************************/
+
+const renderDOMIncremental = response => {
+ // Process each journal entry:
+ // 1 = node added
+ // -1 = node removed
+ const nodes = new Map(response.nodes);
+ let li = null;
+ let ul = null;
+ for ( const entry of response.journal ) {
+ // Remove node
+ if ( entry.what === -1 ) {
+ li = qs$(`#${entry.nid}`);
+ if ( li === null ) { continue; }
+ patchIncremental(li, -1);
+ li.parentNode.removeChild(li);
+ continue;
+ }
+ // Modify node
+ if ( entry.what === 0 ) {
+ // TODO: update selector/filter
+ continue;
+ }
+ // Add node as sibling
+ if ( entry.what === 1 && entry.l ) {
+ const previous = qs$(`#${entry.l}`);
+ // This should not happen
+ if ( previous === null ) {
+ // throw new Error('No left sibling!?');
+ continue;
+ }
+ ul = previous.parentElement;
+ li = nodeFromDomEntry(nodes.get(entry.nid));
+ ul.insertBefore(li, previous.nextElementSibling);
+ patchIncremental(li, 1);
+ continue;
+ }
+ // Add node as child
+ if ( entry.what === 1 && entry.u ) {
+ li = qs$(`#${entry.u}`);
+ // This should not happen
+ if ( li === null ) {
+ // throw new Error('No parent!?');
+ continue;
+ }
+ ul = qs$(li, 'ul');
+ if ( ul === null ) {
+ ul = document.createElement('ul');
+ li.appendChild(ul);
+ dom.cl.add(li, 'branch');
+ }
+ li = nodeFromDomEntry(nodes.get(entry.nid));
+ ul.appendChild(li);
+ patchIncremental(li, 1);
+ continue;
+ }
+ }
+};
+
+/******************************************************************************/
+
+const countFromNode = li => {
+ const span = li.children[2];
+ const cnt = parseInt(dom.attr(span, 'data-cnt'), 10);
+ return isNaN(cnt) ? 0 : cnt;
+};
+
+/******************************************************************************/
+
+const selectorFromNode = node => {
+ let selector = '';
+ while ( node !== null ) {
+ if ( node.localName === 'li' ) {
+ const code = qs$(node, 'code');
+ if ( code !== null ) {
+ selector = `${code.textContent} > ${selector}`;
+ if ( selector.includes('#') ) { break; }
+ }
+ }
+ node = node.parentElement;
+ }
+ return selector.slice(0, -3);
+};
+
+/******************************************************************************/
+
+const selectorFromFilter = node => {
+ while ( node !== null ) {
+ if ( node.localName === 'li' ) {
+ const code = qs$(node, 'code:nth-of-type(2)');
+ if ( code !== null ) {
+ return code.textContent;
+ }
+ }
+ node = node.parentElement;
+ }
+ return '';
+};
+
+/******************************************************************************/
+
+const nidFromNode = node => {
+ let li = node;
+ while ( li !== null ) {
+ if ( li.localName === 'li' ) {
+ return li.id || '';
+ }
+ li = li.parentElement;
+ }
+ return '';
+};
+
+/******************************************************************************/
+
+const startDialog = (( ) => {
+ let dialog;
+ let textarea;
+ let hideSelectors = [];
+ let unhideSelectors = [];
+
+ const parse = function() {
+ hideSelectors = [];
+ unhideSelectors = [];
+
+ const re = /^([^#]*)(#@?#)(.+)$/;
+ for ( let line of textarea.value.split(/\s*\n\s*/) ) {
+ line = line.trim();
+ if ( line === '' || line.charAt(0) === '!' ) { continue; }
+ const matches = re.exec(line);
+ if ( matches === null || matches.length !== 4 ) { continue; }
+ if ( inspectedHostname.lastIndexOf(matches[1]) === -1 ) {
+ continue;
+ }
+ if ( matches[2] === '##' ) {
+ hideSelectors.push(matches[3]);
+ } else {
+ unhideSelectors.push(matches[3]);
+ }
+ }
+
+ showCommitted();
+ };
+
+ const inputTimer = vAPI.defer.create(parse);
+
+ const onInputChanged = ( ) => {
+ inputTimer.on(743);
+ };
+
+ const onClicked = function(ev) {
+ const target = ev.target;
+
+ ev.stopPropagation();
+
+ if ( target.id === 'createCosmeticFilters' ) {
+ vAPI.messaging.send('loggerUI', {
+ what: 'createUserFilter',
+ filters: textarea.value,
+ });
+ // Force a reload for the new cosmetic filter(s) to take effect
+ vAPI.messaging.send('loggerUI', {
+ what: 'reloadTab',
+ tabId: inspectedTabId,
+ });
+ return stop();
+ }
+ };
+
+ const showCommitted = function() {
+ contentInspectorChannel.send({
+ what: 'showCommitted',
+ hide: hideSelectors.join(',\n'),
+ unhide: unhideSelectors.join(',\n')
+ });
+ };
+
+ const showInteractive = function() {
+ contentInspectorChannel.send({
+ what: 'showInteractive',
+ hide: hideSelectors.join(',\n'),
+ unhide: unhideSelectors.join(',\n')
+ });
+ };
+
+ const start = function() {
+ dialog = logger.modalDialog.create('#cosmeticFilteringDialog', stop);
+ textarea = qs$(dialog, 'textarea');
+ hideSelectors = [];
+ for ( const node of qsa$(domTree, 'code.off') ) {
+ if ( dom.cl.has(node, 'filter') ) { continue; }
+ hideSelectors.push(selectorFromNode(node));
+ }
+ const taValue = [];
+ for ( const selector of hideSelectors ) {
+ taValue.push(inspectedHostname + '##' + selector);
+ }
+ const ids = new Set();
+ for ( const node of qsa$(domTree, 'code.filter.off') ) {
+ const id = dom.attr(node, 'data-filter-id');
+ if ( ids.has(id) ) { continue; }
+ ids.add(id);
+ unhideSelectors.push(node.textContent);
+ taValue.push(inspectedHostname + '#@#' + node.textContent);
+ }
+ textarea.value = taValue.join('\n');
+ textarea.addEventListener('input', onInputChanged);
+ dialog.addEventListener('click', onClicked, true);
+ showCommitted();
+ logger.modalDialog.show();
+ };
+
+ const stop = function() {
+ inputTimer.off();
+ showInteractive();
+ textarea.removeEventListener('input', onInputChanged);
+ dialog.removeEventListener('click', onClicked, true);
+ dialog = undefined;
+ textarea = undefined;
+ hideSelectors = [];
+ unhideSelectors = [];
+ };
+
+ return start;
+})();
+
+/******************************************************************************/
+
+const onClicked = ev => {
+ ev.stopPropagation();
+
+ if ( inspectedTabId === 0 ) { return; }
+
+ const target = ev.target;
+ const parent = target.parentElement;
+
+ // Expand/collapse branch
+ if (
+ target.localName === 'span' &&
+ parent instanceof HTMLLIElement &&
+ dom.cl.has(parent, 'branch') &&
+ target === parent.firstElementChild
+ ) {
+ const state = dom.cl.toggle(parent, 'show');
+ if ( !state ) {
+ for ( const node of qsa$(parent, '.branch') ) {
+ dom.cl.remove(node, 'show');
+ }
+ }
+ return;
+ }
+
+ // Not a node or filter
+ if ( target.localName !== 'code' ) { return; }
+
+ // Toggle cosmetic filter
+ if ( dom.cl.has(target, 'filter') ) {
+ contentInspectorChannel.send({
+ what: 'toggleFilter',
+ original: false,
+ target: dom.cl.toggle(target, 'off'),
+ selector: selectorFromNode(target),
+ filter: selectorFromFilter(target),
+ nid: nidFromNode(target)
+ });
+ dom.cl.toggle(
+ qsa$(inspector, `[data-filter-id="${dom.attr(target, 'data-filter-id')}"]`),
+ 'off',
+ dom.cl.has(target, 'off')
+ );
+ }
+ // Toggle node
+ else {
+ contentInspectorChannel.send({
+ what: 'toggleNodes',
+ original: true,
+ target: dom.cl.toggle(target, 'off') === false,
+ selector: selectorFromNode(target),
+ nid: nidFromNode(target)
+ });
+ }
+
+ const cantCreate = qs$(domTree, '.off') === null;
+ dom.cl.toggle(qs$(inspector, '.permatoolbar .revert'), 'disabled', cantCreate);
+ dom.cl.toggle(qs$(inspector, '.permatoolbar .commit'), 'disabled', cantCreate);
+};
+
+/******************************************************************************/
+
+const onMouseOver = (( ) => {
+ let mouseoverTarget = null;
+
+ const mouseoverTimer = vAPI.defer.create(( ) => {
+ contentInspectorChannel.send({
+ what: 'highlightOne',
+ selector: selectorFromNode(mouseoverTarget),
+ nid: nidFromNode(mouseoverTarget),
+ scrollTo: true
+ });
+ });
+
+ return ev => {
+ if ( inspectedTabId === 0 ) { return; }
+ // Convenience: skip real-time highlighting if shift key is pressed.
+ if ( ev.shiftKey ) { return; }
+ // Find closest `li`
+ const target = ev.target.closest('li');
+ if ( target === mouseoverTarget ) { return; }
+ mouseoverTarget = target;
+ mouseoverTimer.on(50);
+ };
+})();
+
+/******************************************************************************/
+
+const currentTabId = ( ) => {
+ if ( dom.cl.has(showdomButton, 'active') === false ) { return 0; }
+ return logger.tabIdFromPageSelector();
+};
+
+/******************************************************************************/
+
+const injectInspector = (( ) => {
+ const timer = vAPI.defer.create(( ) => {
+ const tabId = currentTabId();
+ if ( tabId <= 0 ) { return; }
+ inspectedTabId = tabId;
+ vAPI.messaging.send('loggerUI', {
+ what: 'scriptlet',
+ tabId,
+ scriptlet: 'dom-inspector',
+ });
+ });
+ return ( ) => {
+ shutdownInspector();
+ timer.offon(353);
+ };
+})();
+
+/******************************************************************************/
+
+const shutdownInspector = ( ) => {
+ contentInspectorChannel.disconnect();
+ logger.removeAllChildren(domTree);
+ dom.cl.remove(inspector, 'vExpanded');
+ inspectedTabId = 0;
+};
+
+/******************************************************************************/
+
+const onTabIdChanged = ( ) => {
+ const tabId = currentTabId();
+ if ( tabId <= 0 ) {
+ return toggleOff();
+ }
+ if ( inspectedTabId !== tabId ) {
+ injectInspector();
+ }
+};
+
+/******************************************************************************/
+
+const toggleVExpandView = ( ) => {
+ const branches = qsa$('#domTree li.branch.show > ul > li.branch:not(.show)');
+ for ( const branch of branches ) {
+ dom.cl.add(branch, 'show');
+ }
+};
+
+const toggleVCompactView = ( ) => {
+ const branches = qsa$('#domTree li.branch.show > ul > li:not(.show)');
+ const tohideSet = new Set();
+ for ( const branch of branches ) {
+ const node = branch.closest('li.branch.show');
+ if ( node.id === 'n1' ) { continue; }
+ tohideSet.add(node);
+ }
+ const tohideList = Array.from(tohideSet);
+ let i = tohideList.length - 1;
+ while ( i > 0 ) {
+ if ( tohideList[i-1].contains(tohideList[i]) ) {
+ tohideList.splice(i-1, 1);
+ } else if ( tohideList[i].contains(tohideList[i-1]) ) {
+ tohideList.splice(i, 1);
+ }
+ i -= 1;
+ }
+ for ( const node of tohideList ) {
+ dom.cl.remove(node, 'show');
+ }
+};
+
+const toggleHCompactView = ( ) => {
+ dom.cl.toggle(inspector, 'hCompact');
+};
+
+/******************************************************************************/
+
+const revert = ( ) => {
+ dom.cl.remove('#domTree .off', 'off');
+ contentInspectorChannel.send({ what: 'resetToggledNodes' });
+ dom.cl.add(qs$(inspector, '.permatoolbar .revert'), 'disabled');
+ dom.cl.add(qs$(inspector, '.permatoolbar .commit'), 'disabled');
+};
+
+/******************************************************************************/
+
+const toggleOn = ( ) => {
+ dom.cl.add('#inspectors', 'dom');
+ window.addEventListener('beforeunload', toggleOff);
+ document.addEventListener('tabIdChanged', onTabIdChanged);
+ domTree.addEventListener('click', onClicked, true);
+ domTree.addEventListener('mouseover', onMouseOver, true);
+ dom.on('#domInspector .vExpandToggler', 'click', toggleVExpandView);
+ dom.on('#domInspector .vCompactToggler', 'click', toggleVCompactView);
+ dom.on('#domInspector .hCompactToggler', 'click', toggleHCompactView);
+ dom.on('#domInspector .permatoolbar .revert', 'click', revert);
+ dom.on('#domInspector .permatoolbar .commit', 'click', startDialog);
+ contentInspectorChannel.start();
+ injectInspector();
+};
+
+/******************************************************************************/
+
+const toggleOff = ( ) => {
+ dom.cl.remove(showdomButton, 'active');
+ dom.cl.remove('#inspectors', 'dom');
+ shutdownInspector();
+ window.removeEventListener('beforeunload', toggleOff);
+ document.removeEventListener('tabIdChanged', onTabIdChanged);
+ domTree.removeEventListener('click', onClicked, true);
+ domTree.removeEventListener('mouseover', onMouseOver, true);
+ dom.off('#domInspector .vExpandToggler', 'click', toggleVExpandView);
+ dom.off('#domInspector .vCompactToggler', 'click', toggleVCompactView);
+ dom.off('#domInspector .hCompactToggler', 'click', toggleHCompactView);
+ dom.off('#domInspector .permatoolbar .revert', 'click', revert);
+ dom.off('#domInspector .permatoolbar .commit', 'click', startDialog);
+ contentInspectorChannel.shutdown();
+ inspectedTabId = 0;
+};
+
+/******************************************************************************/
+
+const toggle = ( ) => {
+ if ( dom.cl.toggle(showdomButton, 'active') ) {
+ toggleOn();
+ } else {
+ toggleOff();
+ }
+ logger.resize();
+};
+
+dom.on(showdomButton, 'click', toggle);
+
+/******************************************************************************/
+
+})();