/* 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/. */
// 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 }
);
/** * 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) { thrownew Error("Expect onAvailable, onUpdated or onDestroyed");
}
if (onAvailable) { if (typeof onAvailable !== "function") { thrownew 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") { thrownew Error("onUpdated should be a function");
} this.#watchListeners.onUpdated.push(onUpdated);
}
if (onDestroyed) { if (typeof onDestroyed !== "function") { thrownew 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));
}
// 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);
}
}
#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. returnthis.#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;
}
}
returnnull;
}
/** * 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);
// 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;
/** * 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);
}
/** * 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);
}
// 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;
/** * 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);
} elseif (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;
let ac = this.#mqlChangeAbortControllerMap.get(resourceId); if (!ac) {
ac = new associatedWin.AbortController(); this.#mqlChangeAbortControllerMap.set(resourceId, ac);
}
/** * 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) { returnnull;
}
if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) { return ownerNode.location.href;
}
if (ownerNode.ownerDocument?.location) { return ownerNode.ownerDocument.location.href;
}
returnnull;
}
/** * 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) { returntrue;
}
styleSheet = styleSheet.parentStyleSheet;
} returnfalse;
}
/** * 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 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,
});
}
}
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://content-accessible/accessiblecaret.css" ||
(href === "resource://devtools-highlighter-styles/highlighters.css" && this.#targetActor.sessionContext.type !== "all")
) { returnfalse;
} returntrue;
}
/** * 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
);
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.