stylus/apply.js

374 lines
11 KiB
JavaScript

// using ES5 syntax because ES6 is fast only since around Chrome 55
// so we'll wait until Chrome 60 arguably before converting
var g_disableAll = false;
var g_styleElements = {};
var iframeObserver;
var retiredStyleIds = [];
initObserver();
requestStyles();
function requestStyles(options = {}) {
// If this is a Stylish page (Edit Style or Manage Styles),
// we'll request the styles directly to minimize delay and flicker,
// unless Chrome still starts up and the background page isn't fully loaded.
// (Note: in this case the function may be invoked again from applyStyles.)
var request = Object.assign({
method: "getStyles",
matchUrl: location.href,
enabled: true,
asHash: true,
}, options);
if (typeof getStylesSafe !== 'undefined') {
getStylesSafe(request).then(applyStyles);
} else {
chrome.runtime.sendMessage(request, applyStyles);
}
}
chrome.runtime.onMessage.addListener(applyOnMessage);
function applyOnMessage(request, sender, sendResponse) {
// Also handle special request just for the pop-up
switch (request.method == "updatePopup" ? request.reason : request.method) {
case "styleDeleted":
removeStyle(request.id, document);
break;
case "styleUpdated":
if (request.codeIsUpdated === false) {
applyStyleState(request.style.id, request.style.enabled, document);
break;
}
if (request.style.enabled) {
retireStyle(request.style.id);
// fallthrough to "styleAdded"
} else {
removeStyle(request.style.id, document);
break;
}
case "styleAdded":
if (request.style.enabled) {
chrome.runtime.sendMessage({method: "getStyles", matchUrl: location.href, enabled: true, id: request.style.id, asHash: true}, applyStyles);
}
break;
case "styleApply":
applyStyles(request.styles);
break;
case "styleReplaceAll":
replaceAll(request.styles, document);
break;
case "styleDisableAll":
disableAll(request.disableAll);
break;
case "ping":
sendResponse(true);
break;
}
}
function disableAll(disable) {
if (!disable === !g_disableAll) {
return;
}
g_disableAll = disable;
if (g_disableAll) {
iframeObserver.disconnect();
}
disableSheets(g_disableAll, document);
if (!g_disableAll && document.readyState != "loading") {
iframeObserver.start();
}
function disableSheets(disable, doc) {
Array.prototype.forEach.call(doc.styleSheets, function(stylesheet) {
if (stylesheet.ownerNode.classList.contains("stylus")
&& stylesheet.disabled != disable) {
stylesheet.disabled = disable;
}
});
getDynamicIFrames(doc).forEach(function(iframe) {
if (!disable) {
// update the IFRAME if it was created while the observer was disconnected
addDocumentStylesToIFrame(iframe);
}
disableSheets(disable, iframe.contentDocument);
});
}
}
function applyStyleState(id, enabled, doc) {
var e = doc.getElementById("stylus-" + id);
if (!e) {
if (enabled) {
requestStyles({id});
}
} else {
e.sheet.disabled = !enabled;
getDynamicIFrames(doc).forEach(function(iframe) {
applyStyleState(id, iframe.contentDocument);
});
}
}
function removeStyle(id, doc) {
var e = doc.getElementById("stylus-" + id);
delete g_styleElements["stylus-" + id];
if (e) {
e.remove();
}
if (doc == document && Object.keys(g_styleElements).length == 0) {
iframeObserver.disconnect();
}
getDynamicIFrames(doc).forEach(function(iframe) {
removeStyle(id, iframe.contentDocument);
});
}
// to avoid page flicker when the style is updated
// instead of removing it immediately we rename its ID and queue it
// to be deleted in applyStyles after a new version is fetched and applied
function retireStyle(id, doc) {
var deadID = "ghost-" + id;
if (!doc) {
doc = document;
retiredStyleIds.push(deadID);
delete g_styleElements["stylus-" + id];
// in case something went wrong and new style was never applied
setTimeout(removeStyle.bind(null, deadID, doc), 1000);
}
var e = doc.getElementById("stylus-" + id);
if (e) {
e.id = "stylus-" + deadID;
}
getDynamicIFrames(doc).forEach(function(iframe) {
retireStyle(id, iframe.contentDocument);
});
}
function applyStyles(styleHash) {
if (!styleHash) { // Chrome is starting up
requestStyles();
return;
}
if ("disableAll" in styleHash) {
disableAll(styleHash.disableAll);
delete styleHash.disableAll;
}
for (var styleId in styleHash) {
applySections(styleId, styleHash[styleId]);
}
if (Object.keys(g_styleElements).length) {
// when site response is application/xml Chrome displays our style elements
// under document.documentElement as plain text so we need to move them into HEAD
// (which already is autogenerated at this moment for the xml response)
if (document.head && document.head.firstChild && document.head.firstChild.id == "xml-viewer-style") {
for (var id in g_styleElements) {
document.head.appendChild(document.getElementById(id));
}
}
document.addEventListener("DOMContentLoaded", onDOMContentLoaded);
}
if (retiredStyleIds.length) {
setTimeout(function() {
while (retiredStyleIds.length) {
removeStyle(retiredStyleIds.shift(), document);
}
}, 0);
}
}
function onDOMContentLoaded() {
addDocumentStylesToAllIFrames();
iframeObserver.start();
}
function applySections(styleId, sections) {
var styleElement = document.getElementById("stylus-" + styleId);
// Already there.
if (styleElement) {
return;
}
if (document.documentElement instanceof SVGSVGElement) {
// SVG document, make an SVG style element.
styleElement = document.createElementNS("http://www.w3.org/2000/svg", "style");
} else {
// This will make an HTML style element. If there's SVG embedded in an HTML document, this works on the SVG too.
styleElement = document.createElement("style");
}
styleElement.setAttribute("id", "stylus-" + styleId);
styleElement.setAttribute("class", "stylus");
styleElement.setAttribute("type", "text/css");
styleElement.appendChild(document.createTextNode(sections.map(function(section) {
return section.code;
}).join("\n")));
addStyleElement(styleElement, document);
g_styleElements[styleElement.id] = styleElement;
}
function addStyleElement(styleElement, doc) {
if (!doc.documentElement || doc.getElementById(styleElement.id)) {
return;
}
doc.documentElement.appendChild(doc.importNode(styleElement, true))
.disabled = g_disableAll;
getDynamicIFrames(doc).forEach(function(iframe) {
if (iframeIsLoadingSrcDoc(iframe)) {
addStyleToIFrameSrcDoc(iframe, styleElement);
} else {
addStyleElement(styleElement, iframe.contentDocument);
}
});
}
function addDocumentStylesToIFrame(iframe) {
var doc = iframe.contentDocument;
var srcDocIsLoading = iframeIsLoadingSrcDoc(iframe);
for (var id in g_styleElements) {
if (srcDocIsLoading) {
addStyleToIFrameSrcDoc(iframe, g_styleElements[id]);
} else {
addStyleElement(g_styleElements[id], doc);
}
}
}
function addDocumentStylesToAllIFrames() {
getDynamicIFrames(document).forEach(addDocumentStylesToIFrame);
}
// Only dynamic iframes get the parent document's styles. Other ones should get styles based on their own URLs.
function getDynamicIFrames(doc) {
return Array.prototype.filter.call(doc.getElementsByTagName('iframe'), iframeIsDynamic);
}
function iframeIsDynamic(f) {
var href;
try {
href = f.contentDocument.location.href;
} catch (ex) {
// Cross-origin, so it's not a dynamic iframe
return false;
}
return href == document.location.href || href.indexOf("about:") == 0;
}
function iframeIsLoadingSrcDoc(f) {
return f.srcdoc && f.contentDocument.all.length <= 3;
// 3 nodes or less in total (html, head, body) == new empty iframe about to be overwritten by its 'srcdoc'
}
function addStyleToIFrameSrcDoc(iframe, styleElement) {
if (g_disableAll) {
return;
}
iframe.srcdoc += styleElement.outerHTML;
// make sure the style is added in case srcdoc was malformed
setTimeout(addStyleElement.bind(null, styleElement, iframe.contentDocument), 100);
}
function replaceAll(newStyles, doc, pass2) {
var oldStyles = [].slice.call(doc.querySelectorAll("STYLE.stylus" + (pass2 ? "[id$='-ghost']" : "")));
if (!pass2) {
oldStyles.forEach(function(style) { style.id += "-ghost"; });
}
getDynamicIFrames(doc).forEach(function(iframe) {
replaceAll(newStyles, iframe.contentDocument, pass2);
});
if (doc == document && !pass2) {
g_styleElements = {};
applyStyles(newStyles);
replaceAll(newStyles, doc, true);
}
if (pass2) {
oldStyles.forEach(function(style) { style.remove(); });
}
}
// Observe dynamic IFRAMEs being added
function initObserver() {
var orphanCheckTimer;
iframeObserver = new MutationObserver(function(mutations) {
clearTimeout(orphanCheckTimer);
// MutationObserver runs as a microtask so the timer won't fire until all queued mutations are fired
orphanCheckTimer = setTimeout(orphanCheck, 0);
if (mutations.length > 1000) {
// use a much faster method for very complex pages with 100,000 mutations
// (observer usually receives 1k-10k mutations per call)
addDocumentStylesToAllIFrames();
return;
}
// move the check out of current execution context
// because some same-domain (!) iframes fail to load when their "contentDocument" is accessed (!)
// namely gmail's old chat iframe talkgadget.google.com
setTimeout(process.bind(null, mutations), 0);
});
function process(mutations) {
for (var m = 0, ml = mutations.length; m < ml; m++) {
var mutation = mutations[m];
if (mutation.type === "childList") {
for (var n = 0, nodes = mutation.addedNodes, nl = nodes.length; n < nl; n++) {
var node = nodes[n];
if (node.localName === "iframe" && iframeIsDynamic(node)) {
addDocumentStylesToIFrame(node);
}
}
}
}
}
iframeObserver.start = function() {
// will be ignored by browser if already observing
iframeObserver.observe(document, {childList: true, subtree: true});
}
function orphanCheck() {
orphanCheckTimer = 0;
var port = chrome.runtime.connect();
if (port) {
port.disconnect();
return;
}
// we're orphaned due to an extension update
// we can detach the mutation observer
iframeObserver.takeRecords();
iframeObserver.disconnect();
iframeObserver = null;
// we can detach event listeners
document.removeEventListener("DOMContentLoaded", onDOMContentLoaded);
// we can't detach chrome.runtime.onMessage because it's no longer connected internally
// we can destroy global functions in this context to free up memory
[
'addDocumentStylesToAllIFrames',
'addDocumentStylesToIFrame',
'addStyleElement',
'addStyleToIFrameSrcDoc',
'applyOnMessage',
'applySections',
'applyStyles',
'disableAll',
'getDynamicIFrames',
'iframeIsDynamic',
'iframeIsLoadingSrcDoc',
'initObserver',
'removeStyle',
'replaceAll',
'requestStyles',
'retireStyle'
].forEach(fn => window[fn] = null);
// we can destroy global variables
g_styleElements = iframeObserver = retiredStyleIds = null;
}
}