From 8192fab1b82b3e08ce80d52397ba3daa790c597d Mon Sep 17 00:00:00 2001 From: tophf Date: Tue, 25 Feb 2020 02:16:45 +0300 Subject: [PATCH] show write-style entries for iframes in popup (#861) * account for iframes in popup list/write-style and badge * fix and simplify openURL + onTabReady + message from popup * fixup! resolve about:blank iframes to their parent URL * fixup! don't underline iframe links until hovered * fix width bug in popup only when needed (Chrome 66-69) * fixup! reset styleIds on main page navigation * fixup! call updateCount explicitly on extension pages * fixup! ensure frame url is present * fixup! frameResults entry may be empty * fixup! init main frame first * fixup! track iframes via ports * fixup! reduce badge update rate during page load * fixup! cosmetics * fixup! don't add frames with errors * fixup! cosmetics --- background/background.js | 29 ++-- background/icon-manager.js | 65 +++++++-- background/style-via-api.js | 7 +- background/tab-manager.js | 21 ++- content/apply.js | 41 ++++-- js/messaging.js | 78 +--------- popup.html | 1 + popup/popup.css | 89 +++++++++++- popup/popup.js | 282 +++++++++++++++++++++--------------- 9 files changed, 368 insertions(+), 245 deletions(-) diff --git a/background/background.js b/background/background.js index 6cfc6f07..844f6958 100644 --- a/background/background.js +++ b/background/background.js @@ -2,7 +2,7 @@ URLS ignoreChromeError usercssHelper styleManager msg navigatorUtil workerUtil contentScripts sync findExistingTab createTab activateTab isTabReplaceable getActiveTab - iconManager tabManager */ + tabManager */ 'use strict'; @@ -49,16 +49,27 @@ window.API_METHODS = Object.assign(window.API_METHODS || {}, { openEditor, - updateIconBadge(count) { - iconManager.updateIconBadge(this.sender.tab.id, count); - return true; + /* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent when the tab is ready, + which is needed in the popup, otherwise another extension could force the tab to open in foreground + thus auto-closing the popup (in Chrome at least) and preventing the sendMessage code from running */ + openURL(opts) { + const {message} = opts; + return openURL(opts) // will pass the resolved value untouched when `message` is absent or falsy + .then(message && (tab => tab.status === 'complete' ? tab : onTabReady(tab))) + .then(message && (tab => msg.sendTab(tab.id, opts.message))); + function onTabReady(tab) { + return new Promise((resolve, reject) => + setTimeout(function ping(numTries = 10, delay = 100) { + msg.sendTab(tab.id, {method: 'ping'}) + .catch(() => false) + .then(pong => pong + ? resolve(tab) + : numTries && setTimeout(ping, delay, numTries - 1, delay * 1.5) || + reject('timeout')); + })); + } }, - // exposed for stuff that requires followup sendMessage() like popup::openSettings - // that would fail otherwise if another extension forced the tab to open - // in the foreground thus auto-closing the popup (in Chrome) - openURL, - optionsCustomizeHotkeys() { return browser.runtime.openOptionsPage() .then(() => new Promise(resolve => setTimeout(resolve, 100))) diff --git a/background/icon-manager.js b/background/icon-manager.js index 71a8f29d..7319b32e 100644 --- a/background/icon-manager.js +++ b/background/icon-manager.js @@ -1,9 +1,10 @@ -/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager */ +/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API_METHODS */ /* exported iconManager */ 'use strict'; const iconManager = (() => { const ICON_SIZES = FIREFOX || CHROME >= 2883 && !VIVALDI ? [16, 32] : [19, 38]; + const staleBadges = new Set(); prefs.subscribe([ 'disableAll', @@ -26,32 +27,51 @@ const iconManager = (() => { refreshAllIcons(); }); - return {updateIconBadge}; + Object.assign(API_METHODS, { + /** @param {(number|string)[]} styleIds + * @param {boolean} [lazyBadge=false] preventing flicker during page load */ + updateIconBadge(styleIds, {lazyBadge} = {}) { + // FIXME: in some cases, we only have to redraw the badge. is it worth a optimization? + const {frameId, tab: {id: tabId}} = this.sender; + const value = styleIds.length ? styleIds.map(Number) : undefined; + tabManager.set(tabId, 'styleIds', frameId, value); + debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0); + staleBadges.add(tabId); + if (!frameId) refreshIcon(tabId, true); + }, + }); - // FIXME: in some cases, we only have to redraw the badge. is it worth a optimization? - function updateIconBadge(tabId, count, force = true) { - tabManager.set(tabId, 'count', count); - refreshIconBadgeText(tabId); - refreshIcon(tabId, force); + navigatorUtil.onCommitted(({tabId, frameId}) => { + if (!frameId) tabManager.set(tabId, 'styleIds', undefined); + }); + + chrome.runtime.onConnect.addListener(port => { + if (port.name === 'iframe') { + port.onDisconnect.addListener(onPortDisconnected); + } + }); + + function onPortDisconnected({sender}) { + if (tabManager.get(sender.tab.id, 'styleIds')) { + API_METHODS.updateIconBadge.call({sender}, [], {lazyBadge: true}); + } } function refreshIconBadgeText(tabId) { - const count = tabManager.get(tabId, 'count'); - iconUtil.setBadgeText({ - text: prefs.get('show-badge') && count ? String(count) : '', - tabId - }); + const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : ''; + iconUtil.setBadgeText({tabId, text}); } - function getIconName(count = 0) { + function getIconName(hasStyles = false) { const iconset = prefs.get('iconset') === 1 ? 'light/' : ''; - const postfix = prefs.get('disableAll') ? 'x' : !count ? 'w' : ''; + const postfix = prefs.get('disableAll') ? 'x' : !hasStyles ? 'w' : ''; return `${iconset}$SIZE$${postfix}`; } function refreshIcon(tabId, force = false) { const oldIcon = tabManager.get(tabId, 'icon'); - const newIcon = getIconName(tabManager.get(tabId, 'count')); + const newIcon = getIconName(tabManager.get(tabId, 'styleIds', 0)); + // (changing the icon only for the main page, frameId = 0) if (!force && oldIcon === newIcon) { return; @@ -73,6 +93,14 @@ const iconManager = (() => { ); } + /** @return {number | ''} */ + function getStyleCount(tabId) { + const allIds = new Set(); + const data = tabManager.get(tabId, 'styleIds') || {}; + Object.values(data).forEach(frameIds => frameIds.forEach(id => allIds.add(id))); + return allIds.size || ''; + } + function refreshGlobalIcon() { iconUtil.setIcon({ path: getIconPath(getIconName()) @@ -98,4 +126,11 @@ const iconManager = (() => { refreshIconBadgeText(tabId); } } + + function refreshStaleBadges() { + for (const tabId of staleBadges) { + refreshIconBadgeText(tabId); + } + staleBadges.clear(); + } })(); diff --git a/background/style-via-api.js b/background/style-via-api.js index 12eecfc8..6793c65c 100644 --- a/background/style-via-api.js +++ b/background/style-via-api.js @@ -1,4 +1,4 @@ -/* global API_METHODS styleManager CHROME prefs iconManager */ +/* global API_METHODS styleManager CHROME prefs */ 'use strict'; API_METHODS.styleViaAPI = !CHROME && (() => { @@ -31,12 +31,13 @@ API_METHODS.styleViaAPI = !CHROME && (() => { .then(maybeToggleObserver); }; - function updateCount(request, {tab, frameId}) { + function updateCount(request, sender) { + const {tab, frameId} = sender; if (frameId) { throw new Error('we do not count styles for frames'); } const {frameStyles} = getCachedData(tab.id, frameId); - iconManager.updateIconBadge(tab.id, Object.keys(frameStyles).length); + API_METHODS.updateIconBadge.call({sender}, Object.keys(frameStyles)); } function styleApply({id = null, ignoreUrlCheck = false}, {tab, frameId, url}) { diff --git a/background/tab-manager.js b/background/tab-manager.js index bcd7901a..49061fcf 100644 --- a/background/tab-manager.js +++ b/background/tab-manager.js @@ -24,17 +24,28 @@ const tabManager = (() => { onUpdate(fn) { listeners.push(fn); }, - get(tabId, key) { - const meta = cache.get(tabId); - return meta && meta[key]; + get(tabId, ...keys) { + return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId)); }, - set(tabId, key, value) { + /** + * number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta + * (tabId, 'foo', 123) will set tabId's meta to {foo: 123}, + * (tabId, 'foo', 'bar', 'etc', 123) will set tabId's meta to {foo: {bar: {etc: 123}}} + */ + set(tabId, ...args) { let meta = cache.get(tabId); if (!meta) { meta = {}; cache.set(tabId, meta); } - meta[key] = value; + const value = args.pop(); + const lastKey = args.pop(); + for (const key of args) meta = meta[key] || (meta[key] = {}); + if (value === undefined) { + delete meta[lastKey]; + } else { + meta[lastKey] = value; + } }, list() { return cache.keys(); diff --git a/content/apply.js b/content/apply.js index 28d4390c..57a630d3 100644 --- a/content/apply.js +++ b/content/apply.js @@ -9,12 +9,25 @@ self.INJECTED !== 1 && (() => { self.INJECTED = 1; + let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html'; + const IS_FRAME = window !== parent; const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument; const styleInjector = createStyleInjector({ compare: (a, b) => a.id - b.id, onUpdate: onInjectorUpdate, }); const initializing = init(); + /** @type chrome.runtime.Port */ + let port; + let lazyBadge = IS_FRAME; + + // the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason + if (!IS_TAB) { + chrome.tabs.getCurrent(tab => { + IS_TAB = Boolean(tab); + if (tab && styleInjector.list.length) updateCount(); + }); + } // save it now because chrome.runtime will be unavailable in the orphaned script const orphanEventId = chrome.runtime.id; @@ -32,7 +45,7 @@ self.INJECTED !== 1 && (() => { let parentDomain; prefs.subscribe(['disableAll'], (key, value) => doDisableAll(value)); - if (window !== parent) { + if (IS_FRAME) { prefs.subscribe(['exposeIframes'], updateExposeIframes); } @@ -55,7 +68,7 @@ self.INJECTED !== 1 && (() => { // dynamic about: and javascript: iframes don't have an URL yet // so we'll try the parent frame which is guaranteed to have a real URL try { - if (window !== parent) { + if (IS_FRAME) { matchUrl = parent.location.href; } } catch (e) {} @@ -153,19 +166,19 @@ self.INJECTED !== 1 && (() => { } function updateCount() { - if (window !== parent) { - // we don't care about iframes - return; - } - if (/^\w+?-extension:\/\/.+(popup|options)\.html$/.test(location.href)) { - // popup and the option page are not tabs - return; - } - if (STYLE_VIA_API) { - API.styleViaAPI({method: 'updateCount'}).catch(msg.ignoreError); - } else { - API.updateIconBadge(styleInjector.list.length).catch(console.error); + if (!IS_TAB) return; + if (IS_FRAME) { + if (!port && styleInjector.list.length) { + port = chrome.runtime.connect({name: 'iframe'}); + } else if (port && !styleInjector.list.length) { + port.disconnect(); + } + if (lazyBadge && performance.now() > 1000) lazyBadge = false; } + (STYLE_VIA_API ? + API.styleViaAPI({method: 'updateCount'}) : + API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge}) + ).catch(msg.ignoreError); } function orphanCheck() { diff --git a/js/messaging.js b/js/messaging.js index e0b5e49d..93a4e5a7 100644 --- a/js/messaging.js +++ b/js/messaging.js @@ -1,4 +1,4 @@ -/* exported getActiveTab onTabReady stringAsRegExp getTabRealURL openURL +/* exported getTab getActiveTab onTabReady stringAsRegExp openURL ignoreChromeError getStyleWithNoCode tryRegExp sessionStorageHash download deepEqual closeCurrentTab capitalize CHROME_HAS_BORDER_BUG */ /* global promisify */ @@ -125,82 +125,6 @@ function getActiveTab() { .then(tabs => tabs[0]); } -function getTabRealURL(tab) { - return new Promise(resolve => { - if (tab.url !== 'chrome://newtab/' || URLS.chromeProtectsNTP) { - resolve(tab.url); - } else { - chrome.webNavigation.getFrame({tabId: tab.id, frameId: 0, processId: -1}, frame => { - resolve(frame && frame.url || ''); - }); - } - }); -} - -/** - * Resolves when the [just created] tab is ready for communication. - * @param {Number|Tab} tabOrId - * @returns {Promise} - */ -function onTabReady(tabOrId) { - let tabId, tab; - if (Number.isInteger(tabOrId)) { - tabId = tabOrId; - } else { - tab = tabOrId; - tabId = tab && tab.id; - } - if (!tab) { - return getTab(tabId).then(onTabReady); - } - if (tab.status === 'complete') { - if (!FIREFOX || tab.url !== 'about:blank') { - return Promise.resolve(tab); - } else { - return new Promise(resolve => { - chrome.webNavigation.getFrame({tabId, frameId: 0}, frame => { - ignoreChromeError(); - if (frame) { - onTabReady(tab).then(resolve); - } else { - setTimeout(() => onTabReady(tabId).then(resolve)); - } - }); - }); - } - } - return new Promise((resolve, reject) => { - chrome.webNavigation.onCommitted.addListener(onCommitted); - chrome.webNavigation.onErrorOccurred.addListener(onErrorOccurred); - chrome.tabs.onRemoved.addListener(onTabRemoved); - chrome.tabs.onReplaced.addListener(onTabReplaced); - function onCommitted(info) { - if (info.tabId !== tabId) return; - unregister(); - getTab(tab.id).then(resolve); - } - function onErrorOccurred(info) { - if (info.tabId !== tabId) return; - unregister(); - reject(); - } - function onTabRemoved(removedTabId) { - if (removedTabId !== tabId) return; - unregister(); - reject(); - } - function onTabReplaced(addedTabId, removedTabId) { - onTabRemoved(removedTabId); - } - function unregister() { - chrome.webNavigation.onCommitted.removeListener(onCommitted); - chrome.webNavigation.onErrorOccurred.removeListener(onErrorOccurred); - chrome.tabs.onRemoved.removeListener(onTabRemoved); - chrome.tabs.onReplaced.removeListener(onTabReplaced); - } - }); -} - function urlToMatchPattern(url, ignoreSearch) { // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns if (!/^(http|https|ws|wss|ftp|data|file)$/.test(url.protocol)) { diff --git a/popup.html b/popup.html index d8f44aed..fd80f988 100644 --- a/popup.html +++ b/popup.html @@ -235,6 +235,7 @@
+
diff --git a/popup/popup.css b/popup/popup.css index f6764db8..9e0e509a 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -8,12 +8,6 @@ --outer-padding: 9px; } -html { - /* Chrome 66-?? adds a gap equal to the scrollbar width, - which looks like a bug, see https://crbug.com/821143 */ - overflow: overlay; -} - html, body { height: min-content; max-height: 600px; @@ -313,6 +307,15 @@ a.configure[target="_blank"] .svg-icon.config { color: darkred; } +.frame-url::before { + content: "iframe: "; + color: lightslategray; +} + +.frame .style-name { + font-weight: normal; +} + /* entry menu */ .entry .menu { display: none; @@ -516,13 +519,85 @@ body.blocked .actions > .main-controls { content: "\00ad"; /* "soft" hyphen */ } -#match { +.about-blank > .breadcrumbs { + pointer-events: none; +} + +.about-blank > .breadcrumbs a { + text-decoration: none; +} + +.match { overflow-wrap: break-word; display: block; flex-grow: 9; +} + +.match[data-frame-id="0"] { min-width: 200px; } +.match[data-frame-id="0"] > .match { + margin-top: .25em; +} + +.match:not([data-frame-id="0"]) a { + text-decoration: none; /* not underlining iframe links until hovered to reduce visual noise */ +} + +.match .match { + margin-left: .5rem; +} + +.match .match::before { + content: ""; + width: .25rem; + height: .25rem; + margin-left: -.5rem; + display: block; + position: absolute; + border-width: 1px; + border-style: none none solid solid; +} + +.dupe > .breadcrumbs { + opacity: .5; +} + +.dupe:not([data-children]) { + display: none; +} + +#write-for-frames { + position: absolute; + width: 5px; + height: 5px; + margin-left: -12px; + margin-top: 4px; + --dash: transparent 2px, currentColor 2px, currentColor 3px, transparent 3px; + background: linear-gradient(var(--dash)), linear-gradient(90deg, var(--dash)); +} + +#write-for-frames.expanded { + background: linear-gradient(var(--dash)); +} + +#write-for-frames::after { + position: absolute; + margin: -2px; + border: 1px solid currentColor; + content: ""; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +#write-for-frames:not(.expanded) ~ .match:not([data-frame-id="0"]), +#write-for-frames:not(.expanded) ~ .match .match { + display: none; +} + /* "breadcrumbs" 'new style' links */ .breadcrumbs > .write-style-link { margin-left: 0 diff --git a/popup/popup.js b/popup/popup.js index 7283cb9b..7ec2f28b 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,39 +1,41 @@ -/* global configDialog hotkeys onTabReady msg - getActiveTab FIREFOX getTabRealURL URLS API onDOMready $ $$ prefs +/* global configDialog hotkeys msg + getActiveTab CHROME FIREFOX URLS API onDOMready $ $$ prefs setupLivePrefs template t $create animateElement - tryJSONparse debounce CHROME_HAS_BORDER_BUG */ + tryJSONparse CHROME_HAS_BORDER_BUG */ 'use strict'; +/** @type Element */ let installed; +/** @type string */ let tabURL; -let unsupportedURL; const handleEvent = {}; +const ABOUT_BLANK = 'about:blank'; const ENTRY_ID_PREFIX_RAW = 'style-'; const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW; +if (CHROME >= 3345 && CHROME < 3533) { // Chrome 66-69 adds a gap, https://crbug.com/821143 + document.head.appendChild($create('style', 'html { overflow: overlay }')); +} + toggleSideBorders(); -getActiveTab() - .then(tab => - FIREFOX && tab.url === 'about:blank' && tab.status === 'loading' - ? getTabRealURLFirefox(tab) - : getTabRealURL(tab) - ) - .then(url => Promise.all([ - (tabURL = URLS.supported(url) ? url : '') && - API.getStylesByUrl(tabURL), - onDOMready().then(initPopup), - ])) - .then(([results]) => { - if (!results) { +initTabUrls() + .then(frames => + Promise.all([ + onDOMready().then(() => initPopup(frames)), + ...frames + .filter(f => f.url && !f.isDupe) + .map(({url}) => API.getStylesByUrl(url).then(styles => ({styles, url}))), + ])) + .then(([, ...results]) => { + if (results[0]) { + showStyles(results); + } else { // unsupported URL; - unsupportedURL = true; $('#popup-manage-button').removeAttribute('title'); - return; } - showStyles(results.map(r => Object.assign(r.data, r))); }) .catch(console.error); @@ -83,8 +85,32 @@ function toggleSideBorders(state = prefs.get('popup.borders')) { } } +function initTabUrls() { + return getActiveTab() + .then((tab = {}) => + FIREFOX && tab.status === 'loading' && tab.url === ABOUT_BLANK + ? waitForTabUrlFF(tab) + : tab) + .then(tab => new Promise(resolve => + chrome.webNavigation.getAllFrames({tabId: tab.id}, frames => + resolve({frames, tab})))) + .then(({frames, tab}) => { + let url = tab.pendingUrl || tab.url || ''; // new Chrome uses pendingUrl while connecting + frames = sortTabFrames(frames); + if (url === 'chrome://newtab/' && !URLS.chromeProtectsNTP) { + url = frames[0].url || ''; + } + if (!URLS.supported(url)) { + url = ''; + frames.length = 1; + } + tabURL = frames[0].url = url; + return frames; + }); +} -function initPopup() { +/** @param {chrome.webNavigation.GetAllFrameResultDetails[]} frames */ +function initPopup(frames) { installed = $('#installed'); setPopupWidth(); @@ -120,6 +146,13 @@ function initPopup() { return; } + frames.forEach(createWriterElement); + if (frames.length > 1) { + const el = $('#write-for-frames'); + el.hidden = false; + el.onclick = () => el.classList.toggle('expanded'); + } + getActiveTab().then(function ping(tab, retryCountdown = 10) { msg.sendTab(tab.id, {method: 'ping'}, {frameId: 0}) .catch(() => false) @@ -131,7 +164,7 @@ function initPopup() { // so we'll wait a bit to handle popup being invoked right after switching if (retryCountdown > 0 && ( tab.status !== 'complete' || - FIREFOX && tab.url === 'about:blank')) { + FIREFOX && tab.url === ABOUT_BLANK)) { setTimeout(ping, 100, tab, --retryCountdown); return; } @@ -166,24 +199,26 @@ function initPopup() { document.body.insertBefore(info, document.body.firstChild); }); }); +} - // Write new style links - const writeStyle = $('#write-style'); - const matchTargets = document.createElement('span'); - const matchWrapper = document.createElement('span'); - matchWrapper.id = 'match'; - matchWrapper.appendChild(matchTargets); +/** @param {chrome.webNavigation.GetAllFrameResultDetails} frame */ +function createWriterElement(frame) { + const {url, frameId, parentFrameId, isDupe} = frame; + const targets = $create('span'); // For this URL const urlLink = template.writeStyle.cloneNode(true); + const isAboutBlank = url === ABOUT_BLANK; Object.assign(urlLink, { - href: 'edit.html?url-prefix=' + encodeURIComponent(tabURL), - title: `url-prefix("${tabURL}")`, + href: 'edit.html?url-prefix=' + encodeURIComponent(url), + title: `url-prefix("${url}")`, + tabIndex: isAboutBlank ? -1 : 0, textContent: prefs.get('popup.breadcrumbs.usePath') - ? new URL(tabURL).pathname.slice(1) - // this URL - : t('writeStyleForURL').replace(/ /g, '\u00a0'), - onclick: e => handleEvent.openEditor(e, {'url-prefix': tabURL}), + ? new URL(url).pathname.slice(1) + : frameId + ? isAboutBlank ? url : 'URL' + : t('writeStyleForURL').replace(/ /g, '\u00a0'), // this URL + onclick: e => handleEvent.openEditor(e, {'url-prefix': url}), }); if (prefs.get('popup.breadcrumbs')) { urlLink.onmouseenter = @@ -191,10 +226,10 @@ function initPopup() { urlLink.onmouseleave = urlLink.onblur = () => urlLink.parentNode.classList.remove('url()'); } - matchTargets.appendChild(urlLink); + targets.appendChild(urlLink); // For domain - const domains = getDomains(tabURL); + const domains = getDomains(url); for (const domain of domains) { const numParts = domain.length - domain.replace(/\./g, '').length + 1; // Don't include TLD @@ -209,66 +244,92 @@ function initPopup() { onclick: e => handleEvent.openEditor(e, {domain}), }); domainLink.setAttribute('subdomain', numParts > 1 ? 'true' : ''); - matchTargets.appendChild(domainLink); + targets.appendChild(domainLink); } if (prefs.get('popup.breadcrumbs')) { - matchTargets.classList.add('breadcrumbs'); - matchTargets.appendChild(matchTargets.removeChild(matchTargets.firstElementChild)); + targets.classList.add('breadcrumbs'); + targets.appendChild(urlLink); // making it the last element } - writeStyle.appendChild(matchWrapper); - function getDomains(url) { - let d = /.*?:\/*([^/:]+)|$/.exec(url)[1]; - if (!d || url.startsWith('file:')) { - return []; - } - const domains = [d]; - while (d.indexOf('.') !== -1) { - d = d.substring(d.indexOf('.') + 1); - domains.push(d); - } - return domains; - } + const root = $('#write-style'); + const parent = $(`[data-frame-id="${parentFrameId}"]`, root) || root; + const child = $create({ + tag: 'span', + className: `match${isDupe ? ' dupe' : ''}${isAboutBlank ? ' about-blank' : ''}`, + dataset: {frameId}, + appendChild: targets, + }); + parent.appendChild(child); + parent.dataset.children = (Number(parent.dataset.children) || 0) + 1; } +function getDomains(url) { + let d = url.split(/[/:]+/, 2)[1]; + if (!d || url.startsWith('file:')) { + return []; + } + const domains = [d]; + while (d.includes('.')) { + d = d.substring(d.indexOf('.') + 1); + domains.push(d); + } + return domains; +} + +/** @param {chrome.webNavigation.GetAllFrameResultDetails[]} frames */ +function sortTabFrames(frames) { + const unknown = new Map(frames.map(f => [f.frameId, f])); + const known = new Map([[0, unknown.get(0) || {frameId: 0, url: ''}]]); + unknown.delete(0); + let lastSize = 0; + while (unknown.size !== lastSize) { + for (const [frameId, f] of unknown) { + if (known.has(f.parentFrameId)) { + unknown.delete(frameId); + if (!f.errorOccurred) known.set(frameId, f); + if (f.url === ABOUT_BLANK) f.url = known.get(f.parentFrameId).url; + } + } + lastSize = unknown.size; // guard against an infinite loop due to a weird frame structure + } + const sortedFrames = [...known.values(), ...unknown.values()]; + const urls = new Set([ABOUT_BLANK]); + for (const f of sortedFrames) { + if (!f.url) f.url = ''; + f.isDupe = urls.has(f.url); + urls.add(f.url); + } + return sortedFrames; +} function sortStyles(entries) { const enabledFirst = prefs.get('popup.enabledFirst'); - entries.sort((a, b) => - enabledFirst && a.styleMeta.enabled !== b.styleMeta.enabled ? - (a.styleMeta.enabled ? -1 : 1) : - a.styleMeta.name.localeCompare(b.styleMeta.name) - ); + return entries.sort(({styleMeta: a}, {styleMeta: b}) => + Boolean(a.frameUrl) - Boolean(b.frameUrl) || + enabledFirst && Boolean(b.enabled) - Boolean(a.enabled) || + a.name.localeCompare(b.name)); } -function showStyles(styles) { - if (!styles) { - return; - } - if (!styles.length) { +function showStyles(frameResults) { + const entries = new Map(); + frameResults.forEach(({styles = [], url}, index) => { + styles.forEach(style => { + const {id} = style.data; + if (!entries.has(id)) { + style.frameUrl = index === 0 ? '' : url; + entries.set(id, createStyleElement(Object.assign(style.data, style))); + } + }); + }); + if (entries.size) { + installed.append(...sortStyles([...entries.values()])); + } else { installed.appendChild(template.noStyles.cloneNode(true)); - window.dispatchEvent(new Event('showStyles:done')); - return; } - const entries = styles.map(createStyleElement); - sortStyles(entries); - entries.forEach(e => installed.appendChild(e)); window.dispatchEvent(new Event('showStyles:done')); } -function sortStylesInPlace() { - if (!prefs.get('popup.autoResort')) { - return; - } - const entries = $$('.entry', installed); - if (!entries.length) { - return; - } - sortStyles(entries); - entries.forEach(e => installed.appendChild(e)); -} - function createStyleElement(style) { let entry = $(ENTRY_ID_PREFIX + style.id); @@ -356,6 +417,14 @@ function createStyleElement(style) { $('.exclude-by-domain', entry).title = getExcludeRule('domain'); $('.exclude-by-url', entry).title = getExcludeRule('url'); + const {frameUrl} = style; + if (frameUrl) { + const sel = 'span.frame-url'; + const frameEl = $(sel, entry) || styleName.insertBefore($create(sel), styleName.lastChild); + frameEl.title = frameUrl; + } + entry.classList.toggle('frame', Boolean(frameUrl)); + return entry; } @@ -400,7 +469,11 @@ Object.assign(handleEvent, { event.stopPropagation(); API .toggleStyle(handleEvent.getClickedStyleId(event), this.checked) - .then(sortStylesInPlace); + .then(() => { + if (prefs.get('popup.autoResort')) { + installed.append(...sortStyles($$('.entry', installed))); + } + }); }, toggleExclude(event, type) { @@ -561,23 +634,17 @@ Object.assign(handleEvent, { openURLandHide(event) { event.preventDefault(); - const message = tryJSONparse(this.dataset.sendMessage); getActiveTab() .then(activeTab => API.openURL({ url: this.href || this.dataset.href, - index: activeTab.index + 1 + index: activeTab.index + 1, + message: tryJSONparse(this.dataset.sendMessage), })) - .then(tab => { - if (message) { - return onTabReady(tab) - .then(() => msg.sendTab(tab.id, message)); - } - }) .then(window.close); }, openManager(event) { - if (event.button === 2 && unsupportedURL) return; + if (event.button === 2 && !tabURL) return; event.preventDefault(); if (!this.eventHandled) { // FIXME: this only works if popup is closed @@ -640,32 +707,17 @@ function handleDelete(id) { } } -function getTabRealURLFirefox(tab) { - // wait for FF tab-on-demand to get a real URL (initially about:blank), 5 sec max +function waitForTabUrlFF(tab) { return new Promise(resolve => { - function onNavigation({tabId, url, frameId}) { - if (tabId === tab.id && frameId === 0) { - detach(); - resolve(url); - } - } - - function detach(timedOut) { - if (timedOut) { - resolve(tab.url); - } else { - debounce.unregister(detach); - } - chrome.webNavigation.onBeforeNavigate.removeListener(onNavigation); - chrome.webNavigation.onCommitted.removeListener(onNavigation); - chrome.tabs.onRemoved.removeListener(detach); - chrome.tabs.onReplaced.removeListener(detach); - } - - chrome.webNavigation.onBeforeNavigate.addListener(onNavigation); - chrome.webNavigation.onCommitted.addListener(onNavigation); - chrome.tabs.onRemoved.addListener(detach); - chrome.tabs.onReplaced.addListener(detach); - debounce(detach, 5000, {timedOut: true}); + browser.tabs.onUpdated.addListener(...[ + function onUpdated(tabId, info, updatedTab) { + if (info.url && tabId === tab.id) { + chrome.tabs.onUpdated.removeListener(onUpdated); + resolve(updatedTab); + } + }, + ...'UpdateFilter' in browser.tabs ? [{tabId: tab.id}] : [], + // TODO: remove both spreads and tabId check when strict_min_version >= 61 + ]); }); }