diff options
Diffstat (limited to 'devtools/server/actors/utils/stylesheets-manager.js')
-rw-r--r-- | devtools/server/actors/utils/stylesheets-manager.js | 1031 |
1 files changed, 1031 insertions, 0 deletions
diff --git a/devtools/server/actors/utils/stylesheets-manager.js b/devtools/server/actors/utils/stylesheets-manager.js new file mode 100644 index 0000000000..838e5be602 --- /dev/null +++ b/devtools/server/actors/utils/stylesheets-manager.js @@ -0,0 +1,1031 @@ +/* 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); + + 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<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; + }; + + const styleSheetRules = + InspectorUtils.getAllStyleSheetCSSStyleRules(styleSheet); + const ruleCount = styleSheetRules.length; + // We need to go through nested rules to extract all the rules we're interested in + 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<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://content-accessible/accessiblecaret.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, +}; |