From e9584b2cab1e526c1421d37be606eb33570270f1 Mon Sep 17 00:00:00 2001 From: tophf Date: Tue, 11 Feb 2020 15:19:59 +0300 Subject: [PATCH] group all style management stuff in injector --- content/apply.js | 153 +--------------- content/style-injector.js | 373 ++++++++++++++++++++++++-------------- 2 files changed, 244 insertions(+), 282 deletions(-) diff --git a/content/apply.js b/content/apply.js index fcb5b918..5fecc11e 100644 --- a/content/apply.js +++ b/content/apply.js @@ -10,25 +10,12 @@ self.INJECTED !== 1 && (() => { self.INJECTED = 1; const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument; - const IS_OWN_PAGE = Boolean(chrome.tabs); const styleInjector = createStyleInjector({ compare: (a, b) => a.id - b.id, - onUpdate: onInjectorUpdate - }); - const docRootObserver = createDocRootObserver({ - onChange: () => { - if (styleInjector.outOfOrder()) { - styleInjector.sort(); - return true; - } - } - }); - const docRewriteObserver = createDocRewriteObserver({ - onChange: () => { - docRootObserver.evade(styleInjector.sort); - } + onUpdate: onInjectorUpdate, }); const initializing = init(); + // save it now because chrome.runtime will be unavailable in the orphaned script const orphanEventId = chrome.runtime.id; let isOrphaned; @@ -37,7 +24,7 @@ self.INJECTED !== 1 && (() => { msg.onTab(applyOnMessage); - if (!IS_OWN_PAGE) { + if (!chrome.tabs) { window.dispatchEvent(new CustomEvent(orphanEventId)); window.addEventListener(orphanEventId, orphanCheck, true); } @@ -50,22 +37,16 @@ self.INJECTED !== 1 && (() => { } function onInjectorUpdate() { - if (!IS_OWN_PAGE && styleInjector.list.length) { - docRewriteObserver.start(); - docRootObserver.start(); - } else { - docRewriteObserver.stop(); - docRootObserver.stop(); + if (!isOrphaned) { + updateCount(); + updateExposeIframes(); } - if (isOrphaned) return; - updateCount(); - updateExposeIframes(); } function init() { return STYLE_VIA_API ? API.styleViaAPI({method: 'styleApply'}) : - API.getSectionsByUrl(getMatchUrl()).then(applyStyles); + API.getSectionsByUrl(getMatchUrl()).then(styleInjector.apply); } function getMatchUrl() { @@ -108,7 +89,7 @@ self.INJECTED !== 1 && (() => { if (!sections[request.style.id]) { styleInjector.remove(request.style.id); } else { - applyStyles(sections); + styleInjector.apply(sections); } }); } else { @@ -119,13 +100,13 @@ self.INJECTED !== 1 && (() => { case 'styleAdded': if (request.style.enabled) { API.getSectionsByUrl(getMatchUrl(), request.style.id) - .then(applyStyles); + .then(styleInjector.apply); } break; case 'urlChanged': API.getSectionsByUrl(getMatchUrl()) - .then(replaceAll); + .then(styleInjector.replace); break; case 'backgroundReady': @@ -197,31 +178,6 @@ self.INJECTED !== 1 && (() => { }).catch(console.error); } - function applyStyles(sections) { - return new Promise(resolve => { - const styles = styleMapToArray(sections); - if (styles.length) { - docRootObserver.evade(() => { - styleInjector.addMany(styles); - resolve(); - }); - } else { - resolve(); - } - }); - } - - function replaceAll(newStyles) { - styleInjector.replaceAll(styleMapToArray(newStyles)); - } - - function styleMapToArray(styleMap) { - return Object.values(styleMap).map(s => ({ - id: s.id, - code: s.code.join(''), - })); - } - function orphanCheck() { try { if (chrome.i18n.getUILanguage()) return; @@ -235,93 +191,4 @@ self.INJECTED !== 1 && (() => { msg.off(applyOnMessage); } catch (e) {} } - - function createDocRewriteObserver({onChange}) { - // detect documentElement being rewritten from inside the script - let root; - let observing = false; - let timer; - const observer = new MutationObserver(check); - return {start, stop}; - - function start() { - if (observing) return; - // detect dynamic iframes rewritten after creation by the embedder i.e. externally - root = document.documentElement; - timer = setTimeout(check); - observer.observe(document, {childList: true}); - observing = true; - } - - function stop() { - if (!observing) return; - clearTimeout(timer); - observer.disconnect(); - observing = false; - } - - function check() { - if (root !== document.documentElement) { - root = document.documentElement; - onChange(); - } - } - } - - function createDocRootObserver({onChange}) { - let digest = 0; - let lastCalledTime = NaN; - let observing = false; - const observer = new MutationObserver(() => { - if (digest) { - if (performance.now() - lastCalledTime > 1000) { - digest = 0; - } else if (digest > 5) { - throw new Error('The page keeps generating mutations. Skip the event.'); - } - } - if (onChange()) { - digest++; - lastCalledTime = performance.now(); - } - }); - return {start, stop, evade}; - - function start() { - if (observing) return; - observer.observe(document.documentElement, {childList: true}); - observing = true; - } - - function stop() { - if (!observing) return; - // FIXME: do we need this? - observer.takeRecords(); - observer.disconnect(); - observing = false; - } - - function evade(fn) { - if (observing) { - stop(); - _run(fn); - start(); - } else { - _run(fn); - } - } - - function _run(fn) { - if (document.documentElement) { - fn(); - } else { - new MutationObserver((mutations, observer) => { - if (document.documentElement) { - observer.disconnect(); - fn(); - } - }).observe(document, {childList: true}); - } - } - } })(); diff --git a/content/style-injector.js b/content/style-injector.js index dabb709d..1b496b4c 100644 --- a/content/style-injector.js +++ b/content/style-injector.js @@ -8,8 +8,11 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ const PATCH_ID = 'transition-patch'; // styles are out of order if any of these elements is injected between them const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']); + const IS_OWN_PAGE = Boolean(chrome.tabs); // detect Chrome 65 via a feature it added since browser version can be spoofed const isChromePre65 = chrome.app && typeof Worklet !== 'function'; + const docRewriteObserver = RewriteObserver(_sort); + const docRootObserver = RootObserver(_sortIfNeeded); const list = []; const table = new Map(); let isEnabled = true; @@ -17,78 +20,74 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ // will store the original method refs because the page can override them let creationDoc, createElement, createElementNS; return { - // manipulation - addMany, - remove, + apply, clear, clearOrphans, - replaceAll, - - // method + remove, + replace, toggle, - sort, - - // state - outOfOrder, list, - - // static util - createStyle }; - /* - FF59+ workaround: allow the page to read our sheets, https://github.com/openstyles/stylus/issues/461 - First we're trying the page context document where inline styles may be forbidden by CSP - https://bugzilla.mozilla.org/show_bug.cgi?id=1579345#c3 - and since userAgent.navigator can be spoofed via about:config or devtools, - we're checking for getPreventDefault that was removed in FF59 - */ - function _initCreationDoc() { - creationDoc = !Event.prototype.getPreventDefault && document.wrappedJSObject; - if (creationDoc) { - ({createElement, createElementNS} = creationDoc); - const el = document.documentElement.appendChild(createStyle()); - const isApplied = el.sheet; - el.remove(); - if (isApplied) return; - } - creationDoc = document; - ({createElement, createElementNS} = document); + function apply(styleMap) { + const styles = _styleMapToArray(styleMap); + return !styles.length ? + Promise.resolve([]) : + docRootObserver.evade(() => { + if (!isTransitionPatched) _applyTransitionPatch(styles); + const els = styles.map(_apply); + _emitUpdate(); + return els; + }); } - function outOfOrder() { - if (!list.length) { - return false; + function clear() { + for (const style of list) { + style.el.remove(); } - let el = list[0].el; - if (el.parentNode !== creationDoc.documentElement) { - return true; - } - let i = 0; - while (el) { - if (i < list.length && el === list[i].el) { - i++; - } else if (ORDERED_TAGS.has(el.localName)) { - return true; + list.length = 0; + table.clear(); + _emitUpdate(); + } + + function clearOrphans() { + for (const el of document.querySelectorAll(`style[id^="${PREFIX}"].stylus`)) { + const id = el.id.slice(PREFIX.length); + if (/^\d+$/.test(id) || id === PATCH_ID) { + el.remove(); } - el = el.nextElementSibling; } - // some styles are not injected to the document - return i < list.length; } - function addMany(styles) { - if (!isTransitionPatched) _applyTransitionPatch(styles); - const els = styles.map(_add); - onUpdate(); - return els; + function remove(id) { + _remove(id); + _emitUpdate(); + } + + function replace(styleMap) { + const styles = _styleMapToArray(styleMap); + const added = new Set(styles.map(s => s.id)); + const removed = []; + for (const style of list) { + if (!added.has(style.id)) { + removed.push(style.id); + } + } + styles.forEach(_apply); + removed.forEach(_remove); + _emitUpdate(); + } + + function toggle(_enabled) { + if (isEnabled === _enabled) return; + isEnabled = _enabled; + for (const style of list) { + style.el.disabled = !isEnabled; + } } function _add(style) { - if (table.has(style.id)) { - return _update(style); - } - const el = style.el = createStyle(style.id, style.code); + const el = style.el = _createStyle(style.id, style.code); table.set(style.id, style); const nextIndex = list.findIndex(i => compare(i, style) > 0); if (nextIndex < 0) { @@ -103,14 +102,18 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ return el; } - // CSS transition bug workaround: since we insert styles asynchronously, - // the browsers, especially Firefox, may apply all transitions on page load + function _apply(style) { + return table.has(style.id) ? _update(style) : _add(style); + } + function _applyTransitionPatch(styles) { + // CSS transition bug workaround: since we insert styles asynchronously, + // the browsers, especially Firefox, may apply all transitions on page load isTransitionPatched = document.readyState === 'complete'; if (isTransitionPatched || !styles.some(s => s.code.includes('transition'))) { return; } - const el = createStyle(PATCH_ID, ` + const el = _createStyle(PATCH_ID, ` :root:not(#\\0):not(#\\0) * { transition: none !important; } @@ -121,50 +124,7 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ requestAnimationFrame(() => setTimeout(() => el.remove())); } - function remove(id) { - _remove(id); - onUpdate(); - } - - function _remove(id) { - const style = table.get(id); - if (!style) return; - table.delete(id); - list.splice(list.indexOf(style), 1); - style.el.remove(); - } - - function _update({id, code}) { - const style = table.get(id); - if (style.code === code) return; - style.code = code; - // workaround for Chrome devtools bug fixed in v65 - if (isChromePre65) { - const oldEl = style.el; - style.el = createStyle(id, code); - oldEl.parentNode.insertBefore(style.el, oldEl.nextSibling); - oldEl.remove(); - } else { - style.el.textContent = code; - } - // https://github.com/openstyles/stylus/issues/693 - style.el.disabled = !isEnabled; - } - - function _supersede(domId) { - const el = document.getElementById(domId); - if (el) { - // remove if it looks like our style that wasn't cleaned up in orphanCheck - // (note, Firefox doesn't orphanize content scripts at all so orphanCheck will never run) - if (el.localName === 'style' && el.classList.contains('stylus')) { - el.remove(); - } else { - el.id += ' superseded by Stylus'; - } - } - } - - function createStyle(id, code = '') { + function _createStyle(id, code = '') { if (!creationDoc) _initCreationDoc(); let el; if (document.documentElement instanceof SVGSVGElement) { @@ -179,7 +139,8 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ } if (id) { el.id = `${PREFIX}${id}`; - _supersede(el.id); + const oldEl = document.getElementById(el.id); + if (oldEl) oldEl.id += '-superseded-by-Stylus'; } el.type = 'text/css'; // SVG className is not a string, but an instance of SVGAnimatedString @@ -188,54 +149,188 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ return el; } - function clear() { - for (const style of list) { - style.el.remove(); + function _emitUpdate() { + if (!IS_OWN_PAGE && list.length) { + docRewriteObserver.start(); + docRootObserver.start(); + } else { + docRewriteObserver.stop(); + docRootObserver.stop(); } - list.length = 0; - table.clear(); onUpdate(); } - function clearOrphans() { - for (const el of document.querySelectorAll(`style[id^="${PREFIX}-"].stylus`)) { - const id = el.id.slice(PREFIX.length + 1); - if (/^\d+$/.test(id) || id === PATCH_ID) { - el.remove(); + /* + FF59+ workaround: allow the page to read our sheets, https://github.com/openstyles/stylus/issues/461 + First we're trying the page context document where inline styles may be forbidden by CSP + https://bugzilla.mozilla.org/show_bug.cgi?id=1579345#c3 + and since userAgent.navigator can be spoofed via about:config or devtools, + we're checking for getPreventDefault that was removed in FF59 + */ + function _initCreationDoc() { + creationDoc = !Event.prototype.getPreventDefault && document.wrappedJSObject; + if (creationDoc) { + ({createElement, createElementNS} = creationDoc); + const el = document.documentElement.appendChild(_createStyle()); + const isApplied = el.sheet; + el.remove(); + if (isApplied) return; + } + creationDoc = document; + ({createElement, createElementNS} = document); + } + + function _remove(id) { + const style = table.get(id); + if (!style) return; + table.delete(id); + list.splice(list.indexOf(style), 1); + style.el.remove(); + } + + function _sort() { + docRootObserver.evade(() => { + list.sort(compare); + for (const style of list) { + // moving an element resets its 'disabled' state + document.documentElement.appendChild(style.el); + style.el.disabled = !isEnabled; + } + }); + } + + function _sortIfNeeded() { + let needsSort; + let el = list.length && list[0].el; + if (!el) { + needsSort = false; + } else if (el.parentNode !== creationDoc.documentElement) { + needsSort = true; + } else { + let i = 0; + while (el) { + if (i < list.length && el === list[i].el) { + i++; + } else if (ORDERED_TAGS.has(el.localName)) { + needsSort = true; + break; + } + el = el.nextElementSibling; + } + // some styles are not injected to the document + if (i < list.length) needsSort = true; + } + if (needsSort) _sort(); + return needsSort; + } + + function _styleMapToArray(styleMap) { + return Object.values(styleMap).map(s => ({ + id: s.id, + code: s.code.join(''), + })); + } + + function _update({id, code}) { + const style = table.get(id); + if (style.code === code) return; + style.code = code; + // workaround for Chrome devtools bug fixed in v65 + if (isChromePre65) { + const oldEl = style.el; + style.el = _createStyle(id, code); + oldEl.parentNode.insertBefore(style.el, oldEl.nextSibling); + oldEl.remove(); + } else { + style.el.textContent = code; + } + // https://github.com/openstyles/stylus/issues/693 + style.el.disabled = !isEnabled; + } + + function RewriteObserver(onChange) { + // detect documentElement being rewritten from inside the script + let root; + let observing = false; + let timer; + const observer = new MutationObserver(_check); + return {start, stop}; + + function start() { + if (observing) return; + // detect dynamic iframes rewritten after creation by the embedder i.e. externally + root = document.documentElement; + timer = setTimeout(_check); + observer.observe(document, {childList: true}); + observing = true; + } + + function stop() { + if (!observing) return; + clearTimeout(timer); + observer.disconnect(); + observing = false; + } + + function _check() { + if (root !== document.documentElement) { + root = document.documentElement; + onChange(); } } } - function toggle(_enabled) { - if (isEnabled === _enabled) return; - isEnabled = _enabled; - for (const style of list) { - style.el.disabled = !isEnabled; - } - } + function RootObserver(onChange) { + let digest = 0; + let lastCalledTime = NaN; + let observing = false; + const observer = new MutationObserver(() => { + if (digest) { + if (performance.now() - lastCalledTime > 1000) { + digest = 0; + } else if (digest > 5) { + throw new Error('The page keeps generating mutations. Skip the event.'); + } + } + if (onChange()) { + digest++; + lastCalledTime = performance.now(); + } + }); + return {evade, start, stop}; - function sort() { - list.sort(compare); - for (const style of list) { + function evade(fn) { + const restore = observing && start; + stop(); + return new Promise(resolve => _run(fn, resolve, _waitForRoot)) + .then(restore); + } + + function start() { + if (observing) return; + observer.observe(document.documentElement, {childList: true}); + observing = true; + } + + function stop() { + if (!observing) return; // FIXME: do we need this? - // const copy = document.importNode(el, true); - // el.textContent += ' '; // invalidate CSSOM cache - document.documentElement.appendChild(style.el); - // moving an element resets its 'disabled' state - style.el.disabled = !isEnabled; + observer.takeRecords(); + observer.disconnect(); + observing = false; } - } - function replaceAll(styles) { - const added = new Set(styles.map(s => s.id)); - const removed = []; - for (const style of list) { - if (!added.has(style.id)) { - removed.push(style.id); + function _run(fn, resolve, wait) { + if (document.documentElement) { + resolve(fn()); + return true; } + if (wait) wait(fn, resolve); + } + + function _waitForRoot(...args) { + new MutationObserver((_, observer) => _run(...args) && observer.disconnect()) + .observe(document, {childList: true}); } - styles.forEach(_add); - removed.forEach(_remove); - onUpdate(); } };