/* 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 #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); const { atRules, ruleCount } = this.getStyleSheetRuleCountAndAtRules(styleSheet); if (kind !== UPDATE_PRESERVING_RULES) { this.#notifyPropertyChanged(resourceId, "ruleCount", ruleCount); } if (transition) { this.#startTransition(resourceId, kind, cause); } else { this.#onStyleSheetUpdated({ resourceId, updateKind: "style-applied", updates: { event: { kind, cause }, }, }); } 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 */ 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} 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), }); } } 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} */ 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 of stylesheet to document * - Append