1050 lines
33 KiB
JavaScript
1050 lines
33 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
|
|
const {
|
|
getSourcemapBaseURL,
|
|
} = require("resource://devtools/server/actors/utils/source-map-utils.js");
|
|
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
["addPseudoClassLock", "removePseudoClassLock"],
|
|
"resource://devtools/server/actors/highlighters/utils/markup.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"loadSheet",
|
|
"resource://devtools/shared/layout/utils.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
["getStyleSheetOwnerNode", "getStyleSheetText"],
|
|
"resource://devtools/server/actors/utils/stylesheet-utils.js",
|
|
true
|
|
);
|
|
|
|
const TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning";
|
|
const TRANSITION_DURATION_MS = 500;
|
|
const TRANSITION_BUFFER_MS = 1000;
|
|
const TRANSITION_RULE_SELECTOR = `:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *:not(:-moz-native-anonymous)`;
|
|
const TRANSITION_SHEET =
|
|
"data:text/css;charset=utf-8," +
|
|
encodeURIComponent(`
|
|
${TRANSITION_RULE_SELECTOR} {
|
|
transition-duration: ${TRANSITION_DURATION_MS}ms !important;
|
|
transition-delay: 0ms !important;
|
|
transition-timing-function: ease-out !important;
|
|
transition-property: all !important;
|
|
}
|
|
`);
|
|
|
|
// The possible kinds of style-applied events.
|
|
// UPDATE_PRESERVING_RULES means that the update is guaranteed to
|
|
// preserve the number and order of rules on the style sheet.
|
|
// UPDATE_GENERAL covers any other kind of change to the style sheet.
|
|
const UPDATE_PRESERVING_RULES = 0;
|
|
const UPDATE_GENERAL = 1;
|
|
|
|
// If the user edits a stylesheet, we stash a copy of the edited text
|
|
// here, keyed by the stylesheet. This way, if the tools are closed
|
|
// and then reopened, the edited text will be available. A weak map
|
|
// is used so that navigation by the user will eventually cause the
|
|
// edited text to be collected.
|
|
const modifiedStyleSheets = new WeakMap();
|
|
|
|
/**
|
|
* Manage stylesheets related to a given Target Actor.
|
|
* @emits stylesheet-updated: emitted when there was changes in a stylesheet
|
|
* First arg is an object with the following properties:
|
|
* - resourceId {String}: The id that was assigned to the stylesheet
|
|
* - updateKind {String}: Which kind of update it is ("style-applied",
|
|
* "at-rules-changed", "matches-change", "property-change")
|
|
* - updates {Object}: The update data
|
|
*/
|
|
class StyleSheetsManager extends EventEmitter {
|
|
#abortController;
|
|
// Map<resourceId, AbortController>
|
|
#mqlChangeAbortControllerMap = new Map();
|
|
#styleSheetCount = 0;
|
|
#styleSheetMap = new Map();
|
|
#styleSheetCreationData;
|
|
#targetActor;
|
|
#transitionSheetLoaded;
|
|
#transitionTimeout;
|
|
#watchListeners = {
|
|
onAvailable: [],
|
|
onUpdated: [],
|
|
onDestroyed: [],
|
|
};
|
|
|
|
/**
|
|
* @param TargetActor targetActor
|
|
* The target actor from which we should observe stylesheet changes.
|
|
*/
|
|
constructor(targetActor) {
|
|
super();
|
|
|
|
this.#targetActor = targetActor;
|
|
}
|
|
|
|
#setEventListenersIfNeeded() {
|
|
if (this.#abortController) {
|
|
return;
|
|
}
|
|
|
|
this.#abortController = new AbortController();
|
|
const { signal } = this.#abortController;
|
|
|
|
// Listen for new stylesheet being added via StyleSheetApplicableStateChanged
|
|
this.#targetActor.chromeEventHandler.addEventListener(
|
|
"StyleSheetApplicableStateChanged",
|
|
this.#onApplicableStateChanged,
|
|
{ capture: true, signal }
|
|
);
|
|
this.#targetActor.chromeEventHandler.addEventListener(
|
|
"StyleSheetRemoved",
|
|
this.#onStylesheetRemoved,
|
|
{ capture: true, signal }
|
|
);
|
|
|
|
this.#watchStyleSheetChangeEvents();
|
|
this.#targetActor.on("window-ready", this.#onTargetActorWindowReady, {
|
|
signal,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calling this function will make the StyleSheetsManager start the event listeners needed
|
|
* to watch for stylesheet additions and modifications.
|
|
* This resolves once it notified about existing stylesheets.
|
|
* @param {Object} options
|
|
* @param {Function} onAvailable: Function that will be called when a stylesheet is
|
|
* registered, but also with already registered stylesheets
|
|
* if ignoreExisting is not set to true.
|
|
* This is called with a single object parameter with the following properties:
|
|
* - {String} resourceId: The id that was assigned to the stylesheet
|
|
* - {StyleSheet} styleSheet: The actual stylesheet object
|
|
* - {Object} creationData: An object with:
|
|
* - {Boolean} isCreatedByDevTools: Was the stylesheet created
|
|
* by DevTools (e.g. by the user clicking the new stylesheet
|
|
* button in the styleeditor)
|
|
* - {String} fileName
|
|
* @param {Function} onUpdated: Function that will be called when a stylesheet is updated
|
|
* This is called with a single object parameter with the following properties:
|
|
* - {String} resourceId: The id that was assigned to the stylesheet
|
|
* - {String} updateKind: Which kind of update it is ("style-applied",
|
|
* "at-rules-changed", "matches-change", "property-change")
|
|
* - {Object} updates : The update data
|
|
* @param {Function} onDestroyed: Function that will be called when a stylesheet is removed
|
|
* This is called with a single object parameter with the following properties:
|
|
* - {String} resourceId: The id that was assigned to the stylesheet
|
|
* @param {Boolean} ignoreExisting: Pass to true to avoid onAvailable to be called with
|
|
* already registered stylesheets.
|
|
*/
|
|
async watch({ onAvailable, onUpdated, onDestroyed, ignoreExisting = false }) {
|
|
if (!onAvailable && !onUpdated && !onDestroyed) {
|
|
throw new Error("Expect onAvailable, onUpdated or onDestroyed");
|
|
}
|
|
|
|
if (onAvailable) {
|
|
if (typeof onAvailable !== "function") {
|
|
throw new Error("onAvailable should be a function");
|
|
}
|
|
|
|
// Don't register the listener yet if we're ignoring existing stylesheets, we'll do
|
|
// that at the end of the function, after we processed existing stylesheets.
|
|
}
|
|
|
|
if (onUpdated) {
|
|
if (typeof onUpdated !== "function") {
|
|
throw new Error("onUpdated should be a function");
|
|
}
|
|
this.#watchListeners.onUpdated.push(onUpdated);
|
|
}
|
|
|
|
if (onDestroyed) {
|
|
if (typeof onDestroyed !== "function") {
|
|
throw new Error("onDestroyed should be a function");
|
|
}
|
|
this.#watchListeners.onDestroyed.push(onDestroyed);
|
|
}
|
|
|
|
// Process existing stylesheets
|
|
const promises = [];
|
|
for (const window of this.#targetActor.windows) {
|
|
promises.push(this.#getStyleSheetsForWindow(window));
|
|
}
|
|
|
|
this.#setEventListenersIfNeeded();
|
|
|
|
// Finally, notify about existing stylesheets
|
|
const styleSheets = await Promise.all(promises);
|
|
const styleSheetsData = styleSheets.flat().map(styleSheet => ({
|
|
styleSheet,
|
|
resourceId: this.#registerStyleSheet(styleSheet),
|
|
}));
|
|
|
|
let registeredStyleSheetsPromises;
|
|
if (onAvailable && ignoreExisting !== true) {
|
|
registeredStyleSheetsPromises = styleSheetsData.map(
|
|
({ resourceId, styleSheet }) => onAvailable({ resourceId, styleSheet })
|
|
);
|
|
}
|
|
|
|
// Only register the listener after we went over the list of existing stylesheets
|
|
// so the listener is not triggered by possible calls to #registerStyleSheet earlier.
|
|
if (onAvailable) {
|
|
this.#watchListeners.onAvailable.push(onAvailable);
|
|
}
|
|
|
|
if (registeredStyleSheetsPromises) {
|
|
await Promise.all(registeredStyleSheetsPromises);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the passed listeners
|
|
*
|
|
* @param {Object} options: See this.watch
|
|
*/
|
|
unwatch({ onAvailable, onUpdated, onDestroyed }) {
|
|
if (!this.#watchListeners) {
|
|
return;
|
|
}
|
|
|
|
if (onAvailable) {
|
|
const index = this.#watchListeners.onAvailable.indexOf(onAvailable);
|
|
if (index !== -1) {
|
|
this.#watchListeners.onAvailable.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
if (onUpdated) {
|
|
const index = this.#watchListeners.onUpdated.indexOf(onUpdated);
|
|
if (index !== -1) {
|
|
this.#watchListeners.onUpdated.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
if (onDestroyed) {
|
|
const index = this.#watchListeners.onDestroyed.indexOf(onDestroyed);
|
|
if (index !== -1) {
|
|
this.#watchListeners.onDestroyed.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
#watchStyleSheetChangeEvents() {
|
|
for (const window of this.#targetActor.windows) {
|
|
this.#watchStyleSheetChangeEventsForWindow(window);
|
|
}
|
|
}
|
|
|
|
#onTargetActorWindowReady = ({ window }) => {
|
|
this.#watchStyleSheetChangeEventsForWindow(window);
|
|
};
|
|
|
|
#watchStyleSheetChangeEventsForWindow(window) {
|
|
// We have to set this flag in order to get the
|
|
// StyleSheetApplicableStateChanged and StyleSheetRemoved events. See Document.webidl.
|
|
window.document.styleSheetChangeEventsEnabled = true;
|
|
}
|
|
|
|
#unwatchStyleSheetChangeEvents() {
|
|
for (const window of this.#targetActor.windows) {
|
|
window.document.styleSheetChangeEventsEnabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new style sheet in the document with the given text.
|
|
*
|
|
* @param {Document} document
|
|
* Document that the new style sheet belong to.
|
|
* @param {string} text
|
|
* Content of style sheet.
|
|
* @param {string} fileName
|
|
* If the stylesheet adding is from file, `fileName` indicates the path.
|
|
*/
|
|
async addStyleSheet(document, text, fileName) {
|
|
const parent = document.documentElement;
|
|
const style = document.createElementNS(
|
|
"http://www.w3.org/1999/xhtml",
|
|
"style"
|
|
);
|
|
style.setAttribute("type", "text/css");
|
|
style.setDevtoolsAsTriggeringPrincipal();
|
|
|
|
if (text) {
|
|
style.appendChild(document.createTextNode(text));
|
|
}
|
|
|
|
// This triggers StyleSheetApplicableStateChanged event.
|
|
parent.appendChild(style);
|
|
|
|
// This promise will be resolved when the resource for this stylesheet is available.
|
|
let resolve = null;
|
|
const promise = new Promise(r => {
|
|
resolve = r;
|
|
});
|
|
|
|
if (!this.#styleSheetCreationData) {
|
|
this.#styleSheetCreationData = new WeakMap();
|
|
}
|
|
this.#styleSheetCreationData.set(style.sheet, {
|
|
isCreatedByDevTools: true,
|
|
fileName,
|
|
resolve,
|
|
});
|
|
|
|
await promise;
|
|
|
|
return style.sheet;
|
|
}
|
|
|
|
/**
|
|
* Return resourceId of the given style sheet or create one if the stylesheet wasn't
|
|
* registered yet.
|
|
*
|
|
* @params {StyleSheet} styleSheet
|
|
* @returns {String} resourceId
|
|
*/
|
|
getStyleSheetResourceId(styleSheet) {
|
|
const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
|
|
if (existingResourceId) {
|
|
return existingResourceId;
|
|
}
|
|
|
|
// If we couldn't find an associated resourceId, that means the stylesheet isn't
|
|
// registered yet. Calling #registerStyleSheet will register it and return the
|
|
// associated resourceId it computed for it.
|
|
return this.#registerStyleSheet(styleSheet);
|
|
}
|
|
|
|
/**
|
|
* Return the associated resourceId of the given registered style sheet, or null if the
|
|
* stylesheet wasn't registered yet.
|
|
*
|
|
* @params {StyleSheet} styleSheet
|
|
* @returns {String} resourceId
|
|
*/
|
|
#findStyleSheetResourceId(styleSheet) {
|
|
for (const [
|
|
resourceId,
|
|
existingStyleSheet,
|
|
] of this.#styleSheetMap.entries()) {
|
|
if (styleSheet === existingStyleSheet) {
|
|
return resourceId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Return owner node of the style sheet of the given resource id.
|
|
*
|
|
* @params {String} resourceId
|
|
* The id associated with the stylesheet
|
|
* @returns {Element|null}
|
|
*/
|
|
getOwnerNode(resourceId) {
|
|
const styleSheet = this.#styleSheetMap.get(resourceId);
|
|
return styleSheet.ownerNode;
|
|
}
|
|
|
|
/**
|
|
* Return the index of given stylesheet of the given resource id.
|
|
*
|
|
* @params {String} resourceId
|
|
* The id associated with the stylesheet
|
|
* @returns {Number}
|
|
*/
|
|
getStyleSheetIndex(resourceId) {
|
|
const styleSheet = this.#styleSheetMap.get(resourceId);
|
|
|
|
const styleSheets = InspectorUtils.getAllStyleSheets(
|
|
this.#targetActor.window.document,
|
|
true
|
|
);
|
|
let i = 0;
|
|
for (const sheet of styleSheets) {
|
|
if (!this.#shouldListSheet(sheet)) {
|
|
continue;
|
|
}
|
|
if (sheet == styleSheet) {
|
|
return i;
|
|
}
|
|
i++;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Get the text of a stylesheet given its resourceId.
|
|
*
|
|
* @params {String} resourceId
|
|
* The id associated with the stylesheet
|
|
* @returns {String}
|
|
*/
|
|
async getText(resourceId) {
|
|
const styleSheet = this.#styleSheetMap.get(resourceId);
|
|
|
|
const modifiedText = modifiedStyleSheets.get(styleSheet);
|
|
|
|
// modifiedText is the content of the stylesheet updated by update function.
|
|
// In case not updating, this is undefined.
|
|
if (modifiedText !== undefined) {
|
|
return modifiedText;
|
|
}
|
|
|
|
return getStyleSheetText(styleSheet);
|
|
}
|
|
|
|
/**
|
|
* Toggle the disabled property of the stylesheet
|
|
*
|
|
* @params {String} resourceId
|
|
* The id associated with the stylesheet
|
|
* @return {Boolean} the disabled state after toggling.
|
|
*/
|
|
toggleDisabled(resourceId) {
|
|
const styleSheet = this.#styleSheetMap.get(resourceId);
|
|
styleSheet.disabled = !styleSheet.disabled;
|
|
|
|
this.#notifyPropertyChanged(resourceId, "disabled", styleSheet.disabled);
|
|
|
|
return styleSheet.disabled;
|
|
}
|
|
|
|
/**
|
|
* Update the style sheet in place with new text.
|
|
*
|
|
* @param {String} resourceId
|
|
* @param {String} text
|
|
* New text.
|
|
* @param {Object} options
|
|
* @param {Boolean} options.transition
|
|
* Whether to do CSS transition for change. Defaults to false.
|
|
* @param {Number} options.kind
|
|
* Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL. Defaults to UPDATE_GENERAL.
|
|
* @param {String} options.cause
|
|
* Indicates the cause of this update (e.g. "styleeditor") if this was called
|
|
* from the stylesheet to be edited by the user from the StyleEditor.
|
|
*/
|
|
async setStyleSheetText(
|
|
resourceId,
|
|
text,
|
|
{ transition = false, kind = UPDATE_GENERAL, cause = "" } = {}
|
|
) {
|
|
const styleSheet = this.#styleSheetMap.get(resourceId);
|
|
InspectorUtils.parseStyleSheet(styleSheet, text);
|
|
modifiedStyleSheets.set(styleSheet, text);
|
|
|
|
// getStyleSheetRuleCountAndAtRules can be costly, so only call it when needed,
|
|
// i.e. when the whole stylesheet is modified, not when a rule body is.
|
|
let atRules, ruleCount;
|
|
if (kind !== UPDATE_PRESERVING_RULES) {
|
|
({ atRules, ruleCount } =
|
|
this.getStyleSheetRuleCountAndAtRules(styleSheet));
|
|
this.#notifyPropertyChanged(resourceId, "ruleCount", ruleCount);
|
|
}
|
|
|
|
if (transition) {
|
|
this.#startTransition(resourceId, kind, cause);
|
|
} else {
|
|
this.#onStyleSheetUpdated({
|
|
resourceId,
|
|
updateKind: "style-applied",
|
|
updates: {
|
|
event: { kind, cause },
|
|
},
|
|
});
|
|
}
|
|
|
|
if (kind !== UPDATE_PRESERVING_RULES) {
|
|
this.#onStyleSheetUpdated({
|
|
resourceId,
|
|
updateKind: "at-rules-changed",
|
|
updates: {
|
|
resourceUpdates: { atRules },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies a transition to the stylesheet document so any change made by the user in the
|
|
* client will be animated so it's more visible.
|
|
*
|
|
* @param {String} resourceId
|
|
* The id associated with the stylesheet
|
|
* @param {Number} kind
|
|
* Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
|
|
* @param {String} cause
|
|
* Indicates the cause of this update (e.g. "styleeditor") if this was called
|
|
* from the stylesheet to be edited by the user from the StyleEditor.
|
|
*/
|
|
#startTransition(resourceId, kind, cause) {
|
|
const styleSheet = this.#styleSheetMap.get(resourceId);
|
|
const document = styleSheet.associatedDocument;
|
|
const window = document.ownerGlobal;
|
|
|
|
if (!this.#transitionSheetLoaded) {
|
|
this.#transitionSheetLoaded = true;
|
|
// We don't remove this sheet. It uses an internal selector that
|
|
// we only apply via locks, so there's no need to load and unload
|
|
// it all the time.
|
|
loadSheet(window, TRANSITION_SHEET);
|
|
}
|
|
|
|
addPseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
|
|
|
|
// Set up clean up and commit after transition duration (+buffer)
|
|
// @see #onTransitionEnd
|
|
window.clearTimeout(this.#transitionTimeout);
|
|
this.#transitionTimeout = window.setTimeout(
|
|
this.#onTransitionEnd.bind(this, resourceId, kind, cause),
|
|
TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {String} resourceId
|
|
* The id associated with the stylesheet
|
|
* @param {Number} kind
|
|
* Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
|
|
* @param {String} cause
|
|
* Indicates the cause of this update (e.g. "styleeditor") if this was called
|
|
* from the stylesheet to be edited by the user from the StyleEditor.
|
|
*/
|
|
#onTransitionEnd(resourceId, kind, cause) {
|
|
const styleSheet = this.#styleSheetMap.get(resourceId);
|
|
const document = styleSheet.associatedDocument;
|
|
|
|
this.#transitionTimeout = null;
|
|
removePseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
|
|
|
|
this.#onStyleSheetUpdated({
|
|
resourceId,
|
|
updateKind: "style-applied",
|
|
updates: {
|
|
event: { kind, cause },
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retrieve the CSSRuleList of a given stylesheet
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
* @returns {CSSRuleList}
|
|
*/
|
|
#getCSSRules(styleSheet) {
|
|
try {
|
|
return styleSheet.cssRules;
|
|
} catch (e) {
|
|
// sheet isn't loaded yet
|
|
}
|
|
|
|
if (!styleSheet.ownerNode) {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
styleSheet.ownerNode.addEventListener(
|
|
"load",
|
|
() => resolve(styleSheet.cssRules),
|
|
{ once: true }
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the stylesheets imported by a given stylesheet (via @import)
|
|
*
|
|
* @param {Document} document
|
|
* @param {StyleSheet} styleSheet
|
|
* @returns Array<StyleSheet>
|
|
*/
|
|
async #getImportedStyleSheets(document, styleSheet) {
|
|
const importedStyleSheets = [];
|
|
|
|
for (const rule of await this.#getCSSRules(styleSheet)) {
|
|
const ruleClassName = ChromeUtils.getClassName(rule);
|
|
if (ruleClassName == "CSSImportRule") {
|
|
// With the Gecko style system, the associated styleSheet may be null
|
|
// if it has already been seen because an import cycle for the same
|
|
// URL. With Stylo, the styleSheet will exist (which is correct per
|
|
// the latest CSSOM spec), so we also need to check ancestors for the
|
|
// same URL to avoid cycles.
|
|
if (
|
|
!rule.styleSheet ||
|
|
this.#haveAncestorWithSameURL(rule.styleSheet) ||
|
|
!this.#shouldListSheet(rule.styleSheet)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
importedStyleSheets.push(rule.styleSheet);
|
|
|
|
// recurse imports in this stylesheet as well
|
|
const children = await this.#getImportedStyleSheets(
|
|
document,
|
|
rule.styleSheet
|
|
);
|
|
importedStyleSheets.push(...children);
|
|
} else if (ruleClassName != "CSSCharsetRule") {
|
|
// @import rules must precede all others except @charset
|
|
break;
|
|
}
|
|
}
|
|
|
|
return importedStyleSheets;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the total number of rules (including nested ones) and
|
|
* all the at-rules of a given stylesheet.
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
* @returns {Object} An object of the following shape:
|
|
* - {Integer} ruleCount: The total number of rules in the stylesheet
|
|
* - {Array<Object>} atRules: An array of object of the following shape:
|
|
* - type {String}
|
|
* - conditionText {String}
|
|
* - matches {Boolean}: true if the media rule matches the current state of the document
|
|
* - layerName {String}
|
|
* - line {Number}
|
|
* - column {Number}
|
|
*/
|
|
getStyleSheetRuleCountAndAtRules(styleSheet) {
|
|
const resourceId = this.#findStyleSheetResourceId(styleSheet);
|
|
if (!resourceId) {
|
|
return [];
|
|
}
|
|
|
|
if (this.#mqlChangeAbortControllerMap.has(resourceId)) {
|
|
this.#mqlChangeAbortControllerMap.get(resourceId).abort();
|
|
this.#mqlChangeAbortControllerMap.delete(resourceId);
|
|
}
|
|
|
|
// Accessing the stylesheet associated window might be slow due to cross compartment
|
|
// wrappers, so only retrieve it if it's needed.
|
|
let win;
|
|
const getStyleSheetAssociatedWindow = () => {
|
|
if (!win) {
|
|
win = styleSheet.associatedDocument?.ownerGlobal;
|
|
}
|
|
return win;
|
|
};
|
|
|
|
// This returns the following type of at-rules:
|
|
// - CSSMediaRule
|
|
// - CSSContainerRule
|
|
// - CSSSupportsRule
|
|
// - CSSLayerBlockRule
|
|
// New types can be added from InpsectorUtils.cpp `CollectAtRules`
|
|
const { atRules: styleSheetRules, ruleCount } =
|
|
InspectorUtils.getStyleSheetRuleCountAndAtRules(styleSheet);
|
|
const atRules = [];
|
|
for (const rule of styleSheetRules) {
|
|
const className = ChromeUtils.getClassName(rule);
|
|
if (className === "CSSMediaRule") {
|
|
let matches = false;
|
|
|
|
try {
|
|
const associatedWin = getStyleSheetAssociatedWindow();
|
|
const mql = associatedWin.matchMedia(rule.media.mediaText);
|
|
matches = mql.matches;
|
|
|
|
let ac = this.#mqlChangeAbortControllerMap.get(resourceId);
|
|
if (!ac) {
|
|
ac = new associatedWin.AbortController();
|
|
this.#mqlChangeAbortControllerMap.set(resourceId, ac);
|
|
}
|
|
|
|
const index = atRules.length;
|
|
mql.addEventListener(
|
|
"change",
|
|
() => this.#onMatchesChange(resourceId, index, mql),
|
|
{
|
|
signal: ac.signal,
|
|
}
|
|
);
|
|
} catch (e) {
|
|
// Ignored
|
|
}
|
|
|
|
atRules.push({
|
|
type: "media",
|
|
conditionText: rule.conditionText,
|
|
matches,
|
|
line: InspectorUtils.getRelativeRuleLine(rule),
|
|
column: InspectorUtils.getRuleColumn(rule),
|
|
});
|
|
} else if (className === "CSSContainerRule") {
|
|
atRules.push({
|
|
type: "container",
|
|
conditionText: rule.conditionText,
|
|
line: InspectorUtils.getRelativeRuleLine(rule),
|
|
column: InspectorUtils.getRuleColumn(rule),
|
|
});
|
|
} else if (className === "CSSSupportsRule") {
|
|
atRules.push({
|
|
type: "support",
|
|
conditionText: rule.conditionText,
|
|
line: InspectorUtils.getRelativeRuleLine(rule),
|
|
column: InspectorUtils.getRuleColumn(rule),
|
|
});
|
|
} else if (className === "CSSLayerBlockRule") {
|
|
atRules.push({
|
|
type: "layer",
|
|
layerName: rule.name,
|
|
line: InspectorUtils.getRelativeRuleLine(rule),
|
|
column: InspectorUtils.getRuleColumn(rule),
|
|
});
|
|
} else if (className === "CSSPropertyRule") {
|
|
atRules.push({
|
|
type: "property",
|
|
propertyName: rule.name,
|
|
line: InspectorUtils.getRelativeRuleLine(rule),
|
|
column: InspectorUtils.getRuleColumn(rule),
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
ruleCount,
|
|
atRules,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Called when the status of a media query support changes (i.e. it now matches, or it
|
|
* was matching but isn't anymore)
|
|
*
|
|
* @param {String} resourceId
|
|
* The id associated with the stylesheet
|
|
* @param {Number} index
|
|
* The index of the media rule relatively to all the other at-rules of the stylesheet
|
|
* @param {MediaQueryList} mql
|
|
* The result of matchMedia for the given media rule
|
|
*/
|
|
#onMatchesChange(resourceId, index, mql) {
|
|
this.#onStyleSheetUpdated({
|
|
resourceId,
|
|
updateKind: "matches-change",
|
|
updates: {
|
|
nestedResourceUpdates: [
|
|
{
|
|
path: ["atRules", index, "matches"],
|
|
value: mql.matches,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the node href of a given stylesheet
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
* @returns {String}
|
|
*/
|
|
getNodeHref(styleSheet) {
|
|
const { ownerNode } = styleSheet;
|
|
if (!ownerNode) {
|
|
return null;
|
|
}
|
|
|
|
if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) {
|
|
return ownerNode.location.href;
|
|
}
|
|
|
|
if (ownerNode.ownerDocument?.location) {
|
|
return ownerNode.ownerDocument.location.href;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the sourcemap base url of a given stylesheet
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
* @returns {String}
|
|
*/
|
|
getSourcemapBaseURL(styleSheet) {
|
|
// When the style is injected via nsIDOMWindowUtils.loadSheet, even
|
|
// the parent style sheet has no owner, so default back to target actor
|
|
// document
|
|
const ownerNode = getStyleSheetOwnerNode(styleSheet);
|
|
const ownerDocument = ownerNode
|
|
? ownerNode.ownerDocument
|
|
: this.#targetActor.window;
|
|
|
|
return getSourcemapBaseURL(
|
|
// Technically resolveSourceURL should be used here alongside
|
|
// "this.rawSheet.sourceURL", but the style inspector does not support
|
|
// /*# sourceURL=*/ in CSS, so we're omitting it here (bug 880831).
|
|
styleSheet.href || this.getNodeHref(styleSheet),
|
|
ownerDocument
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get all the stylesheets for a given window
|
|
*
|
|
* @param {Window} window
|
|
* @returns {Array<StyleSheet>}
|
|
*/
|
|
async #getStyleSheetsForWindow(window) {
|
|
const { document } = window;
|
|
const documentOnly = !document.nodePrincipal.isSystemPrincipal;
|
|
|
|
const styleSheets = [];
|
|
|
|
for (const styleSheet of InspectorUtils.getAllStyleSheets(
|
|
document,
|
|
documentOnly
|
|
)) {
|
|
if (!this.#shouldListSheet(styleSheet)) {
|
|
continue;
|
|
}
|
|
|
|
styleSheets.push(styleSheet);
|
|
|
|
// Get all sheets, including imported ones
|
|
const importedStyleSheets = await this.#getImportedStyleSheets(
|
|
document,
|
|
styleSheet
|
|
);
|
|
styleSheets.push(...importedStyleSheets);
|
|
}
|
|
|
|
return styleSheets;
|
|
}
|
|
|
|
/**
|
|
* Returns true if a given stylesheet has an ancestor with the same url it has
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
* @returns {Boolean}
|
|
*/
|
|
#haveAncestorWithSameURL(styleSheet) {
|
|
const href = styleSheet.href;
|
|
while (styleSheet.parentStyleSheet) {
|
|
if (styleSheet.parentStyleSheet.href == href) {
|
|
return true;
|
|
}
|
|
styleSheet = styleSheet.parentStyleSheet;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Helper function called when a property changed in a given stylesheet
|
|
*
|
|
* @param {String} resourceId
|
|
* The id of the stylesheet the change occured in
|
|
* @param {String} property
|
|
* The property that was changed
|
|
* @param {String} value
|
|
* The value of the property
|
|
*/
|
|
#notifyPropertyChanged(resourceId, property, value) {
|
|
this.#onStyleSheetUpdated({
|
|
resourceId,
|
|
updateKind: "property-change",
|
|
updates: { resourceUpdates: { [property]: value } },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Event handler that is called when the state of applicable of style sheet is changed.
|
|
*
|
|
* For now, StyleSheetApplicableStateChanged event will be called at following timings.
|
|
* - Append <link> of stylesheet to document
|
|
* - Append <style> to document
|
|
* - Change disable attribute of stylesheet object
|
|
* - Change disable attribute of <link> to false
|
|
* - Stylesheet is constructed.
|
|
* When appending <link>, <style> or changing `disabled` attribute to false,
|
|
* `applicable` is passed as true. The other hand, when changing `disabled`
|
|
* to true, this will be false.
|
|
*
|
|
* NOTE: StyleSheetApplicableStateChanged is _not_ called when removing the <link>/<style>,
|
|
* but a StyleSheetRemovedEvent is emitted in such case (see #onStyleSheetRemoved)
|
|
*
|
|
* @param {StyleSheetApplicableStateChangedEvent}
|
|
* The triggering event.
|
|
*/
|
|
#onApplicableStateChanged = ({ applicable, stylesheet: styleSheet }) => {
|
|
if (
|
|
// Have interest in applicable stylesheet only.
|
|
applicable &&
|
|
styleSheet.associatedDocument &&
|
|
(!this.#targetActor.ignoreSubFrames ||
|
|
styleSheet.associatedDocument.ownerGlobal ===
|
|
this.#targetActor.window) &&
|
|
this.#shouldListSheet(styleSheet) &&
|
|
!this.#haveAncestorWithSameURL(styleSheet)
|
|
) {
|
|
this.#registerStyleSheet(styleSheet);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Event handler that is called when a style sheet is removed.
|
|
*
|
|
* @param {StyleSheetRemovedEvent}
|
|
* The triggering event.
|
|
*/
|
|
#onStylesheetRemoved = event => {
|
|
this.#unregisterStyleSheet(event.stylesheet);
|
|
};
|
|
|
|
/**
|
|
* If the stylesheet isn't registered yet, this function will generate an associated
|
|
* resourceId and call registered `onAvailable` listeners.
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
* @returns {String} the associated resourceId
|
|
*/
|
|
#registerStyleSheet(styleSheet) {
|
|
const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
|
|
// If the stylesheet is already registered, there's no need to notify about it again.
|
|
if (existingResourceId) {
|
|
return existingResourceId;
|
|
}
|
|
|
|
// It's important to prefix the resourceId with the target actorID so we can't have
|
|
// duplicated resource ids when the client connects to multiple targets.
|
|
const resourceId = `${this.#targetActor.actorID}:stylesheet:${this
|
|
.#styleSheetCount++}`;
|
|
this.#styleSheetMap.set(resourceId, styleSheet);
|
|
|
|
const creationData = this.#styleSheetCreationData?.get(styleSheet);
|
|
this.#styleSheetCreationData?.delete(styleSheet);
|
|
|
|
const onAvailablePromises = [];
|
|
for (const onAvailable of this.#watchListeners.onAvailable) {
|
|
onAvailablePromises.push(
|
|
onAvailable({
|
|
resourceId,
|
|
styleSheet,
|
|
creationData,
|
|
})
|
|
);
|
|
}
|
|
|
|
// creationData exists if this stylesheet was created via `addStyleSheet`.
|
|
if (creationData) {
|
|
// We resolve the promise once the watcher sent the resources to the client,
|
|
// so `addStyleSheet` calls can be fullfilled.
|
|
Promise.all(onAvailablePromises).then(() => creationData?.resolve());
|
|
}
|
|
return resourceId;
|
|
}
|
|
|
|
/**
|
|
* If the stylesheet is registered, this function will call registered `onDestroyed`
|
|
* listeners with the stylesheet resourceId.
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
*/
|
|
#unregisterStyleSheet(styleSheet) {
|
|
const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
|
|
if (!existingResourceId) {
|
|
return;
|
|
}
|
|
|
|
this.#styleSheetMap.delete(existingResourceId);
|
|
this.#styleSheetCreationData?.delete(styleSheet);
|
|
if (this.#mqlChangeAbortControllerMap.has(existingResourceId)) {
|
|
this.#mqlChangeAbortControllerMap.get(existingResourceId).abort();
|
|
this.#mqlChangeAbortControllerMap.delete(existingResourceId);
|
|
}
|
|
|
|
for (const onDestroyed of this.#watchListeners.onDestroyed) {
|
|
onDestroyed({
|
|
resourceId: existingResourceId,
|
|
});
|
|
}
|
|
}
|
|
|
|
#onStyleSheetUpdated(data) {
|
|
this.emit("stylesheet-updated", data);
|
|
|
|
for (const onUpdated of this.#watchListeners.onUpdated) {
|
|
onUpdated(data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the passed styleSheet should be handled.
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
* @returns {Boolean}
|
|
*/
|
|
#shouldListSheet(styleSheet) {
|
|
const href = styleSheet.href?.toLowerCase();
|
|
// FIXME(bug 1826538): Make accessiblecaret.css and similar UA-widget
|
|
// sheets system sheets, then remove this special-case.
|
|
if (
|
|
href === "resource://gre-resources/accessiblecaret.css" ||
|
|
href === "resource://gre-resources/details.css" ||
|
|
(href === "resource://devtools-highlighter-styles/highlighters.css" &&
|
|
this.#targetActor.sessionContext.type !== "all")
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* The StyleSheetsManager instance is managed by the target, so this will be called when
|
|
* the target gets destroyed.
|
|
*/
|
|
destroy() {
|
|
// Cleanup
|
|
if (this.#abortController) {
|
|
this.#abortController.abort();
|
|
}
|
|
if (this.#mqlChangeAbortControllerMap) {
|
|
for (const ac of this.#mqlChangeAbortControllerMap.values()) {
|
|
ac.abort();
|
|
}
|
|
}
|
|
|
|
try {
|
|
this.#unwatchStyleSheetChangeEvents();
|
|
} catch (e) {
|
|
console.error(
|
|
"Error when destroying StyleSheet manager for",
|
|
this.#targetActor,
|
|
": ",
|
|
e
|
|
);
|
|
}
|
|
|
|
this.#styleSheetMap.clear();
|
|
this.#abortController = null;
|
|
this.#mqlChangeAbortControllerMap = null;
|
|
this.#styleSheetCreationData = null;
|
|
this.#styleSheetMap = null;
|
|
this.#targetActor = null;
|
|
this.#watchListeners = null;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
StyleSheetsManager,
|
|
UPDATE_GENERAL,
|
|
UPDATE_PRESERVING_RULES,
|
|
};
|