/* 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/stylesheets/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. * * @fires 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 if (this.#targetActor.chromeEventHandler) { 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 {Element} parent * The element into which we'll append the